From e85804b938d44e911a24083c8a799c313e8bb445 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 30 Aug 2026 12:15:51 +0700 Subject: [PATCH 01/19] feat(registry-server): add configurable registry runtime Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 5 + .github/scripts/test_ci_changes.py | 87 + .github/workflows/ci.yml | 56 + Cargo.lock | 770 +++- Cargo.toml | 9 + .../demo/support/key_material.py | 132 + .../registry-mint/demo/support/provision.py | 79 +- .../demo/support/test_provision.py | 13 + crates/registry-platform-config/src/lib.rs | 4 +- .../registry-platform-config/src/secrets.rs | 140 +- .../src/destination.rs | 633 ++- crates/registry-server/Cargo.toml | 193 + crates/registry-server/README.md | 12 + .../examples/authoring-schema.rs | 47 + crates/registry-server/src/api/context.rs | 271 ++ crates/registry-server/src/api/mod.rs | 2672 ++++++++++++ crates/registry-server/src/api/service.rs | 364 ++ crates/registry-server/src/artifacts.rs | 674 +++ crates/registry-server/src/audit.rs | 607 +++ crates/registry-server/src/auth.rs | 451 ++ crates/registry-server/src/compiler.rs | 2764 +++++++++++++ crates/registry-server/src/contract.rs | 1463 +++++++ crates/registry-server/src/cursor.rs | 554 +++ crates/registry-server/src/data.rs | 1844 +++++++++ crates/registry-server/src/diagnostics.rs | 80 + .../registry-server/src/event_destination.rs | 594 +++ crates/registry-server/src/fixtures.rs | 3150 ++++++++++++++ crates/registry-server/src/generated_ddl.rs | 834 ++++ crates/registry-server/src/idempotency.rs | 486 +++ crates/registry-server/src/lib.rs | 60 + crates/registry-server/src/main.rs | 76 + .../registry-server/src/manifest_adapter.rs | 356 ++ crates/registry-server/src/migration.rs | 711 ++++ crates/registry-server/src/migration_plan.rs | 1726 ++++++++ crates/registry-server/src/model.rs | 410 ++ crates/registry-server/src/mutation.rs | 2477 +++++++++++ crates/registry-server/src/outbox.rs | 264 ++ crates/registry-server/src/package.rs | 3066 ++++++++++++++ crates/registry-server/src/physical_names.rs | 79 + .../registry-server/src/postgres/catalog.rs | 1070 +++++ crates/registry-server/src/postgres/config.rs | 371 ++ .../registry-server/src/postgres/context.rs | 722 ++++ .../registry-server/src/postgres/interlock.rs | 1840 +++++++++ .../src/postgres/migration_ledger.rs | 734 ++++ crates/registry-server/src/postgres/mod.rs | 94 + .../registry-server/src/postgres/mutation.rs | 317 ++ crates/registry-server/src/postgres/read.rs | 1536 +++++++ .../src/postgres/revision_read.rs | 796 ++++ crates/registry-server/src/postgres/roles.rs | 231 ++ crates/registry-server/src/postgres/schema.rs | 407 ++ crates/registry-server/src/revision.rs | 86 + crates/registry-server/src/runtime_config.rs | 1798 ++++++++ crates/registry-server/src/schema.rs | 210 + crates/registry-server/src/startup.rs | 1111 +++++ crates/registry-server/src/tooling.rs | 338 ++ crates/registry-server/src/webhook.rs | 1417 +++++++ .../tests/compiler_contract.rs | 2701 ++++++++++++ .../registry-server/tests/compiler_webhook.rs | 464 +++ .../registry-server/tests/data_operations.rs | 465 +++ .../registry-server/tests/fixture_tooling.rs | 161 + .../fixtures/fixture-tooling/journeys.yaml | 70 + .../fixtures/fixture-tooling/module.yaml | 2 + .../fixtures/fixture-tooling/project.yaml | 53 + .../fixture-tooling/terminal-failure.yaml | 29 + crates/registry-server/tests/http_auth.rs | 692 ++++ .../registry-server/tests/http_read_only.rs | 1281 ++++++ .../registry-server/tests/migration_plan.rs | 878 ++++ .../tests/package_change_plan.rs | 1402 +++++++ .../tests/pilot_acceptance_fixtures.rs | 343 ++ .../registry-server/tests/postgres_batch.rs | 746 ++++ .../tests/postgres_compiled_schema.rs | 678 +++ .../tests/postgres_constraint_races.rs | 724 ++++ .../tests/postgres_data_export.rs | 540 +++ .../tests/postgres_data_farmer.rs | 426 ++ .../tests/postgres_fixture_journeys.rs | 944 +++++ .../registry-server/tests/postgres_kernel.rs | 680 ++++ .../tests/postgres_migration.rs | 1622 ++++++++ .../tests/postgres_mutation.rs | 2523 ++++++++++++ .../registry-server/tests/postgres_package.rs | 2345 +++++++++++ .../tests/postgres_partial_unique.rs | 170 + .../tests/postgres_pilot_acceptance.rs | 1032 +++++ crates/registry-server/tests/postgres_read.rs | 1323 ++++++ .../tests/postgres_revision_http.rs | 585 +++ .../registry-server/tests/postgres_startup.rs | 1260 ++++++ crates/registry-server/tests/postgres_tls.rs | 131 + .../tests/postgres_tombstone_revision.rs | 1010 +++++ .../tests/postgres_webhook_delivery.rs | 1438 +++++++ .../tests/postgres_webhook_outbox.rs | 888 ++++ .../registry-server/tests/runtime_config.rs | 1821 +++++++++ .../tests/schema_fingerprint_rehearsal.rs | 420 ++ crates/registry-server/tests/startup_http.rs | 892 ++++ .../registry-server/tests/startup_ordering.rs | 292 ++ .../tests/support/pilot_acceptance_harness.rs | 702 ++++ .../tests/support/postgres_harness.rs | 171 + crates/registry-serverctl/Cargo.toml | 36 + crates/registry-serverctl/README.md | 50 + .../registry-serverctl/src/apply_lifecycle.rs | 211 + .../registry-serverctl/src/data_lifecycle.rs | 1487 +++++++ crates/registry-serverctl/src/doctor.rs | 168 + crates/registry-serverctl/src/lib.rs | 3619 +++++++++++++++++ crates/registry-serverctl/src/main.rs | 7 + .../src/package_inspection.rs | 43 + .../src/package_lifecycle.rs | 287 ++ .../registry-serverctl/src/test_lifecycle.rs | 490 +++ crates/registry-serverctl/tests/cli.rs | 2971 ++++++++++++++ crates/registry-serverctl/tests/diff.rs | 748 ++++ crates/registry-serverctl/tests/doctor.rs | 165 + .../registry-server/ACCEPTANCE-JOURNEYS.md | 25 + products/registry-server/DECISIONS.md | 34 + .../registry-server/DEFINITION-OF-DONE.md | 29 + products/registry-server/IMPLEMENTATION.md | 20 + products/registry-server/README.md | 131 + .../asset-site-placement-core/module.yaml | 2 + .../asset-site-placement/registry.yaml | 92 + .../asset-site-placement/tests/journeys.yaml | 96 + .../modules/business-core/module.yaml | 2 + .../acceptance/business/registry.yaml | 113 + .../acceptance/business/tests/journeys.yaml | 88 + .../modules/disability-core/module.yaml | 2 + .../acceptance/disability/registry.yaml | 108 + .../acceptance/disability/tests/journeys.yaml | 89 + .../farmer/modules/farmer-core/module.yaml | 2 + .../acceptance/farmer/registry.yaml | 108 + .../acceptance/farmer/tests/journeys.yaml | 91 + .../publicschema-household-core/module.yaml | 42 + .../module.yaml | 8 + .../publicschema-household/registry.yaml | 47 + .../tests/journeys.yaml | 105 + .../contracts/acceptance-scenario-matrix.yaml | 83 + .../contracts/artifact-inventory.yaml | 27 + .../contracts/definition-of-done.yaml | 68 + .../contracts/implementation-schedule.yaml | 22 + .../contracts/package-layout.yaml | 23 + .../contracts/security-invariant-matrix.yaml | 22 + .../contracts/security-test-traceability.yaml | 22 + products/registry-server/demo/.gitignore | 1 + products/registry-server/demo/README.md | 67 + products/registry-server/demo/query.sh | 12 + products/registry-server/demo/run.sh | 248 ++ products/registry-server/demo/support/demo.py | 571 +++ .../registry-server/demo/support/test_demo.py | 146 + .../generated/manifest/registry-manifest.json | 1 + .../generated/metadata/registry.json | 1 + .../generated/openapi.json | 1 + .../generated/postgres/schema.sql | 36 + .../generated/schemas/asset-item.schema.json | 1 + .../schemas/asset-placement.schema.json | 1 + .../generated/schemas/asset-site.schema.json | 1 + .../schemas/inspection-event.schema.json | 1 + .../authoring/registry-project.schema.json | 1747 ++++++++ .../scripts/check-contracts.sh | 15 + .../scripts/check-generated.sh | 56 + .../scripts/check-source-neutrality.sh | 6 + .../scripts/check_source_neutrality.py | 361 ++ .../scripts/compare-generated-tree.py | 88 + .../scripts/test-adopter-workflow.sh | 923 +++++ .../scripts/test-postgres-tls.sh | 173 + .../registry-server/scripts/test-postgres.sh | 38 + .../scripts/test_check_source_neutrality.py | 185 + .../scripts/test_generated_gates.py | 144 + .../scripts/test_validate_product.py | 542 +++ .../scripts/validate_product.py | 667 +++ release/scripts/check-gates-inventory.py | 17 + release/scripts/test_check_gates_inventory.py | 32 + 164 files changed, 90874 insertions(+), 186 deletions(-) create mode 100755 crates/registry-mint/demo/support/key_material.py create mode 100644 crates/registry-server/Cargo.toml create mode 100644 crates/registry-server/README.md create mode 100644 crates/registry-server/examples/authoring-schema.rs create mode 100644 crates/registry-server/src/api/context.rs create mode 100644 crates/registry-server/src/api/mod.rs create mode 100644 crates/registry-server/src/api/service.rs create mode 100644 crates/registry-server/src/artifacts.rs create mode 100644 crates/registry-server/src/audit.rs create mode 100644 crates/registry-server/src/auth.rs create mode 100644 crates/registry-server/src/compiler.rs create mode 100644 crates/registry-server/src/contract.rs create mode 100644 crates/registry-server/src/cursor.rs create mode 100644 crates/registry-server/src/data.rs create mode 100644 crates/registry-server/src/diagnostics.rs create mode 100644 crates/registry-server/src/event_destination.rs create mode 100644 crates/registry-server/src/fixtures.rs create mode 100644 crates/registry-server/src/generated_ddl.rs create mode 100644 crates/registry-server/src/idempotency.rs create mode 100644 crates/registry-server/src/lib.rs create mode 100644 crates/registry-server/src/main.rs create mode 100644 crates/registry-server/src/manifest_adapter.rs create mode 100644 crates/registry-server/src/migration.rs create mode 100644 crates/registry-server/src/migration_plan.rs create mode 100644 crates/registry-server/src/model.rs create mode 100644 crates/registry-server/src/mutation.rs create mode 100644 crates/registry-server/src/outbox.rs create mode 100644 crates/registry-server/src/package.rs create mode 100644 crates/registry-server/src/physical_names.rs create mode 100644 crates/registry-server/src/postgres/catalog.rs create mode 100644 crates/registry-server/src/postgres/config.rs create mode 100644 crates/registry-server/src/postgres/context.rs create mode 100644 crates/registry-server/src/postgres/interlock.rs create mode 100644 crates/registry-server/src/postgres/migration_ledger.rs create mode 100644 crates/registry-server/src/postgres/mod.rs create mode 100644 crates/registry-server/src/postgres/mutation.rs create mode 100644 crates/registry-server/src/postgres/read.rs create mode 100644 crates/registry-server/src/postgres/revision_read.rs create mode 100644 crates/registry-server/src/postgres/roles.rs create mode 100644 crates/registry-server/src/postgres/schema.rs create mode 100644 crates/registry-server/src/revision.rs create mode 100644 crates/registry-server/src/runtime_config.rs create mode 100644 crates/registry-server/src/schema.rs create mode 100644 crates/registry-server/src/startup.rs create mode 100644 crates/registry-server/src/tooling.rs create mode 100644 crates/registry-server/src/webhook.rs create mode 100644 crates/registry-server/tests/compiler_contract.rs create mode 100644 crates/registry-server/tests/compiler_webhook.rs create mode 100644 crates/registry-server/tests/data_operations.rs create mode 100644 crates/registry-server/tests/fixture_tooling.rs create mode 100644 crates/registry-server/tests/fixtures/fixture-tooling/journeys.yaml create mode 100644 crates/registry-server/tests/fixtures/fixture-tooling/module.yaml create mode 100644 crates/registry-server/tests/fixtures/fixture-tooling/project.yaml create mode 100644 crates/registry-server/tests/fixtures/fixture-tooling/terminal-failure.yaml create mode 100644 crates/registry-server/tests/http_auth.rs create mode 100644 crates/registry-server/tests/http_read_only.rs create mode 100644 crates/registry-server/tests/migration_plan.rs create mode 100644 crates/registry-server/tests/package_change_plan.rs create mode 100644 crates/registry-server/tests/pilot_acceptance_fixtures.rs create mode 100644 crates/registry-server/tests/postgres_batch.rs create mode 100644 crates/registry-server/tests/postgres_compiled_schema.rs create mode 100644 crates/registry-server/tests/postgres_constraint_races.rs create mode 100644 crates/registry-server/tests/postgres_data_export.rs create mode 100644 crates/registry-server/tests/postgres_data_farmer.rs create mode 100644 crates/registry-server/tests/postgres_fixture_journeys.rs create mode 100644 crates/registry-server/tests/postgres_kernel.rs create mode 100644 crates/registry-server/tests/postgres_migration.rs create mode 100644 crates/registry-server/tests/postgres_mutation.rs create mode 100644 crates/registry-server/tests/postgres_package.rs create mode 100644 crates/registry-server/tests/postgres_partial_unique.rs create mode 100644 crates/registry-server/tests/postgres_pilot_acceptance.rs create mode 100644 crates/registry-server/tests/postgres_read.rs create mode 100644 crates/registry-server/tests/postgres_revision_http.rs create mode 100644 crates/registry-server/tests/postgres_startup.rs create mode 100644 crates/registry-server/tests/postgres_tls.rs create mode 100644 crates/registry-server/tests/postgres_tombstone_revision.rs create mode 100644 crates/registry-server/tests/postgres_webhook_delivery.rs create mode 100644 crates/registry-server/tests/postgres_webhook_outbox.rs create mode 100644 crates/registry-server/tests/runtime_config.rs create mode 100644 crates/registry-server/tests/schema_fingerprint_rehearsal.rs create mode 100644 crates/registry-server/tests/startup_http.rs create mode 100644 crates/registry-server/tests/startup_ordering.rs create mode 100644 crates/registry-server/tests/support/pilot_acceptance_harness.rs create mode 100644 crates/registry-server/tests/support/postgres_harness.rs create mode 100644 crates/registry-serverctl/Cargo.toml create mode 100644 crates/registry-serverctl/README.md create mode 100644 crates/registry-serverctl/src/apply_lifecycle.rs create mode 100644 crates/registry-serverctl/src/data_lifecycle.rs create mode 100644 crates/registry-serverctl/src/doctor.rs create mode 100644 crates/registry-serverctl/src/lib.rs create mode 100644 crates/registry-serverctl/src/main.rs create mode 100644 crates/registry-serverctl/src/package_inspection.rs create mode 100644 crates/registry-serverctl/src/package_lifecycle.rs create mode 100644 crates/registry-serverctl/src/test_lifecycle.rs create mode 100644 crates/registry-serverctl/tests/cli.rs create mode 100644 crates/registry-serverctl/tests/diff.rs create mode 100644 crates/registry-serverctl/tests/doctor.rs create mode 100644 products/registry-server/ACCEPTANCE-JOURNEYS.md create mode 100644 products/registry-server/DECISIONS.md create mode 100644 products/registry-server/DEFINITION-OF-DONE.md create mode 100644 products/registry-server/IMPLEMENTATION.md create mode 100644 products/registry-server/README.md create mode 100644 products/registry-server/acceptance/asset-site-placement/modules/asset-site-placement-core/module.yaml create mode 100644 products/registry-server/acceptance/asset-site-placement/registry.yaml create mode 100644 products/registry-server/acceptance/asset-site-placement/tests/journeys.yaml create mode 100644 products/registry-server/acceptance/business/modules/business-core/module.yaml create mode 100644 products/registry-server/acceptance/business/registry.yaml create mode 100644 products/registry-server/acceptance/business/tests/journeys.yaml create mode 100644 products/registry-server/acceptance/disability/modules/disability-core/module.yaml create mode 100644 products/registry-server/acceptance/disability/registry.yaml create mode 100644 products/registry-server/acceptance/disability/tests/journeys.yaml create mode 100644 products/registry-server/acceptance/farmer/modules/farmer-core/module.yaml create mode 100644 products/registry-server/acceptance/farmer/registry.yaml create mode 100644 products/registry-server/acceptance/farmer/tests/journeys.yaml create mode 100644 products/registry-server/acceptance/publicschema-household/modules/publicschema-household-core/module.yaml create mode 100644 products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/module.yaml create mode 100644 products/registry-server/acceptance/publicschema-household/registry.yaml create mode 100644 products/registry-server/acceptance/publicschema-household/tests/journeys.yaml create mode 100644 products/registry-server/contracts/acceptance-scenario-matrix.yaml create mode 100644 products/registry-server/contracts/artifact-inventory.yaml create mode 100644 products/registry-server/contracts/definition-of-done.yaml create mode 100644 products/registry-server/contracts/implementation-schedule.yaml create mode 100644 products/registry-server/contracts/package-layout.yaml create mode 100644 products/registry-server/contracts/security-invariant-matrix.yaml create mode 100644 products/registry-server/contracts/security-test-traceability.yaml create mode 100644 products/registry-server/demo/.gitignore create mode 100644 products/registry-server/demo/README.md create mode 100755 products/registry-server/demo/query.sh create mode 100755 products/registry-server/demo/run.sh create mode 100755 products/registry-server/demo/support/demo.py create mode 100755 products/registry-server/demo/support/test_demo.py create mode 100644 products/registry-server/generated/asset-site-placement/generated/manifest/registry-manifest.json create mode 100644 products/registry-server/generated/asset-site-placement/generated/metadata/registry.json create mode 100644 products/registry-server/generated/asset-site-placement/generated/openapi.json create mode 100644 products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql create mode 100644 products/registry-server/generated/asset-site-placement/generated/schemas/asset-item.schema.json create mode 100644 products/registry-server/generated/asset-site-placement/generated/schemas/asset-placement.schema.json create mode 100644 products/registry-server/generated/asset-site-placement/generated/schemas/asset-site.schema.json create mode 100644 products/registry-server/generated/asset-site-placement/generated/schemas/inspection-event.schema.json create mode 100644 products/registry-server/generated/authoring/registry-project.schema.json create mode 100755 products/registry-server/scripts/check-contracts.sh create mode 100755 products/registry-server/scripts/check-generated.sh create mode 100755 products/registry-server/scripts/check-source-neutrality.sh create mode 100644 products/registry-server/scripts/check_source_neutrality.py create mode 100755 products/registry-server/scripts/compare-generated-tree.py create mode 100755 products/registry-server/scripts/test-adopter-workflow.sh create mode 100755 products/registry-server/scripts/test-postgres-tls.sh create mode 100755 products/registry-server/scripts/test-postgres.sh create mode 100644 products/registry-server/scripts/test_check_source_neutrality.py create mode 100755 products/registry-server/scripts/test_generated_gates.py create mode 100644 products/registry-server/scripts/test_validate_product.py create mode 100644 products/registry-server/scripts/validate_product.py diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 0d40032ccb..268c72602e 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -45,6 +45,7 @@ "registry-relay-client-py", ), "relay-v2": ("registry-relay-v2", "registry-relayctl"), + "registry-server": ("registry-server", "registry-serverctl"), "evidence": ( "registry-evidence", "registry-evidence-authoring", @@ -68,6 +69,7 @@ MANIFEST_PACKAGES = frozenset(SHARDS["manifest"]) RELAY_V2_PACKAGES = frozenset(SHARDS["relay-v2"]) RELAY_CLIENT_PACKAGES = frozenset(SHARDS["relay-client"]) +REGISTRY_SERVER_PACKAGES = frozenset(SHARDS["registry-server"]) # Provider publication is part of the Discovery product contract even though # Evidence and Relay own its generation and serving code. Keep this explicit: @@ -514,6 +516,8 @@ def classify( seeds.update(PLATFORM_PACKAGES) elif path.startswith("products/relay-v2/"): seeds.update(RELAY_V2_PACKAGES) + elif path.startswith("products/registry-server/"): + seeds.update(REGISTRY_SERVER_PACKAGES) elif path.startswith("products/identifiers/"): # The catalog gate compiles its focused Relay V2 exporter. # Catalog-only tooling does not require the full Rust matrix. @@ -706,6 +710,7 @@ def classify( or any(path in DISCOVERY_TUTORIAL_INPUTS for path in paths), "relay_v2_contracts": bool(affected & RELAY_V2_PACKAGES), "relay_client_contracts": bool(affected & RELAY_CLIENT_PACKAGES), + "registry_server_contracts": bool(affected & REGISTRY_SERVER_PACKAGES), "evidence_contracts": bool(affected & EVIDENCE_PACKAGES), "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 ae31eacd7d..4c382b90b7 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -20,6 +20,7 @@ EVIDENCE_AUTHORING_GUIDE_IMPLEMENTATION_INPUTS, EVIDENCE_TUTORIAL_INPUTS, IDENTIFIER_CATALOG_INPUTS, + REGISTRY_SERVER_PACKAGES, SECURITY_WORKFLOW_GATES, SHARDS, Workspace, @@ -371,6 +372,38 @@ def test_relay_v2_product_material_selects_runtime_and_tooling(self) -> None: self.assertTrue(outputs["relay_v2_contracts"]) self.assertTrue(outputs["editors"]) + def test_registry_server_paths_select_its_shard_and_product_gate(self) -> None: + for path in ( + "crates/registry-server/src/compiler.rs", + "crates/registry-serverctl/src/main.rs", + "products/registry-server/contracts/definition-of-done.yaml", + ): + with self.subTest(path=path): + outputs = classify(self.workspace, (path,)) + self.assertTrue(outputs["registry_server_contracts"]) + self.assertTrue( + set(outputs["rust_packages"]) & REGISTRY_SERVER_PACKAGES + ) + + product_outputs = classify( + self.workspace, + ("products/registry-server/contracts/definition-of-done.yaml",), + ) + self.assertEqual( + set(REGISTRY_SERVER_PACKAGES), + set(product_outputs["rust_packages"]) & REGISTRY_SERVER_PACKAGES, + ) + + def test_manifest_core_changes_select_registry_server_through_linked_code( + self, + ) -> None: + manifest_change = ("crates/registry-manifest-core/src/lib.rs",) + outputs = classify(self.workspace, manifest_change) + + self.assertTrue(outputs["registry_server_contracts"]) + self.assertIn("registry-server", outputs["rust_packages"]) + self.assertIn("registry-manifest-core", outputs["rust_packages"]) + 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 @@ -842,6 +875,18 @@ def test_current_contract_gates_replace_the_retired_notary_gate(self) -> None: self.assertNotIn("\n notary-contracts:\n", workflow) self.assertNotIn("notary_contracts", workflow) + self.assertIn("\n registry-server-contracts:\n", workflow) + self.assertIn("name: Registry Server product contracts", workflow) + self.assertIn( + "products/registry-server/scripts/check-contracts.sh", workflow + ) + self.assertIn( + "products/registry-server/scripts/test-postgres.sh", workflow + ) + self.assertIn( + "products/registry-server/scripts/test-adopter-workflow.sh", workflow + ) + rust_result = workflow.split("\n rust-result:\n", 1)[1].split( "\n release-tool:\n", 1 )[0] @@ -849,8 +894,50 @@ def test_current_contract_gates_replace_the_retired_notary_gate(self) -> None: self.assertIn("\n - evidence-contracts\n", rust_result) self.assertIn("\n - relay-v2-contracts\n", rust_result) self.assertIn("\n - relay-client-contracts\n", rust_result) + self.assertIn("\n - registry-server-contracts\n", rust_result) self.assertNotIn("\n - notary-contracts\n", rust_result) + def test_registry_server_contracts_pin_postgresql_and_use_the_product_entry_points( + self, + ) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + registry_server_job = workflow.split( + "\n registry-server-contracts:\n", 1 + )[1].split("\n identifiers:\n", 1)[0] + + self.assertIn( + "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675", + registry_server_job, + ) + self.assertIn( + "ports:\n - 5432/tcp", + registry_server_job, + ) + self.assertIn( + "REGISTRY_SERVER_TEST_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server", + registry_server_job, + ) + self.assertIn( + "REGISTRY_SERVER_TEST_TLS_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server", + registry_server_job, + ) + self.assertIn( + "REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL: postgresql://registry_server:registry_server_test@127.0.0.1:${{ job.services.postgres.ports[5432] }}/registry_server", + registry_server_job, + ) + self.assertIn( + "REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }}", + registry_server_job, + ) + for entry_point in ( + "products/registry-server/scripts/check-contracts.sh", + "products/registry-server/scripts/test-postgres.sh", + "products/registry-server/scripts/test-postgres-tls.sh", + "products/registry-server/scripts/test-adopter-workflow.sh", + ): + with self.subTest(entry_point=entry_point): + self.assertIn(entry_point, registry_server_job) + def test_discovery_contracts_pins_node_for_adopter_binding_tests(self) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") discovery_job = workflow.split("\n discovery-contracts:\n", 1)[1].split( diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f5b09721f..3d4e9c2c73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,7 @@ jobs: discovery_contracts: ${{ steps.filter.outputs.discovery_contracts }} relay_v2_contracts: ${{ steps.filter.outputs.relay_v2_contracts }} relay_client_contracts: ${{ steps.filter.outputs.relay_client_contracts }} + registry_server_contracts: ${{ steps.filter.outputs.registry_server_contracts }} evidence_contracts: ${{ steps.filter.outputs.evidence_contracts }} release_tool: ${{ steps.filter.outputs.release_tool }} release_source_proof: ${{ steps.filter.outputs.release_source_proof }} @@ -592,6 +593,60 @@ jobs: - name: Relay client source neutrality run: products/relay-v2/scripts/check-source-neutrality.sh + registry-server-contracts: + name: Registry Server product contracts + needs: changes + if: needs.changes.outputs.registry_server_contracts == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + services: + postgres: + # Docker Hub PostgreSQL 17.11 index, verified 2026-08-29. + image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 + env: + POSTGRES_DB: registry_server + POSTGRES_PASSWORD: registry_server_test + POSTGRES_USER: registry_server + options: >- + --health-cmd "pg_isready -U registry_server -d registry_server" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432/tcp + env: + REGISTRY_SERVER_TEST_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server + REGISTRY_SERVER_TEST_TLS_DATABASE_HOST: localhost + REGISTRY_SERVER_TEST_TLS_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server + REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL: postgresql://registry_server:registry_server_test@127.0.0.1:${{ job.services.postgres.ports[5432] }}/registry_server + REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }} + REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH: ${{ runner.temp }}/registry-server-postgres-trusted-ca.pem + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + submodules: false + + - name: Cache Cargo registry + uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Registry Server contract consistency + run: products/registry-server/scripts/check-contracts.sh + + - name: Registry Server PostgreSQL journeys + run: products/registry-server/scripts/test-postgres.sh + + - name: Registry Server PostgreSQL TLS proof + run: products/registry-server/scripts/test-postgres-tls.sh + + - name: Registry Server clean-checkout adopter workflow + run: products/registry-server/scripts/test-adopter-workflow.sh + identifiers: name: Public identifier catalog needs: changes @@ -627,6 +682,7 @@ jobs: - evidence-contracts - relay-v2-contracts - relay-client-contracts + - registry-server-contracts - identifiers runs-on: ubuntu-24.04 env: diff --git a/Cargo.lock b/Cargo.lock index d7bab83a7d..b6dda227df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,6 +277,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.66.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b84e06fc203107bfbad243f4aba2af864eb7db3b1cf46ea0a023b0b433d2a7" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -349,6 +372,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -376,7 +405,16 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", ] [[package]] @@ -436,7 +474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf", + "phf 0.12.1", ] [[package]] @@ -477,6 +515,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clap" version = "4.6.6" @@ -673,7 +722,7 @@ dependencies = [ "ciborium", "clap", "criterion-plot", - "itertools", + "itertools 0.13.0", "num-traits", "oorandom", "page_size", @@ -694,7 +743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools", + "itertools 0.13.0", ] [[package]] @@ -749,7 +798,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -890,18 +939,51 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" dependencies = [ - "deadpool-runtime", + "deadpool-runtime 0.1.4", "lazy_static", "num_cpus", "tokio", ] +[[package]] +name = "deadpool" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e" +dependencies = [ + "deadpool-runtime 0.3.1", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65a536565624b97fc19f758cd01b15d12908d3344425066efc8162236fbd3749" +dependencies = [ + "async-trait", + "deadpool 0.13.1", + "getrandom 0.4.3", + "tokio", + "tokio-postgres", + "tracing", +] + [[package]] name = "deadpool-runtime" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + [[package]] name = "der" version = "0.7.10" @@ -909,10 +991,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1110,7 +1205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1126,7 +1221,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", "typetag", "uuid", ] @@ -1152,6 +1247,12 @@ dependencies = [ "rand 0.9.5", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1203,6 +1304,18 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -1382,7 +1495,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -1412,6 +1525,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "group" version = "0.13.0" @@ -1500,7 +1619,7 @@ dependencies = [ "ipnet", "jni", "rand 0.10.2", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tokio", "tracing", @@ -1521,7 +1640,7 @@ dependencies = [ "prefix-trie", "rand 0.10.2", "ring", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "url", @@ -1548,7 +1667,7 @@ dependencies = [ "resolv-conf", "smallvec", "system-configuration", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -1571,6 +1690,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.5.0" @@ -1892,7 +2020,16 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74a0559b45528cf0732d911524974977a5749f477d7dd99652830ffdaf53c4d1" dependencies = [ - "nom", + "nom 8.0.0", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", ] [[package]] @@ -1922,7 +2059,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2036,12 +2173,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[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 = "libloading" version = "0.9.0" @@ -2052,6 +2205,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libredox" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +dependencies = [ + "libc", +] + [[package]] name = "libsqlite3-sys" version = "0.38.2" @@ -2063,6 +2225,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2130,6 +2298,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -2142,6 +2320,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2160,7 +2344,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2181,6 +2365,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "napi" version = "3.12.1" @@ -2194,7 +2384,7 @@ dependencies = [ "napi-build", "napi-sys", "nohash-hasher", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "serde_json", "tokio", @@ -2239,7 +2429,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" dependencies = [ - "libloading", + "libloading 0.9.0", ] [[package]] @@ -2263,6 +2453,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -2375,6 +2575,24 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2428,7 +2646,7 @@ dependencies = [ "oxiri", "oxrdf", "ryu-js", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -2440,7 +2658,7 @@ dependencies = [ "oxilangtag", "oxiri", "rand 0.9.5", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -2488,6 +2706,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2503,13 +2727,51 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pg_query" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ca6fdb8f9d32182abf17328789f87f305dd8c8ce5bf48c5aa2b5cffc94e1c04" +dependencies = [ + "bindgen", + "cc", + "fs_extra", + "glob", + "itertools 0.10.5", + "prost", + "prost-build", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "phf" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "phf_shared", + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -2521,6 +2783,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2598,6 +2869,39 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac 0.13.0", + "md-5", + "memchr", + "rand 0.10.2", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator 0.2.0", + "postgres-protocol", + "serde_core", + "serde_json", + "uuid", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2643,6 +2947,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2680,6 +2994,58 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools 0.13.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.13.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + [[package]] name = "pyo3" version = "0.29.2" @@ -2754,10 +3120,10 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -2775,11 +3141,11 @@ dependencies = [ "rand 0.10.2", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -2796,7 +3162,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3017,7 +3383,7 @@ dependencies = [ "serde_yaml_ng", "sha2 0.11.0", "tempfile", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "tower", @@ -3050,7 +3416,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "tracing", @@ -3093,7 +3459,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.20", "url", ] @@ -3116,7 +3482,7 @@ dependencies = [ "serde_yaml_ng", "sha2 0.11.0", "tempfile", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "url", @@ -3159,14 +3525,14 @@ dependencies = [ "reqwest", "rhai", "rusqlite", - "rustix", + "rustix 1.1.4", "schemars", "serde", "serde_json", "serde_norway", "sha2 0.11.0", "tempfile", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "tokio-rustls", @@ -3215,7 +3581,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "url", "wiremock", @@ -3291,13 +3657,13 @@ dependencies = [ "registry-platform-oidc", "registry-platform-sdjwt", "reqwest", - "rustix", + "rustix 1.1.4", "serde", "serde_json", "serde_norway", "subtle", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "tracing-subscriber", @@ -3320,7 +3686,7 @@ dependencies = [ "serde_json", "serde_norway", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.20", "tokio", "utoipa", ] @@ -3348,7 +3714,7 @@ dependencies = [ "registry-platform-buildinfo", "registry-platform-crypto", "rhai", - "rustix", + "rustix 1.1.4", "serde", "serde_json", "serde_norway", @@ -3370,7 +3736,7 @@ dependencies = [ "futures", "registry-evidence-authoring", "registry-relay-v2", - "rustix", + "rustix 1.1.4", "serde_json", "serde_norway", "tempfile", @@ -3404,7 +3770,7 @@ dependencies = [ "serde_path_to_error", "serde_yaml_ng", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -3431,12 +3797,12 @@ dependencies = [ "registry-platform-httputil", "registry-platform-oidc", "reqwest", - "rustix", + "rustix 1.1.4", "serde", "serde_json", "serde_norway", "tempfile", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "tower-http 0.7.0", @@ -3454,13 +3820,13 @@ dependencies = [ "async-trait", "hmac 0.13.0", "registry-platform-canonical-json", - "rustix", + "rustix 1.1.4", "serde", "serde_json", "sha2 0.11.0", "subtle", "tempfile", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "tracing", @@ -3481,7 +3847,7 @@ dependencies = [ "sha2 0.11.0", "subtle", "tempfile", - "thiserror", + "thiserror 2.0.20", "ulid", "zeroize", ] @@ -3497,7 +3863,7 @@ dependencies = [ "ryu-js", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -3506,12 +3872,12 @@ version = "0.25.0" dependencies = [ "base64", "registry-platform-crypto", - "rustix", + "rustix 1.1.4", "serde", "serde_json", "sha2 0.11.0", "tempfile", - "thiserror", + "thiserror 2.0.20", "time", "zeroize", ] @@ -3536,7 +3902,7 @@ dependencies = [ "sha2 0.11.0", "subtle", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "url", "zeroize", @@ -3550,7 +3916,7 @@ dependencies = [ "http", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", "tokio", "tower", "tower-http 0.7.0", @@ -3582,7 +3948,7 @@ dependencies = [ "rustls-native-certs", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "tokio-rustls", @@ -3604,7 +3970,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", "tokio", ] @@ -3621,7 +3987,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "subtle", - "thiserror", + "thiserror 2.0.20", "tokio", "ulid", ] @@ -3631,11 +3997,11 @@ name = "registry-platform-sqlite" version = "0.25.0" dependencies = [ "rusqlite", - "rustix", + "rustix 1.1.4", "serde", "sha2 0.11.0", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", ] @@ -3656,7 +4022,7 @@ dependencies = [ "reqwest", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tower", "wiremock", @@ -3674,7 +4040,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", "tokio", "url", "zeroize", @@ -3752,14 +4118,14 @@ dependencies = [ "registry-relay-client", "registry-relay-http-contract", "reqwest", - "rustix", + "rustix 1.1.4", "schemars", "serde", "serde_json", "serde_norway", "sha2 0.11.0", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tower", "tower-http 0.7.0", @@ -3784,8 +4150,80 @@ dependencies = [ "serde_json", "sha2 0.11.0", "tempfile", - "thiserror", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "registry-server" +version = "0.25.0" +dependencies = [ + "axum", + "base64", + "chacha20poly1305", + "clap", + "deadpool-postgres", + "getrandom 0.4.3", + "hex", + "hmac 0.13.0", + "ipnet", + "jsonschema", + "jsonwebtoken", + "pg_query", + "rcgen", + "registry-manifest-core", + "registry-platform-audit", + "registry-platform-authcommon", + "registry-platform-buildinfo", + "registry-platform-canonical-json", + "registry-platform-config", + "registry-platform-crypto", + "registry-platform-httpsec", + "registry-platform-httputil", + "registry-platform-oidc", + "registry-platform-testing", + "rustix 1.1.4", + "rustls", + "schemars", + "serde", + "serde_json", + "serde_norway", + "serde_path_to_error", + "sha2 0.11.0", + "subtle", + "tempfile", + "thiserror 2.0.20", + "time", "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tokio-rustls", + "tower", + "tracing", + "tracing-subscriber", + "uuid", + "zeroize", +] + +[[package]] +name = "registry-serverctl" +version = "0.25.0" +dependencies = [ + "clap", + "registry-platform-buildinfo", + "registry-platform-canonical-json", + "registry-platform-crypto", + "registry-platform-httputil", + "registry-server", + "reqwest", + "rustix 1.1.4", + "serde", + "serde_json", + "serde_norway", + "sha2 0.11.0", + "thiserror 2.0.20", + "tokio", + "zeroize", ] [[package]] @@ -3833,7 +4271,7 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71ea98a177596a4579881992bd2bd4af27772fc95d0e5f5668a8f9535eca6380" dependencies = [ - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -3903,7 +4341,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags", - "fallible-iterator", + "fallible-iterator 0.3.0", "fallible-streaming-iterator", "libsqlite3-sys", "smallvec", @@ -3921,9 +4359,15 @@ dependencies = [ "http", "mime", "rand 0.10.2", - "thiserror", + "thiserror 2.0.20", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3939,6 +4383,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3948,8 +4405,8 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.52.0", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -4258,6 +4715,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -4404,6 +4867,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4498,10 +4972,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix", - "windows-sys 0.52.0", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -4513,13 +4987,33 @@ dependencies = [ "serde", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -4616,6 +5110,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -4643,6 +5158,47 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf 0.13.1", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-postgres-rustls" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb" +dependencies = [ + "rustls", + "rustls-native-certs", + "sha2 0.11.0", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4916,12 +5472,33 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -5094,6 +5671,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5103,6 +5689,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -5196,6 +5791,31 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "widestring" version = "1.2.1" @@ -5224,7 +5844,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5393,7 +6013,7 @@ checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", "base64", - "deadpool", + "deadpool 0.12.3", "futures", "http", "http-body-util", @@ -5420,6 +6040,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "tls_codec", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 2436dd1386..08dbc095f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ members = [ "crates/registry-relay-client-py", "crates/registry-relay-v2", "crates/registry-relayctl", + "crates/registry-server", + "crates/registry-serverctl", "crates/registry-language-server", ] exclude = [ @@ -79,6 +81,8 @@ registry-relay-client-node = { path = "crates/registry-relay-client-node", versi registry-relay-client-py = { path = "crates/registry-relay-client-py", version = "0.25.0" } registry-relay-v2 = { path = "crates/registry-relay-v2", version = "0.25.0" } registry-relayctl = { path = "crates/registry-relayctl", version = "0.25.0" } +registry-server = { path = "crates/registry-server", version = "0.25.0", default-features = false } +registry-serverctl = { path = "crates/registry-serverctl", version = "0.25.0" } registry-platform-audit = { path = "crates/registry-platform-audit", version = "0.25.0" } registry-platform-authcommon = { path = "crates/registry-platform-authcommon", version = "0.25.0" } registry-platform-buildinfo = { path = "crates/registry-platform-buildinfo", version = "0.25.0" } @@ -106,6 +110,7 @@ chrono-tz = { version = "0.10.4" } clap = { version = "4", features = ["derive", "env"] } criterion = { version = "0.8", features = ["html_reports", "async_tokio"] } csv = { version = "1" } +deadpool-postgres = { version = "=0.14.2", default-features = false, features = ["rt_tokio_1"] } ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } fake = { version = "=4.4.0", default-features = false } fs2 = { version = "0.4" } @@ -127,6 +132,7 @@ napi = { version = "3.12.0", features = ["async", "serde-json"] } napi-build = { version = "2.4.0" } napi-derive = { version = "3.6.2" } p256 = { version = "0.13", features = ["ecdsa"] } +pg_query = { version = "=6.1.1", default-features = false } pkcs1 = { version = "0.7", features = ["alloc"] } pkcs8 = { version = "0.10" } proptest = { version = "1" } @@ -138,6 +144,7 @@ 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"] } +rustls = { version = "0.23", default-features = false, features = ["std", "ring"] } # `bundled` vendors the SQLite amalgamation, so a statement source needs no # system library. `hooks` carries the authorizer and the progress handler, which # are the statement source's safety boundary and its only real cancellation. @@ -158,6 +165,8 @@ tempfile = { version = "3" } thiserror = { version = "2" } time = { version = "0.3", features = ["serde", "formatting", "parsing", "macros"] } tokio = { version = "1", features = ["fs", "macros", "net", "rt", "rt-multi-thread", "signal", "sync", "time"] } +tokio-postgres = { version = "=0.7.18", features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-1"] } +tokio-postgres-rustls = { version = "=0.14.0", default-features = false, features = ["ring", "native-certs"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } tower = { version = "0.5" } tower-lsp-server = { version = "0.23" } diff --git a/crates/registry-mint/demo/support/key_material.py b/crates/registry-mint/demo/support/key_material.py new file mode 100755 index 0000000000..b839669abc --- /dev/null +++ b/crates/registry-mint/demo/support/key_material.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["cryptography>=42"] +# /// +"""Disposable local-development key material shared by Registry Mint demos.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import secrets +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + + +def b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def ed25519_jwk(kid: str) -> tuple[dict, dict]: + """Return a disposable Ed25519 (private JWK, public JWK) pair.""" + 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 p256_jwk() -> tuple[dict, dict]: + """Return a disposable ES256 pair with an RFC 7638 key identifier.""" + private = ec.generate_private_key(ec.SECP256R1()) + numbers = private.private_numbers() + public_numbers = numbers.public_numbers + public_jwk = { + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "x": b64(public_numbers.x.to_bytes(32, "big")), + "y": b64(public_numbers.y.to_bytes(32, "big")), + } + thumbprint_members = { + member: public_jwk[member] for member in ("crv", "kty", "x", "y") + } + thumbprint = json.dumps( + thumbprint_members, sort_keys=True, separators=(",", ":") + ).encode() + public_jwk["kid"] = b64(hashlib.sha256(thumbprint).digest()) + private_jwk = { + **public_jwk, + "d": b64(numbers.private_value.to_bytes(32, "big")), + } + return private_jwk, public_jwk + + +def write(path: Path, text: str, mode: int = 0o644) -> Path: + """Write public local-development material.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + path.chmod(mode) + return path + + +def write_secret(path: Path, text: str) -> Path: + """Create or replace one secret without a wider intermediate mode.""" + path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(text) + path.chmod(0o600) + return path + + +def _new_output(path: Path) -> None: + if path.exists() or path.is_symlink(): + raise SystemExit(f"refusing to replace existing output: {path}") + + +def _generate_p256(private_out: Path, public_out: Path) -> None: + _new_output(private_out) + _new_output(public_out) + private, public = p256_jwk() + write_secret( + private_out, json.dumps(private, sort_keys=True, separators=(",", ":")) + ) + write(public_out, json.dumps(public, sort_keys=True, separators=(",", ":"))) + + +def _generate_secret(output: Path) -> None: + _new_output(output) + write_secret(output, secrets.token_hex(32)) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser() + commands = result.add_subparsers(dest="command", required=True) + p256 = commands.add_parser("p256") + p256.add_argument("--private-out", required=True, type=Path) + p256.add_argument("--public-out", required=True, type=Path) + secret = commands.add_parser("secret-hex") + secret.add_argument("--out", required=True, type=Path) + return result + + +def main() -> int: + args = parser().parse_args() + if args.command == "p256": + _generate_p256(args.private_out, args.public_out) + elif args.command == "secret-hex": + _generate_secret(args.out) + else: # pragma: no cover + raise AssertionError(args.command) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/crates/registry-mint/demo/support/provision.py b/crates/registry-mint/demo/support/provision.py index 2bdbb91af1..fc51e72135 100644 --- a/crates/registry-mint/demo/support/provision.py +++ b/crates/registry-mint/demo/support/provision.py @@ -13,9 +13,7 @@ every run and are worthless outside this directory. """ -import base64 import datetime as dt -import hashlib import json import os import secrets @@ -26,9 +24,11 @@ from cryptography import x509 from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from cryptography.hazmat.primitives.asymmetric import ed25519 from cryptography.x509.oid import NameOID +from key_material import b64, ed25519_jwk, p256_jwk, write, write_secret + MINT_PORT = 8090 TLS_PORT = 8443 EVIDENCE_PORT = 8080 @@ -38,79 +38,6 @@ 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 p256_jwk() -> tuple[dict, dict]: - """Return a service ES256 key whose kid is its RFC 7638 thumbprint.""" - private = ec.generate_private_key(ec.SECP256R1()) - numbers = private.private_numbers() - public_numbers = numbers.public_numbers - public_jwk = { - "kty": "EC", - "crv": "P-256", - "alg": "ES256", - "x": b64(public_numbers.x.to_bytes(32, "big")), - "y": b64(public_numbers.y.to_bytes(32, "big")), - } - thumbprint_members = { - member: public_jwk[member] for member in ("crv", "kty", "x", "y") - } - thumbprint = json.dumps( - thumbprint_members, sort_keys=True, separators=(",", ":") - ).encode() - public_jwk["kid"] = b64(hashlib.sha256(thumbprint).digest()) - private_jwk = { - **public_jwk, - "d": b64(numbers.private_value.to_bytes(32, "big")), - } - return private_jwk, public_jwk - - -def write(path: Path, text: str, mode: int = 0o644) -> Path: - """Write a file everyone on the machine may read: certificates, configuration.""" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - path.chmod(mode) - return path - - -def write_secret(path: Path, text: str) -> Path: - """Write a file that is never wider than owner read/write, not even briefly. - - Creating the file and then narrowing it would leave a freshly generated - signing key readable by anyone on the machine for the length of the write. - `os.open` carries the mode into the creation; the `chmod` after it only - undoes the umask. - """ - path.parent.mkdir(parents=True, exist_ok=True) - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - handle.write(text) - path.chmod(0o600) - return path - - def issue_tls_certificate(root: Path) -> None: """A private CA and one `localhost` server certificate. diff --git a/crates/registry-mint/demo/support/test_provision.py b/crates/registry-mint/demo/support/test_provision.py index 1eb1441d64..58b07863fe 100644 --- a/crates/registry-mint/demo/support/test_provision.py +++ b/crates/registry-mint/demo/support/test_provision.py @@ -25,6 +25,7 @@ def load_module(): + sys.path.insert(0, str(SUPPORT)) specification = importlib.util.spec_from_file_location( "demo_provision", SUPPORT / "provision.py" ) @@ -33,6 +34,8 @@ def load_module(): specification.loader.exec_module(module) except ImportError as error: # pragma: no cover - depends on the environment raise unittest.SkipTest(f"provision.py needs {error.name}") from None + finally: + sys.path.remove(str(SUPPORT)) return module @@ -90,6 +93,16 @@ def test_service_key_is_es256_with_an_rfc7638_identifier(self): self.assertIn("d", private) self.assertNotIn("d", public) + def test_shared_key_helper_creates_owner_only_secret_and_refuses_replacement(self): + output = self.root / "secret" + key_material = sys.modules["key_material"] + key_material._generate_secret(output) + + self.assertEqual(0o600, stat.S_IMODE(output.stat().st_mode)) + self.assertEqual(64, len(output.read_text())) + with self.assertRaises(SystemExit): + key_material._generate_secret(output) + if __name__ == "__main__": sys.exit(0 if unittest.main(exit=False).result.wasSuccessful() else 1) diff --git a/crates/registry-platform-config/src/lib.rs b/crates/registry-platform-config/src/lib.rs index 72b9283287..d86387a118 100644 --- a/crates/registry-platform-config/src/lib.rs +++ b/crates/registry-platform-config/src/lib.rs @@ -5,7 +5,9 @@ mod secrets; use serde_json::Value; use sha2::{Digest, Sha256}; -pub use secrets::{ProtectedSecret, SecretError, SecretProvider, SecretResolver, MAX_SECRET_BYTES}; +pub use secrets::{ + ProtectedSecret, SecretError, SecretProvider, SecretReference, SecretResolver, MAX_SECRET_BYTES, +}; #[derive(Debug, Clone, Eq, PartialEq)] pub struct DeprecatedConfigField { diff --git a/crates/registry-platform-config/src/secrets.rs b/crates/registry-platform-config/src/secrets.rs index c0b34c3cee..2f9be55477 100644 --- a/crates/registry-platform-config/src/secrets.rs +++ b/crates/registry-platform-config/src/secrets.rs @@ -24,6 +24,64 @@ pub enum SecretProvider { File, } +/// Parsed `secret:...` reference whose debug form never exposes the name. +#[derive(Clone, Eq, PartialEq)] +pub struct SecretReference { + reference: String, + provider: SecretProvider, + name_start: usize, +} + +impl SecretReference { + /// Parse one exact `secret:env/NAME` or `secret:file/name` reference. + pub fn parse(reference: impl Into) -> Result { + let reference = reference.into(); + if let Some(name) = reference.strip_prefix("secret:env/") { + if valid_environment_name(name) { + return Ok(Self { + reference, + provider: SecretProvider::Environment, + name_start: "secret:env/".len(), + }); + } + } else if let Some(name) = reference.strip_prefix("secret:file/") { + if valid_file_name(name) { + return Ok(Self { + reference, + provider: SecretProvider::File, + name_start: "secret:file/".len(), + }); + } + } + Err(SecretError::InvalidReference) + } + + #[must_use] + pub fn provider(&self) -> SecretProvider { + self.provider + } + + #[must_use] + pub fn name(&self) -> &str { + &self.reference[self.name_start..] + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.reference + } +} + +impl fmt::Debug for SecretReference { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SecretReference") + .field("provider", &self.provider) + .field("name", &"[REDACTED]") + .finish() + } +} + /// Value-free secret resolution failure. #[derive(Debug, Error, Eq, PartialEq)] pub enum SecretError { @@ -103,32 +161,27 @@ impl SecretResolver { /// Resolve one exact `secret:env/NAME` or `secret:file/name` reference. pub fn resolve(&self, reference: &str) -> Result { - let (provider, name) = parse_reference(reference)?; - if !self.providers.contains(&provider) { + let reference = SecretReference::parse(reference)?; + self.resolve_reference(&reference) + } + + /// Resolve an already parsed secret reference. + pub fn resolve_reference( + &self, + reference: &SecretReference, + ) -> Result { + if !self.providers.contains(&reference.provider()) { return Err(SecretError::ProviderDisabled); } - let bytes = match provider { - SecretProvider::Environment => read_environment(name)?, - SecretProvider::File => read_secret_file(&self.file_root, name)?, + let bytes = match reference.provider() { + SecretProvider::Environment => read_environment(reference.name())?, + SecretProvider::File => read_secret_file(&self.file_root, reference.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')) @@ -238,13 +291,24 @@ mod tests { #[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", + for (valid, provider, name) in [ + ("secret:env/A", SecretProvider::Environment, "A"), + ( + "secret:env/SOURCE_2_PASSWORD", + SecretProvider::Environment, + "SOURCE_2_PASSWORD", + ), + ("secret:file/a", SecretProvider::File, "a"), + ( + "secret:file/source-token_v2.json", + SecretProvider::File, + "source-token_v2.json", + ), ] { - assert!(parse_reference(valid).is_ok(), "{valid}"); + let reference = SecretReference::parse(valid).expect("valid reference parses"); + assert_eq!(reference.provider(), provider); + assert_eq!(reference.name(), name); + assert_eq!(reference.as_str(), valid); } for invalid in [ "secret:env/", @@ -258,15 +322,28 @@ mod tests { "secret:file/token\0suffix", "plain-value", ] { - assert_eq!(parse_reference(invalid), Err(SecretError::InvalidReference)); + assert_eq!( + SecretReference::parse(invalid), + Err(SecretError::InvalidReference) + ); } - assert!(parse_reference(&format!("secret:env/A{}", "B".repeat(127))).is_ok()); + assert!(SecretReference::parse(format!("secret:env/A{}", "B".repeat(127))).is_ok()); assert_eq!( - parse_reference(&format!("secret:env/A{}", "B".repeat(128))), + SecretReference::parse(format!("secret:env/A{}", "B".repeat(128))), Err(SecretError::InvalidReference) ); } + #[test] + fn secret_reference_debug_does_not_render_the_reference_name() { + let reference = + SecretReference::parse("secret:file/reference-name-canary").expect("reference parses"); + let rendered = format!("{reference:?}"); + assert!(rendered.contains("File")); + assert!(!rendered.contains("reference-name-canary")); + assert!(!rendered.contains(reference.as_str())); + } + #[test] fn provider_configuration_is_closed_and_file_roots_are_absolute() { assert_eq!( @@ -286,8 +363,10 @@ mod tests { fn provider_allowlist_is_enforced_before_lookup() { let resolver = SecretResolver::new([SecretProvider::File], "/safe-root").expect("resolver builds"); + let reference = + SecretReference::parse("secret:env/DEFINITELY_NOT_PRESENT").expect("reference parses"); assert!(matches!( - resolver.resolve("secret:env/DEFINITELY_NOT_PRESENT"), + resolver.resolve_reference(&reference), Err(SecretError::ProviderDisabled) )); } @@ -299,8 +378,11 @@ mod tests { env::set_var(NAME, "environment-canary"); let resolver = SecretResolver::new([SecretProvider::Environment], "").expect("resolver builds"); + let reference = + SecretReference::parse("secret:env/REGISTRY_PLATFORM_CONFIG_SECRET_RESOLVER_TEST") + .expect("reference parses"); let secret = resolver - .resolve("secret:env/REGISTRY_PLATFORM_CONFIG_SECRET_RESOLVER_TEST") + .resolve_reference(&reference) .expect("secret resolves"); env::remove_var(NAME); diff --git a/crates/registry-platform-httputil/src/destination.rs b/crates/registry-platform-httputil/src/destination.rs index e5c90a0d62..3d1c88c7d1 100644 --- a/crates/registry-platform-httputil/src/destination.rs +++ b/crates/registry-platform-httputil/src/destination.rs @@ -31,6 +31,7 @@ use http::header::{ use http::uri::PathAndQuery; use http::{HeaderMap, StatusCode}; use ipnet::IpNet; +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; use reqwest::Url; use thiserror::Error; use tokio::sync::Semaphore; @@ -165,6 +166,8 @@ pub trait DestinationSlot: sealed::Sealed { const DEBUG_NAME: &'static str; #[doc(hidden)] const CREDENTIAL_EXCHANGE: bool; + #[doc(hidden)] + const EVENT_DELIVERY: bool; } /// Registry-data destination marker. @@ -174,6 +177,7 @@ impl sealed::Sealed for DataDestination {} impl DestinationSlot for DataDestination { const DEBUG_NAME: &'static str = "data"; const CREDENTIAL_EXCHANGE: bool = false; + const EVENT_DELIVERY: bool = false; } /// Credential-exchange destination marker. @@ -183,6 +187,17 @@ impl sealed::Sealed for CredentialDestination {} impl DestinationSlot for CredentialDestination { const DEBUG_NAME: &'static str = "credential"; const CREDENTIAL_EXCHANGE: bool = true; + const EVENT_DELIVERY: bool = false; +} + +/// Event-delivery destination marker. +pub enum EventDestination {} + +impl sealed::Sealed for EventDestination {} +impl DestinationSlot for EventDestination { + const DEBUG_NAME: &'static str = "event"; + const CREDENTIAL_EXCHANGE: bool = false; + const EVENT_DELIVERY: bool = true; } /// Runtime class for a fixed outbound destination. @@ -197,7 +212,7 @@ pub enum DestinationProfile { /// Plain HTTP to one exact, explicitly allowed private IP for local development. LocalPrivateDevelopmentHttp, /// Test-only HTTPS profile that remains confined to loopback addresses. - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] PinnedLoopbackHttpsTest, } @@ -341,6 +356,8 @@ pub enum DestinationTlsMaterialError { pub type DataDestinationPolicy = FixedDestinationPolicy; /// Credential-exchange fixed destination policy. pub type CredentialDestinationPolicy = FixedDestinationPolicy; +/// Event-delivery fixed destination policy. +pub type EventDestinationPolicy = FixedDestinationPolicy; /// Fixed data destination for one bounded internal service hop. /// @@ -578,12 +595,12 @@ impl FixedDestinationPolicy { ); } } - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] DestinationProfile::PinnedLoopbackHttpsTest if origin.scheme() != "https" => { return Err(DestinationPolicyError::ProductionRequiresHttps); } DestinationProfile::ProductionHttps | DestinationProfile::PrivateServiceHttp => {} - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] DestinationProfile::PinnedLoopbackHttpsTest => {} } @@ -976,7 +993,7 @@ impl FixedDestinationPolicy { } Err(DestinationSendError::PrivateAddressNotAllowed) } - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] DestinationProfile::PinnedLoopbackHttpsTest => { if is_loopback(ip) { Ok(()) @@ -1127,13 +1144,17 @@ pub enum DestinationMethod { ReviewedReadOnlyPost, /// OAuth 2.0 client-credentials POST, valid only for a credential destination slot. OAuth2ClientCredentialsPost, + /// Canonical JSON event POST, valid only for an event destination slot. + EventPost, } impl DestinationMethod { fn as_reqwest(self) -> reqwest::Method { match self { Self::Get => reqwest::Method::GET, - Self::ReviewedReadOnlyPost | Self::OAuth2ClientCredentialsPost => reqwest::Method::POST, + Self::ReviewedReadOnlyPost | Self::OAuth2ClientCredentialsPost | Self::EventPost => { + reqwest::Method::POST + } } } } @@ -1314,6 +1335,7 @@ enum HeaderTemplateInput<'a> { enum RequestTemplateKind { General(DestinationMethod), OAuth2ClientCredentials(OAuth2ClientCredentialsBodyFormat), + EventDelivery, } enum TargetTemplateInput<'a> { @@ -1384,6 +1406,36 @@ pub type DataDestinationRequestTemplate = BoundedDestinationRequestTemplate; +/// Closed event-delivery request template. +pub type EventDestinationRequestTemplate = BoundedDestinationRequestTemplate; + +/// Values for the closed event-delivery header set. +/// +/// The product owns the signature and retry policy. This transport only binds +/// the supplied values to fixed header names and preserves their exact bytes. +pub struct EventDeliveryHeaders<'a> { + pub event_id: &'a [u8], + pub event_type: &'a [u8], + pub generation: &'a [u8], + pub attempt: &'a [u8], + pub timestamp: &'a [u8], + pub idempotency_key: &'a [u8], + pub signature: &'a [u8], +} + +impl fmt::Debug for EventDeliveryHeaders<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("EventDeliveryHeaders([REDACTED])") + } +} + +const MAX_EVENT_ID_HEADER_BYTES: usize = 128; +const MAX_EVENT_TYPE_HEADER_BYTES: usize = 256; +const MAX_EVENT_GENERATION_HEADER_BYTES: usize = 32; +const MAX_EVENT_ATTEMPT_HEADER_BYTES: usize = 32; +const MAX_EVENT_TIMESTAMP_HEADER_BYTES: usize = 64; +const MAX_EVENT_IDEMPOTENCY_KEY_HEADER_BYTES: usize = 256; +const MAX_EVENT_SIGNATURE_HEADER_BYTES: usize = MAX_DESTINATION_HEADER_VALUE_BYTES; /// Closed OAuth 2.0 client-credentials request-body encoding. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1448,6 +1500,100 @@ impl BoundedDestinationRequestTemplate { } } +impl BoundedDestinationRequestTemplate { + /// Compile the sole event-destination request shape. + /// + /// The path is fixed and the request has no query or authorization slot. + /// Header names, JSON content type, and their maximum sizes are closed by + /// this constructor. The body must already be strict canonical JSON. + pub fn event_delivery( + fixed_path: &str, + max_body_bytes: usize, + max_request_bytes: usize, + ) -> Result { + let headers = [ + HeaderTemplateInput::Exact { + name: "accept", + value: b"application/json", + }, + HeaderTemplateInput::Exact { + name: "content-type", + value: b"application/json", + }, + HeaderTemplateInput::Dynamic { + name: "x-registry-event-id", + max_value_bytes: MAX_EVENT_ID_HEADER_BYTES, + }, + HeaderTemplateInput::Dynamic { + name: "x-registry-event-type", + max_value_bytes: MAX_EVENT_TYPE_HEADER_BYTES, + }, + HeaderTemplateInput::Dynamic { + name: "x-registry-event-generation", + max_value_bytes: MAX_EVENT_GENERATION_HEADER_BYTES, + }, + HeaderTemplateInput::Dynamic { + name: "x-registry-delivery-attempt", + max_value_bytes: MAX_EVENT_ATTEMPT_HEADER_BYTES, + }, + HeaderTemplateInput::Dynamic { + name: "x-registry-event-timestamp", + max_value_bytes: MAX_EVENT_TIMESTAMP_HEADER_BYTES, + }, + HeaderTemplateInput::Dynamic { + name: "idempotency-key", + max_value_bytes: MAX_EVENT_IDEMPOTENCY_KEY_HEADER_BYTES, + }, + HeaderTemplateInput::Dynamic { + name: "x-registry-signature", + max_value_bytes: MAX_EVENT_SIGNATURE_HEADER_BYTES, + }, + ]; + Self::new_with_headers( + RequestTemplateKind::EventDelivery, + TargetTemplateInput::Fixed(fixed_path), + &[], + &headers, + DestinationAuthorizationTemplate::Forbidden, + DestinationBodyTemplate::Required { + max_bytes: max_body_bytes, + }, + max_request_bytes, + ) + } + + /// Render caller-owned event metadata and already-canonical JSON bytes. + pub fn render_event( + &self, + headers: EventDeliveryHeaders<'_>, + body: Vec, + ) -> Result { + self.render_event_zeroizing(headers, Zeroizing::new(body)) + } + + /// Render an event while retaining its body in caller-provided zeroizing storage. + pub fn render_event_zeroizing( + &self, + headers: EventDeliveryHeaders<'_>, + body: Zeroizing>, + ) -> Result { + self.render_zeroizing( + &[], + &[ + headers.event_id, + headers.event_type, + headers.generation, + headers.attempt, + headers.timestamp, + headers.idempotency_key, + headers.signature, + ], + None, + Some(body), + ) + } +} + impl BoundedDestinationRequestTemplate { /// Compile the maximum request authority available to one reviewed script allow rule. /// @@ -1520,7 +1666,10 @@ impl BoundedDestinationRequestTemplate { { return Err(DestinationRequestError::TemplateBoundsExceeded); } - if method == DestinationMethod::OAuth2ClientCredentialsPost { + if matches!( + method, + DestinationMethod::OAuth2ClientCredentialsPost | DestinationMethod::EventPost + ) { return Err(DestinationRequestError::MethodSlotMismatch); } Ok(Self { @@ -1536,6 +1685,7 @@ impl BoundedDestinationRequestTemplate { max_bytes: MAX_DESTINATION_REQUEST_BODY_BYTES, }, DestinationMethod::OAuth2ClientCredentialsPost => unreachable!(), + DestinationMethod::EventPost => unreachable!(), }, max_target_bytes: MAX_DESTINATION_TARGET_BYTES, max_request_bytes, @@ -1767,7 +1917,10 @@ impl BoundedDestinationRequestTemplate { body: DestinationBodyTemplate, max_request_bytes: usize, ) -> Result { - if method == DestinationMethod::OAuth2ClientCredentialsPost { + if matches!( + method, + DestinationMethod::OAuth2ClientCredentialsPost | DestinationMethod::EventPost + ) { return Err(DestinationRequestError::MethodSlotMismatch); } let headers = headers @@ -1804,7 +1957,10 @@ impl BoundedDestinationRequestTemplate { body: DestinationBodyTemplate, max_request_bytes: usize, ) -> Result { - if method == DestinationMethod::OAuth2ClientCredentialsPost { + if matches!( + method, + DestinationMethod::OAuth2ClientCredentialsPost | DestinationMethod::EventPost + ) { return Err(DestinationRequestError::MethodSlotMismatch); } let headers = headers @@ -1838,7 +1994,10 @@ impl BoundedDestinationRequestTemplate { body: DestinationBodyTemplate, max_request_bytes: usize, ) -> Result { - if method == DestinationMethod::OAuth2ClientCredentialsPost { + if matches!( + method, + DestinationMethod::OAuth2ClientCredentialsPost | DestinationMethod::EventPost + ) { return Err(DestinationRequestError::MethodSlotMismatch); } let headers = headers @@ -1870,6 +2029,7 @@ impl BoundedDestinationRequestTemplate { RequestTemplateKind::OAuth2ClientCredentials(format) => { (DestinationMethod::OAuth2ClientCredentialsPost, Some(format)) } + RequestTemplateKind::EventDelivery => (DestinationMethod::EventPost, None), }; let (fixed_path, path_segment_max_bytes) = match target { TargetTemplateInput::Fixed(fixed_path) => (fixed_path, None), @@ -1903,12 +2063,17 @@ impl BoundedDestinationRequestTemplate { { return Err(DestinationRequestError::MethodSlotMismatch); } + if method == DestinationMethod::EventPost + && (!query.is_empty() || !closed_event_headers(headers)) + { + return Err(DestinationRequestError::MethodSlotMismatch); + } let max_body_bytes = body.max_bytes(); if max_body_bytes > MAX_DESTINATION_REQUEST_BODY_BYTES { return Err(DestinationRequestError::BodyTooLarge); } match method { - DestinationMethod::Get if S::CREDENTIAL_EXCHANGE => { + DestinationMethod::Get if S::CREDENTIAL_EXCHANGE || S::EVENT_DELIVERY => { return Err(DestinationRequestError::MethodSlotMismatch); } DestinationMethod::Get if body != DestinationBodyTemplate::Forbidden => { @@ -1916,6 +2081,14 @@ impl BoundedDestinationRequestTemplate { } DestinationMethod::ReviewedReadOnlyPost if S::CREDENTIAL_EXCHANGE + || S::EVENT_DELIVERY + || !matches!(body, DestinationBodyTemplate::Required { max_bytes } if max_bytes > 0) => + { + return Err(DestinationRequestError::MethodSlotMismatch); + } + DestinationMethod::EventPost + if !S::EVENT_DELIVERY + || authorization != DestinationAuthorizationTemplate::Forbidden || !matches!(body, DestinationBodyTemplate::Required { max_bytes } if max_bytes > 0) => { return Err(DestinationRequestError::MethodSlotMismatch); @@ -2134,6 +2307,11 @@ impl BoundedDestinationRequestTemplate { if query_values.len() != self.query.len() || header_values.len() != dynamic_header_count { return Err(DestinationRequestError::TemplateValueCountMismatch); } + if self.method == DestinationMethod::EventPost + && header_values.iter().any(|value| value.is_empty()) + { + return Err(DestinationRequestError::InvalidHeaderValue); + } if self .query .iter() @@ -2170,6 +2348,18 @@ impl BoundedDestinationRequestTemplate { (DestinationBodyTemplate::Required { .. }, Some(value)) if !value.is_empty() => {} _ => return Err(DestinationRequestError::BodyPresenceMismatch), } + if self.method == DestinationMethod::EventPost { + let event_body = body + .as_ref() + .ok_or(DestinationRequestError::BodyPresenceMismatch)?; + let value = parse_json_strict(event_body) + .map_err(|_| DestinationRequestError::InvalidEventBody)?; + let canonical = + canonicalize_json(&value).map_err(|_| DestinationRequestError::InvalidEventBody)?; + if canonical.as_slice() != event_body.as_slice() { + return Err(DestinationRequestError::InvalidEventBody); + } + } let mut target = BoundedTargetWriter::new(self.max_target_bytes); target.extend_from_slice(self.fixed_path.as_bytes())?; @@ -2256,6 +2446,58 @@ fn closed_oauth_headers( accept && content_type } +fn closed_event_headers(headers: &[HeaderTemplateInput<'_>]) -> bool { + const EXACT: [(&str, usize, Option<&[u8]>); 9] = [ + ("accept", 0, Some(b"application/json")), + ("content-type", 0, Some(b"application/json")), + ("x-registry-event-id", MAX_EVENT_ID_HEADER_BYTES, None), + ("x-registry-event-type", MAX_EVENT_TYPE_HEADER_BYTES, None), + ( + "x-registry-event-generation", + MAX_EVENT_GENERATION_HEADER_BYTES, + None, + ), + ( + "x-registry-delivery-attempt", + MAX_EVENT_ATTEMPT_HEADER_BYTES, + None, + ), + ( + "x-registry-event-timestamp", + MAX_EVENT_TIMESTAMP_HEADER_BYTES, + None, + ), + ( + "idempotency-key", + MAX_EVENT_IDEMPOTENCY_KEY_HEADER_BYTES, + None, + ), + ( + "x-registry-signature", + MAX_EVENT_SIGNATURE_HEADER_BYTES, + None, + ), + ]; + headers.len() == EXACT.len() + && headers + .iter() + .zip(EXACT) + .all(|(header, expected)| match (header, expected) { + ( + HeaderTemplateInput::Exact { name, value }, + (expected_name, _, Some(expected_value)), + ) => *name == expected_name && *value == expected_value, + ( + HeaderTemplateInput::Dynamic { + name, + max_value_bytes, + }, + (expected_name, expected_max, None), + ) => *name == expected_name && *max_value_bytes == expected_max, + _ => false, + }) +} + struct BoundedTargetWriter { bytes: Zeroizing>, limit: usize, @@ -2581,6 +2823,8 @@ struct SensitiveHeader { pub type DataDestinationRequest = BoundedDestinationRequest; /// Credential-exchange operation request. pub type CredentialDestinationRequest = BoundedDestinationRequest; +/// Event-delivery operation request. +pub type EventDestinationRequest = BoundedDestinationRequest; impl fmt::Debug for BoundedDestinationRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -2642,7 +2886,8 @@ impl BoundedDestinationRequest { let method = match self.method { DestinationMethod::Get => "GET", DestinationMethod::ReviewedReadOnlyPost - | DestinationMethod::OAuth2ClientCredentialsPost => "POST", + | DestinationMethod::OAuth2ClientCredentialsPost + | DestinationMethod::EventPost => "POST", }; let target = std::str::from_utf8(&self.target) .expect("bounded destination targets are validated UTF-8"); @@ -2808,6 +3053,8 @@ pub enum DestinationRequestError { AuthorizationShapeMismatch, #[error("operation body presence does not match the compiled request template")] BodyPresenceMismatch, + #[error("event request body is not strict canonical JSON")] + InvalidEventBody, } /// Value-free resolve, destination-policy, and transport failures. @@ -2875,6 +3122,8 @@ pub struct BoundedDestinationResponse { pub type DataDestinationResponse = BoundedDestinationResponse; /// Credential-exchange response. pub type CredentialDestinationResponse = BoundedDestinationResponse; +/// Event-delivery response. +pub type EventDestinationResponse = BoundedDestinationResponse; impl fmt::Debug for BoundedDestinationResponse { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -3113,6 +3362,8 @@ pub struct BoundedDestinationBody { pub type DataDestinationBody = BoundedDestinationBody; /// Credential-exchange response bytes. pub type CredentialDestinationBody = BoundedDestinationBody; +/// Event-delivery response bytes. +pub type EventDestinationBody = BoundedDestinationBody; impl fmt::Debug for BoundedDestinationBody { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -3846,6 +4097,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; + use axum::extract::OriginalUri; use axum::routing::{get, post}; use axum::Router; use proptest::prelude::*; @@ -3882,6 +4134,18 @@ mod tests { .expect("production policy validates") } + fn event_headers<'a>() -> EventDeliveryHeaders<'a> { + EventDeliveryHeaders { + event_id: b"018f1f47-a922-7e31-8000-000000000001", + event_type: b"widget.tombstoned", + generation: b"42", + attempt: b"1", + timestamp: b"2026-08-30T02:03:04Z", + idempotency_key: b"delivery-018f1f47-a922-7e31-8000-000000000001", + signature: b"v1=caller-owned-signature", + } + } + fn pem(label: &str, der: &[u8]) -> String { use base64::engine::general_purpose::STANDARD; use base64::Engine as _; @@ -5863,6 +6127,353 @@ mod tests { ); } + #[test] + fn event_post_is_confined_to_its_closed_slot_and_canonical_shape() { + let canonical = br#"{"event":"widget.tombstoned","generation":42}"#; + let template = + EventDestinationRequestTemplate::event_delivery("/hooks/registry", 256, 10_240) + .expect("closed event template"); + let request = template + .render_event(event_headers(), canonical.to_vec()) + .expect("canonical event renders"); + let diagnostic = format!("{template:?} {request:?} {:?}", event_headers()); + for canary in [ + "hooks/registry", + "widget.tombstoned", + "delivery-018f1f47", + "caller-owned-signature", + "generation\":42", + ] { + assert!(!diagnostic.contains(canary)); + } + + assert_eq!( + DataDestinationRequestTemplate::new( + DestinationMethod::EventPost, + "/hooks/registry", + &[], + &[], + DestinationAuthorizationTemplate::Forbidden, + DestinationBodyTemplate::Required { max_bytes: 256 }, + 512, + ) + .unwrap_err(), + DestinationRequestError::MethodSlotMismatch + ); + for method in [ + DestinationMethod::Get, + DestinationMethod::ReviewedReadOnlyPost, + ] { + assert_eq!( + EventDestinationRequestTemplate::new( + method, + "/hooks/registry", + &[], + &[], + DestinationAuthorizationTemplate::Forbidden, + if method == DestinationMethod::Get { + DestinationBodyTemplate::Forbidden + } else { + DestinationBodyTemplate::Required { max_bytes: 256 } + }, + 512, + ) + .unwrap_err(), + DestinationRequestError::MethodSlotMismatch + ); + } + assert_eq!( + EventDestinationRequestTemplate::new( + DestinationMethod::OAuth2ClientCredentialsPost, + "/oauth/token", + &[], + &[], + DestinationAuthorizationTemplate::Forbidden, + DestinationBodyTemplate::Required { max_bytes: 256 }, + 512, + ) + .unwrap_err(), + DestinationRequestError::MethodSlotMismatch + ); + + assert_eq!( + template + .render_event(event_headers(), br#"{ "generation": 42 }"#.to_vec()) + .unwrap_err(), + DestinationRequestError::InvalidEventBody + ); + let mut unsafe_headers = event_headers(); + unsafe_headers.signature = b"v1=secret\r\nx-injected: yes"; + assert_eq!( + template + .render_event(unsafe_headers, canonical.to_vec()) + .unwrap_err(), + DestinationRequestError::InvalidHeaderValue + ); + assert_eq!( + template + .render_event(event_headers(), vec![b'x'; 257]) + .unwrap_err(), + DestinationRequestError::TemplateBoundsExceeded + ); + + let bad_name = EventDestinationRequestTemplate::new( + DestinationMethod::Get, + "/hooks/registry", + &[], + &[("bad header", 16)], + DestinationAuthorizationTemplate::Forbidden, + DestinationBodyTemplate::Forbidden, + 512, + ) + .unwrap_err(); + assert_eq!(bad_name, DestinationRequestError::MethodSlotMismatch); + + let userinfo_canary = "event-userinfo-canary"; + let error = EventDestinationPolicy::new( + "event-destination", + &format!("https://{userinfo_canary}:password@example.test/"), + DestinationProfile::ProductionHttps, + &[], + ) + .unwrap_err(); + assert_eq!(error, DestinationPolicyError::OriginUserInfoDenied); + assert!(!format!("{error:?} {error}").contains(userinfo_canary)); + } + + #[tokio::test] + async fn event_send_preserves_exact_origin_path_body_and_closed_headers() { + let captured = Arc::new(Mutex::new(None)); + let route_captured = Arc::clone(&captured); + let app = Router::new().route( + "/hooks/registry", + post( + move |OriginalUri(uri): OriginalUri, headers: HeaderMap, body: Bytes| { + let route_captured = Arc::clone(&route_captured); + async move { + let names = [ + "accept", + "content-type", + "x-registry-event-id", + "x-registry-event-type", + "x-registry-event-generation", + "x-registry-delivery-attempt", + "x-registry-event-timestamp", + "idempotency-key", + "x-registry-signature", + ]; + let exact = uri + .path_and_query() + .is_some_and(|value| value.as_str() == "/hooks/registry") + && headers + .get(HOST) + .and_then(|value| value.to_str().ok()) + .is_some_and(|host| host.starts_with("localhost:")) + && names.iter().all(|name| headers.contains_key(*name)) + && headers.get("accept").and_then(|value| value.to_str().ok()) + == Some("application/json") + && headers + .get("content-type") + .and_then(|value| value.to_str().ok()) + == Some("application/json") + && headers + .get("x-registry-event-id") + .and_then(|value| value.to_str().ok()) + == Some("018f1f47-a922-7e31-8000-000000000001") + && headers + .get("x-registry-event-type") + .and_then(|value| value.to_str().ok()) + == Some("widget.tombstoned") + && headers + .get("x-registry-event-generation") + .and_then(|value| value.to_str().ok()) + == Some("42") + && headers + .get("x-registry-delivery-attempt") + .and_then(|value| value.to_str().ok()) + == Some("1") + && headers + .get("x-registry-event-timestamp") + .and_then(|value| value.to_str().ok()) + == Some("2026-08-30T02:03:04Z") + && headers + .get("idempotency-key") + .and_then(|value| value.to_str().ok()) + == Some("delivery-018f1f47-a922-7e31-8000-000000000001") + && headers + .get("x-registry-signature") + .and_then(|value| value.to_str().ok()) + == Some("v1=caller-owned-signature") + && !headers.contains_key(AUTHORIZATION); + *route_captured.lock().expect("capture lock") = + Some((exact, body.to_vec())); + StatusCode::NO_CONTENT + } + }, + ), + ); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind event test server"); + let address = listener.local_addr().expect("event test address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve event test app"); + }); + + let policy = EventDestinationPolicy::new( + "dev-events", + &format!("http://localhost:{}/", address.port()), + DestinationProfile::LoopbackDevelopmentHttp, + &[], + ) + .expect("development event policy"); + let template = + EventDestinationRequestTemplate::event_delivery("/hooks/registry", 1_024, 10_240) + .expect("closed event template"); + let expected = br#"{"event":"widget.tombstoned","generation":42}"#; + let request = template + .render_event(event_headers(), expected.to_vec()) + .expect("event request renders"); + let resolver = FakeResolver { + answers: vec![address], + calls: AtomicUsize::new(0), + }; + let response = policy + .send_with_resolver( + request, + Duration::from_secs(2), + &resolver, + TransportTrust::System, + ) + .await + .expect("event send succeeds"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); + assert_eq!( + captured.lock().expect("capture lock").as_ref(), + Some(&(true, expected.to_vec())) + ); + } + + #[test] + fn event_destination_rejects_metadata_private_and_substituted_dns_answers() { + let policy = EventDestinationPolicy::new( + "production-events", + "https://events.example.test/", + DestinationProfile::ProductionHttps, + &[], + ) + .expect("production event policy"); + + for (answers, expected) in [ + ( + vec![answer("169.254.169.254", 443)], + DestinationSendError::CloudMetadataDenied, + ), + ( + vec![answer("93.184.216.34", 443), answer("10.0.0.1", 443)], + DestinationSendError::PrivateAddressNotAllowed, + ), + ( + vec![answer("93.184.216.34", 444)], + DestinationSendError::ResolverPortMismatch, + ), + ] { + let answers = ResolvedAnswers::try_collect(answers).expect("bounded DNS answers"); + assert!(matches!( + policy.classify_answers(answers), + Err(actual) if actual == expected + )); + } + } + + #[tokio::test] + async fn event_redirect_and_oversized_response_remain_bounded() { + let redirected = Arc::new(AtomicUsize::new(0)); + let route_redirected = Arc::clone(&redirected); + let app = Router::new() + .route( + "/hooks/redirect", + post(|| async { + ( + StatusCode::FOUND, + [(http::header::LOCATION, "/redirect-target")], + ) + }), + ) + .route( + "/redirect-target", + post(move || { + let route_redirected = Arc::clone(&route_redirected); + async move { + route_redirected.fetch_add(1, Ordering::SeqCst); + StatusCode::NO_CONTENT + } + }), + ) + .route("/hooks/large", post(|| async { vec![b'x'; 65] })); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind event bounds server"); + let address = listener.local_addr().expect("event bounds address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve event bounds app"); + }); + let policy = EventDestinationPolicy::new( + "dev-event-bounds", + &format!("http://localhost:{}/", address.port()), + DestinationProfile::LoopbackDevelopmentHttp, + &[], + ) + .expect("development event policy"); + let resolver = FakeResolver { + answers: vec![address], + calls: AtomicUsize::new(0), + }; + let body = br#"{"event":"widget.tombstoned"}"#; + + let redirect_request = + EventDestinationRequestTemplate::event_delivery("/hooks/redirect", 256, 10_240) + .expect("redirect event template") + .render_event(event_headers(), body.to_vec()) + .expect("redirect event request"); + let redirect_response = policy + .send_with_resolver( + redirect_request, + Duration::from_secs(2), + &resolver, + TransportTrust::System, + ) + .await + .expect("redirect response is returned without following"); + assert_eq!(redirect_response.status(), StatusCode::FOUND); + assert_eq!(redirected.load(Ordering::SeqCst), 0); + + let large_request = + EventDestinationRequestTemplate::event_delivery("/hooks/large", 256, 10_240) + .expect("large-response event template") + .render_event(event_headers(), body.to_vec()) + .expect("large-response event request"); + let large_response = policy + .send_with_resolver( + large_request, + Duration::from_secs(2), + &resolver, + TransportTrust::System, + ) + .await + .expect("large response headers accepted"); + assert_eq!( + large_response.read_bounded(64).await.unwrap_err(), + DestinationResponseError::BodyTooLarge + ); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn pinned_domain_preserves_tls_san_identity() { let (address, root, _, server) = spawn_tls_server("registry.test").await; diff --git a/crates/registry-server/Cargo.toml b/crates/registry-server/Cargo.toml new file mode 100644 index 0000000000..7e36a8504d --- /dev/null +++ b/crates/registry-server/Cargo.toml @@ -0,0 +1,193 @@ +[package] +name = "registry-server" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Domain-neutral compiled registry server and runtime." +repository.workspace = true +publish = false +readme = "README.md" + +[[bin]] +name = "registry-server" +path = "src/main.rs" +required-features = ["runtime"] + +[[test]] +name = "postgres_kernel" +path = "tests/postgres_kernel.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_tls" +path = "tests/postgres_tls.rs" +required-features = ["postgres-tls-test"] + +[[test]] +name = "postgres_mutation" +path = "tests/postgres_mutation.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_webhook_outbox" +path = "tests/postgres_webhook_outbox.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_webhook_delivery" +path = "tests/postgres_webhook_delivery.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_compiled_schema" +path = "tests/postgres_compiled_schema.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_partial_unique" +path = "tests/postgres_partial_unique.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_package" +path = "tests/postgres_package.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_migration" +path = "tests/postgres_migration.rs" +required-features = ["postgres-test", "tooling"] + +[[test]] +name = "postgres_read" +path = "tests/postgres_read.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_revision_http" +path = "tests/postgres_revision_http.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_batch" +path = "tests/postgres_batch.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_pilot_acceptance" +path = "tests/postgres_pilot_acceptance.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_constraint_races" +path = "tests/postgres_constraint_races.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_data_farmer" +path = "tests/postgres_data_farmer.rs" +required-features = ["postgres-test"] + +[[test]] +name = "postgres_data_export" +path = "tests/postgres_data_export.rs" +required-features = ["postgres-test"] + +[[test]] +name = "fixture_tooling" +path = "tests/fixture_tooling.rs" +required-features = ["runtime", "tooling"] + +[[test]] +name = "postgres_fixture_journeys" +path = "tests/postgres_fixture_journeys.rs" +required-features = ["postgres-test", "tooling"] + +[lints] +workspace = true + +[features] +default = [] +runtime = [ + "dep:axum", + "dep:base64", + "dep:clap", + "dep:chacha20poly1305", + "dep:deadpool-postgres", + "dep:getrandom", + "dep:hex", + "dep:hmac", + "dep:ipnet", + "dep:jsonwebtoken", + "dep:registry-platform-audit", + "dep:registry-platform-authcommon", + "dep:registry-platform-buildinfo", + "dep:registry-platform-config", + "dep:registry-platform-crypto", + "dep:registry-platform-httpsec", + "dep:registry-platform-httputil", + "dep:registry-platform-oidc", + "dep:rustls", + "dep:rustix", + "dep:tokio", + "dep:tokio-postgres", + "dep:tokio-postgres-rustls", + "dep:tracing", + "dep:tracing-subscriber", + "dep:zeroize", +] +postgres-test = ["runtime", "dep:tower", "registry-platform-httputil/test-support"] +postgres-tls-test = ["runtime"] +schema = ["dep:schemars"] +tooling = ["dep:pg_query", "dep:tempfile", "dep:tower"] + +[dependencies] +axum = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } +chacha20poly1305 = { workspace = true, optional = true } +clap = { workspace = true, optional = true } +deadpool-postgres = { workspace = true, optional = true } +getrandom = { workspace = true, optional = true } +hex = { workspace = true, optional = true } +hmac = { workspace = true, optional = true } +ipnet = { workspace = true, optional = true } +jsonwebtoken = { workspace = true, optional = true } +registry-platform-canonical-json.workspace = true +registry-manifest-core.workspace = true +registry-platform-audit = { workspace = true, optional = true } +registry-platform-authcommon = { workspace = true, optional = true } +registry-platform-buildinfo = { workspace = true, optional = true } +registry-platform-config = { workspace = true, optional = true } +registry-platform-crypto = { workspace = true, optional = true } +registry-platform-httpsec = { workspace = true, features = ["server"], optional = true } +registry-platform-httputil = { workspace = true, optional = true } +registry-platform-oidc = { workspace = true, optional = true } +jsonschema.workspace = true +pg_query = { workspace = true, optional = true } +rustls = { workspace = true, optional = true } +rustix = { workspace = true, optional = true } +schemars = { workspace = true, optional = true } +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +serde_path_to_error.workspace = true +sha2.workspace = true +subtle.workspace = true +tempfile = { workspace = true, optional = true } +thiserror.workspace = true +time.workspace = true +tokio = { workspace = true, optional = true } +tokio-postgres = { workspace = true, optional = true } +tokio-postgres-rustls = { workspace = true, optional = true } +tower = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true } +uuid.workspace = true +zeroize = { workspace = true, optional = true } + +[dev-dependencies] +rcgen.workspace = true +registry-platform-testing = { workspace = true, features = ["test-utils"] } +tokio-rustls.workspace = true +tower.workspace = true diff --git a/crates/registry-server/README.md b/crates/registry-server/README.md new file mode 100644 index 0000000000..c9aa67adf5 --- /dev/null +++ b/crates/registry-server/README.md @@ -0,0 +1,12 @@ +# Registry Server + +Registry Server is the domain-neutral, configuration-compiled Registry Stack +system of record. The crate owns the governed model, deterministic compiler, +generated contract artifacts, PostgreSQL runtime, and HTTP service. + +The default feature set is I/O-free so authoring tools can compile and inspect +a Registry project without initializing runtime resources. The `runtime` +feature enables the server binary and runtime integrations. + +Domain concepts are configuration. Production code must not embed household, +farmer, disability, business, or other adopter-specific record types. diff --git a/crates/registry-server/examples/authoring-schema.rs b/crates/registry-server/examples/authoring-schema.rs new file mode 100644 index 0000000000..aa34cf8894 --- /dev/null +++ b/crates/registry-server/examples/authoring-schema.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(feature = "schema")] +use std::{env, fs, path::PathBuf, process::ExitCode}; + +#[cfg(feature = "schema")] +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("authoring schema generation failed: {message}"); + ExitCode::FAILURE + } + } +} + +#[cfg(feature = "schema")] +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: authoring-schema --output ".to_owned()); + } + let output = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| "usage: authoring-schema --output ".to_owned())?; + if arguments.next().is_some() { + return Err("usage: authoring-schema --output ".to_owned()); + } + + let documents = registry_server::schema::documents() + .map_err(|error| format!("the authoring schema could not be generated: {error}"))?; + fs::create_dir_all(&output) + .map_err(|error| format!("failed to create {}: {error}", output.display()))?; + for (name, contents) in documents { + let path = output.join(name); + fs::write(&path, contents) + .map_err(|error| format!("failed to write {}: {error}", path.display()))?; + } + Ok(()) +} + +#[cfg(not(feature = "schema"))] +fn main() -> std::process::ExitCode { + eprintln!("authoring-schema requires the registry-server schema feature"); + std::process::ExitCode::from(2) +} diff --git a/crates/registry-server/src/api/context.rs b/crates/registry-server/src/api/context.rs new file mode 100644 index 0000000000..34081842f1 --- /dev/null +++ b/crates/registry-server/src/api/context.rs @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +const MAX_DIRECT_VALUE_BYTES: usize = 512; +const MAX_STRING_SET_VALUES: usize = 64; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VerifiedContextError { + EmptyClaimName, + InvalidDirectValue, + TooManyClaimValues, + DuplicateClaimValue, +} + +/// A claim value accepted only after the configured OIDC verifier has +/// authenticated the token. Request headers and query parameters must never be +/// converted into this type. +#[derive(Clone, Eq, PartialEq)] +pub enum VerifiedClaimValue { + DirectString(String), + DirectStringSet(BTreeSet), +} + +impl VerifiedClaimValue { + pub fn direct_string(value: impl Into) -> Result { + Ok(Self::DirectString(validate_value(value.into())?)) + } + + pub fn direct_string_set(values: I) -> Result + where + I: IntoIterator, + S: Into, + { + let mut result = BTreeSet::new(); + for value in values { + if result.len() >= MAX_STRING_SET_VALUES { + return Err(VerifiedContextError::TooManyClaimValues); + } + if !result.insert(validate_value(value.into())?) { + return Err(VerifiedContextError::DuplicateClaimValue); + } + } + if result.is_empty() { + return Err(VerifiedContextError::InvalidDirectValue); + } + Ok(Self::DirectStringSet(result)) + } + + pub(crate) fn values(&self) -> BTreeSet { + match self { + Self::DirectString(value) => BTreeSet::from([value.clone()]), + Self::DirectStringSet(values) => values.clone(), + } + } +} + +impl fmt::Debug for VerifiedClaimValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +/// Authority material extracted from one already-verified access token. +/// +/// The constructor deliberately requires the configured principal claim name +/// and its direct string value. It does not inspect `sub`, `client_id`, `azp`, +/// or any other fallback claim. +#[derive(Clone, Eq, PartialEq)] +pub struct VerifiedRequestClaims { + principal_claim: Option, + principal: Option, + scopes: BTreeSet, + purpose: Option, + direct_claims: BTreeMap, +} + +impl VerifiedRequestClaims { + pub fn authenticated( + principal_claim: impl Into, + principal: impl Into, + scopes: BTreeSet, + purpose: Option, + direct_claims: BTreeMap, + ) -> Result { + let principal_claim = validate_claim_name(principal_claim.into())?; + let principal = validate_value(principal.into())?; + let purpose = purpose.map(validate_value).transpose()?; + for name in direct_claims.keys() { + validate_claim_name(name.clone())?; + } + Ok(Self { + principal_claim: Some(principal_claim), + principal: Some(principal), + scopes, + purpose, + direct_claims, + }) + } + + #[must_use] + pub fn anonymous() -> Self { + Self { + principal_claim: None, + principal: None, + scopes: BTreeSet::new(), + purpose: None, + direct_claims: BTreeMap::new(), + } + } + + pub(crate) fn principal_claim(&self) -> Option<&str> { + self.principal_claim.as_deref() + } + + pub(crate) fn principal(&self) -> Option<&str> { + self.principal.as_deref() + } + + pub(crate) fn has_scope(&self, scope: &str) -> bool { + self.scopes.contains(scope) + } + + pub(crate) fn purpose(&self) -> Option<&str> { + self.purpose.as_deref() + } + + pub(crate) fn direct_claim(&self, name: &str) -> Option<&VerifiedClaimValue> { + self.direct_claims.get(name) + } +} + +impl fmt::Debug for VerifiedRequestClaims { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedRequestClaims") + .field("principal_claim", &self.principal_claim) + .field("principal", &self.principal.as_ref().map(|_| "")) + .field("scope_count", &self.scopes.len()) + .field("purpose", &self.purpose.as_ref().map(|_| "")) + .field("direct_claims", &self.direct_claims.keys()) + .finish() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RowBoundaryOperator { + Equals, + In, +} + +#[derive(Clone, Eq, PartialEq)] +pub struct VerifiedRowBoundary { + field: String, + operator: RowBoundaryOperator, + values: BTreeSet, +} + +impl VerifiedRowBoundary { + pub(super) fn new( + field: String, + operator: RowBoundaryOperator, + values: BTreeSet, + ) -> Self { + Self { + field, + operator, + values, + } + } + + #[must_use] + pub fn field(&self) -> &str { + &self.field + } + + #[must_use] + pub fn operator(&self) -> &RowBoundaryOperator { + &self.operator + } + + #[must_use] + pub fn values(&self) -> &BTreeSet { + &self.values + } +} + +impl fmt::Debug for VerifiedRowBoundary { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedRowBoundary") + .field("field", &self.field) + .field("operator", &self.operator) + .field("values", &"") + .finish() + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct AuthorizedRequestContext { + principal: Option, + purpose: Option, + selected_profile: String, + row_boundaries: Vec, +} + +impl AuthorizedRequestContext { + pub(crate) fn new( + principal: Option, + purpose: Option, + selected_profile: String, + row_boundaries: Vec, + ) -> Self { + Self { + principal, + purpose, + selected_profile, + row_boundaries, + } + } + + #[must_use] + pub fn principal(&self) -> Option<&str> { + self.principal.as_deref() + } + + #[must_use] + pub fn purpose(&self) -> Option<&str> { + self.purpose.as_deref() + } + + #[must_use] + pub fn selected_profile(&self) -> &str { + &self.selected_profile + } + + #[must_use] + pub fn row_boundaries(&self) -> &[VerifiedRowBoundary] { + &self.row_boundaries + } +} + +impl fmt::Debug for AuthorizedRequestContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizedRequestContext") + .field("principal", &self.principal.as_ref().map(|_| "")) + .field("purpose", &self.purpose.as_ref().map(|_| "")) + .field("selected_profile", &self.selected_profile) + .field("row_boundaries", &self.row_boundaries) + .finish() + } +} + +fn validate_claim_name(value: String) -> Result { + if value.is_empty() || value.len() > 128 || !value.is_ascii() { + return Err(VerifiedContextError::EmptyClaimName); + } + Ok(value) +} + +fn validate_value(value: String) -> Result { + if value.is_empty() + || value.len() > MAX_DIRECT_VALUE_BYTES + || value.chars().any(char::is_control) + { + return Err(VerifiedContextError::InvalidDirectValue); + } + Ok(value) +} diff --git a/crates/registry-server/src/api/mod.rs b/crates/registry-server/src/api/mod.rs new file mode 100644 index 0000000000..e23c0a9b08 --- /dev/null +++ b/crates/registry-server/src/api/mod.rs @@ -0,0 +1,2672 @@ +// SPDX-License-Identifier: Apache-2.0 +//! HTTP surface compiled from one immutable Registry inventory. + +mod context; +mod service; + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt; +use std::sync::Arc; + +use axum::body::{to_bytes, Body}; +use axum::extract::{Path, RawQuery, State}; +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_MATCH}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, patch, post}; +use axum::{middleware, Extension, Json, Router}; +use registry_platform_canonical_json::parse_json_strict; +use registry_platform_httpsec::{security_headers, CspBuilder, Problem}; +use serde_json::{json, Map, Value}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + +pub use context::{ + AuthorizedRequestContext, RowBoundaryOperator, VerifiedClaimValue, VerifiedContextError, + VerifiedRequestClaims, VerifiedRowBoundary, +}; +pub use service::{ + BatchMutationInput, CompiledReadQuery, ConditionalMutationInput, HeldReadResponse, HttpService, + ReadFilterClause, ReadRuntimeIdentity, ReadServiceError, ReadinessProbe, RecordReadRefusal, + RecordReadRequest, RecordReadService, RevisionReadRefusal, RevisionReadRequest, + RevisionReadService, ServiceFuture, +}; + +use crate::auth::{authenticate_request, RegistryAuthenticator}; +use crate::contract::{AccessProfileSource, BoundaryOperator, Classification, Operation}; +use crate::cursor::{now_unix_seconds, CursorBinding, CursorError}; +use crate::idempotency::{HeldResponse, PermittedResponseHeader}; +use crate::model::{ + CompiledEntity, CompiledMetadataEntity, CompiledMetadataEntry, CompiledQueryFilterOperator, + CompiledQueryKind, CompiledQueryOperation, CompiledRevisionKind, CompiledRoute, + MAX_REVISION_HISTORY_RECORDS, +}; +use crate::mutation::{parse_json_patch_document, BatchMutationItem, MutationError}; +use uuid::Uuid; + +const MAX_FIELDS: usize = 128; +const MAX_FIELD_BYTES: usize = 128; +const MAX_MUTATION_BODY_BYTES: usize = 2 * 1024 * 1024; +const MAX_IDEMPOTENCY_KEY_BYTES: usize = 256; +const MAX_RAW_QUERY_BYTES: usize = 16 * 1024; +const MAX_FILTER_CLAUSES: usize = 32; +const MAX_IN_VALUES: usize = 100; + +/// Construct the low-level route set for callers that already hold verified +/// claims. Production network listeners must use [`authenticated_router`]. +/// +/// This seam preserves focused authorization and record-kernel tests without +/// allowing request headers or query values to construct authority. +pub fn router(service: Arc) -> Router { + route_set(service).layer(security_headers(CspBuilder::restrictive())) +} + +fn route_set(service: Arc) -> Router { + let mut app = Router::new() + .route("/healthz", get(health)) + .route("/ready", get(ready)) + .route("/openapi.json", get(openapi)) + .route("/v1/registry", get(registry_metadata)) + .route("/v1/schemas/{entity_id}", get(entity_schema)); + + for route in &service.registry.routes().routes { + app = match route.operation { + Operation::Get | Operation::List => app.route( + &route.path, + get(read_dispatch).layer(Extension(route.clone())), + ), + Operation::Revisions if service.revisions.is_some() => app.route( + &route.path, + get(revision_dispatch).layer(Extension(route.clone())), + ), + Operation::Create if service.mutations.is_some() => app.route( + &route.path, + post(create_dispatch).layer(Extension(route.clone())), + ), + Operation::Batch if service.mutations.is_some() => app.route( + &route.path, + post(batch_dispatch).layer(Extension(route.clone())), + ), + Operation::Patch + if service.mutations.is_some() + && service + .registry + .entities() + .get(&route.entity_id) + .is_some_and(|entity| { + entity.mutation_mode == crate::contract::MutationMode::Mutable + }) => + { + app.route( + &route.path, + patch(patch_dispatch).layer(Extension(route.clone())), + ) + } + Operation::Tombstone + if service.mutations.is_some() + && service + .registry + .entities() + .get(&route.entity_id) + .is_some_and(|entity| { + entity.mutation_mode == crate::contract::MutationMode::Mutable + && entity.tombstone + }) => + { + app.route( + &route.path, + delete(tombstone_dispatch).layer(Extension(route.clone())), + ) + } + _ => app, + }; + } + + app.fallback(not_found) + .method_not_allowed_fallback(not_found) + .with_state(service) +} + +/// Construct the production network router. Bearer admission and complete +/// configured OIDC verification wrap every route, including anonymous and +/// discovery surfaces, so an invalid presented credential never downgrades to +/// anonymous access. +pub fn authenticated_router( + service: Arc, + authenticator: Arc, +) -> Router { + route_set(service) + .layer(middleware::from_fn_with_state( + authenticator, + authenticate_request, + )) + .layer(security_headers(CspBuilder::restrictive())) +} + +async fn health() -> Response { + Json(json!({"status": "alive"})).into_response() +} + +async fn ready(State(service): State>) -> Response { + if service.readiness.is_ready().await { + Json(json!({"status": "ready"})).into_response() + } else { + fixed_problem( + StatusCode::SERVICE_UNAVAILABLE, + "runtime.not_ready", + "Registry runtime is not ready.", + ) + } +} + +async fn openapi( + State(service): State>, + claims: Option>, + RawQuery(raw_query): RawQuery, +) -> Response { + let Ok(options) = QueryOptions::parse(raw_query.as_deref(), false) else { + return concealed(); + }; + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let visible = visible_surfaces(&service, &claims, &options); + if options.access_profile.is_some() && visible.is_empty() { + return concealed(); + } + + let mut paths = Map::new(); + let mut readable_by_entity: BTreeMap> = BTreeMap::new(); + for surface in &visible { + let path = paths + .entry(surface.route.path.clone()) + .or_insert_with(|| Value::Object(Map::new())); + let Value::Object(methods) = path else { + unreachable!("OpenAPI paths are objects") + }; + let mut operation = Map::from_iter([ + ("operationId".to_owned(), json!(surface.route.id)), + ( + "x-registry-entity".to_owned(), + json!(surface.route.entity_id), + ), + ( + "x-registry-operation".to_owned(), + json!(operation_name(surface.route.operation)), + ), + ( + "x-registry-accessProfile".to_owned(), + json!(surface.context.selected_profile()), + ), + ( + "responses".to_owned(), + json!({"200": {"description": "Operation completed"}}), + ), + ]); + if let Some(kind) = surface.route.query_kind { + operation.insert( + "x-registry-queryKind".to_owned(), + Value::String(query_kind_name(kind).to_owned()), + ); + operation.insert("parameters".to_owned(), query_parameters(kind)); + } else if let Some(kind) = surface.route.revision_kind { + operation.insert("parameters".to_owned(), revision_parameters(kind)); + operation.insert( + "x-registry-maximumRecords".to_owned(), + json!(surface.route.maximum_records), + ); + } else if surface.route.operation == Operation::Batch { + let batch = surface + .entity + .batch + .as_ref() + .expect("authorized batch routes have compiled bounds"); + let profile = &surface.entity.access_profiles[surface.context.selected_profile()]; + let allow_create = profile.operations.contains(&Operation::Create); + let allow_patch = profile.operations.contains(&Operation::Patch); + operation.insert("parameters".to_owned(), access_profile_parameters()); + operation.insert( + "x-registry-maximumItems".to_owned(), + json!(batch.maximum_items), + ); + operation.insert( + "x-registry-maximumBytes".to_owned(), + json!(batch.maximum_bytes), + ); + operation.insert( + "requestBody".to_owned(), + batch_request_body( + &surface.route.entity_id, + batch.maximum_items, + allow_create, + allow_patch, + ), + ); + operation.insert( + "responses".to_owned(), + batch_response( + &surface.route.entity_id, + batch.maximum_items, + allow_create, + allow_patch, + ), + ); + } + methods.insert( + method_name(surface.route.method).to_owned(), + Value::Object(operation), + ); + readable_by_entity + .entry(surface.route.entity_id.clone()) + .and_modify(|fields| { + *fields = fields + .intersection(&surface.readable_fields) + .cloned() + .collect(); + }) + .or_insert_with(|| surface.readable_fields.clone()); + } + + let schemas = readable_by_entity + .iter() + .filter_map(|(entity_id, readable)| { + filtered_schema(&service, entity_id, readable).map(|schema| (entity_id.clone(), schema)) + }) + .collect::>(); + Json(json!({ + "openapi": "3.1.0", + "info": {"title": service.registry.registry_id(), "version": service.registry.version()}, + "paths": paths, + "components": {"schemas": schemas} + })) + .into_response() +} + +async fn registry_metadata( + State(service): State>, + claims: Option>, + RawQuery(raw_query): RawQuery, +) -> Response { + let Ok(options) = QueryOptions::parse(raw_query.as_deref(), false) else { + return concealed(); + }; + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let visible = visible_metadata_entries(&service, &claims, &options); + if options.access_profile.is_some() && visible.is_empty() { + return concealed(); + } + + let mut entities: BTreeMap = BTreeMap::new(); + for (metadata_entity, entry) in visible { + entities + .entry(metadata_entity.id.clone()) + .and_modify(|metadata| { + metadata + .operations + .insert(entry.operation, entry.access_profile.clone()); + metadata.readable_fields = metadata + .readable_fields + .intersection(&entry.readable_fields) + .cloned() + .collect(); + }) + .or_insert_with(|| MetadataEntity { + id: metadata_entity.id.clone(), + route: metadata_entity.route.clone(), + operations: BTreeMap::from([(entry.operation, entry.access_profile.clone())]), + readable_fields: entry.readable_fields.clone(), + schema_path: metadata_entity.schema_path.clone(), + }); + } + let entities = entities + .into_values() + .map(|entity| { + json!({ + "id": entity.id, + "route": entity.route, + "operations": entity.operations.into_iter().map(|(operation, access_profile)| json!({ + "operation": operation_name(operation), + "accessProfile": access_profile, + })).collect::>(), + "readableFields": entity.readable_fields, + "schema": entity.schema_path, + }) + }) + .collect::>(); + Json(json!({ + "id": service.registry.registry_id(), + "version": service.registry.version(), + "revision": service.registry.revision(), + "entities": entities, + })) + .into_response() +} + +async fn entity_schema( + State(service): State>, + Path(entity_id): Path, + claims: Option>, + RawQuery(raw_query): RawQuery, +) -> Response { + let Ok(options) = QueryOptions::parse(raw_query.as_deref(), false) else { + return concealed(); + }; + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let surfaces = visible_surfaces(&service, &claims, &options) + .into_iter() + .filter(|surface| surface.route.entity_id == entity_id) + .collect::>(); + let Some(first) = surfaces.first() else { + return concealed(); + }; + let readable = + surfaces + .iter() + .skip(1) + .fold(first.readable_fields.clone(), |fields, surface| { + fields + .intersection(&surface.readable_fields) + .cloned() + .collect() + }); + match filtered_schema(&service, &entity_id, &readable) { + Some(schema) => Json(schema).into_response(), + None => concealed(), + } +} + +async fn read_dispatch( + State(service): State>, + Extension(route): Extension, + claims: Option>, + RawQuery(raw_query): RawQuery, + Path(path): Path>, +) -> Response { + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let options = match QueryOptions::parse(raw_query.as_deref(), true) { + Ok(options) => options, + Err(QueryParseError::Invalid) => { + return audited_known_read_refusal( + &service, + &route, + &claims, + path.get("record_id"), + invalid_query(), + ) + .await; + } + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + let response = + audited_read_concealment(&service, &route, &options, &claims, path.get("record_id")) + .await; + return response; + }; + let query = if route.operation == Operation::List { + match read_query(&service, &route, &surface, &options).await { + Ok(query) => query, + Err(ReadQueryError::Invalid) => { + return audited_read_refusal( + &service, + &route, + &surface, + path.get("record_id"), + invalid_query(), + ) + .await; + } + Err(ReadQueryError::CursorInvalid) => { + return audited_read_refusal( + &service, + &route, + &surface, + path.get("record_id"), + cursor_invalid(), + ) + .await; + } + } + } else { + if options.has_list_query_members() { + return audited_read_refusal( + &service, + &route, + &surface, + path.get("record_id"), + invalid_query(), + ) + .await; + } + None + }; + let readable_fields = if let Some(query) = &query { + query + .cursor_binding + .selected_fields + .iter() + .cloned() + .collect::>() + } else { + match &options.fields { + Some(fields) if fields.is_subset(&surface.readable_fields) => fields.clone(), + Some(_) => { + return audited_read_refusal( + &service, + &route, + &surface, + path.get("record_id"), + concealed(), + ) + .await; + } + None => surface.readable_fields.clone(), + } + }; + if !readable_fields.is_subset(&surface.readable_fields) { + return audited_read_refusal( + &service, + &route, + &surface, + path.get("record_id"), + concealed(), + ) + .await; + } + let request = RecordReadRequest { + entity_id: route.entity_id.clone(), + operation_id: route.id.clone(), + method: route.method, + record_id: path.get("record_id").cloned(), + context: surface.context, + selected_fields: readable_fields.clone(), + maximum_records: query + .as_ref() + .map_or(1, |query| usize::from(query.page_size) + 1), + query, + }; + + match route.operation { + Operation::Get => match service.records.get(request).await { + Ok(Some(record)) => exact_json(record), + Ok(None) => concealed(), + Err(ReadServiceError::Unavailable) => unavailable(), + Err(ReadServiceError::CursorInvalid) => cursor_invalid(), + }, + Operation::List => match service.records.list(request).await { + Ok(response) => exact_json_no_store(response), + Err(ReadServiceError::Unavailable) => unavailable(), + Err(ReadServiceError::CursorInvalid) => cursor_invalid(), + }, + _ => concealed(), + } +} + +async fn revision_dispatch( + State(service): State>, + Extension(route): Extension, + claims: Option>, + RawQuery(raw_query): RawQuery, + Path(path): Path>, +) -> Response { + let Some(revisions) = &service.revisions else { + return concealed(); + }; + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let options = match QueryOptions::parse(raw_query.as_deref(), false) { + Ok(options) => options, + Err(QueryParseError::Invalid) => { + return audited_known_revision_refusal( + revisions.as_ref(), + &route, + &claims, + path.get("record_id"), + invalid_query(), + ) + .await; + } + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + return audited_revision_concealment( + revisions.as_ref(), + &route, + &options, + &claims, + path.get("record_id"), + ) + .await; + }; + let Some(record_id) = path.get("record_id") else { + return audited_revision_refusal(revisions.as_ref(), &route, &surface, None, concealed()) + .await; + }; + let revision = match route.revision_kind { + Some(CompiledRevisionKind::List) if !path.contains_key("revision") => None, + Some(CompiledRevisionKind::Detail) => { + let Some(value) = path + .get("revision") + .and_then(|value| canonical_revision(value)) + else { + return audited_revision_refusal( + revisions.as_ref(), + &route, + &surface, + Some(record_id), + concealed(), + ) + .await; + }; + Some(value) + } + _ => { + return audited_revision_refusal( + revisions.as_ref(), + &route, + &surface, + Some(record_id), + concealed(), + ) + .await; + } + }; + if !valid_canonical_record_uuid(record_id) { + return audited_revision_refusal( + revisions.as_ref(), + &route, + &surface, + Some(record_id), + concealed(), + ) + .await; + } + let Some(maximum_records) = route.maximum_records.map(usize::from) else { + return unavailable(); + }; + if maximum_records == 0 + || maximum_records > usize::from(MAX_REVISION_HISTORY_RECORDS) + || revision.is_some() && maximum_records != 1 + || revision.is_none() && maximum_records != usize::from(MAX_REVISION_HISTORY_RECORDS) + { + return unavailable(); + } + let request = RevisionReadRequest { + entity_id: route.entity_id.clone(), + operation_id: route.id.clone(), + method: route.method, + record_id: record_id.clone(), + revision, + context: surface.context, + selected_fields: surface.readable_fields, + maximum_records, + }; + match route.revision_kind { + Some(CompiledRevisionKind::List) => match revisions.list(request).await { + Ok(Some(response)) => exact_json_no_store(response), + Ok(None) => concealed(), + Err(_) => unavailable(), + }, + Some(CompiledRevisionKind::Detail) => match revisions.detail(request).await { + Ok(Some(response)) => exact_json_no_store(response), + Ok(None) => concealed(), + Err(_) => unavailable(), + }, + None => concealed(), + } +} + +async fn audited_known_revision_refusal( + revisions: &dyn RevisionReadService, + route: &CompiledRoute, + claims: &VerifiedRequestClaims, + target_record: Option<&String>, + response: Response, +) -> Response { + match revisions + .refusal(RevisionReadRefusal { + method: route.method, + operation_id: route.id.clone(), + target_record: target_record.cloned(), + principal: claims.principal().map(str::to_owned), + selected_access_profile: None, + purpose_present: claims.purpose().is_some(), + }) + .await + { + Ok(()) => response, + Err(_) => unavailable(), + } +} + +async fn audited_revision_refusal( + revisions: &dyn RevisionReadService, + route: &CompiledRoute, + surface: &AuthorizedSurface<'_>, + target_record: Option<&String>, + response: Response, +) -> Response { + match revisions + .refusal(RevisionReadRefusal { + method: route.method, + operation_id: route.id.clone(), + target_record: target_record.cloned(), + principal: surface.context.principal().map(str::to_owned), + selected_access_profile: Some(surface.context.selected_profile().to_owned()), + purpose_present: surface.context.purpose().is_some(), + }) + .await + { + Ok(()) => response, + Err(_) => unavailable(), + } +} + +async fn audited_revision_concealment( + revisions: &dyn RevisionReadService, + route: &CompiledRoute, + options: &QueryOptions, + claims: &VerifiedRequestClaims, + target_record: Option<&String>, +) -> Response { + let selected_access_profile = options.access_profile.as_ref().and_then(|profile| { + route + .access_profiles + .iter() + .any(|candidate| candidate == profile) + .then_some(profile.clone()) + }); + match revisions + .refusal(RevisionReadRefusal { + method: route.method, + operation_id: route.id.clone(), + target_record: target_record.cloned(), + principal: claims.principal().map(str::to_owned), + selected_access_profile, + purpose_present: claims.purpose().is_some(), + }) + .await + { + Ok(()) => concealed(), + Err(_) => unavailable(), + } +} + +async fn audited_known_read_refusal( + service: &HttpService, + route: &CompiledRoute, + claims: &VerifiedRequestClaims, + target_record: Option<&String>, + response: Response, +) -> Response { + match service + .records + .refusal(RecordReadRefusal { + method: route.method, + operation_id: route.id.clone(), + target_record: target_record.cloned(), + principal: claims.principal().map(str::to_owned), + selected_access_profile: None, + purpose_present: claims.purpose().is_some(), + }) + .await + { + Ok(()) => response, + Err(_) => unavailable(), + } +} + +async fn audited_read_refusal( + service: &HttpService, + route: &CompiledRoute, + surface: &AuthorizedSurface<'_>, + target_record: Option<&String>, + response: Response, +) -> Response { + match service + .records + .refusal(RecordReadRefusal { + method: route.method, + operation_id: route.id.clone(), + target_record: target_record.cloned(), + principal: surface.context.principal().map(str::to_owned), + selected_access_profile: Some(surface.context.selected_profile().to_owned()), + purpose_present: surface.context.purpose().is_some(), + }) + .await + { + Ok(()) => response, + Err(_) => unavailable(), + } +} + +async fn audited_read_concealment( + service: &HttpService, + route: &CompiledRoute, + options: &QueryOptions, + claims: &VerifiedRequestClaims, + target_record: Option<&String>, +) -> Response { + let selected_access_profile = options.access_profile.as_ref().and_then(|profile| { + route + .access_profiles + .iter() + .any(|candidate| candidate == profile) + .then_some(profile.clone()) + }); + match service + .records + .refusal(RecordReadRefusal { + method: route.method, + operation_id: route.id.clone(), + target_record: target_record.cloned(), + principal: claims.principal().map(str::to_owned), + selected_access_profile, + purpose_present: claims.purpose().is_some(), + }) + .await + { + Ok(()) => concealed(), + Err(_) => unavailable(), + } +} + +async fn create_dispatch( + State(service): State>, + Extension(route): Extension, + claims: Option>, + RawQuery(raw_query): RawQuery, + headers: HeaderMap, + body: Body, +) -> Response { + let Some(mutations) = &service.mutations else { + return concealed(); + }; + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let Ok(options) = QueryOptions::parse(raw_query.as_deref(), false) else { + return audited_mutation_concealment( + mutations, + &route, + &QueryOptions::default(), + &claims, + None, + ) + .await; + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + return audited_mutation_concealment(mutations, &route, &options, &claims, None).await; + }; + let Some(idempotency_key) = single_header(&headers, "idempotency-key") else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + ) + .await; + }; + if !valid_idempotency_key(idempotency_key) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + ) + .await; + } + if !single_content_type(&headers, "application/json") { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + unsupported_media_type(), + ) + .await; + } + let Ok(body) = bounded_body(body).await else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + ) + .await; + }; + let Ok(data) = parse_create_body(&body) else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + ) + .await; + }; + match mutations + .create( + &route.id, + idempotency_key, + &surface.context, + &route.entity_id, + data, + surface.readable_fields, + ) + .await + { + Ok(outcome) => exact_mutation(outcome.response()), + Err(error) => mutation_problem(error), + } +} + +async fn patch_dispatch( + State(service): State>, + Extension(route): Extension, + claims: Option>, + RawQuery(raw_query): RawQuery, + Path(path): Path>, + headers: HeaderMap, + body: Body, +) -> Response { + let Some(mutations) = &service.mutations else { + return concealed(); + }; + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let Some(record_id) = path.get("record_id") else { + return invalid_request(); + }; + let Ok(options) = QueryOptions::parse(raw_query.as_deref(), false) else { + return audited_mutation_concealment( + mutations, + &route, + &QueryOptions::default(), + &claims, + Some(record_id.as_str()), + ) + .await; + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + return audited_mutation_concealment( + mutations, + &route, + &options, + &claims, + Some(record_id.as_str()), + ) + .await; + }; + let Some(idempotency_key) = single_header(&headers, "idempotency-key") else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + ) + .await; + }; + if !valid_idempotency_key(idempotency_key) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + ) + .await; + } + let Some(if_match) = single_header(&headers, IF_MATCH.as_str()) else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + precondition_required(), + ) + .await; + }; + if !valid_if_match(if_match) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + precondition_failed(), + ) + .await; + } + if !single_content_type(&headers, "application/json-patch+json") { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + unsupported_media_type(), + ) + .await; + } + let Ok(body) = bounded_body(body).await else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + ) + .await; + }; + let Ok(document) = parse_json_strict(&body) else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + ) + .await; + }; + let Ok(patch) = parse_json_patch_document(document) else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + ) + .await; + }; + match mutations + .patch( + ConditionalMutationInput { + route_id: &route.id, + idempotency_key, + if_match, + context: &surface.context, + entity_id: &route.entity_id, + record_id, + response_fields: surface.readable_fields, + }, + patch, + ) + .await + { + Ok(outcome) => exact_mutation(outcome.response()), + Err(error) => mutation_problem(error), + } +} + +async fn batch_dispatch( + State(service): State>, + Extension(route): Extension, + claims: Option>, + RawQuery(raw_query): RawQuery, + headers: HeaderMap, + body: Body, +) -> Response { + let Some(mutations) = &service.mutations else { + return concealed(); + }; + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let Ok(options) = QueryOptions::parse(raw_query.as_deref(), false) else { + return audited_mutation_concealment( + mutations, + &route, + &QueryOptions::default(), + &claims, + None, + ) + .await; + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + return audited_mutation_concealment(mutations, &route, &options, &claims, None).await; + }; + let Some(batch) = surface.entity.batch.as_ref() else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + ) + .await; + }; + let Some(idempotency_key) = single_header(&headers, "idempotency-key") else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + ) + .await; + }; + if !valid_idempotency_key(idempotency_key) || headers.contains_key(IF_MATCH) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + ) + .await; + } + if !single_content_type(&headers, "application/json") { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + unsupported_media_type(), + ) + .await; + } + let Ok(body) = bounded_body_to(body, batch.maximum_bytes as usize).await else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + ) + .await; + }; + let Ok(items) = parse_batch_body(&body, usize::from(batch.maximum_items)) else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + ) + .await; + }; + match mutations + .batch(BatchMutationInput { + route_id: &route.id, + idempotency_key, + context: &surface.context, + entity_id: &route.entity_id, + items, + response_fields: surface.readable_fields, + body_bytes: body.len(), + }) + .await + { + Ok(outcome) => exact_mutation(outcome.response()), + Err(error) => mutation_problem(error), + } +} + +async fn tombstone_dispatch( + State(service): State>, + Extension(route): Extension, + claims: Option>, + RawQuery(raw_query): RawQuery, + Path(path): Path>, + headers: HeaderMap, + body: Body, +) -> Response { + let Some(mutations) = &service.mutations else { + return concealed(); + }; + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let Some(record_id) = path.get("record_id") else { + return invalid_request(); + }; + let Ok(options) = QueryOptions::parse(raw_query.as_deref(), false) else { + return audited_mutation_concealment( + mutations, + &route, + &QueryOptions::default(), + &claims, + Some(record_id.as_str()), + ) + .await; + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + return audited_mutation_concealment( + mutations, + &route, + &options, + &claims, + Some(record_id.as_str()), + ) + .await; + }; + let Some(idempotency_key) = single_header(&headers, "idempotency-key") else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + ) + .await; + }; + if !valid_idempotency_key(idempotency_key) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + ) + .await; + } + let Some(if_match) = single_header(&headers, IF_MATCH.as_str()) else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + precondition_required(), + ) + .await; + }; + if !valid_if_match(if_match) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + precondition_failed(), + ) + .await; + } + if headers.contains_key(CONTENT_TYPE) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + unsupported_media_type(), + ) + .await; + } + if !body_is_empty(body).await { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + ) + .await; + } + match mutations + .tombstone(ConditionalMutationInput { + route_id: &route.id, + idempotency_key, + if_match, + context: &surface.context, + entity_id: &route.entity_id, + record_id, + response_fields: surface.readable_fields, + }) + .await + { + Ok(outcome) => exact_mutation(outcome.response()), + Err(error) => mutation_problem(error), + } +} + +async fn audited_mutation_refusal( + mutations: &crate::postgres::PostgresRecordMutationService, + route: &CompiledRoute, + context: &AuthorizedRequestContext, + target_record: Option<&str>, + response: Response, +) -> Response { + match mutations + .record_refusal( + route.method, + &route.id, + target_record, + context.principal(), + Some(context.selected_profile()), + context.purpose().is_some(), + ) + .await + { + Ok(()) => response, + Err(_) => mutation_problem(MutationError::Unavailable), + } +} + +async fn audited_mutation_concealment( + mutations: &crate::postgres::PostgresRecordMutationService, + route: &CompiledRoute, + options: &QueryOptions, + claims: &VerifiedRequestClaims, + target_record: Option<&str>, +) -> Response { + let selected_profile = options + .access_profile + .as_deref() + .or(Some(route.default_access_profile.as_str())); + match mutations + .record_refusal( + route.method, + &route.id, + target_record, + claims.principal(), + selected_profile, + claims.purpose().is_some(), + ) + .await + { + Ok(()) => concealed(), + Err(_) => mutation_problem(MutationError::Unavailable), + } +} + +async fn not_found() -> Response { + concealed() +} + +struct AuthorizedSurface<'a> { + route: &'a CompiledRoute, + entity: &'a CompiledEntity, + context: AuthorizedRequestContext, + readable_fields: BTreeSet, +} + +fn visible_surfaces<'a>( + service: &'a HttpService, + claims: &VerifiedRequestClaims, + options: &QueryOptions, +) -> Vec> { + service + .registry + .routes() + .routes + .iter() + .filter(|route| served_operation(service, route)) + .filter_map(|route| authorize_route(service, route, claims, options)) + .collect() +} + +fn visible_metadata_entries<'a>( + service: &'a HttpService, + claims: &VerifiedRequestClaims, + options: &QueryOptions, +) -> Vec<(&'a CompiledMetadataEntity, &'a CompiledMetadataEntry)> { + visible_surfaces(service, claims, options) + .into_iter() + .filter_map(|surface| metadata_entry_for_surface(service, &surface)) + .collect() +} + +fn metadata_entry_for_surface<'a>( + service: &'a HttpService, + surface: &AuthorizedSurface<'_>, +) -> Option<(&'a CompiledMetadataEntity, &'a CompiledMetadataEntry)> { + let entity = service + .registry + .metadata() + .entities + .iter() + .find(|entity| entity.id == surface.route.entity_id)?; + let entry = entity.entries.iter().find(|entry| { + entry.route_id == surface.route.id + && entry.operation == surface.route.operation + && entry.access_profile == surface.context.selected_profile() + })?; + Some((entity, entry)) +} + +fn authorize_route<'a>( + service: &'a HttpService, + route: &'a CompiledRoute, + claims: &VerifiedRequestClaims, + options: &QueryOptions, +) -> Option> { + let access = + service.registry.access().entries.iter().find(|entry| { + entry.entity_id == route.entity_id && entry.operation == route.operation + })?; + let selected_profile = options + .access_profile + .as_deref() + .unwrap_or(&access.default_profile_id); + if !access.profile_ids.contains(selected_profile) + || !route + .access_profiles + .iter() + .any(|id| id == selected_profile) + { + return None; + } + let entity = service.registry.entities().get(&route.entity_id)?; + let profile = entity.access_profiles.get(selected_profile)?; + if !profile.operations.contains(&route.operation) { + return None; + } + if route.operation == Operation::Revisions && (profile.anonymous || !profile.revision_access) { + return None; + } + if matches!( + route.operation, + Operation::Create | Operation::Patch | Operation::Tombstone | Operation::Batch + ) && profile.anonymous + { + return None; + } + if profile.anonymous { + if entity.classification != Classification::Public { + return None; + } + } else { + let expected_claim = profile.principal_claim.as_deref()?; + if claims.principal_claim() != Some(expected_claim) || claims.principal().is_none() { + return None; + } + } + if !profile + .required_scopes + .iter() + .all(|scope| claims.has_scope(scope)) + { + return None; + } + if !profile.required_purposes.is_empty() + && !claims + .purpose() + .is_some_and(|purpose| profile.required_purposes.contains(purpose)) + { + return None; + } + let row_boundaries = verified_row_boundaries(profile, claims)?; + let readable_fields = profile + .readable_fields + .iter() + .filter(|field| { + !profile.anonymous + || entity + .fields + .get(*field) + .is_some_and(|field| field.classification == Classification::Public) + }) + .cloned() + .collect(); + Some(AuthorizedSurface { + route, + entity, + context: AuthorizedRequestContext::new( + claims.principal().map(str::to_owned), + claims.purpose().map(str::to_owned), + selected_profile.to_owned(), + row_boundaries, + ), + readable_fields, + }) +} + +fn served_operation(service: &HttpService, route: &CompiledRoute) -> bool { + match route.operation { + Operation::Get | Operation::List => true, + Operation::Create => service.mutations.is_some(), + Operation::Batch => { + service.mutations.is_some() + && service + .registry + .entities() + .get(&route.entity_id) + .is_some_and(|entity| entity.batch.is_some()) + } + Operation::Patch => { + service.mutations.is_some() + && service + .registry + .entities() + .get(&route.entity_id) + .is_some_and(|entity| { + entity.mutation_mode == crate::contract::MutationMode::Mutable + }) + } + Operation::Tombstone => { + service.mutations.is_some() + && service + .registry + .entities() + .get(&route.entity_id) + .is_some_and(|entity| { + entity.mutation_mode == crate::contract::MutationMode::Mutable + && entity.tombstone + }) + } + Operation::Revisions => service.revisions.is_some(), + } +} + +fn verified_row_boundaries( + profile: &AccessProfileSource, + claims: &VerifiedRequestClaims, +) -> Option> { + profile + .row_boundaries + .iter() + .map(|boundary| { + let values = claims.direct_claim(&boundary.claim)?.values(); + let operator = match boundary.operator { + BoundaryOperator::Equals if values.len() == 1 => RowBoundaryOperator::Equals, + BoundaryOperator::Equals => return None, + BoundaryOperator::In => RowBoundaryOperator::In, + }; + Some(VerifiedRowBoundary::new( + boundary.field.clone(), + operator, + values, + )) + }) + .collect() +} + +async fn read_query( + service: &HttpService, + route: &CompiledRoute, + surface: &AuthorizedSurface<'_>, + options: &QueryOptions, +) -> Result, ReadQueryError> { + let Some(kind) = route.query_kind else { + return Ok(None); + }; + let Some(operation) = query_operation_for_route(service, route, surface, kind) else { + return Err(ReadQueryError::Invalid); + }; + if let Some(token) = &options.cursor { + let payload = service + .cursors + .open_after_authorization(token, now_unix_seconds(), |payload| { + if payload.binding.route_id != route.id + || payload.binding.query_operation_id != operation.id + || payload.binding.query_kind != kind + || payload.binding.selected_profile != surface.context.selected_profile() + { + return Err(CursorError::Mismatch); + } + let fields = payload + .binding + .selected_fields + .iter() + .cloned() + .collect::>(); + let filters = cursor_filters_to_read_filters(&payload.query.filters) + .map_err(|_| CursorError::Mismatch)?; + validate_query_shape( + surface.entity, + operation, + &filters, + payload.query.sort.as_deref(), + payload.binding.page_size, + ) + .map_err(|_| CursorError::Mismatch)?; + cursor_binding( + service, + route, + surface, + operation, + CursorBindingQuery { + selected_fields: &fields, + filters: &filters, + sort: payload.query.sort.as_deref(), + page_size: payload.binding.page_size, + temporal_instant: payload.binding.temporal_instant.as_deref(), + }, + ) + }) + .map_err(|_| ReadQueryError::CursorInvalid)?; + let fields = payload + .binding + .selected_fields + .iter() + .cloned() + .collect::>(); + if fields.is_empty() || !fields.is_subset(&surface.readable_fields) { + return Err(ReadQueryError::CursorInvalid); + } + let filters = cursor_filters_to_read_filters(&payload.query.filters)?; + return Ok(Some(CompiledReadQuery { + route_id: route.id.clone(), + query_operation_id: operation.id.clone(), + kind, + cursor_binding: payload.binding.clone(), + cursor_query: payload.query.clone(), + filters, + sort: payload.query.sort, + page_size: payload.binding.page_size, + temporal_instant: payload.binding.temporal_instant, + continuation: Some(payload.continuation), + })); + } + + let fields = match &options.fields { + Some(fields) if fields.is_subset(&surface.readable_fields) => fields.clone(), + Some(_) => return Err(ReadQueryError::Invalid), + None => operation.projection_fields.iter().cloned().collect(), + }; + if fields.is_empty() || !fields.is_subset(&surface.readable_fields) { + return Err(ReadQueryError::Invalid); + } + let filters = first_page_filters(operation, &options.filters)?; + let sort = options.sort.clone(); + let page_size = options.page_size.unwrap_or(operation.max_page_size); + validate_query_shape( + surface.entity, + operation, + &filters, + sort.as_deref(), + page_size, + )?; + let temporal_instant = temporal_instant_for(kind, options)?; + let binding = cursor_binding( + service, + route, + surface, + operation, + CursorBindingQuery { + selected_fields: &fields, + filters: &filters, + sort: sort.as_deref(), + page_size, + temporal_instant: temporal_instant.as_deref(), + }, + ) + .map_err(|_| ReadQueryError::Invalid)?; + Ok(Some(CompiledReadQuery { + route_id: route.id.clone(), + query_operation_id: operation.id.clone(), + kind, + cursor_binding: binding, + cursor_query: crate::cursor::CursorQuery { + filters: cursor_filters(&filters), + sort: sort.clone(), + }, + filters, + sort, + page_size, + temporal_instant, + continuation: None, + })) +} + +fn query_operation_for_route<'a>( + service: &'a HttpService, + route: &CompiledRoute, + surface: &AuthorizedSurface<'_>, + kind: CompiledQueryKind, +) -> Option<&'a CompiledQueryOperation> { + service + .registry + .queries() + .operations + .iter() + .find(|operation| { + operation.route_id == route.id + && operation.entity_id == route.entity_id + && operation.profile_id == surface.context.selected_profile() + && operation.kind == kind + }) +} + +fn first_page_filters( + operation: &CompiledQueryOperation, + filters: &[RawFilterClause], +) -> Result, ReadQueryError> { + let mut result = Vec::new(); + let mut in_values: BTreeMap> = BTreeMap::new(); + let mut non_in_fields = BTreeSet::new(); + for filter in filters { + let field = operation + .filter_fields + .iter() + .find(|field| field.field == filter.field) + .ok_or(ReadQueryError::Invalid)?; + if !field.operators.contains(&filter.operator) { + return Err(ReadQueryError::Invalid); + } + let values = match filter.operator { + CompiledQueryFilterOperator::Equals | CompiledQueryFilterOperator::In => { + vec![filter.value.clone()] + } + CompiledQueryFilterOperator::Prefix => vec![filter.value.clone()], + CompiledQueryFilterOperator::Range => { + let (lower, upper) = filter + .value + .split_once("..") + .ok_or(ReadQueryError::Invalid)?; + if lower.is_empty() || upper.is_empty() { + return Err(ReadQueryError::Invalid); + } + vec![lower.to_owned(), upper.to_owned()] + } + CompiledQueryFilterOperator::IsNull | CompiledQueryFilterOperator::IsNotNull => { + if filter.value != "true" { + return Err(ReadQueryError::Invalid); + } + vec!["true".to_owned()] + } + }; + if filter.operator == CompiledQueryFilterOperator::In { + if non_in_fields.contains(&filter.field) { + return Err(ReadQueryError::Invalid); + } + in_values + .entry(filter.field.clone()) + .or_default() + .insert(values[0].clone()); + continue; + } + if in_values.contains_key(&filter.field) { + return Err(ReadQueryError::Invalid); + } + non_in_fields.insert(filter.field.clone()); + result.push(ReadFilterClause { + field: filter.field.clone(), + operator: filter.operator, + values, + }); + } + for (field, values) in in_values { + result.push(ReadFilterClause { + field, + operator: CompiledQueryFilterOperator::In, + values: values.into_iter().collect(), + }); + } + result.sort_by(|left, right| (&left.field, left.operator).cmp(&(&right.field, right.operator))); + Ok(result) +} + +fn cursor_filters_to_read_filters( + filters: &[crate::cursor::CursorFilter], +) -> Result, ReadQueryError> { + filters + .iter() + .map(|filter| { + let operator = match filter.operator.as_str() { + "equals" => CompiledQueryFilterOperator::Equals, + "in" => CompiledQueryFilterOperator::In, + "range" => CompiledQueryFilterOperator::Range, + "is_null" => CompiledQueryFilterOperator::IsNull, + "is_not_null" => CompiledQueryFilterOperator::IsNotNull, + "prefix" => CompiledQueryFilterOperator::Prefix, + _ => return Err(ReadQueryError::CursorInvalid), + }; + Ok(ReadFilterClause { + field: filter.field.clone(), + operator, + values: filter.values.clone(), + }) + }) + .collect() +} + +fn validate_query_shape( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + filters: &[ReadFilterClause], + sort: Option<&str>, + page_size: u16, +) -> Result<(), ReadQueryError> { + if page_size == 0 || page_size > operation.max_page_size || filters.len() > MAX_FILTER_CLAUSES { + return Err(ReadQueryError::Invalid); + } + let mut in_values = 0_usize; + for filter in filters { + let field = operation + .filter_fields + .iter() + .find(|field| field.field == filter.field) + .ok_or(ReadQueryError::Invalid)?; + if !field.operators.contains(&filter.operator) { + return Err(ReadQueryError::Invalid); + } + let compiled_field_type = entity + .fields + .get(&filter.field) + .map(|field| &field.field_type) + .ok_or(ReadQueryError::Invalid)?; + match filter.operator { + CompiledQueryFilterOperator::Equals | CompiledQueryFilterOperator::Prefix => { + if filter.values.len() != 1 { + return Err(ReadQueryError::Invalid); + } + crate::postgres::validate_field_value(&filter.values[0], compiled_field_type) + .map_err(|_| ReadQueryError::Invalid)?; + } + CompiledQueryFilterOperator::In => { + if filter.values.is_empty() { + return Err(ReadQueryError::Invalid); + } + in_values += filter.values.len(); + if in_values > MAX_IN_VALUES { + return Err(ReadQueryError::Invalid); + } + let unique = filter.values.iter().collect::>(); + if unique.len() != filter.values.len() { + return Err(ReadQueryError::Invalid); + } + for value in &filter.values { + crate::postgres::validate_field_value(value, compiled_field_type) + .map_err(|_| ReadQueryError::Invalid)?; + } + } + CompiledQueryFilterOperator::Range => { + if filter.values.len() != 2 { + return Err(ReadQueryError::Invalid); + } + for value in &filter.values { + crate::postgres::validate_field_value(value, compiled_field_type) + .map_err(|_| ReadQueryError::Invalid)?; + } + } + CompiledQueryFilterOperator::IsNull | CompiledQueryFilterOperator::IsNotNull => { + if filter.values.as_slice() != ["true"] { + return Err(ReadQueryError::Invalid); + } + } + } + } + if let Some(sort) = sort { + let sortable = operation + .sort_fields + .iter() + .any(|field| field.field == sort && field.directions.len() == 1); + if !sortable { + return Err(ReadQueryError::Invalid); + } + } + Ok(()) +} + +fn temporal_instant_for( + kind: CompiledQueryKind, + options: &QueryOptions, +) -> Result, ReadQueryError> { + match kind { + CompiledQueryKind::List => { + if options.as_of.is_some() { + return Err(ReadQueryError::Invalid); + } + Ok(None) + } + CompiledQueryKind::Current => { + if options.as_of.is_some() { + return Err(ReadQueryError::Invalid); + } + OffsetDateTime::now_utc() + .format(&Rfc3339) + .map(Some) + .map_err(|_| ReadQueryError::Invalid) + } + CompiledQueryKind::AsOf => { + let value = options.as_of.as_deref().ok_or(ReadQueryError::Invalid)?; + parse_strict_rfc3339_utc(value).map_err(|_| ReadQueryError::Invalid)?; + Ok(Some(value.to_owned())) + } + } +} + +fn parse_strict_rfc3339_utc(value: &str) -> Result { + let parsed = OffsetDateTime::parse(value, &Rfc3339).map_err(|_| ())?; + if parsed.offset() != time::UtcOffset::UTC || parsed.format(&Rfc3339).map_err(|_| ())? != value + { + return Err(()); + } + Ok(parsed) +} + +fn cursor_binding( + service: &HttpService, + route: &CompiledRoute, + surface: &AuthorizedSurface<'_>, + operation: &CompiledQueryOperation, + query: CursorBindingQuery<'_>, +) -> Result { + let selected_fields_vec = query.selected_fields.iter().cloned().collect::>(); + let principal_reference = surface + .context + .principal() + .map(|principal| { + service + .cursors + .binding_digest_bytes(b"registry-server-cursor-principal-v1", principal.as_bytes()) + }) + .transpose()?; + let purpose_reference = surface + .context + .purpose() + .map(|purpose| { + service + .cursors + .binding_digest_bytes(b"registry-server-cursor-purpose-v1", purpose.as_bytes()) + }) + .transpose()?; + let row_boundary_reference = service.cursors.binding_digest( + b"registry-server-cursor-row-boundary-v1", + &json!(surface + .context + .row_boundaries() + .iter() + .map(|boundary| { + json!({ + "field": boundary.field(), + "operator": match boundary.operator() { + RowBoundaryOperator::Equals => "equals", + RowBoundaryOperator::In => "in", + }, + "values": boundary.values(), + }) + }) + .collect::>()), + )?; + let projection_reference = service.cursors.binding_digest( + b"registry-server-cursor-projection-v1", + &json!({"selectedFields": selected_fields_vec}), + )?; + let cursor_filters = cursor_filters(query.filters); + let query_reference = service.cursors.binding_digest( + b"registry-server-cursor-query-v1", + &json!({"filters": cursor_filters, "temporalInstant": query.temporal_instant}), + )?; + let sort_reference = service.cursors.binding_digest( + b"registry-server-cursor-sort-v1", + &json!({"sort": query.sort, "tieBreaker": operation.stable_tie_breaker}), + )?; + Ok(CursorBinding { + package_revision: service.identity.package_revision.clone(), + schema_fingerprint: service.identity.schema_fingerprint.clone(), + registry_revision: service.registry.revision().to_owned(), + route_id: route.id.clone(), + query_operation_id: operation.id.clone(), + query_kind: operation.kind, + selected_profile: surface.context.selected_profile().to_owned(), + principal_reference, + purpose_reference, + row_boundary_reference, + projection_reference, + query_reference, + sort_reference, + page_size: query.page_size, + temporal_instant: query.temporal_instant.map(str::to_owned), + selected_fields: selected_fields_vec, + }) +} + +struct CursorBindingQuery<'a> { + selected_fields: &'a BTreeSet, + filters: &'a [ReadFilterClause], + sort: Option<&'a str>, + page_size: u16, + temporal_instant: Option<&'a str>, +} + +fn cursor_filters(filters: &[ReadFilterClause]) -> Vec { + filters + .iter() + .map(|filter| crate::cursor::CursorFilter { + field: filter.field.clone(), + operator: filter_operator_name(filter.operator).to_owned(), + values: filter.values.clone(), + }) + .collect() +} + +fn filter_operator_name(operator: CompiledQueryFilterOperator) -> &'static str { + match operator { + CompiledQueryFilterOperator::Equals => "equals", + CompiledQueryFilterOperator::In => "in", + CompiledQueryFilterOperator::Range => "range", + CompiledQueryFilterOperator::IsNull => "is_null", + CompiledQueryFilterOperator::IsNotNull => "is_not_null", + CompiledQueryFilterOperator::Prefix => "prefix", + } +} + +fn filtered_schema( + service: &HttpService, + entity_id: &str, + readable_fields: &BTreeSet, +) -> Option { + let path = format!("generated/schemas/{entity_id}.schema.json"); + let artifact = service.registry.artifacts().get(&path)?; + let mut schema: Value = serde_json::from_slice(&artifact.bytes).ok()?; + let object = schema.as_object_mut()?; + let properties = object.get_mut("properties")?.as_object_mut()?; + properties.retain(|field, _| readable_fields.contains(field)); + if let Some(required) = object.get_mut("required").and_then(Value::as_array_mut) { + required.retain(|field| { + field + .as_str() + .is_some_and(|field| readable_fields.contains(field)) + }); + } + Some(schema) +} + +struct MetadataEntity { + id: String, + route: String, + operations: BTreeMap, + readable_fields: BTreeSet, + schema_path: String, +} + +#[derive(Default)] +struct QueryOptions { + access_profile: Option, + fields: Option>, + filters: Vec, + sort: Option, + page_size: Option, + as_of: Option, + cursor: Option, +} + +impl QueryOptions { + fn parse(raw: Option<&str>, allow_fields: bool) -> Result { + let mut result = Self::default(); + let Some(raw) = raw else { + return Ok(result); + }; + if raw.is_empty() || raw.len() > MAX_RAW_QUERY_BYTES { + return Err(QueryParseError::Invalid); + } + let mut in_values = 0_usize; + for pair in raw.split('&') { + let (name, value) = pair.split_once('=').ok_or(QueryParseError::Invalid)?; + let name = percent_decode(name)?; + let value = percent_decode(value)?; + match name.as_str() { + "accessProfile" if result.access_profile.is_none() && valid_id(&value) => { + result.access_profile = Some(value); + } + "fields" if allow_fields && result.fields.is_none() => { + result.fields = Some(parse_fields(&value)?); + } + "filter" if allow_fields => { + if result.filters.len() >= MAX_FILTER_CLAUSES { + return Err(QueryParseError::Invalid); + } + let filter = parse_raw_filter(&value)?; + if filter.operator == CompiledQueryFilterOperator::In { + in_values += 1; + if in_values > MAX_IN_VALUES { + return Err(QueryParseError::Invalid); + } + } + result.filters.push(filter); + } + "sort" if allow_fields && result.sort.is_none() && valid_id(&value) => { + result.sort = Some(value); + } + "pageSize" if allow_fields && result.page_size.is_none() => { + let size = value.parse::().map_err(|_| QueryParseError::Invalid)?; + result.page_size = Some(size); + } + "asOf" if allow_fields && result.as_of.is_none() => { + parse_strict_rfc3339_utc(&value).map_err(|_| QueryParseError::Invalid)?; + result.as_of = Some(value); + } + "cursor" if allow_fields && result.cursor.is_none() && !value.is_empty() => { + result.cursor = Some(value); + } + _ => return Err(QueryParseError::Invalid), + } + } + if result.cursor.is_some() + && (result.fields.is_some() + || !result.filters.is_empty() + || result.sort.is_some() + || result.page_size.is_some() + || result.as_of.is_some()) + { + return Err(QueryParseError::Invalid); + } + Ok(result) + } + + fn has_list_query_members(&self) -> bool { + self.cursor.is_some() + || !self.filters.is_empty() + || self.sort.is_some() + || self.page_size.is_some() + || self.as_of.is_some() + } +} + +#[derive(Clone, Eq, PartialEq)] +struct RawFilterClause { + field: String, + operator: CompiledQueryFilterOperator, + value: String, +} + +impl fmt::Debug for RawFilterClause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RawFilterClause") + .field("field", &self.field) + .field("operator", &self.operator) + .field("value", &"") + .finish() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum QueryParseError { + Invalid, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ReadQueryError { + Invalid, + CursorInvalid, +} + +fn percent_decode(value: &str) -> Result { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' if index + 2 < bytes.len() => { + let high = hex(bytes[index + 1]).ok_or(QueryParseError::Invalid)?; + let low = hex(bytes[index + 2]).ok_or(QueryParseError::Invalid)?; + decoded.push((high << 4) | low); + index += 3; + } + b'%' => return Err(QueryParseError::Invalid), + b'+' => { + decoded.push(b' '); + index += 1; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + String::from_utf8(decoded).map_err(|_| QueryParseError::Invalid) +} + +fn hex(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn valid_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') + }) +} + +fn parse_fields(value: &str) -> Result, QueryParseError> { + if value.is_empty() { + return Err(QueryParseError::Invalid); + } + let mut fields = BTreeSet::new(); + for field in value.split(',') { + if fields.len() >= MAX_FIELDS + || field.is_empty() + || field.len() > MAX_FIELD_BYTES + || !valid_id(field) + || !fields.insert(field.to_owned()) + { + return Err(QueryParseError::Invalid); + } + } + Ok(fields) +} + +fn parse_raw_filter(value: &str) -> Result { + let (field, rest) = value.split_once(':').ok_or(QueryParseError::Invalid)?; + let (operator, value) = rest.split_once(':').ok_or(QueryParseError::Invalid)?; + if field.is_empty() || value.is_empty() || !valid_id(field) { + return Err(QueryParseError::Invalid); + } + let operator = match operator { + "equals" => CompiledQueryFilterOperator::Equals, + "in" => CompiledQueryFilterOperator::In, + "range" => CompiledQueryFilterOperator::Range, + "is_null" => CompiledQueryFilterOperator::IsNull, + "is_not_null" => CompiledQueryFilterOperator::IsNotNull, + "prefix" => CompiledQueryFilterOperator::Prefix, + _ => return Err(QueryParseError::Invalid), + }; + Ok(RawFilterClause { + field: field.to_owned(), + operator, + value: value.to_owned(), + }) +} + +fn operation_name(operation: Operation) -> &'static str { + match operation { + Operation::Get => "get", + Operation::List => "list", + Operation::Create => "create", + Operation::Patch => "patch", + Operation::Tombstone => "tombstone", + Operation::Batch => "batch", + Operation::Revisions => "revisions", + } +} + +fn query_kind_name(kind: CompiledQueryKind) -> &'static str { + match kind { + CompiledQueryKind::List => "list", + CompiledQueryKind::Current => "current", + CompiledQueryKind::AsOf => "as_of", + } +} + +fn query_parameters(kind: CompiledQueryKind) -> Value { + let mut parameters = vec![ + query_parameter( + "accessProfile", + false, + false, + json!({"type": "string"}), + "Select one compiled access profile.", + ), + query_parameter( + "fields", + false, + false, + json!({"type": "string"}), + "Comma-separated subset of readable fields.", + ), + query_parameter( + "filter", + false, + true, + json!({"type": "string"}), + "Repeatable field:operator:value filter clause.", + ), + query_parameter( + "sort", + false, + false, + json!({"type": "string"}), + "One compiled sortable field, ascending only.", + ), + query_parameter( + "pageSize", + false, + false, + json!({"type": "integer", "minimum": 1}), + "Bounded page size within the compiled maximum.", + ), + query_parameter( + "cursor", + false, + false, + json!({"type": "string"}), + "Opaque continuation cursor for the next page.", + ), + ]; + if kind == CompiledQueryKind::AsOf { + parameters.push(query_parameter( + "asOf", + true, + false, + json!({"type": "string", "format": "date-time"}), + "Strict UTC RFC3339 instant for the as-of temporal query.", + )); + } + Value::Array(parameters) +} + +fn revision_parameters(kind: CompiledRevisionKind) -> Value { + let mut parameters = vec![query_parameter( + "accessProfile", + false, + false, + json!({"type": "string"}), + "Select one compiled access profile.", + )]; + parameters.push(path_parameter( + "record_id", + json!({"type": "string", "format": "uuid"}), + "Canonical record UUID.", + )); + if kind == CompiledRevisionKind::Detail { + parameters.push(path_parameter( + "revision", + json!({"type": "integer", "format": "int64", "minimum": 1}), + "Exact positive record revision.", + )); + } + Value::Array(parameters) +} + +fn query_parameter( + name: &str, + required: bool, + repeatable: bool, + schema: Value, + description: &str, +) -> Value { + json!({ + "name": name, + "in": "query", + "required": required, + "description": description, + "schema": schema, + "explode": repeatable, + }) +} + +fn path_parameter(name: &str, schema: Value, description: &str) -> Value { + json!({ + "name": name, + "in": "path", + "required": true, + "description": description, + "schema": schema, + }) +} + +fn valid_canonical_record_uuid(value: &str) -> bool { + value.len() == 36 + && Uuid::parse_str(value).is_ok_and(|identifier| identifier.to_string() == value) +} + +fn canonical_revision(value: &str) -> Option { + let revision = value.parse::().ok()?; + (revision > 0 && revision.to_string() == value).then_some(revision) +} + +fn method_name(method: crate::model::HttpMethod) -> &'static str { + match method { + crate::model::HttpMethod::Delete => "delete", + crate::model::HttpMethod::Get => "get", + crate::model::HttpMethod::Patch => "patch", + crate::model::HttpMethod::Post => "post", + } +} + +fn concealed() -> Response { + fixed_problem( + StatusCode::NOT_FOUND, + "resource.not_found", + "The requested resource was not found.", + ) +} + +fn unavailable() -> Response { + fixed_problem( + StatusCode::SERVICE_UNAVAILABLE, + "source.unavailable", + "The Registry data service is unavailable.", + ) +} + +fn invalid_query() -> Response { + fixed_problem( + StatusCode::BAD_REQUEST, + "query.invalid", + "The query request is invalid.", + ) +} + +fn cursor_invalid() -> Response { + fixed_problem( + StatusCode::BAD_REQUEST, + "query.cursor_invalid", + "The query cursor is invalid.", + ) +} + +fn exact_json(response: HeldReadResponse) -> Response { + let mut builder = Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/json"); + if let Some(etag) = response.strong_etag() { + let Ok(etag) = HeaderValue::from_bytes(etag) else { + return unavailable(); + }; + builder = builder.header(ETAG, etag); + } + builder + .body(Body::from(response.body().to_vec())) + .unwrap_or_else(|_| unavailable()) +} + +fn exact_json_no_store(response: HeldReadResponse) -> Response { + ( + StatusCode::OK, + [ + (CONTENT_TYPE, "application/json"), + (CACHE_CONTROL, "no-store"), + ], + response.body().to_vec(), + ) + .into_response() +} + +fn exact_mutation(response: &HeldResponse) -> Response { + let mut builder = Response::builder().status(response.status()); + for (name, value) in response.headers() { + let Ok(value) = HeaderValue::from_bytes(value) else { + return unavailable(); + }; + builder = match name { + PermittedResponseHeader::ContentType => builder.header(CONTENT_TYPE, value), + PermittedResponseHeader::Etag => builder.header("etag", value), + PermittedResponseHeader::Location => builder.header("location", value), + }; + } + builder + .body(Body::from(response.body().to_vec())) + .unwrap_or_else(|_| unavailable()) +} + +async fn bounded_body(body: Body) -> Result, ()> { + bounded_body_to(body, MAX_MUTATION_BODY_BYTES).await +} + +async fn bounded_body_to(body: Body, maximum_bytes: usize) -> Result, ()> { + let bytes = to_bytes(body, maximum_bytes).await.map_err(|_| ())?; + if bytes.is_empty() { + return Err(()); + } + Ok(bytes.to_vec()) +} + +fn parse_batch_body(body: &[u8], maximum_items: usize) -> Result, ()> { + let value = parse_json_strict(body).map_err(|_| ())?; + let object = value.as_object().ok_or(())?; + if object.len() != 1 { + return Err(()); + } + let items = object.get("items").and_then(Value::as_array).ok_or(())?; + if items.is_empty() || items.len() > maximum_items { + return Err(()); + } + items + .iter() + .map(|item| { + let object = item.as_object().ok_or(())?; + match object.get("operation").and_then(Value::as_str) { + Some("create") + if object.len() == 2 && object.get("data").is_some_and(Value::is_object) => + { + Ok(BatchMutationItem::Create( + object["data"].as_object().expect("checked object").clone(), + )) + } + Some("patch") + if object.len() == 4 + && object.contains_key("recordId") + && object.contains_key("ifMatch") + && object.contains_key("patch") => + { + let record_id = object["recordId"].as_str().ok_or(())?; + let expected_etag = object["ifMatch"].as_str().ok_or(())?; + if !Uuid::parse_str(record_id) + .is_ok_and(|identifier| identifier.to_string() == record_id) + || !valid_if_match(expected_etag) + { + return Err(()); + } + let patch = + parse_json_patch_document(object["patch"].clone()).map_err(|_| ())?; + Ok(BatchMutationItem::Patch { + record_id: record_id.to_owned(), + expected_etag: expected_etag.to_owned(), + patch, + }) + } + _ => Err(()), + } + }) + .collect() +} + +fn access_profile_parameters() -> Value { + Value::Array(vec![query_parameter( + "accessProfile", + false, + false, + json!({"type": "string"}), + "Select one compiled access profile.", + )]) +} + +fn batch_request_body( + entity_id: &str, + maximum_items: u16, + allow_create: bool, + allow_patch: bool, +) -> Value { + let mut item_schemas = Vec::new(); + if allow_create { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["operation", "data"], + "properties": { + "operation": {"const": "create"}, + "data": {"$ref": format!("#/components/schemas/{entity_id}")}, + } + })); + } + if allow_patch { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["operation", "recordId", "ifMatch", "patch"], + "properties": { + "operation": {"const": "patch"}, + "recordId": {"type": "string", "format": "uuid"}, + "ifMatch": {"type": "string"}, + "patch": {"type": "array", "minItems": 1, "maxItems": 128}, + } + })); + } + json!({ + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { + "type": "array", + "minItems": 1, + "maxItems": maximum_items, + "items": {"oneOf": item_schemas} + } + } + } + } + } + }) +} + +fn batch_response( + entity_id: &str, + maximum_items: u16, + allow_create: bool, + allow_patch: bool, +) -> Value { + let operations = [ + allow_create.then_some("create"), + allow_patch.then_some("patch"), + ] + .into_iter() + .flatten() + .collect::>(); + json!({ + "200": { + "description": "Atomic batch committed", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["results"], + "properties": { + "results": { + "type": "array", + "minItems": 1, + "maxItems": maximum_items, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["operation", "id", "revision", "etag", "data"], + "properties": { + "operation": {"enum": operations}, + "id": {"type": "string", "format": "uuid"}, + "revision": {"type": "integer", "format": "int64", "minimum": 1}, + "etag": {"type": "string"}, + "data": {"$ref": format!("#/components/schemas/{entity_id}")}, + } + } + } + } + } + } + } + } + }) +} + +async fn body_is_empty(body: Body) -> bool { + to_bytes(body, 0).await.is_ok_and(|bytes| bytes.is_empty()) +} + +fn parse_create_body(body: &[u8]) -> Result, ()> { + let value = parse_json_strict(body).map_err(|_| ())?; + let object = value.as_object().ok_or(())?; + if object.len() != 1 { + return Err(()); + } + object + .get("data") + .and_then(Value::as_object) + .cloned() + .ok_or(()) +} + +fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + let mut values = headers.get_all(name).iter(); + let value = values.next()?; + if values.next().is_some() { + return None; + } + value.to_str().ok() +} + +fn single_content_type(headers: &HeaderMap, expected: &str) -> bool { + single_header(headers, CONTENT_TYPE.as_str()) == Some(expected) +} + +fn valid_idempotency_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_IDEMPOTENCY_KEY_BYTES + && value + .bytes() + .all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';') +} + +fn valid_if_match(value: &str) -> bool { + value.len() > 5 + && value.len() <= 256 + && value.starts_with("\"rs-") + && value.ends_with('"') + && value.as_bytes()[1..value.len() - 1] + .iter() + .all(|byte| matches!(byte, 0x21 | 0x23..=0x7e)) +} + +fn invalid_request() -> Response { + fixed_problem( + StatusCode::BAD_REQUEST, + "request.invalid", + "The mutation request is invalid.", + ) +} + +fn unsupported_media_type() -> Response { + fixed_problem( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "unsupported.media_type", + "The request media type is not supported.", + ) +} + +fn precondition_required() -> Response { + fixed_problem( + StatusCode::PRECONDITION_REQUIRED, + "precondition.required", + "The mutation precondition is required.", + ) +} + +fn precondition_failed() -> Response { + fixed_problem( + StatusCode::PRECONDITION_FAILED, + "precondition.failed", + "The mutation precondition failed.", + ) +} + +fn mutation_problem(error: MutationError) -> Response { + match error { + MutationError::InvalidRequest => invalid_request(), + MutationError::PreconditionFailed => precondition_failed(), + MutationError::Conflict => fixed_problem( + StatusCode::CONFLICT, + "mutation.conflict", + "The mutation conflicts with current state.", + ), + MutationError::IdempotencyConflict => fixed_problem( + StatusCode::CONFLICT, + "idempotency.conflict", + "The idempotency key is bound to another request.", + ), + MutationError::Unavailable => fixed_problem( + StatusCode::SERVICE_UNAVAILABLE, + "service.unavailable", + "The Registry mutation service is unavailable.", + ), + } +} + +fn fixed_problem(status: StatusCode, code: &'static str, detail: &'static str) -> Response { + Problem::new( + &format!("urn:registry-server:problem:{code}"), + status.canonical_reason().unwrap_or("Request failed"), + status, + ) + .detail(detail) + .with_extra("code", Value::String(code.to_owned())) + .into_response() +} diff --git a/crates/registry-server/src/api/service.rs b/crates/registry-server/src/api/service.rs new file mode 100644 index 0000000000..5b53c819dc --- /dev/null +++ b/crates/registry-server/src/api/service.rs @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde_json::Value; + +use super::context::AuthorizedRequestContext; +use crate::cursor::{CursorBinding, CursorCodec, CursorContinuation, CursorQuery}; +use crate::model::{CompiledQueryFilterOperator, CompiledQueryKind, CompiledRegistry, HttpMethod}; +use crate::mutation::BatchMutationItem; +use crate::postgres::{PostgresRecordMutationService, PostgresRevisionReadService}; + +pub type ServiceFuture<'a, T> = Pin + Send + 'a>>; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HeldReadResponse { + body: Vec, + strong_etag: Option>, +} + +impl HeldReadResponse { + pub fn from_json(value: &Value) -> Result { + let body = registry_platform_canonical_json::canonicalize_json(value) + .map_err(|_| ReadServiceError::Unavailable)?; + Ok(Self { + body, + strong_etag: None, + }) + } + + pub(crate) fn with_strong_etag(mut self, strong_etag: String) -> Self { + self.strong_etag = Some(strong_etag.into_bytes()); + self + } + + #[must_use] + pub fn body(&self) -> &[u8] { + &self.body + } + + #[must_use] + pub fn strong_etag(&self) -> Option<&[u8]> { + self.strong_etag.as_deref() + } +} + +/// Compiler-authorized input shared by conditional record mutations. +pub struct ConditionalMutationInput<'a> { + pub route_id: &'a str, + pub idempotency_key: &'a str, + pub if_match: &'a str, + pub context: &'a AuthorizedRequestContext, + pub entity_id: &'a str, + pub record_id: &'a str, + pub response_fields: BTreeSet, +} + +/// Compiler-authorized input for one bounded entity-local batch transaction. +pub struct BatchMutationInput<'a> { + pub route_id: &'a str, + pub idempotency_key: &'a str, + pub context: &'a AuthorizedRequestContext, + pub entity_id: &'a str, + pub items: Vec, + pub response_fields: BTreeSet, + pub body_bytes: usize, +} + +#[derive(Clone)] +pub struct RecordReadRequest { + pub entity_id: String, + pub operation_id: String, + pub method: HttpMethod, + pub record_id: Option, + pub context: AuthorizedRequestContext, + /// Exact response fields authorized for this operation. Source plans must + /// select and process only this set, plus compiler-owned row-boundary + /// fields from `context`; they must never fetch the profile's wider field + /// set and rely on response filtering. + pub selected_fields: BTreeSet, + pub query: Option, + /// Hard source-execution result bound. Implementations must apply it in + /// the database plan before rows are materialized. + pub maximum_records: usize, +} + +impl fmt::Debug for RecordReadRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RecordReadRequest") + .field("entity_id", &self.entity_id) + .field("operation_id", &self.operation_id) + .field("method", &self.method) + .field("record_id", &self.record_id.as_ref().map(|_| "")) + .field("context", &"") + .field("selected_fields", &self.selected_fields) + .field("query", &self.query.as_ref().map(|_| "")) + .field("maximum_records", &self.maximum_records) + .finish() + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct CompiledReadQuery { + pub route_id: String, + pub query_operation_id: String, + pub kind: CompiledQueryKind, + pub cursor_binding: CursorBinding, + pub cursor_query: CursorQuery, + pub filters: Vec, + pub sort: Option, + pub page_size: u16, + pub temporal_instant: Option, + pub continuation: Option, +} + +impl fmt::Debug for CompiledReadQuery { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CompiledReadQuery") + .field("route_id", &self.route_id) + .field("query_operation_id", &self.query_operation_id) + .field("kind", &self.kind) + .field("cursor_binding", &self.cursor_binding) + .field("cursor_query", &"") + .field( + "filters", + &self + .filters + .iter() + .map(|_| "") + .collect::>(), + ) + .field("sort", &self.sort) + .field("page_size", &self.page_size) + .field( + "temporal_instant", + &self.temporal_instant.as_ref().map(|_| ""), + ) + .field( + "continuation", + &self.continuation.as_ref().map(|_| ""), + ) + .finish() + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct ReadFilterClause { + pub field: String, + pub operator: CompiledQueryFilterOperator, + pub values: Vec, +} + +impl fmt::Debug for ReadFilterClause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReadFilterClause") + .field("field", &self.field) + .field("operator", &self.operator) + .field("values", &"") + .finish() + } +} + +#[derive(Clone)] +pub struct RecordReadRefusal { + pub method: HttpMethod, + pub operation_id: String, + pub target_record: Option, + pub principal: Option, + pub selected_access_profile: Option, + pub purpose_present: bool, +} + +#[derive(Clone)] +pub struct RevisionReadRequest { + pub entity_id: String, + pub operation_id: String, + pub method: HttpMethod, + pub record_id: String, + pub revision: Option, + pub context: AuthorizedRequestContext, + pub selected_fields: BTreeSet, + pub maximum_records: usize, +} + +impl fmt::Debug for RevisionReadRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RevisionReadRequest") + .field("entity_id", &self.entity_id) + .field("operation_id", &self.operation_id) + .field("method", &self.method) + .field("record_id", &"") + .field("revision", &self.revision.map(|_| "")) + .field("context", &"") + .field("selected_fields", &self.selected_fields) + .field("maximum_records", &self.maximum_records) + .finish() + } +} + +#[derive(Clone)] +pub struct RevisionReadRefusal { + pub method: HttpMethod, + pub operation_id: String, + pub target_record: Option, + pub principal: Option, + pub selected_access_profile: Option, + pub purpose_present: bool, +} + +impl fmt::Debug for RevisionReadRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RevisionReadRefusal") + .field("method", &self.method) + .field("operation_id", &self.operation_id) + .field( + "target_record", + &self.target_record.as_ref().map(|_| ""), + ) + .field("principal", &self.principal.as_ref().map(|_| "")) + .field("selected_access_profile", &self.selected_access_profile) + .field("purpose_present", &self.purpose_present) + .finish() + } +} + +impl fmt::Debug for RecordReadRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RecordReadRefusal") + .field("method", &self.method) + .field("operation_id", &self.operation_id) + .field( + "target_record", + &self.target_record.as_ref().map(|_| ""), + ) + .field("principal", &self.principal.as_ref().map(|_| "")) + .field("selected_access_profile", &self.selected_access_profile) + .field("purpose_present", &self.purpose_present) + .finish() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReadRuntimeIdentity { + pub package_revision: String, + pub schema_fingerprint: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReadServiceError { + Unavailable, + CursorInvalid, +} + +/// Record reads execute only after the HTTP layer has selected and authorized +/// one finite compiled access profile. Implementations must apply the supplied +/// projection, result bound, and row boundaries in the database transaction; +/// the HTTP response projection is only defense in depth. +pub trait RecordReadService: Send + Sync { + fn get( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>>; + + fn list( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result>; + + fn refusal( + &self, + _request: RecordReadRefusal, + ) -> ServiceFuture<'_, Result<(), ReadServiceError>> { + Box::pin(async { Ok(()) }) + } +} + +/// Revision reads operate only on the canonical internal revision journal. +/// The HTTP layer must select and authorize one non-anonymous compiled profile +/// before invoking this boundary. +pub trait RevisionReadService: Send + Sync { + fn detail( + &self, + request: RevisionReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>>; + + fn list( + &self, + request: RevisionReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>>; + + fn refusal( + &self, + _request: RevisionReadRefusal, + ) -> ServiceFuture<'_, Result<(), ReadServiceError>> { + Box::pin(async { Ok(()) }) + } +} + +pub trait ReadinessProbe: Send + Sync { + fn is_ready(&self) -> ServiceFuture<'_, bool>; +} + +#[derive(Clone)] +pub struct HttpService { + pub(crate) registry: Arc, + pub(crate) identity: ReadRuntimeIdentity, + pub(crate) records: Arc, + pub(crate) revisions: Option>, + pub(crate) cursors: Arc, + pub(crate) mutations: Option>, + pub(crate) readiness: Arc, +} + +impl HttpService { + #[must_use] + pub fn new( + registry: Arc, + identity: ReadRuntimeIdentity, + records: Arc, + readiness: Arc, + cursors: Arc, + ) -> Self { + Self { + registry, + identity, + records, + revisions: None, + cursors, + mutations: None, + readiness, + } + } + + #[must_use] + pub fn with_postgres_mutations( + mut self, + mutations: Arc, + ) -> Self { + self.mutations = Some(mutations); + self + } + + #[must_use] + pub fn with_postgres_revisions(mut self, revisions: Arc) -> Self { + self.revisions = Some(revisions); + self + } + + #[must_use] + pub fn with_revisions(mut self, revisions: Arc) -> Self { + self.revisions = Some(revisions); + self + } +} diff --git a/crates/registry-server/src/artifacts.rs b/crates/registry-server/src/artifacts.rs new file mode 100644 index 0000000000..4d5f99e047 --- /dev/null +++ b/crates/registry-server/src/artifacts.rs @@ -0,0 +1,674 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeMap; + +use registry_platform_canonical_json::canonicalize_json; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::contract::{ + FieldTypeSource, ManifestProjectionSource, MutationMode, Operation, PackageIdentitySource, +}; +use crate::diagnostics::Diagnostic; +use crate::generated_ddl::DdlInventory; +use crate::manifest_adapter::project_manifest_bytes; +use crate::model::{ + CompiledAccessInventory, CompiledEntity, CompiledEventDeliveryInventory, + CompiledMetadataInventory, CompiledModuleIdentity, CompiledQueryInventory, CompiledQueryKind, + CompiledRevisionKind, CompiledRouteInventory, HttpMethod, +}; +use crate::physical_names::{hex_prefix, PhysicalNameInventory}; + +pub const REGISTRY_METADATA_ARTIFACT_PATH: &str = "generated/metadata/registry.json"; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GeneratedArtifact { + pub path: String, + pub media_type: String, + pub sha256: String, + pub bytes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GeneratedArtifacts { + artifacts: BTreeMap, +} + +impl GeneratedArtifacts { + pub fn entries(&self) -> &BTreeMap { + &self.artifacts + } + + pub fn get(&self, path: &str) -> Option<&GeneratedArtifact> { + self.artifacts.get(path) + } + + pub fn canonical_inventory_bytes(&self) -> Result, Diagnostic> { + let value = serde_json::to_value(self).map_err(|_| canonicalization_error())?; + canonicalize_json(&value).map_err(|_| canonicalization_error()) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EffectiveModel<'a> { + pub registry_id: &'a str, + pub version: &'a str, + pub default_language: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub package: Option<&'a PackageIdentitySource>, + #[serde(skip_serializing_if = "Option::is_none")] + pub manifest_projection: Option<&'a ManifestProjectionSource>, + pub module_order: &'a [String], + pub module_closure: &'a [CompiledModuleIdentity], + pub entities: &'a BTreeMap, + pub physical_names: &'a PhysicalNameInventory, + pub metadata_inventory: &'a CompiledMetadataInventory, + pub query_inventory: &'a CompiledQueryInventory, + pub event_delivery_inventory: &'a CompiledEventDeliveryInventory, +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn generate_artifacts( + registry_id: &str, + version: &str, + default_language: &str, + package: Option<&PackageIdentitySource>, + manifest_projection: Option<&ManifestProjectionSource>, + module_order: &[String], + module_closure: &[CompiledModuleIdentity], + entities: &BTreeMap, + physical_names: &PhysicalNameInventory, + routes: &CompiledRouteInventory, + access: &CompiledAccessInventory, + metadata: &CompiledMetadataInventory, + query: &CompiledQueryInventory, + event_deliveries: &CompiledEventDeliveryInventory, + ddl: &DdlInventory, +) -> Result { + let mut artifacts = BTreeMap::new(); + insert_json( + &mut artifacts, + "compiled/effective-model.json", + &EffectiveModel { + registry_id, + version, + default_language, + package, + manifest_projection, + module_order, + module_closure, + entities, + physical_names, + metadata_inventory: metadata, + query_inventory: query, + event_delivery_inventory: event_deliveries, + }, + )?; + insert_json(&mut artifacts, "compiled/modules.json", &module_closure)?; + insert_json(&mut artifacts, "compiled/routes.json", routes)?; + insert_json(&mut artifacts, "compiled/access.json", access)?; + insert_json(&mut artifacts, "compiled/metadata-inventory.json", metadata)?; + insert_json(&mut artifacts, "compiled/query-inventory.json", query)?; + insert_json( + &mut artifacts, + "compiled/event-deliveries.json", + event_deliveries, + )?; + insert_json(&mut artifacts, REGISTRY_METADATA_ARTIFACT_PATH, metadata)?; + insert_bytes( + &mut artifacts, + "generated/postgres/schema.sql", + "application/sql", + ddl.script().into_bytes(), + ); + + let mut schemas = BTreeMap::new(); + for entity in entities.values() { + let schema = entity_schema(entity); + let path = format!("generated/schemas/{}.schema.json", entity.id); + insert_json_value(&mut artifacts, &path, &schema)?; + schemas.insert(entity.id.clone(), schema); + } + let openapi = openapi_document(registry_id, version, entities, routes, &schemas); + insert_json_value(&mut artifacts, "generated/openapi.json", &openapi)?; + if let Some(projection) = manifest_projection { + insert_bytes( + &mut artifacts, + "generated/manifest/registry-manifest.json", + "application/json", + project_manifest_bytes(registry_id, projection, entities)?, + ); + } + Ok(GeneratedArtifacts { artifacts }) +} + +fn entity_schema(entity: &CompiledEntity) -> Value { + let mut properties = Map::new(); + let mut required = Vec::new(); + for field in entity.fields.values() { + properties.insert(field.id.clone(), field_schema(&field.field_type)); + if field.required { + required.push(Value::String(field.id.clone())); + } + } + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": format!("urn:registry-server:entity:{}", entity.id), + "type": "object", + "additionalProperties": false, + "properties": properties, + "required": required, + "x-registry-mutationMode": match entity.mutation_mode { + MutationMode::Mutable => "mutable", + MutationMode::CreateOnly => "create_only", + } + }) +} + +fn field_schema(field_type: &FieldTypeSource) -> Value { + match field_type { + FieldTypeSource::Boolean => json!({"type": "boolean"}), + FieldTypeSource::String { + min_length, + max_length, + } => json!({ + "type": "string", + "minLength": min_length, + "maxLength": max_length, + }), + FieldTypeSource::Text { max_length } => json!({ + "type": "string", + "maxLength": max_length, + }), + FieldTypeSource::Int64 => json!({ + "type": "integer", + "format": "int64", + }), + FieldTypeSource::Decimal { + precision, + scale, + minimum, + maximum, + } => { + let mut schema = json!({ + "type": "string", + "pattern": decimal_pattern(*precision, *scale), + "x-registry-decimalPrecision": precision, + "x-registry-decimalScale": scale, + }); + let object = schema.as_object_mut().expect("decimal schema is an object"); + if let Some(minimum) = minimum { + object.insert( + "x-registry-decimalMinimum".to_owned(), + Value::String(minimum.clone()), + ); + } + if let Some(maximum) = maximum { + object.insert( + "x-registry-decimalMaximum".to_owned(), + Value::String(maximum.clone()), + ); + } + schema + } + FieldTypeSource::Date => json!({"type": "string", "format": "date"}), + FieldTypeSource::Timestamp => json!({"type": "string", "format": "date-time"}), + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => { + json!({"type": "string", "format": "uuid"}) + } + FieldTypeSource::VocabularyCode { vocabulary, values } => json!({ + "type": "string", + "enum": values, + "x-registry-vocabulary": vocabulary, + }), + FieldTypeSource::Crs84Point { precision, bbox } => { + let mut schema = json!({ + "type": "object", + "description": "CRS84 GeoJSON Point with coordinates in [longitude, latitude] order.", + "additionalProperties": false, + "properties": { + "type": {"const": "Point"}, + "coordinates": { + "type": "array", + "prefixItems": [ + {"type": "number", "minimum": -180, "maximum": 180}, + {"type": "number", "minimum": -90, "maximum": 90} + ], + "items": false, + "minItems": 2, + "maxItems": 2 + } + }, + "required": ["type", "coordinates"], + "x-registry-coordinateReferenceSystem": "CRS84", + "x-registry-coordinatePrecision": precision, + }); + if let Some(bbox) = bbox { + schema + .as_object_mut() + .expect("point schema is an object") + .insert("x-registry-bbox".to_owned(), json!(bbox)); + } + schema + } + FieldTypeSource::Structured { max_bytes, schema } => { + let mut schema = schema.clone(); + schema + .as_object_mut() + .expect("validated structured schema is an object") + .insert("x-registry-maxBytes".to_owned(), json!(max_bytes)); + schema + } + } +} + +pub(crate) fn decimal_pattern(precision: u8, scale: u8) -> String { + let integer_digits = precision - scale; + let integer = if integer_digits == 0 { + "0".to_owned() + } else { + format!("(0|[1-9][0-9]{{0,{}}})", integer_digits - 1) + }; + if scale == 0 { + format!("^-?{integer}$") + } else { + format!("^-?{integer}\\.[0-9]{{{scale}}}$") + } +} + +fn openapi_document( + registry_id: &str, + version: &str, + entities: &BTreeMap, + routes: &CompiledRouteInventory, + schemas: &BTreeMap, +) -> Value { + let mut paths = Map::new(); + for route in &routes.routes { + let method = match route.method { + HttpMethod::Delete => "delete", + HttpMethod::Get => "get", + HttpMethod::Patch => "patch", + HttpMethod::Post => "post", + }; + let path_entry = paths + .entry(route.path.clone()) + .or_insert_with(|| Value::Object(Map::new())); + let Value::Object(operations) = path_entry else { + unreachable!("OpenAPI path entries are objects") + }; + let (status, description) = if route.operation == Operation::Create { + ("201", "Record created") + } else { + ("200", "Operation completed") + }; + let mut responses = Map::new(); + responses.insert(status.to_owned(), json!({"description": description})); + let mut operation = Map::from_iter([ + ("operationId".to_owned(), json!(route.id)), + ("x-registry-entity".to_owned(), json!(route.entity_id)), + ( + "x-registry-operation".to_owned(), + json!(operation_name(route.operation)), + ), + ( + "x-registry-accessProfiles".to_owned(), + json!(route.access_profiles), + ), + ("responses".to_owned(), Value::Object(responses)), + ]); + if let Some(kind) = route.query_kind { + operation.insert( + "x-registry-queryKind".to_owned(), + Value::String(query_kind_name(kind).to_owned()), + ); + operation.insert("parameters".to_owned(), query_parameters(kind)); + } else if let Some(kind) = route.revision_kind { + operation.insert("parameters".to_owned(), revision_parameters(kind)); + operation.insert( + "x-registry-maximumRecords".to_owned(), + json!(route.maximum_records), + ); + } else if route.operation == Operation::Batch { + let batch = entities + .get(&route.entity_id) + .and_then(|entity| entity.batch.as_ref()) + .expect("batch routes require compiled bounds"); + let allow_create = route.access_profiles.iter().any(|profile_id| { + entities[&route.entity_id].access_profiles[profile_id] + .operations + .contains(&Operation::Create) + }); + let allow_patch = route.access_profiles.iter().any(|profile_id| { + entities[&route.entity_id].access_profiles[profile_id] + .operations + .contains(&Operation::Patch) + }); + operation.insert("parameters".to_owned(), access_profile_parameters()); + operation.insert( + "x-registry-maximumItems".to_owned(), + json!(batch.maximum_items), + ); + operation.insert( + "x-registry-maximumBytes".to_owned(), + json!(batch.maximum_bytes), + ); + operation.insert( + "requestBody".to_owned(), + batch_request_body( + &route.entity_id, + batch.maximum_items, + allow_create, + allow_patch, + ), + ); + operation.insert( + "responses".to_owned(), + batch_response( + &route.entity_id, + batch.maximum_items, + allow_create, + allow_patch, + ), + ); + } + operations.insert(method.to_owned(), Value::Object(operation)); + } + let component_schemas: Map = schemas + .iter() + .map(|(id, schema)| (id.clone(), schema.clone())) + .collect(); + json!({ + "openapi": "3.1.0", + "info": {"title": registry_id, "version": version}, + "paths": paths, + "components": {"schemas": component_schemas} + }) +} + +fn access_profile_parameters() -> Value { + Value::Array(vec![query_parameter( + "accessProfile", + false, + false, + json!({"type": "string"}), + "Select one compiled access profile.", + )]) +} + +fn batch_request_body( + entity_id: &str, + maximum_items: u16, + allow_create: bool, + allow_patch: bool, +) -> Value { + let mut item_schemas = Vec::new(); + if allow_create { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["operation", "data"], + "properties": { + "operation": {"const": "create"}, + "data": {"$ref": format!("#/components/schemas/{entity_id}")}, + } + })); + } + if allow_patch { + item_schemas.push(json!({ + "type": "object", + "additionalProperties": false, + "required": ["operation", "recordId", "ifMatch", "patch"], + "properties": { + "operation": {"const": "patch"}, + "recordId": {"type": "string", "format": "uuid"}, + "ifMatch": {"type": "string"}, + "patch": {"type": "array", "minItems": 1, "maxItems": 128}, + } + })); + } + json!({ + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { + "type": "array", + "minItems": 1, + "maxItems": maximum_items, + "items": {"oneOf": item_schemas} + } + } + } + } + } + }) +} + +fn batch_response( + entity_id: &str, + maximum_items: u16, + allow_create: bool, + allow_patch: bool, +) -> Value { + let operations = [ + allow_create.then_some("create"), + allow_patch.then_some("patch"), + ] + .into_iter() + .flatten() + .collect::>(); + json!({ + "200": { + "description": "Atomic batch committed", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["results"], + "properties": { + "results": { + "type": "array", + "minItems": 1, + "maxItems": maximum_items, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["operation", "id", "revision", "etag", "data"], + "properties": { + "operation": {"enum": operations}, + "id": {"type": "string", "format": "uuid"}, + "revision": {"type": "integer", "format": "int64", "minimum": 1}, + "etag": {"type": "string"}, + "data": {"$ref": format!("#/components/schemas/{entity_id}")}, + } + } + } + } + } + } + } + } + }) +} + +fn revision_parameters(kind: CompiledRevisionKind) -> Value { + let mut parameters = vec![query_parameter( + "accessProfile", + false, + false, + json!({"type": "string"}), + "Select one compiled access profile.", + )]; + parameters.push(path_parameter( + "record_id", + json!({"type": "string", "format": "uuid"}), + "Canonical record UUID.", + )); + if kind == CompiledRevisionKind::Detail { + parameters.push(path_parameter( + "revision", + json!({"type": "integer", "format": "int64", "minimum": 1}), + "Exact positive record revision.", + )); + } + Value::Array(parameters) +} + +fn query_parameters(kind: CompiledQueryKind) -> Value { + let mut parameters = vec![ + query_parameter( + "accessProfile", + false, + false, + json!({"type": "string"}), + "Select one compiled access profile.", + ), + query_parameter( + "fields", + false, + false, + json!({"type": "string"}), + "Comma-separated subset of readable fields.", + ), + query_parameter( + "filter", + false, + true, + json!({"type": "string"}), + "Repeatable field:operator:value filter clause.", + ), + query_parameter( + "sort", + false, + false, + json!({"type": "string"}), + "One compiled sortable field, ascending only.", + ), + query_parameter( + "pageSize", + false, + false, + json!({"type": "integer", "minimum": 1}), + "Bounded page size within the compiled maximum.", + ), + query_parameter( + "cursor", + false, + false, + json!({"type": "string"}), + "Opaque continuation cursor for the next page.", + ), + ]; + if kind == CompiledQueryKind::AsOf { + parameters.push(query_parameter( + "asOf", + true, + false, + json!({"type": "string", "format": "date-time"}), + "Strict UTC RFC3339 instant for the as-of temporal query.", + )); + } + Value::Array(parameters) +} + +fn query_parameter( + name: &str, + required: bool, + repeatable: bool, + schema: Value, + description: &str, +) -> Value { + json!({ + "name": name, + "in": "query", + "required": required, + "description": description, + "schema": schema, + "explode": repeatable, + }) +} + +fn path_parameter(name: &str, schema: Value, description: &str) -> Value { + json!({ + "name": name, + "in": "path", + "required": true, + "description": description, + "schema": schema, + }) +} + +fn query_kind_name(kind: CompiledQueryKind) -> &'static str { + match kind { + CompiledQueryKind::List => "list", + CompiledQueryKind::Current => "current", + CompiledQueryKind::AsOf => "as_of", + } +} + +fn operation_name(operation: Operation) -> &'static str { + match operation { + Operation::Create => "create", + Operation::Get => "get", + Operation::List => "list", + Operation::Patch => "patch", + Operation::Tombstone => "tombstone", + Operation::Batch => "batch", + Operation::Revisions => "revisions", + } +} + +fn insert_json( + artifacts: &mut BTreeMap, + path: &str, + value: &T, +) -> Result<(), Diagnostic> { + let value = serde_json::to_value(value).map_err(|_| canonicalization_error())?; + insert_json_value(artifacts, path, &value) +} + +fn insert_json_value( + artifacts: &mut BTreeMap, + path: &str, + value: &Value, +) -> Result<(), Diagnostic> { + let bytes = canonicalize_json(value).map_err(|_| canonicalization_error())?; + insert_bytes(artifacts, path, "application/json", bytes); + Ok(()) +} + +fn insert_bytes( + artifacts: &mut BTreeMap, + path: &str, + media_type: &str, + bytes: Vec, +) { + let digest = Sha256::digest(&bytes); + artifacts.insert( + path.to_owned(), + GeneratedArtifact { + path: path.to_owned(), + media_type: media_type.to_owned(), + sha256: format!("sha256:{}", hex_prefix(&digest, digest.len())), + bytes, + }, + ); +} + +fn canonicalization_error() -> Diagnostic { + Diagnostic::error( + "artifact.canonicalization_failed", + "artifacts", + "the generated artifact could not be canonicalized", + ) +} diff --git a/crates/registry-server/src/audit.rs b/crates/registry-server/src/audit.rs new file mode 100644 index 0000000000..82aed08182 --- /dev/null +++ b/crates/registry-server/src/audit.rs @@ -0,0 +1,607 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Database-owned Registry Server audit chain and pre-I/O release gate. + +use std::time::Duration; + +use deadpool_postgres::Client; +use registry_platform_audit::{AuditChainHasher, AuditEnvelope, AuditKeyHasher, AuditProfile}; +use registry_platform_canonical_json::canonicalize_json; +use serde_json::{json, Value}; +use tokio_postgres::Transaction; +use uuid::Uuid; + +use crate::model::HttpMethod; +use crate::postgres::{ + begin_record_transaction, ClaimContext, ExpectedRegistryIdentity, RegistryLockKey, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PreIoAuditKind { + Attempt, + Refusal, +} + +pub struct PreIoAudit<'a> { + pub kind: PreIoAuditKind, + pub method: HttpMethod, + pub operation_id: &'a str, + pub target_record: Option<&'a str>, +} + +pub(crate) struct HttpRefusalAudit<'a> { + pub method: HttpMethod, + pub operation_id: &'a str, + pub target_record: Option<&'a str>, + pub principal: Option<&'a str>, + pub selected_access_profile: Option<&'a str>, + pub purpose_present: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum RegistryAuditError { + #[error("audit context is invalid")] + InvalidContext, + #[error("audit journal is unavailable")] + Unavailable, +} + +pub(crate) struct TerminalAudit { + pub outcome: TerminalAuditOutcome, + pub method: HttpMethod, + pub operation_id: String, + pub entity_id: String, + pub package_revision: String, + pub selected_access_profile: String, + pub purpose_present: bool, + pub principal_reference: Option, + pub record_reference: Option, + pub record_revision: Option, + pub result_count: Option, + pub field_set_reference: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TerminalAuditOutcome { + Committed, + Replayed, + Returned, + Empty, + Refused, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WebhookAuditPhase { + Attempt, + Terminal, + Replay, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WebhookAuditOutcome { + AttemptStarted, + Delivered, + HttpNonSuccess, + DestinationTimeout, + DestinationResolutionRefused, + DestinationTransportUnavailable, + DestinationPolicyRefused, + DestinationBindingRefused, + PayloadRefused, + WorkerInterrupted, + ReplayRequested, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WebhookAuditDisposition { + Leased, + Delivered, + RetryPending, + DeadLettered, + ReplayPending, +} + +pub(crate) struct WebhookAudit<'a> { + pub event_id: Uuid, + pub compiled_delivery_id: &'a str, + pub package_revision: &'a str, + pub generation: i64, + pub attempt: i16, + pub phase: WebhookAuditPhase, + pub outcome: WebhookAuditOutcome, + pub disposition: WebhookAuditDisposition, +} + +/// Persist one minimized attempt or refusal before protected record I/O. +/// +/// This deliberately owns and commits a transaction separate from any later +/// mutation. A successful return is therefore durable evidence even when the +/// protected operation subsequently fails or rolls back. +pub async fn record_pre_io_audit( + client: &mut Client, + lock_key: RegistryLockKey, + lock_timeout: Duration, + expected: &ExpectedRegistryIdentity, + claims: &ClaimContext, + profile: &AuditProfile, + event: PreIoAudit<'_>, +) -> Result<(), RegistryAuditError> { + if event.operation_id.is_empty() || !profile_is_keyed(profile) { + return Err(RegistryAuditError::InvalidContext); + } + let key_hasher = profile.key_hasher(); + let principal_reference = claims + .principal() + .map(|principal| { + key_hasher.audit_reference_hash( + "registry-server-principal-v1", + &expected.package_revision, + principal, + ) + }) + .transpose() + .map_err(|_| RegistryAuditError::InvalidContext)?; + let record_reference = event + .target_record + .map(|record| { + key_hasher.audit_reference_hash( + "registry-server-record-v1", + &expected.package_revision, + record, + ) + }) + .transpose() + .map_err(|_| RegistryAuditError::InvalidContext)?; + let transaction = begin_record_transaction(client, lock_key, lock_timeout, expected, claims) + .await + .map_err(|_| RegistryAuditError::Unavailable)?; + let record = json!({ + "schema": "registry-server-audit/v1", + "phase": match event.kind { + PreIoAuditKind::Attempt => "attempt", + PreIoAuditKind::Refusal => "refusal", + }, + "method": method_name(event.method), + "operationId": event.operation_id, + "packageRevision": expected.package_revision, + "selectedAccessProfile": claims.access_profile(), + "purposePresent": claims.purpose().is_some(), + "principalReference": principal_reference, + "recordReference": record_reference, + }); + append_envelope(transaction.transaction(), profile, record).await?; + transaction + .commit() + .await + .map_err(|_| RegistryAuditError::Unavailable) +} + +/// Persist a minimized HTTP-layer mutation refusal when authorization failed +/// before a forged `ClaimContext` would be safe to construct. +pub(crate) async fn record_http_refusal_audit( + client: &mut Client, + lock_key: RegistryLockKey, + lock_timeout: Duration, + expected: &ExpectedRegistryIdentity, + profile: &AuditProfile, + event: HttpRefusalAudit<'_>, +) -> Result<(), RegistryAuditError> { + if event.operation_id.is_empty() + || !profile_is_keyed(profile) + || lock_timeout.is_zero() + || lock_timeout > Duration::from_secs(30) + { + return Err(RegistryAuditError::InvalidContext); + } + expected + .validate() + .map_err(|_| RegistryAuditError::InvalidContext)?; + let transaction = client + .transaction() + .await + .map_err(|_| RegistryAuditError::Unavailable)?; + let timeout_millis = + i32::try_from(lock_timeout.as_millis()).map_err(|_| RegistryAuditError::InvalidContext)?; + transaction + .execute( + "SELECT set_config('lock_timeout', $1::text, true)", + &[&format!("{timeout_millis}ms")], + ) + .await + .map_err(|_| RegistryAuditError::Unavailable)?; + transaction + .execute( + "SELECT pg_advisory_xact_lock_shared($1)", + &[&lock_key.get()], + ) + .await + .map_err(|_| RegistryAuditError::Unavailable)?; + let state = transaction + .query_opt( + "SELECT package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .map_err(|_| RegistryAuditError::Unavailable)? + .ok_or(RegistryAuditError::Unavailable)?; + let ready = state.get::<_, String>(7) == "ready" + && state.get::<_, String>(0) == expected.package_id + && state.get::<_, String>(1) == expected.environment + && state.get::<_, String>(2) == expected.instance_id + && state.get::<_, String>(3) == expected.database_id + && state.get::<_, String>(4) == expected.package_revision + && state.get::<_, String>(5) == expected.schema_fingerprint + && state.get::<_, i64>(6) == expected.package_sequence; + if !ready { + return Err(RegistryAuditError::Unavailable); + } + let key_hasher = profile.key_hasher(); + let principal_reference = event + .principal + .map(|principal| { + key_hasher.audit_reference_hash( + "registry-server-principal-v1", + &expected.package_revision, + principal, + ) + }) + .transpose() + .map_err(|_| RegistryAuditError::InvalidContext)?; + let record_reference = event + .target_record + .map(|record| { + key_hasher.audit_reference_hash( + "registry-server-record-v1", + &expected.package_revision, + record, + ) + }) + .transpose() + .map_err(|_| RegistryAuditError::InvalidContext)?; + let mut record = serde_json::Map::from_iter([ + ( + "schema".to_owned(), + Value::String("registry-server-audit/v1".to_owned()), + ), + ("phase".to_owned(), Value::String("refusal".to_owned())), + ( + "method".to_owned(), + Value::String(method_name(event.method).to_owned()), + ), + ( + "operationId".to_owned(), + Value::String(event.operation_id.to_owned()), + ), + ( + "packageRevision".to_owned(), + Value::String(expected.package_revision.clone()), + ), + ( + "purposePresent".to_owned(), + Value::Bool(event.purpose_present), + ), + ]); + if let Some(selected_access_profile) = event.selected_access_profile { + record.insert( + "selectedAccessProfile".to_owned(), + Value::String(selected_access_profile.to_owned()), + ); + } + if let Some(principal_reference) = principal_reference { + record.insert( + "principalReference".to_owned(), + Value::String(principal_reference), + ); + } + if let Some(record_reference) = record_reference { + record.insert( + "recordReference".to_owned(), + Value::String(record_reference), + ); + } + append_envelope(&transaction, profile, Value::Object(record)).await?; + transaction + .commit() + .await + .map_err(|_| RegistryAuditError::Unavailable) +} + +pub(crate) fn profile_is_keyed(profile: &AuditProfile) -> bool { + matches!(profile.chain_hasher(), AuditChainHasher::Keyed(_)) + && matches!(profile.key_hasher(), AuditKeyHasher::Keyed(_)) +} + +pub(crate) async fn append_terminal_audit( + transaction: &Transaction<'_>, + profile: &AuditProfile, + terminal: TerminalAudit, +) -> Result<(), RegistryAuditError> { + append_envelope( + transaction, + profile, + Value::Object(terminal_record(terminal)), + ) + .await +} + +pub(crate) async fn append_webhook_audit( + transaction: &Transaction<'_>, + profile: &AuditProfile, + event: WebhookAudit<'_>, +) -> Result<(), RegistryAuditError> { + let shape_is_valid = match (event.phase, event.outcome, event.disposition) { + ( + WebhookAuditPhase::Attempt, + WebhookAuditOutcome::AttemptStarted, + WebhookAuditDisposition::Leased, + ) => event.attempt > 0, + ( + WebhookAuditPhase::Terminal, + WebhookAuditOutcome::Delivered, + WebhookAuditDisposition::Delivered, + ) + | ( + WebhookAuditPhase::Terminal, + WebhookAuditOutcome::HttpNonSuccess + | WebhookAuditOutcome::DestinationTimeout + | WebhookAuditOutcome::DestinationResolutionRefused + | WebhookAuditOutcome::DestinationTransportUnavailable + | WebhookAuditOutcome::DestinationPolicyRefused + | WebhookAuditOutcome::DestinationBindingRefused + | WebhookAuditOutcome::PayloadRefused + | WebhookAuditOutcome::WorkerInterrupted, + WebhookAuditDisposition::RetryPending | WebhookAuditDisposition::DeadLettered, + ) => event.attempt > 0, + ( + WebhookAuditPhase::Replay, + WebhookAuditOutcome::ReplayRequested, + WebhookAuditDisposition::ReplayPending, + ) => event.attempt == 0, + _ => false, + }; + if !shape_is_valid + || event.generation <= 0 + || event.compiled_delivery_id.is_empty() + || event.compiled_delivery_id.len() > 256 + || event.package_revision.is_empty() + || !profile_is_keyed(profile) + { + return Err(RegistryAuditError::InvalidContext); + } + let key_hasher = profile.key_hasher(); + let event_reference = key_hasher + .audit_reference_hash( + "registry-server-webhook-event-v1", + event.package_revision, + &event.event_id.to_string(), + ) + .map_err(|_| RegistryAuditError::InvalidContext)?; + let delivery_reference = key_hasher + .audit_reference_hash( + "registry-server-webhook-delivery-v1", + event.package_revision, + event.compiled_delivery_id, + ) + .map_err(|_| RegistryAuditError::InvalidContext)?; + append_envelope( + transaction, + profile, + json!({ + "schema": "registry-server-webhook-audit/v1", + "phase": webhook_phase_name(event.phase), + "outcome": webhook_outcome_name(event.outcome), + "disposition": webhook_disposition_name(event.disposition), + "packageRevision": event.package_revision, + "eventReference": event_reference, + "deliveryReference": delivery_reference, + "generation": event.generation, + "attempt": event.attempt, + }), + ) + .await +} + +fn webhook_phase_name(phase: WebhookAuditPhase) -> &'static str { + match phase { + WebhookAuditPhase::Attempt => "attempt", + WebhookAuditPhase::Terminal => "terminal", + WebhookAuditPhase::Replay => "replay", + } +} + +fn webhook_outcome_name(outcome: WebhookAuditOutcome) -> &'static str { + match outcome { + WebhookAuditOutcome::AttemptStarted => "attempt_started", + WebhookAuditOutcome::Delivered => "delivered", + WebhookAuditOutcome::HttpNonSuccess => "http_non_success", + WebhookAuditOutcome::DestinationTimeout => "destination_timeout", + WebhookAuditOutcome::DestinationResolutionRefused => "destination_resolution_refused", + WebhookAuditOutcome::DestinationTransportUnavailable => "destination_transport_unavailable", + WebhookAuditOutcome::DestinationPolicyRefused => "destination_policy_refused", + WebhookAuditOutcome::DestinationBindingRefused => "destination_binding_refused", + WebhookAuditOutcome::PayloadRefused => "payload_refused", + WebhookAuditOutcome::WorkerInterrupted => "worker_interrupted", + WebhookAuditOutcome::ReplayRequested => "replay_requested", + } +} + +fn webhook_disposition_name(disposition: WebhookAuditDisposition) -> &'static str { + match disposition { + WebhookAuditDisposition::Leased => "leased", + WebhookAuditDisposition::Delivered => "delivered", + WebhookAuditDisposition::RetryPending => "retry_pending", + WebhookAuditDisposition::DeadLettered => "dead_lettered", + WebhookAuditDisposition::ReplayPending => "replay_pending", + } +} + +fn terminal_record(terminal: TerminalAudit) -> serde_json::Map { + let mut record = serde_json::Map::from_iter([ + ( + "schema".to_owned(), + Value::String("registry-server-audit/v1".to_owned()), + ), + ("phase".to_owned(), Value::String("terminal".to_owned())), + ( + "outcome".to_owned(), + Value::String( + match terminal.outcome { + TerminalAuditOutcome::Committed => "committed", + TerminalAuditOutcome::Replayed => "replayed", + TerminalAuditOutcome::Returned => "returned", + TerminalAuditOutcome::Empty => "empty", + TerminalAuditOutcome::Refused => "refused", + } + .to_owned(), + ), + ), + ( + "method".to_owned(), + Value::String(method_name(terminal.method).to_owned()), + ), + ( + "operationId".to_owned(), + Value::String(terminal.operation_id), + ), + ("entityId".to_owned(), Value::String(terminal.entity_id)), + ( + "packageRevision".to_owned(), + Value::String(terminal.package_revision), + ), + ( + "selectedAccessProfile".to_owned(), + Value::String(terminal.selected_access_profile), + ), + ( + "purposePresent".to_owned(), + Value::Bool(terminal.purpose_present), + ), + ]); + if let Some(principal_reference) = terminal.principal_reference { + record.insert( + "principalReference".to_owned(), + Value::String(principal_reference), + ); + } + if let Some(record_reference) = terminal.record_reference { + record.insert( + "recordReference".to_owned(), + Value::String(record_reference), + ); + } + if let Some(record_revision) = terminal.record_revision { + record.insert("recordRevision".to_owned(), json!(record_revision)); + } + if let Some(result_count) = terminal.result_count { + record.insert("resultCount".to_owned(), json!(result_count)); + } + if let Some(field_set_reference) = terminal.field_set_reference { + record.insert( + "fieldSetReference".to_owned(), + Value::String(field_set_reference), + ); + } + record +} + +pub(crate) struct ReadTerminalAudit { + pub terminal: TerminalAudit, + pub query_reference: Option, + pub row_boundary_reference: Option, +} + +pub(crate) async fn append_read_terminal_audit( + transaction: &Transaction<'_>, + profile: &AuditProfile, + read_terminal: ReadTerminalAudit, +) -> Result<(), RegistryAuditError> { + let mut terminal = terminal_record(read_terminal.terminal); + if let Some(query_reference) = read_terminal.query_reference { + terminal.insert("queryReference".to_owned(), Value::String(query_reference)); + } + if let Some(row_boundary_reference) = read_terminal.row_boundary_reference { + terminal.insert( + "rowBoundaryReference".to_owned(), + Value::String(row_boundary_reference), + ); + } + append_envelope(transaction, profile, Value::Object(terminal)).await +} + +async fn append_envelope( + transaction: &Transaction<'_>, + profile: &AuditProfile, + record: Value, +) -> Result<(), RegistryAuditError> { + transaction + .execute( + "INSERT INTO registry_internal.registry_audit_head (singleton, last_hash) + VALUES (true, NULL) + ON CONFLICT (singleton) DO NOTHING", + &[], + ) + .await + .map_err(|_| RegistryAuditError::Unavailable)?; + let row = transaction + .query_one( + "SELECT last_hash + FROM registry_internal.registry_audit_head + WHERE singleton + FOR UPDATE", + &[], + ) + .await + .map_err(|_| RegistryAuditError::Unavailable)?; + let previous = row + .get::<_, Option>>(0) + .map(|bytes| <[u8; 32]>::try_from(bytes).map_err(|_| RegistryAuditError::Unavailable)) + .transpose()?; + let envelope = AuditEnvelope::new_with_hasher(record, previous, &profile.chain_hasher()) + .map_err(|_| RegistryAuditError::Unavailable)?; + let envelope_value = + serde_json::to_value(&envelope).map_err(|_| RegistryAuditError::Unavailable)?; + let envelope_bytes = + canonicalize_json(&envelope_value).map_err(|_| RegistryAuditError::Unavailable)?; + let changed = transaction + .execute( + "INSERT INTO registry_internal.registry_audit + (envelope_id, record_hash, envelope) + VALUES ($1, $2, $3)", + &[ + &envelope.envelope_id, + &envelope.record_hash.as_slice(), + &envelope_bytes, + ], + ) + .await + .map_err(|_| RegistryAuditError::Unavailable)?; + if changed != 1 { + return Err(RegistryAuditError::Unavailable); + } + let changed = transaction + .execute( + "UPDATE registry_internal.registry_audit_head + SET last_hash = $1 + WHERE singleton", + &[&envelope.record_hash.as_slice()], + ) + .await + .map_err(|_| RegistryAuditError::Unavailable)?; + if changed != 1 { + return Err(RegistryAuditError::Unavailable); + } + Ok(()) +} + +fn method_name(method: HttpMethod) -> &'static str { + match method { + HttpMethod::Delete => "DELETE", + HttpMethod::Get => "GET", + HttpMethod::Patch => "PATCH", + HttpMethod::Post => "POST", + } +} diff --git a/crates/registry-server/src/auth.rs b/crates/registry-server/src/auth.rs new file mode 100644 index 0000000000..1762419467 --- /dev/null +++ b/crates/registry-server/src/auth.rs @@ -0,0 +1,451 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Production bearer admission and OIDC claim mapping for Registry HTTP routes. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::State; +use axum::http::header::AUTHORIZATION; +use axum::http::{Request, StatusCode}; +use axum::middleware::Next; +use axum::response::Response; +use registry_platform_authcommon::{parse_bearer_token, validate_compact_access_token}; +use registry_platform_httpsec::Problem; +use registry_platform_oidc::{Audience, JwksFetcher, TokenVerifier, TokenVerifierConfig}; +use serde_json::Value; +use thiserror::Error; + +use crate::api::{VerifiedClaimValue, VerifiedRequestClaims}; +use crate::contract::BoundaryOperator; +use crate::model::CompiledRegistry; + +const MAX_CLAIM_NAME_BYTES: usize = 128; +const MAX_SCOPE_VALUES: usize = 128; +const MAX_SCOPE_VALUE_BYTES: usize = 512; + +const REGISTERED_CLAIMS: &[&str] = &[ + "iss", + "aud", + "exp", + "iat", + "nbf", + "sub", + "client_id", + "azp", + "jti", + "cnf", +]; + +/// The one bounded JSON shape accepted for a compiled row-boundary claim. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum RowBoundaryClaimType { + DirectString, + DirectStringSet, +} + +/// One operator-configured row-boundary claim mapping. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RowBoundaryClaimMapping { + name: String, + value_type: RowBoundaryClaimType, +} + +impl RowBoundaryClaimMapping { + #[must_use] + pub fn new(name: impl Into, value_type: RowBoundaryClaimType) -> Self { + Self { + name: name.into(), + value_type, + } + } +} + +/// Direct claims that may become Registry authority after OIDC verification. +#[derive(Clone, Eq, PartialEq)] +pub struct AuthorityClaimConfig { + principal_claim: String, + purpose_claim: Option, + row_boundary_claims: Vec, +} + +impl AuthorityClaimConfig { + #[must_use] + pub fn new( + principal_claim: impl Into, + purpose_claim: Option, + row_boundary_claims: Vec, + ) -> Self { + Self { + principal_claim: principal_claim.into(), + purpose_claim, + row_boundary_claims, + } + } +} + +impl fmt::Debug for AuthorityClaimConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorityClaimConfig") + .field("principal_claim", &self.principal_claim) + .field("purpose_claim", &self.purpose_claim) + .field("row_boundary_claim_count", &self.row_boundary_claims.len()) + .finish() + } +} + +/// A closed construction failure. It deliberately carries no configured URL +/// or claim value that an operator could accidentally copy into a log. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum AuthenticationConfigError { + #[error("the OIDC access-token verification profile is invalid")] + InvalidVerifierProfile, + #[error("an authority claim mapping is invalid")] + InvalidClaimMapping, + #[error("the authority claim mapping does not match the compiled Registry")] + CompiledAuthorityMismatch, +} + +/// A closed request failure. Platform verifier details and the bearer value +/// are intentionally erased at this boundary. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum AuthenticationError { + #[error("the bearer credential is malformed")] + MalformedCredential, + #[error("the bearer credential was refused")] + VerificationRefused, + #[error("the verified credential has invalid authority claims")] + InvalidClaims, +} + +/// Production OIDC verifier plus the only claim mapping allowed to construct +/// [`VerifiedRequestClaims`]. +pub struct RegistryAuthenticator { + verifier: TokenVerifier, + audience: String, + principal_claim: String, + purpose_claim: Option, + row_boundary_claims: BTreeMap, +} + +impl RegistryAuthenticator { + /// Bind one exact platform verifier profile and one exact authority mapping + /// to the immutable compiled Registry served by this process. + pub fn new( + registry: &CompiledRegistry, + verifier_config: TokenVerifierConfig, + key_source: Arc, + claims: AuthorityClaimConfig, + ) -> Result { + validate_verifier_profile(&verifier_config)?; + let row_boundary_claims = validate_claim_mapping(registry, &verifier_config, &claims)?; + let audience = verifier_config.audiences[0].clone(); + Ok(Self { + verifier: TokenVerifier::new(verifier_config, key_source), + audience, + principal_claim: claims.principal_claim, + purpose_claim: claims.purpose_claim, + row_boundary_claims, + }) + } + + /// Verify and map one already-admitted compact bearer token. + pub async fn authenticate( + &self, + token: &str, + ) -> Result { + validate_compact_access_token(token) + .map_err(|_| AuthenticationError::MalformedCredential)?; + let verified = self + .verifier + .verify(token) + .await + .map_err(|_| AuthenticationError::VerificationRefused)?; + if !matches!( + verified.claims.aud.as_ref(), + Some(Audience::One(audience)) if audience == &self.audience + ) { + return Err(AuthenticationError::InvalidClaims); + } + + let claims = &verified.claims.extra; + let principal = required_direct_string(claims.get(&self.principal_claim))?; + let purpose = self + .purpose_claim + .as_deref() + .map(|name| optional_direct_string(claims.get(name))) + .transpose()? + .flatten(); + if verified.scopes.len() > MAX_SCOPE_VALUES { + return Err(AuthenticationError::InvalidClaims); + } + let scopes = verified + .scopes + .into_iter() + .map(validate_scope) + .collect::, _>>()?; + let direct_claims = self + .row_boundary_claims + .iter() + .filter_map(|(name, value_type)| { + claims.get(name).map(|value| { + mapped_claim(value, *value_type).map(|value| (name.clone(), value)) + }) + }) + .collect::, _>>()?; + + VerifiedRequestClaims::authenticated( + self.principal_claim.clone(), + principal, + scopes, + purpose, + direct_claims, + ) + .map_err(|_| AuthenticationError::InvalidClaims) + } +} + +impl fmt::Debug for RegistryAuthenticator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RegistryAuthenticator") + .field("issuer", &"") + .field("audience", &"") + .field("principal_claim", &self.principal_claim) + .field("purpose_claim", &self.purpose_claim) + .field("row_boundary_claims", &self.row_boundary_claims.keys()) + .finish() + } +} + +/// Authenticate a presented bearer before any Registry route authorization. +/// Absence is preserved for anonymous profiles, while every invalid presented +/// credential fails closed. Any caller-supplied authority extension is removed +/// before either branch. +pub(crate) async fn authenticate_request( + State(authenticator): State>, + mut request: Request, + next: Next, +) -> Response { + request.extensions_mut().remove::(); + let token = match bearer_token(request.headers()) { + Ok(None) => return next.run(request).await, + Ok(Some(token)) => token, + Err(_) => return authentication_refused(), + }; + let claims = match authenticator.authenticate(token).await { + Ok(claims) => claims, + Err(_) => return authentication_refused(), + }; + request.extensions_mut().insert(claims); + next.run(request).await +} + +fn bearer_token(headers: &axum::http::HeaderMap) -> Result, AuthenticationError> { + let mut values = headers.get_all(AUTHORIZATION).iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(AuthenticationError::MalformedCredential); + } + let value = value + .to_str() + .map_err(|_| AuthenticationError::MalformedCredential)?; + parse_bearer_token(value) + .map(Some) + .map_err(|_| AuthenticationError::MalformedCredential) +} + +fn validate_verifier_profile( + config: &TokenVerifierConfig, +) -> Result<(), AuthenticationConfigError> { + if !valid_config_value(&config.issuer) + || config.audiences.len() != 1 + || !valid_config_value(&config.audiences[0]) + || config.allowed_algorithms.len() != 1 + || config.allowed_typ.len() != 1 + || !valid_config_value(&config.allowed_typ[0]) + || !valid_claim_name(&config.scope_claim) + || REGISTERED_CLAIMS.contains(&config.scope_claim.as_str()) + || config.scope_separator.is_control() + || config.scope_separator.is_alphanumeric() + { + return Err(AuthenticationConfigError::InvalidVerifierProfile); + } + if let Some(scope_map) = &config.scope_map { + for (source, mapped) in scope_map { + if !valid_scope_value(source) + || mapped.is_empty() + || mapped.len() > MAX_SCOPE_VALUES + || mapped.iter().any(|scope| !valid_scope_value(scope)) + || mapped.iter().collect::>().len() != mapped.len() + { + return Err(AuthenticationConfigError::InvalidVerifierProfile); + } + } + } + Ok(()) +} + +fn validate_claim_mapping( + registry: &CompiledRegistry, + verifier: &TokenVerifierConfig, + claims: &AuthorityClaimConfig, +) -> Result, AuthenticationConfigError> { + let mut configured_names = BTreeSet::new(); + if !valid_authority_claim_name(&claims.principal_claim) + || !configured_names.insert(claims.principal_claim.as_str()) + || claims.principal_claim == verifier.scope_claim + { + return Err(AuthenticationConfigError::InvalidClaimMapping); + } + if let Some(purpose) = &claims.purpose_claim { + if !valid_authority_claim_name(purpose) + || !configured_names.insert(purpose) + || purpose == &verifier.scope_claim + { + return Err(AuthenticationConfigError::InvalidClaimMapping); + } + } + let mut configured_rows = BTreeMap::new(); + for mapping in &claims.row_boundary_claims { + if !valid_authority_claim_name(&mapping.name) + || mapping.name == verifier.scope_claim + || !configured_names.insert(mapping.name.as_str()) + || configured_rows + .insert(mapping.name.clone(), mapping.value_type) + .is_some() + { + return Err(AuthenticationConfigError::InvalidClaimMapping); + } + } + + let mut expected_rows = BTreeMap::new(); + let mut purpose_required = false; + for entity in registry.entities().values() { + for profile in entity.access_profiles.values() { + if profile.anonymous { + if profile.principal_claim.is_some() + || !profile.required_scopes.is_empty() + || !profile.required_purposes.is_empty() + || !profile.row_boundaries.is_empty() + { + return Err(AuthenticationConfigError::CompiledAuthorityMismatch); + } + continue; + } + if profile.principal_claim.as_deref() != Some(claims.principal_claim.as_str()) { + return Err(AuthenticationConfigError::CompiledAuthorityMismatch); + } + purpose_required |= !profile.required_purposes.is_empty(); + for boundary in &profile.row_boundaries { + let value_type = match boundary.operator { + BoundaryOperator::Equals => RowBoundaryClaimType::DirectString, + BoundaryOperator::In => RowBoundaryClaimType::DirectStringSet, + }; + if expected_rows + .insert(boundary.claim.clone(), value_type) + .is_some_and(|prior| prior != value_type) + { + return Err(AuthenticationConfigError::CompiledAuthorityMismatch); + } + } + } + } + if purpose_required != claims.purpose_claim.is_some() || expected_rows != configured_rows { + return Err(AuthenticationConfigError::CompiledAuthorityMismatch); + } + Ok(configured_rows) +} + +fn valid_claim_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_CLAIM_NAME_BYTES + && value.is_ascii() + && !value + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) +} + +fn valid_authority_claim_name(value: &str) -> bool { + valid_claim_name(value) && !REGISTERED_CLAIMS.contains(&value) +} + +fn valid_config_value(value: &str) -> bool { + !value.trim().is_empty() + && value.trim() == value + && value.len() <= 2048 + && !value.chars().any(char::is_control) +} + +fn valid_scope_value(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_SCOPE_VALUE_BYTES + && !value + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) +} + +fn validate_scope(value: String) -> Result { + valid_scope_value(&value) + .then_some(value) + .ok_or(AuthenticationError::InvalidClaims) +} + +fn required_direct_string(value: Option<&Value>) -> Result { + optional_direct_string(value)?.ok_or(AuthenticationError::InvalidClaims) +} + +fn optional_direct_string(value: Option<&Value>) -> Result, AuthenticationError> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.as_str().ok_or(AuthenticationError::InvalidClaims)?; + let value = VerifiedClaimValue::direct_string(value.to_owned()) + .map_err(|_| AuthenticationError::InvalidClaims)?; + match value { + VerifiedClaimValue::DirectString(value) => Ok(Some(value)), + VerifiedClaimValue::DirectStringSet(_) => unreachable!("direct constructor returns string"), + } +} + +fn mapped_claim( + value: &Value, + value_type: RowBoundaryClaimType, +) -> Result { + match value_type { + RowBoundaryClaimType::DirectString => { + let value = value.as_str().ok_or(AuthenticationError::InvalidClaims)?; + VerifiedClaimValue::direct_string(value.to_owned()) + .map_err(|_| AuthenticationError::InvalidClaims) + } + RowBoundaryClaimType::DirectStringSet => { + let values = value.as_array().ok_or(AuthenticationError::InvalidClaims)?; + let values = values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or(AuthenticationError::InvalidClaims) + }) + .collect::, _>>()?; + VerifiedClaimValue::direct_string_set(values) + .map_err(|_| AuthenticationError::InvalidClaims) + } + } +} + +fn authentication_refused() -> Response { + Problem::new( + "urn:registry-server:problem:authentication.refused", + "Unauthorized", + StatusCode::UNAUTHORIZED, + ) + .detail("The bearer credential is missing or refused.") + .with_extra("code", Value::String("authentication.refused".to_owned())) + .into_response() +} diff --git a/crates/registry-server/src/compiler.rs b/crates/registry-server/src/compiler.rs new file mode 100644 index 0000000000..036d57db78 --- /dev/null +++ b/crates/registry-server/src/compiler.rs @@ -0,0 +1,2764 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, BTreeSet}; + +use registry_platform_canonical_json::canonicalize_json; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use time::{format_description::well_known::Rfc3339, Date, Month, OffsetDateTime}; +use uuid::Uuid; + +use crate::artifacts::generate_artifacts; +use crate::contract::{ + parsed_bbox, valid_decimal_bounds, valid_structured_schema, AccessProfileSource, + Classification, ConstraintSource, EntityExtensionSource, EntitySource, EventTrigger, + FieldSource, FieldTypeSource, MutationMode, Operation, RegistryModule, RegistryProject, + UniqueWhenPredicate, ValidTimeRole, WebhookDeadLetterMode, MAX_STRUCTURED_VALUE_BYTES, +}; +use crate::diagnostics::{CompileFailure, Diagnostic}; +use crate::generated_ddl::generate_ddl; +use crate::model::{ + CompiledAccessEntry, CompiledAccessInventory, CompiledEntity, CompiledEventDelivery, + CompiledEventDeliveryInventory, CompiledField, CompiledMetadataEntity, CompiledMetadataEntry, + CompiledMetadataInventory, CompiledModuleIdentity, CompiledQueryFilterField, + CompiledQueryFilterOperator, CompiledQueryInventory, CompiledQueryKind, CompiledQueryOperation, + CompiledQuerySortDirection, CompiledQuerySortField, CompiledQueryTemporalBinding, + CompiledQueryTemporalSemantics, CompiledRegistry, CompiledRevisionKind, CompiledRoute, + CompiledRouteInventory, CompiledTemporal, CompiledWebhookDeliveryMode, HttpMethod, + MAX_REVISION_HISTORY_RECORDS, +}; +use crate::physical_names::{ + hex_prefix, EntityPhysicalNames, PhysicalNameBuilder, PhysicalNameInventory, +}; + +pub const AUTHORING_API_VERSION: &str = "registry.registrystack.org/v1alpha1"; +pub const MAX_BATCH_ITEMS: u16 = 100; +pub const MAX_BATCH_BYTES: u32 = 2_097_152; +pub const MIN_WEBHOOK_ATTEMPT_TIMEOUT_MS: u32 = 100; +/// Maximum per-attempt timeout accepted by the governed event transport. +/// +/// This matches the platform event-destination operation ceiling. Runtime +/// activation may narrow it, but can never widen the compiled authority. +pub const MAX_WEBHOOK_ATTEMPT_TIMEOUT_MS: u32 = 10_000; +pub const MIN_WEBHOOK_BACKOFF_MS: u32 = 100; +pub const MAX_WEBHOOK_BACKOFF_MS: u32 = 3_600_000; +pub const MAX_WEBHOOK_ATTEMPTS: u8 = 20; +/// Maximum canonical event body accepted by the governed webhook transport. +/// +/// This intentionally matches the platform event-destination body ceiling. +/// Keeping it in the pure compiler avoids pulling an HTTP client into the +/// default no-I/O authoring graph; the runtime integration pins the equality. +pub const MAX_WEBHOOK_PAYLOAD_BYTES: u32 = 1_048_576; +const WEBHOOK_BACKOFF_MULTIPLIER: u8 = 2; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompileProfile { + Authoring, + Production, +} + +/// Compile governed source without opening a file, network connection, or database. +pub fn compile_project( + project: &RegistryProject, + modules: &[RegistryModule], + profile: CompileProfile, +) -> Result { + let mut diagnostics = Vec::new(); + let mut findings = Vec::new(); + validate_project_header(project, profile, &mut diagnostics, &mut findings); + let module_closure = + validate_module_locks(project, modules, profile, &mut diagnostics, &mut findings); + let (module_order, module_map) = order_modules(project, modules, &mut diagnostics); + let mut sources = collect_entities(project, &module_order, &module_map, &mut diagnostics); + apply_temporal_roles(&mut sources, &mut diagnostics); + apply_extensions(&mut sources, &module_order, &module_map, &mut diagnostics); + expand_project_access(project, &mut sources, &mut diagnostics); + resolve_vocabularies(project, &mut sources, &mut diagnostics); + validate_entities(&sources, &mut diagnostics); + if !diagnostics.is_empty() { + return Err(CompileFailure::from_errors(diagnostics)); + } + + let (entities, physical_names) = compile_entities(&sources)?; + let (route_inventory, access_inventory) = compile_routes_and_access(&entities)?; + let metadata_inventory = compile_metadata_inventory( + &project.registry.id, + &project.registry.version, + &entities, + &route_inventory, + &access_inventory, + ) + .map_err(CompileFailure::from_one)?; + let query_inventory = compile_query_inventory(&entities, &mut diagnostics); + let event_delivery_inventory = compile_event_delivery_inventory(&entities); + validate_manifest_projection(project, &entities, &mut diagnostics); + if !diagnostics.is_empty() { + return Err(CompileFailure::from_errors(diagnostics)); + } + let ddl = generate_ddl(&entities, &physical_names); + let artifacts = generate_artifacts( + &project.registry.id, + &project.registry.version, + &project.registry.default_language, + project.package.as_ref(), + project.manifest_projection.as_ref(), + &module_order, + &module_closure, + &entities, + &physical_names, + &route_inventory, + &access_inventory, + &metadata_inventory, + &query_inventory, + &event_delivery_inventory, + &ddl, + ) + .map_err(CompileFailure::from_one)?; + let artifact_bytes = artifacts + .canonical_inventory_bytes() + .map_err(CompileFailure::from_one)?; + let revision_digest = Sha256::digest(artifact_bytes); + let revision = format!( + "sha256:{}", + hex_prefix(&revision_digest, revision_digest.len()) + ); + findings.sort(); + + Ok(CompiledRegistry::new( + project.registry.id.clone(), + project.registry.version.clone(), + project.registry.default_language.clone(), + project.package.clone(), + project.manifest_projection.clone(), + module_order, + module_closure, + entities, + physical_names, + route_inventory, + access_inventory, + metadata_inventory, + query_inventory, + event_delivery_inventory, + ddl, + artifacts, + findings, + revision, + )) +} + +fn validate_project_header( + project: &RegistryProject, + profile: CompileProfile, + errors: &mut Vec, + findings: &mut Vec, +) { + if project.api_version != AUTHORING_API_VERSION { + errors.push(Diagnostic::error( + "project.api_version.unsupported", + "project.apiVersion", + "the project uses an unsupported API version", + )); + } + if project.kind != "RegistryProject" { + errors.push(Diagnostic::error( + "project.kind.unsupported", + "project.kind", + "the project uses an unsupported document kind", + )); + } + validate_id(&project.registry.id, "project.registry.id", errors); + nonempty( + &project.registry.version, + "project.registry.version", + "project.version.empty", + errors, + ); + validate_language(&project.registry.default_language, errors); + + match (&project.package, profile) { + (None, CompileProfile::Authoring) => findings.push(Diagnostic::finding( + "package.identity.missing", + "project.package", + "production package identity has not been declared", + )), + (None, CompileProfile::Production) => errors.push(Diagnostic::error( + "package.identity.required", + "project.package", + "production compilation requires package identity", + )), + (Some(package), _) => { + validate_id(&package.environment, "project.package.environment", errors); + validate_id(&package.instance_id, "project.package.instanceId", errors); + if package.sequence == 0 { + errors.push(Diagnostic::error( + "package.sequence.invalid", + "project.package.sequence", + "package sequence must be positive", + )); + } + nonempty( + &package.source_revision, + "project.package.sourceRevision", + "package.source_revision.empty", + errors, + ); + } + } + + match (&project.manifest_projection, profile) { + (None, CompileProfile::Authoring) => findings.push(Diagnostic::finding( + "manifest_projection.missing", + "project.manifestProjection", + "production Registry Manifest projection has not been declared", + )), + (None, CompileProfile::Production) => errors.push(Diagnostic::error( + "manifest_projection.required", + "project.manifestProjection", + "production compilation requires a Registry Manifest projection", + )), + (Some(projection), _) => { + validate_id( + &projection.access_profile, + "project.manifestProjection.accessProfile", + errors, + ); + nonempty( + &projection.catalog.base_url, + "project.manifestProjection.catalog.baseUrl", + "manifest_projection.catalog.base_url.empty", + errors, + ); + nonempty( + &projection.catalog.title, + "project.manifestProjection.catalog.title", + "manifest_projection.catalog.title.empty", + errors, + ); + nonempty( + &projection.catalog.publisher.name, + "project.manifestProjection.catalog.publisher.name", + "manifest_projection.catalog.publisher.name_empty", + errors, + ); + if projection + .catalog + .description + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + errors.push(Diagnostic::error( + "manifest_projection.catalog.description_empty", + "project.manifestProjection.catalog.description", + "optional Registry Manifest projection text must not be empty", + )); + } + if projection + .dataset + .description + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + errors.push(Diagnostic::error( + "manifest_projection.dataset.description_empty", + "project.manifestProjection.dataset.description", + "optional Registry Manifest projection text must not be empty", + )); + } + nonempty( + &projection.dataset.title, + "project.manifestProjection.dataset.title", + "manifest_projection.dataset.title.empty", + errors, + ); + if projection + .dataset + .owner + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + errors.push(Diagnostic::error( + "manifest_projection.dataset.owner_empty", + "project.manifestProjection.dataset.owner", + "optional Registry Manifest projection text must not be empty", + )); + } + } + } + + let mut locks = BTreeSet::new(); + for lock in &project.modules { + validate_id(&lock.id, "project.modules[].id", errors); + if !locks.insert(lock.id.as_str()) { + errors.push(Diagnostic::error( + "module.lock.duplicate", + "project.modules[].id", + "a module lock identifier is duplicated", + )); + } + match (&lock.digest, profile) { + (None, CompileProfile::Authoring) => findings.push(Diagnostic::finding( + "module.lock.digest_missing", + "project.modules[].digest", + "the authoring module lock has no production digest", + )), + (None, CompileProfile::Production) => errors.push(Diagnostic::error( + "module.lock.digest_required", + "project.modules[].digest", + "production compilation requires every module digest", + )), + (Some(digest), _) if !valid_sha256(digest) => errors.push(Diagnostic::error( + "module.lock.digest_invalid", + "project.modules[].digest", + "the module digest is not a canonical SHA-256 identifier", + )), + _ => {} + } + } +} + +fn validate_manifest_projection( + project: &RegistryProject, + entities: &BTreeMap, + errors: &mut Vec, +) { + let Some(projection) = project.manifest_projection.as_ref() else { + return; + }; + let selected_profiles = entities + .values() + .filter_map(|entity| entity.access_profiles.get(&projection.access_profile)) + .collect::>(); + if selected_profiles.is_empty() { + errors.push(Diagnostic::error( + "manifest_projection.access_profile.unknown", + "project.manifestProjection.accessProfile", + "the Registry Manifest projection selects an unknown access profile", + )); + } + if selected_profiles + .iter() + .any(|profile| profile.anonymous != selected_profiles[0].anonymous) + { + errors.push(Diagnostic::error( + "manifest_projection.access_profile.ambiguous", + "project.manifestProjection.accessProfile", + "the Registry Manifest projection access profile must have one disclosure mode", + )); + } +} + +fn order_modules( + project: &RegistryProject, + modules: &[RegistryModule], + errors: &mut Vec, +) -> (Vec, BTreeMap) { + let locked: BTreeSet<&str> = project + .modules + .iter() + .map(|lock| lock.id.as_str()) + .collect(); + let mut module_map = BTreeMap::new(); + for module in modules { + validate_id(&module.id, "modules[].id", errors); + if module_map + .insert(module.id.clone(), module.clone()) + .is_some() + { + errors.push(Diagnostic::error( + "module.id.duplicate", + "modules[].id", + "a module identifier is duplicated", + )); + } + } + + for module in module_map.values() { + let mut dependencies = BTreeSet::new(); + for dependency in &module.dependencies { + if !dependencies.insert(dependency) { + errors.push(Diagnostic::error( + "module.dependency.duplicate", + "modules[].dependencies[]", + "a module dependency is duplicated", + )); + } + if !module_map.contains_key(dependency) && !locked.contains(dependency.as_str()) { + errors.push(Diagnostic::error( + "module.dependency.unknown", + "modules[].dependencies[]", + "a module dependency does not resolve", + )); + } + } + } + + let mut indegree: BTreeMap = + module_map.keys().map(|id| (id.clone(), 0_usize)).collect(); + let mut outgoing: BTreeMap> = BTreeMap::new(); + for module in module_map.values() { + for dependency in &module.dependencies { + if module_map.contains_key(dependency) { + *indegree.get_mut(&module.id).expect("module was indexed") += 1; + outgoing + .entry(dependency.clone()) + .or_default() + .push(module.id.clone()); + } + } + } + let mut ready: BTreeSet = indegree + .iter() + .filter(|(_, degree)| **degree == 0) + .map(|(id, _)| id.clone()) + .collect(); + let mut ordered_external = Vec::new(); + while let Some(id) = ready.pop_first() { + ordered_external.push(id.clone()); + for dependent in outgoing.get(&id).into_iter().flatten() { + let degree = indegree.get_mut(dependent).expect("dependent was indexed"); + *degree -= 1; + if *degree == 0 { + ready.insert(dependent.clone()); + } + } + } + if ordered_external.len() != module_map.len() { + errors.push(Diagnostic::error( + "module.dependency.cycle", + "modules[].dependencies", + "module dependencies contain a cycle", + )); + } + let mut order: Vec = project + .modules + .iter() + .filter(|lock| !module_map.contains_key(&lock.id)) + .map(|lock| lock.id.clone()) + .collect(); + order.sort(); + for id in ordered_external { + if !order.contains(&id) { + order.push(id); + } + } + (order, module_map) +} + +fn validate_module_locks( + project: &RegistryProject, + modules: &[RegistryModule], + profile: CompileProfile, + errors: &mut Vec, + findings: &mut Vec, +) -> Vec { + let locks: BTreeMap<&str, _> = project + .modules + .iter() + .map(|lock| (lock.id.as_str(), lock)) + .collect(); + let loaded: BTreeMap<&str, _> = modules + .iter() + .map(|module| (module.id.as_str(), module)) + .collect(); + let mut ordered_locks: Vec<_> = project.modules.iter().collect(); + ordered_locks.sort_by(|left, right| left.id.cmp(&right.id)); + let mut closure = Vec::new(); + + for lock in ordered_locks { + let Some(module) = loaded.get(lock.id.as_str()).copied() else { + let diagnostic = match profile { + CompileProfile::Authoring => Diagnostic::finding( + "module.source.missing", + "project.modules[].id", + "an authoring module lock has no loaded source", + ), + CompileProfile::Production => Diagnostic::error( + "module.source.required", + "project.modules[].id", + "production compilation requires one source for every module lock", + ), + }; + match profile { + CompileProfile::Authoring => findings.push(diagnostic), + CompileProfile::Production => errors.push(diagnostic), + } + closure.push(CompiledModuleIdentity { + id: lock.id.clone(), + version: lock.version.clone(), + digest: None, + }); + continue; + }; + if lock.version != module.version { + errors.push(Diagnostic::error( + "module.lock.version_mismatch", + "project.modules[].version", + "an authored module does not match its locked version", + )); + } + let actual = module_digest(module); + if let Some(expected) = &lock.digest { + if expected != &actual { + errors.push(Diagnostic::error( + "module.lock.digest_mismatch", + "project.modules[].digest", + "an authored module does not match its locked digest", + )); + } + } + closure.push(CompiledModuleIdentity { + id: module.id.clone(), + version: module.version.clone(), + digest: Some(actual), + }); + } + + for module in modules { + if locks.contains_key(module.id.as_str()) { + continue; + } + let diagnostic = match profile { + CompileProfile::Authoring => Diagnostic::finding( + "module.lock.missing", + "modules[].id", + "an authoring module source has no lock entry", + ), + CompileProfile::Production => Diagnostic::error( + "module.lock.missing", + "modules[].id", + "production compilation requires one lock for every module source", + ), + }; + match profile { + CompileProfile::Authoring => findings.push(diagnostic), + CompileProfile::Production => errors.push(diagnostic), + } + closure.push(CompiledModuleIdentity { + id: module.id.clone(), + version: module.version.clone(), + digest: Some(module_digest(module)), + }); + } + closure.sort_by(|left, right| left.id.cmp(&right.id)); + closure +} + +pub fn module_digest(module: &RegistryModule) -> String { + let value = serde_json::to_value(module).expect("module serializes"); + let bytes = canonicalize_json(&value).expect("module canonicalizes"); + let digest = Sha256::digest(bytes); + format!("sha256:{}", hex_prefix(&digest, digest.len())) +} + +fn collect_entities( + project: &RegistryProject, + module_order: &[String], + modules: &BTreeMap, + errors: &mut Vec, +) -> BTreeMap { + let mut entities = BTreeMap::new(); + for entity in &project.entities { + insert_entity(&mut entities, entity, "project.entities[].id", errors); + } + for module_id in module_order { + if let Some(module) = modules.get(module_id) { + for entity in &module.entities { + insert_entity(&mut entities, entity, "modules[].entities[].id", errors); + } + } + } + entities +} + +fn insert_entity( + entities: &mut BTreeMap, + entity: &EntitySource, + path: &str, + errors: &mut Vec, +) { + if entities.insert(entity.id.clone(), entity.clone()).is_some() { + errors.push(Diagnostic::error( + "entity.id.duplicate", + path, + "an entity identifier is contributed more than once", + )); + } +} + +fn apply_temporal_roles( + entities: &mut BTreeMap, + errors: &mut Vec, +) { + for entity in entities.values_mut() { + let Some(temporal) = &entity.temporal else { + continue; + }; + for (id, role) in [ + (&temporal.start_field, ValidTimeRole::ValidFrom), + (&temporal.end_field, ValidTimeRole::ValidTo), + ] { + let Some(field) = entity.fields.iter_mut().find(|field| &field.id == id) else { + errors.push(Diagnostic::error( + "temporal.field.unknown", + "entities[].temporal", + "a temporal role refers to an unknown field", + )); + continue; + }; + if field + .valid_time_role + .is_some_and(|existing| existing != role) + { + errors.push(Diagnostic::error( + "temporal.role.conflict", + "entities[].temporal", + "a temporal role conflicts with the field declaration", + )); + } else { + field.valid_time_role = Some(role); + } + } + } +} + +fn apply_extensions( + entities: &mut BTreeMap, + module_order: &[String], + modules: &BTreeMap, + errors: &mut Vec, +) { + for module_id in module_order { + let Some(module) = modules.get(module_id) else { + continue; + }; + let mut extensions = module.extend_entities.clone(); + extensions.sort_by(|left, right| left.entity.cmp(&right.entity)); + for extension in &extensions { + let Some(entity) = entities.get_mut(&extension.entity) else { + errors.push(Diagnostic::error( + "extension.entity.unknown", + "modules[].extendEntities[].entity", + "an extension targets an unknown entity", + )); + continue; + }; + merge_extension(entity, extension, errors); + } + } +} + +fn merge_extension( + entity: &mut EntitySource, + extension: &EntityExtensionSource, + errors: &mut Vec, +) { + merge_by_id( + &mut entity.fields, + &extension.fields, + |value| value.id.as_str(), + "extension.field.duplicate", + "modules[].extendEntities[].fields[].id", + "a field identifier is contributed more than once", + errors, + ); + merge_by_id( + &mut entity.indexes, + &extension.indexes, + |value| value.id.as_str(), + "extension.index.duplicate", + "modules[].extendEntities[].indexes[].id", + "an index identifier is contributed more than once", + errors, + ); + merge_by_id( + &mut entity.access_profiles, + &extension.access_profiles, + |value| value.id.as_str(), + "extension.access_profile.duplicate", + "modules[].extendEntities[].accessProfiles[].id", + "an access profile identifier is contributed more than once", + errors, + ); + merge_by_id( + &mut entity.events, + &extension.events, + |value| value.id.as_str(), + "extension.event.duplicate", + "modules[].extendEntities[].events[].id", + "an event identifier is contributed more than once", + errors, + ); + + let mut known: BTreeSet = entity + .constraints + .iter() + .map(derived_constraint_id) + .collect(); + for constraint in &extension.constraints { + if known.insert(derived_constraint_id(constraint)) { + entity.constraints.push(constraint.clone()); + } else { + errors.push(Diagnostic::error( + "extension.constraint.duplicate", + "modules[].extendEntities[].constraints[]", + "a constraint identifier is contributed more than once", + )); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn merge_by_id( + target: &mut Vec, + contributed: &[T], + id: impl Fn(&T) -> &str, + code: &str, + path: &str, + message: &str, + errors: &mut Vec, +) { + let mut known: BTreeSet = target.iter().map(|value| id(value).to_owned()).collect(); + for value in contributed { + if known.insert(id(value).to_owned()) { + target.push(value.clone()); + } else { + errors.push(Diagnostic::error(code, path, message)); + } + } +} + +fn expand_project_access( + project: &RegistryProject, + entities: &mut BTreeMap, + errors: &mut Vec, +) { + let mut profile_ids = BTreeSet::new(); + for profile in &project.access_profiles { + validate_id(&profile.id, "project.accessProfiles[].id", errors); + if !profile_ids.insert(profile.id.as_str()) { + errors.push(Diagnostic::error( + "access_profile.id.duplicate", + "project.accessProfiles[].id", + "an access profile identifier is duplicated", + )); + } + nonempty( + &profile.principal_claim, + "project.accessProfiles[].principalClaim", + "access_profile.principal_claim.empty", + errors, + ); + let mut granted_entities = BTreeSet::new(); + for grant in &profile.grants { + if !granted_entities.insert(grant.entity.as_str()) { + errors.push(Diagnostic::error( + "access_profile.grant.duplicate", + "project.accessProfiles[].grants[].entity", + "an access profile contains duplicate entity grants", + )); + continue; + } + let Some(entity) = entities.get_mut(&grant.entity) else { + errors.push(Diagnostic::error( + "access_profile.grant.entity_unknown", + "project.accessProfiles[].grants[].entity", + "an access grant refers to an unknown entity", + )); + continue; + }; + if entity + .access_profiles + .iter() + .any(|existing| existing.id == profile.id) + { + errors.push(Diagnostic::error( + "access_profile.id.duplicate", + "project.accessProfiles[].id", + "an access profile identifier is duplicated for an entity", + )); + continue; + } + entity.access_profiles.push(AccessProfileSource { + id: profile.id.clone(), + default: profile.default, + anonymous: false, + principal_claim: Some(profile.principal_claim.clone()), + required_scopes: profile.required_scopes.clone(), + required_purposes: profile.purposes.clone(), + operations: grant.actions.clone(), + readable_fields: grant.readable_fields.clone(), + writable_fields: grant.writable_fields.clone(), + filterable_fields: grant.filterable_fields.clone(), + sortable_fields: grant.sortable_fields.clone(), + row_boundaries: grant.row_boundaries.clone(), + revision_access: grant.revision_access, + allow_data_export: grant.allow_data_export, + }); + } + } +} + +fn resolve_vocabularies( + project: &RegistryProject, + entities: &mut BTreeMap, + errors: &mut Vec, +) { + let mut vocabularies = BTreeMap::new(); + for vocabulary in &project.vocabularies { + validate_id(&vocabulary.id, "project.vocabularies[].id", errors); + if vocabulary.values.is_empty() + || has_duplicates(&vocabulary.values) + || vocabulary.values.iter().any(|value| !valid_code(value)) + { + errors.push(Diagnostic::error( + "vocabulary.values.invalid", + "project.vocabularies[].values", + "a vocabulary must contain a non-empty duplicate-free value set", + )); + } + if vocabularies + .insert(vocabulary.id.clone(), vocabulary.values.clone()) + .is_some() + { + errors.push(Diagnostic::error( + "vocabulary.id.duplicate", + "project.vocabularies[].id", + "a vocabulary identifier is duplicated", + )); + } + } + for entity in entities.values_mut() { + for field in &mut entity.fields { + if let FieldTypeSource::VocabularyCode { vocabulary, values } = &mut field.field_type { + if values.is_empty() { + if let Some(resolved) = vocabularies.get(vocabulary) { + *values = resolved.clone(); + } else { + errors.push(Diagnostic::error( + "field.vocabulary.unknown", + "entities[].fields[].vocabulary", + "a field refers to an unknown vocabulary", + )); + } + } + } + } + } +} + +fn validate_entities(entities: &BTreeMap, errors: &mut Vec) { + let mut routes = BTreeSet::new(); + for entity in entities.values() { + validate_id(&entity.id, "entities[].id", errors); + validate_id(&entity.route, "entities[].route", errors); + if !routes.insert(entity.route.as_str()) { + errors.push(Diagnostic::error( + "entity.route.duplicate", + "entities[].route", + "an entity route is duplicated", + )); + } + if entity.mutation_mode == MutationMode::CreateOnly && entity.tombstone { + errors.push(Diagnostic::error( + "entity.tombstone.create_only", + "entities[].tombstone", + "a create-only entity cannot expose tombstone behavior", + )); + } + let grants_batch = entity + .access_profiles + .iter() + .any(|profile| profile.operations.contains(&Operation::Batch)); + match entity.batch.as_ref() { + None if grants_batch => errors.push(Diagnostic::error( + "entity.batch.required", + "entities[].batch", + "an entity granted batch access must declare bounded batch configuration", + )), + Some(batch) + if batch.maximum_items == 0 + || batch.maximum_items > MAX_BATCH_ITEMS + || batch.maximum_bytes == 0 + || batch.maximum_bytes > MAX_BATCH_BYTES => + { + errors.push(Diagnostic::error( + "entity.batch.bounds_invalid", + "entities[].batch", + "batch maximumItems and maximumBytes must be within the supported bounds", + )); + } + _ => {} + } + validate_entity_fields(entity, entities, errors); + validate_constraints(entity, errors); + validate_indexes(entity, errors); + validate_profiles(entity, errors); + validate_events(entity, errors); + } +} + +fn validate_entity_fields( + entity: &EntitySource, + entities: &BTreeMap, + errors: &mut Vec, +) { + let mut fields = BTreeSet::new(); + let mut roles = BTreeMap::new(); + for field in &entity.fields { + validate_id(&field.id, "entities[].fields[].id", errors); + if !fields.insert(field.id.as_str()) { + errors.push(Diagnostic::error( + "field.id.duplicate", + "entities[].fields[].id", + "a field identifier is duplicated", + )); + } + match &field.field_type { + FieldTypeSource::String { + min_length, + max_length, + } if *max_length == 0 || *max_length > 1_000_000 || min_length > max_length => errors + .push(Diagnostic::error( + "field.string.bounds_invalid", + "entities[].fields[]", + "string length bounds are invalid", + )), + FieldTypeSource::Text { max_length } + if *max_length == 0 || *max_length > 10_000_000 => + { + errors.push(Diagnostic::error( + "field.text.bound_invalid", + "entities[].fields[].maxLength", + "text length bound must be positive", + )); + } + FieldTypeSource::VocabularyCode { values, .. } + if values.is_empty() + || has_duplicates(values) + || values.iter().any(|value| !valid_code(value)) => + { + errors.push(Diagnostic::error( + "field.vocabulary.values_invalid", + "entities[].fields[].values", + "a vocabulary field requires a non-empty duplicate-free value set", + )); + } + FieldTypeSource::Decimal { + precision, + scale, + minimum, + maximum, + } if !valid_decimal_bounds( + *precision, + *scale, + minimum.as_deref(), + maximum.as_deref(), + ) => + { + errors.push(Diagnostic::error( + "field.decimal.bounds_invalid", + "entities[].fields[]", + "decimal precision, scale, or canonical bounds are invalid", + )); + } + FieldTypeSource::Reference { target, .. } if !entities.contains_key(target) => { + errors.push(Diagnostic::error( + "field.reference.target_unknown", + "entities[].fields[].target", + "a reference target does not resolve", + )); + } + FieldTypeSource::Crs84Point { precision, bbox } + if *precision > 9 + || bbox + .as_ref() + .is_some_and(|bbox| parsed_bbox(bbox, *precision).is_none()) => + { + errors.push(Diagnostic::error( + "field.crs84_point.bounds_invalid", + "entities[].fields[]", + "CRS84 point precision or CRS84 bounding box is invalid", + )); + } + FieldTypeSource::Structured { max_bytes, schema } + if *max_bytes == 0 + || *max_bytes > MAX_STRUCTURED_VALUE_BYTES + || !valid_structured_schema(schema) => + { + errors.push(Diagnostic::error( + "field.structured.schema_invalid", + "entities[].fields[]", + "structured field schema or byte bound is invalid", + )); + } + _ => {} + } + if let Some(role) = field.valid_time_role { + if !matches!( + field.field_type, + FieldTypeSource::Date | FieldTypeSource::Timestamp + ) { + errors.push(Diagnostic::error( + "field.valid_time.type_invalid", + "entities[].fields[].validTimeRole", + "a valid-time role requires a date or timestamp field", + )); + } + if roles.insert(role, &field.field_type).is_some() { + errors.push(Diagnostic::error( + "field.valid_time.role_duplicate", + "entities[].fields[].validTimeRole", + "a valid-time role is declared more than once", + )); + } + if role == ValidTimeRole::ValidFrom && !field.required { + errors.push(Diagnostic::error( + "field.valid_time.start_required", + "entities[].fields[].required", + "a valid-time start field must be required", + )); + } + if role == ValidTimeRole::ValidTo && field.required { + errors.push(Diagnostic::error( + "field.valid_time.end_must_allow_open", + "entities[].fields[].required", + "a valid-time end field must permit an open interval", + )); + } + } + } + if let (Some(from), Some(to)) = ( + roles.get(&ValidTimeRole::ValidFrom), + roles.get(&ValidTimeRole::ValidTo), + ) { + if std::mem::discriminant(*from) != std::mem::discriminant(*to) { + errors.push(Diagnostic::error( + "field.valid_time.type_mismatch", + "entities[].fields[].validTimeRole", + "valid-time boundary fields must use the same type", + )); + } + } +} + +fn validate_constraints(entity: &EntitySource, errors: &mut Vec) { + let fields: BTreeMap<&str, &FieldSource> = entity + .fields + .iter() + .map(|field| (field.id.as_str(), field)) + .collect(); + let mut ids = BTreeSet::new(); + for constraint in &entity.constraints { + let id = derived_constraint_id(constraint); + if !ids.insert(id) { + errors.push(Diagnostic::error( + "constraint.id.duplicate", + "entities[].constraints[]", + "a constraint identifier is duplicated", + )); + } + let referenced = match constraint { + ConstraintSource::Unique { fields, .. } => fields.clone(), + ConstraintSource::Compare { left, right, .. } => vec![left.clone(), right.clone()], + ConstraintSource::IntRange { field, .. } + | ConstraintSource::Vocabulary { field, .. } => vec![field.clone()], + ConstraintSource::TemporalNonOverlap { scope_fields, .. } => scope_fields.clone(), + }; + if referenced.is_empty() + || referenced + .iter() + .any(|field| !fields.contains_key(field.as_str())) + { + errors.push(Diagnostic::error( + "constraint.field.unknown", + "entities[].constraints[]", + "a constraint has an empty or unresolved field set", + )); + continue; + } + if let ConstraintSource::Unique { when, .. } = constraint { + validate_unique_when(entity, when.as_deref(), errors); + } + match constraint { + ConstraintSource::Unique { .. } => {} + ConstraintSource::Compare { left, right, .. } => { + let left_type = &fields[left.as_str()].field_type; + let right_type = &fields[right.as_str()].field_type; + if std::mem::discriminant(left_type) != std::mem::discriminant(right_type) + || !matches!( + left_type, + FieldTypeSource::Int64 | FieldTypeSource::Date | FieldTypeSource::Timestamp + ) + { + errors.push(Diagnostic::error( + "constraint.compare.type_mismatch", + "entities[].constraints[]", + "compared fields must use the same ordered scalar type", + )); + } + } + ConstraintSource::IntRange { + field, + minimum, + maximum, + .. + } => { + if !matches!(fields[field.as_str()].field_type, FieldTypeSource::Int64) + || minimum.is_none() && maximum.is_none() + || minimum.zip(*maximum).is_some_and(|(min, max)| min > max) + { + errors.push(Diagnostic::error( + "constraint.range.invalid", + "entities[].constraints[]", + "an integer range has an incompatible field or invalid bounds", + )); + } + } + ConstraintSource::Vocabulary { field, values, .. } => { + let declared_values = match &fields[field.as_str()].field_type { + FieldTypeSource::VocabularyCode { values, .. } => Some(values), + _ => None, + }; + if values.is_empty() + || has_duplicates(values) + || values.iter().any(|value| !valid_code(value)) + || declared_values + .is_none_or(|declared| values.iter().any(|value| !declared.contains(value))) + { + errors.push(Diagnostic::error( + "constraint.vocabulary.invalid", + "entities[].constraints[]", + "a vocabulary constraint is incompatible or has invalid values", + )); + } + } + ConstraintSource::TemporalNonOverlap { + start_field, + end_field, + scope_fields, + .. + } => { + let from = entity + .fields + .iter() + .find(|field| field.valid_time_role == Some(ValidTimeRole::ValidFrom)); + let to = entity + .fields + .iter() + .find(|field| field.valid_time_role == Some(ValidTimeRole::ValidTo)); + if from.is_none() + || to.is_none() + || start_field + .as_ref() + .is_some_and(|id| from.is_none_or(|field| &field.id != id)) + || end_field + .as_ref() + .is_some_and(|id| to.is_none_or(|field| &field.id != id)) + { + errors.push(Diagnostic::error( + "constraint.temporal.roles_invalid", + "entities[].constraints[]", + "a temporal constraint requires matching valid-time boundary fields", + )); + } + if scope_fields.iter().any(|field| { + fields + .get(field.as_str()) + .is_some_and(|field| !field.required) + }) { + errors.push(Diagnostic::error( + "constraint.temporal.scope_nullable", + "entities[].constraints[].scopeFields", + "a temporal non-overlap scope field must be required", + )); + } + if scope_fields.iter().any(|field| { + fields.get(field.as_str()).is_some_and(|field| { + !supports_temporal_non_overlap_scope(&field.field_type) + }) + }) { + errors.push(Diagnostic::error( + "constraint.temporal.scope_type_unsupported", + "entities[].constraints[].scopeFields", + "a temporal non-overlap scope field must use a supported scalar type", + )); + } + } + } + if matches!( + constraint, + ConstraintSource::Unique { fields, .. } + if has_duplicates(fields) + ) || matches!( + constraint, + ConstraintSource::TemporalNonOverlap { scope_fields, .. } + if scope_fields.is_empty() || has_duplicates(scope_fields) + ) { + errors.push(Diagnostic::error( + "constraint.fields.duplicate", + "entities[].constraints[]", + "a constraint field tuple must be non-empty and duplicate-free", + )); + } + } + validate_anonymous_constraint_processing(entity, &fields, errors); + if let Some(temporal) = &entity.temporal { + let matched = entity.constraints.iter().any(|constraint| { + matches!( + constraint, + ConstraintSource::TemporalNonOverlap { + scope_fields, + start_field, + end_field, + .. + } if scope_fields == &temporal.scope_fields + && start_field.as_ref() == Some(&temporal.start_field) + && end_field.as_ref() == Some(&temporal.end_field) + ) + }); + if !matched { + errors.push(Diagnostic::error( + "temporal.constraint.missing", + "entities[].temporal", + "the temporal declaration must match one non-overlap constraint", + )); + } + } +} + +fn validate_anonymous_constraint_processing( + entity: &EntitySource, + fields: &BTreeMap<&str, &FieldSource>, + errors: &mut Vec, +) { + // In the current contract, `anonymous` marks the public profile surface. + // Every field processed by a constraint on that entity must therefore meet + // the public classification floor even when it is not otherwise readable. + if !entity + .access_profiles + .iter() + .any(|profile| profile.anonymous) + { + return; + } + let processes_non_public = entity + .constraints + .iter() + .flat_map(constraint_processed_fields) + .any(|field| { + fields + .get(field) + .is_some_and(|field| field.classification != Classification::Public) + }); + if processes_non_public { + errors.push(Diagnostic::error( + "access_profile.public.processing_non_public", + "entities[].constraints[]", + "an anonymous profile is a public surface and may process only public constraint fields", + )); + } +} + +fn constraint_processed_fields(constraint: &ConstraintSource) -> Vec<&str> { + match constraint { + ConstraintSource::Unique { fields, when, .. } => fields + .iter() + .map(String::as_str) + .chain( + when.iter() + .flatten() + .filter_map(unique_when_predicate_field), + ) + .collect(), + ConstraintSource::Compare { left, right, .. } => { + vec![left.as_str(), right.as_str()] + } + ConstraintSource::IntRange { field, .. } | ConstraintSource::Vocabulary { field, .. } => { + vec![field.as_str()] + } + ConstraintSource::TemporalNonOverlap { + scope_fields, + start_field, + end_field, + .. + } => scope_fields + .iter() + .map(String::as_str) + .chain(start_field.iter().map(String::as_str)) + .chain(end_field.iter().map(String::as_str)) + .collect(), + } +} + +fn supports_temporal_non_overlap_scope(field_type: &FieldTypeSource) -> bool { + // These source types generate PostgreSQL scalar columns whose GiST equality + // operator classes are supplied by the required btree_gist extension. + // Structured and CRS84 point fields generate jsonb columns, for which this + // compiler does not install or require a GiST equality operator class. + matches!( + field_type, + FieldTypeSource::Boolean + | FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::Int64 + | FieldTypeSource::Decimal { .. } + | FieldTypeSource::Date + | FieldTypeSource::Timestamp + | FieldTypeSource::Uuid + | FieldTypeSource::VocabularyCode { .. } + | FieldTypeSource::Reference { .. } + ) +} + +fn validate_indexes(entity: &EntitySource, errors: &mut Vec) { + let fields: BTreeSet<&str> = entity + .fields + .iter() + .map(|field| field.id.as_str()) + .collect(); + let mut ids = BTreeSet::new(); + for index in &entity.indexes { + validate_id(&index.id, "entities[].indexes[].id", errors); + if !ids.insert(index.id.as_str()) { + errors.push(Diagnostic::error( + "index.id.duplicate", + "entities[].indexes[].id", + "an index identifier is duplicated", + )); + } + if index.fields.is_empty() + || has_duplicates(&index.fields) + || index + .fields + .iter() + .any(|field| !fields.contains(field.as_str())) + { + errors.push(Diagnostic::error( + "index.fields.invalid", + "entities[].indexes[].fields", + "an index has an empty, duplicate, or unresolved field set", + )); + } + } +} + +fn validate_profiles(entity: &EntitySource, errors: &mut Vec) { + let fields: BTreeMap<&str, &FieldSource> = entity + .fields + .iter() + .map(|field| (field.id.as_str(), field)) + .collect(); + let mut ids = BTreeSet::new(); + for access in &entity.access_profiles { + validate_id(&access.id, "entities[].accessProfiles[].id", errors); + if !ids.insert(access.id.as_str()) { + errors.push(Diagnostic::error( + "access_profile.id.duplicate", + "entities[].accessProfiles[].id", + "an access profile identifier is duplicated", + )); + } + if access.operations.is_empty() { + errors.push(Diagnostic::error( + "access_profile.operations.empty", + "entities[].accessProfiles[].operations", + "an access profile must grant at least one operation", + )); + } + if !access.anonymous && access.principal_claim.as_deref().is_none_or(str::is_empty) { + errors.push(Diagnostic::error( + "access_profile.principal_claim.required", + "entities[].accessProfiles[].principalClaim", + "an authenticated profile requires a direct principal claim", + )); + } + if access + .required_scopes + .iter() + .chain(&access.required_purposes) + .any(|value| value.is_empty()) + { + errors.push(Diagnostic::error( + "access_profile.claim_value.invalid", + "entities[].accessProfiles[]", + "required scope and purpose values must be non-empty", + )); + } + for operation in &access.operations { + if (entity.mutation_mode == MutationMode::CreateOnly + && matches!(operation, Operation::Patch | Operation::Tombstone)) + || (*operation == Operation::Tombstone && !entity.tombstone) + { + errors.push(Diagnostic::error( + "access_profile.operation.unavailable", + "entities[].accessProfiles[].operations", + "an access profile grants an operation the entity does not expose", + )); + } + } + if access.operations.contains(&Operation::Batch) + && !access + .operations + .iter() + .any(|operation| matches!(operation, Operation::Create | Operation::Patch)) + { + errors.push(Diagnostic::error( + "access_profile.batch.underlying_operation_required", + "entities[].accessProfiles[].operations", + "a batch access profile must grant create or patch for its items", + )); + } + if access.anonymous + && access.operations.iter().any(|operation| { + matches!( + operation, + Operation::Create | Operation::Patch | Operation::Tombstone | Operation::Batch + ) + }) + { + errors.push(Diagnostic::error( + "access_profile.anonymous.mutation_forbidden", + "entities[].accessProfiles[].operations", + "an anonymous access profile cannot grant a mutation operation", + )); + } + if access.allow_data_export + && (access.anonymous + || !access.operations.contains(&Operation::List) + || access.readable_fields.is_empty()) + { + errors.push(Diagnostic::error( + "access_profile.data_export.invalid", + "entities[].accessProfiles[].allowDataExport", + "bulk data export requires an authenticated list profile with a readable projection", + )); + } + let mut processed = access.readable_fields.clone(); + processed.extend(access.writable_fields.iter().cloned()); + processed.extend(access.filterable_fields.iter().cloned()); + processed.extend(access.sortable_fields.iter().cloned()); + processed.extend( + access + .row_boundaries + .iter() + .map(|boundary| boundary.field.clone()), + ); + if processed + .iter() + .any(|field| !fields.contains_key(field.as_str())) + { + errors.push(Diagnostic::error( + "access_profile.field.unknown", + "entities[].accessProfiles[]", + "an access profile refers to an unknown field", + )); + } + if !access.filterable_fields.is_subset(&access.readable_fields) + || !access.sortable_fields.is_subset(&access.readable_fields) + { + errors.push(Diagnostic::error( + "access_profile.processing.wider_than_read", + "entities[].accessProfiles[]", + "filterable and sortable fields must be readable", + )); + } + if access.anonymous + && (entity.classification != Classification::Public + || processed.iter().any(|field| { + fields + .get(field.as_str()) + .is_some_and(|field| field.classification != Classification::Public) + })) + { + errors.push(Diagnostic::error( + "access_profile.public.processing_non_public", + "entities[].accessProfiles[]", + "an anonymous profile may process only public fields", + )); + } + let mut boundaries = BTreeSet::new(); + for boundary in &access.row_boundaries { + if fields.get(boundary.field.as_str()).is_some_and(|field| { + matches!( + field.field_type, + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } + ) + }) { + errors.push(Diagnostic::error( + "access_profile.row_boundary.type_unsupported", + "entities[].accessProfiles[].rowBoundaries", + "CRS84 point and structured fields cannot be row-boundary fields", + )); + } + if boundary.claim.is_empty() + || !boundaries.insert(( + boundary.field.as_str(), + boundary.claim.as_str(), + boundary.operator, + )) + { + errors.push(Diagnostic::error( + "access_profile.row_boundary.invalid", + "entities[].accessProfiles[].rowBoundaries", + "row boundaries must be direct, non-empty, and duplicate-free", + )); + } + } + } + for operation in all_operations() { + let profiles: Vec<&AccessProfileSource> = entity + .access_profiles + .iter() + .filter(|access| access.operations.contains(&operation)) + .collect(); + if profiles.is_empty() { + continue; + } + let explicit_defaults = profiles.iter().filter(|access| access.default).count(); + if profiles.len() > 1 && explicit_defaults != 1 + || profiles.len() == 1 && explicit_defaults > 1 + { + errors.push(Diagnostic::error( + "access_profile.default.invalid", + "entities[].accessProfiles[].default", + "each exposed operation requires exactly one default profile", + )); + } + } +} + +fn validate_events(entity: &EntitySource, errors: &mut Vec) { + let fields: BTreeMap<&str, &FieldSource> = entity + .fields + .iter() + .map(|field| (field.id.as_str(), field)) + .collect(); + let mut ids = BTreeSet::new(); + for event in &entity.events { + validate_id(&event.id, "entities[].events[].id", errors); + if !ids.insert(event.id.as_str()) { + errors.push(Diagnostic::error( + "event.id.duplicate", + "entities[].events[].id", + "an event identifier is duplicated", + )); + } + if event.projection.is_empty() { + errors.push(Diagnostic::error( + "event.projection.empty", + "entities[].events[].projection", + "an event projection must contain at least one field", + )); + } + if event + .projection + .iter() + .any(|field| !fields.contains_key(field.as_str())) + { + errors.push(Diagnostic::error( + "event.projection.field_unknown", + "entities[].events[].projection", + "an event projection refers to an unknown field", + )); + } + let maximum_payload_bytes = maximum_event_payload_bytes(&event.projection, |field| { + fields + .get(field) + .map(|field| (&field.field_type, field.required)) + }); + if matches!( + event.trigger, + EventTrigger::Patched | EventTrigger::Tombstoned + ) && entity.mutation_mode == MutationMode::CreateOnly + { + errors.push(Diagnostic::error( + "event.trigger.unavailable", + "entities[].events[].trigger", + "an event trigger is unavailable for a create-only entity", + )); + } + if event.trigger == EventTrigger::Tombstoned && !entity.tombstone { + errors.push(Diagnostic::error( + "event.trigger.unavailable", + "entities[].events[].trigger", + "a tombstone event requires tombstone behavior", + )); + } + let Some(webhook) = event.webhook.as_ref() else { + continue; + }; + if maximum_payload_bytes + .is_some_and(|maximum| maximum > u64::from(MAX_WEBHOOK_PAYLOAD_BYTES)) + { + errors.push(Diagnostic::error( + "event.webhook.projection_too_large", + "entities[].events[].projection", + "the webhook projection can exceed the governed transport body bound", + )); + } + if !valid_logical_destination_id(&webhook.destination_id) { + errors.push(Diagnostic::error( + "event.webhook.destination.invalid", + "entities[].events[].webhook.destinationId", + "a webhook destination must use the closed logical identifier grammar", + )); + } + if event.projection.iter().any(|field| { + fields + .get(field.as_str()) + .is_some_and(|field| field.classification > webhook.classification_ceiling) + }) { + errors.push(Diagnostic::error( + "event.webhook.classification_ceiling.underdeclared", + "entities[].events[].webhook.classificationCeiling", + "the webhook classification ceiling is below a projected field", + )); + } + let delivery = &webhook.delivery; + if !(MIN_WEBHOOK_ATTEMPT_TIMEOUT_MS..=MAX_WEBHOOK_ATTEMPT_TIMEOUT_MS) + .contains(&delivery.attempt_timeout_ms) + { + errors.push(Diagnostic::error( + "event.webhook.timeout.invalid", + "entities[].events[].webhook.delivery.attemptTimeoutMs", + "the webhook per-attempt timeout is outside the supported bounds", + )); + } + if !(MIN_WEBHOOK_BACKOFF_MS..=MAX_WEBHOOK_BACKOFF_MS).contains(&delivery.initial_backoff_ms) + || !(MIN_WEBHOOK_BACKOFF_MS..=MAX_WEBHOOK_BACKOFF_MS) + .contains(&delivery.maximum_backoff_ms) + || delivery.initial_backoff_ms > delivery.maximum_backoff_ms + { + errors.push(Diagnostic::error( + "event.webhook.backoff.invalid", + "entities[].events[].webhook.delivery", + "webhook backoff bounds must be positive, bounded, and internally coherent", + )); + } + if delivery.maximum_attempts == 0 || delivery.maximum_attempts > MAX_WEBHOOK_ATTEMPTS { + errors.push(Diagnostic::error( + "event.webhook.attempts.invalid", + "entities[].events[].webhook.delivery.maximumAttempts", + "webhook maximum attempts must be within the supported bound", + )); + } + if delivery.dead_letter != Some(WebhookDeadLetterMode::Required) { + errors.push(Diagnostic::error( + "event.webhook.dead_letter.required", + "entities[].events[].webhook.delivery.deadLetter", + "webhook delivery requires dead-letter handling", + )); + } + } +} + +fn valid_logical_destination_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +fn maximum_event_payload_bytes<'a>( + projection: &BTreeSet, + field: impl Fn(&str) -> Option<(&'a FieldTypeSource, bool)>, +) -> Option { + // Canonical JSON object braces plus one comma between projected members. + let mut total = 2_u64.checked_add(projection.len().saturating_sub(1) as u64)?; + for field_id in projection { + let (field_type, required) = field(field_id)?; + // Field identifiers use the compiler's ASCII identifier grammar, so + // their canonical key encoding is quotes plus the identifier bytes. + // Optional SQL NULLs materialize as JSON null in the immutable event + // projection, so their four bytes are also part of the maximum. + let maximum_value_bytes = maximum_field_json_bytes(field_type)?; + let maximum_value_bytes = if required { + maximum_value_bytes + } else { + maximum_value_bytes.max(4) + }; + total = total + .checked_add(field_id.len() as u64 + 3)? + .checked_add(maximum_value_bytes)?; + } + Some(total) +} + +fn maximum_field_json_bytes(field_type: &FieldTypeSource) -> Option { + let bytes = match field_type { + FieldTypeSource::Boolean => 5, + // A JSON string character needs at most six bytes as a `\uXXXX` + // escape. Quotes add two bytes. + FieldTypeSource::String { max_length, .. } | FieldTypeSource::Text { max_length } => { + 2_u64.checked_add(u64::from(*max_length).checked_mul(6)?)? + } + FieldTypeSource::Int64 => 20, + FieldTypeSource::Decimal { + precision, scale, .. + } => { + // Decimal values are transported as JSON strings to preserve + // exact scale. Account for the optional sign, decimal point, and + // both JSON string quotes. + u64::from(*precision) + + u64::from(*scale > 0) + + u64::from(*scale > 0 && scale == precision) + + 3 + } + FieldTypeSource::Date => 12, + // RFC 3339 values accepted by `time` are bounded. Keep a conservative + // envelope for quotes, subsecond precision, and a numeric offset. + FieldTypeSource::Timestamp => 64, + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => 38, + FieldTypeSource::VocabularyCode { values, .. } => values + .iter() + .filter_map(|value| canonicalize_json(&Value::String(value.clone())).ok()) + .map(|value| value.len() as u64) + .max()?, + // The closed Point shape and precision grammar fit well below this + // conservative bound. + FieldTypeSource::Crs84Point { .. } => 128, + FieldTypeSource::Structured { max_bytes, .. } => u64::from(*max_bytes), + }; + Some(bytes) +} + +fn compile_event_delivery_inventory( + entities: &BTreeMap, +) -> CompiledEventDeliveryInventory { + let mut deliveries = entities + .values() + .flat_map(|entity| { + entity.events.values().filter_map(move |event| { + let webhook = event.webhook.as_ref()?; + let delivery = &webhook.delivery; + Some(CompiledEventDelivery { + id: format!("events.{}.{}.webhook", entity.id, event.id), + entity_id: entity.id.clone(), + event_id: event.id.clone(), + trigger: event.trigger, + destination_id: webhook.destination_id.clone(), + projection_fields: event.projection.iter().cloned().collect(), + classification_ceiling: webhook.classification_ceiling, + authentication_profile: webhook.authentication_profile, + delivery_mode: CompiledWebhookDeliveryMode::AfterCommit, + attempt_timeout_ms: delivery.attempt_timeout_ms, + initial_backoff_ms: delivery.initial_backoff_ms, + maximum_backoff_ms: delivery.maximum_backoff_ms, + exponential_backoff_multiplier: WEBHOOK_BACKOFF_MULTIPLIER, + maximum_attempts: delivery.maximum_attempts, + retry_delays_ms: webhook_retry_delays( + delivery.initial_backoff_ms, + delivery.maximum_backoff_ms, + delivery.maximum_attempts, + ), + maximum_payload_bytes: u32::try_from( + maximum_event_payload_bytes(&event.projection, |field| { + entity + .fields + .get(field) + .map(|field| (&field.field_type, field.required)) + }) + .expect("validated webhook projection fields are bounded"), + ) + .expect("validated webhook projection fits the transport bound"), + dead_letter: delivery + .dead_letter + .expect("validated webhook delivery requires dead letter"), + operator_replay: delivery.operator_replay, + }) + }) + }) + .collect::>(); + deliveries.sort_by(|left, right| left.id.cmp(&right.id)); + CompiledEventDeliveryInventory { deliveries } +} + +fn webhook_retry_delays(initial_ms: u32, maximum_ms: u32, maximum_attempts: u8) -> Vec { + let mut delay = initial_ms; + (1..maximum_attempts) + .map(|_| { + let current = delay; + delay = delay + .saturating_mul(u32::from(WEBHOOK_BACKOFF_MULTIPLIER)) + .min(maximum_ms); + current + }) + .collect() +} + +fn compile_entities( + sources: &BTreeMap, +) -> Result<(BTreeMap, PhysicalNameInventory), CompileFailure> { + let mut builder = PhysicalNameBuilder::new(); + let mut entities = BTreeMap::new(); + let mut inventory = BTreeMap::new(); + for source in sources.values() { + let table = builder + .derive("e", &source.id, "entities[].id") + .map_err(CompileFailure::from_one)?; + let mut field_names = BTreeMap::new(); + let mut fields = BTreeMap::new(); + let mut sorted_fields = source.fields.clone(); + sorted_fields.sort_by(|left, right| left.id.cmp(&right.id)); + for field in sorted_fields { + let physical = builder + .derive( + "f", + &format!("{}.{}", source.id, field.id), + "entities[].fields[].id", + ) + .map_err(CompileFailure::from_one)?; + field_names.insert(field.id.clone(), physical.clone()); + fields.insert( + field.id.clone(), + CompiledField { + id: field.id, + field_type: field.field_type, + required: field.required, + classification: field.classification, + valid_time_role: field.valid_time_role, + physical_name: physical, + }, + ); + } + let mut constraints = BTreeMap::new(); + let mut constraint_names = BTreeMap::new(); + for constraint in &source.constraints { + let id = derived_constraint_id(constraint); + let physical = builder + .derive( + "c", + &format!("{}.{}", source.id, id), + "entities[].constraints[]", + ) + .map_err(CompileFailure::from_one)?; + constraint_names.insert(id.clone(), physical); + constraints.insert(id, normalized_constraint(constraint)); + } + for field in &source.fields { + if matches!(field.field_type, FieldTypeSource::Reference { .. }) { + let id = format!("reference:{}", field.id); + let physical = builder + .derive( + "r", + &format!("{}.{}", source.id, field.id), + "entities[].fields[].target", + ) + .map_err(CompileFailure::from_one)?; + constraint_names.insert(id, physical); + } + } + let mut indexes = BTreeMap::new(); + let mut index_names = BTreeMap::new(); + for index in &source.indexes { + let physical = builder + .derive( + "i", + &format!("{}.{}", source.id, index.id), + "entities[].indexes[].id", + ) + .map_err(CompileFailure::from_one)?; + index_names.insert(index.id.clone(), physical); + indexes.insert(index.id.clone(), index.fields.clone()); + } + let mut profiles = BTreeMap::new(); + let mut policy_names = BTreeMap::new(); + for access in &source.access_profiles { + let physical = builder + .derive( + "p", + &format!("{}.{}", source.id, access.id), + "entities[].accessProfiles[].id", + ) + .map_err(CompileFailure::from_one)?; + policy_names.insert(access.id.clone(), physical); + profiles.insert(access.id.clone(), access.clone()); + } + let events = source + .events + .iter() + .map(|event| (event.id.clone(), event.clone())) + .collect(); + inventory.insert( + source.id.clone(), + EntityPhysicalNames { + table: table.clone(), + fields: field_names, + constraints: constraint_names, + indexes: index_names, + policies: policy_names, + }, + ); + entities.insert( + source.id.clone(), + CompiledEntity { + id: source.id.clone(), + route: source.route.clone(), + mutation_mode: source.mutation_mode.clone(), + tombstone: source.tombstone, + batch: source.batch.clone(), + classification: source.classification, + physical_table: table, + temporal: source.temporal.clone().map(CompiledTemporal::from), + fields, + constraints, + indexes, + access_profiles: profiles, + events, + }, + ); + } + Ok(( + entities, + PhysicalNameInventory { + entities: inventory, + }, + )) +} + +fn compile_routes_and_access( + entities: &BTreeMap, +) -> Result<(CompiledRouteInventory, CompiledAccessInventory), CompileFailure> { + let mut routes = Vec::new(); + let mut entries = Vec::new(); + for entity in entities.values() { + for operation in all_operations() { + if operation == Operation::Batch && entity.batch.is_none() { + continue; + } + let profiles: Vec<&AccessProfileSource> = entity + .access_profiles + .values() + .filter(|profile| { + profile.operations.contains(&operation) + && (operation != Operation::Revisions + || profile.revision_access && !profile.anonymous) + }) + .collect(); + if profiles.is_empty() { + continue; + } + let default = if profiles.len() == 1 { + profiles[0] + } else { + profiles + .iter() + .copied() + .find(|profile| profile.default) + .expect("default profile was validated") + }; + let profile_ids: BTreeSet = + profiles.iter().map(|profile| profile.id.clone()).collect(); + let (method, path) = route_shape(entity, operation); + let route = CompiledRoute { + id: format!("records.{}.{}", entity.id, operation_id(operation)), + entity_id: entity.id.clone(), + method, + path, + operation, + query_kind: (operation == Operation::List).then_some(CompiledQueryKind::List), + revision_kind: None, + maximum_records: None, + access_profiles: profile_ids.iter().cloned().collect(), + default_access_profile: default.id.clone(), + }; + if operation == Operation::Revisions { + routes.push(CompiledRoute { + id: format!("records.{}.revisions.list", entity.id), + revision_kind: Some(CompiledRevisionKind::List), + maximum_records: Some(MAX_REVISION_HISTORY_RECORDS), + ..route.clone() + }); + routes.push(CompiledRoute { + id: format!("records.{}.revisions.detail", entity.id), + path: format!("{}/{{revision}}", route.path), + revision_kind: Some(CompiledRevisionKind::Detail), + maximum_records: Some(1), + ..route + }); + } else { + routes.push(route); + } + if operation == Operation::List && entity.temporal.is_some() { + for kind in [CompiledQueryKind::Current, CompiledQueryKind::AsOf] { + routes.push(CompiledRoute { + id: format!("records.{}.{}", entity.id, query_kind_id(kind)), + entity_id: entity.id.clone(), + method: HttpMethod::Get, + path: format!("/v1/records/{}:{}", entity.route, query_kind_id(kind)), + operation, + query_kind: Some(kind), + revision_kind: None, + maximum_records: None, + access_profiles: profile_ids.iter().cloned().collect(), + default_access_profile: default.id.clone(), + }); + } + } + entries.push(CompiledAccessEntry { + entity_id: entity.id.clone(), + operation, + profile_ids, + default_profile_id: default.id.clone(), + }); + } + } + routes.sort_by(|left, right| { + (&left.path, left.method, &left.id).cmp(&(&right.path, right.method, &right.id)) + }); + entries.sort_by(|left, right| { + (&left.entity_id, left.operation).cmp(&(&right.entity_id, right.operation)) + }); + Ok(( + CompiledRouteInventory { routes }, + CompiledAccessInventory { entries }, + )) +} + +fn compile_metadata_inventory( + registry_id: &str, + version: &str, + entities: &BTreeMap, + routes: &CompiledRouteInventory, + access: &CompiledAccessInventory, +) -> Result { + let access_by_operation = access + .entries + .iter() + .map(|entry| ((entry.entity_id.as_str(), entry.operation), entry)) + .collect::>(); + let mut entries_by_entity: BTreeMap> = BTreeMap::new(); + for route in &routes.routes { + let Some(entity) = entities.get(&route.entity_id) else { + return Err(inconsistent_metadata_inventory()); + }; + let Some(access_entry) = + access_by_operation.get(&(route.entity_id.as_str(), route.operation)) + else { + return Err(inconsistent_metadata_inventory()); + }; + for profile_id in &route.access_profiles { + if !access_entry.profile_ids.contains(profile_id) { + return Err(inconsistent_metadata_inventory()); + } + let Some(profile) = entity.access_profiles.get(profile_id) else { + return Err(inconsistent_metadata_inventory()); + }; + let readable_fields = profile + .readable_fields + .iter() + .filter(|field| { + !profile.anonymous + || entity + .fields + .get(*field) + .is_some_and(|field| field.classification == Classification::Public) + }) + .cloned() + .collect(); + entries_by_entity + .entry(entity.id.clone()) + .or_default() + .push(CompiledMetadataEntry { + route_id: route.id.clone(), + operation: route.operation, + access_profile: profile_id.clone(), + readable_fields, + }); + } + } + let entities = entities + .values() + .filter_map(|entity| { + let mut entries = entries_by_entity.remove(&entity.id)?; + entries.sort_by(|left, right| { + (&left.route_id, left.operation, &left.access_profile).cmp(&( + &right.route_id, + right.operation, + &right.access_profile, + )) + }); + Some(CompiledMetadataEntity { + id: entity.id.clone(), + route: entity.route.clone(), + schema_path: format!("/v1/schemas/{}", entity.id), + entries, + }) + }) + .collect(); + Ok(CompiledMetadataInventory { + registry_id: registry_id.to_owned(), + version: version.to_owned(), + entities, + }) +} + +fn inconsistent_metadata_inventory() -> Diagnostic { + Diagnostic::error( + "metadata_inventory.inconsistent", + "compiled.metadataInventory", + "compiled metadata inventory no longer matches compiled route and access inventories", + ) +} + +fn compile_query_inventory( + entities: &BTreeMap, + errors: &mut Vec, +) -> CompiledQueryInventory { + let mut operations = Vec::new(); + let route_ids = entities + .values() + .flat_map(|entity| { + [ + (entity.id.clone(), CompiledQueryKind::List), + (entity.id.clone(), CompiledQueryKind::Current), + (entity.id.clone(), CompiledQueryKind::AsOf), + ] + }) + .map(|(entity_id, kind)| { + ( + (entity_id.clone(), kind), + format!("records.{entity_id}.{}", query_kind_id(kind)), + ) + }) + .collect::>(); + for entity in entities.values() { + for profile in entity.access_profiles.values() { + if !profile.operations.contains(&Operation::List) { + continue; + } + if let Some(operation) = query_operation( + entity, + profile, + &route_ids[&(entity.id.clone(), CompiledQueryKind::List)], + CompiledQueryKind::List, + None, + errors, + ) { + operations.push(operation); + } + if let Some(temporal) = &entity.temporal { + let binding = temporal_binding(temporal); + if let Some(operation) = query_operation( + entity, + profile, + &route_ids[&(entity.id.clone(), CompiledQueryKind::Current)], + CompiledQueryKind::Current, + Some(binding.clone()), + errors, + ) { + operations.push(operation); + } + if let Some(operation) = query_operation( + entity, + profile, + &route_ids[&(entity.id.clone(), CompiledQueryKind::AsOf)], + CompiledQueryKind::AsOf, + Some(binding), + errors, + ) { + operations.push(operation); + } + } + } + } + operations.sort_by(|left, right| left.id.cmp(&right.id)); + CompiledQueryInventory { operations } +} + +fn query_operation( + entity: &CompiledEntity, + profile: &AccessProfileSource, + route_id: &str, + kind: CompiledQueryKind, + temporal: Option, + errors: &mut Vec, +) -> Option { + if let Some(binding) = &temporal { + let temporal_fields = [&binding.start_field, &binding.end_field]; + if temporal_fields + .iter() + .any(|field| !profile.readable_fields.contains(*field)) + { + errors.push(Diagnostic::error( + "query.temporal.field_not_readable", + "entities[].accessProfiles[].readableFields", + "temporal query boundary fields must be readable by the selected profile", + )); + return None; + } + if profile.anonymous + && temporal_fields.iter().any(|field| { + entity + .fields + .get(*field) + .is_some_and(|compiled| compiled.classification != Classification::Public) + }) + { + errors.push(Diagnostic::error( + "query.temporal.public_processing_non_public", + "entities[].accessProfiles[]", + "an anonymous temporal query may process only public boundary fields", + )); + return None; + } + } + + let mut projection_fields = profile + .readable_fields + .iter() + .cloned() + .collect::>(); + projection_fields.sort(); + let filter_fields = profile + .filterable_fields + .iter() + .filter_map(|field| { + let compiled = entity.fields.get(field)?; + query_filter_field(&compiled.field_type, field, errors) + }) + .collect::>(); + let sort_fields = profile + .sortable_fields + .iter() + .filter_map(|field| { + let compiled = entity.fields.get(field)?; + query_sort_field(&compiled.field_type, field, errors) + }) + .collect::>(); + + Some(CompiledQueryOperation { + id: format!( + "records.{}.{}.{}", + entity.id, + profile.id, + query_kind_id(kind) + ), + route_id: route_id.to_owned(), + entity_id: entity.id.clone(), + profile_id: profile.id.clone(), + kind, + max_page_size: 100, + projection_fields, + filter_fields, + sort_fields, + stable_tie_breaker: "record_id".to_owned(), + temporal, + }) +} + +fn query_filter_field( + field_type: &FieldTypeSource, + field: &str, + errors: &mut Vec, +) -> Option { + let mut operators = vec![ + CompiledQueryFilterOperator::Equals, + CompiledQueryFilterOperator::In, + CompiledQueryFilterOperator::IsNull, + CompiledQueryFilterOperator::IsNotNull, + ]; + match field_type { + FieldTypeSource::Boolean | FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => {} + FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::VocabularyCode { .. } => { + operators.push(CompiledQueryFilterOperator::Prefix); + } + FieldTypeSource::Int64 + | FieldTypeSource::Decimal { .. } + | FieldTypeSource::Date + | FieldTypeSource::Timestamp => { + operators.push(CompiledQueryFilterOperator::Range); + } + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + errors.push(Diagnostic::error( + "query.filter.field_type_unsupported", + "entities[].accessProfiles[].filterableFields", + "a query filter field must use a supported scalar type", + )); + return None; + } + } + operators.sort(); + Some(CompiledQueryFilterField { + field: field.to_owned(), + operators, + }) +} + +fn query_sort_field( + field_type: &FieldTypeSource, + field: &str, + errors: &mut Vec, +) -> Option { + if matches!( + field_type, + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } + ) { + errors.push(Diagnostic::error( + "query.sort.field_type_unsupported", + "entities[].accessProfiles[].sortableFields", + "a query sort field must use a supported scalar type", + )); + return None; + } + Some(CompiledQuerySortField { + field: field.to_owned(), + directions: vec![CompiledQuerySortDirection::Asc], + }) +} + +fn temporal_binding(temporal: &CompiledTemporal) -> CompiledQueryTemporalBinding { + CompiledQueryTemporalBinding { + start_field: temporal.start_field.clone(), + end_field: temporal.end_field.clone(), + scope_fields: temporal.scope_fields.clone(), + semantics: CompiledQueryTemporalSemantics::StartInclusiveEndExclusive, + } +} + +fn query_kind_id(kind: CompiledQueryKind) -> &'static str { + match kind { + CompiledQueryKind::List => "list", + CompiledQueryKind::Current => "current", + CompiledQueryKind::AsOf => "as-of", + } +} + +fn route_shape(entity: &CompiledEntity, operation: Operation) -> (HttpMethod, String) { + let base = format!("/v1/records/{}", entity.route); + match operation { + Operation::Create => (HttpMethod::Post, base), + Operation::Get => (HttpMethod::Get, format!("{base}/{{record_id}}")), + Operation::List => (HttpMethod::Get, base), + Operation::Patch => (HttpMethod::Patch, format!("{base}/{{record_id}}")), + Operation::Tombstone => (HttpMethod::Delete, format!("{base}/{{record_id}}")), + Operation::Batch => (HttpMethod::Post, format!("{base}:batch")), + Operation::Revisions => (HttpMethod::Get, format!("{base}/{{record_id}}/revisions")), + } +} + +fn all_operations() -> [Operation; 7] { + [ + Operation::Create, + Operation::Get, + Operation::List, + Operation::Patch, + Operation::Tombstone, + Operation::Batch, + Operation::Revisions, + ] +} + +fn operation_id(operation: Operation) -> &'static str { + match operation { + Operation::Create => "create", + Operation::Get => "get", + Operation::List => "list", + Operation::Patch => "patch", + Operation::Tombstone => "tombstone", + Operation::Batch => "batch", + Operation::Revisions => "revisions", + } +} + +fn derived_constraint_id(constraint: &ConstraintSource) -> String { + if let Some(id) = constraint.explicit_id() { + return id.to_owned(); + } + let normalized = normalized_constraint(constraint); + let value = serde_json::to_value(&normalized).expect("constraint serializes"); + let bytes = canonicalize_json(&value).expect("constraint canonicalizes"); + let digest = Sha256::digest(bytes); + let kind = match constraint { + ConstraintSource::Unique { .. } => "unique", + ConstraintSource::Compare { .. } => "compare", + ConstraintSource::IntRange { .. } => "int-range", + ConstraintSource::Vocabulary { .. } => "vocabulary", + ConstraintSource::TemporalNonOverlap { .. } => "temporal-non-overlap", + }; + format!("{kind}-{}", hex_prefix(&digest, 8)) +} + +fn validate_unique_when( + entity: &EntitySource, + when: Option<&[UniqueWhenPredicate]>, + errors: &mut Vec, +) { + let Some(when) = when else { + return; + }; + if when.is_empty() { + errors.push(Diagnostic::error( + "constraint.unique.when.empty", + "entities[].constraints[].when", + "a partial unique constraint requires at least one closed predicate", + )); + return; + } + + let fields: BTreeMap<&str, &FieldSource> = entity + .fields + .iter() + .map(|field| (field.id.as_str(), field)) + .collect(); + let mut active_lifecycle = false; + let mut field_states: BTreeMap<&str, UniqueWhenFieldState> = BTreeMap::new(); + + for predicate in when { + match predicate { + UniqueWhenPredicate::ActiveLifecycle {} => { + if active_lifecycle { + errors.push(Diagnostic::error( + "constraint.unique.when.duplicate", + "entities[].constraints[].when", + "partial unique predicates must be duplicate-free", + )); + } + active_lifecycle = true; + } + UniqueWhenPredicate::FieldEquals { field, value } => { + let Some(source) = validate_unique_when_field(field, &fields, errors) else { + continue; + }; + let Some(canonical) = canonical_field_literal(value, &source.field_type) else { + errors.push(Diagnostic::error( + "constraint.unique.when.literal_invalid", + "entities[].constraints[].when[].value", + "a partial unique literal must be canonical for the field type", + )); + continue; + }; + let state = field_states.entry(field.as_str()).or_default(); + if state.is_null { + errors.push(Diagnostic::error( + "constraint.unique.when.contradiction", + "entities[].constraints[].when", + "partial unique predicates contain a contradiction", + )); + } + if state.is_not_null { + errors.push(Diagnostic::error( + "constraint.unique.when.duplicate", + "entities[].constraints[].when", + "partial unique predicates must be duplicate-free", + )); + } + if let Some(existing) = &state.equals { + errors.push(Diagnostic::error( + if existing == &canonical { + "constraint.unique.when.duplicate" + } else { + "constraint.unique.when.contradiction" + }, + "entities[].constraints[].when", + if existing == &canonical { + "partial unique predicates must be duplicate-free" + } else { + "partial unique predicates contain a contradiction" + }, + )); + } + state.equals = Some(canonical); + } + UniqueWhenPredicate::FieldIsNull { field } => { + let Some(source) = validate_unique_when_field(field, &fields, errors) else { + continue; + }; + if source.required { + errors.push(Diagnostic::error( + "constraint.unique.when.null_invalid", + "entities[].constraints[].when[].field", + "a partial unique null predicate must be useful for the field", + )); + continue; + } + let state = field_states.entry(field.as_str()).or_default(); + if state.is_null { + errors.push(Diagnostic::error( + "constraint.unique.when.duplicate", + "entities[].constraints[].when", + "partial unique predicates must be duplicate-free", + )); + } + if state.is_not_null || state.equals.is_some() { + errors.push(Diagnostic::error( + "constraint.unique.when.contradiction", + "entities[].constraints[].when", + "partial unique predicates contain a contradiction", + )); + } + state.is_null = true; + } + UniqueWhenPredicate::FieldIsNotNull { field } => { + let Some(source) = validate_unique_when_field(field, &fields, errors) else { + continue; + }; + if source.required { + errors.push(Diagnostic::error( + "constraint.unique.when.null_invalid", + "entities[].constraints[].when[].field", + "a partial unique null predicate must be useful for the field", + )); + continue; + } + let state = field_states.entry(field.as_str()).or_default(); + if state.is_not_null || state.equals.is_some() { + errors.push(Diagnostic::error( + "constraint.unique.when.duplicate", + "entities[].constraints[].when", + "partial unique predicates must be duplicate-free", + )); + } + if state.is_null { + errors.push(Diagnostic::error( + "constraint.unique.when.contradiction", + "entities[].constraints[].when", + "partial unique predicates contain a contradiction", + )); + } + state.is_not_null = true; + } + } + } +} + +#[derive(Default)] +struct UniqueWhenFieldState { + equals: Option, + is_null: bool, + is_not_null: bool, +} + +fn validate_unique_when_field<'a>( + field: &str, + fields: &BTreeMap<&str, &'a FieldSource>, + errors: &mut Vec, +) -> Option<&'a FieldSource> { + let Some(source) = fields.get(field).copied() else { + errors.push(Diagnostic::error( + "constraint.unique.when.field_unknown", + "entities[].constraints[].when[].field", + "a partial unique predicate refers to an unknown field", + )); + return None; + }; + if matches!( + source.field_type, + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } + ) { + errors.push(Diagnostic::error( + "constraint.unique.when.field_unsupported", + "entities[].constraints[].when[].field", + "CRS84 point and structured fields cannot be partial unique predicates", + )); + return None; + } + Some(source) +} + +fn canonical_field_literal(value: &Value, field_type: &FieldTypeSource) -> Option { + match field_type { + FieldTypeSource::Boolean => value.as_bool().map(|value| value.to_string()), + FieldTypeSource::String { + min_length, + max_length, + } => value.as_str().and_then(|value| { + let length = value.chars().count(); + (length >= *min_length as usize && length <= *max_length as usize) + .then(|| value.to_owned()) + }), + FieldTypeSource::Text { max_length } => value + .as_str() + .filter(|value| value.chars().count() <= *max_length as usize) + .map(str::to_owned), + FieldTypeSource::Int64 => value.as_i64().map(|parsed| parsed.to_string()), + FieldTypeSource::Decimal { + precision, + scale, + minimum, + maximum, + } => value.as_str().and_then(|value| { + crate::contract::valid_decimal_value( + value, + *precision, + *scale, + minimum.as_deref(), + maximum.as_deref(), + ) + .then(|| value.to_owned()) + }), + FieldTypeSource::Date => value + .as_str() + .filter(|value| valid_iso_date(value)) + .map(str::to_owned), + FieldTypeSource::Timestamp => value + .as_str() + .and_then(|value| canonical_timestamp(value).then(|| value.to_owned())), + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => value + .as_str() + .filter(|value| valid_uuid(value)) + .map(str::to_owned), + FieldTypeSource::VocabularyCode { values, .. } => value + .as_str() + .filter(|value| values.iter().any(|allowed| allowed == *value)) + .map(str::to_owned), + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => None, + } +} + +fn valid_iso_date(value: &str) -> bool { + if value.len() != 10 + || value.as_bytes()[4] != b'-' + || value.as_bytes()[7] != b'-' + || value + .bytes() + .enumerate() + .any(|(index, byte)| !matches!(index, 4 | 7) && !byte.is_ascii_digit()) + { + return false; + } + let Ok(year) = value[0..4].parse::() else { + return false; + }; + let Some(month) = value[5..7] + .parse::() + .ok() + .and_then(|month| Month::try_from(month).ok()) + else { + return false; + }; + let Ok(day) = value[8..10].parse::() else { + return false; + }; + (1..=9999).contains(&year) && Date::from_calendar_date(year, month, day).is_ok() +} + +fn canonical_timestamp(value: &str) -> bool { + let Ok(timestamp) = OffsetDateTime::parse(value, &Rfc3339) else { + return false; + }; + let Ok(formatted) = timestamp.format(&Rfc3339) else { + return false; + }; + formatted == value +} + +fn valid_uuid(value: &str) -> bool { + value.len() == 36 + && value.as_bytes()[8] == b'-' + && value.as_bytes()[13] == b'-' + && value.as_bytes()[18] == b'-' + && value.as_bytes()[23] == b'-' + && value + .bytes() + .enumerate() + .all(|(index, byte)| matches!(index, 8 | 13 | 18 | 23) || byte.is_ascii_hexdigit()) + && Uuid::parse_str(value).is_ok_and(|identifier| identifier.to_string() == value) +} + +fn unique_when_predicate_field(predicate: &UniqueWhenPredicate) -> Option<&str> { + match predicate { + UniqueWhenPredicate::FieldEquals { field, .. } + | UniqueWhenPredicate::FieldIsNull { field } + | UniqueWhenPredicate::FieldIsNotNull { field } => Some(field), + UniqueWhenPredicate::ActiveLifecycle {} => None, + } +} + +fn normalized_constraint(constraint: &ConstraintSource) -> ConstraintSource { + match constraint { + ConstraintSource::Unique { id, fields, when } => ConstraintSource::Unique { + id: id.clone(), + fields: fields.clone(), + when: when.as_ref().map(|predicates| { + let mut predicates = predicates.clone(); + predicates.sort_by_key(unique_when_predicate_sort_key); + predicates + }), + }, + _ => constraint.clone(), + } +} + +fn unique_when_predicate_sort_key(predicate: &UniqueWhenPredicate) -> String { + match predicate { + UniqueWhenPredicate::FieldEquals { field, value } => { + format!("field:{field}:equals:{}", value) + } + UniqueWhenPredicate::FieldIsNull { field } => format!("field:{field}:is_null"), + UniqueWhenPredicate::FieldIsNotNull { field } => format!("field:{field}:is_not_null"), + UniqueWhenPredicate::ActiveLifecycle {} => "lifecycle:active".to_owned(), + } +} + +fn validate_id(value: &str, path: &str, errors: &mut Vec) { + let valid = !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }); + if !valid { + errors.push(Diagnostic::error( + "identifier.invalid", + path, + "an identifier must use the closed lowercase identifier grammar", + )); + } +} + +fn validate_language(value: &str, errors: &mut Vec) { + if value.is_empty() + || value.len() > 35 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + errors.push(Diagnostic::error( + "project.default_language.invalid", + "project.registry.defaultLanguage", + "the default language tag is invalid", + )); + } +} + +fn nonempty(value: &str, path: &str, code: &str, errors: &mut Vec) { + if value.trim().is_empty() { + errors.push(Diagnostic::error( + code, + path, + "a required source field is empty", + )); + } +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..] + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn has_duplicates(values: &[T]) -> bool { + let mut seen = BTreeSet::new(); + values.iter().any(|value| !seen.insert(value)) +} + +fn valid_code(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.chars().all(|character| !character.is_control()) +} diff --git a/crates/registry-server/src/contract.rs b/crates/registry-server/src/contract.rs new file mode 100644 index 0000000000..55219d634a --- /dev/null +++ b/crates/registry-server/src/contract.rs @@ -0,0 +1,1463 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; + +use jsonschema::{Draft, JSONSchema}; +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use serde::{ + de::DeserializeOwned, de::Error as _, de::IntoDeserializer, Deserialize, Deserializer, + Serialize, +}; +use serde_json::Value; + +use crate::diagnostics::{CompileFailure, Diagnostic}; + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RegistryProject { + pub api_version: String, + pub kind: String, + pub registry: RegistryIdentitySource, + #[serde(default)] + pub package: Option, + #[serde(default)] + pub manifest_projection: Option, + #[serde(default)] + pub modules: Vec, + #[serde(default)] + pub entities: Vec, + #[serde(default)] + pub access_profiles: Vec, + #[serde(default)] + pub vocabularies: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RegistryIdentitySource { + pub id: String, + pub version: String, + pub default_language: String, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PackageIdentitySource { + pub environment: String, + pub instance_id: String, + pub sequence: u64, + pub source_revision: String, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionSource { + pub access_profile: String, + pub classification_ceiling: Classification, + pub catalog: ManifestProjectionCatalogSource, + pub dataset: ManifestProjectionDatasetSource, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionCatalogSource { + pub base_url: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub publisher: ManifestProjectionPublisherSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub participant_id: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionPublisherSource { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub iri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authority_type: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionDatasetSource { + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManifestProjectionDatasetStatus { + UnderDevelopment, + Active, + Completed, + Deprecated, + Withdrawn, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ModuleLockSource { + pub id: String, + pub version: String, + #[serde(default)] + pub digest: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RegistryModule { + pub id: String, + pub version: String, + #[serde(default)] + pub dependencies: Vec, + #[serde(default)] + pub entities: Vec, + #[serde(default)] + pub extend_entities: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct EntitySource { + pub id: String, + pub route: String, + pub mutation_mode: MutationMode, + #[serde(default)] + pub tombstone: bool, + #[serde(default)] + pub batch: Option, + #[serde(default = "default_classification")] + pub classification: Classification, + #[serde(default)] + pub fields: Vec, + #[serde(default)] + pub constraints: Vec, + #[serde(default)] + pub indexes: Vec, + #[serde(default)] + pub access_profiles: Vec, + #[serde(default)] + pub events: Vec, + #[serde(default)] + pub temporal: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct BatchSource { + pub maximum_items: u16, + pub maximum_bytes: u32, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct EntityExtensionSource { + pub entity: String, + #[serde(default)] + pub fields: Vec, + #[serde(default)] + pub constraints: Vec, + #[serde(default)] + pub indexes: Vec, + #[serde(default)] + pub access_profiles: Vec, + #[serde(default)] + pub events: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MutationMode { + Mutable, + CreateOnly, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Classification { + Public, + Internal, + Restricted, +} + +fn default_classification() -> Classification { + Classification::Internal +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FieldSource { + pub id: String, + #[serde(flatten)] + pub field_type: FieldTypeSource, + #[serde(default)] + pub required: bool, + pub classification: Classification, + #[serde(default)] + pub valid_time_role: Option, +} + +#[cfg(feature = "schema")] +impl schemars::JsonSchema for FieldSource { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("FieldSource") + } + + fn schema_id() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(concat!(module_path!(), "::FieldSource")) + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + FieldSourceSchema::json_schema(generator) + } +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(untagged)] +enum FieldSourceSchema { + Boolean(BooleanFieldSourceSchema), + String(StringFieldSourceSchema), + Text(TextFieldSourceSchema), + Int64(Int64FieldSourceSchema), + Decimal(DecimalFieldSourceSchema), + Date(DateFieldSourceSchema), + Timestamp(TimestampFieldSourceSchema), + Uuid(UuidFieldSourceSchema), + VocabularyCode(VocabularyCodeFieldSourceSchema), + Reference(ReferenceFieldSourceSchema), + Crs84Point(Crs84PointFieldSourceSchema), + Structured(StructuredFieldSourceSchema), +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct BooleanFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: BooleanFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct StringFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: StringFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, + #[serde(default)] + min_length: u32, + max_length: u32, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct TextFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: TextFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, + max_length: u32, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Int64FieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: Int64FieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct DecimalFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: DecimalFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, + precision: u8, + scale: u8, + #[serde(default)] + minimum: Option, + #[serde(default)] + maximum: Option, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct DateFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: DateFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct TimestampFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: TimestampFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct UuidFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: UuidFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct VocabularyCodeFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: VocabularyCodeFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, + vocabulary: String, + #[serde(default)] + values: Vec, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ReferenceFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: ReferenceFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, + target: String, + #[serde(default)] + on_delete: ReferenceDelete, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Crs84PointFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: Crs84PointFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, + precision: u8, + #[serde(default)] + bbox: Option, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct StructuredFieldSourceSchema { + id: String, + #[serde(rename = "type")] + field_type: StructuredFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, + max_bytes: u32, + schema: Value, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum BooleanFieldKindSchema { + Boolean, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum StringFieldKindSchema { + String, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum TextFieldKindSchema { + Text, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum Int64FieldKindSchema { + Int64, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum DecimalFieldKindSchema { + Decimal, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum DateFieldKindSchema { + Date, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum TimestampFieldKindSchema { + Timestamp, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum UuidFieldKindSchema { + Uuid, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +enum VocabularyCodeFieldKindSchema { + #[serde(rename = "vocabulary-code")] + VocabularyCode, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum ReferenceFieldKindSchema { + Reference, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +enum Crs84PointFieldKindSchema { + #[serde(rename = "crs84-point")] + Crs84Point, +} + +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum StructuredFieldKindSchema { + Structured, +} + +impl<'de> Deserialize<'de> for FieldSource { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RawFieldSource::deserialize(deserializer)?; + let field_type = match raw.kind { + RawFieldKind::Boolean => { + reject_type_options::(&raw, TypeOptionAllowances::NONE)?; + FieldTypeSource::Boolean + } + RawFieldKind::String => { + reject_type_options::(&raw, TypeOptionAllowances::STRING)?; + FieldTypeSource::String { + min_length: raw.min_length.unwrap_or_default(), + max_length: raw + .max_length + .ok_or_else(|| D::Error::custom("string maxLength is required"))?, + } + } + RawFieldKind::Text => { + reject_type_options::(&raw, TypeOptionAllowances::TEXT)?; + FieldTypeSource::Text { + max_length: raw + .max_length + .ok_or_else(|| D::Error::custom("text maxLength is required"))?, + } + } + RawFieldKind::Int64 => { + reject_type_options::(&raw, TypeOptionAllowances::NONE)?; + FieldTypeSource::Int64 + } + RawFieldKind::Decimal => { + reject_type_options::(&raw, TypeOptionAllowances::DECIMAL)?; + FieldTypeSource::Decimal { + precision: raw + .precision + .ok_or_else(|| D::Error::custom("decimal precision is required"))?, + scale: raw + .scale + .ok_or_else(|| D::Error::custom("decimal scale is required"))?, + minimum: raw.minimum.clone(), + maximum: raw.maximum.clone(), + } + } + RawFieldKind::Date => { + reject_type_options::(&raw, TypeOptionAllowances::NONE)?; + FieldTypeSource::Date + } + RawFieldKind::Timestamp => { + reject_type_options::(&raw, TypeOptionAllowances::NONE)?; + FieldTypeSource::Timestamp + } + RawFieldKind::Uuid => { + reject_type_options::(&raw, TypeOptionAllowances::NONE)?; + FieldTypeSource::Uuid + } + RawFieldKind::VocabularyCode => { + reject_type_options::(&raw, TypeOptionAllowances::VOCABULARY)?; + FieldTypeSource::VocabularyCode { + vocabulary: raw + .vocabulary + .clone() + .ok_or_else(|| D::Error::custom("vocabulary is required"))?, + values: raw.values.clone(), + } + } + RawFieldKind::Reference => { + reject_type_options::(&raw, TypeOptionAllowances::REFERENCE)?; + FieldTypeSource::Reference { + target: raw + .target + .clone() + .ok_or_else(|| D::Error::custom("reference target is required"))?, + on_delete: raw.on_delete.clone().unwrap_or_default(), + } + } + RawFieldKind::Crs84Point => { + reject_type_options::(&raw, TypeOptionAllowances::CRS84_POINT)?; + FieldTypeSource::Crs84Point { + precision: raw + .precision + .ok_or_else(|| D::Error::custom("point precision is required"))?, + bbox: raw.bbox.clone(), + } + } + RawFieldKind::Structured => { + reject_type_options::(&raw, TypeOptionAllowances::STRUCTURED)?; + FieldTypeSource::Structured { + max_bytes: raw + .max_bytes + .ok_or_else(|| D::Error::custom("structured maxBytes is required"))?, + schema: raw + .schema + .clone() + .ok_or_else(|| D::Error::custom("structured schema is required"))?, + } + } + }; + Ok(Self { + id: raw.id, + field_type, + required: raw.required, + classification: raw.classification, + valid_time_role: raw.valid_time_role, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct RawFieldSource { + id: String, + #[serde(rename = "type")] + kind: RawFieldKind, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, + #[serde(default)] + min_length: Option, + #[serde(default)] + max_length: Option, + #[serde(default)] + precision: Option, + #[serde(default)] + scale: Option, + #[serde(default)] + minimum: Option, + #[serde(default)] + maximum: Option, + #[serde(default)] + bbox: Option, + #[serde(default)] + max_bytes: Option, + #[serde(default)] + schema: Option, + #[serde(default)] + vocabulary: Option, + #[serde(default)] + values: Vec, + #[serde(default)] + target: Option, + #[serde(default)] + on_delete: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum RawFieldKind { + Boolean, + String, + Text, + Int64, + Decimal, + Date, + Timestamp, + Uuid, + #[serde(rename = "vocabulary-code")] + VocabularyCode, + Reference, + #[serde(rename = "crs84-point")] + Crs84Point, + Structured, +} + +#[derive(Clone, Copy)] +struct TypeOptionAllowances { + min_length: bool, + max_length: bool, + precision: bool, + scale: bool, + decimal_bounds: bool, + structured: bool, + vocabulary: bool, + target: bool, + bbox: bool, + delete: bool, +} + +impl TypeOptionAllowances { + const NONE: Self = Self { + min_length: false, + max_length: false, + precision: false, + scale: false, + decimal_bounds: false, + structured: false, + vocabulary: false, + target: false, + bbox: false, + delete: false, + }; + const STRING: Self = Self { + min_length: true, + max_length: true, + ..Self::NONE + }; + const TEXT: Self = Self { + max_length: true, + ..Self::NONE + }; + const DECIMAL: Self = Self { + precision: true, + scale: true, + decimal_bounds: true, + ..Self::NONE + }; + const VOCABULARY: Self = Self { + vocabulary: true, + ..Self::NONE + }; + const REFERENCE: Self = Self { + target: true, + delete: true, + ..Self::NONE + }; + const CRS84_POINT: Self = Self { + precision: true, + bbox: true, + ..Self::NONE + }; + const STRUCTURED: Self = Self { + structured: true, + ..Self::NONE + }; +} + +fn reject_type_options( + raw: &RawFieldSource, + allowed: TypeOptionAllowances, +) -> Result<(), E> { + if (!allowed.min_length && raw.min_length.is_some()) + || (!allowed.max_length && raw.max_length.is_some()) + || (!allowed.precision && raw.precision.is_some()) + || (!allowed.scale && raw.scale.is_some()) + || (!allowed.decimal_bounds && (raw.minimum.is_some() || raw.maximum.is_some())) + || (!allowed.structured && (raw.max_bytes.is_some() || raw.schema.is_some())) + || (!allowed.bbox && raw.bbox.is_some()) + || (!allowed.vocabulary && (raw.vocabulary.is_some() || !raw.values.is_empty())) + || (!allowed.target && raw.target.is_some()) + || (!allowed.delete && raw.on_delete.is_some()) + { + return Err(E::custom("the field type contains an incompatible option")); + } + Ok(()) +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde( + deny_unknown_fields, + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum FieldTypeSource { + Boolean, + String { + #[serde(default)] + min_length: u32, + max_length: u32, + }, + Text { + max_length: u32, + }, + Int64, + Decimal { + precision: u8, + scale: u8, + #[serde(skip_serializing_if = "Option::is_none")] + minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + maximum: Option, + }, + Date, + Timestamp, + Uuid, + #[serde(rename = "vocabulary-code")] + VocabularyCode { + vocabulary: String, + #[serde(default)] + values: Vec, + }, + Reference { + target: String, + #[serde(default)] + on_delete: ReferenceDelete, + }, + #[serde(rename = "crs84-point")] + Crs84Point { + precision: u8, + #[serde(skip_serializing_if = "Option::is_none")] + bbox: Option, + }, + Structured { + max_bytes: u32, + schema: Value, + }, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Crs84BboxSource { + pub west: String, + pub south: String, + pub east: String, + pub north: String, +} + +pub(crate) const MAX_STRUCTURED_SCHEMA_BYTES: usize = 64 * 1024; +pub(crate) const MAX_STRUCTURED_VALUE_BYTES: u32 = 1024 * 1024; + +pub(crate) fn decimal_scaled_value(value: &str, precision: u8, scale: u8) -> Option { + if !(1..=38).contains(&precision) || scale > precision { + return None; + } + let unsigned = value.strip_prefix('-').unwrap_or(value); + if unsigned.is_empty() || unsigned.contains('+') { + return None; + } + let max_integer_digits = usize::from(precision - scale); + let (integer, fraction) = if scale == 0 { + if unsigned.contains('.') { + return None; + } + (unsigned, "") + } else { + let (integer, fraction) = unsigned.split_once('.')?; + if fraction.len() != usize::from(scale) { + return None; + } + (integer, fraction) + }; + if integer.is_empty() + || integer.bytes().any(|byte| !byte.is_ascii_digit()) + || fraction.bytes().any(|byte| !byte.is_ascii_digit()) + || integer.len() > 1 && integer.starts_with('0') + || integer != "0" && integer.len() > max_integer_digits + || integer == "0" && scale == 0 && precision == 0 + { + return None; + } + let digits = format!("{integer}{fraction}"); + let scaled = digits.parse::().ok()?; + if value.starts_with('-') { + if scaled == 0 { + None + } else { + Some(-scaled) + } + } else { + Some(scaled) + } +} + +pub(crate) fn valid_decimal_bounds( + precision: u8, + scale: u8, + minimum: Option<&str>, + maximum: Option<&str>, +) -> bool { + if !(1..=38).contains(&precision) || scale > precision { + return false; + } + let minimum = match minimum { + Some(value) => match decimal_scaled_value(value, precision, scale) { + Some(parsed) => Some(parsed), + None => return false, + }, + None => None, + }; + let maximum = match maximum { + Some(value) => match decimal_scaled_value(value, precision, scale) { + Some(parsed) => Some(parsed), + None => return false, + }, + None => None, + }; + minimum + .zip(maximum) + .is_none_or(|(minimum, maximum)| minimum <= maximum) +} + +#[cfg_attr(not(feature = "runtime"), allow(dead_code))] +pub(crate) fn valid_decimal_value( + value: &str, + precision: u8, + scale: u8, + minimum: Option<&str>, + maximum: Option<&str>, +) -> bool { + let Some(parsed) = decimal_scaled_value(value, precision, scale) else { + return false; + }; + if let Some(minimum) = minimum { + let Some(minimum) = decimal_scaled_value(minimum, precision, scale) else { + return false; + }; + if parsed < minimum { + return false; + } + } + if let Some(maximum) = maximum { + let Some(maximum) = decimal_scaled_value(maximum, precision, scale) else { + return false; + }; + if parsed > maximum { + return false; + } + } + true +} + +#[cfg_attr(not(feature = "runtime"), allow(dead_code))] +pub(crate) fn parsed_bbox(bbox: &Crs84BboxSource, precision: u8) -> Option<(f64, f64, f64, f64)> { + if precision > 9 { + return None; + } + let west = parse_coordinate(&bbox.west, precision, -180.0, 180.0)?; + let south = parse_coordinate(&bbox.south, precision, -90.0, 90.0)?; + let east = parse_coordinate(&bbox.east, precision, -180.0, 180.0)?; + let north = parse_coordinate(&bbox.north, precision, -90.0, 90.0)?; + (west <= east && south <= north).then_some((west, south, east, north)) +} + +#[cfg_attr(not(feature = "runtime"), allow(dead_code))] +pub(crate) fn valid_crs84_point( + value: &Value, + precision: u8, + bbox: Option<&Crs84BboxSource>, +) -> bool { + if precision > 9 { + return false; + } + let Some(object) = value.as_object() else { + return false; + }; + if object.len() != 2 || object.get("type").and_then(Value::as_str) != Some("Point") { + return false; + } + let Some(coordinates) = object.get("coordinates").and_then(Value::as_array) else { + return false; + }; + if coordinates.len() != 2 { + return false; + } + let Some(lon) = coordinate_number(&coordinates[0], precision, -180.0, 180.0) else { + return false; + }; + let Some(lat) = coordinate_number(&coordinates[1], precision, -90.0, 90.0) else { + return false; + }; + bbox.and_then(|bbox| parsed_bbox(bbox, precision)) + .is_none_or(|(west, south, east, north)| { + lon >= west && lon <= east && lat >= south && lat <= north + }) +} + +pub(crate) fn valid_structured_schema(schema: &Value) -> bool { + schema.as_object().is_some_and(|object| { + schema_declares_object(object) + && object.get("additionalProperties") == Some(&Value::Bool(false)) + }) && canonicalize_json(schema).is_ok_and(|bytes| bytes.len() <= MAX_STRUCTURED_SCHEMA_BYTES) + && schema_refs_are_local(schema) + && object_schemas_are_closed(schema) + && JSONSchema::options() + .with_draft(Draft::Draft202012) + .compile(schema) + .is_ok() +} + +fn schema_declares_object(object: &serde_json::Map) -> bool { + object.get("type").is_some_and(|kind| { + kind == "object" + || kind + .as_array() + .is_some_and(|types| types.iter().any(|kind| kind == "object")) + }) +} + +#[cfg_attr(not(feature = "runtime"), allow(dead_code))] +pub(crate) fn valid_structured_value(value: &Value, max_bytes: u32, schema: &Value) -> bool { + if max_bytes == 0 || max_bytes > MAX_STRUCTURED_VALUE_BYTES || !valid_structured_schema(schema) + { + return false; + } + let Ok(bytes) = canonicalize_json(value) else { + return false; + }; + if bytes.len() > max_bytes as usize { + return false; + } + JSONSchema::options() + .with_draft(Draft::Draft202012) + .compile(schema) + .is_ok_and(|compiled| compiled.is_valid(value)) +} + +#[cfg_attr(not(feature = "runtime"), allow(dead_code))] +fn parse_coordinate(value: &str, precision: u8, minimum: f64, maximum: f64) -> Option { + if value.is_empty() || value.starts_with('+') || value.contains('e') || value.contains('E') { + return None; + } + let unsigned = value.strip_prefix('-').unwrap_or(value); + let (integer, fraction) = unsigned.split_once('.').unwrap_or((unsigned, "")); + if integer.is_empty() + || integer.bytes().any(|byte| !byte.is_ascii_digit()) + || fraction.bytes().any(|byte| !byte.is_ascii_digit()) + || integer.len() > 1 && integer.starts_with('0') + || fraction.len() > usize::from(precision) + { + return None; + } + let parsed = value.parse::().ok()?; + (parsed >= minimum && parsed <= maximum).then_some(parsed) +} + +#[cfg_attr(not(feature = "runtime"), allow(dead_code))] +fn coordinate_number(value: &Value, precision: u8, minimum: f64, maximum: f64) -> Option { + value + .is_number() + .then(|| parse_coordinate(&value.to_string(), precision, minimum, maximum)) + .flatten() +} + +fn schema_refs_are_local(value: &Value) -> bool { + match value { + Value::Object(object) => object.iter().all(|(key, value)| { + if key == "$ref" { + value + .as_str() + .is_some_and(|reference| reference == "#" || reference.starts_with("#/")) + } else { + schema_refs_are_local(value) + } + }), + Value::Array(values) => values.iter().all(schema_refs_are_local), + _ => true, + } +} + +fn object_schemas_are_closed(value: &Value) -> bool { + match value { + Value::Object(object) => { + let describes_object = object.get("properties").is_some() + || object.get("patternProperties").is_some() + || schema_declares_object(object); + (!describes_object || object.get("additionalProperties") == Some(&Value::Bool(false))) + && object.values().all(object_schemas_are_closed) + } + Value::Array(values) => values.iter().all(object_schemas_are_closed), + _ => true, + } +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReferenceDelete { + #[default] + Restrict, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ValidTimeRole { + ValidFrom, + ValidTo, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde( + deny_unknown_fields, + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum ConstraintSource { + Unique { + #[serde(default)] + id: Option, + fields: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + when: Option>, + }, + Compare { + #[serde(default)] + id: Option, + left: String, + operator: ComparisonOperator, + right: String, + }, + IntRange { + #[serde(default)] + id: Option, + field: String, + #[serde(default)] + minimum: Option, + #[serde(default)] + maximum: Option, + }, + Vocabulary { + #[serde(default)] + id: Option, + field: String, + values: Vec, + }, + #[serde(rename = "temporal-non-overlap")] + TemporalNonOverlap { + #[serde(default)] + id: Option, + scope_fields: Vec, + #[serde(default)] + start_field: Option, + #[serde(default)] + end_field: Option, + }, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde( + deny_unknown_fields, + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum UniqueWhenPredicate { + FieldEquals { field: String, value: Value }, + FieldIsNull { field: String }, + FieldIsNotNull { field: String }, + ActiveLifecycle {}, +} + +impl ConstraintSource { + pub fn explicit_id(&self) -> Option<&str> { + match self { + Self::Unique { id, .. } + | Self::Compare { id, .. } + | Self::IntRange { id, .. } + | Self::Vocabulary { id, .. } + | Self::TemporalNonOverlap { id, .. } => id.as_deref(), + } + } +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TemporalSource { + pub start_field: String, + pub end_field: String, + pub scope_fields: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComparisonOperator { + LessThan, + LessThanOrEqual, + GreaterThan, + GreaterThanOrEqual, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct IndexSource { + pub id: String, + pub fields: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AccessProfileSource { + pub id: String, + #[serde(default)] + pub default: bool, + #[serde(default)] + pub anonymous: bool, + #[serde(default)] + pub principal_claim: Option, + #[serde(default)] + pub required_scopes: BTreeSet, + #[serde(default)] + pub required_purposes: BTreeSet, + pub operations: BTreeSet, + #[serde(default)] + pub readable_fields: BTreeSet, + #[serde(default)] + pub writable_fields: BTreeSet, + #[serde(default)] + pub filterable_fields: BTreeSet, + #[serde(default)] + pub sortable_fields: BTreeSet, + #[serde(default)] + pub row_boundaries: Vec, + #[serde(default)] + pub revision_access: bool, + #[serde(default)] + pub allow_data_export: bool, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Operation { + Create, + Get, + List, + Patch, + Tombstone, + Batch, + Revisions, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RowBoundarySource { + pub field: String, + pub claim: String, + pub operator: BoundaryOperator, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BoundaryOperator { + Equals, + In, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct EventSource { + pub id: String, + pub trigger: EventTrigger, + pub projection: BTreeSet, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhook: Option, +} + +/// Governed, destination-neutral webhook subscription. +/// +/// Deployment configuration may bind `destination_id` to transport details +/// and tighten these bounds, but cannot supply or widen this authority. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct WebhookSource { + pub destination_id: String, + pub classification_ceiling: Classification, + pub authentication_profile: WebhookAuthenticationProfile, + pub delivery: WebhookDeliverySource, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookAuthenticationProfile { + HmacSha256V1, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct WebhookDeliverySource { + pub attempt_timeout_ms: u32, + pub initial_backoff_ms: u32, + pub maximum_backoff_ms: u32, + pub maximum_attempts: u8, + #[serde(default)] + pub dead_letter: Option, + pub operator_replay: bool, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookDeadLetterMode { + Required, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProjectAccessProfileSource { + pub id: String, + #[serde(default)] + pub default: bool, + pub principal_claim: String, + #[serde(default)] + pub required_scopes: BTreeSet, + #[serde(default)] + pub purposes: BTreeSet, + #[serde(default)] + pub grants: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AccessGrantSource { + pub entity: String, + pub actions: BTreeSet, + #[serde(default)] + pub readable_fields: BTreeSet, + #[serde(default)] + pub writable_fields: BTreeSet, + #[serde(default)] + pub filterable_fields: BTreeSet, + #[serde(default)] + pub sortable_fields: BTreeSet, + #[serde(default)] + pub row_boundaries: Vec, + #[serde(default)] + pub revision_access: bool, + #[serde(default)] + pub allow_data_export: bool, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct VocabularySource { + pub id: String, + pub values: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventTrigger { + Created, + Patched, + Tombstoned, +} + +pub fn parse_project_json(bytes: &[u8]) -> Result { + parse_json(bytes, "project") +} + +pub fn parse_module_json(bytes: &[u8]) -> Result { + parse_json(bytes, "module") +} + +pub fn parse_project_yaml(bytes: &[u8]) -> Result { + parse_yaml(bytes, "project") +} + +pub fn parse_module_yaml(bytes: &[u8]) -> Result { + parse_yaml(bytes, "module") +} + +fn parse_json(bytes: &[u8], root: &str) -> Result { + let value = parse_json_strict(bytes).map_err(|_| { + CompileFailure::from_one(Diagnostic::error( + "source.json.invalid", + root, + "the JSON source is structurally invalid", + )) + })?; + deserialize_value(value, root) +} + +fn parse_yaml(bytes: &[u8], root: &str) -> Result { + let deserializer = serde_norway::Deserializer::from_slice(bytes); + serde_path_to_error::deserialize(deserializer).map_err(|error| { + let suffix = error.path().to_string(); + let path = if suffix.is_empty() { + root.to_owned() + } else { + format!("{root}.{suffix}") + }; + CompileFailure::from_one(Diagnostic::error( + "source.yaml.invalid", + path, + "the YAML source is structurally invalid", + )) + }) +} + +fn deserialize_value( + value: serde_json::Value, + root: &str, +) -> Result { + let deserializer = value.into_deserializer(); + serde_path_to_error::deserialize(deserializer).map_err(|error| { + let suffix = error.path().to_string(); + let path = if suffix.is_empty() { + root.to_owned() + } else { + format!("{root}.{suffix}") + }; + CompileFailure::from_one(Diagnostic::error( + "source.shape.invalid", + path, + "the source field is unknown, duplicated, missing, or has the wrong type", + )) + }) +} diff --git a/crates/registry-server/src/cursor.rs b/crates/registry-server/src/cursor.rs new file mode 100644 index 0000000000..2aae4fc95f --- /dev/null +++ b/crates/registry-server/src/cursor.rs @@ -0,0 +1,554 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Registry Server-owned confidential keyset cursor codec. + +use std::fmt; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use chacha20poly1305::aead::{Aead, Payload}; +use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; +use hmac::{Hmac, Mac}; +use registry_platform_canonical_json::canonicalize_json; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::Sha256; +use zeroize::Zeroizing; + +use crate::model::CompiledQueryKind; + +const WIRE_VERSION: u8 = 1; +const ROOT_SECRET_MIN_BYTES: usize = 32; +const KEY_BYTES: usize = 32; +const NONCE_BYTES: usize = 24; +const TAG_BYTES: usize = 16; +const MAX_PAYLOAD_BYTES: usize = 8 * 1024; +const MAX_TOKEN_BYTES: usize = (1 + NONCE_BYTES + MAX_PAYLOAD_BYTES + TAG_BYTES) * 2; +const MAX_AGE_SECONDS: u64 = 86_400; +const CURSOR_AAD: &[u8] = b"registry-server-cursor-v1"; +const AEAD_LABEL: &[u8] = b"registry-server-cursor-aead-key-v1"; +const BINDING_LABEL: &[u8] = b"registry-server-cursor-binding-key-v1"; + +type HmacSha256 = Hmac; + +#[derive(Clone)] +pub struct CursorCodec { + aead_key: Zeroizing<[u8; KEY_BYTES]>, + binding_key: Zeroizing<[u8; KEY_BYTES]>, + max_age: Duration, +} + +impl CursorCodec { + pub fn new(root_secret: Zeroizing>, max_age: Duration) -> Result { + if root_secret.len() < ROOT_SECRET_MIN_BYTES + || max_age.is_zero() + || max_age.as_secs() > MAX_AGE_SECONDS + { + return Err(CursorError::Configuration); + } + Ok(Self { + aead_key: derive_key(root_secret.as_slice(), AEAD_LABEL)?, + binding_key: derive_key(root_secret.as_slice(), BINDING_LABEL)?, + max_age, + }) + } + + pub fn encode(&self, payload: &CursorPayload) -> Result { + if payload.version != WIRE_VERSION || payload.expires_at_unix_seconds > payload.max_expiry() + { + return Err(CursorError::Malformed); + } + let plaintext = serde_json::to_vec(payload).map_err(|_| CursorError::Malformed)?; + if plaintext.is_empty() || plaintext.len() > MAX_PAYLOAD_BYTES { + return Err(CursorError::Malformed); + } + let cipher = XChaCha20Poly1305::new_from_slice(self.aead_key.as_slice()) + .map_err(|_| CursorError::Configuration)?; + let mut nonce = [0_u8; NONCE_BYTES]; + getrandom::fill(&mut nonce).map_err(|_| CursorError::Configuration)?; + let nonce = XNonce::from(nonce); + let ciphertext = cipher + .encrypt( + &nonce, + Payload { + msg: plaintext.as_slice(), + aad: CURSOR_AAD, + }, + ) + .map_err(|_| CursorError::Configuration)?; + let mut envelope = Vec::with_capacity(1 + NONCE_BYTES + ciphertext.len()); + envelope.push(WIRE_VERSION); + envelope.extend_from_slice(&nonce); + envelope.extend_from_slice(&ciphertext); + Ok(URL_SAFE_NO_PAD.encode(envelope)) + } + + pub(crate) fn open_after_authorization( + &self, + token: &str, + now_unix_seconds: u64, + expected: impl FnOnce(&CursorPayload) -> Result, + ) -> Result { + let payload = self.decode(token, now_unix_seconds)?; + if payload.binding != expected(&payload)? { + return Err(CursorError::Mismatch); + } + Ok(payload) + } + + fn decode(&self, token: &str, now_unix_seconds: u64) -> Result { + if token.is_empty() || token.len() > MAX_TOKEN_BYTES { + return Err(CursorError::Invalid); + } + let envelope = URL_SAFE_NO_PAD + .decode(token) + .map_err(|_| CursorError::Invalid)?; + if envelope.len() <= 1 + NONCE_BYTES + TAG_BYTES + || envelope.len() > 1 + NONCE_BYTES + MAX_PAYLOAD_BYTES + TAG_BYTES + || envelope[0] != WIRE_VERSION + { + return Err(CursorError::Invalid); + } + let (nonce, ciphertext) = envelope[1..].split_at(NONCE_BYTES); + let nonce: [u8; NONCE_BYTES] = nonce.try_into().map_err(|_| CursorError::Invalid)?; + let cipher = XChaCha20Poly1305::new_from_slice(self.aead_key.as_slice()) + .map_err(|_| CursorError::Configuration)?; + let plaintext = cipher + .decrypt( + &XNonce::from(nonce), + Payload { + msg: ciphertext, + aad: CURSOR_AAD, + }, + ) + .map_err(|_| CursorError::Invalid)?; + if plaintext.is_empty() || plaintext.len() > MAX_PAYLOAD_BYTES { + return Err(CursorError::Invalid); + } + let payload: CursorPayload = + serde_json::from_slice(&plaintext).map_err(|_| CursorError::Invalid)?; + if payload.version != WIRE_VERSION { + return Err(CursorError::Invalid); + } + if payload + .issued_at_unix_seconds + .checked_add(self.max_age.as_secs()) + .filter(|max| payload.expires_at_unix_seconds <= *max) + .is_none() + { + return Err(CursorError::Invalid); + } + if payload.expires_at_unix_seconds <= now_unix_seconds { + return Err(CursorError::Expired); + } + Ok(payload) + } + + pub fn new_payload( + &self, + issued_at_unix_seconds: u64, + binding: CursorBinding, + query: CursorQuery, + continuation: CursorContinuation, + ) -> Result { + let expires_at_unix_seconds = issued_at_unix_seconds + .checked_add(self.max_age.as_secs()) + .ok_or(CursorError::Configuration)?; + Ok(CursorPayload { + version: WIRE_VERSION, + issued_at_unix_seconds, + expires_at_unix_seconds, + binding, + query, + continuation, + }) + } + + pub fn binding_digest( + &self, + domain: &'static [u8], + value: &Value, + ) -> Result { + let bytes = canonicalize_json(value).map_err(|_| CursorError::Malformed)?; + self.binding_digest_bytes(domain, &bytes) + } + + pub fn binding_digest_bytes( + &self, + domain: &'static [u8], + value: &[u8], + ) -> Result { + let mut mac = HmacSha256::new_from_slice(self.binding_key.as_slice()) + .map_err(|_| CursorError::Configuration)?; + mac.update(domain); + mac.update(&[0]); + mac.update(value); + Ok(format!( + "hmac-sha256:{}", + hex::encode(mac.finalize().into_bytes()) + )) + } +} + +impl fmt::Debug for CursorCodec { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CursorCodec") + .field("aead_key", &"") + .field("binding_key", &"") + .field("max_age", &self.max_age) + .finish() + } +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CursorPayload { + pub(crate) version: u8, + pub(crate) issued_at_unix_seconds: u64, + pub(crate) expires_at_unix_seconds: u64, + pub(crate) binding: CursorBinding, + pub(crate) query: CursorQuery, + pub(crate) continuation: CursorContinuation, +} + +impl CursorPayload { + fn max_expiry(&self) -> u64 { + self.issued_at_unix_seconds.saturating_add(MAX_AGE_SECONDS) + } +} + +impl fmt::Debug for CursorPayload { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CursorPayload") + .field("version", &self.version) + .field("issued_at_unix_seconds", &self.issued_at_unix_seconds) + .field("expires_at_unix_seconds", &self.expires_at_unix_seconds) + .field("binding", &self.binding) + .field("query", &"") + .field("continuation", &"") + .finish() + } +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CursorBinding { + pub(crate) package_revision: String, + pub(crate) schema_fingerprint: String, + pub(crate) registry_revision: String, + pub(crate) route_id: String, + pub(crate) query_operation_id: String, + pub(crate) query_kind: CompiledQueryKind, + pub(crate) selected_profile: String, + pub(crate) principal_reference: Option, + pub(crate) purpose_reference: Option, + pub(crate) row_boundary_reference: String, + pub(crate) projection_reference: String, + pub(crate) query_reference: String, + pub(crate) sort_reference: String, + pub(crate) page_size: u16, + pub(crate) temporal_instant: Option, + pub(crate) selected_fields: Vec, +} + +impl fmt::Debug for CursorBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CursorBinding") + .field("package_revision", &self.package_revision) + .field("schema_fingerprint", &self.schema_fingerprint) + .field("registry_revision", &self.registry_revision) + .field("route_id", &self.route_id) + .field("query_operation_id", &self.query_operation_id) + .field("query_kind", &self.query_kind) + .field("selected_profile", &self.selected_profile) + .field( + "principal_reference", + &self.principal_reference.as_ref().map(|_| ""), + ) + .field( + "purpose_reference", + &self.purpose_reference.as_ref().map(|_| ""), + ) + .field("row_boundary_reference", &"") + .field("projection_reference", &"") + .field("query_reference", &"") + .field("sort_reference", &"") + .field("page_size", &self.page_size) + .field( + "temporal_instant", + &self.temporal_instant.as_ref().map(|_| ""), + ) + .field("selected_fields", &self.selected_fields) + .finish() + } +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CursorQuery { + pub(crate) filters: Vec, + pub(crate) sort: Option, +} + +impl fmt::Debug for CursorQuery { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CursorQuery()") + } +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CursorFilter { + pub(crate) field: String, + pub(crate) operator: String, + pub(crate) values: Vec, +} + +impl fmt::Debug for CursorFilter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CursorFilter") + .field("field", &self.field) + .field("operator", &self.operator) + .field("values", &"") + .finish() + } +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CursorContinuation { + pub(crate) last_record_id: String, + pub(crate) sort_value: Option, +} + +impl fmt::Debug for CursorContinuation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CursorContinuation()") + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum CursorError { + #[error("cursor configuration is invalid")] + Configuration, + #[error("cursor is malformed")] + Malformed, + #[error("cursor is invalid")] + Invalid, + #[error("cursor is expired")] + Expired, + #[error("cursor does not match this request")] + Mismatch, +} + +pub fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn derive_key(secret: &[u8], label: &[u8]) -> Result, CursorError> { + let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| CursorError::Configuration)?; + mac.update(label); + Ok(Zeroizing::new(mac.finalize().into_bytes().into())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn codec() -> CursorCodec { + CursorCodec::new(Zeroizing::new(vec![0x51; 32]), Duration::from_secs(60)) + .expect("cursor codec accepts strong test secret") + } + + fn binding() -> CursorBinding { + CursorBinding { + package_revision: "package-a".to_owned(), + schema_fingerprint: "schema-a".to_owned(), + registry_revision: "registry-a".to_owned(), + route_id: "records.asset.list".to_owned(), + query_operation_id: "records.asset.operator.list".to_owned(), + query_kind: CompiledQueryKind::List, + selected_profile: "operator".to_owned(), + principal_reference: Some("hmac-sha256:principal".to_owned()), + purpose_reference: Some("hmac-sha256:purpose".to_owned()), + row_boundary_reference: "hmac-sha256:row-boundary".to_owned(), + projection_reference: "hmac-sha256:projection".to_owned(), + query_reference: "hmac-sha256:query".to_owned(), + sort_reference: "hmac-sha256:sort".to_owned(), + page_size: 50, + temporal_instant: Some("2026-01-01T00:00:00Z".to_owned()), + selected_fields: vec!["label".to_owned()], + } + } + + fn payload() -> CursorPayload { + CursorPayload { + version: WIRE_VERSION, + issued_at_unix_seconds: 1_000, + expires_at_unix_seconds: 1_060, + binding: binding(), + query: CursorQuery { + filters: vec![CursorFilter { + field: "label".to_owned(), + operator: "prefix".to_owned(), + values: vec!["al".to_owned()], + }], + sort: Some("label".to_owned()), + }, + continuation: CursorContinuation { + last_record_id: "00000000-0000-4000-8000-000000000001".to_owned(), + sort_value: Some("alpha".to_owned()), + }, + } + } + + #[test] + fn cursor_codec_conceals_payload_and_uses_fresh_nonces() { + let codec = codec(); + let payload = payload(); + let first = codec.encode(&payload).expect("first cursor encodes"); + let second = codec.encode(&payload).expect("second cursor encodes"); + assert_ne!(first, second); + for token in [&first, &second] { + assert!(!token.contains("package-a")); + assert!(!token.contains("principal")); + let envelope = URL_SAFE_NO_PAD.decode(token).expect("cursor is base64url"); + let envelope_text = String::from_utf8_lossy(&envelope); + assert!(!envelope_text.contains("package-a")); + assert!(!envelope_text.contains("principal")); + assert!(!envelope_text.contains("alpha")); + assert_eq!(codec.decode(token, 1_001).expect("cursor decodes"), payload); + } + } + + #[test] + fn cursor_codec_refuses_tamper_expiry_and_size_bounds() { + assert!(CursorCodec::new(Zeroizing::new(vec![0x51; 31]), Duration::from_secs(60)).is_err()); + assert!( + CursorCodec::new(Zeroizing::new(vec![0x51; 32]), Duration::from_secs(86_401)).is_err() + ); + let codec = codec(); + let token = codec.encode(&payload()).expect("cursor encodes"); + let mut tampered = token.into_bytes(); + let last = tampered.len() - 1; + tampered[last] = if tampered[last] == b'A' { b'B' } else { b'A' }; + let tampered = String::from_utf8(tampered).expect("base64url remains UTF-8"); + assert!(matches!( + codec.decode(&tampered, 1_001), + Err(CursorError::Invalid) + )); + let expired = codec.encode(&payload()).expect("cursor encodes"); + assert!(matches!( + codec.decode(&expired, 1_061), + Err(CursorError::Expired) + )); + + let mut too_large = payload(); + too_large.continuation.sort_value = Some("x".repeat(MAX_PAYLOAD_BYTES)); + assert!(matches!( + codec.encode(&too_large), + Err(CursorError::Malformed) + )); + assert!(matches!( + codec.decode(&"A".repeat(MAX_TOKEN_BYTES + 1), 1), + Err(CursorError::Invalid) + )); + } + + #[test] + fn cursor_binding_mismatch_cases_are_separate_and_value_free() { + let codec = codec(); + let expected = binding(); + let token = codec.encode(&payload()).expect("cursor encodes"); + let cases = [ + { + let mut value = expected.clone(); + value.package_revision = "package-b".to_owned(); + value + }, + { + let mut value = expected.clone(); + value.schema_fingerprint = "schema-b".to_owned(); + value + }, + { + let mut value = expected.clone(); + value.registry_revision = "registry-b".to_owned(); + value + }, + { + let mut value = expected.clone(); + value.route_id = "records.asset.current".to_owned(); + value + }, + { + let mut value = expected.clone(); + value.query_operation_id = "records.asset.operator.current".to_owned(); + value + }, + { + let mut value = expected.clone(); + value.selected_profile = "public".to_owned(); + value + }, + { + let mut value = expected.clone(); + value.principal_reference = Some("hmac-sha256:other-principal".to_owned()); + value + }, + { + let mut value = expected.clone(); + value.purpose_reference = None; + value + }, + { + let mut value = expected.clone(); + value.row_boundary_reference = "hmac-sha256:other-row".to_owned(); + value + }, + { + let mut value = expected.clone(); + value.projection_reference = "hmac-sha256:other-projection".to_owned(); + value + }, + { + let mut value = expected.clone(); + value.query_reference = "hmac-sha256:other-query".to_owned(); + value + }, + { + let mut value = expected.clone(); + value.sort_reference = "hmac-sha256:other-sort".to_owned(); + value + }, + { + let mut value = expected.clone(); + value.page_size = 51; + value + }, + { + let mut value = expected.clone(); + value.temporal_instant = Some("2026-01-02T00:00:00Z".to_owned()); + value + }, + ]; + for actual in cases { + assert!(matches!( + codec.open_after_authorization(&token, 1_001, |_| Ok(actual)), + Err(CursorError::Mismatch) + )); + } + assert_eq!(format!("{:?}", CursorError::Mismatch), "Mismatch"); + let opened = codec + .open_after_authorization(&token, 1_001, |_| Ok(expected)) + .expect("matching authorized binding opens cursor"); + assert_eq!(opened.continuation.sort_value.as_deref(), Some("alpha")); + } +} diff --git a/crates/registry-server/src/data.rs b/crates/registry-server/src/data.rs new file mode 100644 index 0000000000..c482aa5dd9 --- /dev/null +++ b/crates/registry-server/src/data.rs @@ -0,0 +1,1844 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Structural data import and export planning against compiled authority. +//! +//! Planning and checkpoints do no I/O. Execution emits closed requests to a +//! caller-supplied authenticated HTTP transport, so imports and exports reuse +//! the ordinary API instead of inventing a second data-access path. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::future::Future; + +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use time::{format_description::well_known::Rfc3339, Date, Month, OffsetDateTime}; +use uuid::Uuid; + +use crate::contract::{ + valid_crs84_point, valid_decimal_value, valid_structured_value, FieldTypeSource, MutationMode, + Operation, +}; +use crate::model::{CompiledEntity, CompiledQueryKind, CompiledRegistry, HttpMethod}; + +const DATA_API_VERSION: &str = "registry.registrystack.org/v1alpha1"; +const IMPORT_CHECKPOINT_KIND: &str = "RegistryDataImportCheckpoint"; +const EXPORT_CHECKPOINT_KIND: &str = "RegistryDataExportCheckpoint"; +const CHUNK_ALGORITHM_VERSION: &str = "greedy-canonical-http-batch-v1"; +const IDEMPOTENCY_DOMAIN: &str = "registry-data-import-chunk-v1"; +const MAX_BINDING_BYTES: usize = 256; +const MAX_CURSOR_BYTES: usize = 16 * 1024; +/// Maximum canonical JSONL bytes accepted by one import plan. +pub const MAX_DATA_IMPORT_INPUT_BYTES: usize = 256 * 1024 * 1024; +const MAX_INPUT_ITEMS: usize = 1_000_000; +const MAX_PATCH_OPERATIONS: usize = 128; +/// Maximum response-body bytes accepted from one Registry data HTTP exchange. +pub const MAX_DATA_HTTP_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DataHttpMethod { + Get, + Post, +} + +/// One closed request for the ordinary Registry HTTP surface. +/// +/// Authentication remains transport-owned so bearer material is never stored +/// in a data plan, checkpoint, error, or debug representation. +pub struct DataHttpRequest { + method: DataHttpMethod, + path_and_query: String, + content_type: Option<&'static str>, + idempotency_key: Option, + body: Vec, +} + +impl fmt::Debug for DataHttpRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DataHttpRequest") + .field("method", &self.method) + .field("content_type", &self.content_type) + .field("idempotency_key_present", &self.idempotency_key.is_some()) + .field("body_length", &self.body.len()) + .finish_non_exhaustive() + } +} + +impl DataHttpRequest { + pub fn method(&self) -> DataHttpMethod { + self.method + } + + pub fn path_and_query(&self) -> &str { + &self.path_and_query + } + + pub fn content_type(&self) -> Option<&'static str> { + self.content_type + } + + pub fn idempotency_key(&self) -> Option<&str> { + self.idempotency_key.as_deref() + } + + pub fn body(&self) -> &[u8] { + &self.body + } +} + +/// Bounded response returned by the transport after normal HTTP +/// authentication and authorization have completed. +pub struct DataHttpResponse { + status: u16, + content_type: Option, + body: Vec, +} + +impl fmt::Debug for DataHttpResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DataHttpResponse") + .field("status", &self.status) + .field("content_type_present", &self.content_type.is_some()) + .field("body_length", &self.body.len()) + .finish() + } +} + +impl DataHttpResponse { + pub fn new( + status: u16, + content_type: Option, + body: Vec, + ) -> Result { + if !(100..=599).contains(&status) + || body.len() > MAX_DATA_HTTP_RESPONSE_BYTES + || content_type.as_deref().is_some_and(|value| { + value.is_empty() + || value.len() > MAX_BINDING_BYTES + || !value.is_ascii() + || value.bytes().any(|byte| byte.is_ascii_control()) + }) + { + return Err(DataError::InvalidResponse); + } + Ok(Self { + status, + content_type, + body, + }) + } +} + +#[derive(Clone, Copy)] +pub(crate) enum FieldValue<'a> { + Json(&'a Value), + #[cfg(feature = "runtime")] + Text(&'a str), +} + +/// The single no-I/O value/type rule used by import validation, mutations, +/// and PostgreSQL claim-boundary construction. +pub(crate) fn validate_field_value(value: FieldValue<'_>, field_type: &FieldTypeSource) -> bool { + match value { + FieldValue::Json(value) => validate_json_field_value(value, field_type), + #[cfg(feature = "runtime")] + FieldValue::Text(value) => validate_text_field_value(value, field_type), + } +} + +fn validate_json_field_value(value: &Value, field_type: &FieldTypeSource) -> bool { + match field_type { + FieldTypeSource::Boolean => value.is_boolean(), + FieldTypeSource::Int64 => value.as_i64().is_some(), + FieldTypeSource::Crs84Point { precision, bbox } => { + valid_crs84_point(value, *precision, bbox.as_ref()) + } + FieldTypeSource::Structured { max_bytes, schema } => { + valid_structured_value(value, *max_bytes, schema) + } + _ => value + .as_str() + .is_some_and(|value| validate_text_field_value(value, field_type)), + } +} + +fn validate_text_field_value(value: &str, field_type: &FieldTypeSource) -> bool { + match field_type { + FieldTypeSource::Boolean => matches!(value, "true" | "false"), + FieldTypeSource::String { + min_length, + max_length, + } => { + let length = value.chars().count(); + length >= *min_length as usize && length <= *max_length as usize + } + FieldTypeSource::Text { max_length } => value.chars().count() <= *max_length as usize, + FieldTypeSource::Int64 => value + .parse::() + .is_ok_and(|parsed| parsed.to_string() == value), + FieldTypeSource::Decimal { + precision, + scale, + minimum, + maximum, + } => valid_decimal_value( + value, + *precision, + *scale, + minimum.as_deref(), + maximum.as_deref(), + ), + FieldTypeSource::Date => valid_iso_date(value), + FieldTypeSource::Timestamp => OffsetDateTime::parse(value, &Rfc3339).is_ok(), + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => valid_uuid(value), + FieldTypeSource::VocabularyCode { values, .. } => { + values.iter().any(|allowed| allowed == value) + } + FieldTypeSource::Crs84Point { precision, bbox } => parse_json_strict(value.as_bytes()) + .is_ok_and(|parsed| valid_crs84_point(&parsed, *precision, bbox.as_ref())), + FieldTypeSource::Structured { max_bytes, schema } => parse_json_strict(value.as_bytes()) + .is_ok_and(|parsed| valid_structured_value(&parsed, *max_bytes, schema)), + } +} + +fn valid_iso_date(value: &str) -> bool { + if value.len() != 10 + || value.as_bytes().get(4) != Some(&b'-') + || value.as_bytes().get(7) != Some(&b'-') + || value + .bytes() + .enumerate() + .any(|(index, byte)| !matches!(index, 4 | 7) && !byte.is_ascii_digit()) + { + return false; + } + let Ok(year) = value[0..4].parse::() else { + return false; + }; + let Some(month) = value[5..7] + .parse::() + .ok() + .and_then(|month| Month::try_from(month).ok()) + else { + return false; + }; + let Ok(day) = value[8..10].parse::() else { + return false; + }; + (1..=9999).contains(&year) && Date::from_calendar_date(year, month, day).is_ok() +} + +fn valid_uuid(value: &str) -> bool { + value.len() == 36 + && value.bytes().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => byte == b'-', + _ => byte.is_ascii_hexdigit(), + }) + && Uuid::parse_str(value).is_ok_and(|identifier| identifier.to_string() == value) +} + +fn valid_import_uuid(value: &str) -> bool { + valid_uuid(value) + && Uuid::parse_str(value).is_ok_and(|identifier| identifier.get_version_num() == 4) +} + +fn valid_strong_etag(value: &str) -> bool { + value.len() > 5 + && value.len() <= MAX_BINDING_BYTES + && value.starts_with("\"rs-") + && value.ends_with('"') + && value.as_bytes()[1..value.len() - 1] + .iter() + .all(|byte| matches!(byte, 0x21 | 0x23..=0x7e)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DataImportOperation { + Create, + Patch, +} + +impl DataImportOperation { + fn compiled(self) -> Operation { + match self { + Self::Create => Operation::Create, + Self::Patch => Operation::Patch, + } + } +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum DataError { + #[error("data authority binding is invalid")] + InvalidBinding, + #[error("data input is invalid")] + InvalidInput, + #[error("data item is invalid")] + InvalidItem, + #[error("one data item exceeds the compiled batch byte bound")] + ItemTooLarge, + #[error("data checkpoint does not match the active binding")] + CheckpointMismatch, + #[error("the Registry data transport is unavailable")] + TransportUnavailable, + #[error("the Registry data operation was refused")] + OperationRefused, + #[error("the Registry data response is invalid")] + InvalidResponse, +} + +#[derive(Clone, Eq, PartialEq)] +pub struct DataChunk { + index: u64, + start_item: u64, + end_item: u64, + next_byte_offset: u64, + canonical_body: Vec, + digest: String, + prefix_digest: String, +} + +impl fmt::Debug for DataChunk { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DataChunk") + .field("index", &self.index) + .field("start_item", &self.start_item) + .field("end_item", &self.end_item) + .field("next_byte_offset", &self.next_byte_offset) + .field("body_length", &self.canonical_body.len()) + .field("digest", &self.digest) + .finish() + } +} + +impl DataChunk { + pub fn index(&self) -> u64 { + self.index + } + + pub fn item_range(&self) -> std::ops::Range { + self.start_item..self.end_item + } + + pub fn next_byte_offset(&self) -> u64 { + self.next_byte_offset + } + + /// Exact canonical body for the compiled HTTP batch operation. + pub fn canonical_body(&self) -> &[u8] { + &self.canonical_body + } + + pub fn digest(&self) -> &str { + &self.digest + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct DataImportPlan { + entity_id: String, + operation: DataImportOperation, + profile_id: String, + input_digest: String, + input_length: u64, + item_count: u64, + maximum_items: u16, + maximum_bytes: u32, + chunks: Vec, + route_path: String, + response_fields: BTreeMap, +} + +impl fmt::Debug for DataImportPlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DataImportPlan") + .field("operation", &self.operation) + .field("input_length", &self.input_length) + .field("item_count", &self.item_count) + .field("maximum_items", &self.maximum_items) + .field("maximum_bytes", &self.maximum_bytes) + .field("chunk_count", &self.chunks.len()) + .finish_non_exhaustive() + } +} + +impl DataImportPlan { + pub fn from_jsonl( + registry: &CompiledRegistry, + entity_id: &str, + operation: DataImportOperation, + profile_id: &str, + input: &[u8], + ) -> Result { + let (entity, maximum_items, maximum_bytes, route_path) = + resolve_import_binding(registry, entity_id, operation, profile_id)?; + if input.is_empty() || input.len() > MAX_DATA_IMPORT_INPUT_BYTES { + return Err(DataError::InvalidInput); + } + let mut parsed = Vec::new(); + let mut offset = 0usize; + for raw_line in input.split_inclusive(|byte| *byte == b'\n') { + offset = offset + .checked_add(raw_line.len()) + .ok_or(DataError::InvalidInput)?; + let line = raw_line.strip_suffix(b"\n").unwrap_or(raw_line); + let line = line.strip_suffix(b"\r").unwrap_or(line); + if line.is_empty() || parsed.len() >= MAX_INPUT_ITEMS { + return Err(DataError::InvalidInput); + } + let value = parse_json_strict(line).map_err(|_| DataError::InvalidItem)?; + let canonical_item = validate_item(entity, operation, profile_id, value)?; + parsed.push((canonical_item, offset)); + } + if parsed.is_empty() { + return Err(DataError::InvalidInput); + } + let chunks = plan_chunks(input, &parsed, maximum_items, maximum_bytes)?; + Ok(Self { + entity_id: entity_id.to_owned(), + operation, + profile_id: profile_id.to_owned(), + input_digest: sha256_hex(input), + input_length: input.len() as u64, + item_count: parsed.len() as u64, + maximum_items, + maximum_bytes, + chunks, + route_path, + response_fields: entity.access_profiles[profile_id] + .readable_fields + .iter() + .map(|field_id| { + let field = &entity.fields[field_id]; + (field_id.clone(), (field.field_type.clone(), field.required)) + }) + .collect(), + }) + } + + pub fn entity_id(&self) -> &str { + &self.entity_id + } + + pub fn operation(&self) -> DataImportOperation { + self.operation + } + + pub fn profile_id(&self) -> &str { + &self.profile_id + } + + pub fn input_digest(&self) -> &str { + &self.input_digest + } + + pub fn input_length(&self) -> u64 { + self.input_length + } + + pub fn item_count(&self) -> u64 { + self.item_count + } + + pub fn maximum_items(&self) -> u16 { + self.maximum_items + } + + pub fn maximum_bytes(&self) -> u32 { + self.maximum_bytes + } + + pub fn chunks(&self) -> &[DataChunk] { + &self.chunks + } +} + +fn resolve_import_binding<'a>( + registry: &'a CompiledRegistry, + entity_id: &str, + operation: DataImportOperation, + profile_id: &str, +) -> Result<(&'a CompiledEntity, u16, u32, String), DataError> { + if !valid_binding(entity_id) || !valid_binding(profile_id) { + return Err(DataError::InvalidBinding); + } + let entity = registry + .entities() + .get(entity_id) + .ok_or(DataError::InvalidBinding)?; + let batch = entity.batch.as_ref().ok_or(DataError::InvalidBinding)?; + let profile = entity + .access_profiles + .get(profile_id) + .ok_or(DataError::InvalidBinding)?; + let operation = operation.compiled(); + let access_matches = |candidate: Operation| { + registry.access().entries.iter().any(|entry| { + entry.entity_id == entity_id + && entry.operation == candidate + && entry.profile_ids.contains(profile_id) + }) + }; + let batch_route = registry.routes().routes.iter().find(|route| { + route.entity_id == entity_id + && route.operation == Operation::Batch + && route.method == HttpMethod::Post + && route.access_profiles.iter().any(|id| id == profile_id) + }); + let item_route_matches = registry.routes().routes.iter().any(|route| { + route.entity_id == entity_id + && route.operation == operation + && route.access_profiles.iter().any(|id| id == profile_id) + && matches!( + (operation, route.method), + (Operation::Create, HttpMethod::Post) | (Operation::Patch, HttpMethod::Patch) + ) + }); + if profile.anonymous + || !profile.operations.contains(&Operation::Batch) + || !profile.operations.contains(&operation) + || !access_matches(Operation::Batch) + || !access_matches(operation) + || batch_route.is_none() + || !item_route_matches + || operation == Operation::Patch && entity.mutation_mode != MutationMode::Mutable + || batch.maximum_items == 0 + || batch.maximum_bytes == 0 + { + return Err(DataError::InvalidBinding); + } + Ok(( + entity, + batch.maximum_items, + batch.maximum_bytes, + batch_route.expect("checked batch route").path.clone(), + )) +} + +fn validate_item( + entity: &CompiledEntity, + operation: DataImportOperation, + profile_id: &str, + value: Value, +) -> Result { + let object = value.as_object().ok_or(DataError::InvalidItem)?; + let profile = entity + .access_profiles + .get(profile_id) + .ok_or(DataError::InvalidBinding)?; + match operation { + DataImportOperation::Create => { + require_exact_keys(object, &["operation", "data"])?; + if object.get("operation").and_then(Value::as_str) != Some("create") { + return Err(DataError::InvalidItem); + } + let data = object + .get("data") + .and_then(Value::as_object) + .ok_or(DataError::InvalidItem)?; + validate_create_data(entity, profile_id, data)?; + } + DataImportOperation::Patch => { + require_exact_keys(object, &["operation", "recordId", "ifMatch", "patch"])?; + if object.get("operation").and_then(Value::as_str) != Some("patch") + || !object + .get("recordId") + .and_then(Value::as_str) + .is_some_and(valid_uuid) + || !object + .get("ifMatch") + .and_then(Value::as_str) + .is_some_and(valid_strong_etag) + { + return Err(DataError::InvalidItem); + } + let patch = object + .get("patch") + .and_then(Value::as_array) + .ok_or(DataError::InvalidItem)?; + validate_patch(entity, profile, patch)?; + } + } + Ok(value) +} + +fn validate_create_data( + entity: &CompiledEntity, + profile_id: &str, + data: &Map, +) -> Result<(), DataError> { + let profile = &entity.access_profiles[profile_id]; + if entity + .fields + .values() + .any(|field| field.required && !data.contains_key(&field.id)) + { + return Err(DataError::InvalidItem); + } + for (field_id, value) in data { + let field = entity.fields.get(field_id).ok_or(DataError::InvalidItem)?; + if !profile.writable_fields.contains(field_id) + || value.is_null() && field.required + || !value.is_null() && !validate_field_value(FieldValue::Json(value), &field.field_type) + { + return Err(DataError::InvalidItem); + } + } + Ok(()) +} + +fn validate_patch( + entity: &CompiledEntity, + profile: &crate::contract::AccessProfileSource, + patch: &[Value], +) -> Result<(), DataError> { + if patch.is_empty() || patch.len() > MAX_PATCH_OPERATIONS { + return Err(DataError::InvalidItem); + } + let mut mutated = false; + for operation in patch { + let operation = operation.as_object().ok_or(DataError::InvalidItem)?; + let name = operation + .get("op") + .and_then(Value::as_str) + .ok_or(DataError::InvalidItem)?; + let path = operation + .get("path") + .and_then(Value::as_str) + .ok_or(DataError::InvalidItem)?; + let field_id = patch_field(path)?; + let field = entity.fields.get(&field_id).ok_or(DataError::InvalidItem)?; + match name { + "add" | "replace" => { + require_exact_keys(operation, &["op", "path", "value"])?; + let value = &operation["value"]; + if !profile.writable_fields.contains(&field_id) + || value.is_null() && field.required + || !value.is_null() + && !validate_field_value(FieldValue::Json(value), &field.field_type) + { + return Err(DataError::InvalidItem); + } + mutated = true; + } + "remove" => { + require_exact_keys(operation, &["op", "path"])?; + if field.required || !profile.writable_fields.contains(&field_id) { + return Err(DataError::InvalidItem); + } + mutated = true; + } + "test" => { + require_exact_keys(operation, &["op", "path", "value"])?; + let value = &operation["value"]; + if !profile.readable_fields.contains(&field_id) + || !value.is_null() + && !validate_field_value(FieldValue::Json(value), &field.field_type) + { + return Err(DataError::InvalidItem); + } + } + _ => return Err(DataError::InvalidItem), + } + } + if !mutated { + return Err(DataError::InvalidItem); + } + Ok(()) +} + +fn patch_field(path: &str) -> Result { + let encoded = path.strip_prefix("/data/").ok_or(DataError::InvalidItem)?; + if encoded.is_empty() || encoded.contains('/') { + return Err(DataError::InvalidItem); + } + let mut decoded = String::new(); + let mut chars = encoded.chars(); + while let Some(character) = chars.next() { + if character != '~' { + decoded.push(character); + continue; + } + match chars.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => return Err(DataError::InvalidItem), + } + } + if !valid_binding(&decoded) { + return Err(DataError::InvalidItem); + } + Ok(decoded) +} + +fn require_exact_keys(object: &Map, expected: &[&str]) -> Result<(), DataError> { + if object.len() != expected.len() || expected.iter().any(|key| !object.contains_key(*key)) { + return Err(DataError::InvalidItem); + } + Ok(()) +} + +fn plan_chunks( + input: &[u8], + parsed: &[(Value, usize)], + maximum_items: u16, + maximum_bytes: u32, +) -> Result, DataError> { + let mut chunks = Vec::new(); + let mut start = 0usize; + while start < parsed.len() { + let maximum_end = parsed + .len() + .min(start.saturating_add(maximum_items as usize)); + let mut accepted = None; + for end in start + 1..=maximum_end { + let items = parsed[start..end] + .iter() + .map(|(item, _)| item.clone()) + .collect::>(); + let body = + canonicalize_json(&json!({"items": items})).map_err(|_| DataError::InvalidItem)?; + if body.len() > maximum_bytes as usize { + break; + } + accepted = Some((end, body)); + } + let Some((end, body)) = accepted else { + return Err(DataError::ItemTooLarge); + }; + let next_byte_offset = parsed[end - 1].1; + chunks.push(DataChunk { + index: chunks.len() as u64, + start_item: start as u64, + end_item: end as u64, + next_byte_offset: next_byte_offset as u64, + digest: sha256_hex(&body), + prefix_digest: sha256_hex(&input[..next_byte_offset]), + canonical_body: body, + }); + start = end; + } + Ok(chunks) +} + +#[derive(Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DataImportCheckpoint { + api_version: String, + kind: String, + package_revision: String, + schema_fingerprint: String, + entity_id: String, + operation: DataImportOperation, + profile_id: String, + input_digest: String, + input_length: u64, + item_count: u64, + chunk_algorithm_version: String, + maximum_items: u16, + maximum_bytes: u32, + import_id: String, + next_item_index: u64, + next_byte_offset: u64, + committed_prefix_digest: String, + completed_chunk_count: u64, + complete: bool, +} + +impl fmt::Debug for DataImportCheckpoint { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DataImportCheckpoint") + .field("operation", &self.operation) + .field("input_length", &self.input_length) + .field("item_count", &self.item_count) + .field("next_item_index", &self.next_item_index) + .field("next_byte_offset", &self.next_byte_offset) + .field("completed_chunk_count", &self.completed_chunk_count) + .field("complete", &self.complete) + .finish_non_exhaustive() + } +} + +impl DataImportCheckpoint { + pub fn start( + plan: &DataImportPlan, + package_revision: &str, + schema_fingerprint: &str, + ) -> Result { + validate_checkpoint_binding(package_revision, schema_fingerprint)?; + Ok(Self { + api_version: DATA_API_VERSION.to_owned(), + kind: IMPORT_CHECKPOINT_KIND.to_owned(), + package_revision: package_revision.to_owned(), + schema_fingerprint: schema_fingerprint.to_owned(), + entity_id: plan.entity_id.clone(), + operation: plan.operation, + profile_id: plan.profile_id.clone(), + input_digest: plan.input_digest.clone(), + input_length: plan.input_length, + item_count: plan.item_count, + chunk_algorithm_version: CHUNK_ALGORITHM_VERSION.to_owned(), + maximum_items: plan.maximum_items, + maximum_bytes: plan.maximum_bytes, + import_id: Uuid::new_v4().to_string(), + next_item_index: 0, + next_byte_offset: 0, + committed_prefix_digest: sha256_hex(&[]), + completed_chunk_count: 0, + complete: false, + }) + } + + /// Restores a checkpoint only when its random import identity matches the + /// identity retained by the executor for this import. + pub fn from_json( + bytes: &[u8], + plan: &DataImportPlan, + package_revision: &str, + schema_fingerprint: &str, + expected_import_id: &str, + ) -> Result { + let value = parse_json_strict(bytes).map_err(|_| DataError::CheckpointMismatch)?; + let checkpoint: Self = + serde_json::from_value(value).map_err(|_| DataError::CheckpointMismatch)?; + checkpoint.validate_resume( + plan, + package_revision, + schema_fingerprint, + expected_import_id, + )?; + Ok(checkpoint) + } + + pub fn canonical_json(&self) -> Result, DataError> { + canonicalize_json(&serde_json::to_value(self).map_err(|_| DataError::CheckpointMismatch)?) + .map_err(|_| DataError::CheckpointMismatch) + } + + /// Checks every import binding against the plan and executor-held import + /// identity. + pub fn validate_resume( + &self, + plan: &DataImportPlan, + package_revision: &str, + schema_fingerprint: &str, + expected_import_id: &str, + ) -> Result<(), DataError> { + validate_checkpoint_binding(package_revision, schema_fingerprint) + .map_err(|_| DataError::CheckpointMismatch)?; + if self.api_version != DATA_API_VERSION + || self.kind != IMPORT_CHECKPOINT_KIND + || self.package_revision != package_revision + || self.schema_fingerprint != schema_fingerprint + || self.entity_id != plan.entity_id + || self.operation != plan.operation + || self.profile_id != plan.profile_id + || self.input_digest != plan.input_digest + || self.input_length != plan.input_length + || self.item_count != plan.item_count + || self.chunk_algorithm_version != CHUNK_ALGORITHM_VERSION + || self.maximum_items != plan.maximum_items + || self.maximum_bytes != plan.maximum_bytes + || !valid_import_uuid(&self.import_id) + || self.import_id != expected_import_id + { + return Err(DataError::CheckpointMismatch); + } + let completed = usize::try_from(self.completed_chunk_count) + .map_err(|_| DataError::CheckpointMismatch)?; + if completed > plan.chunks.len() { + return Err(DataError::CheckpointMismatch); + } + let (next_item_index, next_byte_offset, committed_prefix_digest) = if completed == 0 { + (0, 0, sha256_hex(&[])) + } else { + let chunk = &plan.chunks[completed - 1]; + ( + chunk.end_item, + chunk.next_byte_offset, + chunk.prefix_digest.clone(), + ) + }; + let complete = completed == plan.chunks.len(); + if self.next_item_index != next_item_index + || self.next_byte_offset != next_byte_offset + || self.committed_prefix_digest != committed_prefix_digest + || self.complete != complete + { + return Err(DataError::CheckpointMismatch); + } + Ok(()) + } + + pub fn idempotency_key( + &self, + plan: &DataImportPlan, + chunk_index: u64, + package_revision: &str, + schema_fingerprint: &str, + expected_import_id: &str, + ) -> Result { + self.validate_resume( + plan, + package_revision, + schema_fingerprint, + expected_import_id, + )?; + let chunk = plan + .chunks + .get(usize::try_from(chunk_index).map_err(|_| DataError::InvalidBinding)?) + .ok_or(DataError::InvalidBinding)?; + let binding = canonicalize_json(&json!({ + "domain": IDEMPOTENCY_DOMAIN, + "importId": self.import_id, + "inputDigest": self.input_digest, + "chunkIndex": chunk_index, + "chunkDigest": chunk.digest, + })) + .map_err(|_| DataError::InvalidBinding)?; + Ok(format!("rs-data-v1-{}", sha256_hex(&binding))) + } + + pub fn commit_chunk( + &mut self, + plan: &DataImportPlan, + package_revision: &str, + schema_fingerprint: &str, + chunk_index: u64, + expected_import_id: &str, + ) -> Result<(), DataError> { + self.validate_resume( + plan, + package_revision, + schema_fingerprint, + expected_import_id, + )?; + if self.complete || chunk_index != self.completed_chunk_count { + return Err(DataError::CheckpointMismatch); + } + let chunk = plan + .chunks + .get(usize::try_from(chunk_index).map_err(|_| DataError::CheckpointMismatch)?) + .ok_or(DataError::CheckpointMismatch)?; + self.next_item_index = chunk.end_item; + self.next_byte_offset = chunk.next_byte_offset; + self.committed_prefix_digest + .clone_from(&chunk.prefix_digest); + self.completed_chunk_count += 1; + self.complete = self.completed_chunk_count == plan.chunks.len() as u64; + Ok(()) + } + + pub fn next_item_index(&self) -> u64 { + self.next_item_index + } + + pub fn next_byte_offset(&self) -> u64 { + self.next_byte_offset + } + + pub fn completed_chunk_count(&self) -> u64 { + self.completed_chunk_count + } + + pub fn is_complete(&self) -> bool { + self.complete + } + + pub fn import_id(&self) -> &str { + &self.import_id + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DataImportProgress { + chunk_index: u64, + committed_items: u64, + complete: bool, +} + +impl DataImportProgress { + pub fn chunk_index(&self) -> u64 { + self.chunk_index + } + + pub fn committed_items(&self) -> u64 { + self.committed_items + } + + pub fn is_complete(&self) -> bool { + self.complete + } +} + +/// Execute exactly one bounded import chunk through the compiled HTTP batch +/// route. The dispatcher owns bearer admission and must send this request to +/// the ordinary authenticated Registry router. +pub async fn execute_import_chunk( + plan: &DataImportPlan, + checkpoint: &mut DataImportCheckpoint, + package_revision: &str, + schema_fingerprint: &str, + expected_import_id: &str, + mut dispatch: Dispatch, +) -> Result, DataError> +where + Dispatch: FnMut(DataHttpRequest) -> DispatchFuture, + DispatchFuture: Future>, +{ + checkpoint.validate_resume( + plan, + package_revision, + schema_fingerprint, + expected_import_id, + )?; + if checkpoint.is_complete() { + return Ok(None); + } + let chunk_index = checkpoint.completed_chunk_count(); + let chunk = plan + .chunks + .get(usize::try_from(chunk_index).map_err(|_| DataError::CheckpointMismatch)?) + .ok_or(DataError::CheckpointMismatch)?; + let idempotency_key = checkpoint.idempotency_key( + plan, + chunk_index, + package_revision, + schema_fingerprint, + expected_import_id, + )?; + let request = DataHttpRequest { + method: DataHttpMethod::Post, + path_and_query: query_path( + &plan.route_path, + &[("accessProfile", plan.profile_id.as_str())], + ), + content_type: Some("application/json"), + idempotency_key: Some(idempotency_key), + body: chunk.canonical_body.clone(), + }; + let response = dispatch(request) + .await + .map_err(|_| DataError::TransportUnavailable)?; + validate_import_response(&response, plan, chunk)?; + checkpoint.commit_chunk( + plan, + package_revision, + schema_fingerprint, + chunk_index, + expected_import_id, + )?; + Ok(Some(DataImportProgress { + chunk_index, + committed_items: chunk.end_item - chunk.start_item, + complete: checkpoint.is_complete(), + })) +} + +#[derive(Clone, Eq, PartialEq)] +pub struct DataExportPlan { + entity_id: String, + profile_id: String, + requested_fields: Vec, + route_path: String, + maximum_page_size: u16, + response_fields: BTreeMap, +} + +impl fmt::Debug for DataExportPlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DataExportPlan") + .field("field_count", &self.requested_fields.len()) + .finish_non_exhaustive() + } +} + +impl DataExportPlan { + pub fn from_compiled( + registry: &CompiledRegistry, + entity_id: &str, + profile_id: &str, + requested_fields: I, + ) -> Result + where + I: IntoIterator, + S: Into, + { + if !valid_binding(entity_id) || !valid_binding(profile_id) { + return Err(DataError::InvalidBinding); + } + let entity = registry + .entities() + .get(entity_id) + .ok_or(DataError::InvalidBinding)?; + let profile = entity + .access_profiles + .get(profile_id) + .ok_or(DataError::InvalidBinding)?; + let fields = requested_fields + .into_iter() + .map(Into::into) + .collect::>(); + if fields.is_empty() + || fields.iter().any(|field| !valid_binding(field)) + || fields.iter().collect::>().len() != fields.len() + { + return Err(DataError::InvalidBinding); + } + let requested = fields.iter().cloned().collect::>(); + let expected_projection = profile.readable_fields.iter().cloned().collect::>(); + let access_matches = registry.access().entries.iter().any(|entry| { + entry.entity_id == entity_id + && entry.operation == Operation::List + && entry.profile_ids.contains(profile_id) + }); + let route = registry.routes().routes.iter().find(|route| { + route.entity_id == entity_id + && route.operation == Operation::List + && route.method == HttpMethod::Get + && route.query_kind == Some(CompiledQueryKind::List) + && route.access_profiles.iter().any(|id| id == profile_id) + }); + let query = route.and_then(|route| { + registry.queries().operations.iter().find(|query| { + query.entity_id == entity_id + && query.profile_id == profile_id + && query.kind == CompiledQueryKind::List + && query.route_id == route.id + && query.projection_fields == expected_projection + && requested + .iter() + .all(|field| query.projection_fields.contains(field)) + }) + }); + if profile.anonymous + || !profile.allow_data_export + || !profile.operations.contains(&Operation::List) + || profile.readable_fields.is_empty() + || !requested.is_subset(&profile.readable_fields) + || !access_matches + || query.is_none() + { + return Err(DataError::InvalidBinding); + } + let response_fields = requested + .iter() + .map(|field_id| { + let field = &entity.fields[field_id]; + (field_id.clone(), (field.field_type.clone(), field.required)) + }) + .collect(); + Ok(Self { + entity_id: entity_id.to_owned(), + profile_id: profile_id.to_owned(), + requested_fields: requested.into_iter().collect(), + route_path: route.expect("checked list route").path.clone(), + maximum_page_size: query.expect("checked list query").max_page_size, + response_fields, + }) + } + + pub fn entity_id(&self) -> &str { + &self.entity_id + } + + pub fn profile_id(&self) -> &str { + &self.profile_id + } + + pub fn requested_fields(&self) -> &[String] { + &self.requested_fields + } +} + +#[derive(Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DataExportCheckpoint { + api_version: String, + kind: String, + package_revision: String, + schema_fingerprint: String, + entity_id: String, + operation: Operation, + profile_id: String, + requested_fields: Vec, + output_length: u64, + output_prefix_digest: String, + record_count: u64, + completed_page_count: u64, + next_cursor: Option, + complete: bool, +} + +struct DataExportPage<'a> { + prior_output_prefix: &'a [u8], + output_prefix: &'a [u8], + added_record_count: u64, + next_cursor: Option, +} + +impl fmt::Debug for DataExportPage<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DataExportPage") + .field("prior_output_length", &self.prior_output_prefix.len()) + .field("output_length", &self.output_prefix.len()) + .field("added_record_count", &self.added_record_count) + .field("cursor_present", &self.next_cursor.is_some()) + .finish() + } +} + +impl<'a> DataExportPage<'a> { + fn new( + prior_output_prefix: &'a [u8], + output_prefix: &'a [u8], + added_record_count: u64, + next_cursor: Option, + ) -> Result { + let prior_record_count = canonical_jsonl_record_count(prior_output_prefix)?; + let output_record_count = canonical_jsonl_record_count(output_prefix)?; + if !output_prefix.starts_with(prior_output_prefix) + || output_record_count + != prior_record_count + .checked_add(added_record_count) + .ok_or(DataError::CheckpointMismatch)? + || invalid_cursor(next_cursor.as_deref()) + { + return Err(DataError::CheckpointMismatch); + } + Ok(Self { + prior_output_prefix, + output_prefix, + added_record_count, + next_cursor, + }) + } +} + +#[derive(Clone, Eq, PartialEq)] +enum DataExportContinuation { + Initial, + Next(String), + Complete, +} + +/// Executor-held proof of the last HTTP page observed for an export. +/// +/// Fields and constructors are private. A checkpoint file alone therefore +/// cannot create continuation or terminal authority by deleting its cursor or +/// changing `complete`. +#[derive(Clone, Eq, PartialEq)] +pub struct DataExportResumeState { + package_revision: String, + schema_fingerprint: String, + entity_id: String, + profile_id: String, + requested_fields: Vec, + output_length: u64, + output_prefix_digest: String, + record_count: u64, + completed_page_count: u64, + continuation: DataExportContinuation, +} + +impl fmt::Debug for DataExportResumeState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DataExportResumeState") + .field("output_length", &self.output_length) + .field("record_count", &self.record_count) + .field("completed_page_count", &self.completed_page_count) + .field( + "continuation", + &match &self.continuation { + DataExportContinuation::Initial => "initial", + DataExportContinuation::Next(_) => "next", + DataExportContinuation::Complete => "complete", + }, + ) + .finish_non_exhaustive() + } +} + +impl DataExportResumeState { + fn initial(plan: &DataExportPlan, package_revision: &str, schema_fingerprint: &str) -> Self { + Self { + package_revision: package_revision.to_owned(), + schema_fingerprint: schema_fingerprint.to_owned(), + entity_id: plan.entity_id.clone(), + profile_id: plan.profile_id.clone(), + requested_fields: plan.requested_fields.clone(), + output_length: 0, + output_prefix_digest: sha256_hex(&[]), + record_count: 0, + completed_page_count: 0, + continuation: DataExportContinuation::Initial, + } + } + + fn next( + &self, + output_prefix: &[u8], + record_count: u64, + completed_page_count: u64, + next_cursor: Option, + ) -> Self { + Self { + package_revision: self.package_revision.clone(), + schema_fingerprint: self.schema_fingerprint.clone(), + entity_id: self.entity_id.clone(), + profile_id: self.profile_id.clone(), + requested_fields: self.requested_fields.clone(), + output_length: output_prefix.len() as u64, + output_prefix_digest: sha256_hex(output_prefix), + record_count, + completed_page_count, + continuation: match next_cursor { + Some(cursor) => DataExportContinuation::Next(cursor), + None => DataExportContinuation::Complete, + }, + } + } + + fn next_cursor(&self) -> Option<&str> { + match &self.continuation { + DataExportContinuation::Next(cursor) => Some(cursor), + DataExportContinuation::Initial | DataExportContinuation::Complete => None, + } + } + + fn is_complete(&self) -> bool { + self.continuation == DataExportContinuation::Complete + } +} + +impl fmt::Debug for DataExportCheckpoint { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DataExportCheckpoint") + .field("operation", &self.operation) + .field("field_count", &self.requested_fields.len()) + .field("output_length", &self.output_length) + .field("record_count", &self.record_count) + .field("cursor_present", &self.next_cursor.is_some()) + .field("complete", &self.complete) + .finish_non_exhaustive() + } +} + +impl DataExportCheckpoint { + pub fn start( + plan: &DataExportPlan, + package_revision: &str, + schema_fingerprint: &str, + ) -> Result<(Self, DataExportResumeState), DataError> { + validate_checkpoint_binding(package_revision, schema_fingerprint)?; + let checkpoint = Self { + api_version: DATA_API_VERSION.to_owned(), + kind: EXPORT_CHECKPOINT_KIND.to_owned(), + package_revision: package_revision.to_owned(), + schema_fingerprint: schema_fingerprint.to_owned(), + entity_id: plan.entity_id.clone(), + operation: Operation::List, + profile_id: plan.profile_id.clone(), + requested_fields: plan.requested_fields.clone(), + output_length: 0, + output_prefix_digest: sha256_hex(&[]), + record_count: 0, + completed_page_count: 0, + next_cursor: None, + complete: false, + }; + Ok(( + checkpoint, + DataExportResumeState::initial(plan, package_revision, schema_fingerprint), + )) + } + + /// Restores a checkpoint only when its complete state matches the opaque + /// state produced by the executor after the last observed HTTP page. + pub fn from_json( + bytes: &[u8], + plan: &DataExportPlan, + package_revision: &str, + schema_fingerprint: &str, + output_prefix: &[u8], + resume_state: &DataExportResumeState, + ) -> Result { + let value = parse_json_strict(bytes).map_err(|_| DataError::CheckpointMismatch)?; + let checkpoint: Self = + serde_json::from_value(value).map_err(|_| DataError::CheckpointMismatch)?; + checkpoint.validate_resume( + plan, + package_revision, + schema_fingerprint, + output_prefix, + resume_state, + )?; + Ok(checkpoint) + } + + pub fn canonical_json(&self) -> Result, DataError> { + canonicalize_json(&serde_json::to_value(self).map_err(|_| DataError::CheckpointMismatch)?) + .map_err(|_| DataError::CheckpointMismatch) + } + + /// Checks filesystem output structurally and binds cursor and terminal + /// state to the last executor-observed HTTP response. + pub fn validate_resume( + &self, + plan: &DataExportPlan, + package_revision: &str, + schema_fingerprint: &str, + output_prefix: &[u8], + resume_state: &DataExportResumeState, + ) -> Result<(), DataError> { + validate_checkpoint_binding(package_revision, schema_fingerprint) + .map_err(|_| DataError::CheckpointMismatch)?; + let record_count = canonical_jsonl_record_count(output_prefix)?; + if self.api_version != DATA_API_VERSION + || self.kind != EXPORT_CHECKPOINT_KIND + || self.package_revision != package_revision + || self.schema_fingerprint != schema_fingerprint + || self.entity_id != plan.entity_id + || self.operation != Operation::List + || self.profile_id != plan.profile_id + || self.requested_fields != plan.requested_fields + || self.output_length != output_prefix.len() as u64 + || self.output_prefix_digest != sha256_hex(output_prefix) + || self.record_count != record_count + || self.completed_page_count != resume_state.completed_page_count + || resume_state.package_revision != package_revision + || resume_state.schema_fingerprint != schema_fingerprint + || resume_state.entity_id != plan.entity_id + || resume_state.profile_id != plan.profile_id + || resume_state.requested_fields != plan.requested_fields + || resume_state.output_length != output_prefix.len() as u64 + || resume_state.output_prefix_digest != sha256_hex(output_prefix) + || resume_state.record_count != record_count + || self.next_cursor.as_deref() != resume_state.next_cursor() + || self.complete != resume_state.is_complete() + || matches!(&resume_state.continuation, DataExportContinuation::Initial) + && (self.output_length != 0 + || self.record_count != 0 + || self.completed_page_count != 0 + || self.next_cursor.is_some() + || self.complete) + || !matches!(&resume_state.continuation, DataExportContinuation::Initial) + && self.completed_page_count == 0 + || self.complete && self.next_cursor.is_some() + || invalid_cursor(self.next_cursor.as_deref()) + || invalid_cursor(resume_state.next_cursor()) + { + return Err(DataError::CheckpointMismatch); + } + Ok(()) + } + + /// Records a page only after the executor checked the HTTP response. + fn record_page( + &mut self, + plan: &DataExportPlan, + package_revision: &str, + schema_fingerprint: &str, + resume_state: &DataExportResumeState, + page: DataExportPage<'_>, + ) -> Result { + self.validate_resume( + plan, + package_revision, + schema_fingerprint, + page.prior_output_prefix, + resume_state, + )?; + let record_count = canonical_jsonl_record_count(page.output_prefix)?; + if self.complete || invalid_cursor(page.next_cursor.as_deref()) { + return Err(DataError::CheckpointMismatch); + } + self.output_length = page.output_prefix.len() as u64; + self.output_prefix_digest = sha256_hex(page.output_prefix); + self.record_count = record_count; + self.completed_page_count = self + .completed_page_count + .checked_add(1) + .ok_or(DataError::CheckpointMismatch)?; + self.complete = page.next_cursor.is_none(); + self.next_cursor.clone_from(&page.next_cursor); + Ok(resume_state.next( + page.output_prefix, + record_count, + self.completed_page_count, + page.next_cursor, + )) + } + + pub fn output_length(&self) -> u64 { + self.output_length + } + + pub fn record_count(&self) -> u64 { + self.record_count + } + + pub fn is_complete(&self) -> bool { + self.complete + } +} + +pub struct DataExportProgress { + output_prefix: Vec, + resume_state: DataExportResumeState, + added_record_count: u64, + complete: bool, +} + +impl fmt::Debug for DataExportProgress { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DataExportProgress") + .field("output_length", &self.output_prefix.len()) + .field("cursor_present", &self.resume_state.next_cursor().is_some()) + .field("added_record_count", &self.added_record_count) + .field("complete", &self.complete) + .finish() + } +} + +impl DataExportProgress { + pub fn output_prefix(&self) -> &[u8] { + &self.output_prefix + } + + pub fn trusted_next_cursor(&self) -> Option<&str> { + self.resume_state.next_cursor() + } + + pub fn resume_state(&self) -> &DataExportResumeState { + &self.resume_state + } + + pub fn added_record_count(&self) -> u64 { + self.added_record_count + } + + pub fn is_complete(&self) -> bool { + self.complete + } + + pub fn into_parts(self) -> (Vec, DataExportResumeState) { + (self.output_prefix, self.resume_state) + } +} + +/// Execute exactly one bounded export page through the compiled HTTP list +/// route. Continuation and terminal state come only from the opaque state +/// produced after the last executor-observed response. +pub async fn execute_export_page( + plan: &DataExportPlan, + checkpoint: &mut DataExportCheckpoint, + package_revision: &str, + schema_fingerprint: &str, + prior_output_prefix: &[u8], + resume_state: &DataExportResumeState, + mut dispatch: Dispatch, +) -> Result, DataError> +where + Dispatch: FnMut(DataHttpRequest) -> DispatchFuture, + DispatchFuture: Future>, +{ + checkpoint.validate_resume( + plan, + package_revision, + schema_fingerprint, + prior_output_prefix, + resume_state, + )?; + if checkpoint.is_complete() { + return Err(DataError::CheckpointMismatch); + } + let fields = plan.requested_fields.join(","); + let page_size = plan.maximum_page_size.to_string(); + let path_and_query = if let Some(cursor) = resume_state.next_cursor() { + query_path( + &plan.route_path, + &[ + ("accessProfile", plan.profile_id.as_str()), + ("cursor", cursor), + ], + ) + } else { + query_path( + &plan.route_path, + &[ + ("accessProfile", plan.profile_id.as_str()), + ("fields", fields.as_str()), + ("pageSize", page_size.as_str()), + ], + ) + }; + let response = dispatch(DataHttpRequest { + method: DataHttpMethod::Get, + path_and_query, + content_type: None, + idempotency_key: None, + body: Vec::new(), + }) + .await + .map_err(|_| DataError::TransportUnavailable)?; + let (records, next_cursor) = validate_export_response(&response, plan)?; + if resume_state + .next_cursor() + .is_some_and(|prior| next_cursor.as_deref() == Some(prior)) + { + return Err(DataError::InvalidResponse); + } + let mut output_prefix = Vec::with_capacity( + prior_output_prefix + .len() + .checked_add(response.body.len()) + .ok_or(DataError::InvalidResponse)?, + ); + output_prefix.extend_from_slice(prior_output_prefix); + for record in &records { + output_prefix + .extend_from_slice(&canonicalize_json(record).map_err(|_| DataError::InvalidResponse)?); + output_prefix.push(b'\n'); + } + let added_record_count = + u64::try_from(records.len()).map_err(|_| DataError::InvalidResponse)?; + let page = DataExportPage::new( + prior_output_prefix, + &output_prefix, + added_record_count, + next_cursor.clone(), + )?; + let resume_state = checkpoint.record_page( + plan, + package_revision, + schema_fingerprint, + resume_state, + page, + )?; + Ok(Some(DataExportProgress { + output_prefix, + resume_state, + added_record_count, + complete: checkpoint.is_complete(), + })) +} + +fn validate_import_response( + response: &DataHttpResponse, + plan: &DataImportPlan, + chunk: &DataChunk, +) -> Result<(), DataError> { + require_success_json(response)?; + let value = parse_canonical_response(&response.body)?; + let object = value.as_object().ok_or(DataError::InvalidResponse)?; + require_exact_keys(object, &["results"]).map_err(|_| DataError::InvalidResponse)?; + let results = object["results"] + .as_array() + .ok_or(DataError::InvalidResponse)?; + let submitted = parse_json_strict(&chunk.canonical_body) + .map_err(|_| DataError::InvalidResponse)?["items"] + .as_array() + .ok_or(DataError::InvalidResponse)? + .clone(); + if results.len() != submitted.len() || results.len() > usize::from(plan.maximum_items) { + return Err(DataError::InvalidResponse); + } + for (result, submitted) in results.iter().zip(&submitted) { + let result = result.as_object().ok_or(DataError::InvalidResponse)?; + require_exact_keys(result, &["operation", "id", "revision", "etag", "data"]) + .map_err(|_| DataError::InvalidResponse)?; + let expected_operation = submitted["operation"] + .as_str() + .ok_or(DataError::InvalidResponse)?; + if result["operation"].as_str() != Some(expected_operation) + || !result["id"].as_str().is_some_and(valid_uuid) + || !result["revision"].as_u64().is_some_and(|value| value > 0) + || !result["etag"].as_str().is_some_and(valid_strong_etag) + { + return Err(DataError::InvalidResponse); + } + if expected_operation == "patch" && result["id"].as_str() != submitted["recordId"].as_str() + { + return Err(DataError::InvalidResponse); + } + let data = result["data"] + .as_object() + .ok_or(DataError::InvalidResponse)?; + if !valid_response_data(data, &plan.response_fields) { + return Err(DataError::InvalidResponse); + } + } + Ok(()) +} + +fn validate_export_response( + response: &DataHttpResponse, + plan: &DataExportPlan, +) -> Result<(Vec, Option), DataError> { + require_success_json(response)?; + let value = parse_canonical_response(&response.body)?; + let object = value.as_object().ok_or(DataError::InvalidResponse)?; + require_exact_keys(object, &["items", "pageInfo"]).map_err(|_| DataError::InvalidResponse)?; + let items = object["items"] + .as_array() + .ok_or(DataError::InvalidResponse)?; + if items.len() > usize::from(plan.maximum_page_size) { + return Err(DataError::InvalidResponse); + } + let page_info = object["pageInfo"] + .as_object() + .ok_or(DataError::InvalidResponse)?; + require_exact_keys(page_info, &["nextCursor"]).map_err(|_| DataError::InvalidResponse)?; + let next_cursor = match &page_info["nextCursor"] { + Value::Null => None, + Value::String(value) if !invalid_cursor(Some(value)) => Some(value.clone()), + _ => return Err(DataError::InvalidResponse), + }; + if items.is_empty() && next_cursor.is_some() { + return Err(DataError::InvalidResponse); + } + for item in items { + let item = item.as_object().ok_or(DataError::InvalidResponse)?; + require_exact_keys(item, &["id", "revision", "data"]) + .map_err(|_| DataError::InvalidResponse)?; + if !item["id"].as_str().is_some_and(valid_uuid) + || !item["revision"].as_u64().is_some_and(|value| value > 0) + { + return Err(DataError::InvalidResponse); + } + let data = item["data"].as_object().ok_or(DataError::InvalidResponse)?; + if !valid_response_data(data, &plan.response_fields) { + return Err(DataError::InvalidResponse); + } + } + Ok((items.clone(), next_cursor)) +} + +fn valid_response_data( + data: &Map, + fields: &BTreeMap, +) -> bool { + data.len() == fields.len() + && fields.iter().all(|(field_id, (field_type, required))| { + data.get(field_id).is_some_and(|value| { + if value.is_null() { + !required + } else { + validate_field_value(FieldValue::Json(value), field_type) + } + }) + }) +} + +fn require_success_json(response: &DataHttpResponse) -> Result<(), DataError> { + if response.status != 200 { + return Err(DataError::OperationRefused); + } + if response.content_type.as_deref() != Some("application/json") { + return Err(DataError::InvalidResponse); + } + Ok(()) +} + +fn parse_canonical_response(bytes: &[u8]) -> Result { + let value = parse_json_strict(bytes).map_err(|_| DataError::InvalidResponse)?; + if canonicalize_json(&value).map_err(|_| DataError::InvalidResponse)? != bytes { + return Err(DataError::InvalidResponse); + } + Ok(value) +} + +fn query_path(path: &str, parameters: &[(&str, &str)]) -> String { + let mut result = String::with_capacity(path.len() + 64); + result.push_str(path); + for (index, (name, value)) in parameters.iter().enumerate() { + result.push(if index == 0 { '?' } else { '&' }); + result.push_str(name); + result.push('='); + percent_encode_query_value(value, &mut result); + } + result +} + +fn percent_encode_query_value(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 validate_checkpoint_binding( + package_revision: &str, + schema_fingerprint: &str, +) -> Result<(), DataError> { + if !valid_binding(package_revision) || !valid_binding(schema_fingerprint) { + return Err(DataError::InvalidBinding); + } + Ok(()) +} + +fn invalid_cursor(cursor: Option<&str>) -> bool { + cursor.is_some_and(|cursor| { + cursor.is_empty() || cursor.len() > MAX_CURSOR_BYTES || cursor.chars().any(char::is_control) + }) +} + +fn canonical_jsonl_record_count(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Ok(0); + } + if !bytes.ends_with(b"\n") { + return Err(DataError::CheckpointMismatch); + } + let mut record_count = 0u64; + for line in bytes[..bytes.len() - 1].split(|byte| *byte == b'\n') { + if line.is_empty() || line.ends_with(b"\r") { + return Err(DataError::CheckpointMismatch); + } + let record = parse_json_strict(line).map_err(|_| DataError::CheckpointMismatch)?; + if !record.is_object() + || canonicalize_json(&record).map_err(|_| DataError::CheckpointMismatch)? != line + { + return Err(DataError::CheckpointMismatch); + } + record_count = record_count + .checked_add(1) + .ok_or(DataError::CheckpointMismatch)?; + } + Ok(record_count) +} + +fn valid_binding(value: &str) -> bool { + !value.is_empty() && value.len() <= MAX_BINDING_BYTES && !value.chars().any(char::is_control) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut encoded = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} diff --git a/crates/registry-server/src/diagnostics.rs b/crates/registry-server/src/diagnostics.rs new file mode 100644 index 0000000000..2026cb3071 --- /dev/null +++ b/crates/registry-server/src/diagnostics.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Severity of one deterministic compiler diagnostic. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticSeverity { + Finding, + Error, +} + +/// A stable, value-free diagnostic addressed to a source field. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Diagnostic { + pub severity: DiagnosticSeverity, + pub code: String, + pub path: String, + pub message: String, +} + +impl Diagnostic { + pub(crate) fn error(code: &str, path: impl Into, message: &str) -> Self { + Self { + severity: DiagnosticSeverity::Error, + code: code.to_owned(), + path: path.into(), + message: message.to_owned(), + } + } + + pub(crate) fn finding(code: &str, path: impl Into, message: &str) -> Self { + Self { + severity: DiagnosticSeverity::Finding, + code: code.to_owned(), + path: path.into(), + message: message.to_owned(), + } + } +} + +/// All errors from one compile, sorted independently of input order. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CompileFailure { + diagnostics: Vec, +} + +impl CompileFailure { + pub fn diagnostics(&self) -> &[Diagnostic] { + &self.diagnostics + } + + pub(crate) fn from_one(diagnostic: Diagnostic) -> Self { + Self { + diagnostics: vec![diagnostic], + } + } + + pub(crate) fn from_errors(mut diagnostics: Vec) -> Self { + diagnostics.sort(); + diagnostics.dedup(); + Self { diagnostics } + } +} + +impl fmt::Display for CompileFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "registry compilation failed with {} diagnostic(s)", + self.diagnostics.len() + ) + } +} + +impl std::error::Error for CompileFailure {} diff --git a/crates/registry-server/src/event_destination.rs b/crates/registry-server/src/event_destination.rs new file mode 100644 index 0000000000..487acfc3fc --- /dev/null +++ b/crates/registry-server/src/event_destination.rs @@ -0,0 +1,594 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Activation of deployment bindings for governed webhook destinations. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, + sync::Arc, + time::Duration, +}; + +use ipnet::IpNet; +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_config::{ + ProtectedSecret, SecretError, SecretReference, SecretResolver, MAX_SECRET_BYTES, +}; +use registry_platform_httputil::destination::{ + DestinationDnsFamily, DestinationProfile, DestinationTlsMaterial, EventDestinationPolicy, + EventDestinationRequestTemplate, MAX_DESTINATION_PRIVATE_CIDRS, + MAX_DESTINATION_REQUEST_BODY_BYTES, MAX_DESTINATION_REQUEST_HEADER_BYTES, + MAX_DESTINATION_TARGET_BYTES, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::{ + compiler::{ + MAX_WEBHOOK_ATTEMPTS, MAX_WEBHOOK_ATTEMPT_TIMEOUT_MS, MAX_WEBHOOK_PAYLOAD_BYTES, + MIN_WEBHOOK_ATTEMPT_TIMEOUT_MS, + }, + model::CompiledRegistry, +}; + +const DESTINATION_BINDING_SCHEMA_VERSION: &str = "registry-server.event-destinations/v1"; +const MIN_HMAC_SHA256_KEY_BYTES: usize = 32; +const MAX_EVENT_REQUEST_BYTES: usize = MAX_DESTINATION_TARGET_BYTES + + MAX_DESTINATION_REQUEST_HEADER_BYTES + + MAX_DESTINATION_REQUEST_BODY_BYTES; + +const _: () = assert!(MAX_WEBHOOK_PAYLOAD_BYTES as usize == MAX_DESTINATION_REQUEST_BODY_BYTES); + +/// Value-free refusal while activating deployed event destinations. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum EventDestinationActivationError { + #[error("event destination activation found an invalid deployment binding")] + InvalidBinding, + #[error("event destination activation requires an exact compiled binding set")] + InventoryMismatch, + #[error("event destination runtime ceilings exceed compiled delivery authority")] + DeliveryCeilingWidening, + #[error("event destination secret resolution failed")] + Secret, + #[error("event destination signing material is invalid")] + InvalidSigningMaterial, + #[error("event destination TLS material is invalid")] + InvalidTlsMaterial, +} + +impl From for EventDestinationActivationError { + fn from(_error: SecretError) -> Self { + Self::Secret + } +} + +pub type Result = std::result::Result; + +/// The exact activated deployment bindings for one compiled Registry. +/// +/// The registry deliberately has no iterator over configured names. A caller +/// must already hold a compiler-issued logical destination id to obtain a +/// binding. +pub struct ActivatedEventDestinationRegistry { + binding_digest: String, + bindings: BTreeMap, +} + +impl ActivatedEventDestinationRegistry { + pub(crate) fn activate( + compiled: &CompiledRegistry, + configured: &EventDestinationConfigs, + secrets: &SecretResolver, + ) -> Result { + let compiled_ids = compiled + .event_deliveries() + .deliveries + .iter() + .map(|delivery| delivery.destination_id.as_str()) + .collect::>(); + let configured_ids = configured.bindings.keys().map(String::as_str).collect(); + if compiled_ids != configured_ids { + return Err(EventDestinationActivationError::InventoryMismatch); + } + + let mut bindings = BTreeMap::new(); + for (logical_id, config) in &configured.bindings { + let deliveries = compiled + .event_deliveries() + .deliveries + .iter() + .filter(|delivery| delivery.destination_id == *logical_id) + .collect::>(); + if deliveries.is_empty() { + return Err(EventDestinationActivationError::InventoryMismatch); + } + if deliveries.iter().any(|delivery| { + config.delivery_ceilings.attempt_timeout_milliseconds > delivery.attempt_timeout_ms + || config.delivery_ceilings.maximum_attempts > delivery.maximum_attempts + }) { + return Err(EventDestinationActivationError::DeliveryCeilingWidening); + } + let maximum_payload_bytes = deliveries + .iter() + .map(|delivery| delivery.maximum_payload_bytes) + .max() + .ok_or(EventDestinationActivationError::InventoryMismatch)?; + let activated = config.activate(logical_id, maximum_payload_bytes, secrets)?; + if bindings.insert(logical_id.clone(), activated).is_some() { + return Err(EventDestinationActivationError::InventoryMismatch); + } + } + + Ok(Self { + binding_digest: configured.binding_digest()?, + bindings, + }) + } + + /// Digest of the exact non-secret deployment binding document. + #[must_use] + pub fn binding_digest(&self) -> &str { + &self.binding_digest + } + + /// Look up a binding only by its compiler-issued logical destination id. + #[must_use] + pub fn lookup(&self, compiled_logical_id: &str) -> Option<&ActivatedEventDestination> { + self.bindings.get(compiled_logical_id) + } +} + +impl fmt::Debug for ActivatedEventDestinationRegistry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ActivatedEventDestinationRegistry") + .field("binding_digest", &self.binding_digest) + .field("binding_count", &self.bindings.len()) + .finish() + } +} + +/// One activated logical event destination. +pub struct ActivatedEventDestination { + binding_digest: String, + policy: Arc, + request_template: EventDestinationRequestTemplate, + hmac_sha256_key: ProtectedSecret, + attempt_timeout: Duration, + maximum_attempts: u8, +} + +impl ActivatedEventDestination { + /// Digest of this exact logical destination's non-secret deployment binding. + #[must_use] + pub fn binding_digest(&self) -> &str { + &self.binding_digest + } + + /// Share the only outbound authority for this logical destination. + #[must_use] + pub fn policy(&self) -> Arc { + Arc::clone(&self.policy) + } + + /// Borrow the closed event request template for rendering a later delivery. + #[must_use] + pub fn request_template(&self) -> &EventDestinationRequestTemplate { + &self.request_template + } + + /// Borrow signing bytes only for the duration of the supplied operation. + pub fn with_hmac_sha256_key(&self, use_key: impl FnOnce(&[u8]) -> T) -> T { + use_key(self.hmac_sha256_key.expose_secret()) + } + + /// Deployed per-attempt timeout, already proved no wider than every user. + #[must_use] + pub fn attempt_timeout(&self) -> Duration { + self.attempt_timeout + } + + /// Deployed maximum attempt count, already proved no wider than every user. + #[must_use] + pub fn maximum_attempts(&self) -> u8 { + self.maximum_attempts + } +} + +impl fmt::Debug for ActivatedEventDestination { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ActivatedEventDestination") + .field("binding_digest", &self.binding_digest) + .field("policy", &self.policy) + .field("request_template", &self.request_template) + .field("hmac_sha256_key", &"[REDACTED]") + .field("attempt_timeout", &self.attempt_timeout) + .field("maximum_attempts", &self.maximum_attempts) + .finish() + } +} + +#[derive(Clone, Default)] +pub(crate) struct EventDestinationConfigs { + bindings: BTreeMap, +} + +impl EventDestinationConfigs { + pub(crate) fn from_raw(raw: RawEventDestinationConfigs) -> ConfigResult { + if raw.len() > 128 { + return Err(EventDestinationConfigError); + } + let mut bindings = BTreeMap::new(); + for (logical_id, raw_config) in raw { + if !valid_logical_destination_id(&logical_id) { + return Err(EventDestinationConfigError); + } + let config = EventDestinationConfig::from_raw(&logical_id, raw_config)?; + if bindings.insert(logical_id, config).is_some() { + return Err(EventDestinationConfigError); + } + } + Ok(Self { bindings }) + } + + fn binding_digest(&self) -> Result { + let destinations = self + .bindings + .iter() + .map(|(logical_id, config)| config.digest_value(logical_id)) + .collect::>(); + let value = json!({ + "schemaVersion": DESTINATION_BINDING_SCHEMA_VERSION, + "eventDestinations": destinations, + }); + canonical_binding_digest(&value) + } +} + +impl fmt::Debug for EventDestinationConfigs { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EventDestinationConfigs") + .field("binding_count", &self.bindings.len()) + .finish() + } +} + +#[derive(Clone)] +struct EventDestinationConfig { + origin: String, + path: String, + network_profile: EventDestinationNetworkProfile, + dns_family: EventDestinationDnsFamily, + allowed_private_cidrs: Vec, + hmac_sha256_key_ref: SecretReference, + tls: Option, + delivery_ceilings: EventDestinationDeliveryCeilings, +} + +impl EventDestinationConfig { + fn from_raw(logical_id: &str, raw: RawEventDestinationConfig) -> ConfigResult { + let hmac_sha256_key_ref = + parse_secret_reference(raw.hmac_sha256_key_ref).ok_or(EventDestinationConfigError)?; + let tls = raw + .tls + .map(EventDestinationTlsConfig::from_raw) + .transpose()?; + let delivery_ceilings = EventDestinationDeliveryCeilings::from_raw(raw.delivery_ceilings)?; + let allowed_private_cidrs = parse_private_cidrs(raw.allowed_private_cidrs)?; + let config = Self { + origin: raw.origin, + path: raw.path, + network_profile: raw.network_profile, + dns_family: raw.dns_family, + allowed_private_cidrs, + hmac_sha256_key_ref, + tls, + delivery_ceilings, + }; + config.validate_platform_binding(logical_id)?; + Ok(config) + } + + fn validate_platform_binding(&self, logical_id: &str) -> ConfigResult<()> { + EventDestinationPolicy::new_with_dns_family( + logical_id, + &self.origin, + self.network_profile.platform(), + &self.allowed_private_cidrs, + self.dns_family.platform(), + ) + .map_err(|_| EventDestinationConfigError)?; + EventDestinationRequestTemplate::event_delivery( + &self.path, + MAX_DESTINATION_REQUEST_BODY_BYTES, + MAX_EVENT_REQUEST_BYTES, + ) + .map_err(|_| EventDestinationConfigError)?; + Ok(()) + } + + fn activate( + &self, + logical_id: &str, + maximum_payload_bytes: u32, + secrets: &SecretResolver, + ) -> Result { + let hmac_sha256_key = secrets.resolve_reference(&self.hmac_sha256_key_ref)?; + if !(MIN_HMAC_SHA256_KEY_BYTES..=MAX_SECRET_BYTES).contains(&hmac_sha256_key.len()) { + return Err(EventDestinationActivationError::InvalidSigningMaterial); + } + + let mut policy = EventDestinationPolicy::new_with_dns_family( + logical_id, + &self.origin, + self.network_profile.platform(), + &self.allowed_private_cidrs, + self.dns_family.platform(), + ) + .map_err(|_| EventDestinationActivationError::InvalidBinding)?; + if let Some(tls) = &self.tls { + let ca_bundle = tls + .ca_bundle_ref + .as_ref() + .map(|reference| secrets.resolve_reference(reference)) + .transpose()?; + let client_identity = tls + .client_identity_ref + .as_ref() + .map(|reference| secrets.resolve_reference(reference)) + .transpose()?; + let material = DestinationTlsMaterial::from_pem( + ca_bundle.as_ref().map(ProtectedSecret::expose_secret), + client_identity.as_ref().map(ProtectedSecret::expose_secret), + ) + .map_err(|_| EventDestinationActivationError::InvalidTlsMaterial)?; + policy = policy.require_configured_tls(); + policy + .install_configured_tls(material) + .map_err(|_| EventDestinationActivationError::InvalidTlsMaterial)?; + } + + let maximum_payload_bytes = usize::try_from(maximum_payload_bytes) + .map_err(|_| EventDestinationActivationError::InvalidBinding)?; + let request_template = EventDestinationRequestTemplate::event_delivery( + &self.path, + maximum_payload_bytes, + MAX_EVENT_REQUEST_BYTES, + ) + .map_err(|_| EventDestinationActivationError::InvalidBinding)?; + + Ok(ActivatedEventDestination { + binding_digest: self.binding_digest(logical_id)?, + policy: Arc::new(policy), + request_template, + hmac_sha256_key, + attempt_timeout: Duration::from_millis(u64::from( + self.delivery_ceilings.attempt_timeout_milliseconds, + )), + maximum_attempts: self.delivery_ceilings.maximum_attempts, + }) + } + + fn digest_value(&self, logical_id: &str) -> Value { + let tls = self.tls.as_ref().map(|tls| { + json!({ + "caBundleRef": tls.ca_bundle_ref.as_ref().map(SecretReference::as_str), + "clientIdentityRef": tls.client_identity_ref.as_ref().map(SecretReference::as_str), + }) + }); + json!({ + "logicalId": logical_id, + "origin": self.origin, + "path": self.path, + "networkProfile": self.network_profile.as_str(), + "dnsFamily": self.dns_family.as_str(), + "allowedPrivateCidrs": self.allowed_private_cidrs.iter().map(ToString::to_string).collect::>(), + "hmacSha256KeyRef": self.hmac_sha256_key_ref.as_str(), + "tls": tls, + "deliveryCeilings": { + "attemptTimeoutMilliseconds": self.delivery_ceilings.attempt_timeout_milliseconds, + "maximumAttempts": self.delivery_ceilings.maximum_attempts, + }, + }) + } + + fn binding_digest(&self, logical_id: &str) -> Result { + canonical_binding_digest(&json!({ + "schemaVersion": DESTINATION_BINDING_SCHEMA_VERSION, + "destination": self.digest_value(logical_id), + })) + } +} + +#[derive(Clone)] +struct EventDestinationTlsConfig { + ca_bundle_ref: Option, + client_identity_ref: Option, +} + +impl EventDestinationTlsConfig { + fn from_raw(raw: RawEventDestinationTlsConfig) -> ConfigResult { + let ca_bundle_ref = raw + .ca_bundle_ref + .map(parse_secret_reference) + .transpose_option()?; + let client_identity_ref = raw + .client_identity_ref + .map(parse_secret_reference) + .transpose_option()?; + if ca_bundle_ref.is_none() && client_identity_ref.is_none() { + return Err(EventDestinationConfigError); + } + Ok(Self { + ca_bundle_ref, + client_identity_ref, + }) + } +} + +trait TransposeOption { + fn transpose_option(self) -> ConfigResult>; +} + +impl TransposeOption for Option> { + fn transpose_option(self) -> ConfigResult> { + match self { + Some(Some(value)) => Ok(Some(value)), + Some(None) => Err(EventDestinationConfigError), + None => Ok(None), + } + } +} + +#[derive(Clone, Copy)] +struct EventDestinationDeliveryCeilings { + attempt_timeout_milliseconds: u32, + maximum_attempts: u8, +} + +impl EventDestinationDeliveryCeilings { + fn from_raw(raw: RawEventDestinationDeliveryCeilings) -> ConfigResult { + if !(MIN_WEBHOOK_ATTEMPT_TIMEOUT_MS..=MAX_WEBHOOK_ATTEMPT_TIMEOUT_MS) + .contains(&raw.attempt_timeout_milliseconds) + || raw.maximum_attempts == 0 + || raw.maximum_attempts > MAX_WEBHOOK_ATTEMPTS + { + return Err(EventDestinationConfigError); + } + Ok(Self { + attempt_timeout_milliseconds: raw.attempt_timeout_milliseconds, + maximum_attempts: raw.maximum_attempts, + }) + } +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +enum EventDestinationNetworkProfile { + ProductionHttps, + #[cfg(feature = "postgres-test")] + PinnedLoopbackHttpsTest, +} + +impl EventDestinationNetworkProfile { + fn platform(self) -> DestinationProfile { + match self { + Self::ProductionHttps => DestinationProfile::ProductionHttps, + #[cfg(feature = "postgres-test")] + Self::PinnedLoopbackHttpsTest => DestinationProfile::PinnedLoopbackHttpsTest, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::ProductionHttps => "productionHttps", + #[cfg(feature = "postgres-test")] + Self::PinnedLoopbackHttpsTest => "pinnedLoopbackHttpsTest", + } + } +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +enum EventDestinationDnsFamily { + DualStackStrict, + Ipv4Only, +} + +impl EventDestinationDnsFamily { + fn platform(self) -> DestinationDnsFamily { + match self { + Self::DualStackStrict => DestinationDnsFamily::DualStackStrict, + Self::Ipv4Only => DestinationDnsFamily::Ipv4Only, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::DualStackStrict => "dualStackStrict", + Self::Ipv4Only => "ipv4Only", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct EventDestinationConfigError; + +type ConfigResult = std::result::Result; + +pub(crate) type RawEventDestinationConfigs = BTreeMap; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RawEventDestinationConfig { + origin: String, + path: String, + network_profile: EventDestinationNetworkProfile, + dns_family: EventDestinationDnsFamily, + allowed_private_cidrs: Vec, + hmac_sha256_key_ref: String, + #[serde(default)] + tls: Option, + delivery_ceilings: RawEventDestinationDeliveryCeilings, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawEventDestinationTlsConfig { + #[serde(default)] + ca_bundle_ref: Option, + #[serde(default)] + client_identity_ref: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawEventDestinationDeliveryCeilings { + attempt_timeout_milliseconds: u32, + maximum_attempts: u8, +} + +fn parse_secret_reference(value: String) -> Option { + SecretReference::parse(value).ok() +} + +fn parse_private_cidrs(raw: Vec) -> ConfigResult> { + if raw.len() > MAX_DESTINATION_PRIVATE_CIDRS { + return Err(EventDestinationConfigError); + } + let mut parsed = Vec::with_capacity(raw.len()); + for value in raw { + let cidr = value + .parse::() + .map_err(|_| EventDestinationConfigError)?; + if cidr.trunc() != cidr || cidr.to_string() != value { + return Err(EventDestinationConfigError); + } + if parsed.last().is_some_and(|prior| prior >= &cidr) { + return Err(EventDestinationConfigError); + } + parsed.push(cidr); + } + Ok(parsed) +} + +fn valid_logical_destination_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +fn canonical_binding_digest(value: &Value) -> Result { + let canonical = + canonicalize_json(value).map_err(|_| EventDestinationActivationError::InvalidBinding)?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(canonical)))) +} diff --git a/crates/registry-server/src/fixtures.rs b/crates/registry-server/src/fixtures.rs new file mode 100644 index 0000000000..4f451089ce --- /dev/null +++ b/crates/registry-server/src/fixtures.rs @@ -0,0 +1,3150 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Strict project journeys and candidate-bound, non-authorizing test receipts. +//! +//! Journey source is reviewed project input, not HTTP authority. The validator +//! therefore resolves every logical entity, operation, profile, and field +//! against the compiled Registry before the executor may perform I/O. The +//! executor receives only requests derived by this module and already-verified +//! synthetic claims. It cannot accept a caller URL, SQL fragment, physical +//! identifier, credential, or arbitrary header. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +use axum::body::{to_bytes, Body}; +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE, ETAG, IF_MATCH}; +use axum::http::{Method, Request, Response, StatusCode}; +use axum::Router; +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use registry_platform_oidc::{JwksFetcher, TokenVerifier}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use tower::Service as _; +use zeroize::Zeroizing; + +use crate::api::{HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture}; +use crate::api::{VerifiedClaimValue, VerifiedRequestClaims}; +use crate::auth::RegistryAuthenticator; +use crate::compiler::{compile_project, module_digest, CompileProfile}; +use crate::contract::{parse_module_yaml, parse_project_yaml}; +use crate::contract::{AccessProfileSource, Operation}; +use crate::model::CompiledRoute; +use crate::model::{CompiledRegistry, HttpMethod}; +#[cfg(any(test, feature = "postgres-test"))] +use crate::package::{canonical_signed_bytes as package_canonical_signed_bytes, VerifiedPackage}; +use crate::package::{ + PackageCompileProfile, PackageFileRole, PreparedPackage, FIXTURE_JOURNEYS_PATH, +}; +use crate::postgres::{ + PostgresRecordMutationService, PostgresRecordReadService, PostgresRevisionReadService, + PreparedSchemaTestCatalogVerifier, PreparedSchemaTestDatabase, RuntimePool, +}; +use crate::runtime_config::RuntimeConfig; +#[cfg(feature = "postgres-test")] +use crate::startup::PreparedServer; + +const JOURNEY_API_VERSION: &str = "registry.registrystack.org/server-journeys/v1"; +const RECEIPT_API_VERSION: &str = "registry.registrystack.org/server-schema-test-receipt/v1"; +const RECEIPT_KIND: &str = "SchemaTestReceipt"; +const MAX_JOURNEY_FILE_BYTES: usize = 1024 * 1024; +const MAX_JOURNEYS: usize = 128; +const MAX_STEPS_PER_JOURNEY: usize = 128; +const MAX_TOTAL_STEPS: usize = 512; +const MAX_BODY_BYTES: usize = 256 * 1024; +const MAX_RESPONSE_BYTES: usize = 512 * 1024; +const MAX_RECEIPT_BYTES: usize = 64 * 1024; +const MAX_SOURCE_BYTES: usize = 1024 * 1024; +const MAX_IDENTIFIER_BYTES: usize = 64; +const MAX_BINDING_BYTES: usize = 256; +const MAX_BEARER_TOKEN_BYTES: usize = 32 * 1024; +const MIN_SUPPORTED_POSTGRES_MAJOR: u16 = 13; +const MAX_SUPPORTED_POSTGRES_MAJOR: u16 = 18; + +type CredentialMap = BTreeMap<(String, String), Option>>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FixtureError { + JourneyTooLarge, + JourneyShapeRefused, + JourneyVersionRefused, + JourneyBoundsRefused, + DuplicateIdentifier, + LogicalReferenceRefused, + AuthorityWideningRefused, + RequestConstructionRefused, + ResponseTooLarge, + ResponseShapeRefused, + ExpectationMismatch, + ExecutionRefused, + CandidateBindingRefused, + ReceiptShapeRefused, + ReceiptBindingRefused, +} + +impl fmt::Display for FixtureError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::JourneyTooLarge => "the fixture journey exceeded a fixed bound", + Self::JourneyShapeRefused => "the fixture journey shape was refused", + Self::JourneyVersionRefused => "the fixture journey version was refused", + Self::JourneyBoundsRefused => "the fixture journey inventory was refused", + Self::DuplicateIdentifier => "the fixture journey contains a duplicate identifier", + Self::LogicalReferenceRefused => "the fixture logical reference was refused", + Self::AuthorityWideningRefused => "the fixture authority reference was refused", + Self::RequestConstructionRefused => "the fixture request could not be constructed", + Self::ResponseTooLarge => "the fixture response exceeded a fixed bound", + Self::ResponseShapeRefused => "the fixture response shape was refused", + Self::ExpectationMismatch => "the fixture expectation did not match", + Self::ExecutionRefused => "the fixture request execution was refused", + Self::CandidateBindingRefused => "the schema test candidate binding was refused", + Self::ReceiptShapeRefused => "the schema test receipt shape was refused", + Self::ReceiptBindingRefused => "the schema test receipt binding was refused", + }) + } +} + +impl std::error::Error for FixtureError {} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct JourneyDocument { + api_version: String, + journeys: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct JourneySource { + id: String, + steps: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct StepSource { + id: String, + entity: String, + access_profile: String, + #[serde(default)] + claims: ClaimsSource, + request: ActionSource, + expect: ExpectationSource, + #[serde(default)] + capture: Option, +} + +#[derive(Clone, Deserialize)] +#[serde( + deny_unknown_fields, + rename_all = "snake_case", + rename_all_fields = "camelCase", + tag = "operation" +)] +enum ActionSource { + Create { + data: Map, + }, + Get { + record_ref: String, + }, + List, + Patch { + record_ref: String, + etag_ref: String, + changes: Vec, + }, + Batch { + items: Vec, + }, +} + +impl ActionSource { + fn operation(&self) -> Operation { + match self { + Self::Create { .. } => Operation::Create, + Self::Get { .. } => Operation::Get, + Self::List => Operation::List, + Self::Patch { .. } => Operation::Patch, + Self::Batch { .. } => Operation::Batch, + } + } +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct FieldChangeSource { + field: String, + value: Value, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "operation")] +enum BatchItemSource { + Create { data: Map }, +} + +#[derive(Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ClaimsSource { + #[serde(default)] + principal: Option, + #[serde(default)] + scopes: BTreeSet, + #[serde(default)] + purpose: Option, + #[serde(default)] + direct_claims: BTreeMap, +} + +#[derive(Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +enum ExpectedOutcome { + Success, + Refusal, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ExpectationSource { + outcome: ExpectedOutcome, + status: u16, + #[serde(default)] + fields: Map, + #[serde(default)] + count: Option, + #[serde(default)] + problem_code: Option, +} + +/// A complete journey suite that has been resolved against one exact compiled +/// Registry. Its internals are private so execution cannot substitute paths or +/// authority material after structural preflight. +#[derive(Clone)] +pub struct ValidatedFixtureJourneys { + registry_revision: String, + file_sha256: String, + file_bytes: Vec, + journeys: Vec, +} + +impl fmt::Debug for ValidatedFixtureJourneys { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ValidatedFixtureJourneys") + .field("journey_count", &self.journeys.len()) + .field( + "step_count", + &self + .journeys + .iter() + .map(|journey| journey.steps.len()) + .sum::(), + ) + .finish_non_exhaustive() + } +} + +impl ValidatedFixtureJourneys { + pub fn journey_ids(&self) -> Vec<&str> { + self.journeys + .iter() + .map(|journey| journey.id.as_str()) + .collect() + } + + pub fn file_sha256(&self) -> &str { + &self.file_sha256 + } +} + +#[derive(Clone)] +struct ValidatedJourney { + id: String, + steps: Vec, +} + +#[derive(Clone)] +struct ValidatedStep { + id: String, + entity: String, + access_profile: String, + claims: ClaimsSource, + route: CompiledRoute, + profile: AccessProfileSource, + action: ActionSource, + expect: ExpectationSource, + capture: Option, +} + +/// Parse and resolve all journey references before any request executor is +/// called. YAML errors are deliberately collapsed into a value-free refusal. +pub fn validate_fixture_journeys( + bytes: &[u8], + registry: &CompiledRegistry, +) -> Result { + if bytes.is_empty() || bytes.len() > MAX_JOURNEY_FILE_BYTES { + return Err(FixtureError::JourneyTooLarge); + } + let deserializer = serde_norway::Deserializer::from_slice(bytes); + let document: JourneyDocument = serde_path_to_error::deserialize(deserializer) + .map_err(|_| FixtureError::JourneyShapeRefused)?; + if document.api_version != JOURNEY_API_VERSION { + return Err(FixtureError::JourneyVersionRefused); + } + if document.journeys.is_empty() || document.journeys.len() > MAX_JOURNEYS { + return Err(FixtureError::JourneyBoundsRefused); + } + + let mut journey_ids = BTreeSet::new(); + let mut step_ids = BTreeSet::new(); + let mut total_steps = 0usize; + let mut journeys = Vec::with_capacity(document.journeys.len()); + for journey in document.journeys { + let mut capture_ids = BTreeSet::new(); + if !valid_stable_id(&journey.id) || !journey_ids.insert(journey.id.clone()) { + return Err(if valid_stable_id(&journey.id) { + FixtureError::DuplicateIdentifier + } else { + FixtureError::LogicalReferenceRefused + }); + } + if journey.steps.is_empty() || journey.steps.len() > MAX_STEPS_PER_JOURNEY { + return Err(FixtureError::JourneyBoundsRefused); + } + total_steps = total_steps + .checked_add(journey.steps.len()) + .ok_or(FixtureError::JourneyBoundsRefused)?; + if total_steps > MAX_TOTAL_STEPS { + return Err(FixtureError::JourneyBoundsRefused); + } + let mut steps = Vec::with_capacity(journey.steps.len()); + for step in journey.steps { + if !valid_stable_id(&step.id) || !step_ids.insert(step.id.clone()) { + return Err(if valid_stable_id(&step.id) { + FixtureError::DuplicateIdentifier + } else { + FixtureError::LogicalReferenceRefused + }); + } + validate_action_references(&step.request, &capture_ids)?; + let capture = step.capture.clone(); + if let Some(identifier) = capture.as_deref() { + if !valid_stable_id(identifier) || !capture_ids.insert(identifier.to_owned()) { + return Err(if valid_stable_id(identifier) { + FixtureError::DuplicateIdentifier + } else { + FixtureError::LogicalReferenceRefused + }); + } + } + let entity = registry + .entities() + .get(&step.entity) + .ok_or(FixtureError::LogicalReferenceRefused)?; + let profile = entity + .access_profiles + .get(&step.access_profile) + .ok_or(FixtureError::LogicalReferenceRefused)?; + let operation = step.request.operation(); + if !profile.operations.contains(&operation) { + return Err(FixtureError::LogicalReferenceRefused); + } + let route = registry + .routes() + .routes + .iter() + .find(|route| { + route.entity_id == step.entity + && route.operation == operation + && route.method == operation_method(operation) + && route.access_profiles.contains(&step.access_profile) + }) + .cloned() + .ok_or(FixtureError::LogicalReferenceRefused)?; + validate_claims(&step.claims, profile, step.expect.outcome)?; + validate_action_fields(&step.request, entity, profile)?; + validate_expectation(&step.expect, operation, profile, capture.is_some())?; + steps.push(ValidatedStep { + id: step.id, + entity: step.entity, + access_profile: step.access_profile, + claims: step.claims, + route, + profile: profile.clone(), + action: step.request, + expect: step.expect, + capture, + }); + } + journeys.push(ValidatedJourney { + id: journey.id, + steps, + }); + } + Ok(ValidatedFixtureJourneys { + registry_revision: registry.revision().to_owned(), + file_sha256: sha256(bytes), + file_bytes: bytes.to_vec(), + journeys, + }) +} + +fn validate_action_references( + action: &ActionSource, + captures: &BTreeSet, +) -> Result<(), FixtureError> { + let references: &[&str] = match action { + ActionSource::Get { record_ref } => &[record_ref], + ActionSource::Patch { + record_ref, + etag_ref, + .. + } => &[record_ref, etag_ref], + ActionSource::Create { .. } | ActionSource::List | ActionSource::Batch { .. } => &[], + }; + if references + .iter() + .any(|identifier| !valid_stable_id(identifier) || !captures.contains(*identifier)) + { + return Err(FixtureError::LogicalReferenceRefused); + } + Ok(()) +} + +fn validate_claims( + claims: &ClaimsSource, + profile: &AccessProfileSource, + outcome: ExpectedOutcome, +) -> Result<(), FixtureError> { + if profile.anonymous { + if claims.principal.is_some() + || !claims.scopes.is_empty() + || claims.purpose.is_some() + || !claims.direct_claims.is_empty() + { + return Err(FixtureError::AuthorityWideningRefused); + } + return Ok(()); + } + if profile.principal_claim.is_none() + || claims + .principal + .as_deref() + .is_none_or(|value| value.is_empty() || value.len() > MAX_BINDING_BYTES) + || !claims.scopes.is_subset(&profile.required_scopes) + { + return Err(FixtureError::AuthorityWideningRefused); + } + let boundary_claims = profile + .row_boundaries + .iter() + .map(|boundary| boundary.claim.as_str()) + .collect::>(); + if claims.direct_claims.iter().any(|(name, value)| { + !boundary_claims.contains(name.as_str()) + || value.is_empty() + || value.len() > MAX_BINDING_BYTES + }) { + return Err(FixtureError::AuthorityWideningRefused); + } + if let Some(purpose) = claims.purpose.as_deref() { + if purpose.len() > MAX_BINDING_BYTES || !profile.required_purposes.contains(purpose) { + return Err(FixtureError::AuthorityWideningRefused); + } + } + if outcome == ExpectedOutcome::Success + && (claims.principal.is_none() + || claims.scopes != profile.required_scopes + || (!profile.required_purposes.is_empty() && claims.purpose.is_none()) + || boundary_claims + .iter() + .any(|name| !claims.direct_claims.contains_key(*name))) + { + return Err(FixtureError::AuthorityWideningRefused); + } + Ok(()) +} + +fn validate_action_fields( + action: &ActionSource, + entity: &crate::model::CompiledEntity, + profile: &AccessProfileSource, +) -> Result<(), FixtureError> { + let validate_data = |data: &Map| { + if data.is_empty() + || data.keys().any(|field| { + !entity.fields.contains_key(field) || !profile.writable_fields.contains(field) + }) + || canonical_size(&Value::Object(data.clone()))? > MAX_BODY_BYTES + { + return Err(FixtureError::LogicalReferenceRefused); + } + Ok(()) + }; + match action { + ActionSource::Create { data } => validate_data(data), + ActionSource::Get { .. } | ActionSource::List => Ok(()), + ActionSource::Patch { changes, .. } => { + if changes.is_empty() || changes.len() > entity.fields.len() { + return Err(FixtureError::JourneyBoundsRefused); + } + let mut fields = BTreeSet::new(); + for change in changes { + if !fields.insert(change.field.as_str()) + || !entity.fields.contains_key(&change.field) + || !profile.writable_fields.contains(&change.field) + { + return Err(FixtureError::LogicalReferenceRefused); + } + } + let document = Value::Array( + changes + .iter() + .map(|change| { + json!({"op":"replace","path":format!("/data/{}", change.field),"value":change.value}) + }) + .collect(), + ); + if canonical_size(&document)? > MAX_BODY_BYTES { + return Err(FixtureError::JourneyTooLarge); + } + Ok(()) + } + ActionSource::Batch { items } => { + let maximum_items = entity + .batch + .as_ref() + .map(|batch| usize::from(batch.maximum_items)) + .ok_or(FixtureError::LogicalReferenceRefused)?; + if items.is_empty() || items.len() > maximum_items { + return Err(FixtureError::JourneyBoundsRefused); + } + for item in items { + match item { + BatchItemSource::Create { data } => validate_data(data)?, + } + } + let body_bytes = canonical_size(&batch_body(items))?; + let compiled_maximum = usize::try_from( + entity + .batch + .as_ref() + .expect("batch inventory was checked") + .maximum_bytes, + ) + .map_err(|_| FixtureError::JourneyBoundsRefused)?; + if body_bytes > MAX_BODY_BYTES || body_bytes > compiled_maximum { + return Err(FixtureError::JourneyTooLarge); + } + Ok(()) + } + } +} + +fn validate_expectation( + expectation: &ExpectationSource, + operation: Operation, + profile: &AccessProfileSource, + captures: bool, +) -> Result<(), FixtureError> { + if expectation + .fields + .keys() + .any(|field| !profile.readable_fields.contains(field) || field.len() > MAX_IDENTIFIER_BYTES) + || canonical_size(&Value::Object(expectation.fields.clone()))? > MAX_RESPONSE_BYTES + { + return Err(FixtureError::LogicalReferenceRefused); + } + match expectation.outcome { + ExpectedOutcome::Success => { + let expected = match operation { + Operation::Create => 201, + Operation::Get | Operation::List | Operation::Patch | Operation::Batch => 200, + Operation::Tombstone | Operation::Revisions => { + return Err(FixtureError::LogicalReferenceRefused) + } + }; + if expectation.status != expected || expectation.problem_code.is_some() { + return Err(FixtureError::JourneyShapeRefused); + } + if matches!(operation, Operation::List | Operation::Batch) { + if expectation.count.is_none() || !expectation.fields.is_empty() || captures { + return Err(FixtureError::JourneyShapeRefused); + } + } else if expectation.count.is_some() { + return Err(FixtureError::JourneyShapeRefused); + } + } + ExpectedOutcome::Refusal => { + if expectation.status < 400 + || expectation.problem_code.as_deref().is_none_or(|code| { + code.is_empty() + || code.len() > MAX_IDENTIFIER_BYTES + || !code.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_') + }) + }) + || !expectation.fields.is_empty() + || expectation.count.is_some() + || captures + || problem_contract(expectation.status, expectation.problem_code.as_deref()) + .is_none() + { + return Err(FixtureError::JourneyShapeRefused); + } + } + } + Ok(()) +} + +fn canonical_size(value: &Value) -> Result { + canonicalize_json(value) + .map(|bytes| bytes.len()) + .map_err(|_| FixtureError::JourneyShapeRefused) +} + +/// Success token produced only after every selected journey and step has +/// matched. It has no serialization or public constructor. +struct SuccessfulFixtureJourneys { + registry_revision: String, + file_sha256: String, + journey_ids: Vec, + candidate_binding_sha256: String, +} + +impl fmt::Debug for SuccessfulFixtureJourneys { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SuccessfulFixtureJourneys") + .field("journey_count", &self.journey_ids.len()) + .finish_non_exhaustive() + } +} + +struct Observation { + record_id: String, + etag: String, +} + +/// Concrete state machine used only by the real-PostgreSQL integration gate. +/// +/// The runner derives its identity from `registry_state` through the same +/// runtime pool used by the HTTP services, captures the prepared server's +/// router, and owns dispatch through receipt completion. It never exposes a +/// request or accepts a caller-created response. Otherwise a `postgres-test` +/// dependency could feed canned success documents into the state machine and +/// mint a receipt without exercising the Registry router or PostgreSQL. This +/// feature-gated seam is not available to ordinary tooling or runtime builds. +/// The production command path must receive equivalent dispatch and pool +/// identity directly from startup rather than accept an implementable executor. +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +pub struct PostgresFixtureTestRunner { + pool: RuntimePool, + app: Router, + bearer_tokens: Vec, + bearer_index: usize, + suite: ValidatedFixtureJourneys, + candidate: ValidatedSchemaTestCandidate, + execution_facts: SchemaTestExecutionFacts, + journey_index: usize, + step_index: usize, + observations: BTreeMap, +} + +#[cfg(feature = "postgres-test")] +impl PostgresFixtureTestRunner { + pub async fn prepare( + package: &VerifiedPackage, + sources: &SchemaTestSources<'_>, + suite: &ValidatedFixtureJourneys, + prepared: &PreparedServer, + bearer_tokens: Vec, + ) -> Result { + let (app, pool) = prepared + .fixture_runtime() + .ok_or(FixtureError::ExecutionRefused)?; + let step_count = suite + .journeys + .iter() + .try_fold(0_usize, |count, journey| { + count.checked_add(journey.steps.len()) + }) + .ok_or(FixtureError::JourneyBoundsRefused)?; + if bearer_tokens.len() != step_count + || bearer_tokens.iter().any(|token| { + token.is_empty() + || token.len() > MAX_BEARER_TOKEN_BYTES + || token.bytes().any(|byte| { + !(byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) + }) + }) + { + return Err(FixtureError::RequestConstructionRefused); + } + let execution_facts = database_execution_facts(&pool).await?; + let candidate = validate_schema_test_candidate(package, sources, &execution_facts, suite)?; + Ok(Self { + pool, + app, + bearer_tokens, + bearer_index: 0, + suite: suite.clone(), + candidate, + execution_facts, + journey_index: 0, + step_index: 0, + observations: BTreeMap::new(), + }) + } + + fn next_request(&self) -> Result>, FixtureError> { + let Some(journey) = self.suite.journeys.get(self.journey_index) else { + return Ok(None); + }; + let step = journey + .steps + .get(self.step_index) + .ok_or(FixtureError::ExecutionRefused)?; + let bearer = self + .bearer_tokens + .get(self.bearer_index) + .ok_or(FixtureError::ExecutionRefused)?; + fixture_request(step, &self.observations, Some(bearer)).map(Some) + } + + /// Execute every validated journey through the captured Registry router. + /// A failure consumes the runner and therefore cannot be converted into a + /// completed result or receipt by skipping the remaining steps. + pub async fn run_all(mut self) -> Result { + while let Some(request) = self.next_request()? { + let response = self + .app + .call(request) + .await + .map_err(|error| match error {})?; + self.accept_current_response(response).await?; + } + self.finish().await + } + + async fn accept_current_response( + &mut self, + response: Response, + ) -> Result<(), FixtureError> { + let step = self + .suite + .journeys + .get(self.journey_index) + .and_then(|journey| journey.steps.get(self.step_index)) + .cloned() + .ok_or(FixtureError::ExecutionRefused)?; + accept_response(&step, response, &mut self.observations).await?; + self.bearer_index += 1; + self.step_index += 1; + let journey = self + .suite + .journeys + .get(self.journey_index) + .ok_or(FixtureError::ExecutionRefused)?; + if self.step_index == journey.steps.len() { + self.journey_index += 1; + self.step_index = 0; + self.observations.clear(); + } + Ok(()) + } + + async fn finish(self) -> Result { + if self.journey_index != self.suite.journeys.len() + || self.step_index != 0 + || self.bearer_index != self.bearer_tokens.len() + { + return Err(FixtureError::ExecutionRefused); + } + let final_facts = database_execution_facts(&self.pool).await?; + if final_facts != self.execution_facts { + return Err(FixtureError::CandidateBindingRefused); + } + Ok(CompletedPostgresFixtureTest { + successful: SuccessfulFixtureJourneys { + registry_revision: self.suite.registry_revision.clone(), + file_sha256: self.suite.file_sha256.clone(), + journey_ids: sorted_journey_ids(&self.suite), + candidate_binding_sha256: candidate_binding_sha256(&self.candidate), + }, + candidate: self.candidate, + }) + } +} + +/// Completed result from the concrete real-PostgreSQL test runner. It exposes +/// receipt operations, never the candidate or a success-token constructor. +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +pub struct CompletedPostgresFixtureTest { + candidate: ValidatedSchemaTestCandidate, + successful: SuccessfulFixtureJourneys, +} + +#[cfg(feature = "postgres-test")] +impl CompletedPostgresFixtureTest { + pub fn build_receipt( + &self, + suite: &ValidatedFixtureJourneys, + ) -> Result { + build_schema_test_receipt(&self.candidate, suite, &self.successful) + } + + pub fn revalidate_receipt( + &self, + bytes: &[u8], + suite: &ValidatedFixtureJourneys, + ) -> Result { + revalidate_schema_test_receipt(bytes, &self.candidate, suite) + } +} + +/// One private bearer credential bound to a validated journey step. +pub struct SchemaTestCredentialBinding { + journey_id: String, + step_id: String, + bearer_token: Option>, +} + +impl SchemaTestCredentialBinding { + pub fn bearer( + journey_id: impl Into, + step_id: impl Into, + bearer_token: Zeroizing, + ) -> Self { + Self { + journey_id: journey_id.into(), + step_id: step_id.into(), + bearer_token: Some(bearer_token), + } + } + + pub fn anonymous(journey_id: impl Into, step_id: impl Into) -> Self { + Self { + journey_id: journey_id.into(), + step_id: step_id.into(), + bearer_token: None, + } + } +} + +impl fmt::Debug for SchemaTestCredentialBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchemaTestCredentialBinding") + .field("journey_id", &self.journey_id) + .field("step_id", &self.step_id) + .field("has_bearer_token", &self.bearer_token.is_some()) + .finish() + } +} + +/// Closed credential inventory consumed by the schema-test executor. +pub struct SchemaTestCredentialBindings { + bindings: Vec, +} + +impl SchemaTestCredentialBindings { + /// Close the credential inventory against one exact validated suite before + /// any database preparation is necessary. Protected steps require one + /// bearer value and anonymous steps require an explicit anonymous binding. + pub fn new( + suite: &ValidatedFixtureJourneys, + bindings: Vec, + ) -> Result { + let result = Self { bindings }; + result.validate(suite)?; + Ok(result) + } + + fn validate(&self, suite: &ValidatedFixtureJourneys) -> Result<(), FixtureError> { + let expected = suite.journeys.iter().flat_map(|journey| { + journey.steps.iter().map(move |step| { + ( + (journey.id.as_str(), step.id.as_str()), + step.profile.anonymous, + ) + }) + }); + if self.bindings.len() > MAX_TOTAL_STEPS { + return Err(FixtureError::JourneyBoundsRefused); + } + if self.bindings.len() != expected.clone().count() { + return Err(FixtureError::RequestConstructionRefused); + } + let expected = expected.collect::>(); + let mut actual = BTreeSet::new(); + for binding in &self.bindings { + if !valid_stable_id(&binding.journey_id) || !valid_stable_id(&binding.step_id) { + return Err(FixtureError::RequestConstructionRefused); + } + let key = (binding.journey_id.as_str(), binding.step_id.as_str()); + let Some(anonymous) = expected.get(&key) else { + return Err(FixtureError::RequestConstructionRefused); + }; + if !actual.insert(key) + || match (*anonymous, binding.bearer_token.as_ref()) { + (true, None) => false, + (false, Some(token)) => !valid_bearer_token(token), + (true, Some(_)) | (false, None) => true, + } + { + return Err(FixtureError::RequestConstructionRefused); + } + } + Ok(()) + } + + fn into_map(self, suite: &ValidatedFixtureJourneys) -> Result { + self.validate(suite)?; + let mut actual = BTreeMap::new(); + for binding in self.bindings { + let key = (binding.journey_id, binding.step_id); + if actual.insert(key, binding.bearer_token).is_some() { + return Err(FixtureError::RequestConstructionRefused); + } + } + Ok(actual) + } +} + +fn valid_bearer_token(token: &str) -> bool { + if token.is_empty() || token.len() > MAX_BEARER_TOKEN_BYTES { + return false; + } + let mut segments = token.split('.'); + let valid_segment = |segment: &str| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + }; + matches!( + ( + segments.next(), + segments.next(), + segments.next(), + segments.next(), + ), + (Some(header), Some(claims), Some(signature), None) + if valid_segment(header) && valid_segment(claims) && valid_segment(signature) + ) +} + +impl fmt::Debug for SchemaTestCredentialBindings { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchemaTestCredentialBindings") + .field("binding_count", &self.bindings.len()) + .finish() + } +} + +/// Execute a pre-sign schema test through the production database and HTTP +/// services. The returned receipt is deterministic and non-authorizing. +pub async fn execute_schema_test( + database: PreparedSchemaTestDatabase, + config: &RuntimeConfig, + package: &PreparedPackage, + suite: &ValidatedFixtureJourneys, + credentials: SchemaTestCredentialBindings, +) -> Result { + let key_source = config + .oidc_key_source() + .await + .map_err(|_| FixtureError::ExecutionRefused)?; + execute_schema_test_with_key_source(database, config, package, suite, credentials, key_source) + .await +} + +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +pub async fn execute_schema_test_with_key_source_for_test( + database: PreparedSchemaTestDatabase, + config: &RuntimeConfig, + package: &PreparedPackage, + suite: &ValidatedFixtureJourneys, + credentials: SchemaTestCredentialBindings, + key_source: Arc, +) -> Result { + execute_schema_test_with_key_source(database, config, package, suite, credentials, key_source) + .await +} + +async fn execute_schema_test_with_key_source( + database: PreparedSchemaTestDatabase, + config: &RuntimeConfig, + package: &PreparedPackage, + suite: &ValidatedFixtureJourneys, + credentials: SchemaTestCredentialBindings, + key_source: Arc, +) -> Result { + // Credential coverage and mode are pure preflight. Keep this before even + // reading candidate database facts so malformed inputs cannot trigger I/O. + let credential_map = credentials.into_map(suite)?; + let pool = database.pool(); + let initial_facts = database_execution_facts(&pool).await?; + let (candidate, compiled) = + validate_prepared_schema_test_candidate(package, &initial_facts, suite)?; + let mut runtime = SchemaTestRuntime::new( + database, + config, + compiled, + key_source, + initial_facts.clone(), + ) + .await?; + + let mut bearer_index = 0usize; + let mut observations = BTreeMap::new(); + for journey in &suite.journeys { + for step in &journey.steps { + let bearer = credential_map + .get(&(journey.id.clone(), step.id.clone())) + .ok_or(FixtureError::RequestConstructionRefused)?; + match (step.profile.anonymous, bearer.as_ref()) { + (true, None) => {} + (true, Some(_)) | (false, None) => { + return Err(FixtureError::RequestConstructionRefused); + } + (false, Some(token)) => { + runtime.authenticate_exact(step, token.as_str()).await?; + } + } + let bearer_token = bearer.as_ref().map(|token| token.as_str()); + let request = fixture_request(step, &observations, bearer_token)?; + let response = runtime + .app + .call(request) + .await + .map_err(|error| match error {})?; + accept_response(step, response, &mut observations).await?; + bearer_index += 1; + } + observations.clear(); + } + if bearer_index != credential_map.len() { + return Err(FixtureError::ExecutionRefused); + } + let final_facts = database_execution_facts(&runtime.pool).await?; + if final_facts != runtime.initial_facts || !runtime.readiness.is_ready().await { + return Err(FixtureError::CandidateBindingRefused); + } + let successful = SuccessfulFixtureJourneys { + registry_revision: suite.registry_revision.clone(), + file_sha256: suite.file_sha256.clone(), + journey_ids: sorted_journey_ids(suite), + candidate_binding_sha256: candidate_binding_sha256(&candidate), + }; + build_schema_test_receipt(&candidate, suite, &successful) +} + +struct SchemaTestRuntime { + app: Router, + pool: RuntimePool, + initial_facts: SchemaTestExecutionFacts, + authenticator: Arc, + verifier: TokenVerifier, + readiness: Arc, +} + +impl SchemaTestRuntime { + async fn new( + database: PreparedSchemaTestDatabase, + config: &RuntimeConfig, + compiled: CompiledRegistry, + key_source: Arc, + initial_facts: SchemaTestExecutionFacts, + ) -> Result { + key_source + .ensure_key_set() + .await + .map_err(|_| FixtureError::ExecutionRefused)?; + let pool = database.pool(); + let registry = Arc::new(compiled); + let audit_profile = config + .audit_profile() + .map_err(|_| FixtureError::ExecutionRefused)?; + let cursor_codec = Arc::new( + config + .cursor_codec() + .map_err(|_| FixtureError::ExecutionRefused)?, + ); + let event_destinations = Arc::new( + config + .activate_event_destinations(®istry) + .map_err(|_| FixtureError::ExecutionRefused)?, + ); + let authenticator = Arc::new( + RegistryAuthenticator::new( + ®istry, + config.authentication().oidc().token_verifier_config(), + Arc::clone(&key_source), + config.authentication().authority_claim_config(), + ) + .map_err(|_| FixtureError::ExecutionRefused)?, + ); + let verifier = TokenVerifier::new( + config.authentication().oidc().token_verifier_config(), + Arc::clone(&key_source), + ); + let readiness = Arc::new(SchemaTestReadiness { + pool: pool.clone(), + catalog_verifier: database.catalog_verifier(), + key_source, + }); + if !readiness.is_ready().await { + return Err(FixtureError::CandidateBindingRefused); + } + let expected = database.expected().clone(); + let read_identity = ReadRuntimeIdentity { + package_revision: expected.package_revision.clone(), + schema_fingerprint: expected.schema_fingerprint.clone(), + }; + let records = Arc::new(PostgresRecordReadService::new( + pool.clone(), + Arc::clone(®istry), + expected.clone(), + database.lock_key(), + config.operational_timeouts().record_lock, + audit_profile.clone(), + Arc::clone(&cursor_codec), + )); + let revisions = Arc::new(PostgresRevisionReadService::new( + pool.clone(), + Arc::clone(®istry), + expected.clone(), + database.lock_key(), + config.operational_timeouts().record_lock, + audit_profile.clone(), + )); + let mutations = Arc::new(PostgresRecordMutationService::new_with_event_destinations( + pool.clone(), + Arc::clone(®istry), + expected, + database.lock_key(), + config.operational_timeouts().record_lock, + audit_profile, + Some(event_destinations), + )); + let service = Arc::new( + HttpService::new( + registry, + read_identity, + records, + Arc::clone(&readiness) as Arc, + cursor_codec, + ) + .with_postgres_revisions(revisions) + .with_postgres_mutations(mutations), + ); + let app = crate::startup::with_request_timeout_for_test( + crate::api::authenticated_router(service, Arc::clone(&authenticator)), + config.operational_timeouts().http_request, + ); + Ok(Self { + app, + pool, + initial_facts, + authenticator, + verifier, + readiness, + }) + } + + async fn authenticate_exact( + &self, + step: &ValidatedStep, + token: &str, + ) -> Result<(), FixtureError> { + let verified = self + .verifier + .verify(token) + .await + .map_err(|_| FixtureError::RequestConstructionRefused)?; + let mapped = self + .authenticator + .authenticate(token) + .await + .map_err(|_| FixtureError::RequestConstructionRefused)?; + let scopes = verified.scopes.into_iter().collect::>(); + if scopes != step.claims.scopes + || mapped.principal_claim() != step.profile.principal_claim.as_deref() + || mapped.principal() != step.claims.principal.as_deref() + || mapped.purpose() != step.claims.purpose.as_deref() + { + return Err(FixtureError::AuthorityWideningRefused); + } + for (name, expected) in &step.claims.direct_claims { + if mapped + .direct_claim(name) + .map(VerifiedClaimValue::values) + .as_ref() + != Some(&BTreeSet::from([expected.clone()])) + { + return Err(FixtureError::AuthorityWideningRefused); + } + } + let actual_names = step + .profile + .row_boundaries + .iter() + .filter(|boundary| mapped.direct_claim(&boundary.claim).is_some()) + .map(|boundary| boundary.claim.clone()) + .collect::>(); + if actual_names != step.claims.direct_claims.keys().cloned().collect() { + return Err(FixtureError::AuthorityWideningRefused); + } + Ok(()) + } +} + +struct SchemaTestReadiness { + pool: RuntimePool, + catalog_verifier: PreparedSchemaTestCatalogVerifier, + key_source: Arc, +} + +impl SchemaTestReadiness { + async fn check(&self) -> Result<(), FixtureError> { + let client = self + .pool + .get() + .await + .map_err(|_| FixtureError::ExecutionRefused)?; + let maintenance = client + .query_opt( + "SELECT maintenance_status + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .map_err(|_| FixtureError::ExecutionRefused)? + .ok_or(FixtureError::CandidateBindingRefused)? + .get::<_, String>(0); + if maintenance != "ready" { + return Err(FixtureError::CandidateBindingRefused); + } + self.catalog_verifier + .verify() + .await + .map_err(|_| FixtureError::CandidateBindingRefused)?; + self.key_source + .ensure_key_set() + .await + .map_err(|_| FixtureError::ExecutionRefused)?; + Ok(()) + } +} + +impl ReadinessProbe for SchemaTestReadiness { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async move { self.check().await.is_ok() }) + } +} + +async fn database_execution_facts( + pool: &RuntimePool, +) -> Result { + let client = pool + .get() + .await + .map_err(|_| FixtureError::ExecutionRefused)?; + let row = client + .query_one( + "SELECT current_database(), current_setting('server_version_num'), + package_id, environment, instance_id, database_id, + active_package_revision, package_sequence, + schema_fingerprint, maintenance_status + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .map_err(|_| FixtureError::ExecutionRefused)?; + let version = row + .get::<_, String>(1) + .parse::() + .map_err(|_| FixtureError::ExecutionRefused)?; + let sequence = + u64::try_from(row.get::<_, i64>(7)).map_err(|_| FixtureError::CandidateBindingRefused)?; + let postgres_major = + u16::try_from(version / 10_000).map_err(|_| FixtureError::CandidateBindingRefused)?; + Ok(SchemaTestExecutionFacts::from_database_snapshot( + DatabaseExecutionSnapshot { + current_database: row.get(0), + package_id: row.get(2), + environment: row.get(3), + instance_id: row.get(4), + database_id: row.get(5), + package_revision: row.get(6), + sequence, + schema_fingerprint: row.get(8), + postgres_major, + maintenance_status: row.get(9), + }, + )) +} + +fn fixture_request( + step: &ValidatedStep, + observations: &BTreeMap, + bearer_token: Option<&str>, +) -> Result, FixtureError> { + let mut path = step.route.path.clone(); + let mut method = Method::GET; + let mut body = Body::empty(); + let mut content_type = None; + let mut if_match = None; + match &step.action { + ActionSource::Create { data } => { + method = Method::POST; + body = json_body(&json!({"data": data}))?; + content_type = Some("application/json"); + } + ActionSource::Get { record_ref } => { + let observed = observations + .get(record_ref) + .ok_or(FixtureError::RequestConstructionRefused)?; + path = path.replace("{record_id}", &observed.record_id); + } + ActionSource::List => {} + ActionSource::Patch { + record_ref, + etag_ref, + changes, + } => { + let record = observations + .get(record_ref) + .ok_or(FixtureError::RequestConstructionRefused)?; + let etag = observations + .get(etag_ref) + .ok_or(FixtureError::RequestConstructionRefused)?; + path = path.replace("{record_id}", &record.record_id); + method = Method::PATCH; + body = json_body(&Value::Array( + changes + .iter() + .map(|change| { + json!({"op":"replace","path":format!("/data/{}", change.field),"value":change.value}) + }) + .collect(), + ))?; + content_type = Some("application/json-patch+json"); + if_match = Some(etag.etag.as_str()); + } + ActionSource::Batch { items } => { + method = Method::POST; + body = json_body(&batch_body(items))?; + content_type = Some("application/json"); + } + } + if !path.starts_with('/') || path.contains(['?', '#']) || path.contains('{') { + return Err(FixtureError::RequestConstructionRefused); + } + path.push_str("?accessProfile="); + path.push_str(&step.access_profile); + let mut request = Request::builder() + .method(method) + .uri(path) + .body(body) + .map_err(|_| FixtureError::RequestConstructionRefused)?; + if let Some(value) = content_type { + request.headers_mut().insert( + CONTENT_TYPE, + value + .parse() + .map_err(|_| FixtureError::RequestConstructionRefused)?, + ); + } + if matches!( + step.action, + ActionSource::Create { .. } | ActionSource::Patch { .. } | ActionSource::Batch { .. } + ) { + let key = format!("fixture-{}-{}", step.entity, step.id); + request.headers_mut().insert( + "idempotency-key", + key.parse() + .map_err(|_| FixtureError::RequestConstructionRefused)?, + ); + } + if let Some(value) = if_match { + request.headers_mut().insert( + IF_MATCH, + value + .parse() + .map_err(|_| FixtureError::RequestConstructionRefused)?, + ); + } + if let Some(token) = bearer_token { + request.headers_mut().insert( + AUTHORIZATION, + format!("Bearer {token}") + .parse() + .map_err(|_| FixtureError::RequestConstructionRefused)?, + ); + } else { + request.extensions_mut().insert(verified_claims(step)?); + } + Ok(request) +} + +fn json_body(value: &Value) -> Result { + let bytes = canonicalize_json(value).map_err(|_| FixtureError::RequestConstructionRefused)?; + if bytes.len() > MAX_BODY_BYTES { + return Err(FixtureError::JourneyTooLarge); + } + Ok(Body::from(bytes)) +} + +fn batch_body(items: &[BatchItemSource]) -> Value { + json!({ + "items": items.iter().map(|item| match item { + BatchItemSource::Create { data } => json!({"operation":"create","data":data}), + }).collect::>() + }) +} + +fn verified_claims(step: &ValidatedStep) -> Result { + if step.profile.anonymous { + return Ok(VerifiedRequestClaims::anonymous()); + } + let principal_claim = step + .profile + .principal_claim + .as_deref() + .ok_or(FixtureError::RequestConstructionRefused)?; + let principal = step + .claims + .principal + .as_deref() + .ok_or(FixtureError::RequestConstructionRefused)?; + let direct_claims = step + .claims + .direct_claims + .iter() + .map(|(name, value)| { + VerifiedClaimValue::direct_string(value.clone()) + .map(|value| (name.clone(), value)) + .map_err(|_| FixtureError::RequestConstructionRefused) + }) + .collect::, _>>()?; + VerifiedRequestClaims::authenticated( + principal_claim, + principal, + step.claims.scopes.clone(), + step.claims.purpose.clone(), + direct_claims, + ) + .map_err(|_| FixtureError::RequestConstructionRefused) +} + +async fn accept_response( + step: &ValidatedStep, + response: Response, + observations: &mut BTreeMap, +) -> Result<(), FixtureError> { + let status = response.status(); + let headers = response.headers().clone(); + let bytes = to_bytes(response.into_body(), MAX_RESPONSE_BYTES) + .await + .map_err(|_| FixtureError::ResponseTooLarge)?; + let document = parse_json_strict(&bytes).map_err(|_| FixtureError::ResponseShapeRefused)?; + assert_response(step, status, &document)?; + if let Some(capture) = step.capture.as_ref() { + let record_id = document + .get("id") + .and_then(Value::as_str) + .filter(|value| uuid::Uuid::parse_str(value).is_ok_and(|id| id.to_string() == *value)) + .ok_or(FixtureError::ResponseShapeRefused)?; + let etag = headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty() && value.len() <= MAX_BINDING_BYTES) + .ok_or(FixtureError::ResponseShapeRefused)?; + observations.insert( + capture.clone(), + Observation { + record_id: record_id.to_owned(), + etag: etag.to_owned(), + }, + ); + } + Ok(()) +} + +fn assert_response( + step: &ValidatedStep, + status: StatusCode, + document: &Value, +) -> Result<(), FixtureError> { + if status.as_u16() != step.expect.status { + return Err(FixtureError::ExpectationMismatch); + } + match step.expect.outcome { + ExpectedOutcome::Refusal => { + let (title, detail) = + problem_contract(step.expect.status, step.expect.problem_code.as_deref()) + .ok_or(FixtureError::ResponseShapeRefused)?; + let code = step + .expect + .problem_code + .as_deref() + .ok_or(FixtureError::ResponseShapeRefused)?; + let object = exact_object(document, &["type", "title", "status", "detail", "code"])?; + let expected_type = format!("urn:registry-server:problem:{code}"); + if object.get("type").and_then(Value::as_str) != Some(expected_type.as_str()) + || object.get("title").and_then(Value::as_str) != Some(title) + || object.get("status").and_then(Value::as_u64) + != Some(u64::from(step.expect.status)) + || object.get("detail").and_then(Value::as_str) != Some(detail) + || object.get("code").and_then(Value::as_str) != Some(code) + { + return Err(FixtureError::ExpectationMismatch); + } + } + ExpectedOutcome::Success => match step.action { + ActionSource::List => { + let object = exact_object(document, &["items", "pageInfo"])?; + let items = object + .get("items") + .and_then(Value::as_array) + .ok_or(FixtureError::ResponseShapeRefused)?; + let page_info = exact_object( + object + .get("pageInfo") + .ok_or(FixtureError::ResponseShapeRefused)?, + &["nextCursor"], + )?; + if !page_info.get("nextCursor").is_some_and(|cursor| { + cursor.is_null() + || cursor.as_str().is_some_and(|value| { + !value.is_empty() && value.len() <= MAX_BINDING_BYTES + }) + }) || Some(items.len()) != step.expect.count + { + return Err(FixtureError::ExpectationMismatch); + } + for item in items { + assert_record_shape(item, &step.profile.readable_fields, &Map::new())?; + } + } + ActionSource::Batch { .. } => { + let object = exact_object(document, &["results"])?; + let results = object + .get("results") + .and_then(Value::as_array) + .ok_or(FixtureError::ResponseShapeRefused)?; + if Some(results.len()) != step.expect.count { + return Err(FixtureError::ExpectationMismatch); + } + for result in results { + let object = + exact_object(result, &["operation", "id", "revision", "etag", "data"])?; + if object.get("operation").and_then(Value::as_str) != Some("create") + || object + .get("etag") + .and_then(Value::as_str) + .is_none_or(|etag| { + etag.len() > MAX_BINDING_BYTES + || !etag.starts_with("\"rs-") + || !etag.ends_with('"') + }) + { + return Err(FixtureError::ResponseShapeRefused); + } + assert_record_members(object, &step.profile.readable_fields, &Map::new())?; + } + } + ActionSource::Create { .. } | ActionSource::Get { .. } | ActionSource::Patch { .. } => { + assert_record_shape(document, &step.profile.readable_fields, &step.expect.fields)?; + } + }, + } + Ok(()) +} + +fn exact_object<'a>( + value: &'a Value, + expected_keys: &[&str], +) -> Result<&'a Map, FixtureError> { + let object = value + .as_object() + .ok_or(FixtureError::ResponseShapeRefused)?; + if object.len() != expected_keys.len() + || expected_keys.iter().any(|key| !object.contains_key(*key)) + { + return Err(FixtureError::ResponseShapeRefused); + } + Ok(object) +} + +fn assert_record_shape( + value: &Value, + readable_fields: &BTreeSet, + expected_fields: &Map, +) -> Result<(), FixtureError> { + let object = exact_object(value, &["id", "revision", "data"])?; + assert_record_members(object, readable_fields, expected_fields) +} + +fn assert_record_members( + object: &Map, + readable_fields: &BTreeSet, + expected_fields: &Map, +) -> Result<(), FixtureError> { + let identifier = object + .get("id") + .and_then(Value::as_str) + .ok_or(FixtureError::ResponseShapeRefused)?; + let revision = object + .get("revision") + .and_then(Value::as_u64) + .ok_or(FixtureError::ResponseShapeRefused)?; + let data = object + .get("data") + .and_then(Value::as_object) + .ok_or(FixtureError::ResponseShapeRefused)?; + if revision == 0 + || !uuid::Uuid::parse_str(identifier).is_ok_and(|parsed| parsed.to_string() == identifier) + || !data + .keys() + .all(|field| readable_fields.contains(field.as_str())) + || expected_fields + .iter() + .any(|(field, expected)| data.get(field) != Some(expected)) + { + return Err(FixtureError::ExpectationMismatch); + } + Ok(()) +} + +fn problem_contract(status: u16, code: Option<&str>) -> Option<(&'static str, &'static str)> { + match (status, code?) { + (400, "query.invalid") => Some(("Bad Request", "The query request is invalid.")), + (400, "request.invalid") => Some(("Bad Request", "The mutation request is invalid.")), + (404, "resource.not_found") => Some(("Not Found", "The requested resource was not found.")), + (409, "mutation.conflict") => { + Some(("Conflict", "The mutation conflicts with current state.")) + } + (409, "idempotency.conflict") => Some(( + "Conflict", + "The idempotency key is bound to another request.", + )), + (412, "precondition.failed") => { + Some(("Precondition Failed", "The mutation precondition failed.")) + } + (415, "unsupported.media_type") => Some(( + "Unsupported Media Type", + "The request media type is not supported.", + )), + (428, "precondition.required") => Some(( + "Precondition Required", + "The mutation precondition is required.", + )), + (503, "source.unavailable") => Some(( + "Service Unavailable", + "The Registry data service is unavailable.", + )), + (503, "service.unavailable") => Some(( + "Service Unavailable", + "The Registry mutation service is unavailable.", + )), + _ => None, + } +} + +/// One exact source file supplied to the postgres-test-only candidate +/// validator. Paths are not authority: they must exactly match the +/// already-verified package closure. +#[cfg(any(test, feature = "postgres-test"))] +pub struct FixtureSourceFile<'a> { + pub path: &'a str, + pub bytes: &'a [u8], +} + +/// One exact module source in package closure order. +#[cfg(any(test, feature = "postgres-test"))] +pub struct FixtureModuleSource<'a> { + pub id: &'a str, + pub path: &'a str, + pub bytes: &'a [u8], +} + +/// Source-only candidate input. Deployment identity, compiler revision, +/// sequence, prior revision, PostgreSQL version, and schema fingerprint are +/// deliberately absent and cannot be asserted by a tooling caller. +#[cfg(any(test, feature = "postgres-test"))] +pub struct SchemaTestSources<'a> { + pub project: FixtureSourceFile<'a>, + pub modules: &'a [FixtureModuleSource<'a>], + pub migration_plan: FixtureSourceFile<'a>, +} + +/// Exact database facts captured by the sealed runner that executed the +/// journeys. There is intentionally no public constructor. +#[derive(Clone, Eq, PartialEq)] +struct SchemaTestExecutionFacts { + current_database: String, + package_id: String, + environment: String, + instance_id: String, + package_revision: String, + database_id: String, + sequence: u64, + schema_fingerprint: String, + postgres_major: u16, + maintenance_status: String, +} + +impl SchemaTestExecutionFacts { + fn from_database_snapshot(snapshot: DatabaseExecutionSnapshot) -> Self { + Self { + current_database: snapshot.current_database, + package_id: snapshot.package_id, + environment: snapshot.environment, + instance_id: snapshot.instance_id, + package_revision: snapshot.package_revision, + database_id: snapshot.database_id, + sequence: snapshot.sequence, + schema_fingerprint: snapshot.schema_fingerprint, + postgres_major: snapshot.postgres_major, + maintenance_status: snapshot.maintenance_status, + } + } +} + +struct DatabaseExecutionSnapshot { + current_database: String, + package_id: String, + environment: String, + instance_id: String, + database_id: String, + package_revision: String, + sequence: u64, + schema_fingerprint: String, + postgres_major: u16, + maintenance_status: String, +} + +/// A Production-recompiled, VerifiedPackage-bound candidate paired with facts +/// from the same sealed database execution context. Fields and constructors +/// stay private so this type cannot become a self-asserted authority bag. +pub struct ValidatedSchemaTestCandidate { + registry_revision: String, + project_source_revision: String, + compiler_source_revision: String, + environment: String, + instance_id: String, + database_id: String, + sequence: u64, + prior_package_revision: Option, + target_package_revision: String, + source_closure_sha256: String, + migration_plan_sha256: String, + signing_input_sha256: String, + postgres_major: u16, + target_managed_schema_fingerprint: String, +} + +impl fmt::Debug for ValidatedSchemaTestCandidate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ValidatedSchemaTestCandidate") + .field("sequence", &self.sequence) + .field("postgres_major", &self.postgres_major) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SchemaTestReceipt { + api_version: String, + kind: String, + registry_revision: String, + project_source_revision: String, + compiler_source_revision: String, + environment: String, + instance_id: String, + database_id: String, + sequence: u64, + #[serde(skip_serializing_if = "Option::is_none")] + prior_package_revision: Option, + candidate_package_revision: String, + source_closure_sha256: String, + migration_plan_sha256: String, + signing_input_sha256: String, + postgres_major: u16, + target_managed_schema_fingerprint: String, + successful_journey_ids: Vec, + journey_file_sha256: String, +} + +impl fmt::Debug for SchemaTestReceipt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchemaTestReceipt") + .field("sequence", &self.sequence) + .field("postgres_major", &self.postgres_major) + .field("journey_count", &self.successful_journey_ids.len()) + .finish_non_exhaustive() + } +} + +impl SchemaTestReceipt { + pub fn canonical_bytes(&self) -> Result, FixtureError> { + canonicalize_json( + &serde_json::to_value(self).map_err(|_| FixtureError::ReceiptShapeRefused)?, + ) + .map_err(|_| FixtureError::ReceiptShapeRefused) + } + + pub fn successful_journey_ids(&self) -> &[String] { + &self.successful_journey_ids + } +} + +/// Build a receipt only from the unforgeable all-success token and exact +/// candidate bytes. The receipt intentionally carries no signature, readiness, +/// activation intent, or authorization claim. +fn build_schema_test_receipt( + candidate: &ValidatedSchemaTestCandidate, + suite: &ValidatedFixtureJourneys, + successful: &SuccessfulFixtureJourneys, +) -> Result { + if successful.registry_revision != suite.registry_revision + || successful.file_sha256 != suite.file_sha256 + || successful.journey_ids != sorted_journey_ids(suite) + || successful.candidate_binding_sha256 != candidate_binding_sha256(candidate) + { + return Err(FixtureError::ReceiptBindingRefused); + } + Ok(receipt_for_candidate(candidate, suite)) +} + +/// Parse one canonical receipt and rederive every field from exact candidate +/// and journey bytes. This permits later package assembly to require the +/// evidence without treating the receipt as authority. +#[cfg(any(test, feature = "postgres-test"))] +fn revalidate_schema_test_receipt( + bytes: &[u8], + candidate: &ValidatedSchemaTestCandidate, + suite: &ValidatedFixtureJourneys, +) -> Result { + let receipt = parse_canonical_schema_test_receipt(bytes)?; + if receipt != receipt_for_candidate(candidate, suite) { + return Err(FixtureError::ReceiptBindingRefused); + } + Ok(receipt) +} + +/// Validate a non-authorizing schema-test receipt against the exact unsigned +/// candidate package and reviewed journey suite. Every authoritative field is +/// rederived from package bytes. `postgresMajor` remains execution metadata, +/// but only a supported value can survive the exact receipt comparison. +pub fn validate_schema_test_receipt_for_package( + bytes: &[u8], + package: &PreparedPackage, + suite: &ValidatedFixtureJourneys, +) -> Result { + let receipt = parse_canonical_schema_test_receipt(bytes)?; + let (candidate, _) = + derive_prepared_schema_test_candidate(package, suite, receipt.postgres_major)?; + if receipt != receipt_for_candidate(&candidate, suite) { + return Err(FixtureError::ReceiptBindingRefused); + } + Ok(receipt) +} + +fn parse_canonical_schema_test_receipt(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_RECEIPT_BYTES { + return Err(FixtureError::ReceiptShapeRefused); + } + let value = parse_json_strict(bytes).map_err(|_| FixtureError::ReceiptShapeRefused)?; + let canonical = canonicalize_json(&value).map_err(|_| FixtureError::ReceiptShapeRefused)?; + if canonical != bytes { + return Err(FixtureError::ReceiptShapeRefused); + } + let receipt: SchemaTestReceipt = + serde_json::from_value(value).map_err(|_| FixtureError::ReceiptShapeRefused)?; + if !(MIN_SUPPORTED_POSTGRES_MAJOR..=MAX_SUPPORTED_POSTGRES_MAJOR) + .contains(&receipt.postgres_major) + { + return Err(FixtureError::ReceiptBindingRefused); + } + Ok(receipt) +} + +fn receipt_for_candidate( + candidate: &ValidatedSchemaTestCandidate, + suite: &ValidatedFixtureJourneys, +) -> SchemaTestReceipt { + SchemaTestReceipt { + api_version: RECEIPT_API_VERSION.to_owned(), + kind: RECEIPT_KIND.to_owned(), + registry_revision: candidate.registry_revision.clone(), + project_source_revision: candidate.project_source_revision.clone(), + compiler_source_revision: candidate.compiler_source_revision.to_owned(), + environment: candidate.environment.to_owned(), + instance_id: candidate.instance_id.clone(), + database_id: candidate.database_id.clone(), + sequence: candidate.sequence, + prior_package_revision: candidate.prior_package_revision.clone(), + candidate_package_revision: candidate.target_package_revision.clone(), + source_closure_sha256: candidate.source_closure_sha256.clone(), + migration_plan_sha256: candidate.migration_plan_sha256.clone(), + signing_input_sha256: candidate.signing_input_sha256.clone(), + postgres_major: candidate.postgres_major, + target_managed_schema_fingerprint: candidate.target_managed_schema_fingerprint.clone(), + successful_journey_ids: sorted_journey_ids(suite), + journey_file_sha256: suite.file_sha256.clone(), + } +} + +#[cfg(any(test, feature = "postgres-test"))] +fn validate_schema_test_candidate( + package: &VerifiedPackage, + sources: &SchemaTestSources<'_>, + execution: &SchemaTestExecutionFacts, + suite: &ValidatedFixtureJourneys, +) -> Result { + let manifest = package.manifest(); + if package.registry().revision() != suite.registry_revision + || manifest.compiler.profile != PackageCompileProfile::Production + || sources.project.path != manifest.sources.project + || sources.project.bytes.is_empty() + || sources.project.bytes.len() > MAX_SOURCE_BYTES + || sources.migration_plan.path != "database/migration-plan.json" + || sources.migration_plan.bytes.is_empty() + || sources.migration_plan.bytes.len() > MAX_SOURCE_BYTES + || execution.package_id != manifest.package_id + || execution.environment != manifest.environment + || execution.instance_id != manifest.instance_id + || execution.package_revision != manifest.package_revision + || execution.database_id != manifest.database_id + || execution.sequence != manifest.sequence + || execution.schema_fingerprint != manifest.schema_fingerprint + || execution.maintenance_status != "ready" + || execution.current_database.is_empty() + || execution.current_database.len() > MAX_BINDING_BYTES + || !(MIN_SUPPORTED_POSTGRES_MAJOR..=MAX_SUPPORTED_POSTGRES_MAJOR) + .contains(&execution.postgres_major) + || !manifest_file_matches( + manifest, + PackageFileRole::SourceProject, + sources.project.path, + sources.project.bytes, + ) + || manifest.sources.fixture_journeys != FIXTURE_JOURNEYS_PATH + || !manifest_file_matches( + manifest, + PackageFileRole::FixtureJourneys, + FIXTURE_JOURNEYS_PATH, + &suite.file_bytes, + ) + { + return Err(FixtureError::CandidateBindingRefused); + } + + let project = parse_project_yaml(sources.project.bytes) + .map_err(|_| FixtureError::CandidateBindingRefused)?; + if sources.modules.len() != manifest.sources.modules.len() + || project.modules.len() != sources.modules.len() + { + return Err(FixtureError::CandidateBindingRefused); + } + let mut modules = Vec::with_capacity(sources.modules.len()); + for ((captured, locked), source) in manifest + .sources + .modules + .iter() + .zip(&project.modules) + .zip(sources.modules) + { + if source.id != captured.id + || source.path != captured.path + || source.id != locked.id + || source.bytes.is_empty() + || source.bytes.len() > MAX_SOURCE_BYTES + || !manifest_file_matches( + manifest, + PackageFileRole::SourceModule, + source.path, + source.bytes, + ) + { + return Err(FixtureError::CandidateBindingRefused); + } + let module = + parse_module_yaml(source.bytes).map_err(|_| FixtureError::CandidateBindingRefused)?; + let digest = module_digest(&module); + if module.id != source.id + || module.version != locked.version + || locked.digest.as_deref() != Some(digest.as_str()) + { + return Err(FixtureError::CandidateBindingRefused); + } + modules.push(module); + } + + let compiled = compile_project(&project, &modules, CompileProfile::Production) + .map_err(|_| FixtureError::CandidateBindingRefused)?; + if compiled != *package.registry() { + return Err(FixtureError::CandidateBindingRefused); + } + let project_identity = compiled + .package() + .ok_or(FixtureError::CandidateBindingRefused)?; + if project_identity.environment != manifest.environment + || project_identity.instance_id != manifest.instance_id + || project_identity.sequence != manifest.sequence + || manifest.migration_plan.from_revision != manifest.prior_revision + || manifest.migration_plan.reviewed_descriptors.is_empty() + != package.reviewed_migration_plan().is_none() + { + return Err(FixtureError::CandidateBindingRefused); + } + let canonical_migration_plan = canonicalize_json( + &serde_json::to_value(&manifest.migration_plan) + .map_err(|_| FixtureError::CandidateBindingRefused)?, + ) + .map_err(|_| FixtureError::CandidateBindingRefused)?; + if canonical_migration_plan != sources.migration_plan.bytes { + return Err(FixtureError::CandidateBindingRefused); + } + let migration_file = manifest + .files + .iter() + .find(|file| file.role == PackageFileRole::MigrationPlan) + .ok_or(FixtureError::CandidateBindingRefused)?; + if migration_file.path != sources.migration_plan.path + || migration_file.size != sources.migration_plan.bytes.len() as u64 + || migration_file.sha256 != sha256(sources.migration_plan.bytes) + { + return Err(FixtureError::CandidateBindingRefused); + } + + Ok(ValidatedSchemaTestCandidate { + registry_revision: compiled.revision().to_owned(), + project_source_revision: project_identity.source_revision.clone(), + compiler_source_revision: manifest.compiler.source_revision.clone(), + environment: manifest.environment.clone(), + instance_id: manifest.instance_id.clone(), + database_id: manifest.database_id.clone(), + sequence: manifest.sequence, + prior_package_revision: manifest.prior_revision.clone(), + target_package_revision: manifest.package_revision.clone(), + source_closure_sha256: source_closure_sha256(sources, suite), + migration_plan_sha256: sha256(sources.migration_plan.bytes), + signing_input_sha256: sha256( + &package_canonical_signed_bytes(manifest) + .map_err(|_| FixtureError::CandidateBindingRefused)?, + ), + postgres_major: execution.postgres_major, + target_managed_schema_fingerprint: execution.schema_fingerprint.clone(), + }) +} + +fn validate_prepared_schema_test_candidate( + package: &PreparedPackage, + execution: &SchemaTestExecutionFacts, + suite: &ValidatedFixtureJourneys, +) -> Result<(ValidatedSchemaTestCandidate, CompiledRegistry), FixtureError> { + let manifest = package.manifest(); + if execution.package_id != manifest.package_id + || execution.environment != manifest.environment + || execution.instance_id != manifest.instance_id + || execution.package_revision != manifest.package_revision + || execution.database_id != manifest.database_id + || execution.sequence != manifest.sequence + || execution.schema_fingerprint != manifest.schema_fingerprint + || execution.maintenance_status != "ready" + || execution.current_database.is_empty() + || execution.current_database.len() > MAX_BINDING_BYTES + { + return Err(FixtureError::CandidateBindingRefused); + } + + derive_prepared_schema_test_candidate(package, suite, execution.postgres_major) +} + +fn derive_prepared_schema_test_candidate( + package: &PreparedPackage, + suite: &ValidatedFixtureJourneys, + postgres_major: u16, +) -> Result<(ValidatedSchemaTestCandidate, CompiledRegistry), FixtureError> { + let manifest = package.manifest(); + if manifest.compiler.profile != PackageCompileProfile::Production + || !(MIN_SUPPORTED_POSTGRES_MAJOR..=MAX_SUPPORTED_POSTGRES_MAJOR).contains(&postgres_major) + || manifest.sources.fixture_journeys != FIXTURE_JOURNEYS_PATH + || !prepared_files_match_manifest(package) + { + return Err(FixtureError::CandidateBindingRefused); + } + + let files = package.file_bytes(); + let project_bytes = files + .get(&manifest.sources.project) + .ok_or(FixtureError::CandidateBindingRefused)?; + if project_bytes.is_empty() || project_bytes.len() > MAX_SOURCE_BYTES { + return Err(FixtureError::CandidateBindingRefused); + } + let project = + parse_project_yaml(project_bytes).map_err(|_| FixtureError::CandidateBindingRefused)?; + if project.modules.len() != manifest.sources.modules.len() { + return Err(FixtureError::CandidateBindingRefused); + } + let mut modules = Vec::with_capacity(manifest.sources.modules.len()); + for (locked, captured) in project.modules.iter().zip(&manifest.sources.modules) { + if locked.id != captured.id { + return Err(FixtureError::CandidateBindingRefused); + } + let module_bytes = files + .get(&captured.path) + .ok_or(FixtureError::CandidateBindingRefused)?; + if module_bytes.is_empty() || module_bytes.len() > MAX_SOURCE_BYTES { + return Err(FixtureError::CandidateBindingRefused); + } + let module = + parse_module_yaml(module_bytes).map_err(|_| FixtureError::CandidateBindingRefused)?; + if module.id != captured.id + || module.version != locked.version + || locked.digest.as_deref() != Some(module_digest(&module).as_str()) + { + return Err(FixtureError::CandidateBindingRefused); + } + modules.push(module); + } + let compiled = compile_project(&project, &modules, CompileProfile::Production) + .map_err(|_| FixtureError::CandidateBindingRefused)?; + let project_identity = compiled + .package() + .ok_or(FixtureError::CandidateBindingRefused)?; + if compiled != *package.registry() + || compiled.revision() != suite.registry_revision + || compiled.registry_id() != manifest.package_id + || project_identity.environment != manifest.environment + || project_identity.instance_id != manifest.instance_id + || project_identity.sequence != manifest.sequence + || manifest.migration_plan.from_revision != manifest.prior_revision + { + return Err(FixtureError::CandidateBindingRefused); + } + let packaged_journeys = files + .get(FIXTURE_JOURNEYS_PATH) + .ok_or(FixtureError::CandidateBindingRefused)?; + if packaged_journeys.as_slice() != suite.file_bytes.as_slice() + || sha256(packaged_journeys) != suite.file_sha256 + || !manifest_file_matches( + manifest, + PackageFileRole::FixtureJourneys, + FIXTURE_JOURNEYS_PATH, + packaged_journeys, + ) + { + return Err(FixtureError::CandidateBindingRefused); + } + let migration_plan_bytes = files + .get("database/migration-plan.json") + .ok_or(FixtureError::CandidateBindingRefused)?; + let canonical_migration_plan = canonicalize_json( + &serde_json::to_value(&manifest.migration_plan) + .map_err(|_| FixtureError::CandidateBindingRefused)?, + ) + .map_err(|_| FixtureError::CandidateBindingRefused)?; + if migration_plan_bytes.as_slice() != canonical_migration_plan.as_slice() { + return Err(FixtureError::CandidateBindingRefused); + } + + Ok(( + ValidatedSchemaTestCandidate { + registry_revision: compiled.revision().to_owned(), + project_source_revision: project_identity.source_revision.clone(), + compiler_source_revision: manifest.compiler.source_revision.clone(), + environment: manifest.environment.clone(), + instance_id: manifest.instance_id.clone(), + database_id: manifest.database_id.clone(), + sequence: manifest.sequence, + prior_package_revision: manifest.prior_revision.clone(), + target_package_revision: manifest.package_revision.clone(), + source_closure_sha256: source_closure_sha256_from_package(package)?, + migration_plan_sha256: sha256(migration_plan_bytes), + signing_input_sha256: sha256(package.canonical_signed_bytes()), + postgres_major, + target_managed_schema_fingerprint: manifest.schema_fingerprint.clone(), + }, + compiled, + )) +} + +fn prepared_files_match_manifest(package: &PreparedPackage) -> bool { + let files = package.file_bytes(); + if files.len() != package.manifest().files.len() { + return false; + } + package.manifest().files.iter().all(|file| { + files + .get(&file.path) + .is_some_and(|bytes| file.size == bytes.len() as u64 && file.sha256 == sha256(bytes)) + }) +} + +fn manifest_file_matches( + manifest: &crate::package::PackageManifest, + role: PackageFileRole, + path: &str, + bytes: &[u8], +) -> bool { + manifest.files.iter().any(|file| { + file.role == role + && file.path == path + && file.size == bytes.len() as u64 + && file.sha256 == sha256(bytes) + }) +} + +#[cfg(any(test, feature = "postgres-test"))] +fn source_closure_sha256( + sources: &SchemaTestSources<'_>, + suite: &ValidatedFixtureJourneys, +) -> String { + let mut digest = Sha256::new(); + digest.update(b"registry-server-schema-test-source-closure-v2\0"); + digest_part( + &mut digest, + sources.project.path.as_bytes(), + sources.project.bytes, + ); + for source in sources.modules { + digest_part(&mut digest, source.id.as_bytes(), source.path.as_bytes()); + digest_part(&mut digest, source.path.as_bytes(), source.bytes); + } + digest_part( + &mut digest, + FIXTURE_JOURNEYS_PATH.as_bytes(), + &suite.file_bytes, + ); + encoded_sha256(digest.finalize().as_slice()) +} + +fn source_closure_sha256_from_package(package: &PreparedPackage) -> Result { + let manifest = package.manifest(); + let files = package.file_bytes(); + let project_bytes = files + .get(&manifest.sources.project) + .ok_or(FixtureError::CandidateBindingRefused)?; + let mut digest = Sha256::new(); + digest.update(b"registry-server-schema-test-source-closure-v2\0"); + digest_part( + &mut digest, + manifest.sources.project.as_bytes(), + project_bytes, + ); + for module in &manifest.sources.modules { + let bytes = files + .get(&module.path) + .ok_or(FixtureError::CandidateBindingRefused)?; + digest_part(&mut digest, module.id.as_bytes(), module.path.as_bytes()); + digest_part(&mut digest, module.path.as_bytes(), bytes); + } + let journeys = files + .get(FIXTURE_JOURNEYS_PATH) + .ok_or(FixtureError::CandidateBindingRefused)?; + digest_part(&mut digest, FIXTURE_JOURNEYS_PATH.as_bytes(), journeys); + Ok(encoded_sha256(digest.finalize().as_slice())) +} + +fn candidate_binding_sha256(candidate: &ValidatedSchemaTestCandidate) -> String { + let mut digest = Sha256::new(); + digest.update(b"registry-server-schema-test-candidate-binding-v1\0"); + for value in [ + candidate.registry_revision.as_bytes(), + candidate.target_package_revision.as_bytes(), + candidate.source_closure_sha256.as_bytes(), + candidate.migration_plan_sha256.as_bytes(), + candidate.signing_input_sha256.as_bytes(), + candidate.target_managed_schema_fingerprint.as_bytes(), + ] { + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); + } + digest.update(candidate.postgres_major.to_be_bytes()); + encoded_sha256(digest.finalize().as_slice()) +} + +fn digest_part(digest: &mut Sha256, name: &[u8], bytes: &[u8]) { + digest.update((name.len() as u64).to_be_bytes()); + digest.update(name); + digest.update((bytes.len() as u64).to_be_bytes()); + digest.update(bytes); +} + +fn sorted_journey_ids(suite: &ValidatedFixtureJourneys) -> Vec { + let mut ids = suite + .journeys + .iter() + .map(|journey| journey.id.clone()) + .collect::>(); + ids.sort(); + ids +} + +fn valid_stable_id(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= MAX_IDENTIFIER_BYTES + && bytes[0].is_ascii_lowercase() + && bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') +} + +fn operation_method(operation: Operation) -> HttpMethod { + match operation { + Operation::Create | Operation::Batch => HttpMethod::Post, + Operation::Get | Operation::List | Operation::Revisions => HttpMethod::Get, + Operation::Patch => HttpMethod::Patch, + Operation::Tombstone => HttpMethod::Delete, + } +} + +fn sha256(bytes: &[u8]) -> String { + encoded_sha256(Sha256::digest(bytes).as_slice()) +} + +fn encoded_sha256(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut result = String::with_capacity(71); + result.push_str("sha256:"); + for byte in bytes { + result.push(char::from(HEX[usize::from(byte >> 4)])); + result.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::package::{ + load_package, prepare_package, PackageBuildRequest, PackageIntent, PackageLoadContext, + PackageMigrationPlanInput, PackageModuleSource, PackageSourceFile, SignaturePolicy, + }; + + const PROJECT_TEMPLATE: &[u8] = + include_bytes!("../tests/fixtures/fixture-tooling/project.yaml"); + const MODULE_SOURCE: &[u8] = include_bytes!("../tests/fixtures/fixture-tooling/module.yaml"); + const JOURNEY_SOURCE: &[u8] = include_bytes!("../tests/fixtures/fixture-tooling/journeys.yaml"); + const COMPILER_SOURCE_REVISION: &str = "fixture-project-source"; + const DATABASE_ID: &str = "fixture-database"; + const DIGEST_A: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const DIGEST_B: &str = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + #[tokio::test] + async fn fixture_test_receipt_is_deterministic_and_bound_to_the_exact_candidate() { + let fixture = package_fixture(DIGEST_A); + let suite = validate_fixture_journeys(JOURNEY_SOURCE, fixture.package.registry()) + .expect("strict suite validates"); + let execution = execution_facts(&fixture.package, DIGEST_A, 16); + let candidate = validated_candidate(&fixture, &execution, &suite) + .expect("verified package and database facts close the candidate"); + let successful = execute_scripted(&suite, &candidate, ScriptMode::Success) + .await + .expect("closed responses pass"); + + let first = build_schema_test_receipt(&candidate, &suite, &successful) + .expect("complete run builds receipt"); + let second = build_schema_test_receipt(&candidate, &suite, &successful) + .expect("identical run facts build again"); + let bytes = first.canonical_bytes().expect("receipt canonicalizes"); + assert_eq!(bytes, second.canonical_bytes().expect("receipt repeats")); + assert_eq!(first.successful_journey_ids(), ["widget-lifecycle"]); + assert_eq!( + revalidate_schema_test_receipt(&bytes, &candidate, &suite) + .expect("exact receipt revalidates") + .canonical_bytes() + .expect("revalidated receipt canonicalizes"), + bytes + ); + assert_eq!( + validate_schema_test_receipt_for_package(&bytes, &fixture.prepared, &suite) + .expect("ordinary package-bound validator accepts the exact receipt") + .canonical_bytes() + .expect("validated receipt canonicalizes"), + bytes + ); + + assert_candidate_build_substitutions_are_refused(&fixture, &suite, &execution); + assert_receipt_substitutions_are_refused(&bytes, &candidate, &suite); + assert_public_receipt_negatives(&bytes, &fixture, &suite); + assert_closed_response_negatives(&suite, &candidate).await; + } + + #[test] + fn schema_test_credentials_require_exact_tuple_and_authorization_mode_preflight() { + let fixture = package_fixture(DIGEST_A); + let suite = validate_fixture_journeys(JOURNEY_SOURCE, fixture.package.registry()) + .expect("strict suite validates"); + let exact = || protected_credential_bindings(&suite); + SchemaTestCredentialBindings::new(&suite, exact()) + .expect("one bearer binding per protected step passes pure preflight"); + + let mut missing = exact(); + missing.pop(); + assert_eq!( + SchemaTestCredentialBindings::new(&suite, missing).unwrap_err(), + FixtureError::RequestConstructionRefused + ); + + let mut extra = exact(); + extra.push(SchemaTestCredentialBinding::bearer( + "widget-lifecycle", + "undeclared-step", + Zeroizing::new("opaque.extra.token".to_owned()), + )); + assert_eq!( + SchemaTestCredentialBindings::new(&suite, extra).unwrap_err(), + FixtureError::RequestConstructionRefused + ); + + let mut duplicate = exact(); + duplicate.pop(); + duplicate.push(SchemaTestCredentialBinding::bearer( + "widget-lifecycle", + "create-widget", + Zeroizing::new("opaque.duplicate.token".to_owned()), + )); + assert_eq!( + SchemaTestCredentialBindings::new(&suite, duplicate).unwrap_err(), + FixtureError::RequestConstructionRefused + ); + + let mut missing_bearer = exact(); + missing_bearer[0] = + SchemaTestCredentialBinding::anonymous("widget-lifecycle", "create-widget"); + assert_eq!( + SchemaTestCredentialBindings::new(&suite, missing_bearer).unwrap_err(), + FixtureError::RequestConstructionRefused + ); + + let mut anonymous_suite = suite.clone(); + anonymous_suite.journeys[0].steps[0].profile.anonymous = true; + let mut bearer_for_anonymous = exact(); + assert_eq!( + SchemaTestCredentialBindings::new(&anonymous_suite, bearer_for_anonymous).unwrap_err(), + FixtureError::RequestConstructionRefused + ); + bearer_for_anonymous = exact(); + bearer_for_anonymous[0] = + SchemaTestCredentialBinding::anonymous("widget-lifecycle", "create-widget"); + SchemaTestCredentialBindings::new(&anonymous_suite, bearer_for_anonymous) + .expect("explicit anonymous mode matches the anonymous step"); + + for token in [ + "", + "contains a space", + "has/slash", + "singlecomponent", + "two.parts", + "too.many.parts.here", + ".leading.parts", + "trailing.parts.", + ] { + let mut malformed = exact(); + malformed[0] = SchemaTestCredentialBinding::bearer( + "widget-lifecycle", + "create-widget", + Zeroizing::new(token.to_owned()), + ); + let error = SchemaTestCredentialBindings::new(&suite, malformed).unwrap_err(); + assert_eq!(error, FixtureError::RequestConstructionRefused); + if !token.is_empty() { + assert!(!format!("{error:?}").contains(token)); + } + } + let mut oversized = exact(); + oversized[0] = SchemaTestCredentialBinding::bearer( + "widget-lifecycle", + "create-widget", + Zeroizing::new(format!("{}.b.c", "a".repeat(MAX_BEARER_TOKEN_BYTES))), + ); + assert_eq!( + SchemaTestCredentialBindings::new(&suite, oversized).unwrap_err(), + FixtureError::RequestConstructionRefused + ); + let secret = "secret.canary.token"; + let debug = format!( + "{:?}", + SchemaTestCredentialBinding::bearer( + "widget-lifecycle", + "create-widget", + Zeroizing::new(secret.to_owned()), + ) + ); + assert!(!debug.contains(secret)); + } + + #[test] + fn schema_test_receipt_binds_the_exact_signing_policy_without_granting_authority() { + let prepared = production_prepared_package("fixture-signer-one"); + let suite = validate_fixture_journeys(JOURNEY_SOURCE, prepared.registry()) + .expect("Production journey suite validates"); + let (candidate, _) = derive_prepared_schema_test_candidate(&prepared, &suite, 16) + .expect("unsigned candidate derives without activation authority"); + let bytes = receipt_for_candidate(&candidate, &suite) + .canonical_bytes() + .expect("receipt canonicalizes"); + validate_schema_test_receipt_for_package(&bytes, &prepared, &suite) + .expect("exact unsigned candidate revalidates its receipt"); + + let changed_policy = production_prepared_package("fixture-signer-two"); + assert_eq!( + validate_schema_test_receipt_for_package(&bytes, &changed_policy, &suite), + Err(FixtureError::ReceiptBindingRefused) + ); + } + + fn protected_credential_bindings( + suite: &ValidatedFixtureJourneys, + ) -> Vec { + suite + .journeys + .iter() + .flat_map(|journey| { + journey.steps.iter().map(move |step| { + SchemaTestCredentialBinding::bearer( + journey.id.clone(), + step.id.clone(), + Zeroizing::new("opaque.fixture.token".to_owned()), + ) + }) + }) + .collect() + } + + fn assert_public_receipt_negatives( + bytes: &[u8], + fixture: &PackageFixture, + suite: &ValidatedFixtureJourneys, + ) { + let noncanonical = [bytes, b"\n"].concat(); + assert_eq!( + validate_schema_test_receipt_for_package(&noncanonical, &fixture.prepared, suite), + Err(FixtureError::ReceiptShapeRefused) + ); + + let mut unknown: Value = serde_json::from_slice(bytes).expect("receipt parses"); + unknown["unknownAuthority"] = json!(true); + let unknown = canonicalize_json(&unknown).expect("unknown receipt canonicalizes"); + assert_eq!( + validate_schema_test_receipt_for_package(&unknown, &fixture.prepared, suite), + Err(FixtureError::ReceiptShapeRefused) + ); + assert_eq!( + validate_schema_test_receipt_for_package( + &vec![b'x'; MAX_RECEIPT_BYTES + 1], + &fixture.prepared, + suite, + ), + Err(FixtureError::ReceiptShapeRefused) + ); + + let changed_journey_bytes = [JOURNEY_SOURCE, b"\n# reviewed change\n"].concat(); + let changed_suite = + validate_fixture_journeys(&changed_journey_bytes, fixture.package.registry()) + .expect("semantically equivalent changed journey validates"); + assert_eq!( + validate_schema_test_receipt_for_package(bytes, &fixture.prepared, &changed_suite), + Err(FixtureError::CandidateBindingRefused) + ); + + let rehashed_substitution = package_fixture_with_journeys(DIGEST_A, &changed_journey_bytes); + assert_eq!( + validate_schema_test_receipt_for_package(bytes, &rehashed_substitution.prepared, suite,), + Err(FixtureError::CandidateBindingRefused) + ); + + let changed_fingerprint = package_fixture(DIGEST_B); + assert_eq!( + validate_schema_test_receipt_for_package(bytes, &changed_fingerprint.prepared, suite), + Err(FixtureError::ReceiptBindingRefused) + ); + + for (field, replacement) in [ + ("apiVersion", json!("registry.invalid/v2")), + ("kind", json!("ActivationApproval")), + ("registryRevision", json!(DIGEST_B)), + ("projectSourceRevision", json!("another-source")), + ("compilerSourceRevision", json!("another-compiler")), + ("candidatePackageRevision", json!(DIGEST_B)), + ("sourceClosureSha256", json!(DIGEST_B)), + ("migrationPlanSha256", json!(DIGEST_B)), + ("signingInputSha256", json!(DIGEST_B)), + ("targetManagedSchemaFingerprint", json!(DIGEST_B)), + ("environment", json!("staging")), + ("instanceId", json!("another-instance")), + ("databaseId", json!("another-database")), + ("sequence", json!(2)), + ("priorPackageRevision", json!(DIGEST_B)), + ("postgresMajor", json!(19)), + ("successfulJourneyIds", json!(["another-journey"])), + ("journeyFileSha256", json!(DIGEST_B)), + ] { + let mut changed: Value = serde_json::from_slice(bytes).expect("receipt parses"); + changed[field] = replacement; + let changed = canonicalize_json(&changed).expect("changed receipt canonicalizes"); + assert!(matches!( + validate_schema_test_receipt_for_package(&changed, &fixture.prepared, suite), + Err(FixtureError::ReceiptBindingRefused) + )); + } + } + + fn assert_candidate_build_substitutions_are_refused( + fixture: &PackageFixture, + suite: &ValidatedFixtureJourneys, + execution: &SchemaTestExecutionFacts, + ) { + let changed_project = [fixture.project.as_slice(), b"\n"].concat(); + let modules = [FixtureModuleSource { + id: "fixture-core", + path: "sources/modules/fixture-core.yaml", + bytes: &fixture.module, + }]; + let changed_project_sources = SchemaTestSources { + project: FixtureSourceFile { + path: "sources/project.yaml", + bytes: &changed_project, + }, + modules: &modules, + migration_plan: FixtureSourceFile { + path: "database/migration-plan.json", + bytes: &fixture.migration_plan, + }, + }; + assert!(matches!( + validate_schema_test_candidate( + &fixture.package, + &changed_project_sources, + execution, + suite, + ), + Err(FixtureError::CandidateBindingRefused) + )); + + let changed_module = [fixture.module.as_slice(), b"\n"].concat(); + let changed_modules = [FixtureModuleSource { + id: "fixture-core", + path: "sources/modules/fixture-core.yaml", + bytes: &changed_module, + }]; + let changed_module_sources = SchemaTestSources { + project: FixtureSourceFile { + path: "sources/project.yaml", + bytes: &fixture.project, + }, + modules: &changed_modules, + migration_plan: FixtureSourceFile { + path: "database/migration-plan.json", + bytes: &fixture.migration_plan, + }, + }; + assert!(matches!( + validate_schema_test_candidate( + &fixture.package, + &changed_module_sources, + execution, + suite, + ), + Err(FixtureError::CandidateBindingRefused) + )); + + let changed_digest_project = String::from_utf8(fixture.project.clone()) + .expect("fixture is UTF-8") + .replacen( + &module_digest(&parse_module_yaml(&fixture.module).unwrap()), + DIGEST_B, + 1, + ) + .into_bytes(); + let changed_digest_sources = SchemaTestSources { + project: FixtureSourceFile { + path: "sources/project.yaml", + bytes: &changed_digest_project, + }, + modules: &modules, + migration_plan: FixtureSourceFile { + path: "database/migration-plan.json", + bytes: &fixture.migration_plan, + }, + }; + assert!(matches!( + validate_schema_test_candidate( + &fixture.package, + &changed_digest_sources, + execution, + suite, + ), + Err(FixtureError::CandidateBindingRefused) + )); + + let changed_package = package_fixture(DIGEST_B); + assert!(matches!( + validated_candidate(&changed_package, execution, suite), + Err(FixtureError::CandidateBindingRefused) + )); + + let changed_plan = [fixture.migration_plan.as_slice(), b"\n"].concat(); + let changed_plan_sources = SchemaTestSources { + project: FixtureSourceFile { + path: "sources/project.yaml", + bytes: &fixture.project, + }, + modules: &modules, + migration_plan: FixtureSourceFile { + path: "database/migration-plan.json", + bytes: &changed_plan, + }, + }; + assert!(matches!( + validate_schema_test_candidate( + &fixture.package, + &changed_plan_sources, + execution, + suite, + ), + Err(FixtureError::CandidateBindingRefused) + )); + + for changed_execution in [ + SchemaTestExecutionFacts { + schema_fingerprint: DIGEST_B.to_owned(), + ..execution.clone() + }, + SchemaTestExecutionFacts { + package_revision: DIGEST_B.to_owned(), + ..execution.clone() + }, + SchemaTestExecutionFacts { + environment: "staging".to_owned(), + ..execution.clone() + }, + SchemaTestExecutionFacts { + sequence: 2, + ..execution.clone() + }, + ] { + assert!(matches!( + validated_candidate(fixture, &changed_execution, suite), + Err(FixtureError::CandidateBindingRefused) + )); + } + } + + fn assert_receipt_substitutions_are_refused( + bytes: &[u8], + candidate: &ValidatedSchemaTestCandidate, + suite: &ValidatedFixtureJourneys, + ) { + for (field, replacement) in [ + ("sourceClosureSha256", json!(DIGEST_B)), + ("journeyFileSha256", json!(DIGEST_B)), + ("targetManagedSchemaFingerprint", json!(DIGEST_B)), + ("environment", json!("staging")), + ("sequence", json!(2)), + ("priorPackageRevision", json!(DIGEST_B)), + ("postgresMajor", json!(17)), + ("projectSourceRevision", json!("substituted")), + ("compilerSourceRevision", json!("substituted")), + ("migrationPlanSha256", json!(DIGEST_B)), + ] { + let mut value: Value = serde_json::from_slice(bytes).expect("receipt JSON parses"); + value[field] = replacement; + let changed = canonicalize_json(&value).expect("changed receipt canonicalizes"); + assert_eq!( + revalidate_schema_test_receipt(&changed, candidate, suite), + Err(FixtureError::ReceiptBindingRefused) + ); + } + } + + async fn assert_closed_response_negatives( + suite: &ValidatedFixtureJourneys, + candidate: &ValidatedSchemaTestCandidate, + ) { + let create = &suite.journeys[0].steps[0]; + let identifier = "123e4567-e89b-12d3-a456-426614174000"; + let unreadable = json!({ + "id": identifier, + "revision": 1, + "data": { + "jurisdiction": "zone-a", "label": "first", "note": "initial", + "quantity": 1, "record_id": "canary" + } + }); + assert_eq!( + assert_response(create, StatusCode::CREATED, &unreadable), + Err(FixtureError::ExpectationMismatch) + ); + + let refusal = &suite.journeys[0].steps[5]; + let extra_refusal = json!({ + "type": "urn:registry-server:problem:resource.not_found", + "title": "Not Found", + "status": 404, + "detail": "The requested resource was not found.", + "code": "resource.not_found", + "canaryDetail": "protected" + }); + assert_eq!( + assert_response(refusal, StatusCode::NOT_FOUND, &extra_refusal), + Err(FixtureError::ResponseShapeRefused) + ); + let changed_detail = json!({ + "type": "urn:registry-server:problem:resource.not_found", + "title": "Not Found", + "status": 404, + "detail": "protected canary", + "code": "resource.not_found" + }); + assert_eq!( + assert_response(refusal, StatusCode::NOT_FOUND, &changed_detail), + Err(FixtureError::ExpectationMismatch) + ); + + let malformed_list = json!({ + "items": [{"id": identifier, "revision": 1, "data": {"record_id": "canary"}}], + "pageInfo": {"nextCursor": null} + }); + assert!( + assert_response(&suite.journeys[0].steps[2], StatusCode::OK, &malformed_list,).is_err() + ); + + let malformed_batch = + json!({"results": [{"operation": "create"}, {"operation": "create"}]}); + assert_eq!( + assert_response( + &suite.journeys[0].steps[4], + StatusCode::OK, + &malformed_batch, + ), + Err(FixtureError::ResponseShapeRefused) + ); + + assert_eq!( + execute_scripted(suite, candidate, ScriptMode::Oversized) + .await + .unwrap_err(), + FixtureError::ResponseTooLarge + ); + assert_eq!( + execute_scripted(suite, candidate, ScriptMode::PartialFailure) + .await + .unwrap_err(), + FixtureError::ExpectationMismatch + ); + } + + struct PackageFixture { + prepared: PreparedPackage, + package: VerifiedPackage, + project: Vec, + module: Vec, + migration_plan: Vec, + } + + fn validated_candidate( + fixture: &PackageFixture, + execution: &SchemaTestExecutionFacts, + suite: &ValidatedFixtureJourneys, + ) -> Result { + let modules = [FixtureModuleSource { + id: "fixture-core", + path: "sources/modules/fixture-core.yaml", + bytes: &fixture.module, + }]; + validate_schema_test_candidate( + &fixture.package, + &SchemaTestSources { + project: FixtureSourceFile { + path: "sources/project.yaml", + bytes: &fixture.project, + }, + modules: &modules, + migration_plan: FixtureSourceFile { + path: "database/migration-plan.json", + bytes: &fixture.migration_plan, + }, + }, + execution, + suite, + ) + } + + fn package_fixture(schema_fingerprint: &str) -> PackageFixture { + package_fixture_with_journeys(schema_fingerprint, JOURNEY_SOURCE) + } + + fn package_fixture_with_journeys( + schema_fingerprint: &str, + journey_source: &[u8], + ) -> PackageFixture { + let module = parse_module_yaml(MODULE_SOURCE).expect("module fixture parses"); + let project = String::from_utf8(PROJECT_TEMPLATE.to_vec()) + .expect("project fixture is UTF-8") + .replace("MODULE_DIGEST", &module_digest(&module)) + .into_bytes(); + let prepared = prepare_package(PackageBuildRequest { + environment: "local".to_owned(), + instance_id: "fixture-instance".to_owned(), + database_id: DATABASE_ID.to_owned(), + sequence: 1, + prior_revision: None, + compiler_source_revision: COMPILER_SOURCE_REVISION.to_owned(), + schema_fingerprint: schema_fingerprint.to_owned(), + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + project: PackageSourceFile { + path: "sources/project.yaml".to_owned(), + bytes: project.clone(), + }, + modules: vec![PackageModuleSource { + id: "fixture-core".to_owned(), + path: "sources/modules/fixture-core.yaml".to_owned(), + bytes: MODULE_SOURCE.to_vec(), + }], + fixture_journeys: PackageSourceFile { + path: FIXTURE_JOURNEYS_PATH.to_owned(), + bytes: journey_source.to_vec(), + }, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + }) + .expect("fixture package prepares"); + let migration_plan = prepared + .file_bytes() + .get("database/migration-plan.json") + .expect("prepared package contains migration plan") + .clone(); + let temporary = tempfile::tempdir().expect("temporary root creates"); + let package_root = temporary + .path() + .canonicalize() + .expect("temporary root canonicalizes") + .join("package"); + prepared + .publish_to_directory(&package_root, Vec::new()) + .expect("local package publishes"); + let package = load_package( + &package_root, + &PackageLoadContext { + environment: "local", + instance_id: "fixture-instance", + database_id: DATABASE_ID, + database_initialization_environment: "local", + compiler_source_revision: COMPILER_SOURCE_REVISION, + trust_anchor: None, + intent: PackageIntent::InitialActivation, + }, + ) + .expect("fixture package rederives and verifies"); + PackageFixture { + prepared, + package, + project, + module: MODULE_SOURCE.to_vec(), + migration_plan, + } + } + + fn production_prepared_package(key_id: &str) -> PreparedPackage { + let module = parse_module_yaml(MODULE_SOURCE).expect("module fixture parses"); + let project = String::from_utf8(PROJECT_TEMPLATE.to_vec()) + .expect("project fixture is UTF-8") + .replace("MODULE_DIGEST", &module_digest(&module)) + .replace("environment: local", "environment: production") + .into_bytes(); + prepare_package(PackageBuildRequest { + environment: "production".to_owned(), + instance_id: "fixture-instance".to_owned(), + database_id: DATABASE_ID.to_owned(), + sequence: 1, + prior_revision: None, + compiler_source_revision: COMPILER_SOURCE_REVISION.to_owned(), + schema_fingerprint: DIGEST_A.to_owned(), + signature_policy: SignaturePolicy { + threshold: 1, + key_ids: vec![key_id.to_owned()], + }, + project: PackageSourceFile { + path: "sources/project.yaml".to_owned(), + bytes: project, + }, + modules: vec![PackageModuleSource { + id: "fixture-core".to_owned(), + path: "sources/modules/fixture-core.yaml".to_owned(), + bytes: MODULE_SOURCE.to_vec(), + }], + fixture_journeys: PackageSourceFile { + path: FIXTURE_JOURNEYS_PATH.to_owned(), + bytes: JOURNEY_SOURCE.to_vec(), + }, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + }) + .expect("Production candidate package prepares") + } + + fn execution_facts( + package: &VerifiedPackage, + schema_fingerprint: &str, + postgres_major: u16, + ) -> SchemaTestExecutionFacts { + SchemaTestExecutionFacts::from_database_snapshot(DatabaseExecutionSnapshot { + current_database: "fixture_test_database".to_owned(), + package_id: package.manifest().package_id.clone(), + environment: package.manifest().environment.clone(), + instance_id: package.manifest().instance_id.clone(), + database_id: package.manifest().database_id.clone(), + package_revision: package.manifest().package_revision.clone(), + sequence: package.manifest().sequence, + schema_fingerprint: schema_fingerprint.to_owned(), + postgres_major, + maintenance_status: "ready".to_owned(), + }) + } + + #[derive(Clone, Copy)] + enum ScriptMode { + Success, + Oversized, + PartialFailure, + } + + async fn execute_scripted( + suite: &ValidatedFixtureJourneys, + candidate: &ValidatedSchemaTestCandidate, + mode: ScriptMode, + ) -> Result { + for journey in &suite.journeys { + let mut observations = BTreeMap::::new(); + for (index, step) in journey.steps.iter().enumerate() { + let _request = fixture_request(step, &observations, None)?; + let response = scripted_response(index, mode)?; + let status = response.status(); + let headers = response.headers().clone(); + let bytes = to_bytes(response.into_body(), MAX_RESPONSE_BYTES) + .await + .map_err(|_| FixtureError::ResponseTooLarge)?; + let document = + parse_json_strict(&bytes).map_err(|_| FixtureError::ResponseShapeRefused)?; + assert_response(step, status, &document)?; + if let Some(capture) = step.capture.as_ref() { + observations.insert( + capture.clone(), + Observation { + record_id: document + .get("id") + .and_then(Value::as_str) + .ok_or(FixtureError::ResponseShapeRefused)? + .to_owned(), + etag: headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .ok_or(FixtureError::ResponseShapeRefused)? + .to_owned(), + }, + ); + } + } + } + Ok(SuccessfulFixtureJourneys { + registry_revision: suite.registry_revision.clone(), + file_sha256: suite.file_sha256.clone(), + journey_ids: sorted_journey_ids(suite), + candidate_binding_sha256: candidate_binding_sha256(candidate), + }) + } + + fn scripted_response(index: usize, mode: ScriptMode) -> Result, FixtureError> { + if matches!(mode, ScriptMode::Oversized) { + return Response::builder() + .status(200) + .body(Body::from(vec![b'x'; MAX_RESPONSE_BYTES + 1])) + .map_err(|_| FixtureError::ExecutionRefused); + } + let id = "123e4567-e89b-12d3-a456-426614174000"; + let second = "123e4567-e89b-12d3-a456-426614174001"; + let third = "123e4567-e89b-12d3-a456-426614174002"; + let (status, document, etag) = match index { + 0 => ( + 201, + json!({"id":id,"revision":1,"data":{"jurisdiction":"zone-a","label":"first","note":"initial","quantity":1}}), + Some("\"rs-one\""), + ), + 1 => ( + 200, + json!({"id":id,"revision":1,"data":{"jurisdiction":"zone-a","label":"first","note":"initial","quantity":1}}), + Some("\"rs-one\""), + ), + 2 => ( + 200, + json!({"items":[{"id":id,"revision":1,"data":{"jurisdiction":"zone-a","label":"first","note":"initial","quantity":1}}],"pageInfo":{"nextCursor":null}}), + None, + ), + 3 => ( + 200, + json!({"id":id,"revision":2,"data":{"jurisdiction":"zone-a","label":"first","note":"revised","quantity":1}}), + Some("\"rs-two\""), + ), + 4 => ( + 200, + json!({"results":[ + {"operation":"create","id":second,"revision":1,"etag":"\"rs-second\"","data":{"jurisdiction":"zone-a","label":"second","quantity":2}}, + {"operation":"create","id":third,"revision":1,"etag":"\"rs-third\"","data":{"jurisdiction":"zone-a","label":"third","quantity":3}} + ]}), + None, + ), + 5 => ( + 404, + json!({ + "type":"urn:registry-server:problem:resource.not_found", + "title":"Not Found", + "status":404, + "detail":"The requested resource was not found.", + "code": if matches!(mode, ScriptMode::PartialFailure) { + "query.invalid" + } else { + "resource.not_found" + } + }), + None, + ), + _ => return Err(FixtureError::ExecutionRefused), + }; + let mut builder = Response::builder().status(status); + if let Some(value) = etag { + builder = builder.header(ETAG, value); + } + builder + .body(Body::from( + serde_json::to_vec(&document).map_err(|_| FixtureError::ExecutionRefused)?, + )) + .map_err(|_| FixtureError::ExecutionRefused) + } +} diff --git a/crates/registry-server/src/generated_ddl.rs b/crates/registry-server/src/generated_ddl.rs new file mode 100644 index 0000000000..27ec1c79dd --- /dev/null +++ b/crates/registry-server/src/generated_ddl.rs @@ -0,0 +1,834 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::contract::{ + BoundaryOperator, ComparisonOperator, ConstraintSource, FieldTypeSource, Operation, + UniqueWhenPredicate, ValidTimeRole, +}; +use crate::model::CompiledEntity; +use crate::physical_names::{hex_prefix, PhysicalNameInventory}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DdlStatementKind { + Schema, + Table, + Column, + Reference, + Constraint, + Index, + RowSecurity, + Policy, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TablePrivilege { + Select, + Insert, + Update, +} + +impl TablePrivilege { + pub fn as_sql(self) -> &'static str { + match self { + Self::Select => "SELECT", + Self::Insert => "INSERT", + Self::Update => "UPDATE", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PolicyCommand { + Select, + Insert, + Update, +} + +impl PolicyCommand { + pub fn as_sql(self) -> &'static str { + match self { + Self::Select => "SELECT", + Self::Insert => "INSERT", + Self::Update => "UPDATE", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DdlPolicy { + pub name: String, + pub command: PolicyCommand, + pub access_profile: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub using_expression: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub check_expression: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DdlTable { + pub entity_id: String, + pub physical_name: String, + pub runtime_privileges: BTreeSet, + pub policies: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DdlStatement { + pub id: String, + pub kind: DdlStatementKind, + pub sql: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DdlInventory { + pub requires_btree_gist: bool, + pub statements: Vec, + pub tables: Vec, +} + +impl DdlInventory { + pub fn script(&self) -> String { + let mut output = String::new(); + for statement in &self.statements { + output.push_str(&statement.sql); + output.push_str(";\n"); + } + output + } +} + +pub(crate) fn generate_ddl( + entities: &BTreeMap, + names: &PhysicalNameInventory, +) -> DdlInventory { + let mut statements = vec![DdlStatement { + id: "schema.registry_data".to_owned(), + kind: DdlStatementKind::Schema, + sql: "CREATE SCHEMA IF NOT EXISTS registry_data".to_owned(), + }]; + + for entity in entities.values() { + let mut columns = vec![ + "record_id uuid NOT NULL".to_owned(), + "record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0)".to_owned(), + "record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned'))".to_owned(), + "created_at timestamptz NOT NULL DEFAULT transaction_timestamp()".to_owned(), + "updated_at timestamptz NOT NULL DEFAULT transaction_timestamp()".to_owned(), + "active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> '')".to_owned(), + "PRIMARY KEY (record_id)".to_owned(), + ]; + for field in entity.fields.values() { + columns.push(column_definition(field)); + } + statements.push(DdlStatement { + id: format!("entity.{}.table", entity.id), + kind: DdlStatementKind::Table, + sql: format!( + "CREATE TABLE registry_data.{} ({})", + quote_identifier(&entity.physical_table), + columns.join(", ") + ), + }); + } + + for entity in entities.values() { + let entity_names = &names.entities[&entity.id]; + if entity.temporal.is_some() { + statements.push(DdlStatement { + id: format!("entity.{}.constraint.temporal-order", entity.id), + kind: DdlStatementKind::Constraint, + sql: temporal_order_constraint_sql(entity), + }); + } + for field in entity.fields.values() { + if let FieldTypeSource::Reference { target, .. } = &field.field_type { + let constraint_name = derived_reference_name(entity_names, &field.id); + statements.push(DdlStatement { + id: format!("entity.{}.field.{}.reference", entity.id, field.id), + kind: DdlStatementKind::Reference, + sql: format!( + "ALTER TABLE registry_data.{} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES registry_data.{} (record_id) ON DELETE RESTRICT", + quote_identifier(&entity.physical_table), + quote_identifier(constraint_name), + quote_identifier(&field.physical_name), + quote_identifier(&entities[target].physical_table), + ), + }); + } + } + + for (constraint_id, constraint) in &entity.constraints { + let kind = if matches!(constraint, ConstraintSource::Unique { when: Some(_), .. }) { + DdlStatementKind::Index + } else { + DdlStatementKind::Constraint + }; + statements.push(DdlStatement { + id: format!("entity.{}.constraint.{constraint_id}", entity.id), + kind, + sql: constraint_sql(entity, entity_names, constraint_id, constraint), + }); + } + + for (index_id, fields) in &entity.indexes { + let columns = fields + .iter() + .map(|field| quote_identifier(&entity.fields[field].physical_name)) + .collect::>() + .join(", "); + statements.push(DdlStatement { + id: format!("entity.{}.index.{index_id}", entity.id), + kind: DdlStatementKind::Index, + sql: format!( + "CREATE INDEX {} ON registry_data.{} ({columns})", + quote_identifier(&entity_names.indexes[index_id]), + quote_identifier(&entity.physical_table), + ), + }); + } + } + + let mut tables = Vec::new(); + for entity in entities.values() { + let runtime_privileges = runtime_privileges(entity); + let policies = policies(entity); + let table = quote_identifier(&entity.physical_table); + statements.push(DdlStatement { + id: format!("entity.{}.rls.enable", entity.id), + kind: DdlStatementKind::RowSecurity, + sql: format!("ALTER TABLE registry_data.{table} ENABLE ROW LEVEL SECURITY"), + }); + statements.push(DdlStatement { + id: format!("entity.{}.rls.force", entity.id), + kind: DdlStatementKind::RowSecurity, + sql: format!("ALTER TABLE registry_data.{table} FORCE ROW LEVEL SECURITY"), + }); + for policy in &policies { + statements.push(DdlStatement { + id: format!( + "entity.{}.policy.{}.{}", + entity.id, + policy.access_profile, + policy.command.as_sql().to_ascii_lowercase() + ), + kind: DdlStatementKind::Policy, + sql: policy_sql(&table, policy), + }); + } + tables.push(DdlTable { + entity_id: entity.id.clone(), + physical_name: entity.physical_table.clone(), + runtime_privileges, + policies, + }); + } + + DdlInventory { + requires_btree_gist: entities.values().any(|entity| { + entity + .constraints + .values() + .any(|value| matches!(value, ConstraintSource::TemporalNonOverlap { .. })) + }), + statements, + tables, + } +} + +#[cfg(feature = "runtime")] +pub(crate) fn add_column_statement( + entity: &CompiledEntity, + field: &crate::model::CompiledField, +) -> DdlStatement { + DdlStatement { + id: format!("entity.{}.field.{}.column", entity.id, field.id), + kind: DdlStatementKind::Column, + sql: format!( + "ALTER TABLE registry_data.{} ADD COLUMN {}", + quote_identifier(&entity.physical_table), + column_definition(field) + ), + } +} + +fn column_definition(field: &crate::model::CompiledField) -> String { + let identifier = quote_identifier(&field.physical_name); + let mut column = format!("{} {}", identifier, sql_type(&field.field_type)); + if field.required { + column.push_str(" NOT NULL"); + } + if let Some(check) = field_check(&identifier, &field.field_type) { + column.push_str(" CHECK ("); + column.push_str(&check); + column.push(')'); + } + column +} + +fn runtime_privileges(entity: &CompiledEntity) -> BTreeSet { + let operations = entity + .access_profiles + .values() + .flat_map(|profile| profile.operations.iter().copied()) + .collect::>(); + let mut privileges = BTreeSet::new(); + if operations.iter().any(|operation| { + matches!( + operation, + Operation::Get | Operation::List | Operation::Batch | Operation::Revisions + ) + }) { + privileges.insert(TablePrivilege::Select); + } + if operations.contains(&Operation::Create) { + privileges.insert(TablePrivilege::Insert); + } + if operations + .iter() + .any(|operation| matches!(operation, Operation::Patch | Operation::Tombstone)) + { + privileges.insert(TablePrivilege::Update); + } + privileges +} + +fn policies(entity: &CompiledEntity) -> Vec { + let mut policies = Vec::new(); + for profile in entity.access_profiles.values() { + for command in [ + PolicyCommand::Select, + PolicyCommand::Insert, + PolicyCommand::Update, + ] { + if !profile_supports_command(&profile.operations, command) { + continue; + } + let authority = policy_authority_expression(entity, profile); + let (using_expression, check_expression) = match command { + PolicyCommand::Select => ( + Some(format!("({authority}) AND record_lifecycle = 'active'")), + None, + ), + PolicyCommand::Insert => ( + None, + Some(format!("({authority}) AND record_lifecycle = 'active'")), + ), + PolicyCommand::Update => { + let lifecycle_check = if profile.operations.contains(&Operation::Tombstone) { + "record_lifecycle IN ('active', 'tombstoned')" + } else { + "record_lifecycle = 'active'" + }; + ( + Some(format!("({authority}) AND record_lifecycle = 'active'")), + Some(format!("({authority}) AND {lifecycle_check}")), + ) + } + }; + policies.push(DdlPolicy { + name: policy_name(&entity.id, &profile.id, command), + command, + access_profile: profile.id.clone(), + using_expression, + check_expression, + }); + } + } + policies +} + +fn profile_supports_command(operations: &BTreeSet, command: PolicyCommand) -> bool { + match command { + PolicyCommand::Select => operations.iter().any(|operation| { + matches!( + operation, + Operation::Get | Operation::List | Operation::Batch | Operation::Revisions + ) + }), + PolicyCommand::Insert => operations.contains(&Operation::Create), + PolicyCommand::Update => operations + .iter() + .any(|operation| matches!(operation, Operation::Patch | Operation::Tombstone)), + } +} + +fn policy_authority_expression( + entity: &CompiledEntity, + profile: &crate::contract::AccessProfileSource, +) -> String { + let mut predicates = vec![format!( + "NULLIF(current_setting('registry.access_profile', true), '') = {}", + quote_literal(&profile.id) + )]; + if !profile.anonymous { + predicates + .push("NULLIF(current_setting('registry.principal', true), '') IS NOT NULL".to_owned()); + } + if !profile.required_purposes.is_empty() { + let purposes = profile + .required_purposes + .iter() + .map(|purpose| quote_literal(purpose)) + .collect::>() + .join(", "); + predicates.push(format!( + "NULLIF(current_setting('registry.purpose', true), '') IN ({purposes})" + )); + } + + let context = "NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb"; + predicates.push(format!("jsonb_typeof({context}) = 'array'")); + predicates.push(format!( + "jsonb_array_length({context}) = {}", + profile.row_boundaries.len() + )); + for (index, boundary) in profile.row_boundaries.iter().enumerate() { + let entry = format!("({context} -> {index})"); + let values = format!("({entry} -> 'values')"); + predicates.push(format!("jsonb_typeof({entry}) = 'object'")); + predicates.push(format!( + "({entry} - 'field' - 'operator' - 'values') = '{{}}'::jsonb" + )); + predicates.push(format!( + "{entry} ->> 'field' = {}", + quote_literal(&boundary.field) + )); + predicates.push(format!( + "{entry} ->> 'operator' = {}", + quote_literal(match boundary.operator { + BoundaryOperator::Equals => "equals", + BoundaryOperator::In => "in", + }) + )); + predicates.push(format!("jsonb_typeof({values}) = 'array'")); + let column = field_name(entity, &boundary.field); + let value_type = policy_value_type(&entity.fields[&boundary.field].field_type); + match boundary.operator { + BoundaryOperator::Equals => { + predicates.push(format!("jsonb_array_length({values}) = 1")); + predicates.push(format!("{column} = ({values} ->> 0)::{value_type}")); + } + BoundaryOperator::In => { + predicates.push(format!("jsonb_array_length({values}) BETWEEN 1 AND 64")); + predicates.push(format!( + "{column} = ANY (ARRAY(SELECT boundary_value::{value_type} FROM jsonb_array_elements_text({values}) AS boundary_values(boundary_value)))" + )); + } + } + } + predicates.join(" AND ") +} + +fn policy_value_type(field_type: &FieldTypeSource) -> &'static str { + match field_type { + FieldTypeSource::Boolean => "boolean", + FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::VocabularyCode { .. } => "text", + FieldTypeSource::Int64 => "bigint", + FieldTypeSource::Decimal { .. } => "numeric", + FieldTypeSource::Date => "date", + FieldTypeSource::Timestamp => "timestamptz", + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => "uuid", + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => "jsonb", + } +} + +fn policy_name(entity_id: &str, profile_id: &str, command: PolicyCommand) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"registry-server/rls-policy/v1"); + hasher.update((entity_id.len() as u64).to_be_bytes()); + hasher.update(entity_id.as_bytes()); + hasher.update((profile_id.len() as u64).to_be_bytes()); + hasher.update(profile_id.as_bytes()); + hasher.update(command.as_sql().as_bytes()); + let digest = hasher.finalize(); + let suffix = digest[..12] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!( + "registry_rls_{}_{}", + command.as_sql().to_ascii_lowercase(), + suffix + ) +} + +fn policy_sql(table: &str, policy: &DdlPolicy) -> String { + let mut sql = format!( + "CREATE POLICY {} ON registry_data.{table} FOR {}", + quote_identifier(&policy.name), + policy.command.as_sql() + ); + if let Some(expression) = &policy.using_expression { + sql.push_str(" USING ("); + sql.push_str(expression); + sql.push(')'); + } + if let Some(expression) = &policy.check_expression { + sql.push_str(" WITH CHECK ("); + sql.push_str(expression); + sql.push(')'); + } + sql +} + +fn derived_reference_name<'a>( + names: &'a crate::physical_names::EntityPhysicalNames, + field: &str, +) -> &'a str { + names.constraints[&format!("reference:{field}")].as_str() +} + +fn constraint_sql( + entity: &CompiledEntity, + names: &crate::physical_names::EntityPhysicalNames, + constraint_id: &str, + constraint: &ConstraintSource, +) -> String { + let table = quote_identifier(&entity.physical_table); + let name = quote_identifier(&names.constraints[constraint_id]); + let check = match constraint { + ConstraintSource::Unique { + fields, when: None, .. + } => { + let fields = field_list(entity, fields); + return format!( + "ALTER TABLE registry_data.{table} ADD CONSTRAINT {name} UNIQUE ({fields})" + ); + } + ConstraintSource::Unique { + fields, + when: Some(when), + .. + } => { + let fields = field_list(entity, fields); + let predicate = partial_unique_predicate(entity, when); + return format!( + "CREATE UNIQUE INDEX {name} ON registry_data.{table} ({fields}) WHERE {predicate}" + ); + } + ConstraintSource::Compare { + left, + operator, + right, + .. + } => format!( + "{} {} {}", + field_name(entity, left), + comparison_operator(*operator), + field_name(entity, right) + ), + ConstraintSource::IntRange { + field, + minimum, + maximum, + .. + } => { + let column = field_name(entity, field); + let mut parts = Vec::new(); + if let Some(minimum) = minimum { + parts.push(format!("{column} >= {minimum}")); + } + if let Some(maximum) = maximum { + parts.push(format!("{column} <= {maximum}")); + } + parts.join(" AND ") + } + ConstraintSource::Vocabulary { field, values, .. } => { + let values = values + .iter() + .map(|value| quote_literal(value)) + .collect::>() + .join(", "); + format!("{} IN ({values})", field_name(entity, field)) + } + ConstraintSource::TemporalNonOverlap { scope_fields, .. } => { + let (valid_from, valid_to) = temporal_boundary_fields(entity); + let function = match valid_from.field_type { + FieldTypeSource::Date => "daterange", + FieldTypeSource::Timestamp => "tstzrange", + _ => unreachable!("valid-time field kind was validated"), + }; + let mut elements = scope_fields + .iter() + .map(|field| format!("{} WITH =", field_name(entity, field))) + .collect::>(); + elements.push(format!( + "{function}({}, {}, '[)') WITH &&", + quote_identifier(&valid_from.physical_name), + quote_identifier(&valid_to.physical_name) + )); + return format!( + "ALTER TABLE registry_data.{table} ADD CONSTRAINT {name} EXCLUDE USING gist ({})", + elements.join(", ") + ); + } + }; + format!("ALTER TABLE registry_data.{table} ADD CONSTRAINT {name} CHECK ({check})") +} + +fn temporal_order_constraint_sql(entity: &CompiledEntity) -> String { + let table = quote_identifier(&entity.physical_table); + let name = quote_identifier(&temporal_order_constraint_name(&entity.id)); + let (valid_from, valid_to) = temporal_boundary_fields(entity); + format!( + "ALTER TABLE registry_data.{table} ADD CONSTRAINT {name} CHECK ({} IS NULL OR {} < {})", + quote_identifier(&valid_to.physical_name), + quote_identifier(&valid_from.physical_name), + quote_identifier(&valid_to.physical_name) + ) +} + +fn temporal_order_constraint_name(entity_id: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"registry-server/temporal-order/v1"); + hasher.update((entity_id.len() as u64).to_be_bytes()); + hasher.update(entity_id.as_bytes()); + let digest = hasher.finalize(); + format!("registry_temporal_order_{}", hex_prefix(&digest, 12)) +} + +fn temporal_boundary_fields( + entity: &CompiledEntity, +) -> (&crate::model::CompiledField, &crate::model::CompiledField) { + let valid_from = entity + .fields + .values() + .find(|field| field.valid_time_role == Some(ValidTimeRole::ValidFrom)) + .expect("validated valid_from field"); + let valid_to = entity + .fields + .values() + .find(|field| field.valid_time_role == Some(ValidTimeRole::ValidTo)) + .expect("validated valid_to field"); + (valid_from, valid_to) +} + +fn partial_unique_predicate(entity: &CompiledEntity, when: &[UniqueWhenPredicate]) -> String { + let mut predicates = when.iter().collect::>(); + predicates.sort_by_key(|predicate| unique_when_predicate_sort_key(predicate)); + predicates + .into_iter() + .map(|predicate| partial_unique_predicate_sql(entity, predicate)) + .collect::>() + .join(" AND ") +} + +fn partial_unique_predicate_sql( + entity: &CompiledEntity, + predicate: &UniqueWhenPredicate, +) -> String { + match predicate { + UniqueWhenPredicate::FieldEquals { field, value } => format!( + "{} = {}", + field_name(entity, field), + typed_literal_sql(value, &entity.fields[field].field_type) + ), + UniqueWhenPredicate::FieldIsNull { field } => { + format!("{} IS NULL", field_name(entity, field)) + } + UniqueWhenPredicate::FieldIsNotNull { field } => { + format!("{} IS NOT NULL", field_name(entity, field)) + } + UniqueWhenPredicate::ActiveLifecycle {} => "record_lifecycle = 'active'".to_owned(), + } +} + +fn typed_literal_sql(value: &Value, field_type: &FieldTypeSource) -> String { + match field_type { + FieldTypeSource::Boolean => format!( + "{}::boolean", + quote_literal(if value.as_bool().expect("validated boolean literal") { + "true" + } else { + "false" + }) + ), + FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::VocabularyCode { .. } => { + quote_literal(value.as_str().expect("validated text literal")) + } + FieldTypeSource::Int64 => format!( + "{}::bigint", + quote_literal(&value.as_i64().expect("validated int64 literal").to_string()) + ), + FieldTypeSource::Decimal { + precision, scale, .. + } => format!( + "{}::numeric({precision},{scale})", + quote_literal(value.as_str().expect("validated decimal literal")) + ), + FieldTypeSource::Date => format!( + "{}::date", + quote_literal(value.as_str().expect("validated date literal")) + ), + FieldTypeSource::Timestamp => format!( + "{}::timestamptz", + quote_literal(value.as_str().expect("validated timestamp literal")) + ), + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => format!( + "{}::uuid", + quote_literal(value.as_str().expect("validated UUID literal")) + ), + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + unreachable!("validated partial unique predicates reject JSON field types") + } + } +} + +fn unique_when_predicate_sort_key(predicate: &UniqueWhenPredicate) -> String { + match predicate { + UniqueWhenPredicate::FieldEquals { field, value } => { + format!("field:{field}:equals:{}", value) + } + UniqueWhenPredicate::FieldIsNull { field } => format!("field:{field}:is_null"), + UniqueWhenPredicate::FieldIsNotNull { field } => format!("field:{field}:is_not_null"), + UniqueWhenPredicate::ActiveLifecycle {} => "lifecycle:active".to_owned(), + } +} + +fn sql_type(field_type: &FieldTypeSource) -> String { + match field_type { + FieldTypeSource::Boolean => "boolean".to_owned(), + FieldTypeSource::String { max_length, .. } => format!("varchar({max_length})"), + FieldTypeSource::Text { .. } | FieldTypeSource::VocabularyCode { .. } => "text".to_owned(), + FieldTypeSource::Int64 => "bigint".to_owned(), + FieldTypeSource::Decimal { + precision, scale, .. + } => { + format!("numeric({precision},{scale})") + } + FieldTypeSource::Date => "date".to_owned(), + FieldTypeSource::Timestamp => "timestamptz".to_owned(), + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => "uuid".to_owned(), + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + "jsonb".to_owned() + } + } +} + +fn field_check(identifier: &str, field_type: &FieldTypeSource) -> Option { + match field_type { + FieldTypeSource::String { min_length, .. } if *min_length > 0 => { + Some(format!("char_length({identifier}) >= {min_length}")) + } + FieldTypeSource::Text { max_length } => { + Some(format!("char_length({identifier}) <= {max_length}")) + } + FieldTypeSource::VocabularyCode { values, .. } => Some(format!( + "{identifier} IN ({})", + values + .iter() + .map(|value| quote_literal(value)) + .collect::>() + .join(", ") + )), + FieldTypeSource::Decimal { + minimum, maximum, .. + } => { + let mut parts = Vec::new(); + if let Some(minimum) = minimum { + parts.push(format!("{identifier} >= {minimum}")); + } + if let Some(maximum) = maximum { + parts.push(format!("{identifier} <= {maximum}")); + } + (!parts.is_empty()).then(|| parts.join(" AND ")) + } + FieldTypeSource::Crs84Point { precision, bbox } => { + let mut parts = vec![ + format!("jsonb_typeof({identifier}) = 'object'"), + format!("{identifier} ->> 'type' = 'Point'"), + format!("({identifier} - 'type' - 'coordinates') = '{{}}'::jsonb"), + format!("jsonb_typeof({identifier} -> 'coordinates') = 'array'"), + format!("jsonb_array_length({identifier} -> 'coordinates') = 2"), + format!("jsonb_typeof({identifier} -> 'coordinates' -> 0) = 'number'"), + format!("jsonb_typeof({identifier} -> 'coordinates' -> 1) = 'number'"), + format!("({identifier} -> 'coordinates' ->> 0)::numeric BETWEEN -180 AND 180"), + format!("({identifier} -> 'coordinates' ->> 1)::numeric BETWEEN -90 AND 90"), + format!( + "({identifier} -> 'coordinates' ->> 0) ~ {}", + quote_literal(&coordinate_pattern(*precision, 180)) + ), + format!( + "({identifier} -> 'coordinates' ->> 1) ~ {}", + quote_literal(&coordinate_pattern(*precision, 90)) + ), + ]; + if let Some(bbox) = bbox { + parts.push(format!( + "({identifier} -> 'coordinates' ->> 0)::numeric BETWEEN {} AND {}", + quote_literal(&bbox.west), + quote_literal(&bbox.east) + )); + parts.push(format!( + "({identifier} -> 'coordinates' ->> 1)::numeric BETWEEN {} AND {}", + quote_literal(&bbox.south), + quote_literal(&bbox.north) + )); + } + Some(parts.join(" AND ")) + } + FieldTypeSource::Structured { max_bytes, .. } => { + Some(format!("octet_length({identifier}::text) <= {max_bytes}")) + } + _ => None, + } +} + +fn coordinate_pattern(precision: u8, maximum_abs: u16) -> String { + let integer = match maximum_abs { + 180 => "(0|[1-9][0-9]?|1[0-7][0-9]|180)", + 90 => "(0|[1-9]|[1-8][0-9]|90)", + _ => unreachable!("only CRS84 coordinate axes are generated"), + }; + if precision == 0 { + format!("^-?{integer}$") + } else { + format!("^-?{integer}(\\.[0-9]{{1,{precision}}})?$") + } +} + +fn field_list(entity: &CompiledEntity, fields: &[String]) -> String { + fields + .iter() + .map(|field| field_name(entity, field)) + .collect::>() + .join(", ") +} + +fn field_name(entity: &CompiledEntity, field: &str) -> String { + quote_identifier(&entity.fields[field].physical_name) +} + +fn comparison_operator(operator: ComparisonOperator) -> &'static str { + match operator { + ComparisonOperator::LessThan => "<", + ComparisonOperator::LessThanOrEqual => "<=", + ComparisonOperator::GreaterThan => ">", + ComparisonOperator::GreaterThanOrEqual => ">=", + } +} + +fn quote_identifier(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn quote_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} diff --git a/crates/registry-server/src/idempotency.rs b/crates/registry-server/src/idempotency.rs new file mode 100644 index 0000000000..dd598c495b --- /dev/null +++ b/crates/registry-server/src/idempotency.rs @@ -0,0 +1,486 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! PostgreSQL-backed mutation idempotency binding and exact held responses. + +use std::collections::{BTreeMap, BTreeSet}; + +use registry_platform_audit::AuditProfile; +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use serde_json::{json, Value}; +use tokio_postgres::Transaction; + +use crate::model::HttpMethod; +use crate::postgres::ClaimContext; + +const MAX_IDEMPOTENCY_KEY_BYTES: usize = 256; +const MAX_HEADER_VALUE_BYTES: usize = 8 * 1024; +const MAX_HELD_BODY_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum PermittedResponseHeader { + ContentType, + Etag, + Location, +} + +impl PermittedResponseHeader { + fn as_str(self) -> &'static str { + match self { + Self::ContentType => "content-type", + Self::Etag => "etag", + Self::Location => "location", + } + } + + fn from_u8(value: u8) -> Option { + match value { + 1 => Some(Self::ContentType), + 2 => Some(Self::Etag), + 3 => Some(Self::Location), + _ => None, + } + } + + fn to_u8(self) -> u8 { + match self { + Self::ContentType => 1, + Self::Etag => 2, + Self::Location => 3, + } + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct HeldResponse { + status: u16, + body: Vec, + headers: BTreeMap>, +} + +impl HeldResponse { + pub(crate) fn from_json( + status: u16, + body: &serde_json::Value, + headers: BTreeMap>, + ) -> Result { + if !(200..=299).contains(&status) + || headers.values().any(|value| !valid_header_value(value)) + { + return Err(IdempotencyError::InvalidInput); + } + let body = canonicalize_json(body).map_err(|_| IdempotencyError::InvalidInput)?; + if body.is_empty() || body.len() > MAX_HELD_BODY_BYTES { + return Err(IdempotencyError::InvalidInput); + } + Ok(Self { + status, + body, + headers, + }) + } + + #[must_use] + pub fn status(&self) -> u16 { + self.status + } + + #[must_use] + pub fn body(&self) -> &[u8] { + &self.body + } + + #[must_use] + pub fn headers(&self) -> &BTreeMap> { + &self.headers + } +} + +pub(crate) struct IdempotencyBinding<'a> { + pub key: &'a str, + pub context: &'a ClaimContext, + pub method: HttpMethod, + pub route: &'a str, + pub target_record: Option<&'a str>, + pub package_revision: &'a str, + pub response_fields: &'a BTreeSet, + pub canonical_request_digest: [u8; 32], +} + +pub(crate) struct ResolvedIdempotencyBinding { + pub key_reference: String, + pub binding_reference: String, + pub principal_reference: String, + pub record_reference: String, +} + +pub(crate) struct StoredMutationResult { + pub response: HeldResponse, + pub metadata: StoredResultMetadata, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum StoredResultMetadata { + Record { + record_reference: String, + record_revision: i64, + }, + Batch { + result_count: u16, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum IdempotencyError { + #[error("mutation request is invalid")] + InvalidInput, + #[error("idempotency key is already bound to another request")] + Conflict, + #[error("mutation state is unavailable")] + Unavailable, +} + +pub(crate) fn resolve_binding( + profile: &AuditProfile, + binding: &IdempotencyBinding<'_>, +) -> Result { + if binding.key.is_empty() + || binding.key.len() > MAX_IDEMPOTENCY_KEY_BYTES + || binding.route.is_empty() + || binding.package_revision.is_empty() + || binding.response_fields.iter().any(|field| field.is_empty()) + { + return Err(IdempotencyError::InvalidInput); + } + + let key_hasher = profile.key_hasher(); + let key_reference = key_hasher + .audit_reference_hash("registry-server-idempotency-key-v1", "", binding.key) + .map_err(|_| IdempotencyError::InvalidInput)?; + let canonical_context = + canonical_claim_context(profile, binding.context, binding.package_revision)?; + let principal_reference = key_hasher + .audit_reference_hash( + "registry-server-principal-v1", + binding.package_revision, + binding + .context + .principal() + .ok_or(IdempotencyError::InvalidInput)?, + ) + .map_err(|_| IdempotencyError::InvalidInput)?; + let record_reference = binding + .target_record + .map(|target_record| { + if target_record.is_empty() { + return Err(IdempotencyError::InvalidInput); + } + key_hasher + .audit_reference_hash( + "registry-server-record-v1", + binding.package_revision, + target_record, + ) + .map_err(|_| IdempotencyError::InvalidInput) + }) + .transpose()?; + let canonical = canonicalize_json(&json!({ + "context": canonical_context, + "method": method_name(binding.method), + "route": binding.route, + "targetRecordReference": record_reference, + "packageRevision": binding.package_revision, + "responseFields": binding.response_fields, + "canonicalRequestDigest": hex(&binding.canonical_request_digest), + })) + .map_err(|_| IdempotencyError::InvalidInput)?; + let canonical = std::str::from_utf8(&canonical).map_err(|_| IdempotencyError::InvalidInput)?; + let binding_reference = key_hasher + .audit_reference_hash( + "registry-server-idempotency-binding-v1", + binding.package_revision, + canonical, + ) + .map_err(|_| IdempotencyError::InvalidInput)?; + + Ok(ResolvedIdempotencyBinding { + key_reference, + binding_reference, + principal_reference, + record_reference: record_reference.unwrap_or_default(), + }) +} + +/// Canonical, value-safe identity of every verified authorization input that +/// PostgreSQL receives for one protected operation. +pub(crate) fn canonical_claim_context( + profile: &AuditProfile, + context: &ClaimContext, + package_revision: &str, +) -> Result { + let principal = context.principal().ok_or(IdempotencyError::InvalidInput)?; + let key_hasher = profile.key_hasher(); + let principal_reference = key_hasher + .audit_reference_hash("registry-server-principal-v1", package_revision, principal) + .map_err(|_| IdempotencyError::InvalidInput)?; + let row_boundaries = context + .row_boundaries() + .iter() + .map(|boundary| { + let reference_context = format!( + "{package_revision}:{}:{}", + boundary.field(), + boundary.operator().as_str() + ); + let value_references = boundary + .values() + .into_iter() + .map(|value| { + key_hasher.audit_reference_hash( + "registry-server-row-boundary-value-v1", + &reference_context, + value, + ) + }) + .collect::, _>>() + .map_err(|_| IdempotencyError::InvalidInput)?; + Ok(json!({ + "field": boundary.field(), + "operator": boundary.operator().as_str(), + "valueReferences": value_references, + })) + }) + .collect::, IdempotencyError>>()?; + Ok(json!({ + "entityId": context.entity_id(), + "principalReference": principal_reference, + "selectedAccessProfile": context.access_profile(), + "verifiedPurpose": context.purpose(), + "rowBoundaries": row_boundaries, + })) +} + +pub(crate) async fn lock_and_load( + transaction: &Transaction<'_>, + binding: &ResolvedIdempotencyBinding, +) -> Result, IdempotencyError> { + transaction + .execute( + "SELECT pg_advisory_xact_lock(pg_catalog.hashtextextended($1, 0))", + &[&binding.key_reference], + ) + .await + .map_err(|_| IdempotencyError::Unavailable)?; + let Some(row) = transaction + .query_opt( + "SELECT binding_reference, result_kind, record_revision, response_status, + response_body, response_headers, record_reference, result_count + FROM registry_internal.registry_idempotency + WHERE key_reference = $1", + &[&binding.key_reference], + ) + .await + .map_err(|_| IdempotencyError::Unavailable)? + else { + return Ok(None); + }; + if row.get::<_, String>(0) != binding.binding_reference { + return Err(IdempotencyError::Conflict); + } + let metadata = match row.get::<_, String>(1).as_str() { + "record" => { + let record_revision = row + .get::<_, Option>(2) + .filter(|revision| *revision > 0) + .ok_or(IdempotencyError::Unavailable)?; + let record_reference = row + .get::<_, Option>(6) + .filter(|reference| !reference.is_empty()) + .ok_or(IdempotencyError::Unavailable)?; + if row.get::<_, Option>(7).is_some() { + return Err(IdempotencyError::Unavailable); + } + StoredResultMetadata::Record { + record_reference, + record_revision, + } + } + "batch" => { + if row.get::<_, Option>(2).is_some() || row.get::<_, Option>(6).is_some() { + return Err(IdempotencyError::Unavailable); + } + let result_count = row + .get::<_, Option>(7) + .and_then(|count| u16::try_from(count).ok()) + .filter(|count| *count > 0) + .ok_or(IdempotencyError::Unavailable)?; + StoredResultMetadata::Batch { result_count } + } + _ => return Err(IdempotencyError::Unavailable), + }; + let status = u16::try_from(row.get::<_, i16>(3)).map_err(|_| IdempotencyError::Unavailable)?; + let body = row.get::<_, Vec>(4); + if body.is_empty() || body.len() > MAX_HELD_BODY_BYTES { + return Err(IdempotencyError::Unavailable); + } + let parsed = parse_json_strict(&body).map_err(|_| IdempotencyError::Unavailable)?; + if canonicalize_json(&parsed).map_err(|_| IdempotencyError::Unavailable)? != body { + return Err(IdempotencyError::Unavailable); + } + let headers = decode_headers(&row.get::<_, Vec>(5))?; + Ok(Some(StoredMutationResult { + response: HeldResponse { + status, + body, + headers, + }, + metadata, + })) +} + +pub(crate) async fn insert_result( + transaction: &Transaction<'_>, + binding: &ResolvedIdempotencyBinding, + metadata: &StoredResultMetadata, + response: &HeldResponse, +) -> Result<(), IdempotencyError> { + let status = i16::try_from(response.status).map_err(|_| IdempotencyError::InvalidInput)?; + let headers = encode_headers(&response.headers)?; + let (result_kind, record_revision, record_reference, result_count) = match metadata { + StoredResultMetadata::Record { + record_reference, + record_revision, + } if !record_reference.is_empty() && *record_revision > 0 => ( + "record", + Some(*record_revision), + Some(record_reference.as_str()), + None, + ), + StoredResultMetadata::Batch { result_count } if *result_count > 0 => ( + "batch", + None, + None, + Some(i16::try_from(*result_count).map_err(|_| IdempotencyError::InvalidInput)?), + ), + _ => return Err(IdempotencyError::InvalidInput), + }; + let changed = transaction + .execute( + "INSERT INTO registry_internal.registry_idempotency + (key_reference, binding_reference, result_kind, record_revision, + response_status, response_body, response_headers, record_reference, result_count) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + &[ + &binding.key_reference, + &binding.binding_reference, + &result_kind, + &record_revision, + &status, + &response.body, + &headers, + &record_reference, + &result_count, + ], + ) + .await + .map_err(|_| IdempotencyError::Unavailable)?; + if changed != 1 { + return Err(IdempotencyError::Unavailable); + } + Ok(()) +} + +fn encode_headers( + headers: &BTreeMap>, +) -> Result, IdempotencyError> { + let count = u16::try_from(headers.len()).map_err(|_| IdempotencyError::InvalidInput)?; + let mut encoded = Vec::new(); + encoded.extend_from_slice(&count.to_be_bytes()); + for (name, value) in headers { + let length = u32::try_from(value.len()).map_err(|_| IdempotencyError::InvalidInput)?; + encoded.push(name.to_u8()); + encoded.extend_from_slice(&length.to_be_bytes()); + encoded.extend_from_slice(value); + } + Ok(encoded) +} + +fn decode_headers( + encoded: &[u8], +) -> Result>, IdempotencyError> { + let Some(count) = encoded.get(..2) else { + return Err(IdempotencyError::Unavailable); + }; + let count = usize::from(u16::from_be_bytes([count[0], count[1]])); + let mut offset = 2; + let mut headers = BTreeMap::new(); + for _ in 0..count { + let name = encoded + .get(offset) + .copied() + .and_then(PermittedResponseHeader::from_u8) + .ok_or(IdempotencyError::Unavailable)?; + offset += 1; + let length = encoded + .get(offset..offset + 4) + .ok_or(IdempotencyError::Unavailable)?; + let length = u32::from_be_bytes([length[0], length[1], length[2], length[3]]) as usize; + offset += 4; + let value = encoded + .get(offset..offset + length) + .ok_or(IdempotencyError::Unavailable)? + .to_vec(); + offset += length; + if !valid_header_value(&value) || headers.insert(name, value).is_some() { + return Err(IdempotencyError::Unavailable); + } + } + if offset != encoded.len() { + return Err(IdempotencyError::Unavailable); + } + Ok(headers) +} + +fn valid_header_value(value: &[u8]) -> bool { + !value.is_empty() + && value.len() <= MAX_HEADER_VALUE_BYTES + && value.iter().all(|byte| matches!(byte, b'\t' | 0x20..=0x7e)) +} + +fn method_name(method: HttpMethod) -> &'static str { + match method { + HttpMethod::Delete => "DELETE", + HttpMethod::Get => "GET", + HttpMethod::Patch => "PATCH", + HttpMethod::Post => "POST", + } +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +impl std::fmt::Display for PermittedResponseHeader { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl std::fmt::Debug for HeldResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("HeldResponse") + .field("status", &self.status) + .field( + "body", + &format_args!("", self.body.len()), + ) + .field("headers", &self.headers.keys()) + .finish() + } +} diff --git a/crates/registry-server/src/lib.rs b/crates/registry-server/src/lib.rs new file mode 100644 index 0000000000..097484ada7 --- /dev/null +++ b/crates/registry-server/src/lib.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![doc = include_str!("../README.md")] + +#[cfg(feature = "runtime")] +pub mod api; +pub mod artifacts; +#[cfg(feature = "runtime")] +pub mod audit; +#[cfg(feature = "runtime")] +pub mod auth; +pub mod compiler; +pub mod contract; +#[cfg(feature = "runtime")] +pub mod cursor; +pub mod data; +pub mod diagnostics; +#[cfg(feature = "runtime")] +pub mod event_destination; +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub mod fixtures; +pub mod generated_ddl; +#[cfg(feature = "runtime")] +pub mod idempotency; +pub mod manifest_adapter; +#[cfg(feature = "runtime")] +pub mod migration; +#[cfg(feature = "runtime")] +pub mod migration_plan; +pub mod model; +#[cfg(feature = "runtime")] +pub mod mutation; +#[cfg(feature = "runtime")] +pub mod outbox; +#[cfg(feature = "runtime")] +pub mod package; +pub mod physical_names; +#[cfg(feature = "runtime")] +pub mod postgres; +#[cfg(feature = "runtime")] +pub mod revision; +#[cfg(feature = "runtime")] +pub mod runtime_config; +#[cfg(feature = "schema")] +pub mod schema; +#[cfg(feature = "runtime")] +pub mod startup; +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub mod tooling; +#[cfg(feature = "runtime")] +pub mod webhook; + +pub use artifacts::{GeneratedArtifact, GeneratedArtifacts}; +pub use compiler::{compile_project, CompileProfile}; +pub use contract::{ + parse_module_json, parse_module_yaml, parse_project_json, parse_project_yaml, RegistryModule, + RegistryProject, +}; +pub use diagnostics::{CompileFailure, Diagnostic, DiagnosticSeverity}; +pub use model::CompiledRegistry; diff --git a/crates/registry-server/src/main.rs b/crates/registry-server/src/main.rs new file mode 100644 index 0000000000..849d42cb3f --- /dev/null +++ b/crates/registry-server/src/main.rs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Registry Server process entry point. + +use std::path::PathBuf; + +use clap::Parser; +use registry_server::startup::{ + operational_log_level, prepare, serve, OperationalEvent, OperationalLogLevel, +}; +use tracing_subscriber::filter::Targets; +use tracing_subscriber::prelude::*; + +#[derive(Debug, Parser)] +#[command( + name = "registry-server", + about = "Serve one verified Registry Server package", + version = registry_platform_buildinfo::DISPLAY_VERSION +)] +struct Arguments { + #[arg(long, value_name = "ABSOLUTE_FILE")] + config: PathBuf, +} + +#[tokio::main] +async fn main() { + let arguments = Arguments::parse(); + let level = match operational_log_level(std::env::var("REGISTRY_SERVER_LOG").ok().as_deref()) { + Ok(level) => level, + Err(error) => { + initialize_logging(OperationalLogLevel::Error); + OperationalEvent::StoppedWithError(error).emit(); + std::process::exit(2); + } + }; + initialize_logging_filter(level); + + if !arguments.config.is_absolute() { + OperationalEvent::Stopped.emit(); + std::process::exit(2); + } + OperationalEvent::StartupBegan.emit(); + let prepared = match prepare(&arguments.config).await { + Ok(prepared) => prepared, + Err(error) => { + OperationalEvent::StoppedWithError(error).emit(); + std::process::exit(1); + } + }; + if let Err(error) = serve(prepared).await { + OperationalEvent::StoppedWithError(error).emit(); + std::process::exit(1); + } +} + +fn initialize_logging(level: OperationalLogLevel) { + let filter = match level { + OperationalLogLevel::Info => tracing_subscriber::filter::LevelFilter::INFO, + OperationalLogLevel::Warn => tracing_subscriber::filter::LevelFilter::WARN, + OperationalLogLevel::Error => tracing_subscriber::filter::LevelFilter::ERROR, + }; + initialize_logging_filter(filter); +} + +fn initialize_logging_filter(level: tracing_subscriber::filter::LevelFilter) { + let filter = Targets::new().with_target("registry_server", level); + tracing_subscriber::registry() + .with(filter) + .with( + tracing_subscriber::fmt::layer() + .json() + .with_target(false) + .with_current_span(false) + .with_span_list(false), + ) + .init(); +} diff --git a/crates/registry-server/src/manifest_adapter.rs b/crates/registry-server/src/manifest_adapter.rs new file mode 100644 index 0000000000..3e76de6e63 --- /dev/null +++ b/crates/registry-server/src/manifest_adapter.rs @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: Apache-2.0 +//! One-way lossy Registry Manifest projection. + +use std::collections::{BTreeMap, BTreeSet}; + +use registry_manifest_core::{ + compile_manifest, AccessRights, AdmsStatus, CatalogManifest, DatasetManifest, FieldConstraints, + FieldManifest, FieldType, LocalizedText, MetadataError, MetadataManifest, PublisherManifest, + RelationshipManifest, Sensitivity, +}; +use registry_platform_canonical_json::canonicalize_json; +use serde_json::Value; + +use crate::artifacts::decimal_pattern; +use crate::contract::{ + Classification, FieldTypeSource, ManifestProjectionDatasetStatus, ManifestProjectionSource, + Operation, +}; +use crate::diagnostics::Diagnostic; +use crate::model::{CompiledEntity, CompiledField}; + +pub(crate) fn project_manifest_bytes( + registry_id: &str, + projection: &ManifestProjectionSource, + entities: &BTreeMap, +) -> Result, Diagnostic> { + let manifest = project_manifest(registry_id, projection, entities); + compile_manifest(&manifest).map_err(manifest_diagnostic)?; + let mut value = + serde_json::to_value(&manifest).map_err(|_| manifest_canonicalization_diagnostic())?; + strip_null_members(&mut value); + let manifest: MetadataManifest = serde_json::from_value(value.clone()) + .map_err(|_| manifest_canonicalization_diagnostic())?; + compile_manifest(&manifest).map_err(manifest_diagnostic)?; + canonicalize_json(&value).map_err(|_| manifest_canonicalization_diagnostic()) +} + +fn project_manifest( + registry_id: &str, + projection: &ManifestProjectionSource, + entities: &BTreeMap, +) -> MetadataManifest { + let visible_entities = visible_entities(projection, entities); + let access_rights = if selected_profile_is_anonymous(projection, &visible_entities) { + AccessRights::Public + } else { + AccessRights::Restricted + }; + + MetadataManifest { + schema_version: "registry-manifest/v1".to_owned(), + catalog: CatalogManifest { + id: registry_id.to_owned(), + base_url: projection.catalog.base_url.clone(), + title: LocalizedText::Plain(projection.catalog.title.clone()), + description: projection + .catalog + .description + .clone() + .map(LocalizedText::Plain), + publisher: PublisherManifest { + name: projection.catalog.publisher.name.clone(), + iri: projection.catalog.publisher.iri.clone(), + authority_type: projection.catalog.publisher.authority_type.clone(), + }, + participant_id: projection.catalog.participant_id.clone(), + conforms_to: Vec::new(), + standards: Default::default(), + application_profiles: Vec::new(), + }, + vocabularies: BTreeMap::new(), + profiles: Vec::new(), + evaluation_profiles: Vec::new(), + ecosystem_bindings: Vec::new(), + requirements: Vec::new(), + evidence_types: Vec::new(), + authorities: Vec::new(), + public_services: Vec::new(), + data_services: Vec::new(), + forms: Vec::new(), + datasets: vec![DatasetManifest { + id: registry_id.to_owned(), + title: LocalizedText::Plain(projection.dataset.title.clone()), + description: projection + .dataset + .description + .clone() + .map(LocalizedText::Plain), + owner: projection.dataset.owner.clone(), + sensitivity: sensitivity(projection.classification_ceiling), + access_rights, + update_frequency: Default::default(), + conforms_to: Vec::new(), + applicable_legislation: Vec::new(), + spatial_coverage: None, + status: projection.dataset.status.map(adms_status), + public_services: Vec::new(), + policy: None, + evidence_offerings: Vec::new(), + entities: visible_entities + .iter() + .map(|entity| project_entity(projection, entity, &visible_entities)) + .collect(), + }], + codelists: Vec::new(), + } +} + +fn visible_entities<'a>( + projection: &ManifestProjectionSource, + entities: &'a BTreeMap, +) -> Vec<&'a CompiledEntity> { + entities + .values() + .filter(|entity| entity.classification <= projection.classification_ceiling) + .filter(|entity| { + entity + .access_profiles + .get(&projection.access_profile) + .is_some_and(|profile| { + profile.operations.contains(&Operation::Get) + || profile.operations.contains(&Operation::List) + }) + }) + .collect() +} + +fn selected_profile_is_anonymous( + projection: &ManifestProjectionSource, + visible_entities: &[&CompiledEntity], +) -> bool { + visible_entities.iter().all(|entity| { + entity + .access_profiles + .get(&projection.access_profile) + .is_some_and(|profile| profile.anonymous) + }) +} + +fn project_entity( + projection: &ManifestProjectionSource, + entity: &CompiledEntity, + visible_entities: &[&CompiledEntity], +) -> registry_manifest_core::EntityManifest { + let visible_entity_ids = visible_entities + .iter() + .map(|entity| entity.id.as_str()) + .collect::>(); + let readable_fields = entity + .access_profiles + .get(&projection.access_profile) + .map(|profile| profile.readable_fields.clone()) + .unwrap_or_default(); + + let fields = entity + .fields + .values() + .filter(|field| readable_fields.contains(&field.id)) + .filter(|field| field.classification <= projection.classification_ceiling) + .filter_map(project_field) + .collect(); + let relationships = entity + .fields + .values() + .filter(|field| readable_fields.contains(&field.id)) + .filter(|field| field.classification <= projection.classification_ceiling) + .filter_map(|field| project_relationship(field, &visible_entity_ids)) + .collect(); + + registry_manifest_core::EntityManifest { + name: entity.id.clone(), + title: None, + description: None, + concept_uri: None, + identifiers: Vec::new(), + fields, + relationships, + } +} + +fn project_field(field: &CompiledField) -> Option { + let (field_type, constraints) = match &field.field_type { + FieldTypeSource::Boolean => (FieldType::Boolean, FieldConstraints::default()), + FieldTypeSource::String { + min_length, + max_length, + } => ( + FieldType::String, + FieldConstraints { + min_length: Some(u64::from(*min_length)), + max_length: Some(u64::from(*max_length)), + ..FieldConstraints::default() + }, + ), + FieldTypeSource::Text { max_length } => ( + FieldType::String, + FieldConstraints { + max_length: Some(u64::from(*max_length)), + ..FieldConstraints::default() + }, + ), + FieldTypeSource::Int64 => (FieldType::Integer, FieldConstraints::default()), + FieldTypeSource::Decimal { + precision, scale, .. + } => ( + FieldType::String, + FieldConstraints { + pattern: Some(decimal_pattern(*precision, *scale)), + ..FieldConstraints::default() + }, + ), + FieldTypeSource::Date => (FieldType::Date, FieldConstraints::default()), + FieldTypeSource::Timestamp => (FieldType::Timestamp, FieldConstraints::default()), + FieldTypeSource::Uuid => (FieldType::String, FieldConstraints::default()), + FieldTypeSource::VocabularyCode { values, .. } => ( + FieldType::Code, + FieldConstraints { + values: values.clone(), + ..FieldConstraints::default() + }, + ), + FieldTypeSource::Reference { .. } + | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Structured { .. } => return None, + }; + Some(FieldManifest { + name: field.id.clone(), + field_type, + required: field.required, + constraints, + concepts: Vec::new(), + codelist: None, + unit: None, + language: None, + }) +} + +fn project_relationship( + field: &CompiledField, + visible_entity_ids: &BTreeSet<&str>, +) -> Option { + let FieldTypeSource::Reference { target, .. } = &field.field_type else { + return None; + }; + visible_entity_ids + .contains(target.as_str()) + .then(|| RelationshipManifest { + name: field.id.clone(), + target_entity: Some(target.clone()), + target: None, + cardinality: Some(if field.required { + "one".to_owned() + } else { + "zero_or_one".to_owned() + }), + role: None, + concept_uri: None, + }) +} + +fn sensitivity(classification: Classification) -> Sensitivity { + match classification { + Classification::Public => Sensitivity::Public, + Classification::Internal => Sensitivity::Internal, + Classification::Restricted => Sensitivity::Confidential, + } +} + +fn adms_status(status: ManifestProjectionDatasetStatus) -> AdmsStatus { + match status { + ManifestProjectionDatasetStatus::UnderDevelopment => AdmsStatus::UnderDevelopment, + ManifestProjectionDatasetStatus::Active => AdmsStatus::Active, + ManifestProjectionDatasetStatus::Completed => AdmsStatus::Completed, + ManifestProjectionDatasetStatus::Deprecated => AdmsStatus::Deprecated, + ManifestProjectionDatasetStatus::Withdrawn => AdmsStatus::Withdrawn, + } +} + +fn strip_null_members(value: &mut Value) { + match value { + Value::Object(object) => { + object.retain(|_, child| { + strip_null_members(child); + !child.is_null() + }); + } + Value::Array(values) => { + for child in values { + strip_null_members(child); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} + +fn manifest_diagnostic(error: MetadataError) -> Diagnostic { + match error { + MetadataError::VersionUnsupported => Diagnostic::error( + "manifest_projection.invalid", + "project.manifestProjection", + "the Registry Manifest projection is invalid", + ), + MetadataError::Validation { errors } => { + let path = errors + .first() + .map(|error| format!("project.manifestProjection.{}", error.path)) + .unwrap_or_else(|| "project.manifestProjection".to_owned()); + Diagnostic::error( + "manifest_projection.invalid", + path, + "the Registry Manifest projection is invalid", + ) + } + } +} + +fn manifest_canonicalization_diagnostic() -> Diagnostic { + Diagnostic::error( + "manifest_projection.canonicalization_failed", + "project.manifestProjection", + "the Registry Manifest projection could not be canonicalized", + ) +} + +#[cfg(test)] +mod tests { + use registry_manifest_core::FieldType; + + use super::project_field; + use crate::contract::{Classification, FieldTypeSource}; + use crate::model::CompiledField; + + #[test] + fn decimal_projection_preserves_the_canonical_string_wire_contract() { + let projected = project_field(&CompiledField { + id: "measurement".to_owned(), + field_type: FieldTypeSource::Decimal { + precision: 12, + scale: 4, + minimum: None, + maximum: None, + }, + required: true, + classification: Classification::Internal, + valid_time_role: None, + physical_name: "field_measurement".to_owned(), + }) + .expect("decimal is representable in the portable Manifest"); + + assert_eq!(projected.field_type, FieldType::String); + assert_eq!( + projected.constraints.pattern.as_deref(), + Some("^-?(0|[1-9][0-9]{0,7})\\.[0-9]{4}$") + ); + } +} diff --git a/crates/registry-server/src/migration.rs b/crates/registry-server/src/migration.rs new file mode 100644 index 0000000000..8859f7b7b6 --- /dev/null +++ b/crates/registry-server/src/migration.rs @@ -0,0 +1,711 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Verified package apply coordinator. + +use std::{ + fs::File, + io::Read as _, + path::{Path, PathBuf}, + time::Duration, +}; + +use sha2::{Digest, Sha256}; +use thiserror::Error; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + +use crate::migration_plan::{ + ExternalBackupBinding, ReviewedMigrationStepDescriptor, ValidatedReviewedMigrationPlan, +}; +use crate::package::{PackageFileRole, VerifiedPackage}; +use crate::postgres::{ + statement_checksum, ConnectionConfig, ExpectedManagedCatalog, ExpectedRegistryIdentity, + MigrationArtifactBinding, MigrationLedgerEntry, MigrationLedgerStep, MigrationLedgerStepKind, + MigrationPlanKind, PackageDdlStatement, RegistryLockKey, ReviewedExecutionOutcome, + ReviewedPackageExecutionRequest, SqlIdentifier, VerifiedPackageApplyConnection, +}; + +const MAX_LOCK_TIMEOUT: Duration = Duration::from_secs(300); +const MAX_STATEMENT_TIMEOUT: Duration = Duration::from_secs(60 * 60); + +/// Value-free apply failures. Neither SQL nor database or package values cross +/// this boundary. +#[derive(Debug, Error, Clone, Copy, Eq, PartialEq)] +pub enum MigrationError { + #[error("the verified package is not a valid activation successor")] + PackageBinding, + #[error("the verified package has no additive migration work")] + EmptyPlan, + #[error("the Registry package apply failed")] + ApplyFailed, + #[error("destructive backup evidence is invalid")] + BackupEvidence, +} + +pub type Result = std::result::Result; + +/// Exact durable precondition under which a verified package may be applied. +pub enum ApplyPrecondition<'a> { + InitialActivation, + Successor { + current: &'a ExpectedRegistryIdentity, + }, +} + +/// The configured least-privilege database roles used by one apply. +#[derive(Clone, Copy)] +pub struct ApplyRoles<'a> { + migration: &'a SqlIdentifier, + runtime: &'a SqlIdentifier, +} + +impl<'a> ApplyRoles<'a> { + #[must_use] + pub fn new(migration: &'a SqlIdentifier, runtime: &'a SqlIdentifier) -> Self { + Self { migration, runtime } + } +} + +/// Bounded lock and per-statement execution timeouts for one apply. +#[derive(Clone, Copy)] +pub struct ApplyTimeouts { + lock: Duration, + statement: Duration, +} + +/// One local external-backup file bound to the reviewed package artifact that +/// describes it. The path grants authority only to read and retain that exact +/// file for this apply; it cannot add SQL, a checkpoint, or a migration target. +#[derive(Clone, Copy)] +pub struct DestructiveBackupEvidence<'a> { + binding_path: &'a str, + local_path: &'a Path, +} + +impl<'a> DestructiveBackupEvidence<'a> { + #[must_use] + pub fn new(binding_path: &'a str, local_path: &'a Path) -> Self { + Self { + binding_path, + local_path, + } + } +} + +#[cfg(feature = "postgres-test")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[doc(hidden)] +pub enum ReviewedMigrationFaultPoint { + AfterCommittedChunk(u64), +} + +impl ApplyTimeouts { + pub fn new(lock: Duration, statement: Duration) -> Result { + if lock < Duration::from_millis(1) + || lock > MAX_LOCK_TIMEOUT + || statement < Duration::from_millis(1) + || statement > MAX_STATEMENT_TIMEOUT + { + return Err(MigrationError::ApplyFailed); + } + Ok(Self { lock, statement }) + } +} + +/// Closed library request for applying one already verified package. There is +/// no raw path, arbitrary SQL, down-migration, backfill, or destructive-plan +/// entry point in this lifecycle. +pub struct ApplyVerifiedPackageRequest<'a> { + config: &'a ConnectionConfig, + package: &'a VerifiedPackage, + precondition: ApplyPrecondition<'a>, + roles: ApplyRoles<'a>, + timeouts: ApplyTimeouts, + backup_evidence: &'a [DestructiveBackupEvidence<'a>], + fault_after_committed_chunks: Option, +} + +impl<'a> ApplyVerifiedPackageRequest<'a> { + #[must_use] + pub fn new( + config: &'a ConnectionConfig, + package: &'a VerifiedPackage, + precondition: ApplyPrecondition<'a>, + roles: ApplyRoles<'a>, + timeouts: ApplyTimeouts, + ) -> Self { + Self { + config, + package, + precondition, + roles, + timeouts, + backup_evidence: &[], + fault_after_committed_chunks: None, + } + } + + #[must_use] + pub fn with_destructive_backup_evidence( + mut self, + evidence: &'a [DestructiveBackupEvidence<'a>], + ) -> Self { + self.backup_evidence = evidence; + self + } + + #[cfg(feature = "postgres-test")] + #[must_use] + #[doc(hidden)] + pub fn with_fault_for_test(mut self, fault: ReviewedMigrationFaultPoint) -> Self { + self.fault_after_committed_chunks = match fault { + ReviewedMigrationFaultPoint::AfterCommittedChunk(chunks) => Some(chunks), + }; + self + } +} + +/// Apply the exact plan already rederived by the package verifier, verify the +/// resulting managed catalog and signed schema fingerprint, and atomically +/// activate its identity with an immutable applied-ledger outcome. +/// +/// Threat: a caller might try to run SQL outside the reviewed package, apply a +/// startup package, skip sequence/prior checks, clear failed maintenance with a +/// different target, or race record work. Enforcement is this package-only +/// coordinator plus the exact-role dedicated session lock and ledger. Failures +/// after the control plane begins leave applying or failed state, so records +/// remain unavailable and recovery is exact-target fix-forward only. +pub async fn apply_verified_package( + request: ApplyVerifiedPackageRequest<'_>, +) -> Result { + let manifest = request.package.manifest(); + let target_sequence = + i64::try_from(manifest.sequence).map_err(|_| MigrationError::PackageBinding)?; + let target = ExpectedRegistryIdentity { + package_id: manifest.package_id.clone(), + environment: manifest.environment.clone(), + instance_id: manifest.instance_id.clone(), + database_id: manifest.database_id.clone(), + package_revision: manifest.package_revision.clone(), + schema_fingerprint: manifest.schema_fingerprint.clone(), + package_sequence: target_sequence, + }; + let current = match request.precondition { + ApplyPrecondition::InitialActivation => { + if !request.package.verified_for_initial_activation() + || manifest.sequence != 1 + || manifest.prior_revision.is_some() + || manifest.migration_plan.from_revision.is_some() + { + return Err(MigrationError::PackageBinding); + } + None + } + ApplyPrecondition::Successor { current } => { + current + .validate() + .map_err(|_| MigrationError::PackageBinding)?; + let active_sequence = u64::try_from(current.package_sequence) + .map_err(|_| MigrationError::PackageBinding)?; + if !request + .package + .verified_for_activation(¤t.package_revision, active_sequence) + || manifest.environment != current.environment + || manifest.package_id != current.package_id + || manifest.instance_id != current.instance_id + || manifest.database_id != current.database_id + || manifest.prior_revision.as_deref() != Some(current.package_revision.as_str()) + || manifest.migration_plan.from_revision.as_deref() + != Some(current.package_revision.as_str()) + || target_sequence <= current.package_sequence + { + return Err(MigrationError::PackageBinding); + } + Some(current) + } + }; + let reviewed_plan = request.package.reviewed_migration_plan(); + if manifest.migration_plan.reviewed_descriptors.is_empty() != reviewed_plan.is_none() + || reviewed_plan.is_some() && current.is_none() + { + return Err(MigrationError::PackageBinding); + } + if manifest.migration_plan.statements.is_empty() && reviewed_plan.is_none() { + return Err(MigrationError::EmptyPlan); + } + + let compiler_checksums = manifest + .migration_plan + .statements + .iter() + .map(|statement| statement_checksum(&statement.sql)) + .collect::>(); + let ledger = if let Some(plan) = reviewed_plan { + reviewed_ledger( + request.package, + current.ok_or(MigrationError::PackageBinding)?, + plan, + )? + } else { + MigrationLedgerEntry { + source_revision: current.map(|identity| identity.package_revision.clone()), + target_revision: target.package_revision.clone(), + package_sequence: target.package_sequence, + plan_kind: MigrationPlanKind::CompiledAdditive, + statement_checksums: compiler_checksums.clone(), + artifact_bindings: Vec::new(), + steps: Vec::new(), + } + }; + let statements = manifest + .migration_plan + .statements + .iter() + .zip(&compiler_checksums) + .map(|(statement, checksum)| PackageDdlStatement { + sql: &statement.sql, + checksum, + }) + .collect::>(); + + // Threat: a path-only backup check could be swapped between validation + // and the maintenance transition. The library opens with NOFOLLOW, checks + // exact package-bound metadata and bytes, and retains every descriptor + // through activation. It never interprets backup contents or grants them + // package or migration authority. + let _retained_backup_evidence = verify_destructive_backup_evidence( + reviewed_plan, + current, + &target, + request.backup_evidence, + ) + .await?; + + let lock_key = + RegistryLockKey::derive(&manifest.package_id).map_err(|_| MigrationError::ApplyFailed)?; + let mut connection = VerifiedPackageApplyConnection::acquire_for_verified_package( + request.config, + lock_key, + request.roles.migration, + request.timeouts.lock, + request.timeouts.statement, + ) + .await + .map_err(|_| MigrationError::ApplyFailed)?; + let began = if let Some(current) = current { + connection + .begin_successor_package(current, &target, &ledger) + .await + } else { + connection + .begin_initial_package(&target, &ledger, request.roles.runtime) + .await + }; + if began.is_err() { + let _ = connection.release().await; + return Err(MigrationError::ApplyFailed); + } + + let expected_catalog = ExpectedManagedCatalog::compiled(request.package.registry()); + if let Some(plan) = reviewed_plan { + let prior_tables = manifest + .migration_plan + .prior_baseline + .as_ref() + .ok_or(MigrationError::PackageBinding)? + .entities + .values() + .map(|entity| entity.physical_table.clone()) + .collect::>(); + let candidate_tables = request + .package + .registry() + .entities() + .values() + .map(|entity| entity.physical_table.clone()) + .collect::>(); + let execution = connection + .execute_reviewed_package_plan(ReviewedPackageExecutionRequest { + registry: request.package.registry(), + plan, + compiler_statements: &statements, + ledger: &ledger, + prior_tables: &prior_tables, + candidate_tables: &candidate_tables, + compiler_lock_timeout: request.timeouts.lock, + compiler_statement_timeout: request.timeouts.statement, + fault_after_committed_chunks: request.fault_after_committed_chunks, + }) + .await; + match execution { + Ok(ReviewedExecutionOutcome::Complete) => {} + Ok(ReviewedExecutionOutcome::Interrupted) => { + let _ = connection.release().await; + return Err(MigrationError::ApplyFailed); + } + Err(_) => return fail_and_release(connection, &target, &ledger).await, + } + if connection + .reconcile_runtime_acl(request.package.registry(), request.roles.runtime) + .await + .is_err() + { + return fail_and_release(connection, &target, &ledger).await; + } + if connection + .activate_verified_package( + current, + &target, + &ledger, + &expected_catalog, + request.roles.migration, + request.roles.runtime, + ) + .await + .is_err() + { + return fail_and_release(connection, &target, &ledger).await; + } + connection + .release() + .await + .map_err(|_| MigrationError::ApplyFailed)?; + return Ok(target); + } + + if connection + .reconcile_runtime_acl(request.package.registry(), request.roles.runtime) + .await + .is_ok() + && connection + .activate_verified_package( + current, + &target, + &ledger, + &expected_catalog, + request.roles.migration, + request.roles.runtime, + ) + .await + .is_ok() + { + connection + .release() + .await + .map_err(|_| MigrationError::ApplyFailed)?; + return Ok(target); + } + + let ddl_result = if current.is_some() { + connection + .execute_successor_package_ddl(&statements, request.timeouts.statement) + .await + } else { + connection + .execute_initial_package_ddl( + request.package.registry(), + &statements, + request.roles.runtime, + request.timeouts.statement, + ) + .await + }; + if ddl_result.is_err() { + return fail_and_release(connection, &target, &ledger).await; + } + let acl_result = connection + .reconcile_runtime_acl(request.package.registry(), request.roles.runtime) + .await; + if acl_result.is_err() { + return fail_and_release(connection, &target, &ledger).await; + } + let activation_result = connection + .activate_verified_package( + current, + &target, + &ledger, + &expected_catalog, + request.roles.migration, + request.roles.runtime, + ) + .await; + if activation_result.is_err() { + return fail_and_release(connection, &target, &ledger).await; + } + connection + .release() + .await + .map_err(|_| MigrationError::ApplyFailed)?; + Ok(target) +} + +async fn fail_and_release( + mut connection: VerifiedPackageApplyConnection, + target: &ExpectedRegistryIdentity, + ledger: &MigrationLedgerEntry, +) -> Result { + let marked_failed = connection + .mark_verified_package_failed(target, ledger) + .await + .is_ok(); + let released = connection.release().await.is_ok(); + let _ = (marked_failed, released); + Err(MigrationError::ApplyFailed) +} + +fn reviewed_ledger( + package: &VerifiedPackage, + current: &ExpectedRegistryIdentity, + plan: &ValidatedReviewedMigrationPlan, +) -> Result { + let manifest = package.manifest(); + if plan.migrations().is_empty() + || manifest.migration_plan.prior_schema_fingerprint.as_deref() + != Some(current.schema_fingerprint.as_str()) + { + return Err(MigrationError::PackageBinding); + } + + let mut statement_checksums = Vec::new(); + for migration in plan.migrations() { + if migration.rehearsal_receipt.prior_revision != current.package_revision + || migration.rehearsal_receipt.prior_schema_fingerprint != current.schema_fingerprint + || migration.rehearsal_receipt.final_schema_fingerprint != manifest.schema_fingerprint + { + return Err(MigrationError::PackageBinding); + } + statement_checksums.extend( + migration + .pre_assertions + .iter() + .map(|assertion| assertion.sha256.clone()), + ); + } + statement_checksums.extend( + manifest + .migration_plan + .statements + .iter() + .map(|statement| statement_checksum(&statement.sql)), + ); + for migration in plan.migrations() { + statement_checksums.extend(migration.steps.iter().map(|step| step.sha256.clone())); + } + for migration in plan.migrations() { + statement_checksums.extend( + migration + .post_assertions + .iter() + .map(|assertion| assertion.sha256.clone()), + ); + } + + let artifact_bindings = manifest + .files + .iter() + .filter(|file| { + matches!( + file.role, + PackageFileRole::ReviewedMigrationDescriptor + | PackageFileRole::ReviewedMigrationStepSql + | PackageFileRole::ReviewedMigrationAssertionSql + | PackageFileRole::MigrationRehearsalReceipt + | PackageFileRole::ExternalBackupBinding + | PackageFileRole::MigrationRehearsalFixture + ) + }) + .map(|file| MigrationArtifactBinding { + path: file.path.clone(), + checksum: file.sha256.clone(), + }) + .collect::>(); + + let mut steps = Vec::new(); + for (step_index, statement) in manifest.migration_plan.statements.iter().enumerate() { + steps.push(MigrationLedgerStep { + migration_ordinal: 0, + step_ordinal: i32::try_from(step_index).map_err(|_| MigrationError::PackageBinding)?, + step_id: format!("compiler-{step_index:04}"), + kind: MigrationLedgerStepKind::CompilerDdl, + checksum: statement_checksum(&statement.sql), + }); + } + for (migration_index, migration) in plan.migrations().iter().enumerate() { + let migration_ordinal = + i32::try_from(migration_index + 1).map_err(|_| MigrationError::PackageBinding)?; + for (step_index, step) in migration.steps.iter().enumerate() { + let (step_id, kind) = match &step.descriptor { + ReviewedMigrationStepDescriptor::TransactionalSql { id, .. } => { + (id.clone(), MigrationLedgerStepKind::TransactionalSql) + } + ReviewedMigrationStepDescriptor::ChunkedBackfill { id, .. } => { + (id.clone(), MigrationLedgerStepKind::ChunkedBackfill) + } + }; + steps.push(MigrationLedgerStep { + migration_ordinal, + step_ordinal: i32::try_from(step_index) + .map_err(|_| MigrationError::PackageBinding)?, + step_id, + kind, + checksum: step.sha256.clone(), + }); + } + } + + let ledger = MigrationLedgerEntry { + source_revision: Some(current.package_revision.clone()), + target_revision: manifest.package_revision.clone(), + package_sequence: i64::try_from(manifest.sequence) + .map_err(|_| MigrationError::PackageBinding)?, + plan_kind: MigrationPlanKind::Reviewed, + statement_checksums, + artifact_bindings, + steps, + }; + ledger + .validate() + .map_err(|_| MigrationError::PackageBinding)?; + Ok(ledger) +} + +async fn verify_destructive_backup_evidence( + plan: Option<&ValidatedReviewedMigrationPlan>, + current: Option<&ExpectedRegistryIdentity>, + target: &ExpectedRegistryIdentity, + evidence: &[DestructiveBackupEvidence<'_>], +) -> Result> { + let Some(plan) = plan else { + return if evidence.is_empty() { + Ok(Vec::new()) + } else { + Err(MigrationError::BackupEvidence) + }; + }; + let current = current.ok_or(MigrationError::PackageBinding)?; + let required = plan + .migrations() + .iter() + .filter_map(|migration| { + migration + .descriptor + .backup_binding_path + .as_deref() + .zip(migration.backup_binding.as_ref()) + }) + .collect::>(); + if required.len() != evidence.len() { + return Err(MigrationError::BackupEvidence); + } + + let mut retained = Vec::with_capacity(required.len()); + for ((binding_path, binding), supplied) in required.into_iter().zip(evidence) { + if supplied.binding_path != binding_path + || !supplied.local_path.is_absolute() + || binding.database_id != current.database_id + || binding.prior_revision != current.package_revision + || binding.prior_schema_fingerprint != current.schema_fingerprint + || target.database_id != current.database_id + || target.package_revision == current.package_revision + { + return Err(MigrationError::BackupEvidence); + } + let created = OffsetDateTime::parse(&binding.created_at, &Rfc3339) + .map_err(|_| MigrationError::BackupEvidence)?; + let now = OffsetDateTime::now_utc(); + if created > now + || (now - created).whole_seconds() + > i64::try_from(binding.max_age_seconds) + .map_err(|_| MigrationError::BackupEvidence)? + { + return Err(MigrationError::BackupEvidence); + } + let path = supplied.local_path.to_path_buf(); + let binding = binding.clone(); + retained.push( + tokio::task::spawn_blocking(move || open_bound_backup(path, &binding)) + .await + .map_err(|_| MigrationError::BackupEvidence)??, + ); + } + Ok(retained) +} + +#[cfg(unix)] +fn open_bound_backup(path: PathBuf, binding: &ExternalBackupBinding) -> Result { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + use rustix::fs::{Mode, OFlags}; + + let before = std::fs::symlink_metadata(&path).map_err(|_| MigrationError::BackupEvidence)?; + if before.file_type().is_symlink() || !before.is_file() { + return Err(MigrationError::BackupEvidence); + } + let descriptor = rustix::fs::open( + &path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| MigrationError::BackupEvidence)?; + let file = File::from(descriptor); + let opened = file + .metadata() + .map_err(|_| MigrationError::BackupEvidence)?; + let after = std::fs::symlink_metadata(&path).map_err(|_| MigrationError::BackupEvidence)?; + if !opened.is_file() + || after.file_type().is_symlink() + || !same_backup_file(&before, &opened) + || !same_backup_file(&opened, &after) + || opened.uid() != rustix::process::geteuid().as_raw() + || opened.permissions().mode() & 0o7777 != 0o600 + || opened.nlink() != 1 + || opened.len() != binding.byte_length + { + return Err(MigrationError::BackupEvidence); + } + verify_backup_digest(file, binding) +} + +#[cfg(unix)] +fn same_backup_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt as _; + + left.dev() == right.dev() + && left.ino() == right.ino() + && left.len() == right.len() + && left.mtime() == right.mtime() + && left.mtime_nsec() == right.mtime_nsec() +} + +#[cfg(not(unix))] +fn open_bound_backup(_path: PathBuf, _binding: &ExternalBackupBinding) -> Result { + // The reviewed destructive path requires owner and link-count proofs that + // this runtime currently obtains only from Unix descriptor metadata. + Err(MigrationError::BackupEvidence) +} + +fn verify_backup_digest(mut file: File, binding: &ExternalBackupBinding) -> Result { + let mut reader = (&mut file).take(binding.byte_length.saturating_add(1)); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + let mut read = 0_u64; + loop { + let count = reader + .read(&mut buffer) + .map_err(|_| MigrationError::BackupEvidence)?; + if count == 0 { + break; + } + read = read + .checked_add(u64::try_from(count).map_err(|_| MigrationError::BackupEvidence)?) + .ok_or(MigrationError::BackupEvidence)?; + hasher.update(&buffer[..count]); + } + let mut checksum = String::from("sha256:"); + for byte in hasher.finalize() { + use std::fmt::Write as _; + write!(&mut checksum, "{byte:02x}").expect("writing to a String cannot fail"); + } + if read != binding.byte_length || checksum != binding.sha256 { + return Err(MigrationError::BackupEvidence); + } + Ok(file) +} diff --git a/crates/registry-server/src/migration_plan.rs b/crates/registry-server/src/migration_plan.rs new file mode 100644 index 0000000000..37ff584229 --- /dev/null +++ b/crates/registry-server/src/migration_plan.rs @@ -0,0 +1,1726 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Closed, reviewed migration descriptors and PostgreSQL AST validation. +//! +//! Threat: a reviewed migration artifact could otherwise smuggle a second +//! statement, session or role mutation, cross-schema access, unbounded DML, or +//! evidence for a different package into a signed package. This module is the +//! single validator used while constructing and rederiving package closure. + +#[cfg(feature = "tooling")] +use std::collections::{BTreeMap, BTreeSet}; + +#[cfg(feature = "tooling")] +use pg_query::protobuf::{ + a_const, node::Node as PgNode, AExprKind, AlterTableType, ConstrType, ObjectType, SetOperation, + SubLinkType, +}; +#[cfg(feature = "tooling")] +use pg_query::NodeRef; +#[cfg(feature = "tooling")] +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "tooling")] +use sha2::{Digest, Sha256}; +#[cfg(feature = "tooling")] +use thiserror::Error; +#[cfg(feature = "tooling")] +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; + +#[cfg(feature = "tooling")] +use crate::model::CompiledEntity; +#[cfg(feature = "tooling")] +use crate::package::CompiledRegistryChangeTargetKind; +use crate::package::{ + CompiledRegistryChange, CompiledRegistryChangeClass, CompiledRegistryChangeCode, + CompiledRegistryChangeTarget, +}; + +#[cfg(feature = "tooling")] +const MAX_DESCRIPTOR_BYTES: usize = 1024 * 1024; +#[cfg(feature = "tooling")] +const MAX_SQL_BYTES: usize = 1024 * 1024; +#[cfg(feature = "tooling")] +const MAX_FIXTURE_BYTES: usize = 16 * 1024 * 1024; +#[cfg(feature = "tooling")] +const MAX_ARTIFACTS: usize = 1024; +#[cfg(feature = "tooling")] +const MAX_STEPS: usize = 256; +#[cfg(feature = "tooling")] +const MAX_ASSERTIONS: usize = 256; +#[cfg(feature = "tooling")] +const MAX_LOCK_TIMEOUT_MS: u64 = 300_000; +#[cfg(feature = "tooling")] +const MAX_STATEMENT_TIMEOUT_MS: u64 = 3_600_000; +#[cfg(feature = "tooling")] +const MAX_CHUNK_SIZE: u32 = 10_000; +#[cfg(feature = "tooling")] +const MAX_TOTAL_ROWS: u64 = 100_000_000; +#[cfg(feature = "tooling")] +const MAX_BACKUP_AGE_SECONDS: u64 = 31 * 24 * 60 * 60; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewedMigrationFile { + pub path: String, + pub bytes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewedMigrationSource { + pub module_id: String, + pub descriptor: ReviewedMigrationFile, + pub files: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReviewedMigrationDescriptor { + pub id: String, + pub change_class: CompiledRegistryChangeClass, + pub covers: Vec, + pub recovery: ReviewedMigrationRecovery, + pub lock_timeout_ms: u64, + pub statement_timeout_ms: u64, + pub steps: Vec, + pub pre_assertions: Vec, + pub post_assertions: Vec, + pub rehearsal_receipt_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backup_binding_path: Option, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReviewedChangeCover { + pub code: CompiledRegistryChangeCode, + pub target: CompiledRegistryChangeTarget, +} + +impl From<&CompiledRegistryChange> for ReviewedChangeCover { + fn from(change: &CompiledRegistryChange) -> Self { + Self { + code: change.code, + target: change.target.clone(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewedMigrationRecovery { + ExactTargetResume, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", deny_unknown_fields, rename_all = "snake_case")] +pub enum ReviewedMigrationStepDescriptor { + TransactionalSql { + id: String, + sql_path: String, + objects: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + affected_rows: Option, + }, + ChunkedBackfill { + id: String, + entity_id: String, + sql_path: String, + objects: Vec, + cursor: ChunkCursorProtocol, + chunk_size: u32, + max_total_rows: u64, + lock_timeout_ms: u64, + statement_timeout_ms: u64, + exact_affected_rows: bool, + }, +} + +impl ReviewedMigrationStepDescriptor { + #[cfg(feature = "tooling")] + fn id(&self) -> &str { + match self { + Self::TransactionalSql { id, .. } | Self::ChunkedBackfill { id, .. } => id, + } + } + + #[cfg(feature = "tooling")] + fn sql_path(&self) -> &str { + match self { + Self::TransactionalSql { sql_path, .. } | Self::ChunkedBackfill { sql_path, .. } => { + sql_path + } + } + } + + #[cfg(feature = "tooling")] + fn objects(&self) -> &[ReviewedMigrationObject] { + match self { + Self::TransactionalSql { objects, .. } | Self::ChunkedBackfill { objects, .. } => { + objects + } + } + } +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReviewedMigrationObject { + pub schema: String, + pub table: String, + pub entity_id: String, + pub kind: ReviewedMigrationObjectKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub member_id: Option, + pub physical_name: String, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewedMigrationObjectKind { + Entity, + Field, + Constraint, + Index, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChunkCursorProtocol { + RecordIdUuidArray, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AffectedRowBounds { + pub min: u64, + pub max: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReviewedMigrationAssertionDescriptor { + pub id: String, + pub sql_path: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct MigrationRehearsalReceipt { + pub prior_revision: String, + pub prior_schema_fingerprint: String, + pub plan_sha256: String, + pub sql_sha256: Vec, + pub assertion_sha256: Vec, + pub fixture_inventory: Vec, + pub postgres_major: u16, + pub row_assertions: Vec, + pub final_schema_fingerprint: String, + pub proofs: RehearsalProofs, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ArtifactDigestBinding { + pub path: String, + pub sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RehearsalFixture { + pub id: String, + pub path: String, + pub sha256: String, + pub row_count: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RehearsalRowAssertion { + pub step_id: String, + pub affected_rows: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RehearsalProofs { + pub lock_timeout: bool, + pub chunk_resume: bool, + pub destructive_resume: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ExternalBackupBinding { + pub database_id: String, + pub prior_revision: String, + pub prior_schema_fingerprint: String, + pub sha256: String, + pub byte_length: u64, + pub created_at: String, + pub max_age_seconds: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedReviewedMigrationPlan { + migrations: Vec, +} + +impl ValidatedReviewedMigrationPlan { + #[must_use] + pub fn migrations(&self) -> &[ValidatedReviewedMigration] { + &self.migrations + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedReviewedMigration { + pub module_id: String, + pub descriptor_path: String, + pub descriptor: ReviewedMigrationDescriptor, + pub steps: Vec, + pub pre_assertions: Vec, + pub post_assertions: Vec, + pub rehearsal_receipt: MigrationRehearsalReceipt, + pub backup_binding: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedReviewedMigrationStep { + pub descriptor: ReviewedMigrationStepDescriptor, + pub sql: String, + pub sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidatedReviewedMigrationAssertion { + pub descriptor: ReviewedMigrationAssertionDescriptor, + pub sql: String, + pub sha256: String, +} + +#[cfg(feature = "tooling")] +#[derive(Clone, Debug)] +pub(crate) struct ReviewedPlanBindings<'a> { + pub prior_revision: &'a str, + pub prior_schema_fingerprint: &'a str, + pub final_schema_fingerprint: &'a str, + pub database_id: &'a str, + pub changes: &'a [CompiledRegistryChange], + pub prior_entities: &'a BTreeMap, + pub candidate_entities: &'a BTreeMap, + pub prior_physical_names: &'a crate::physical_names::PhysicalNameInventory, + pub candidate_physical_names: &'a crate::physical_names::PhysicalNameInventory, +} + +#[cfg(feature = "tooling")] +#[derive(Clone, Debug)] +pub(crate) struct PreparedReviewedMigrationPlan { + pub descriptor_paths: Vec, + pub files: BTreeMap>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ReviewedArtifactKind { + Descriptor, + StepSql, + AssertionSql, + RehearsalReceipt, + BackupBinding, + Fixture, +} + +#[cfg(feature = "tooling")] +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub(crate) enum ReviewedMigrationError { + #[error("the reviewed migration descriptor is invalid")] + Descriptor, + #[error("the reviewed migration coverage is invalid")] + Coverage, + #[error("the reviewed migration SQL is outside the accepted AST")] + Sql, + #[error("the reviewed migration evidence is not bound")] + Evidence, + #[error("the reviewed migration artifact closure is invalid")] + Closure, +} + +#[cfg(feature = "tooling")] +pub(crate) fn prepare_reviewed_migration_plan( + sources: &[ReviewedMigrationSource], + bindings: &ReviewedPlanBindings<'_>, +) -> Result { + if sources.is_empty() || sources.len() > MAX_ARTIFACTS { + return Err(ReviewedMigrationError::Coverage); + } + let mut descriptor_paths = Vec::with_capacity(sources.len()); + let mut files = BTreeMap::new(); + let mut prior_descriptor = None; + for source in sources { + if !valid_id(&source.module_id) + || prior_descriptor + .as_ref() + .is_some_and(|prior: &String| prior >= &source.descriptor.path) + { + return Err(ReviewedMigrationError::Descriptor); + } + prior_descriptor = Some(source.descriptor.path.clone()); + descriptor_paths.push(source.descriptor.path.clone()); + if files + .insert( + source.descriptor.path.clone(), + source.descriptor.bytes.clone(), + ) + .is_some() + { + return Err(ReviewedMigrationError::Closure); + } + for file in &source.files { + if files + .insert(file.path.clone(), file.bytes.clone()) + .is_some() + { + return Err(ReviewedMigrationError::Closure); + } + } + } + let validated = validate_reviewed_migration_plan(&descriptor_paths, &files, bindings)?; + for (source, migration) in sources.iter().zip(validated.migrations()) { + if source.module_id != migration.module_id { + return Err(ReviewedMigrationError::Descriptor); + } + } + Ok(PreparedReviewedMigrationPlan { + descriptor_paths, + files, + }) +} + +#[cfg(feature = "tooling")] +pub(crate) fn validate_reviewed_migration_plan( + descriptor_paths: &[String], + files: &BTreeMap>, + bindings: &ReviewedPlanBindings<'_>, +) -> Result { + if descriptor_paths.len() > MAX_ARTIFACTS + || !strictly_sorted(descriptor_paths.iter().map(String::as_str)) + { + return Err(ReviewedMigrationError::Closure); + } + if bindings + .changes + .iter() + .any(|change| change.class == CompiledRegistryChangeClass::Unsupported) + { + return Err(ReviewedMigrationError::Coverage); + } + + let declared_tables = bindings + .prior_entities + .values() + .chain(bindings.candidate_entities.values()) + .map(|entity| entity.physical_table.as_str()) + .collect::>(); + let non_additive = bindings + .changes + .iter() + .filter(|change| change.class != CompiledRegistryChangeClass::CompatibleAdditive) + .map(|change| (ReviewedChangeCover::from(change), change.class)) + .collect::>(); + if non_additive.is_empty() != descriptor_paths.is_empty() { + return Err(ReviewedMigrationError::Coverage); + } + + let mut claimed = BTreeSet::new(); + let mut referenced_paths = BTreeSet::new(); + let mut migrations = Vec::with_capacity(descriptor_paths.len()); + for descriptor_path in descriptor_paths { + let descriptor_bytes = files + .get(descriptor_path) + .ok_or(ReviewedMigrationError::Closure)?; + if descriptor_bytes.len() > MAX_DESCRIPTOR_BYTES { + return Err(ReviewedMigrationError::Descriptor); + } + let descriptor: ReviewedMigrationDescriptor = parse_canonical(descriptor_bytes)?; + let (module_id, base) = descriptor_base(descriptor_path, &descriptor.id)?; + referenced_paths.insert(descriptor_path.clone()); + validate_descriptor_shape(&descriptor, &base)?; + for cover in &descriptor.covers { + let Some(expected_class) = non_additive.get(cover) else { + return Err(ReviewedMigrationError::Coverage); + }; + if *expected_class != descriptor.change_class || !claimed.insert(cover.clone()) { + return Err(ReviewedMigrationError::Coverage); + } + } + + let mut steps = Vec::with_capacity(descriptor.steps.len()); + let mut object_covers = BTreeSet::new(); + for step in &descriptor.steps { + let path = step.sql_path(); + referenced_paths.insert(path.to_owned()); + let sql = read_sql(files, path)?; + validate_step_sql(step, sql, &descriptor, bindings, &declared_tables)?; + for object in step.objects() { + object_covers.insert(object_cover(object, &descriptor.covers)?); + } + steps.push(ValidatedReviewedMigrationStep { + descriptor: step.clone(), + sql: sql.to_owned(), + sha256: digest(sql.as_bytes()), + }); + } + let descriptor_covers = descriptor.covers.iter().cloned().collect::>(); + if descriptor.steps.is_empty() && covers_are_metadata_only(&descriptor.covers) { + object_covers = descriptor_covers.clone(); + } + if object_covers != descriptor_covers { + return Err(ReviewedMigrationError::Coverage); + } + let pre_assertions = validate_assertions( + &descriptor.pre_assertions, + files, + &declared_tables, + &mut referenced_paths, + )?; + let post_assertions = validate_assertions( + &descriptor.post_assertions, + files, + &declared_tables, + &mut referenced_paths, + )?; + + referenced_paths.insert(descriptor.rehearsal_receipt_path.clone()); + let receipt_bytes = files + .get(&descriptor.rehearsal_receipt_path) + .ok_or(ReviewedMigrationError::Evidence)?; + let receipt: MigrationRehearsalReceipt = + parse_canonical(receipt_bytes).map_err(|_| ReviewedMigrationError::Evidence)?; + validate_receipt( + &receipt, + ReceiptValidationContext { + descriptor_bytes, + steps: &steps, + pre_assertions: &pre_assertions, + post_assertions: &post_assertions, + descriptor: &descriptor, + bindings, + base: &base, + files, + referenced_paths: &mut referenced_paths, + }, + )?; + + let backup_binding = match &descriptor.backup_binding_path { + Some(path) => { + referenced_paths.insert(path.clone()); + let bytes = files.get(path).ok_or(ReviewedMigrationError::Evidence)?; + let binding: ExternalBackupBinding = + parse_canonical(bytes).map_err(|_| ReviewedMigrationError::Evidence)?; + validate_backup(&binding, bindings)?; + Some(binding) + } + None => None, + }; + if descriptor.change_class == CompiledRegistryChangeClass::DestructiveOrIrreversible + && backup_binding.is_none() + { + return Err(ReviewedMigrationError::Evidence); + } + migrations.push(ValidatedReviewedMigration { + module_id, + descriptor_path: descriptor_path.clone(), + descriptor, + steps, + pre_assertions, + post_assertions, + rehearsal_receipt: receipt, + backup_binding, + }); + } + if claimed != non_additive.keys().cloned().collect() + || referenced_paths != files.keys().cloned().collect() + { + return Err(ReviewedMigrationError::Coverage); + } + Ok(ValidatedReviewedMigrationPlan { migrations }) +} + +pub(crate) fn reviewed_artifact_kind(path: &str) -> Option { + let components = path.split('/').collect::>(); + match components.as_slice() { + ["modules", module, "migrations", migration, "descriptor.json"] + if valid_id(module) && valid_id(migration) => + { + Some(ReviewedArtifactKind::Descriptor) + } + ["modules", module, "migrations", migration, "steps", file] + if valid_id(module) + && valid_id(migration) + && file.strip_suffix(".sql").is_some_and(valid_id) => + { + Some(ReviewedArtifactKind::StepSql) + } + ["modules", module, "migrations", migration, "assertions", file] + if valid_id(module) + && valid_id(migration) + && file.strip_suffix(".sql").is_some_and(valid_id) => + { + Some(ReviewedArtifactKind::AssertionSql) + } + ["modules", module, "migrations", migration, "rehearsal.json"] + if valid_id(module) && valid_id(migration) => + { + Some(ReviewedArtifactKind::RehearsalReceipt) + } + ["modules", module, "migrations", migration, "backup.json"] + if valid_id(module) && valid_id(migration) => + { + Some(ReviewedArtifactKind::BackupBinding) + } + ["modules", module, "migrations", migration, "fixtures", file] + if valid_id(module) + && valid_id(migration) + && file.strip_suffix(".jsonl").is_some_and(valid_id) => + { + Some(ReviewedArtifactKind::Fixture) + } + _ => None, + } +} + +#[cfg(feature = "tooling")] +fn validate_descriptor_shape( + descriptor: &ReviewedMigrationDescriptor, + base: &str, +) -> Result<(), ReviewedMigrationError> { + let metadata_only = covers_are_metadata_only(&descriptor.covers); + if !valid_id(&descriptor.id) + || matches!( + descriptor.change_class, + CompiledRegistryChangeClass::CompatibleAdditive + | CompiledRegistryChangeClass::Unsupported + ) + || descriptor.covers.is_empty() + || !strictly_sorted(descriptor.covers.iter()) + || (descriptor.steps.is_empty() && !metadata_only) + || descriptor.steps.len() > MAX_STEPS + || (descriptor.pre_assertions.is_empty() && !metadata_only) + || descriptor.pre_assertions.len() > MAX_ASSERTIONS + || (descriptor.post_assertions.is_empty() && !metadata_only) + || descriptor.post_assertions.len() > MAX_ASSERTIONS + || !valid_timeout(descriptor.lock_timeout_ms, MAX_LOCK_TIMEOUT_MS) + || !valid_timeout(descriptor.statement_timeout_ms, MAX_STATEMENT_TIMEOUT_MS) + || descriptor.recovery != ReviewedMigrationRecovery::ExactTargetResume + || descriptor.rehearsal_receipt_path != format!("{base}/rehearsal.json") + || descriptor + .backup_binding_path + .as_ref() + .is_some_and(|path| path != &format!("{base}/backup.json")) + { + return Err(ReviewedMigrationError::Descriptor); + } + let mut ids = BTreeSet::new(); + for step in &descriptor.steps { + if !valid_id(step.id()) + || !ids.insert(step.id()) + || step.sql_path() != format!("{base}/steps/{}.sql", step.id()) + || step.objects().is_empty() + || !strictly_sorted(step.objects().iter()) + { + return Err(ReviewedMigrationError::Descriptor); + } + match step { + ReviewedMigrationStepDescriptor::TransactionalSql { + affected_rows: Some(bounds), + .. + } if bounds.min > bounds.max || bounds.max > MAX_TOTAL_ROWS => { + return Err(ReviewedMigrationError::Descriptor); + } + ReviewedMigrationStepDescriptor::ChunkedBackfill { + entity_id, + chunk_size, + max_total_rows, + lock_timeout_ms, + statement_timeout_ms, + exact_affected_rows, + .. + } if !valid_id(entity_id) + || *chunk_size == 0 + || *chunk_size > MAX_CHUNK_SIZE + || *max_total_rows == 0 + || *max_total_rows > MAX_TOTAL_ROWS + || !valid_timeout(*lock_timeout_ms, descriptor.lock_timeout_ms) + || !valid_timeout(*statement_timeout_ms, descriptor.statement_timeout_ms) + || !*exact_affected_rows => + { + return Err(ReviewedMigrationError::Descriptor); + } + _ => {} + } + } + for assertion in descriptor + .pre_assertions + .iter() + .chain(&descriptor.post_assertions) + { + if !valid_id(&assertion.id) + || !ids.insert(&assertion.id) + || assertion.sql_path != format!("{base}/assertions/{}.sql", assertion.id) + { + return Err(ReviewedMigrationError::Descriptor); + } + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn validate_step_sql( + step: &ReviewedMigrationStepDescriptor, + sql: &str, + descriptor: &ReviewedMigrationDescriptor, + bindings: &ReviewedPlanBindings<'_>, + declared_tables: &BTreeSet<&str>, +) -> Result<(), ReviewedMigrationError> { + let parsed = parse_one(sql)?; + validate_ast_objects(&parsed, declared_tables, false)?; + let root = root_node(&parsed)?; + let parsed_objects = match step { + ReviewedMigrationStepDescriptor::TransactionalSql { affected_rows, .. } => { + let (dml, objects) = match root { + PgNode::UpdateStmt(update) => { + validate_update_relation(update, declared_tables)?; + (true, update_objects(update, bindings)?) + } + PgNode::AlterTableStmt(alter) => { + validate_alter_table(alter, declared_tables)?; + (false, alter_table_objects(alter, bindings)?) + } + PgNode::IndexStmt(index) => { + if index.concurrent { + return Err(ReviewedMigrationError::Sql); + } + validate_range_var( + index.relation.as_ref().ok_or(ReviewedMigrationError::Sql)?, + declared_tables, + )?; + (false, index_objects(index, bindings)?) + } + PgNode::DropStmt(drop) => { + validate_drop_table(drop, declared_tables)?; + (false, drop_table_objects(drop, bindings)?) + } + _ => return Err(ReviewedMigrationError::Sql), + }; + if dml != affected_rows.is_some() { + return Err(ReviewedMigrationError::Sql); + } + objects + } + ReviewedMigrationStepDescriptor::ChunkedBackfill { entity_id, .. } => { + if descriptor.change_class != CompiledRegistryChangeClass::DataBackfillRequired { + return Err(ReviewedMigrationError::Descriptor); + } + let entity = bindings + .candidate_entities + .get(entity_id) + .or_else(|| bindings.prior_entities.get(entity_id)) + .ok_or(ReviewedMigrationError::Descriptor)?; + if !descriptor + .covers + .iter() + .any(|cover| cover.target.entity_id.as_deref() == Some(entity_id)) + { + return Err(ReviewedMigrationError::Coverage); + } + let PgNode::UpdateStmt(update) = root else { + return Err(ReviewedMigrationError::Sql); + }; + validate_chunked_update(update, &entity.physical_table, declared_tables, &parsed)?; + update_objects(update, bindings)? + } + }; + if parsed_objects != step.objects() { + return Err(ReviewedMigrationError::Sql); + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn validate_assertions( + descriptors: &[ReviewedMigrationAssertionDescriptor], + files: &BTreeMap>, + declared_tables: &BTreeSet<&str>, + referenced_paths: &mut BTreeSet, +) -> Result, ReviewedMigrationError> { + let mut result = Vec::with_capacity(descriptors.len()); + for descriptor in descriptors { + referenced_paths.insert(descriptor.sql_path.clone()); + let sql = read_sql(files, &descriptor.sql_path)?; + let parsed = parse_one(sql)?; + validate_ast_objects(&parsed, declared_tables, true)?; + let PgNode::SelectStmt(select) = root_node(&parsed)? else { + return Err(ReviewedMigrationError::Sql); + }; + if select.into_clause.is_some() + || select.with_clause.is_some() + || !select.locking_clause.is_empty() + || SetOperation::try_from(select.op).ok() != Some(SetOperation::SetopNone) + || select.target_list.len() != 1 + { + return Err(ReviewedMigrationError::Sql); + } + let value = select + .target_list + .first() + .and_then(|node| node.node.as_ref()) + .and_then(|node| match node { + PgNode::ResTarget(target) => target.val.as_deref(), + _ => None, + }) + .and_then(|node| node.node.as_ref()) + .ok_or(ReviewedMigrationError::Sql)?; + if !boolean_result_expression(value)? { + return Err(ReviewedMigrationError::Sql); + } + result.push(ValidatedReviewedMigrationAssertion { + descriptor: descriptor.clone(), + sql: sql.to_owned(), + sha256: digest(sql.as_bytes()), + }); + } + Ok(result) +} + +#[cfg(feature = "tooling")] +struct ReceiptValidationContext<'a> { + descriptor_bytes: &'a [u8], + steps: &'a [ValidatedReviewedMigrationStep], + pre_assertions: &'a [ValidatedReviewedMigrationAssertion], + post_assertions: &'a [ValidatedReviewedMigrationAssertion], + descriptor: &'a ReviewedMigrationDescriptor, + bindings: &'a ReviewedPlanBindings<'a>, + base: &'a str, + files: &'a BTreeMap>, + referenced_paths: &'a mut BTreeSet, +} + +#[cfg(feature = "tooling")] +fn validate_receipt( + receipt: &MigrationRehearsalReceipt, + context: ReceiptValidationContext<'_>, +) -> Result<(), ReviewedMigrationError> { + let ReceiptValidationContext { + descriptor_bytes, + steps, + pre_assertions, + post_assertions, + descriptor, + bindings, + base, + files, + referenced_paths, + } = context; + let expected_sql = steps + .iter() + .map(|step| ArtifactDigestBinding { + path: step.descriptor.sql_path().to_owned(), + sha256: step.sha256.clone(), + }) + .collect::>(); + let expected_assertions = pre_assertions + .iter() + .chain(post_assertions) + .map(|assertion| ArtifactDigestBinding { + path: assertion.descriptor.sql_path.clone(), + sha256: assertion.sha256.clone(), + }) + .collect::>(); + let metadata_only = steps.is_empty() && covers_are_metadata_only(&descriptor.covers); + if receipt.prior_revision != bindings.prior_revision + || receipt.prior_schema_fingerprint != bindings.prior_schema_fingerprint + || receipt.final_schema_fingerprint != bindings.final_schema_fingerprint + || receipt.plan_sha256 != digest(descriptor_bytes) + || receipt.sql_sha256 != expected_sql + || receipt.assertion_sha256 != expected_assertions + || !(13..=18).contains(&receipt.postgres_major) + || (receipt.fixture_inventory.is_empty() && !metadata_only) + || !strictly_sorted( + receipt + .fixture_inventory + .iter() + .map(|fixture| fixture.id.as_str()), + ) + || !receipt.proofs.lock_timeout + { + return Err(ReviewedMigrationError::Evidence); + } + for fixture in &receipt.fixture_inventory { + if !valid_id(&fixture.id) + || fixture.path != format!("{base}/fixtures/{}.jsonl", fixture.id) + || !valid_digest(&fixture.sha256) + || !referenced_paths.insert(fixture.path.clone()) + { + return Err(ReviewedMigrationError::Evidence); + } + let bytes = files + .get(&fixture.path) + .ok_or(ReviewedMigrationError::Evidence)?; + if bytes.is_empty() + || bytes.len() > MAX_FIXTURE_BYTES + || !bytes.ends_with(b"\n") + || digest(bytes) != fixture.sha256 + || validate_fixture_jsonl(bytes)? != fixture.row_count + { + return Err(ReviewedMigrationError::Evidence); + } + } + let has_chunks = steps.iter().any(|step| { + matches!( + step.descriptor, + ReviewedMigrationStepDescriptor::ChunkedBackfill { .. } + ) + }); + let destructive = + descriptor.change_class == CompiledRegistryChangeClass::DestructiveOrIrreversible; + if receipt.proofs.chunk_resume != has_chunks || receipt.proofs.destructive_resume != destructive + { + return Err(ReviewedMigrationError::Evidence); + } + let expected_row_steps = steps + .iter() + .filter_map(|step| match &step.descriptor { + ReviewedMigrationStepDescriptor::TransactionalSql { + id, + affected_rows: Some(bounds), + .. + } => Some((id.as_str(), bounds.min, bounds.max)), + ReviewedMigrationStepDescriptor::ChunkedBackfill { + id, max_total_rows, .. + } => Some((id.as_str(), 0, *max_total_rows)), + _ => None, + }) + .collect::>(); + if receipt.row_assertions.len() != expected_row_steps.len() { + return Err(ReviewedMigrationError::Evidence); + } + for (assertion, (step_id, min, max)) in receipt.row_assertions.iter().zip(expected_row_steps) { + if assertion.step_id != step_id + || assertion.affected_rows < min + || assertion.affected_rows > max + { + return Err(ReviewedMigrationError::Evidence); + } + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn validate_fixture_jsonl(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| ReviewedMigrationError::Evidence)?; + let mut count = 0_u64; + for line in text.split_terminator('\n') { + if line.is_empty() || line.ends_with('\r') { + return Err(ReviewedMigrationError::Evidence); + } + let value = + parse_json_strict(line.as_bytes()).map_err(|_| ReviewedMigrationError::Evidence)?; + let canonical = canonicalize_json(&value).map_err(|_| ReviewedMigrationError::Evidence)?; + if canonical != line.as_bytes() { + return Err(ReviewedMigrationError::Evidence); + } + count = count + .checked_add(1) + .ok_or(ReviewedMigrationError::Evidence)?; + } + Ok(count) +} + +#[cfg(feature = "tooling")] +fn validate_backup( + backup: &ExternalBackupBinding, + bindings: &ReviewedPlanBindings<'_>, +) -> Result<(), ReviewedMigrationError> { + if backup.database_id != bindings.database_id + || backup.prior_revision != bindings.prior_revision + || backup.prior_schema_fingerprint != bindings.prior_schema_fingerprint + || !valid_digest(&backup.sha256) + || backup.byte_length == 0 + || backup.max_age_seconds == 0 + || backup.max_age_seconds > MAX_BACKUP_AGE_SECONDS + || OffsetDateTime::parse(&backup.created_at, &Rfc3339).is_err() + { + return Err(ReviewedMigrationError::Evidence); + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn parse_one(sql: &str) -> Result { + if sql.is_empty() || sql.len() > MAX_SQL_BYTES || sql.as_bytes().contains(&0) { + return Err(ReviewedMigrationError::Sql); + } + let parsed = pg_query::parse(sql).map_err(|_| ReviewedMigrationError::Sql)?; + if parsed.protobuf.stmts.len() != 1 || !parsed.warnings.is_empty() { + return Err(ReviewedMigrationError::Sql); + } + Ok(parsed) +} + +#[cfg(feature = "tooling")] +fn root_node(parsed: &pg_query::ParseResult) -> Result<&PgNode, ReviewedMigrationError> { + parsed + .protobuf + .stmts + .first() + .and_then(|statement| statement.stmt.as_deref()) + .and_then(|statement| statement.node.as_ref()) + .ok_or(ReviewedMigrationError::Sql) +} + +#[cfg(feature = "tooling")] +fn validate_ast_objects( + parsed: &pg_query::ParseResult, + declared_tables: &BTreeSet<&str>, + assertion: bool, +) -> Result<(), ReviewedMigrationError> { + let mut statement_nodes = 0; + for (node, _, _, _) in parsed.protobuf.nodes() { + match node { + NodeRef::RangeVar(range) => validate_range_var(range, declared_tables)?, + NodeRef::FuncCall(function) => validate_function(function)?, + NodeRef::AExpr(expression) => validate_operator(expression)?, + NodeRef::TypeName(type_name) => validate_type_name(type_name)?, + NodeRef::ParamRef(_) if assertion => return Err(ReviewedMigrationError::Sql), + NodeRef::SqlvalueFunction(_) + | NodeRef::RangeFunction(_) + | NodeRef::TableFunc(_) + | NodeRef::IntoClause(_) => return Err(ReviewedMigrationError::Sql), + NodeRef::SelectStmt(_) if assertion => statement_nodes += 1, + NodeRef::UpdateStmt(_) + | NodeRef::AlterTableStmt(_) + | NodeRef::IndexStmt(_) + | NodeRef::DropStmt(_) + | NodeRef::SelectStmt(_) => statement_nodes += 1, + node if is_forbidden_statement_node(node) => return Err(ReviewedMigrationError::Sql), + _ => {} + } + } + if (!assertion && statement_nodes != 1) || (assertion && statement_nodes == 0) { + return Err(ReviewedMigrationError::Sql); + } + Ok(()) +} + +#[allow(clippy::match_same_arms)] +#[cfg(feature = "tooling")] +fn is_forbidden_statement_node(node: NodeRef<'_>) -> bool { + matches!( + node, + NodeRef::InsertStmt(_) + | NodeRef::DeleteStmt(_) + | NodeRef::MergeStmt(_) + | NodeRef::TransactionStmt(_) + | NodeRef::VariableSetStmt(_) + | NodeRef::VariableShowStmt(_) + | NodeRef::CreateStmt(_) + | NodeRef::CreateTableAsStmt(_) + | NodeRef::CopyStmt(_) + | NodeRef::CreateFunctionStmt(_) + | NodeRef::AlterFunctionStmt(_) + | NodeRef::DoStmt(_) + | NodeRef::CreateTrigStmt(_) + | NodeRef::CreateEventTrigStmt(_) + | NodeRef::AlterEventTrigStmt(_) + | NodeRef::CreateSchemaStmt(_) + | NodeRef::AlterObjectSchemaStmt(_) + | NodeRef::CreateExtensionStmt(_) + | NodeRef::AlterExtensionStmt(_) + | NodeRef::AlterExtensionContentsStmt(_) + | NodeRef::CreatedbStmt(_) + | NodeRef::DropdbStmt(_) + | NodeRef::CreateRoleStmt(_) + | NodeRef::AlterRoleStmt(_) + | NodeRef::DropRoleStmt(_) + | NodeRef::AlterRoleSetStmt(_) + | NodeRef::AlterDatabaseStmt(_) + | NodeRef::AlterDatabaseSetStmt(_) + | NodeRef::GrantStmt(_) + | NodeRef::GrantRoleStmt(_) + | NodeRef::AlterDefaultPrivilegesStmt(_) + | NodeRef::TruncateStmt(_) + | NodeRef::VacuumStmt(_) + | NodeRef::CallStmt(_) + | NodeRef::LockStmt(_) + | NodeRef::PrepareStmt(_) + | NodeRef::ExecuteStmt(_) + | NodeRef::DeallocateStmt(_) + | NodeRef::DeclareCursorStmt(_) + | NodeRef::CreateSeqStmt(_) + | NodeRef::AlterSeqStmt(_) + | NodeRef::CreatePolicyStmt(_) + | NodeRef::AlterPolicyStmt(_) + | NodeRef::ViewStmt(_) + | NodeRef::RuleStmt(_) + | NodeRef::RefreshMatViewStmt(_) + | NodeRef::ReindexStmt(_) + | NodeRef::ClusterStmt(_) + | NodeRef::LoadStmt(_) + ) +} + +#[cfg(feature = "tooling")] +fn validate_range_var( + range: &pg_query::protobuf::RangeVar, + declared_tables: &BTreeSet<&str>, +) -> Result<(), ReviewedMigrationError> { + if !range.catalogname.is_empty() + || range.schemaname != "registry_data" + || !declared_tables.contains(range.relname.as_str()) + || (!range.relpersistence.is_empty() && range.relpersistence != "p") + { + return Err(ReviewedMigrationError::Sql); + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn validate_function( + function: &pg_query::protobuf::FuncCall, +) -> Result<(), ReviewedMigrationError> { + let name = node_strings(&function.funcname)?; + if !matches!( + name.as_slice(), + [schema, function] + if schema == "pg_catalog" + && matches!(function.as_str(), "count" | "bool_and" | "every") + ) || function.over.is_some() + || function.agg_within_group + || function.func_variadic + { + return Err(ReviewedMigrationError::Sql); + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn validate_operator(expression: &pg_query::protobuf::AExpr) -> Result<(), ReviewedMigrationError> { + let names = node_strings(&expression.name)?; + if names.len() != 1 + || !matches!( + names[0].as_str(), + "=" | "<>" | "<" | ">" | "<=" | ">=" | "+" | "-" | "*" | "/" + ) + { + return Err(ReviewedMigrationError::Sql); + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn boolean_result_expression(node: &PgNode) -> Result { + Ok(match node { + PgNode::AExpr(expression) => { + let names = node_strings(&expression.name)?; + names.len() == 1 && matches!(names[0].as_str(), "=" | "<>" | "<" | ">" | "<=" | ">=") + } + PgNode::BoolExpr(_) | PgNode::BooleanTest(_) | PgNode::NullTest(_) => true, + PgNode::SubLink(link) => { + SubLinkType::try_from(link.sub_link_type).ok() == Some(SubLinkType::ExistsSublink) + } + PgNode::AConst(constant) => matches!(constant.val, Some(a_const::Val::Boolval(_))), + _ => false, + }) +} + +#[cfg(feature = "tooling")] +fn validate_type_name( + type_name: &pg_query::protobuf::TypeName, +) -> Result<(), ReviewedMigrationError> { + let names = node_strings(&type_name.names)?; + if type_name.setof + || type_name.pct_type + || !matches!( + names.as_slice(), + [schema, name] + if schema == "pg_catalog" + && matches!( + name.as_str(), + "bool" + | "date" + | "float8" + | "int2" + | "int4" + | "int8" + | "jsonb" + | "numeric" + | "text" + | "timestamp" + | "timestamptz" + | "uuid" + | "varchar" + ) + ) + { + return Err(ReviewedMigrationError::Sql); + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn validate_update_relation( + update: &pg_query::protobuf::UpdateStmt, + declared_tables: &BTreeSet<&str>, +) -> Result<(), ReviewedMigrationError> { + validate_range_var( + update + .relation + .as_ref() + .ok_or(ReviewedMigrationError::Sql)?, + declared_tables, + )?; + if update.target_list.is_empty() + || update.where_clause.is_none() + || update.with_clause.is_some() + || !update.from_clause.is_empty() + || !update.returning_list.is_empty() + { + return Err(ReviewedMigrationError::Sql); + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn validate_chunked_update( + update: &pg_query::protobuf::UpdateStmt, + physical_table: &str, + declared_tables: &BTreeSet<&str>, + parsed: &pg_query::ParseResult, +) -> Result<(), ReviewedMigrationError> { + validate_update_relation(update, declared_tables)?; + let relation = update + .relation + .as_ref() + .ok_or(ReviewedMigrationError::Sql)?; + if relation.relname != physical_table { + return Err(ReviewedMigrationError::Sql); + } + for target in &update.target_list { + let Some(PgNode::ResTarget(target)) = target.node.as_ref() else { + return Err(ReviewedMigrationError::Sql); + }; + if target.name.is_empty() || target.name == "record_id" || !target.indirection.is_empty() { + return Err(ReviewedMigrationError::Sql); + } + } + let where_node = update + .where_clause + .as_deref() + .and_then(|node| node.node.as_ref()) + .ok_or(ReviewedMigrationError::Sql)?; + let PgNode::AExpr(expression) = where_node else { + return Err(ReviewedMigrationError::Sql); + }; + if AExprKind::try_from(expression.kind).ok() != Some(AExprKind::AexprOpAny) + || node_strings(&expression.name)?.as_slice() != ["="] + || !is_column_ref(expression.lexpr.as_deref(), "record_id") + || !is_uuid_array_parameter(expression.rexpr.as_deref()) + { + return Err(ReviewedMigrationError::Sql); + } + let parameters = parsed + .protobuf + .nodes() + .into_iter() + .filter_map(|(node, _, _, _)| match node { + NodeRef::ParamRef(parameter) => Some(parameter.number), + _ => None, + }) + .collect::>(); + if parameters != [1] { + return Err(ReviewedMigrationError::Sql); + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn validate_alter_table( + alter: &pg_query::protobuf::AlterTableStmt, + declared_tables: &BTreeSet<&str>, +) -> Result<(), ReviewedMigrationError> { + validate_range_var( + alter.relation.as_ref().ok_or(ReviewedMigrationError::Sql)?, + declared_tables, + )?; + if alter.cmds.is_empty() { + return Err(ReviewedMigrationError::Sql); + } + for command in &alter.cmds { + let Some(PgNode::AlterTableCmd(command)) = command.node.as_ref() else { + return Err(ReviewedMigrationError::Sql); + }; + let subtype = + AlterTableType::try_from(command.subtype).map_err(|_| ReviewedMigrationError::Sql)?; + if !matches!( + subtype, + AlterTableType::AtColumnDefault + | AlterTableType::AtDropNotNull + | AlterTableType::AtSetNotNull + | AlterTableType::AtDropColumn + | AlterTableType::AtAddConstraint + | AlterTableType::AtAlterConstraint + | AlterTableType::AtValidateConstraint + | AlterTableType::AtDropConstraint + | AlterTableType::AtAlterColumnType + ) { + return Err(ReviewedMigrationError::Sql); + } + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn validate_drop_table( + drop: &pg_query::protobuf::DropStmt, + declared_tables: &BTreeSet<&str>, +) -> Result<(), ReviewedMigrationError> { + if ObjectType::try_from(drop.remove_type).ok() != Some(ObjectType::ObjectTable) + || drop.concurrent + || drop.objects.len() != 1 + { + return Err(ReviewedMigrationError::Sql); + } + let Some(PgNode::List(object)) = drop.objects[0].node.as_ref() else { + return Err(ReviewedMigrationError::Sql); + }; + let names = node_strings(&object.items)?; + if names.len() != 2 + || names[0] != "registry_data" + || !declared_tables.contains(names[1].as_str()) + { + return Err(ReviewedMigrationError::Sql); + } + Ok(()) +} + +#[cfg(feature = "tooling")] +fn update_objects( + update: &pg_query::protobuf::UpdateStmt, + bindings: &ReviewedPlanBindings<'_>, +) -> Result, ReviewedMigrationError> { + let relation = update + .relation + .as_ref() + .ok_or(ReviewedMigrationError::Sql)?; + let entity_id = entity_for_table(&relation.relname, bindings)?; + let mut objects = Vec::with_capacity(update.target_list.len()); + for target in &update.target_list { + let Some(PgNode::ResTarget(target)) = target.node.as_ref() else { + return Err(ReviewedMigrationError::Sql); + }; + let member_id = member_for_physical( + &entity_id, + &target.name, + ReviewedMigrationObjectKind::Field, + bindings, + )?; + objects.push(reviewed_object( + &relation.relname, + &entity_id, + ReviewedMigrationObjectKind::Field, + Some(member_id), + &target.name, + )); + } + finish_objects(objects) +} + +#[cfg(feature = "tooling")] +fn alter_table_objects( + alter: &pg_query::protobuf::AlterTableStmt, + bindings: &ReviewedPlanBindings<'_>, +) -> Result, ReviewedMigrationError> { + let relation = alter.relation.as_ref().ok_or(ReviewedMigrationError::Sql)?; + let entity_id = entity_for_table(&relation.relname, bindings)?; + let mut objects = Vec::with_capacity(alter.cmds.len()); + for command in &alter.cmds { + let Some(PgNode::AlterTableCmd(command)) = command.node.as_ref() else { + return Err(ReviewedMigrationError::Sql); + }; + let subtype = + AlterTableType::try_from(command.subtype).map_err(|_| ReviewedMigrationError::Sql)?; + let kind = match subtype { + AlterTableType::AtAddConstraint + | AlterTableType::AtAlterConstraint + | AlterTableType::AtValidateConstraint + | AlterTableType::AtDropConstraint => ReviewedMigrationObjectKind::Constraint, + AlterTableType::AtColumnDefault + | AlterTableType::AtDropNotNull + | AlterTableType::AtSetNotNull + | AlterTableType::AtDropColumn + | AlterTableType::AtAlterColumnType => ReviewedMigrationObjectKind::Field, + _ => return Err(ReviewedMigrationError::Sql), + }; + let member_name = alter_table_command_member_name(command, subtype)?; + let member_id = member_for_physical(&entity_id, member_name, kind, bindings)?; + objects.push(reviewed_object( + &relation.relname, + &entity_id, + kind, + Some(member_id), + member_name, + )); + } + finish_objects(objects) +} + +#[cfg(feature = "tooling")] +fn alter_table_command_member_name( + command: &pg_query::protobuf::AlterTableCmd, + subtype: AlterTableType, +) -> Result<&str, ReviewedMigrationError> { + if !command.name.is_empty() { + return Ok(&command.name); + } + if subtype == AlterTableType::AtAddConstraint { + let Some(PgNode::Constraint(constraint)) = + command.def.as_deref().and_then(|node| node.node.as_ref()) + else { + return Err(ReviewedMigrationError::Sql); + }; + if ConstrType::try_from(constraint.contype).is_err() || constraint.conname.is_empty() { + return Err(ReviewedMigrationError::Sql); + } + return Ok(&constraint.conname); + } + Err(ReviewedMigrationError::Sql) +} + +#[cfg(feature = "tooling")] +fn index_objects( + index: &pg_query::protobuf::IndexStmt, + bindings: &ReviewedPlanBindings<'_>, +) -> Result, ReviewedMigrationError> { + let relation = index.relation.as_ref().ok_or(ReviewedMigrationError::Sql)?; + if index.idxname.is_empty() + || !index.table_space.is_empty() + || (!index.access_method.is_empty() && index.access_method != "btree") + { + return Err(ReviewedMigrationError::Sql); + } + let entity_id = entity_for_table(&relation.relname, bindings)?; + let member_id = member_for_physical( + &entity_id, + &index.idxname, + ReviewedMigrationObjectKind::Index, + bindings, + )?; + Ok(vec![reviewed_object( + &relation.relname, + &entity_id, + ReviewedMigrationObjectKind::Index, + Some(member_id), + &index.idxname, + )]) +} + +#[cfg(feature = "tooling")] +fn drop_table_objects( + drop: &pg_query::protobuf::DropStmt, + bindings: &ReviewedPlanBindings<'_>, +) -> Result, ReviewedMigrationError> { + let Some(PgNode::List(object)) = drop.objects[0].node.as_ref() else { + return Err(ReviewedMigrationError::Sql); + }; + let names = node_strings(&object.items)?; + let table = names.get(1).ok_or(ReviewedMigrationError::Sql)?; + let entity_id = entity_for_table(table, bindings)?; + Ok(vec![reviewed_object( + table, + &entity_id, + ReviewedMigrationObjectKind::Entity, + None, + table, + )]) +} + +#[cfg(feature = "tooling")] +fn entity_for_table( + table: &str, + bindings: &ReviewedPlanBindings<'_>, +) -> Result { + let ids = bindings + .prior_entities + .values() + .chain(bindings.candidate_entities.values()) + .filter(|entity| entity.physical_table == table) + .map(|entity| entity.id.as_str()) + .collect::>(); + if ids.len() != 1 { + return Err(ReviewedMigrationError::Sql); + } + Ok(ids.into_iter().next().expect("one id exists").to_owned()) +} + +#[cfg(feature = "tooling")] +fn member_for_physical( + entity_id: &str, + physical_name: &str, + kind: ReviewedMigrationObjectKind, + bindings: &ReviewedPlanBindings<'_>, +) -> Result { + let inventories = [ + bindings.prior_physical_names, + bindings.candidate_physical_names, + ]; + let mut ids = BTreeSet::new(); + for inventory in inventories { + let Some(entity) = inventory.entities.get(entity_id) else { + continue; + }; + let members = match kind { + ReviewedMigrationObjectKind::Field => &entity.fields, + ReviewedMigrationObjectKind::Constraint => &entity.constraints, + ReviewedMigrationObjectKind::Index => &entity.indexes, + ReviewedMigrationObjectKind::Entity => return Err(ReviewedMigrationError::Sql), + }; + ids.extend( + members + .iter() + .filter(|(_, physical)| physical.as_str() == physical_name) + .map(|(id, _)| id.as_str()), + ); + } + if ids.len() != 1 { + return Err(ReviewedMigrationError::Sql); + } + Ok(ids.into_iter().next().expect("one id exists").to_owned()) +} + +#[cfg(feature = "tooling")] +fn reviewed_object( + table: &str, + entity_id: &str, + kind: ReviewedMigrationObjectKind, + member_id: Option, + physical_name: &str, +) -> ReviewedMigrationObject { + ReviewedMigrationObject { + schema: "registry_data".to_owned(), + table: table.to_owned(), + entity_id: entity_id.to_owned(), + kind, + member_id, + physical_name: physical_name.to_owned(), + } +} + +#[cfg(feature = "tooling")] +fn finish_objects( + mut objects: Vec, +) -> Result, ReviewedMigrationError> { + objects.sort(); + if objects.is_empty() || objects.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(ReviewedMigrationError::Sql); + } + Ok(objects) +} + +#[cfg(feature = "tooling")] +fn object_cover( + object: &ReviewedMigrationObject, + covers: &[ReviewedChangeCover], +) -> Result { + let kind = match object.kind { + ReviewedMigrationObjectKind::Entity => CompiledRegistryChangeTargetKind::Entity, + ReviewedMigrationObjectKind::Field => CompiledRegistryChangeTargetKind::Field, + ReviewedMigrationObjectKind::Constraint => CompiledRegistryChangeTargetKind::Constraint, + ReviewedMigrationObjectKind::Index => CompiledRegistryChangeTargetKind::Index, + }; + let target = CompiledRegistryChangeTarget { + kind, + entity_id: Some(object.entity_id.clone()), + member_id: object.member_id.clone(), + }; + let matches = covers + .iter() + .filter(|cover| { + cover.target == target + || reference_target_cover_matches_implicit_constraint(cover, object) + }) + .cloned() + .collect::>(); + if matches.len() != 1 { + return Err(ReviewedMigrationError::Coverage); + } + Ok(matches.into_iter().next().expect("one cover exists")) +} + +#[cfg(feature = "tooling")] +fn reference_target_cover_matches_implicit_constraint( + cover: &ReviewedChangeCover, + object: &ReviewedMigrationObject, +) -> bool { + cover.code == CompiledRegistryChangeCode::ReferenceTargetChanged + && object.kind == ReviewedMigrationObjectKind::Constraint + && cover.target.kind == CompiledRegistryChangeTargetKind::Field + && cover.target.entity_id.as_deref() == Some(object.entity_id.as_str()) + && object + .member_id + .as_deref() + .and_then(|member| member.strip_prefix("reference:")) + == cover.target.member_id.as_deref() +} + +#[cfg(feature = "tooling")] +fn covers_are_metadata_only(covers: &[ReviewedChangeCover]) -> bool { + covers.iter().all(|cover| { + matches!( + cover.code, + CompiledRegistryChangeCode::EntityRouteChanged + | CompiledRegistryChangeCode::EntityMutationModeChanged + | CompiledRegistryChangeCode::EntityClassificationChanged + | CompiledRegistryChangeCode::FieldClassificationChanged + | CompiledRegistryChangeCode::FieldTemporalRoleChanged + | CompiledRegistryChangeCode::AccessProfileAdded + | CompiledRegistryChangeCode::AccessProfileRemoved + | CompiledRegistryChangeCode::AccessProfileChanged + | CompiledRegistryChangeCode::RouteAdded + | CompiledRegistryChangeCode::RouteRemoved + | CompiledRegistryChangeCode::RouteChanged + | CompiledRegistryChangeCode::QueryInventoryChanged + | CompiledRegistryChangeCode::EventAdded + | CompiledRegistryChangeCode::EventRemoved + | CompiledRegistryChangeCode::EventChanged + ) + }) +} + +#[cfg(feature = "tooling")] +fn is_column_ref(node: Option<&pg_query::protobuf::Node>, expected: &str) -> bool { + let Some(PgNode::ColumnRef(column)) = node.and_then(|node| node.node.as_ref()) else { + return false; + }; + node_strings(&column.fields).is_ok_and(|names| names.as_slice() == [expected]) +} + +#[cfg(feature = "tooling")] +fn is_uuid_array_parameter(node: Option<&pg_query::protobuf::Node>) -> bool { + let Some(PgNode::TypeCast(cast)) = node.and_then(|node| node.node.as_ref()) else { + return false; + }; + let parameter = cast.arg.as_deref().and_then(|node| node.node.as_ref()); + let type_name = cast.type_name.as_ref(); + matches!(parameter, Some(PgNode::ParamRef(parameter)) if parameter.number == 1) + && type_name.is_some_and(|name| { + name.array_bounds.len() == 1 + && node_strings(&name.names) + .is_ok_and(|names| names.as_slice() == ["pg_catalog", "uuid"]) + }) +} + +#[cfg(feature = "tooling")] +fn node_strings(nodes: &[pg_query::protobuf::Node]) -> Result, ReviewedMigrationError> { + nodes + .iter() + .map(|node| match node.node.as_ref() { + Some(PgNode::String(value)) => Ok(value.sval.clone()), + _ => Err(ReviewedMigrationError::Sql), + }) + .collect() +} + +#[cfg(feature = "tooling")] +fn read_sql<'a>( + files: &'a BTreeMap>, + path: &str, +) -> Result<&'a str, ReviewedMigrationError> { + let bytes = files.get(path).ok_or(ReviewedMigrationError::Closure)?; + if reviewed_artifact_kind(path) != Some(ReviewedArtifactKind::StepSql) + && reviewed_artifact_kind(path) != Some(ReviewedArtifactKind::AssertionSql) + { + return Err(ReviewedMigrationError::Closure); + } + std::str::from_utf8(bytes).map_err(|_| ReviewedMigrationError::Sql) +} + +#[cfg(feature = "tooling")] +fn descriptor_base( + path: &str, + descriptor_id: &str, +) -> Result<(String, String), ReviewedMigrationError> { + if reviewed_artifact_kind(path) != Some(ReviewedArtifactKind::Descriptor) { + return Err(ReviewedMigrationError::Descriptor); + } + let components = path.split('/').collect::>(); + let module_id = components[1]; + if components[3] != descriptor_id { + return Err(ReviewedMigrationError::Descriptor); + } + Ok(( + module_id.to_owned(), + format!("modules/{module_id}/migrations/{descriptor_id}"), + )) +} + +#[cfg(feature = "tooling")] +fn parse_canonical Deserialize<'de>>( + bytes: &[u8], +) -> Result { + let value = parse_json_strict(bytes).map_err(|_| ReviewedMigrationError::Descriptor)?; + let canonical = canonicalize_json(&value).map_err(|_| ReviewedMigrationError::Descriptor)?; + if canonical != bytes { + return Err(ReviewedMigrationError::Descriptor); + } + serde_json::from_value(value).map_err(|_| ReviewedMigrationError::Descriptor) +} + +#[cfg(feature = "tooling")] +fn strictly_sorted(values: impl Iterator) -> bool { + let mut prior = None; + for value in values { + if prior.as_ref().is_some_and(|prior| prior >= &value) { + return false; + } + prior = Some(value); + } + true +} + +#[cfg(feature = "tooling")] +fn valid_timeout(value: u64, ceiling: u64) -> bool { + value > 0 && value <= ceiling +} + +fn valid_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 96 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +#[cfg(feature = "tooling")] +fn valid_digest(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|hex| { + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +#[cfg(feature = "tooling")] +fn digest(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut result = String::with_capacity(71); + result.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + write!(&mut result, "{byte:02x}").expect("writing to a String cannot fail"); + } + result +} diff --git a/crates/registry-server/src/model.rs b/crates/registry-server/src/model.rs new file mode 100644 index 0000000000..09d478b8a7 --- /dev/null +++ b/crates/registry-server/src/model.rs @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::artifacts::GeneratedArtifacts; +use crate::contract::{ + AccessProfileSource, BatchSource, Classification, ConstraintSource, EventSource, + FieldTypeSource, ManifestProjectionSource, MutationMode, Operation, PackageIdentitySource, + TemporalSource, ValidTimeRole, WebhookAuthenticationProfile, WebhookDeadLetterMode, +}; +use crate::diagnostics::Diagnostic; +use crate::generated_ddl::DdlInventory; +use crate::physical_names::PhysicalNameInventory; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledField { + pub id: String, + pub field_type: FieldTypeSource, + pub required: bool, + pub classification: Classification, + #[serde(skip_serializing_if = "Option::is_none")] + pub valid_time_role: Option, + pub physical_name: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledEntity { + pub id: String, + pub route: String, + pub mutation_mode: MutationMode, + pub tombstone: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub batch: Option, + pub classification: Classification, + pub physical_table: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub temporal: Option, + pub fields: BTreeMap, + pub constraints: BTreeMap, + pub indexes: BTreeMap>, + pub access_profiles: BTreeMap, + pub events: BTreeMap, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledTemporal { + pub start_field: String, + pub end_field: String, + pub scope_fields: Vec, +} + +impl From for CompiledTemporal { + fn from(source: TemporalSource) -> Self { + Self { + start_field: source.start_field, + end_field: source.end_field, + scope_fields: source.scope_fields, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + Delete, + Get, + Patch, + Post, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledRoute { + pub id: String, + pub entity_id: String, + pub method: HttpMethod, + pub path: String, + pub operation: Operation, + #[serde(skip_serializing_if = "Option::is_none")] + pub query_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum_records: Option, + pub access_profiles: Vec, + pub default_access_profile: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledRouteInventory { + pub routes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledEventDelivery { + pub id: String, + pub entity_id: String, + pub event_id: String, + pub trigger: crate::contract::EventTrigger, + pub destination_id: String, + pub projection_fields: Vec, + pub classification_ceiling: Classification, + pub authentication_profile: WebhookAuthenticationProfile, + pub delivery_mode: CompiledWebhookDeliveryMode, + pub attempt_timeout_ms: u32, + pub initial_backoff_ms: u32, + pub maximum_backoff_ms: u32, + /// Fixed V1 exponential multiplier. Runtime configuration may only tighten + /// the resulting delays. + pub exponential_backoff_multiplier: u8, + pub maximum_attempts: u8, + pub retry_delays_ms: Vec, + /// Compiler-proved upper bound for the canonical projected JSON body. + pub maximum_payload_bytes: u32, + pub dead_letter: WebhookDeadLetterMode, + pub operator_replay: bool, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompiledWebhookDeliveryMode { + AfterCommit, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledEventDeliveryInventory { + pub deliveries: Vec, +} + +/// Conservative, non-pageable bound for one record's newest revision entries. +pub const MAX_REVISION_HISTORY_RECORDS: u16 = 100; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompiledRevisionKind { + List, + Detail, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledAccessEntry { + pub entity_id: String, + pub operation: Operation, + pub profile_ids: BTreeSet, + pub default_profile_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledAccessInventory { + pub entries: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledMetadataEntry { + pub route_id: String, + pub operation: Operation, + pub access_profile: String, + pub readable_fields: BTreeSet, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledMetadataEntity { + pub id: String, + pub route: String, + pub schema_path: String, + pub entries: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledMetadataInventory { + pub registry_id: String, + pub version: String, + pub entities: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompiledQueryKind { + List, + Current, + AsOf, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompiledQueryFilterOperator { + Equals, + In, + Range, + IsNull, + IsNotNull, + Prefix, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompiledQuerySortDirection { + Asc, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledQueryFilterField { + pub field: String, + pub operators: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledQuerySortField { + pub field: String, + pub directions: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledQueryTemporalBinding { + pub start_field: String, + pub end_field: String, + pub scope_fields: Vec, + pub semantics: CompiledQueryTemporalSemantics, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompiledQueryTemporalSemantics { + StartInclusiveEndExclusive, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledQueryOperation { + pub id: String, + pub route_id: String, + pub entity_id: String, + pub profile_id: String, + pub kind: CompiledQueryKind, + pub max_page_size: u16, + pub projection_fields: Vec, + pub filter_fields: Vec, + pub sort_fields: Vec, + pub stable_tie_breaker: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub temporal: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledQueryInventory { + pub operations: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledModuleIdentity { + pub id: String, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub digest: Option, +} + +/// Immutable result consumed by runtime, migration, and authoring surfaces. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledRegistry { + registry_id: String, + version: String, + default_language: String, + package: Option, + manifest_projection: Option, + module_order: Vec, + module_closure: Vec, + entities: BTreeMap, + physical_names: PhysicalNameInventory, + route_inventory: CompiledRouteInventory, + access_inventory: CompiledAccessInventory, + metadata_inventory: CompiledMetadataInventory, + query_inventory: CompiledQueryInventory, + event_delivery_inventory: CompiledEventDeliveryInventory, + ddl: DdlInventory, + artifacts: GeneratedArtifacts, + findings: Vec, + revision: String, +} + +impl CompiledRegistry { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + registry_id: String, + version: String, + default_language: String, + package: Option, + manifest_projection: Option, + module_order: Vec, + module_closure: Vec, + entities: BTreeMap, + physical_names: PhysicalNameInventory, + route_inventory: CompiledRouteInventory, + access_inventory: CompiledAccessInventory, + metadata_inventory: CompiledMetadataInventory, + query_inventory: CompiledQueryInventory, + event_delivery_inventory: CompiledEventDeliveryInventory, + ddl: DdlInventory, + artifacts: GeneratedArtifacts, + findings: Vec, + revision: String, + ) -> Self { + Self { + registry_id, + version, + default_language, + package, + manifest_projection, + module_order, + module_closure, + entities, + physical_names, + route_inventory, + access_inventory, + metadata_inventory, + query_inventory, + event_delivery_inventory, + ddl, + artifacts, + findings, + revision, + } + } + + pub fn registry_id(&self) -> &str { + &self.registry_id + } + + pub fn version(&self) -> &str { + &self.version + } + + pub fn package(&self) -> Option<&PackageIdentitySource> { + self.package.as_ref() + } + + pub fn manifest_projection(&self) -> Option<&ManifestProjectionSource> { + self.manifest_projection.as_ref() + } + + pub fn module_order(&self) -> &[String] { + &self.module_order + } + + pub fn module_closure(&self) -> &[CompiledModuleIdentity] { + &self.module_closure + } + + pub fn entities(&self) -> &BTreeMap { + &self.entities + } + + pub fn physical_names(&self) -> &PhysicalNameInventory { + &self.physical_names + } + + pub fn routes(&self) -> &CompiledRouteInventory { + &self.route_inventory + } + + pub fn access(&self) -> &CompiledAccessInventory { + &self.access_inventory + } + + pub fn metadata(&self) -> &CompiledMetadataInventory { + &self.metadata_inventory + } + + pub fn queries(&self) -> &CompiledQueryInventory { + &self.query_inventory + } + + pub fn event_deliveries(&self) -> &CompiledEventDeliveryInventory { + &self.event_delivery_inventory + } + + pub fn ddl(&self) -> &DdlInventory { + &self.ddl + } + + pub fn artifacts(&self) -> &GeneratedArtifacts { + &self.artifacts + } + + pub fn findings(&self) -> &[Diagnostic] { + &self.findings + } + + pub fn revision(&self) -> &str { + &self.revision + } +} diff --git a/crates/registry-server/src/mutation.rs b/crates/registry-server/src/mutation.rs new file mode 100644 index 0000000000..4b6cc76f0b --- /dev/null +++ b/crates/registry-server/src/mutation.rs @@ -0,0 +1,2477 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! One product-owned PostgreSQL transaction for a complete record mutation. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::time::Duration; + +use deadpool_postgres::Client; +use registry_platform_audit::AuditProfile; +use registry_platform_canonical_json::canonicalize_json; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use tokio_postgres::types::ToSql; +use tokio_postgres::{error::SqlState, GenericClient, Transaction}; +use uuid::Uuid; + +use crate::audit::{ + append_terminal_audit, profile_is_keyed, record_pre_io_audit, PreIoAudit, PreIoAuditKind, + RegistryAuditError, TerminalAudit, TerminalAuditOutcome, +}; +use crate::contract::{ + AccessProfileSource, EventTrigger, FieldTypeSource, MutationMode, Operation, +}; +use crate::data::{validate_field_value, FieldValue}; +use crate::event_destination::ActivatedEventDestinationRegistry; +use crate::idempotency::{ + insert_result, lock_and_load, resolve_binding, HeldResponse, IdempotencyBinding, + IdempotencyError, PermittedResponseHeader, StoredResultMetadata, +}; +use crate::model::{ + CompiledEntity, CompiledEventDelivery, CompiledRegistry, CompiledRoute, + CompiledWebhookDeliveryMode, HttpMethod, +}; +use crate::outbox::{insert_configured_events, OutboxError, OutboxMutation}; +use crate::postgres::{ + begin_record_transaction, ClaimContext, ExpectedRegistryIdentity, RegistryLockKey, + SqlIdentifier, +}; +use crate::revision::{canonical_snapshot, insert_revision, RevisionError, RevisionInsert}; + +const MAX_LOGICAL_ID_BYTES: usize = 256; +const TOMBSTONE_CURSOR: &str = "registry_tombstone_current"; + +/// Install the exact W3 mutation journal contract with the migration role. +/// +/// The schema is intentionally product-owned here. PostgreSQL catalog closure +/// consumes these exact objects rather than independently defining them. +pub async fn install_mutation_schema( + migration: &impl GenericClient, + runtime_role: &SqlIdentifier, +) -> Result<(), MutationError> { + migration + .batch_execute( + "CREATE TABLE IF NOT EXISTS registry_internal.registry_revisions ( + entity_id text NOT NULL CHECK (entity_id <> ''), + record_id uuid NOT NULL, + record_reference text NOT NULL CHECK (record_reference <> ''), + record_revision bigint NOT NULL CHECK (record_revision > 0), + predecessor_revision bigint + CHECK (predecessor_revision IS NULL OR predecessor_revision > 0), + record_lifecycle text NOT NULL + CHECK (record_lifecycle IN ('active', 'tombstoned')), + package_revision text NOT NULL CHECK (package_revision <> ''), + operation_id text NOT NULL CHECK (operation_id <> ''), + mutation_kind text NOT NULL + CHECK (mutation_kind IN ('create', 'patch', 'tombstone')), + principal_reference text NOT NULL CHECK (principal_reference <> ''), + request_reference text NOT NULL CHECK (request_reference <> ''), + snapshot bytea NOT NULL + CHECK (octet_length(snapshot) > 0 AND octet_length(snapshot) <= 2097152), + created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (entity_id, record_id, record_revision), + CHECK (predecessor_revision IS NULL OR predecessor_revision < record_revision) + ); + CREATE TABLE IF NOT EXISTS registry_internal.registry_outbox ( + outbox_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + event_id uuid NOT NULL UNIQUE, + event_type text NOT NULL CHECK (event_type <> ''), + trigger text NOT NULL CHECK (trigger IN ('created', 'patched', 'tombstoned')), + entity_id text NOT NULL CHECK (entity_id <> ''), + record_reference text NOT NULL CHECK (record_reference <> ''), + record_revision bigint NOT NULL CHECK (record_revision > 0), + package_revision text NOT NULL CHECK (package_revision <> ''), + schema_fingerprint text NOT NULL CHECK (schema_fingerprint <> ''), + payload bytea NOT NULL + CHECK (octet_length(payload) > 0 AND octet_length(payload) <= 2097152), + created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + UNIQUE (event_id, package_revision, schema_fingerprint) + ); + CREATE TABLE IF NOT EXISTS registry_internal.registry_webhook_deliveries ( + event_id uuid NOT NULL, + compiled_delivery_id text NOT NULL + CHECK (compiled_delivery_id <> '' AND octet_length(compiled_delivery_id) <= 256), + logical_destination_id text NOT NULL + CHECK (logical_destination_id ~ '^[a-z][a-z0-9_-]{0,63}$'), + destination_binding_digest text NOT NULL + CHECK (destination_binding_digest ~ '^sha256:[0-9a-f]{64}$'), + package_revision text NOT NULL + CHECK (package_revision <> '' AND octet_length(package_revision) <= 256), + schema_fingerprint text NOT NULL + CHECK (schema_fingerprint <> '' AND octet_length(schema_fingerprint) <= 256), + classification_ceiling text NOT NULL + CHECK (classification_ceiling IN ('public', 'internal', 'restricted')), + authentication_profile text NOT NULL + CHECK (authentication_profile = 'hmac_sha256_v1'), + delivery_mode text NOT NULL CHECK (delivery_mode = 'after_commit'), + attempt_timeout_ms bigint NOT NULL + CHECK (attempt_timeout_ms BETWEEN 100 AND 10000), + initial_backoff_ms bigint NOT NULL + CHECK (initial_backoff_ms BETWEEN 100 AND 3600000), + maximum_backoff_ms bigint NOT NULL + CHECK (maximum_backoff_ms BETWEEN initial_backoff_ms AND 3600000), + exponential_backoff_multiplier smallint NOT NULL + CHECK (exponential_backoff_multiplier = 2), + maximum_attempts smallint NOT NULL + CHECK (maximum_attempts BETWEEN 1 AND 20), + retry_delays_ms bigint[] NOT NULL + CHECK ( + cardinality(retry_delays_ms) = maximum_attempts - 1 + AND array_position(retry_delays_ms, NULL) IS NULL + AND initial_backoff_ms <= ALL(retry_delays_ms) + AND maximum_backoff_ms >= ALL(retry_delays_ms) + ), + maximum_payload_bytes bigint NOT NULL + CHECK (maximum_payload_bytes BETWEEN 1 AND 1048576), + payload_digest bytea NOT NULL CHECK (octet_length(payload_digest) = 32), + deployed_attempt_timeout_ms bigint NOT NULL + CHECK (deployed_attempt_timeout_ms BETWEEN 100 AND attempt_timeout_ms), + deployed_maximum_attempts smallint NOT NULL + CHECK (deployed_maximum_attempts BETWEEN 1 AND maximum_attempts), + dead_letter text NOT NULL CHECK (dead_letter = 'required'), + operator_replay boolean NOT NULL, + created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (event_id, compiled_delivery_id), + FOREIGN KEY (event_id, package_revision, schema_fingerprint) + REFERENCES registry_internal.registry_outbox + (event_id, package_revision, schema_fingerprint) + ON DELETE RESTRICT + ); + CREATE TABLE IF NOT EXISTS registry_internal.registry_webhook_delivery_state ( + event_id uuid NOT NULL, + compiled_delivery_id text NOT NULL + CHECK (compiled_delivery_id <> '' AND octet_length(compiled_delivery_id) <= 256), + generation bigint NOT NULL CHECK (generation > 0), + state text NOT NULL + CHECK (state IN ('pending', 'leased', 'delivered', 'dead_lettered')), + attempt smallint NOT NULL CHECK (attempt BETWEEN 0 AND 20), + next_attempt_at timestamptz, + attempt_started_at timestamptz, + lease_expires_at timestamptz, + lease_token uuid, + delivered_at timestamptz, + dead_lettered_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (event_id, compiled_delivery_id), + FOREIGN KEY (event_id, compiled_delivery_id) + REFERENCES registry_internal.registry_webhook_deliveries + (event_id, compiled_delivery_id) + ON DELETE RESTRICT, + CHECK ( + (state = 'pending' + AND next_attempt_at IS NOT NULL + AND attempt_started_at IS NULL + AND lease_expires_at IS NULL + AND lease_token IS NULL + AND delivered_at IS NULL + AND dead_lettered_at IS NULL) + OR (state = 'leased' + AND attempt > 0 + AND next_attempt_at IS NULL + AND attempt_started_at IS NOT NULL + AND lease_expires_at > attempt_started_at + AND lease_token IS NOT NULL + AND delivered_at IS NULL + AND dead_lettered_at IS NULL) + OR (state = 'delivered' + AND attempt > 0 + AND next_attempt_at IS NULL + AND attempt_started_at IS NULL + AND lease_expires_at IS NULL + AND lease_token IS NULL + AND delivered_at IS NOT NULL + AND dead_lettered_at IS NULL) + OR (state = 'dead_lettered' + AND attempt > 0 + AND next_attempt_at IS NULL + AND attempt_started_at IS NULL + AND lease_expires_at IS NULL + AND lease_token IS NULL + AND delivered_at IS NULL + AND dead_lettered_at IS NOT NULL) + ) + ); + CREATE INDEX IF NOT EXISTS registry_webhook_delivery_state_due_idx + ON registry_internal.registry_webhook_delivery_state + (next_attempt_at, event_id, compiled_delivery_id) + WHERE state = 'pending'; + CREATE INDEX IF NOT EXISTS registry_webhook_delivery_state_expired_idx + ON registry_internal.registry_webhook_delivery_state + (lease_expires_at, event_id, compiled_delivery_id) + WHERE state = 'leased'; + CREATE TABLE IF NOT EXISTS registry_internal.registry_audit ( + envelope_id text PRIMARY KEY CHECK (envelope_id <> ''), + record_hash bytea NOT NULL UNIQUE CHECK (octet_length(record_hash) = 32), + envelope bytea NOT NULL + CHECK (octet_length(envelope) > 0 AND octet_length(envelope) <= 65536), + created_at timestamptz NOT NULL DEFAULT transaction_timestamp() + ); + CREATE TABLE IF NOT EXISTS registry_internal.registry_audit_head ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + last_hash bytea CHECK (last_hash IS NULL OR octet_length(last_hash) = 32) + ); + CREATE TABLE IF NOT EXISTS registry_internal.registry_idempotency ( + key_reference text PRIMARY KEY CHECK (key_reference <> ''), + binding_reference text NOT NULL CHECK (binding_reference <> ''), + result_kind text NOT NULL CHECK (result_kind IN ('record', 'batch')), + record_reference text CHECK (record_reference <> ''), + record_revision bigint CHECK (record_revision > 0), + result_count smallint CHECK (result_count > 0 AND result_count <= 100), + response_status smallint NOT NULL CHECK (response_status BETWEEN 200 AND 299), + response_body bytea NOT NULL + CHECK (octet_length(response_body) > 0 AND octet_length(response_body) <= 2097152), + response_headers bytea NOT NULL CHECK (octet_length(response_headers) <= 65536), + created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + CHECK ( + (result_kind = 'record' AND record_reference IS NOT NULL + AND record_revision IS NOT NULL AND result_count IS NULL) + OR + (result_kind = 'batch' AND record_reference IS NULL + AND record_revision IS NULL AND result_count IS NOT NULL) + ) + ); + REVOKE ALL ON registry_internal.registry_revisions, + registry_internal.registry_outbox, + registry_internal.registry_webhook_deliveries, + registry_internal.registry_webhook_delivery_state, + registry_internal.registry_audit, + registry_internal.registry_audit_head, + registry_internal.registry_idempotency FROM PUBLIC;", + ) + .await + .map_err(|_| MutationError::Unavailable)?; + let role = runtime_role.as_str(); + migration + .batch_execute(&format!( + "REVOKE ALL ON registry_internal.registry_revisions, + registry_internal.registry_outbox, + registry_internal.registry_webhook_deliveries, + registry_internal.registry_webhook_delivery_state, + registry_internal.registry_audit, + registry_internal.registry_audit_head, + registry_internal.registry_idempotency FROM \"{role}\"; + GRANT SELECT, INSERT ON registry_internal.registry_revisions, + registry_internal.registry_outbox, + registry_internal.registry_webhook_deliveries, + registry_internal.registry_audit, + registry_internal.registry_idempotency TO \"{role}\"; + GRANT SELECT, INSERT, UPDATE + ON registry_internal.registry_webhook_delivery_state TO \"{role}\"; + GRANT SELECT, INSERT, UPDATE ON registry_internal.registry_audit_head TO \"{role}\"; + GRANT USAGE, SELECT ON SEQUENCE registry_internal.registry_outbox_outbox_id_seq + TO \"{role}\";" + )) + .await + .map_err(|_| MutationError::Unavailable)?; + Ok(()) +} + +#[derive(Clone)] +pub struct MutationPlan { + route: CompiledRoute, + entity: CompiledEntity, + event_deliveries: Vec, +} + +impl MutationPlan { + pub fn from_compiled( + registry: &CompiledRegistry, + route_id: &str, + ) -> Result { + let route = registry + .routes() + .routes + .iter() + .find(|route| route.id == route_id) + .ok_or(MutationError::InvalidRequest)?; + let entity = registry + .entities() + .get(&route.entity_id) + .ok_or(MutationError::InvalidRequest)?; + match (route.operation, route.method) { + (Operation::Create, HttpMethod::Post) + | (Operation::Patch, HttpMethod::Patch) + | (Operation::Tombstone, HttpMethod::Delete) + | (Operation::Batch, HttpMethod::Post) => {} + _ => return Err(MutationError::InvalidRequest), + } + if matches!(route.operation, Operation::Patch | Operation::Tombstone) + && entity.mutation_mode != MutationMode::Mutable + { + return Err(MutationError::InvalidRequest); + } + if route.operation == Operation::Tombstone && !entity.tombstone { + return Err(MutationError::InvalidRequest); + } + if route.operation == Operation::Batch && entity.batch.is_none() { + return Err(MutationError::InvalidRequest); + } + let inventory = registry + .physical_names() + .entities + .get(&entity.id) + .ok_or(MutationError::InvalidRequest)?; + if inventory.table != entity.physical_table + || entity.fields.iter().any(|(id, field)| { + inventory.fields.get(id) != Some(&field.physical_name) + || !valid_physical_identifier(&field.physical_name) + }) + || !valid_physical_identifier(&entity.physical_table) + { + return Err(MutationError::InvalidRequest); + } + let event_deliveries = exact_entity_event_deliveries(registry, entity)?; + Ok(Self { + route: route.clone(), + entity: entity.clone(), + event_deliveries, + }) + } + + #[must_use] + pub fn operation_id(&self) -> &str { + &self.route.id + } + + #[must_use] + pub fn route(&self) -> &str { + &self.route.path + } + + fn batch_item(&self, operation: Operation, profile_id: &str) -> Result { + if self.route.operation != Operation::Batch + || !matches!(operation, Operation::Create | Operation::Patch) + { + return Err(MutationError::InvalidRequest); + } + let (method, path) = match operation { + Operation::Create => ( + HttpMethod::Post, + format!("/v1/records/{}", self.entity.route), + ), + Operation::Patch => ( + HttpMethod::Patch, + format!("/v1/records/{}/{{record_id}}", self.entity.route), + ), + _ => return Err(MutationError::InvalidRequest), + }; + Ok(Self { + route: CompiledRoute { + id: format!( + "records.{}.{}", + self.entity.id, + match operation { + Operation::Create => "create", + Operation::Patch => "patch", + _ => unreachable!(), + } + ), + entity_id: self.entity.id.clone(), + method, + path, + operation, + query_kind: None, + revision_kind: None, + maximum_records: None, + access_profiles: vec![profile_id.to_owned()], + default_access_profile: profile_id.to_owned(), + }, + entity: self.entity.clone(), + event_deliveries: self.event_deliveries.clone(), + }) + } +} + +fn exact_entity_event_deliveries( + registry: &CompiledRegistry, + entity: &CompiledEntity, +) -> Result, MutationError> { + // A widened or substituted serialized inventory would become outbound + // authority. Re-derive every source-bound member before retaining it. + let deliveries = registry + .event_deliveries() + .deliveries + .iter() + .filter(|delivery| delivery.entity_id == entity.id) + .cloned() + .collect::>(); + let mut delivery_ids = BTreeSet::new(); + let mut delivered_events = BTreeSet::new(); + for delivery in &deliveries { + let event = entity + .events + .get(&delivery.event_id) + .ok_or(MutationError::InvalidRequest)?; + let webhook = event + .webhook + .as_ref() + .ok_or(MutationError::InvalidRequest)?; + let source_delivery = &webhook.delivery; + let expected_projection = event.projection.iter().cloned().collect::>(); + if !delivery_ids.insert(delivery.id.as_str()) + || !delivered_events.insert(delivery.event_id.as_str()) + || delivery.id != format!("events.{}.{}.webhook", entity.id, event.id) + || delivery.trigger != event.trigger + || delivery.destination_id != webhook.destination_id + || delivery.projection_fields != expected_projection + || delivery.classification_ceiling != webhook.classification_ceiling + || delivery.authentication_profile != webhook.authentication_profile + || delivery.delivery_mode != CompiledWebhookDeliveryMode::AfterCommit + || delivery.attempt_timeout_ms != source_delivery.attempt_timeout_ms + || delivery.initial_backoff_ms != source_delivery.initial_backoff_ms + || delivery.maximum_backoff_ms != source_delivery.maximum_backoff_ms + || delivery.exponential_backoff_multiplier != 2 + || delivery.maximum_attempts != source_delivery.maximum_attempts + || delivery.retry_delays_ms + != expected_retry_delays( + source_delivery.initial_backoff_ms, + source_delivery.maximum_backoff_ms, + source_delivery.maximum_attempts, + ) + || Some(delivery.dead_letter) != source_delivery.dead_letter + || delivery.operator_replay != source_delivery.operator_replay + || Some(delivery.maximum_payload_bytes) + != expected_maximum_event_payload_bytes(entity, event) + { + return Err(MutationError::InvalidRequest); + } + } + if entity + .events + .values() + .any(|event| event.webhook.is_some() && !delivered_events.contains(event.id.as_str())) + { + return Err(MutationError::InvalidRequest); + } + Ok(deliveries) +} + +fn expected_maximum_event_payload_bytes( + entity: &CompiledEntity, + event: &crate::contract::EventSource, +) -> Option { + let mut total = 2_u64.checked_add(event.projection.len().saturating_sub(1) as u64)?; + for field_id in &event.projection { + let field = entity.fields.get(field_id)?; + let maximum_value_bytes = maximum_field_json_bytes(&field.field_type)?; + let maximum_value_bytes = if field.required { + maximum_value_bytes + } else { + maximum_value_bytes.max(4) + }; + total = total + .checked_add(field_id.len() as u64 + 3)? + .checked_add(maximum_value_bytes)?; + } + let total = u32::try_from(total).ok()?; + (total <= crate::compiler::MAX_WEBHOOK_PAYLOAD_BYTES).then_some(total) +} + +fn maximum_field_json_bytes(field_type: &FieldTypeSource) -> Option { + match field_type { + FieldTypeSource::Boolean => Some(5), + FieldTypeSource::String { max_length, .. } | FieldTypeSource::Text { max_length } => { + 2_u64.checked_add(u64::from(*max_length).checked_mul(6)?) + } + FieldTypeSource::Int64 => Some(20), + FieldTypeSource::Decimal { + precision, scale, .. + } => Some( + u64::from(*precision) + + u64::from(*scale > 0) + + u64::from(*scale > 0 && scale == precision) + + 3, + ), + FieldTypeSource::Date => Some(12), + FieldTypeSource::Timestamp => Some(64), + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => Some(38), + FieldTypeSource::VocabularyCode { values, .. } => values + .iter() + .filter_map(|value| canonicalize_json(&Value::String(value.clone())).ok()) + .map(|value| value.len() as u64) + .max(), + FieldTypeSource::Crs84Point { .. } => Some(128), + FieldTypeSource::Structured { max_bytes, .. } => Some(u64::from(*max_bytes)), + } +} + +fn expected_retry_delays(initial_ms: u32, maximum_ms: u32, maximum_attempts: u8) -> Vec { + let mut delay = initial_ms; + (1..maximum_attempts) + .map(|_| { + let current = delay; + delay = delay.saturating_mul(2).min(maximum_ms); + current + }) + .collect() +} + +pub struct MutationRequest<'a> { + pub plan: &'a MutationPlan, + pub idempotency_key: &'a str, + pub claims: &'a ClaimContext, + pub record_id: Option<&'a str>, + pub expected_etag: Option<&'a str>, + pub body: MutationBody, + pub response_fields: BTreeSet, +} + +pub struct BatchMutationRequest<'a> { + pub plan: &'a MutationPlan, + pub idempotency_key: &'a str, + pub claims: &'a ClaimContext, + pub items: Vec, + pub response_fields: BTreeSet, + pub body_bytes: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BatchMutationItem { + Create(Map), + Patch { + record_id: String, + expected_etag: String, + patch: Vec, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MutationBody { + Create(Map), + Patch(Vec), + Tombstone, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PatchOperation { + Add { path: String, value: Value }, + Replace { path: String, value: Value }, + Remove { path: String }, + Test { path: String, value: Value }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MutationOutcome { + response: HeldResponse, + replayed: bool, +} + +impl MutationOutcome { + #[must_use] + pub fn response(&self) -> &HeldResponse { + &self.response + } + + #[must_use] + pub fn replayed(&self) -> bool { + self.replayed + } +} + +#[derive(Clone)] +pub struct MutationCoordinator { + lock_key: RegistryLockKey, + lock_timeout: Duration, + expected: ExpectedRegistryIdentity, + audit_profile: AuditProfile, + event_destinations: Option>, +} + +impl MutationCoordinator { + #[must_use] + pub fn new( + lock_key: RegistryLockKey, + lock_timeout: Duration, + expected: ExpectedRegistryIdentity, + audit_profile: AuditProfile, + ) -> Self { + Self::new_with_event_destinations(lock_key, lock_timeout, expected, audit_profile, None) + } + + #[must_use] + pub fn new_with_event_destinations( + lock_key: RegistryLockKey, + lock_timeout: Duration, + expected: ExpectedRegistryIdentity, + audit_profile: AuditProfile, + event_destinations: Option>, + ) -> Self { + Self { + lock_key, + lock_timeout, + expected, + audit_profile, + event_destinations, + } + } + + /// Execute a mutation only through durable attempt/refusal and terminal + /// audit gates. No public mutation entry point bypasses this ordering. + pub async fn execute( + &self, + client: &mut Client, + request: MutationRequest<'_>, + ) -> Result { + self.execute_guarded(client, &request, FaultControl::Disabled) + .await + } + + pub async fn execute_batch( + &self, + client: &mut Client, + request: BatchMutationRequest<'_>, + ) -> Result { + self.execute_batch_guarded(client, &request, FaultControl::Disabled) + .await + } + + #[cfg(feature = "postgres-test")] + #[doc(hidden)] + pub async fn execute_batch_with_fault( + &self, + client: &mut Client, + request: BatchMutationRequest<'_>, + fault: MutationFaultPoint, + ) -> Result { + self.execute_batch_guarded(client, &request, FaultControl::At(fault)) + .await + } + + #[cfg(feature = "postgres-test")] + #[doc(hidden)] + pub async fn execute_with_fault( + &self, + client: &mut Client, + request: MutationRequest<'_>, + fault: MutationFaultPoint, + ) -> Result { + self.execute_guarded(client, &request, FaultControl::At(fault)) + .await + } + + async fn execute_guarded( + &self, + client: &mut Client, + request: &MutationRequest<'_>, + fault: FaultControl, + ) -> Result { + if !profile_is_keyed(&self.audit_profile) { + return Err(MutationError::Unavailable); + } + if let Err(error) = validate_request(request, &self.expected) { + self.record_boundary_audit(client, request, PreIoAuditKind::Refusal) + .await?; + return Err(error); + } + self.record_boundary_audit(client, request, PreIoAuditKind::Attempt) + .await?; + let result = self.execute_after_attempt(client, request, fault).await; + if result.is_err() && !fault.is_enabled() { + self.record_boundary_audit(client, request, PreIoAuditKind::Refusal) + .await?; + } + result + } + + async fn execute_batch_guarded( + &self, + client: &mut Client, + request: &BatchMutationRequest<'_>, + fault: FaultControl, + ) -> Result { + if !profile_is_keyed(&self.audit_profile) { + return Err(MutationError::Unavailable); + } + if let Err(error) = validate_batch_request(request, &self.expected) { + self.record_batch_boundary_audit(client, request, PreIoAuditKind::Refusal) + .await?; + return Err(error); + } + self.record_batch_boundary_audit(client, request, PreIoAuditKind::Attempt) + .await?; + let result = self + .execute_batch_after_attempt(client, request, fault) + .await; + if result.is_err() && !fault.is_enabled() { + self.record_batch_boundary_audit(client, request, PreIoAuditKind::Refusal) + .await?; + } + result + } + + async fn record_batch_boundary_audit( + &self, + client: &mut Client, + request: &BatchMutationRequest<'_>, + kind: PreIoAuditKind, + ) -> Result<(), MutationError> { + record_pre_io_audit( + client, + self.lock_key, + self.lock_timeout, + &self.expected, + request.claims, + &self.audit_profile, + PreIoAudit { + kind, + method: request.plan.route.method, + operation_id: &request.plan.route.id, + target_record: None, + }, + ) + .await?; + Ok(()) + } + + async fn record_boundary_audit( + &self, + client: &mut Client, + request: &MutationRequest<'_>, + kind: PreIoAuditKind, + ) -> Result<(), MutationError> { + record_pre_io_audit( + client, + self.lock_key, + self.lock_timeout, + &self.expected, + request.claims, + &self.audit_profile, + PreIoAudit { + kind, + method: request.plan.route.method, + operation_id: &request.plan.route.id, + target_record: request.record_id, + }, + ) + .await?; + Ok(()) + } + + async fn execute_after_attempt( + &self, + client: &mut Client, + request: &MutationRequest<'_>, + fault: FaultControl, + ) -> Result { + let canonical_request_digest = canonical_request_digest(request)?; + let binding = resolve_binding( + &self.audit_profile, + &IdempotencyBinding { + key: request.idempotency_key, + context: request.claims, + method: request.plan.route.method, + route: &request.plan.route.path, + target_record: request.record_id, + package_revision: &self.expected.package_revision, + response_fields: &request.response_fields, + canonical_request_digest, + }, + )?; + let transaction = begin_record_transaction( + client, + self.lock_key, + self.lock_timeout, + &self.expected, + request.claims, + ) + .await + .map_err(|_| MutationError::Unavailable)?; + + if let Some(stored) = lock_and_load(transaction.transaction(), &binding).await? { + if !matches!(&stored.metadata, StoredResultMetadata::Record { .. }) { + return Err(MutationError::Unavailable); + } + append_terminal_audit( + transaction.transaction(), + &self.audit_profile, + TerminalAudit { + outcome: TerminalAuditOutcome::Replayed, + method: request.plan.route.method, + operation_id: request.plan.route.id.clone(), + entity_id: request.plan.entity.id.clone(), + package_revision: self.expected.package_revision.clone(), + selected_access_profile: request.claims.access_profile().to_owned(), + purpose_present: request.claims.purpose().is_some(), + principal_reference: Some(binding.principal_reference.clone()), + record_reference: match &stored.metadata { + StoredResultMetadata::Record { + record_reference, .. + } => Some(record_reference.clone()), + StoredResultMetadata::Batch { .. } => None, + }, + record_revision: match &stored.metadata { + StoredResultMetadata::Record { + record_revision, .. + } => Some(*record_revision), + StoredResultMetadata::Batch { .. } => None, + }, + result_count: None, + field_set_reference: None, + }, + ) + .await?; + transaction + .commit() + .await + .map_err(|_| MutationError::Unavailable)?; + return Ok(MutationOutcome { + response: stored.response, + replayed: true, + }); + } + + fault.fail_at(MutationFaultPoint::BeforeCurrentRow)?; + let current = apply_current_row( + transaction.transaction(), + request, + &self.audit_profile, + &self.expected.package_revision, + ) + .await?; + let record_reference = match request.record_id { + Some(_) => binding.record_reference.clone(), + None => record_reference( + &self.audit_profile, + &self.expected.package_revision, + ¤t.record_id, + )?, + }; + let held = self.held_response(request, ¤t)?; + let snapshot = canonical_snapshot(¤t.data)?; + fault.fail_at(MutationFaultPoint::BeforeRevision)?; + insert_revision( + transaction.transaction(), + RevisionInsert { + entity_id: &request.plan.entity.id, + record_id: current.record_uuid, + record_reference: &record_reference, + record_revision: current.record_revision, + predecessor_revision: current.predecessor_revision, + lifecycle: ¤t.record_lifecycle, + package_revision: &self.expected.package_revision, + operation_id: &request.plan.route.id, + mutation_kind: mutation_kind(request.plan.route.operation), + principal_reference: &binding.principal_reference, + request_reference: &binding.binding_reference, + snapshot: &snapshot, + }, + ) + .await?; + fault.fail_at(MutationFaultPoint::BeforeOutbox)?; + insert_configured_events( + transaction.transaction(), + &request.plan.entity.events, + &request.plan.event_deliveries, + self.event_destinations.as_deref(), + OutboxMutation { + trigger: mutation_trigger(request.plan.route.operation), + entity_id: &request.plan.entity.id, + record_reference: &record_reference, + record_revision: current.record_revision, + package_revision: &self.expected.package_revision, + schema_fingerprint: &self.expected.schema_fingerprint, + data: ¤t.data, + }, + ) + .await?; + fault.fail_at(MutationFaultPoint::BeforeTerminalAudit)?; + append_terminal_audit( + transaction.transaction(), + &self.audit_profile, + TerminalAudit { + outcome: TerminalAuditOutcome::Committed, + method: request.plan.route.method, + operation_id: request.plan.route.id.clone(), + entity_id: request.plan.entity.id.clone(), + package_revision: self.expected.package_revision.clone(), + selected_access_profile: request.claims.access_profile().to_owned(), + purpose_present: request.claims.purpose().is_some(), + principal_reference: Some(binding.principal_reference.clone()), + record_reference: Some(record_reference.clone()), + record_revision: Some(current.record_revision), + result_count: None, + field_set_reference: None, + }, + ) + .await?; + fault.fail_at(MutationFaultPoint::BeforeIdempotency)?; + insert_result( + transaction.transaction(), + &binding, + &StoredResultMetadata::Record { + record_revision: current.record_revision, + record_reference: record_reference.clone(), + }, + &held, + ) + .await?; + fault.fail_at(MutationFaultPoint::BeforeCommit)?; + transaction + .commit() + .await + .map_err(|_| MutationError::Unavailable)?; + fault.fail_at(MutationFaultPoint::AfterCommitBeforeResponseRelease)?; + Ok(MutationOutcome { + response: held, + replayed: false, + }) + } + + async fn execute_batch_after_attempt( + &self, + client: &mut Client, + request: &BatchMutationRequest<'_>, + fault: FaultControl, + ) -> Result { + let canonical_request_digest = canonical_batch_request_digest(request)?; + let binding = resolve_binding( + &self.audit_profile, + &IdempotencyBinding { + key: request.idempotency_key, + context: request.claims, + method: request.plan.route.method, + route: &request.plan.route.path, + target_record: None, + package_revision: &self.expected.package_revision, + response_fields: &request.response_fields, + canonical_request_digest, + }, + )?; + let transaction = begin_record_transaction( + client, + self.lock_key, + self.lock_timeout, + &self.expected, + request.claims, + ) + .await + .map_err(|_| MutationError::Unavailable)?; + + if let Some(stored) = lock_and_load(transaction.transaction(), &binding).await? { + let StoredResultMetadata::Batch { result_count } = stored.metadata else { + return Err(MutationError::Unavailable); + }; + append_terminal_audit( + transaction.transaction(), + &self.audit_profile, + TerminalAudit { + outcome: TerminalAuditOutcome::Replayed, + method: request.plan.route.method, + operation_id: request.plan.route.id.clone(), + entity_id: request.plan.entity.id.clone(), + package_revision: self.expected.package_revision.clone(), + selected_access_profile: request.claims.access_profile().to_owned(), + purpose_present: request.claims.purpose().is_some(), + principal_reference: Some(binding.principal_reference.clone()), + record_reference: None, + record_revision: None, + result_count: Some(usize::from(result_count)), + field_set_reference: None, + }, + ) + .await?; + transaction + .commit() + .await + .map_err(|_| MutationError::Unavailable)?; + return Ok(MutationOutcome { + response: stored.response, + replayed: true, + }); + } + + let mut held_items = Vec::with_capacity(request.items.len()); + for (item_index, item) in request.items.iter().enumerate() { + let item_plan = request + .plan + .batch_item(item.operation(), request.claims.access_profile())?; + let (record_id, expected_etag, body) = item.request_parts(); + let item_request = MutationRequest { + plan: &item_plan, + idempotency_key: request.idempotency_key, + claims: request.claims, + record_id, + expected_etag, + body, + response_fields: request.response_fields.clone(), + }; + fault.fail_at(MutationFaultPoint::BeforeCurrentRow)?; + let current = apply_current_row( + transaction.transaction(), + &item_request, + &self.audit_profile, + &self.expected.package_revision, + ) + .await?; + let record_reference = record_reference( + &self.audit_profile, + &self.expected.package_revision, + ¤t.record_id, + )?; + let snapshot = canonical_snapshot(¤t.data)?; + fault.fail_at(MutationFaultPoint::BeforeRevision)?; + insert_revision( + transaction.transaction(), + RevisionInsert { + entity_id: &item_plan.entity.id, + record_id: current.record_uuid, + record_reference: &record_reference, + record_revision: current.record_revision, + predecessor_revision: current.predecessor_revision, + lifecycle: ¤t.record_lifecycle, + package_revision: &self.expected.package_revision, + operation_id: &item_plan.route.id, + mutation_kind: mutation_kind(item_plan.route.operation), + principal_reference: &binding.principal_reference, + request_reference: &binding.binding_reference, + snapshot: &snapshot, + }, + ) + .await?; + fault.fail_at(MutationFaultPoint::BeforeOutbox)?; + insert_configured_events( + transaction.transaction(), + &item_plan.entity.events, + &item_plan.event_deliveries, + self.event_destinations.as_deref(), + OutboxMutation { + trigger: mutation_trigger(item_plan.route.operation), + entity_id: &item_plan.entity.id, + record_reference: &record_reference, + record_revision: current.record_revision, + package_revision: &self.expected.package_revision, + schema_fingerprint: &self.expected.schema_fingerprint, + data: ¤t.data, + }, + ) + .await?; + held_items.push(self.batch_item_response(request, item, ¤t)?); + #[cfg(feature = "postgres-test")] + if item_index == 0 { + fault.fail_at(MutationFaultPoint::AfterFirstBatchItem)?; + } + #[cfg(not(feature = "postgres-test"))] + let _ = item_index; + } + + let result_count = + u16::try_from(held_items.len()).map_err(|_| MutationError::Unavailable)?; + let held = HeldResponse::from_json( + 200, + &json!({"results": held_items}), + BTreeMap::from([( + PermittedResponseHeader::ContentType, + b"application/json".to_vec(), + )]), + )?; + fault.fail_at(MutationFaultPoint::BeforeTerminalAudit)?; + append_terminal_audit( + transaction.transaction(), + &self.audit_profile, + TerminalAudit { + outcome: TerminalAuditOutcome::Committed, + method: request.plan.route.method, + operation_id: request.plan.route.id.clone(), + entity_id: request.plan.entity.id.clone(), + package_revision: self.expected.package_revision.clone(), + selected_access_profile: request.claims.access_profile().to_owned(), + purpose_present: request.claims.purpose().is_some(), + principal_reference: Some(binding.principal_reference.clone()), + record_reference: None, + record_revision: None, + result_count: Some(usize::from(result_count)), + field_set_reference: None, + }, + ) + .await?; + fault.fail_at(MutationFaultPoint::BeforeIdempotency)?; + insert_result( + transaction.transaction(), + &binding, + &StoredResultMetadata::Batch { result_count }, + &held, + ) + .await?; + fault.fail_at(MutationFaultPoint::BeforeCommit)?; + transaction + .commit() + .await + .map_err(|_| MutationError::Unavailable)?; + fault.fail_at(MutationFaultPoint::AfterCommitBeforeResponseRelease)?; + Ok(MutationOutcome { + response: held, + replayed: false, + }) + } + + fn batch_item_response( + &self, + request: &BatchMutationRequest<'_>, + item: &BatchMutationItem, + current: &CurrentRow, + ) -> Result { + let data = current + .data + .iter() + .filter(|(field, _)| request.response_fields.contains(*field)) + .map(|(field, value)| (field.clone(), value.clone())) + .collect::>(); + let etag = strong_record_etag( + &self.audit_profile, + request.claims, + &self.expected.package_revision, + ¤t.record_id, + current.record_revision, + &request.response_fields, + )?; + Ok(json!({ + "operation": match item { + BatchMutationItem::Create(_) => "create", + BatchMutationItem::Patch { .. } => "patch", + }, + "id": current.record_id, + "revision": current.record_revision, + "etag": etag, + "data": data, + })) + } + + fn held_response( + &self, + request: &MutationRequest<'_>, + current: &CurrentRow, + ) -> Result { + let data = current + .data + .iter() + .filter(|(field, _)| request.response_fields.contains(*field)) + .map(|(field, value)| (field.clone(), value.clone())) + .collect::>(); + let body = json!({ + "id": current.record_id, + "revision": current.record_revision, + "data": data, + }); + let etag = strong_record_etag( + &self.audit_profile, + request.claims, + &self.expected.package_revision, + ¤t.record_id, + current.record_revision, + &request.response_fields, + )?; + let mut headers = BTreeMap::from([ + ( + PermittedResponseHeader::ContentType, + b"application/json".to_vec(), + ), + (PermittedResponseHeader::Etag, etag.into_bytes()), + ]); + let status = match request.plan.route.operation { + Operation::Create => { + headers.insert( + PermittedResponseHeader::Location, + format!( + "{}/{}", + request.plan.route.path.trim_end_matches('/'), + current.record_id + ) + .into_bytes(), + ); + 201 + } + Operation::Patch => 200, + Operation::Tombstone => 200, + _ => return Err(MutationError::InvalidRequest), + }; + HeldResponse::from_json(status, &body, headers).map_err(MutationError::from) + } +} + +pub(crate) fn strong_record_etag( + profile: &AuditProfile, + claims: &ClaimContext, + package_revision: &str, + record_id: &str, + record_revision: i64, + response_fields: &BTreeSet, +) -> Result { + let key_hasher = profile.key_hasher(); + let principal_reference = claims + .principal() + .map(|principal| { + key_hasher.audit_reference_hash( + "registry-server-principal-v1", + package_revision, + principal, + ) + }) + .transpose() + .map_err(|_| MutationError::Unavailable)?; + let row_boundaries = claims + .row_boundaries() + .iter() + .map(|boundary| { + let reference_context = format!( + "{package_revision}:{}:{}", + boundary.field(), + boundary.operator().as_str() + ); + let value_references = boundary + .values() + .into_iter() + .map(|value| { + key_hasher.audit_reference_hash( + "registry-server-row-boundary-value-v1", + &reference_context, + value, + ) + }) + .collect::, _>>() + .map_err(|_| MutationError::Unavailable)?; + Ok(json!({ + "field": boundary.field(), + "operator": boundary.operator().as_str(), + "valueReferences": value_references, + })) + }) + .collect::, MutationError>>()?; + let authorization_context = json!({ + "entityId": claims.entity_id(), + "principalReference": principal_reference, + "selectedAccessProfile": claims.access_profile(), + "verifiedPurpose": claims.purpose(), + "rowBoundaries": row_boundaries, + }); + let etag_input = canonicalize_json(&json!({ + "authorizationContext": authorization_context, + "packageRevision": package_revision, + "recordId": record_id, + "recordRevision": record_revision, + "responseFields": response_fields, + })) + .map_err(|_| MutationError::InvalidRequest)?; + let etag_input = std::str::from_utf8(&etag_input).map_err(|_| MutationError::InvalidRequest)?; + let digest = profile + .key_hasher() + .audit_reference_hash( + "registry-server-response-etag-v1", + package_revision, + etag_input, + ) + .map_err(|_| MutationError::Unavailable)?; + Ok(format!("\"rs-{digest}\"")) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum MutationError { + #[error("mutation request is invalid")] + InvalidRequest, + #[error("mutation precondition failed")] + PreconditionFailed, + #[error("mutation conflicts with current state")] + Conflict, + #[error("idempotency key is already bound to another request")] + IdempotencyConflict, + #[error("mutation service is unavailable")] + Unavailable, +} + +#[cfg(feature = "postgres-test")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MutationFaultPoint { + BeforeCurrentRow, + BeforeRevision, + BeforeOutbox, + AfterFirstBatchItem, + BeforeTerminalAudit, + BeforeIdempotency, + BeforeCommit, + AfterCommitBeforeResponseRelease, +} + +#[cfg(not(feature = "postgres-test"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MutationFaultPoint { + BeforeCurrentRow, + BeforeRevision, + BeforeOutbox, + BeforeTerminalAudit, + BeforeIdempotency, + BeforeCommit, + AfterCommitBeforeResponseRelease, +} + +#[derive(Clone, Copy)] +enum FaultControl { + Disabled, + #[cfg(feature = "postgres-test")] + At(MutationFaultPoint), +} + +impl FaultControl { + fn fail_at(self, point: MutationFaultPoint) -> Result<(), MutationError> { + #[cfg(feature = "postgres-test")] + if matches!(self, Self::At(configured) if configured == point) { + return Err(MutationError::Unavailable); + } + let _ = (self, point); + Ok(()) + } + + fn is_enabled(self) -> bool { + #[cfg(feature = "postgres-test")] + if matches!(self, Self::At(_)) { + return true; + } + false + } +} + +struct CurrentRow { + record_uuid: Uuid, + record_id: String, + record_revision: i64, + predecessor_revision: Option, + record_lifecycle: String, + data: Map, +} + +async fn apply_current_row( + transaction: &Transaction<'_>, + request: &MutationRequest<'_>, + audit_profile: &AuditProfile, + package_revision: &str, +) -> Result { + match request.plan.route.operation { + Operation::Create => { + let record_id = Uuid::new_v4().to_string(); + apply_create_row(transaction, request, &record_id).await + } + Operation::Patch => { + let current = load_current_row_for_update(transaction, request).await?; + let expected = request + .expected_etag + .ok_or(MutationError::InvalidRequest)? + .as_bytes(); + let current_etag = strong_record_etag( + audit_profile, + request.claims, + package_revision, + ¤t.record_id, + current.record_revision, + &request.response_fields, + )?; + if expected.ct_eq(current_etag.as_bytes()).unwrap_u8() != 1 { + return Err(MutationError::PreconditionFailed); + } + let data = apply_patch_document(request, ¤t.data)?; + let mut row = + apply_patch_row(transaction, request, current.record_revision, data).await?; + row.predecessor_revision = Some(current.record_revision); + Ok(row) + } + Operation::Tombstone => { + let current = load_tombstone_row_for_update(transaction, request).await?; + let expected = request + .expected_etag + .ok_or(MutationError::InvalidRequest)? + .as_bytes(); + let current_etag = strong_record_etag( + audit_profile, + request.claims, + package_revision, + ¤t.record_id, + current.record_revision, + &request.response_fields, + )?; + if expected.ct_eq(current_etag.as_bytes()).unwrap_u8() != 1 { + return Err(MutationError::PreconditionFailed); + } + apply_tombstone_row(transaction, request, current).await + } + _ => Err(MutationError::InvalidRequest), + } +} + +async fn apply_create_row( + transaction: &Transaction<'_>, + request: &MutationRequest<'_>, + record_id: &str, +) -> Result { + let MutationBody::Create(data) = &request.body else { + return Err(MutationError::InvalidRequest); + }; + let submitted_fields = request + .plan + .entity + .fields + .values() + .filter(|field| data.contains_key(&field.id)) + .collect::>(); + let mut values = Vec::>::with_capacity(submitted_fields.len() + 2); + values.push(Some(record_id.to_owned())); + for field in &submitted_fields { + values.push(sql_value(&data[&field.id], &field.field_type)?); + } + let parameters = values + .iter() + .map(|value| value as &(dyn ToSql + Sync)) + .collect::>(); + let table = quote_identifier(&request.plan.entity.physical_table); + let field_columns = submitted_fields + .iter() + .map(|field| quote_identifier(&field.physical_name)) + .collect::>(); + let field_parameters = submitted_fields + .iter() + .enumerate() + .map(|(index, field)| typed_parameter(index + 2, &field.field_type)) + .collect::>(); + let returning = returning_projection(&request.plan.entity); + + let mut columns = vec![ + "record_id".to_owned(), + "record_revision".to_owned(), + "record_lifecycle".to_owned(), + ]; + columns.extend(field_columns); + let mut placeholders = vec![ + "$1::text::uuid".to_owned(), + "1".to_owned(), + "'active'".to_owned(), + ]; + placeholders.extend(field_parameters); + let sql = format!( + "INSERT INTO registry_data.{table} ({}) VALUES ({}) RETURNING {returning}", + columns.join(", "), + placeholders.join(", ") + ); + let row = transaction + .query_one(&sql, ¶meters) + .await + .map_err(map_database_error)?; + row_to_current(&request.plan.entity, &row) +} + +async fn apply_patch_row( + transaction: &Transaction<'_>, + request: &MutationRequest<'_>, + expected_revision: i64, + data: Map, +) -> Result { + let record_id = request.record_id.ok_or(MutationError::InvalidRequest)?; + let submitted_fields = request + .plan + .entity + .fields + .values() + .filter(|field| data.contains_key(&field.id)) + .collect::>(); + if submitted_fields.is_empty() { + return Err(MutationError::InvalidRequest); + } + let mut values = Vec::>::with_capacity(submitted_fields.len() + 2); + values.push(Some(record_id.to_owned())); + for field in &submitted_fields { + values.push(sql_value(&data[&field.id], &field.field_type)?); + } + values.push(Some(expected_revision.to_string())); + let parameters = values + .iter() + .map(|value| value as &(dyn ToSql + Sync)) + .collect::>(); + let table = quote_identifier(&request.plan.entity.physical_table); + let assignments = submitted_fields + .iter() + .enumerate() + .map(|(index, field)| { + format!( + "{} = {}", + quote_identifier(&field.physical_name), + typed_parameter(index + 2, &field.field_type) + ) + }) + .collect::>(); + let expected_parameter = values.len(); + let returning = returning_projection(&request.plan.entity); + let sql = format!( + "UPDATE registry_data.{table} + SET record_revision = record_revision + 1, + active_package_revision = DEFAULT, + updated_at = transaction_timestamp(), + {} + WHERE record_id = $1::text::uuid + AND record_revision = ${expected_parameter}::text::bigint + AND record_lifecycle = 'active' + RETURNING {returning}", + assignments.join(", ") + ); + let row = transaction + .query_opt(&sql, ¶meters) + .await + .map_err(map_database_error)? + .ok_or(MutationError::PreconditionFailed)?; + row_to_current(&request.plan.entity, &row) +} + +async fn apply_tombstone_row( + transaction: &Transaction<'_>, + request: &MutationRequest<'_>, + current: CurrentRow, +) -> Result { + let _ = request.record_id.ok_or(MutationError::InvalidRequest)?; + let table = quote_identifier(&request.plan.entity.physical_table); + let next_revision = current + .record_revision + .checked_add(1) + .ok_or(MutationError::Unavailable)?; + let changed = transaction + .execute( + &format!( + "UPDATE registry_data.{table} + SET record_revision = $1::bigint, + record_lifecycle = 'tombstoned', + active_package_revision = DEFAULT, + updated_at = transaction_timestamp() + WHERE CURRENT OF {TOMBSTONE_CURSOR}" + ), + &[&next_revision], + ) + .await + .map_err(map_database_error)?; + if changed != 1 { + return Err(MutationError::PreconditionFailed); + } + Ok(CurrentRow { + record_uuid: current.record_uuid, + record_id: current.record_id, + record_revision: next_revision, + predecessor_revision: Some(current.record_revision), + record_lifecycle: "tombstoned".to_owned(), + data: current.data, + }) +} + +async fn load_current_row_for_update( + transaction: &Transaction<'_>, + request: &MutationRequest<'_>, +) -> Result { + let record_id = request.record_id.ok_or(MutationError::InvalidRequest)?; + if !valid_uuid(record_id) { + return Err(MutationError::InvalidRequest); + } + let table = quote_identifier(&request.plan.entity.physical_table); + let returning = returning_projection(&request.plan.entity); + let sql = format!( + "SELECT {returning} + FROM registry_data.{table} + WHERE record_id = $1::text::uuid + AND record_lifecycle = 'active' + FOR UPDATE" + ); + let row = transaction + .query_opt(&sql, &[&record_id]) + .await + .map_err(map_database_error)? + .ok_or(MutationError::PreconditionFailed)?; + row_to_current(&request.plan.entity, &row) +} + +async fn load_tombstone_row_for_update( + transaction: &Transaction<'_>, + request: &MutationRequest<'_>, +) -> Result { + let record_id = request.record_id.ok_or(MutationError::InvalidRequest)?; + if !valid_uuid(record_id) { + return Err(MutationError::InvalidRequest); + } + let table = quote_identifier(&request.plan.entity.physical_table); + let returning = returning_projection(&request.plan.entity); + let declare = format!( + "DECLARE {TOMBSTONE_CURSOR} NO SCROLL CURSOR FOR + SELECT {returning} + FROM registry_data.{table} + WHERE record_id = $1::text::uuid + AND record_lifecycle = 'active' + FOR UPDATE" + ); + transaction + .execute(&declare, &[&record_id]) + .await + .map_err(map_database_error)?; + let fetch = format!("FETCH FORWARD 1 FROM {TOMBSTONE_CURSOR}"); + let row = transaction + .query_opt(&fetch, &[]) + .await + .map_err(map_database_error)? + .ok_or(MutationError::PreconditionFailed)?; + row_to_current(&request.plan.entity, &row) +} + +fn apply_patch_document( + request: &MutationRequest<'_>, + current: &Map, +) -> Result, MutationError> { + let MutationBody::Patch(operations) = &request.body else { + return Err(MutationError::InvalidRequest); + }; + if operations.is_empty() { + return Err(MutationError::InvalidRequest); + } + let profile = selected_profile(request)?; + let mut materialized = current.clone(); + let mut changed = Map::new(); + let mut mutated = false; + for operation in operations { + match operation { + PatchOperation::Add { path, value } | PatchOperation::Replace { path, value } => { + let field_id = patch_field(path)?; + let field = request + .plan + .entity + .fields + .get(&field_id) + .ok_or(MutationError::InvalidRequest)?; + if !profile.writable_fields.contains(&field_id) || value.is_null() && field.required + { + return Err(MutationError::InvalidRequest); + } + sql_value(value, &field.field_type)?; + materialized.insert(field_id.clone(), value.clone()); + changed.insert(field_id, value.clone()); + mutated = true; + } + PatchOperation::Remove { path } => { + let field_id = patch_field(path)?; + let field = request + .plan + .entity + .fields + .get(&field_id) + .ok_or(MutationError::InvalidRequest)?; + if field.required || !profile.writable_fields.contains(&field_id) { + return Err(MutationError::InvalidRequest); + } + materialized.insert(field_id.clone(), Value::Null); + changed.insert(field_id, Value::Null); + mutated = true; + } + PatchOperation::Test { path, value } => { + let field_id = patch_field(path)?; + if !profile.readable_fields.contains(&field_id) + || !request.plan.entity.fields.contains_key(&field_id) + { + return Err(MutationError::InvalidRequest); + } + if materialized.get(&field_id) != Some(value) { + return Err(MutationError::Conflict); + } + } + } + } + if !mutated { + return Err(MutationError::InvalidRequest); + } + Ok(changed) +} + +pub fn parse_json_patch_document(value: Value) -> Result, MutationError> { + let operations = value.as_array().ok_or(MutationError::InvalidRequest)?; + if operations.is_empty() || operations.len() > 128 { + return Err(MutationError::InvalidRequest); + } + operations + .iter() + .map(|operation| { + let object = operation.as_object().ok_or(MutationError::InvalidRequest)?; + let op = object + .get("op") + .and_then(Value::as_str) + .ok_or(MutationError::InvalidRequest)?; + let path = object + .get("path") + .and_then(Value::as_str) + .ok_or(MutationError::InvalidRequest)?; + match op { + "add" | "replace" | "test" if object.len() == 3 && object.contains_key("value") => { + let value = object["value"].clone(); + match op { + "add" => Ok(PatchOperation::Add { + path: path.to_owned(), + value, + }), + "replace" => Ok(PatchOperation::Replace { + path: path.to_owned(), + value, + }), + "test" => Ok(PatchOperation::Test { + path: path.to_owned(), + value, + }), + _ => unreachable!(), + } + } + "remove" if object.len() == 2 => Ok(PatchOperation::Remove { + path: path.to_owned(), + }), + _ => Err(MutationError::InvalidRequest), + } + }) + .collect() +} + +fn patch_field(path: &str) -> Result { + let suffix = path + .strip_prefix("/data/") + .ok_or(MutationError::InvalidRequest)?; + if suffix.is_empty() || suffix.contains('/') { + return Err(MutationError::InvalidRequest); + } + decode_pointer_segment(suffix) +} + +fn decode_pointer_segment(value: &str) -> Result { + let mut decoded = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(character) = chars.next() { + if character != '~' { + decoded.push(character); + continue; + } + match chars.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => return Err(MutationError::InvalidRequest), + } + } + Ok(decoded) +} + +fn returning_projection(entity: &CompiledEntity) -> String { + let mut expressions = vec![ + "record_id::text".to_owned(), + "record_revision".to_owned(), + "record_lifecycle".to_owned(), + ]; + expressions.extend(entity.fields.values().map(field_json_projection)); + expressions.join(", ") +} + +fn field_json_projection(field: &crate::model::CompiledField) -> String { + let column = quote_identifier(&field.physical_name); + match field.field_type { + FieldTypeSource::Decimal { .. } => format!("to_jsonb({column}::text)"), + _ => format!("to_jsonb({column})"), + } +} + +fn row_to_current( + entity: &CompiledEntity, + row: &tokio_postgres::Row, +) -> Result { + let record_id = row + .try_get::<_, String>(0) + .map_err(|_| MutationError::Unavailable)?; + let record_revision = row + .try_get::<_, i64>(1) + .map_err(|_| MutationError::Unavailable)?; + let record_lifecycle = row + .try_get::<_, String>(2) + .map_err(|_| MutationError::Unavailable)?; + let record_uuid = Uuid::parse_str(&record_id).map_err(|_| MutationError::Unavailable)?; + if record_uuid.to_string() != record_id + || record_revision <= 0 + || !matches!(record_lifecycle.as_str(), "active" | "tombstoned") + || row.len() != entity.fields.len() + 3 + { + return Err(MutationError::Unavailable); + } + let mut data = Map::new(); + for (index, field) in entity.fields.values().enumerate() { + let value = row + .try_get::<_, Option>(index + 3) + .map_err(|_| MutationError::Unavailable)? + .unwrap_or(Value::Null); + data.insert(field.id.clone(), value); + } + Ok(CurrentRow { + record_uuid, + record_id, + record_revision, + predecessor_revision: None, + record_lifecycle, + data, + }) +} + +fn validate_request( + request: &MutationRequest<'_>, + expected: &ExpectedRegistryIdentity, +) -> Result<(), MutationError> { + expected + .validate() + .map_err(|_| MutationError::InvalidRequest)?; + request + .claims + .validate() + .map_err(|_| MutationError::InvalidRequest)?; + let profile = selected_profile(request)?; + let submitted_fields = request.body.submitted_fields()?; + if request.claims.entity_id() != request.plan.entity.id + || request.claims.principal().is_none() + || profile.anonymous + || request.plan.route.id.is_empty() + || request.plan.route.id.len() > MAX_LOGICAL_ID_BYTES + || request.plan.entity.id.is_empty() + || request.plan.entity.id.len() > MAX_LOGICAL_ID_BYTES + || submitted_fields + .iter() + .any(|field| !request.plan.entity.fields.contains_key(field)) + || submitted_fields + .iter() + .any(|field| !profile.writable_fields.contains(field)) + || request.response_fields.is_empty() + || !request.response_fields.is_subset(&profile.readable_fields) + { + return Err(MutationError::InvalidRequest); + } + match request.plan.route.operation { + Operation::Create + if request.record_id.is_none() + && request.expected_etag.is_none() + && matches!(request.body, MutationBody::Create(_)) => + { + let MutationBody::Create(data) = &request.body else { + unreachable!("create request body matched above") + }; + if request + .plan + .entity + .fields + .values() + .any(|field| field.required && !data.contains_key(&field.id)) + { + return Err(MutationError::InvalidRequest); + } + } + Operation::Patch + if request.record_id.is_some_and(valid_uuid) + && request.expected_etag.is_some_and(valid_strong_etag) + && matches!(&request.body, MutationBody::Patch(operations) if !operations.is_empty()) => + {} + Operation::Tombstone + if request.record_id.is_some_and(valid_uuid) + && request.expected_etag.is_some_and(valid_strong_etag) + && matches!(request.body, MutationBody::Tombstone) => {} + _ => return Err(MutationError::InvalidRequest), + } + if let MutationBody::Create(data) = &request.body { + for (field_id, value) in data { + let field = &request.plan.entity.fields[field_id]; + if value.is_null() && field.required { + return Err(MutationError::InvalidRequest); + } + sql_value(value, &field.field_type)?; + } + } + Ok(()) +} + +impl BatchMutationItem { + fn operation(&self) -> Operation { + match self { + Self::Create(_) => Operation::Create, + Self::Patch { .. } => Operation::Patch, + } + } + + fn request_parts(&self) -> (Option<&str>, Option<&str>, MutationBody) { + match self { + Self::Create(data) => (None, None, MutationBody::Create(data.clone())), + Self::Patch { + record_id, + expected_etag, + patch, + } => ( + Some(record_id), + Some(expected_etag), + MutationBody::Patch(patch.clone()), + ), + } + } + + fn canonical_json(&self) -> Value { + match self { + Self::Create(data) => json!({"operation": "create", "data": data}), + Self::Patch { + record_id, + expected_etag, + patch, + } => json!({ + "operation": "patch", + "recordId": record_id, + "ifMatch": expected_etag, + "patch": mutation_body_json(&MutationBody::Patch(patch.clone())), + }), + } + } +} + +fn validate_batch_request( + request: &BatchMutationRequest<'_>, + expected: &ExpectedRegistryIdentity, +) -> Result<(), MutationError> { + expected + .validate() + .map_err(|_| MutationError::InvalidRequest)?; + request + .claims + .validate() + .map_err(|_| MutationError::InvalidRequest)?; + let batch = request + .plan + .entity + .batch + .as_ref() + .ok_or(MutationError::InvalidRequest)?; + let profile = request + .plan + .entity + .access_profiles + .get(request.claims.access_profile()) + .ok_or(MutationError::InvalidRequest)?; + if request.plan.route.operation != Operation::Batch + || request.plan.route.method != HttpMethod::Post + || request.claims.entity_id() != request.plan.entity.id + || request.claims.principal().is_none() + || profile.anonymous + || !profile.operations.contains(&Operation::Batch) + || !request + .plan + .route + .access_profiles + .iter() + .any(|candidate| candidate == request.claims.access_profile()) + || request.items.is_empty() + || request.items.len() > usize::from(batch.maximum_items) + || request.body_bytes == 0 + || request.body_bytes > batch.maximum_bytes as usize + || request.response_fields.is_empty() + || !request.response_fields.is_subset(&profile.readable_fields) + { + return Err(MutationError::InvalidRequest); + } + + for item in &request.items { + if !profile.operations.contains(&item.operation()) + || item.operation() == Operation::Patch + && request.plan.entity.mutation_mode != MutationMode::Mutable + { + return Err(MutationError::InvalidRequest); + } + let item_plan = request + .plan + .batch_item(item.operation(), request.claims.access_profile())?; + let (record_id, expected_etag, body) = item.request_parts(); + let item_request = MutationRequest { + plan: &item_plan, + idempotency_key: request.idempotency_key, + claims: request.claims, + record_id, + expected_etag, + body, + response_fields: request.response_fields.clone(), + }; + validate_request(&item_request, expected)?; + if let MutationBody::Patch(operations) = &item_request.body { + validate_patch_static(&item_request, operations)?; + } + } + Ok(()) +} + +fn validate_patch_static( + request: &MutationRequest<'_>, + operations: &[PatchOperation], +) -> Result<(), MutationError> { + let profile = selected_profile(request)?; + let mut mutated = false; + for operation in operations { + let path = match operation { + PatchOperation::Add { path, .. } + | PatchOperation::Replace { path, .. } + | PatchOperation::Remove { path } + | PatchOperation::Test { path, .. } => path, + }; + let field_id = patch_field(path)?; + let field = request + .plan + .entity + .fields + .get(&field_id) + .ok_or(MutationError::InvalidRequest)?; + match operation { + PatchOperation::Add { value, .. } | PatchOperation::Replace { value, .. } => { + if !profile.writable_fields.contains(&field_id) || value.is_null() && field.required + { + return Err(MutationError::InvalidRequest); + } + sql_value(value, &field.field_type)?; + mutated = true; + } + PatchOperation::Remove { .. } => { + if field.required || !profile.writable_fields.contains(&field_id) { + return Err(MutationError::InvalidRequest); + } + mutated = true; + } + PatchOperation::Test { value, .. } => { + if !profile.readable_fields.contains(&field_id) { + return Err(MutationError::InvalidRequest); + } + if !value.is_null() { + sql_value(value, &field.field_type)?; + } + } + } + } + if !mutated { + return Err(MutationError::InvalidRequest); + } + Ok(()) +} + +fn selected_profile<'a>( + request: &'a MutationRequest<'a>, +) -> Result<&'a AccessProfileSource, MutationError> { + let profile = request + .plan + .entity + .access_profiles + .get(request.claims.access_profile()) + .ok_or(MutationError::InvalidRequest)?; + if !request + .plan + .route + .access_profiles + .iter() + .any(|candidate| candidate == request.claims.access_profile()) + { + return Err(MutationError::InvalidRequest); + } + Ok(profile) +} + +fn canonical_request_digest(request: &MutationRequest<'_>) -> Result<[u8; 32], MutationError> { + let canonical = canonicalize_json(&json!({ + "method": method_name(request.plan.route.method), + "route": request.plan.route.path, + "targetRecord": request.record_id, + "expectedEtag": request.expected_etag, + "mutationBody": mutation_body_json(&request.body), + })) + .map_err(|_| MutationError::InvalidRequest)?; + Ok(Sha256::digest(canonical).into()) +} + +fn canonical_batch_request_digest( + request: &BatchMutationRequest<'_>, +) -> Result<[u8; 32], MutationError> { + let canonical = canonicalize_json(&json!({ + "method": method_name(request.plan.route.method), + "route": request.plan.route.path, + "items": request.items.iter().map(BatchMutationItem::canonical_json).collect::>(), + })) + .map_err(|_| MutationError::InvalidRequest)?; + Ok(Sha256::digest(canonical).into()) +} + +impl MutationBody { + fn submitted_fields(&self) -> Result, MutationError> { + match self { + Self::Create(data) => Ok(data.keys().cloned().collect()), + Self::Patch(operations) => operations + .iter() + .filter_map(|operation| match operation { + PatchOperation::Add { path, .. } + | PatchOperation::Replace { path, .. } + | PatchOperation::Remove { path } => Some(patch_field(path)), + PatchOperation::Test { .. } => None, + }) + .collect(), + Self::Tombstone => Ok(Vec::new()), + } + } +} + +fn mutation_body_json(body: &MutationBody) -> Value { + match body { + MutationBody::Create(data) => json!({"create": data}), + MutationBody::Patch(operations) => Value::Array( + operations + .iter() + .map(|operation| match operation { + PatchOperation::Add { path, value } => { + json!({"op": "add", "path": path, "value": value}) + } + PatchOperation::Replace { path, value } => { + json!({"op": "replace", "path": path, "value": value}) + } + PatchOperation::Remove { path } => json!({"op": "remove", "path": path}), + PatchOperation::Test { path, value } => { + json!({"op": "test", "path": path, "value": value}) + } + }) + .collect(), + ), + MutationBody::Tombstone => json!({"tombstone": true}), + } +} + +fn valid_strong_etag(value: &str) -> bool { + value.len() > 5 + && value.len() <= 256 + && value.starts_with("\"rs-") + && value.ends_with('"') + && value.as_bytes()[1..value.len() - 1] + .iter() + .all(|byte| matches!(byte, 0x21 | 0x23..=0x7e)) +} + +fn record_reference( + profile: &AuditProfile, + package_revision: &str, + record_id: &str, +) -> Result { + profile + .key_hasher() + .audit_reference_hash("registry-server-record-v1", package_revision, record_id) + .map_err(|_| MutationError::Unavailable) +} + +fn sql_value(value: &Value, field_type: &FieldTypeSource) -> Result, MutationError> { + if value.is_null() { + return Ok(None); + } + if !validate_field_value(FieldValue::Json(value), field_type) { + return Err(MutationError::InvalidRequest); + } + let value = match field_type { + FieldTypeSource::Boolean => value.as_bool().map(|value| value.to_string()), + FieldTypeSource::Int64 => value.as_i64().map(|value| value.to_string()), + FieldTypeSource::Decimal { .. } => value.as_str().map(str::to_owned), + FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::VocabularyCode { .. } + | FieldTypeSource::Date + | FieldTypeSource::Timestamp + | FieldTypeSource::Uuid + | FieldTypeSource::Reference { .. } => value.as_str().map(str::to_owned), + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + canonicalize_json(value) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok().filter(|_| !value.is_null())) + } + } + .ok_or(MutationError::InvalidRequest)?; + Ok(Some(value)) +} + +fn typed_parameter(index: usize, field_type: &FieldTypeSource) -> String { + let cast = match field_type { + FieldTypeSource::Boolean => "boolean", + FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::VocabularyCode { .. } => "text", + FieldTypeSource::Int64 => "bigint", + FieldTypeSource::Decimal { + precision, scale, .. + } => { + return format!("${index}::text::numeric({precision},{scale})"); + } + FieldTypeSource::Date => "date", + FieldTypeSource::Timestamp => "timestamptz", + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => "uuid", + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => "jsonb", + }; + format!("${index}::text::{cast}") +} + +fn mutation_trigger(operation: Operation) -> EventTrigger { + match operation { + Operation::Create => EventTrigger::Created, + Operation::Patch => EventTrigger::Patched, + Operation::Tombstone => EventTrigger::Tombstoned, + _ => unreachable!("mutation plans admit only create, patch, and tombstone"), + } +} + +fn mutation_kind(operation: Operation) -> &'static str { + match operation { + Operation::Create => "create", + Operation::Patch => "patch", + Operation::Tombstone => "tombstone", + _ => unreachable!("mutation plans admit only create, patch, and tombstone"), + } +} + +fn method_name(method: HttpMethod) -> &'static str { + match method { + HttpMethod::Delete => "DELETE", + HttpMethod::Get => "GET", + HttpMethod::Patch => "PATCH", + HttpMethod::Post => "POST", + } +} + +fn map_database_error(error: tokio_postgres::Error) -> MutationError { + match error.code() { + Some(code) + if code == &SqlState::UNIQUE_VIOLATION + || code == &SqlState::FOREIGN_KEY_VIOLATION + || code == &SqlState::CHECK_VIOLATION + || code == &SqlState::NOT_NULL_VIOLATION + || code == &SqlState::EXCLUSION_VIOLATION => + { + MutationError::Conflict + } + _ => MutationError::Unavailable, + } +} + +fn valid_physical_identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + bytes + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_lowercase()) + && bytes.all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()) + && value.len() <= 63 +} + +fn quote_identifier(value: &str) -> String { + debug_assert!(valid_physical_identifier(value)); + format!("\"{value}\"") +} + +fn valid_uuid(value: &str) -> bool { + value.len() == 36 + && value.bytes().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => byte == b'-', + _ => byte.is_ascii_hexdigit(), + }) + && Uuid::parse_str(value).is_ok_and(|identifier| identifier.to_string() == value) +} + +impl From for MutationError { + fn from(error: IdempotencyError) -> Self { + match error { + IdempotencyError::InvalidInput => Self::InvalidRequest, + IdempotencyError::Conflict => Self::IdempotencyConflict, + IdempotencyError::Unavailable => Self::Unavailable, + } + } +} + +impl From for MutationError { + fn from(error: RevisionError) -> Self { + match error { + RevisionError::InvalidSnapshot => Self::InvalidRequest, + RevisionError::Unavailable => Self::Unavailable, + } + } +} + +impl From for MutationError { + fn from(error: OutboxError) -> Self { + match error { + OutboxError::InvalidProjection => Self::InvalidRequest, + OutboxError::Unavailable => Self::Unavailable, + } + } +} + +impl From for MutationError { + fn from(error: RegistryAuditError) -> Self { + match error { + RegistryAuditError::InvalidContext => Self::InvalidRequest, + RegistryAuditError::Unavailable => Self::Unavailable, + } + } +} + +#[cfg(all(test, feature = "postgres-test"))] +mod tests { + use super::*; + + #[test] + fn response_etag_binds_package_authority_boundary_and_projection() { + let profile = AuditProfile::production_from_secret_bytes(vec![0x4d; 32].into()) + .expect("test profile is strongly keyed"); + let baseline = ClaimContext::kernel_for_test( + "principal".to_owned(), + "operator".to_owned(), + Some("purpose-a".to_owned()), + "zone-a".to_owned(), + ) + .expect("baseline context is valid"); + let changed_purpose = ClaimContext::kernel_for_test( + "principal".to_owned(), + "operator".to_owned(), + Some("purpose-b".to_owned()), + "zone-a".to_owned(), + ) + .expect("changed-purpose context is valid"); + let changed_boundary = ClaimContext::kernel_for_test( + "principal".to_owned(), + "operator".to_owned(), + Some("purpose-a".to_owned()), + "zone-b".to_owned(), + ) + .expect("changed-boundary context is valid"); + let changed_profile = ClaimContext::kernel_for_test( + "principal".to_owned(), + "review-operator".to_owned(), + Some("purpose-a".to_owned()), + "zone-a".to_owned(), + ) + .expect("changed-profile context is valid"); + let baseline_fields = BTreeSet::from(["label".to_owned()]); + let changed_fields = BTreeSet::from(["label".to_owned(), "quantity".to_owned()]); + let etag = |claims, package_revision, response_fields| { + strong_record_etag( + &profile, + claims, + package_revision, + "00000000-0000-0000-0000-000000000001", + 1, + response_fields, + ) + .expect("ETag context is canonical") + }; + + let baseline_etag = etag(&baseline, "package-1", &baseline_fields); + assert_ne!( + baseline_etag, + etag(&baseline, "package-2", &baseline_fields) + ); + assert_ne!( + baseline_etag, + etag(&changed_profile, "package-1", &baseline_fields) + ); + assert_ne!( + baseline_etag, + etag(&changed_purpose, "package-1", &baseline_fields) + ); + assert_ne!( + baseline_etag, + etag(&changed_boundary, "package-1", &baseline_fields) + ); + assert_ne!(baseline_etag, etag(&baseline, "package-1", &changed_fields)); + } + + #[test] + fn mutation_scalar_validation_refuses_invalid_lexical_values_before_sql() { + for (field_type, value) in [ + (FieldTypeSource::Uuid, "not-a-uuid"), + ( + FieldTypeSource::Reference { + target: "entry".to_owned(), + on_delete: Default::default(), + }, + "still-not-a-uuid", + ), + (FieldTypeSource::Date, "2026-02-30"), + (FieldTypeSource::Timestamp, "2026-08-29 12:00:00"), + ] { + assert_eq!( + sql_value(&Value::String(value.to_owned()), &field_type), + Err(MutationError::InvalidRequest) + ); + } + assert_eq!( + sql_value( + &Value::String("too-long".to_owned()), + &FieldTypeSource::String { + min_length: 1, + max_length: 3, + }, + ), + Err(MutationError::InvalidRequest) + ); + assert_eq!( + sql_value( + &Value::String("01.20".to_owned()), + &FieldTypeSource::Decimal { + precision: 4, + scale: 2, + minimum: Some("0.00".to_owned()), + maximum: Some("9.99".to_owned()), + }, + ), + Err(MutationError::InvalidRequest) + ); + assert_eq!( + sql_value( + &Value::String("10.00".to_owned()), + &FieldTypeSource::Decimal { + precision: 4, + scale: 2, + minimum: Some("0.00".to_owned()), + maximum: Some("9.99".to_owned()), + }, + ), + Err(MutationError::InvalidRequest) + ); + assert_eq!( + sql_value( + &json!({"type":"Point","coordinates":[100.123,10.12345]}), + &FieldTypeSource::Crs84Point { + precision: 4, + bbox: None, + }, + ), + Err(MutationError::InvalidRequest) + ); + assert_eq!( + sql_value( + &json!({"code":"ok","extra":"refused"}), + &FieldTypeSource::Structured { + max_bytes: 128, + schema: json!({ + "type": "object", + "additionalProperties": false, + "properties": {"code": {"type": "string"}}, + "required": ["code"] + }), + }, + ), + Err(MutationError::InvalidRequest) + ); + assert_eq!( + sql_value( + &json!({"code":"ok"}), + &FieldTypeSource::Structured { + max_bytes: 8, + schema: json!({ + "type": "object", + "additionalProperties": false, + "properties": {"code": {"type": "string"}}, + "required": ["code"] + }), + }, + ), + Err(MutationError::InvalidRequest) + ); + assert!(sql_value( + &json!({"type":"Point","coordinates":[100.1234,10.1234]}), + &FieldTypeSource::Crs84Point { + precision: 4, + bbox: None, + }, + ) + .is_ok()); + } +} diff --git a/crates/registry-server/src/outbox.rs b/crates/registry-server/src/outbox.rs new file mode 100644 index 0000000000..e59f66cc47 --- /dev/null +++ b/crates/registry-server/src/outbox.rs @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Immutable configured events created inside the owning record transaction. + +use std::collections::BTreeMap; + +use registry_platform_canonical_json::canonicalize_json; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use tokio_postgres::Transaction; +use uuid::Uuid; + +use crate::contract::{ + Classification, EventSource, EventTrigger, WebhookAuthenticationProfile, WebhookDeadLetterMode, +}; +use crate::event_destination::ActivatedEventDestinationRegistry; +use crate::model::{CompiledEventDelivery, CompiledWebhookDeliveryMode}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum OutboxError { + #[error("configured event projection is invalid")] + InvalidProjection, + #[error("mutation outbox is unavailable")] + Unavailable, +} + +pub(crate) struct OutboxMutation<'a> { + pub trigger: EventTrigger, + pub entity_id: &'a str, + pub record_reference: &'a str, + pub record_revision: i64, + pub package_revision: &'a str, + pub schema_fingerprint: &'a str, + pub data: &'a Map, +} + +pub(crate) async fn insert_configured_events( + transaction: &Transaction<'_>, + events: &BTreeMap, + deliveries: &[CompiledEventDelivery], + destinations: Option<&ActivatedEventDestinationRegistry>, + mutation: OutboxMutation<'_>, +) -> Result<(), OutboxError> { + for event in events + .values() + .filter(|event| event.trigger == mutation.trigger) + { + let delivery = deliveries + .iter() + .find(|delivery| delivery.event_id == event.id); + let projection_fields = delivery.map_or_else( + || { + event + .projection + .iter() + .map(String::as_str) + .collect::>() + }, + |delivery| { + delivery + .projection_fields + .iter() + .map(String::as_str) + .collect() + }, + ); + let mut projection = Map::new(); + for field in projection_fields { + let value = mutation + .data + .get(field) + .ok_or(OutboxError::InvalidProjection)?; + projection.insert(field.to_owned(), value.clone()); + } + let payload = canonicalize_json(&Value::Object(projection)) + .map_err(|_| OutboxError::InvalidProjection)?; + let event_id = Uuid::new_v4(); + let activated = if let Some(delivery) = delivery { + if payload.len() + > usize::try_from(delivery.maximum_payload_bytes) + .map_err(|_| OutboxError::InvalidProjection)? + { + return Err(OutboxError::InvalidProjection); + } + let destination = destinations + .and_then(|destinations| destinations.lookup(&delivery.destination_id)) + .ok_or(OutboxError::Unavailable)?; + let deployed_attempt_timeout = u32::try_from(destination.attempt_timeout().as_millis()) + .map_err(|_| OutboxError::Unavailable)?; + if deployed_attempt_timeout > delivery.attempt_timeout_ms + || destination.maximum_attempts() > delivery.maximum_attempts + { + return Err(OutboxError::Unavailable); + } + Some((delivery, destination)) + } else { + None + }; + let changed = transaction + .execute( + "INSERT INTO registry_internal.registry_outbox + (event_id, event_type, trigger, entity_id, record_reference, + record_revision, package_revision, schema_fingerprint, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + &[ + &event_id, + &event.id, + &trigger_name(mutation.trigger), + &mutation.entity_id, + &mutation.record_reference, + &mutation.record_revision, + &mutation.package_revision, + &mutation.schema_fingerprint, + &payload, + ], + ) + .await + .map_err(|_| OutboxError::Unavailable)?; + if changed != 1 { + return Err(OutboxError::Unavailable); + } + if let Some((delivery, destination)) = activated { + insert_webhook_delivery( + transaction, + event_id, + WebhookCapture { + delivery, + payload: &payload, + destination_binding_digest: destination.binding_digest(), + deployed_attempt_timeout: destination.attempt_timeout(), + deployed_maximum_attempts: destination.maximum_attempts(), + package_revision: mutation.package_revision, + schema_fingerprint: mutation.schema_fingerprint, + }, + ) + .await?; + } + } + Ok(()) +} + +struct WebhookCapture<'a> { + delivery: &'a CompiledEventDelivery, + payload: &'a [u8], + destination_binding_digest: &'a str, + deployed_attempt_timeout: std::time::Duration, + deployed_maximum_attempts: u8, + package_revision: &'a str, + schema_fingerprint: &'a str, +} + +async fn insert_webhook_delivery( + transaction: &Transaction<'_>, + event_id: Uuid, + capture: WebhookCapture<'_>, +) -> Result<(), OutboxError> { + let WebhookCapture { + delivery, + payload, + destination_binding_digest, + deployed_attempt_timeout, + deployed_maximum_attempts, + package_revision, + schema_fingerprint, + } = capture; + let retry_delays_ms = delivery + .retry_delays_ms + .iter() + .copied() + .map(i64::from) + .collect::>(); + let payload_digest = Sha256::digest(payload).to_vec(); + let deployed_attempt_timeout_ms = i64::try_from(deployed_attempt_timeout.as_millis()) + .map_err(|_| OutboxError::Unavailable)?; + let changed = transaction + .execute( + "INSERT INTO registry_internal.registry_webhook_deliveries + (event_id, compiled_delivery_id, logical_destination_id, + destination_binding_digest, package_revision, schema_fingerprint, + classification_ceiling, authentication_profile, delivery_mode, + attempt_timeout_ms, initial_backoff_ms, maximum_backoff_ms, + exponential_backoff_multiplier, maximum_attempts, retry_delays_ms, + maximum_payload_bytes, payload_digest, deployed_attempt_timeout_ms, + deployed_maximum_attempts, dead_letter, operator_replay) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, + $10, $11, $12, $13, $14, $15, $16, $17, $18, + $19, $20, $21)", + &[ + &event_id, + &delivery.id, + &delivery.destination_id, + &destination_binding_digest, + &package_revision, + &schema_fingerprint, + &classification_name(delivery.classification_ceiling), + &authentication_profile_name(delivery.authentication_profile), + &delivery_mode_name(delivery.delivery_mode), + &i64::from(delivery.attempt_timeout_ms), + &i64::from(delivery.initial_backoff_ms), + &i64::from(delivery.maximum_backoff_ms), + &i16::from(delivery.exponential_backoff_multiplier), + &i16::from(delivery.maximum_attempts), + &retry_delays_ms, + &i64::from(delivery.maximum_payload_bytes), + &payload_digest, + &deployed_attempt_timeout_ms, + &i16::from(deployed_maximum_attempts), + &dead_letter_name(delivery.dead_letter), + &delivery.operator_replay, + ], + ) + .await + .map_err(|_| OutboxError::Unavailable)?; + if changed != 1 { + return Err(OutboxError::Unavailable); + } + let changed = transaction + .execute( + "INSERT INTO registry_internal.registry_webhook_delivery_state + (event_id, compiled_delivery_id, generation, state, attempt, next_attempt_at) + VALUES ($1, $2, 1, 'pending', 0, transaction_timestamp())", + &[&event_id, &delivery.id], + ) + .await + .map_err(|_| OutboxError::Unavailable)?; + if changed != 1 { + return Err(OutboxError::Unavailable); + } + Ok(()) +} + +fn classification_name(classification: Classification) -> &'static str { + match classification { + Classification::Public => "public", + Classification::Internal => "internal", + Classification::Restricted => "restricted", + } +} + +fn authentication_profile_name(profile: WebhookAuthenticationProfile) -> &'static str { + match profile { + WebhookAuthenticationProfile::HmacSha256V1 => "hmac_sha256_v1", + } +} + +fn delivery_mode_name(mode: CompiledWebhookDeliveryMode) -> &'static str { + match mode { + CompiledWebhookDeliveryMode::AfterCommit => "after_commit", + } +} + +fn dead_letter_name(mode: WebhookDeadLetterMode) -> &'static str { + match mode { + WebhookDeadLetterMode::Required => "required", + } +} + +fn trigger_name(trigger: EventTrigger) -> &'static str { + match trigger { + EventTrigger::Created => "created", + EventTrigger::Patched => "patched", + EventTrigger::Tombstoned => "tombstoned", + } +} diff --git a/crates/registry-server/src/package.rs b/crates/registry-server/src/package.rs new file mode 100644 index 0000000000..173c3712ff --- /dev/null +++ b/crates/registry-server/src/package.rs @@ -0,0 +1,3066 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Closed Registry Server package verification boundary. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use registry_platform_crypto::{verify, PublicJwk}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::artifacts::REGISTRY_METADATA_ARTIFACT_PATH; +use crate::compiler::{compile_project, CompileProfile}; +use crate::contract::{ + parse_module_yaml, parse_project_yaml, FieldTypeSource, RegistryModule, RegistryProject, +}; +use crate::generated_ddl::{add_column_statement, DdlStatement}; +#[cfg(feature = "tooling")] +use crate::migration_plan::{ + prepare_reviewed_migration_plan, validate_reviewed_migration_plan, + PreparedReviewedMigrationPlan, ReviewedMigrationRecovery, ReviewedMigrationSource, + ReviewedMigrationStepDescriptor, ReviewedPlanBindings, +}; +use crate::migration_plan::{ + reviewed_artifact_kind, ReviewedArtifactKind, ValidatedReviewedMigrationPlan, +}; +use crate::model::{ + CompiledAccessInventory, CompiledEntity, CompiledQueryInventory, CompiledRouteInventory, +}; +use crate::physical_names::PhysicalNameInventory; +use crate::CompiledRegistry; + +pub const PACKAGE_API_VERSION: &str = "registry.registrystack.org/package/v1"; +pub const TRUST_ANCHOR_API_VERSION: &str = "registry.registrystack.org/package-trust/v1"; +pub const COMPILER_ID: &str = "registry-server"; +pub const FIXTURE_JOURNEYS_PATH: &str = "tests/journeys.yaml"; +pub const MAX_PACKAGE_SOURCE_FILE_BYTES: u64 = 16 * 1024 * 1024; + +const MANIFEST_PATH: &str = "package.json"; +const MAX_MANIFEST_BYTES: u64 = 4 * 1024 * 1024; +const MAX_FILE_BYTES: u64 = MAX_PACKAGE_SOURCE_FILE_BYTES; +const MAX_PACKAGE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_PACKAGE_FILES: usize = 1_024; +const MAX_PATH_BYTES: usize = 512; +const MAX_PATH_COMPONENTS: usize = 16; +const MAX_MIGRATION_STATEMENTS: usize = 1_024; +const MAX_MIGRATION_BASELINE_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PackageEnvelope { + pub api_version: String, + pub signed: PackageManifest, + pub signatures: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PackageManifest { + pub package_id: String, + pub package_revision: String, + pub environment: String, + pub instance_id: String, + pub database_id: String, + pub sequence: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prior_revision: Option, + pub compiler: CompilerIdentity, + pub schema_fingerprint: String, + pub signature_policy: SignaturePolicy, + pub sources: CapturedSources, + pub files: Vec, + pub migration_plan: MigrationPlan, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompilerIdentity { + pub id: String, + pub source_revision: String, + pub profile: PackageCompileProfile, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackageCompileProfile { + Production, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SignaturePolicy { + pub threshold: u16, + pub key_ids: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PackageSignature { + pub key_id: String, + pub signature_hex: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CapturedSources { + pub project: String, + pub modules: Vec, + pub fixture_journeys: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CapturedModule { + pub id: String, + pub path: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PackageFile { + pub path: String, + pub role: PackageFileRole, + pub size: u64, + pub sha256: String, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackageFileRole { + SourceProject, + SourceModule, + FixtureJourneys, + GovernedModel, + PhysicalNameInventory, + RouteInventory, + AccessInventory, + QueryInventory, + EventInventory, + CallerSafeMetadata, + GeneratedDdl, + MigrationPlan, + GeneratedOpenapi, + EntityJsonSchema, + LossyManifestProjection, + ReviewedMigrationDescriptor, + ReviewedMigrationStepSql, + ReviewedMigrationAssertionSql, + MigrationRehearsalReceipt, + ExternalBackupBinding, + MigrationRehearsalFixture, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct MigrationPlan { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub from_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prior_baseline: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub changes: Vec, + pub statements: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reviewed_descriptors: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prior_schema_fingerprint: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledRegistryMigrationBaseline { + pub package_revision: String, + pub registry_id: String, + pub registry_version: String, + pub registry_revision: String, + pub entities: BTreeMap, + pub physical_names: PhysicalNameInventory, + pub routes: CompiledRouteInventory, + pub access: CompiledAccessInventory, + pub queries: CompiledQueryInventory, +} + +impl CompiledRegistryMigrationBaseline { + pub fn from_compiled(package_revision: &str, compiled: &CompiledRegistry) -> Self { + Self { + package_revision: package_revision.to_owned(), + registry_id: compiled.registry_id().to_owned(), + registry_version: compiled.version().to_owned(), + registry_revision: compiled.revision().to_owned(), + entities: compiled.entities().clone(), + physical_names: compiled.physical_names().clone(), + routes: compiled.routes().clone(), + access: compiled.access().clone(), + queries: compiled.queries().clone(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledRegistryChangeSet { + pub from_revision: String, + pub changes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub migration_plan: Option, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledRegistryChange { + pub class: CompiledRegistryChangeClass, + pub code: CompiledRegistryChangeCode, + pub target: CompiledRegistryChangeTarget, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompiledRegistryChangeClass { + CompatibleAdditive, + DataBackfillRequired, + AccessOrDisclosureChange, + DestructiveOrIrreversible, + Unsupported, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompiledRegistryChangeCode { + RegistryIdentityChanged, + EntityAdded, + EntityRemoved, + EntityPhysicalNameChanged, + EntityRouteChanged, + EntityMutationModeChanged, + EntityClassificationChanged, + EntityTemporalChanged, + FieldAddedOptional, + FieldAddedRequired, + FieldRemoved, + FieldTypeChanged, + FieldPhysicalNameChanged, + FieldRequirednessChanged, + FieldClassificationChanged, + FieldTemporalRoleChanged, + ReferenceTargetChanged, + ConstraintAdded, + ConstraintRemoved, + ConstraintChanged, + IndexAdded, + IndexRemoved, + IndexChanged, + AccessProfileAdded, + AccessProfileRemoved, + AccessProfileChanged, + RouteAdded, + RouteRemoved, + RouteChanged, + QueryInventoryChanged, + EventAdded, + EventRemoved, + EventChanged, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledRegistryChangeTarget { + pub kind: CompiledRegistryChangeTargetKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entity_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub member_id: Option, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompiledRegistryChangeTargetKind { + Registry, + Entity, + Field, + Constraint, + Index, + AccessProfile, + Route, + QueryInventory, + Event, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PackageTrustAnchor { + pub api_version: String, + pub environment: String, + pub instance_id: String, + pub database_id: String, + pub threshold: u16, + pub keys: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TrustAnchorKey { + pub key_id: String, + pub jwk: Value, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PackageIntent<'a> { + InitialActivation, + Activation { + active_revision: &'a str, + active_sequence: u64, + }, + Startup { + active_revision: &'a str, + active_sequence: u64, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum VerifiedPackageIntent { + InitialActivation, + Activation { + active_revision: String, + active_sequence: u64, + }, + Startup { + active_revision: String, + active_sequence: u64, + }, +} + +impl VerifiedPackageIntent { + fn from_intent(intent: PackageIntent<'_>) -> Self { + match intent { + PackageIntent::InitialActivation => Self::InitialActivation, + PackageIntent::Activation { + active_revision, + active_sequence, + } => Self::Activation { + active_revision: active_revision.to_owned(), + active_sequence, + }, + PackageIntent::Startup { + active_revision, + active_sequence, + } => Self::Startup { + active_revision: active_revision.to_owned(), + active_sequence, + }, + } + } +} + +pub struct PackageLoadContext<'a> { + pub environment: &'a str, + pub instance_id: &'a str, + pub database_id: &'a str, + /// Environment durably recorded when the database was initialized. + pub database_initialization_environment: &'a str, + pub compiler_source_revision: &'a str, + pub trust_anchor: Option<&'a Path>, + pub intent: PackageIntent<'a>, +} + +/// Deployment bindings available to read-only package inspection. +/// +/// The expected revision and sequence are configuration bindings only. This +/// context carries no activation intent or durable database-state claim, so a +/// successful inspection proves package closure, derivation, signature, and +/// configured identity only, never readiness or activation authority. +pub struct PackageInspectionContext<'a> { + pub environment: &'a str, + pub instance_id: &'a str, + pub database_id: &'a str, + pub database_initialization_environment: &'a str, + pub compiler_source_revision: &'a str, + pub trust_anchor: Option<&'a Path>, + pub expected_package_revision: &'a str, + pub expected_sequence: u64, +} + +/// Closed operator-facing migration facts retained only by a fully rederived +/// tooling inspection. This summary deliberately carries no SQL, paths, +/// identifiers, physical names, signatures, trust material, or activation +/// authority. +#[cfg(feature = "tooling")] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationInspectionSummary { + plan_kind: MigrationInspectionPlanKind, + has_prior_revision: bool, + has_prior_baseline: bool, + change_count: usize, + change_counts: MigrationInspectionChangeCounts, + generated_statement_count: usize, + reviewed_migrations: Vec, +} + +#[cfg(feature = "tooling")] +impl MigrationInspectionSummary { + pub fn plan_kind(&self) -> MigrationInspectionPlanKind { + self.plan_kind + } + + pub fn has_prior_revision(&self) -> bool { + self.has_prior_revision + } + + pub fn has_prior_baseline(&self) -> bool { + self.has_prior_baseline + } + + pub fn change_count(&self) -> usize { + self.change_count + } + + pub fn change_counts(&self) -> &MigrationInspectionChangeCounts { + &self.change_counts + } + + pub fn generated_statement_count(&self) -> usize { + self.generated_statement_count + } + + pub fn reviewed_migrations(&self) -> &[ReviewedMigrationInspectionSummary] { + &self.reviewed_migrations + } +} + +#[cfg(feature = "tooling")] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationInspectionPlanKind { + Initial, + CompatibleAdditive, + Reviewed, +} + +#[cfg(feature = "tooling")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationInspectionChangeCounts { + compatible_additive: usize, + data_backfill_required: usize, + access_or_disclosure_change: usize, + destructive_or_irreversible: usize, + unsupported: usize, +} + +#[cfg(feature = "tooling")] +impl MigrationInspectionChangeCounts { + pub fn compatible_additive(&self) -> usize { + self.compatible_additive + } + + pub fn data_backfill_required(&self) -> usize { + self.data_backfill_required + } + + pub fn access_or_disclosure_change(&self) -> usize { + self.access_or_disclosure_change + } + + pub fn destructive_or_irreversible(&self) -> usize { + self.destructive_or_irreversible + } + + pub fn unsupported(&self) -> usize { + self.unsupported + } + + fn record(&mut self, class: CompiledRegistryChangeClass) { + match class { + CompiledRegistryChangeClass::CompatibleAdditive => self.compatible_additive += 1, + CompiledRegistryChangeClass::DataBackfillRequired => { + self.data_backfill_required += 1; + } + CompiledRegistryChangeClass::AccessOrDisclosureChange => { + self.access_or_disclosure_change += 1; + } + CompiledRegistryChangeClass::DestructiveOrIrreversible => { + self.destructive_or_irreversible += 1; + } + CompiledRegistryChangeClass::Unsupported => self.unsupported += 1, + } + } +} + +#[cfg(feature = "tooling")] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewedMigrationInspectionSummary { + change_class: CompiledRegistryChangeClass, + recovery: ReviewedMigrationRecovery, + lock_timeout_ms: u64, + statement_timeout_ms: u64, + transactional_step_count: usize, + chunked_step_count: usize, + pre_assertion_count: usize, + post_assertion_count: usize, + backup_required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + chunked_step_bounds: Option, +} + +#[cfg(feature = "tooling")] +impl ReviewedMigrationInspectionSummary { + pub fn change_class(&self) -> CompiledRegistryChangeClass { + self.change_class + } + + pub fn recovery(&self) -> ReviewedMigrationRecovery { + self.recovery + } + + pub fn lock_timeout_ms(&self) -> u64 { + self.lock_timeout_ms + } + + pub fn statement_timeout_ms(&self) -> u64 { + self.statement_timeout_ms + } + + pub fn transactional_step_count(&self) -> usize { + self.transactional_step_count + } + + pub fn chunked_step_count(&self) -> usize { + self.chunked_step_count + } + + pub fn pre_assertion_count(&self) -> usize { + self.pre_assertion_count + } + + pub fn post_assertion_count(&self) -> usize { + self.post_assertion_count + } + + pub fn backup_required(&self) -> bool { + self.backup_required + } + + pub fn chunked_step_bounds(&self) -> Option<&ReviewedChunkedStepBounds> { + self.chunked_step_bounds.as_ref() + } +} + +#[cfg(feature = "tooling")] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewedChunkedStepBounds { + minimum_chunk_size: u32, + maximum_chunk_size: u32, + maximum_total_rows: u64, +} + +#[cfg(feature = "tooling")] +impl ReviewedChunkedStepBounds { + pub fn minimum_chunk_size(&self) -> u32 { + self.minimum_chunk_size + } + + pub fn maximum_chunk_size(&self) -> u32 { + self.maximum_chunk_size + } + + pub fn maximum_total_rows(&self) -> u64 { + self.maximum_total_rows + } +} + +/// A closed package rederived for read-only comparison. +/// +/// Unlike [`VerifiedPackage`], this type cannot authorize startup or apply. +pub struct IntegrityInspectedPackage { + package_revision: String, + registry: CompiledRegistry, + #[cfg(feature = "tooling")] + migration: MigrationInspectionSummary, +} + +impl IntegrityInspectedPackage { + pub fn package_revision(&self) -> &str { + &self.package_revision + } + + pub fn registry(&self) -> &CompiledRegistry { + &self.registry + } + + /// Return a value-minimized operator summary. Its presence proves only the + /// package inspection described by [`PackageInspectionContext`], never + /// startup readiness, database state, or activation authority. + #[cfg(feature = "tooling")] + pub fn migration_summary(&self) -> &MigrationInspectionSummary { + &self.migration + } +} + +/// A package whose filesystem closure, signatures, bindings, sources, compiler +/// derivation, generated bytes, and migration plan have all been verified. +pub struct VerifiedPackage { + manifest: PackageManifest, + registry: CompiledRegistry, + intent: VerifiedPackageIntent, + reviewed_migration_plan: Option, +} + +impl VerifiedPackage { + pub fn manifest(&self) -> &PackageManifest { + &self.manifest + } + + pub fn registry(&self) -> &CompiledRegistry { + &self.registry + } + + /// Resolved reviewed SQL and evidence, present only after tooling-owned AST + /// validation. Runtime-only package loading carries no authored-SQL parser. + #[must_use] + pub fn reviewed_migration_plan(&self) -> Option<&ValidatedReviewedMigrationPlan> { + self.reviewed_migration_plan.as_ref() + } + + pub(crate) fn verified_for_initial_activation(&self) -> bool { + self.intent == VerifiedPackageIntent::InitialActivation + } + + pub(crate) fn verified_for_activation( + &self, + active_revision: &str, + active_sequence: u64, + ) -> bool { + matches!( + &self.intent, + VerifiedPackageIntent::Activation { + active_revision: verified_revision, + active_sequence: verified_sequence, + } if verified_revision == active_revision && *verified_sequence == active_sequence + ) + } +} + +/// Value-free failures. Paths, source values, SQL, key material, signatures, +/// and deployment bindings are deliberately absent from both Display and Debug. +#[derive(Debug, Error, Clone, Copy, Eq, PartialEq)] +pub enum PackageError { + #[error("the package path is unsafe")] + UnsafePath, + #[error("the package exceeds its resource bounds")] + Bounds, + #[error("the package could not be read")] + Read, + #[error("the package is not canonical JSON")] + CanonicalJson, + #[error("the package filesystem closure is invalid")] + Closure, + #[error("the package integrity check failed")] + Integrity, + #[error("the package deployment binding is invalid")] + Binding, + #[error("the package signature policy failed")] + Signature, + #[error("the package compiler derivation failed")] + Derivation, + #[error("the package migration plan is invalid")] + MigrationPlan, + #[error("the package permissions are unsafe")] + Permissions, +} + +pub type Result = std::result::Result; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackageSourceFile { + pub path: String, + pub bytes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackageModuleSource { + pub id: String, + pub path: String, + pub bytes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PackageMigrationPlanInput { + InitialCompiledDdl, + Successor { + prior_registry: Box, + }, + #[cfg(feature = "tooling")] + ReviewedSuccessor { + prior_registry: Box, + prior_schema_fingerprint: String, + migrations: Vec, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackageBuildRequest { + pub environment: String, + pub instance_id: String, + pub database_id: String, + pub sequence: u64, + pub prior_revision: Option, + pub compiler_source_revision: String, + pub schema_fingerprint: String, + pub signature_policy: SignaturePolicy, + pub project: PackageSourceFile, + pub modules: Vec, + pub fixture_journeys: PackageSourceFile, + pub migration_plan: PackageMigrationPlanInput, +} + +/// A deterministic package payload with its revision fixed before any caller +/// supplies signatures. +#[derive(Debug)] +pub struct PreparedPackage { + manifest: PackageManifest, + registry: CompiledRegistry, + files: BTreeMap>, + signed_bytes: Vec, +} + +impl PreparedPackage { + pub fn manifest(&self) -> &PackageManifest { + &self.manifest + } + + pub fn canonical_signed_bytes(&self) -> &[u8] { + &self.signed_bytes + } + + pub fn package_revision(&self) -> &str { + &self.manifest.package_revision + } + + /// The exact Production compilation captured by this candidate package. + pub fn registry(&self) -> &CompiledRegistry { + &self.registry + } + + pub fn file_bytes(&self) -> &BTreeMap> { + &self.files + } + + pub fn envelope(&self, signatures: Vec) -> Result { + validate_publication_signatures(&self.manifest, &signatures)?; + Ok(PackageEnvelope { + api_version: PACKAGE_API_VERSION.to_owned(), + signed: self.manifest.clone(), + signatures, + }) + } + + /// Publish into a new package directory. The manifest is written last, so + /// a partial directory is never accepted as a package by `load_package`. + pub fn publish_to_directory( + &self, + destination: &Path, + signatures: Vec, + ) -> Result<()> { + reject_symlink_components(destination)?; + if destination.exists() { + return Err(PackageError::Closure); + } + let parent = destination.parent().ok_or(PackageError::UnsafePath)?; + reject_symlink_components(parent)?; + if !parent.is_dir() { + return Err(PackageError::UnsafePath); + } + fs::create_dir(destination).map_err(|_| PackageError::Closure)?; + if self.manifest.environment != "local" { + set_safe_directory_permissions(destination)?; + } + let publish = (|| { + for (path, bytes) in &self.files { + let relative = Path::new(path); + let full = destination.join(relative); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).map_err(|_| PackageError::Closure)?; + if self.manifest.environment != "local" { + set_safe_directory_permissions(parent)?; + } + } + write_new_file(&full, bytes, self.manifest.environment != "local")?; + } + let envelope = self.envelope(signatures)?; + let manifest_bytes = canonicalize_json( + &serde_json::to_value(&envelope).map_err(|_| PackageError::CanonicalJson)?, + ) + .map_err(|_| PackageError::CanonicalJson)?; + write_new_file( + &destination.join(MANIFEST_PATH), + &manifest_bytes, + self.manifest.environment != "local", + ) + })(); + if publish.is_err() { + let _ = remove_created_package_dir(destination); + } + publish + } +} + +/// Compare two compiled Registries by stable logical identifiers and return a +/// value-free change set. The embedded migration plan is present only when +/// every change is a compiler-derived compatible additive change. +pub fn compiled_registry_change_set( + previous: &CompiledRegistry, + candidate: &CompiledRegistry, + prior_package_revision: &str, +) -> CompiledRegistryChangeSet { + let previous_baseline = + CompiledRegistryMigrationBaseline::from_compiled(prior_package_revision, previous); + compiled_registry_change_set_from_baseline( + &previous_baseline, + candidate, + prior_package_revision, + ) +} + +fn compiled_registry_change_set_from_baseline( + previous: &CompiledRegistryMigrationBaseline, + candidate: &CompiledRegistry, + prior_package_revision: &str, +) -> CompiledRegistryChangeSet { + let candidate_baseline = CompiledRegistryMigrationBaseline::from_compiled("", candidate); + let mut changes = Vec::new(); + compare_registry_identity(previous, &candidate_baseline, &mut changes); + compare_entities(previous, &candidate_baseline, &mut changes); + compare_routes(previous, &candidate_baseline, &mut changes); + compare_query_inventory(previous, &candidate_baseline, &mut changes); + sort_changes(&mut changes); + changes.dedup(); + + let mut change_set = CompiledRegistryChangeSet { + from_revision: prior_package_revision.to_owned(), + changes, + migration_plan: None, + }; + if change_set + .changes + .iter() + .all(|change| change.class == CompiledRegistryChangeClass::CompatibleAdditive) + { + change_set.migration_plan = Some(additive_migration_plan( + previous, + candidate, + prior_package_revision, + change_set.changes.clone(), + )); + } + change_set +} + +/// Convert a value-free change set into an applicable migration plan only when +/// every classified change is compatible additive. +pub fn change_set_to_applicable_migration_plan( + change_set: &CompiledRegistryChangeSet, +) -> Result { + if change_set + .changes + .iter() + .all(|change| change.class == CompiledRegistryChangeClass::CompatibleAdditive) + { + change_set + .migration_plan + .clone() + .ok_or(PackageError::MigrationPlan) + } else { + Err(PackageError::MigrationPlan) + } +} + +fn compare_registry_identity( + previous: &CompiledRegistryMigrationBaseline, + candidate: &CompiledRegistryMigrationBaseline, + changes: &mut Vec, +) { + if previous.registry_id != candidate.registry_id + || previous.registry_version != candidate.registry_version + { + push_change( + changes, + CompiledRegistryChangeClass::Unsupported, + CompiledRegistryChangeCode::RegistryIdentityChanged, + target(CompiledRegistryChangeTargetKind::Registry, None, None), + ); + } +} + +fn compare_entities( + previous: &CompiledRegistryMigrationBaseline, + candidate: &CompiledRegistryMigrationBaseline, + changes: &mut Vec, +) { + for (entity_id, previous_entity) in &previous.entities { + let Some(candidate_entity) = candidate.entities.get(entity_id) else { + push_change( + changes, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::EntityRemoved, + target( + CompiledRegistryChangeTargetKind::Entity, + Some(entity_id.as_str()), + None, + ), + ); + continue; + }; + if previous_entity.physical_table != candidate_entity.physical_table { + push_change( + changes, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::EntityPhysicalNameChanged, + target( + CompiledRegistryChangeTargetKind::Entity, + Some(entity_id.as_str()), + None, + ), + ); + } + if previous_entity.route != candidate_entity.route { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::EntityRouteChanged, + target( + CompiledRegistryChangeTargetKind::Entity, + Some(entity_id.as_str()), + None, + ), + ); + } + if previous_entity.mutation_mode != candidate_entity.mutation_mode + || previous_entity.tombstone != candidate_entity.tombstone + { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::EntityMutationModeChanged, + target( + CompiledRegistryChangeTargetKind::Entity, + Some(entity_id.as_str()), + None, + ), + ); + } + if previous_entity.classification != candidate_entity.classification { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::EntityClassificationChanged, + target( + CompiledRegistryChangeTargetKind::Entity, + Some(entity_id.as_str()), + None, + ), + ); + } + if previous_entity.temporal != candidate_entity.temporal { + push_change( + changes, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::EntityTemporalChanged, + target( + CompiledRegistryChangeTargetKind::Entity, + Some(entity_id.as_str()), + None, + ), + ); + } + compare_fields(entity_id, previous_entity, candidate_entity, changes); + compare_map( + entity_id, + &previous_entity.constraints, + &candidate_entity.constraints, + CompiledRegistryChangeTargetKind::Constraint, + CompiledRegistryChangeCode::ConstraintAdded, + CompiledRegistryChangeCode::ConstraintRemoved, + CompiledRegistryChangeCode::ConstraintChanged, + CompiledRegistryChangeClass::CompatibleAdditive, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + changes, + ); + compare_map( + entity_id, + &previous_entity.indexes, + &candidate_entity.indexes, + CompiledRegistryChangeTargetKind::Index, + CompiledRegistryChangeCode::IndexAdded, + CompiledRegistryChangeCode::IndexRemoved, + CompiledRegistryChangeCode::IndexChanged, + CompiledRegistryChangeClass::CompatibleAdditive, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + changes, + ); + compare_map( + entity_id, + &previous_entity.access_profiles, + &candidate_entity.access_profiles, + CompiledRegistryChangeTargetKind::AccessProfile, + CompiledRegistryChangeCode::AccessProfileAdded, + CompiledRegistryChangeCode::AccessProfileRemoved, + CompiledRegistryChangeCode::AccessProfileChanged, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + changes, + ); + compare_map( + entity_id, + &previous_entity.events, + &candidate_entity.events, + CompiledRegistryChangeTargetKind::Event, + CompiledRegistryChangeCode::EventAdded, + CompiledRegistryChangeCode::EventRemoved, + CompiledRegistryChangeCode::EventChanged, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + changes, + ); + } + + for entity_id in candidate.entities.keys() { + if !previous.entities.contains_key(entity_id) { + push_change( + changes, + CompiledRegistryChangeClass::CompatibleAdditive, + CompiledRegistryChangeCode::EntityAdded, + target( + CompiledRegistryChangeTargetKind::Entity, + Some(entity_id.as_str()), + None, + ), + ); + } + } +} + +fn compare_fields( + entity_id: &str, + previous: &CompiledEntity, + candidate: &CompiledEntity, + changes: &mut Vec, +) { + for (field_id, previous_field) in &previous.fields { + let Some(candidate_field) = candidate.fields.get(field_id) else { + push_change( + changes, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::FieldRemoved, + target( + CompiledRegistryChangeTargetKind::Field, + Some(entity_id), + Some(field_id.as_str()), + ), + ); + continue; + }; + if previous_field.physical_name != candidate_field.physical_name { + push_change( + changes, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::FieldPhysicalNameChanged, + target( + CompiledRegistryChangeTargetKind::Field, + Some(entity_id), + Some(field_id.as_str()), + ), + ); + } + if previous_field.field_type != candidate_field.field_type { + let code = match (&previous_field.field_type, &candidate_field.field_type) { + ( + FieldTypeSource::Reference { + target: previous_target, + .. + }, + FieldTypeSource::Reference { + target: candidate_target, + .. + }, + ) if previous_target != candidate_target => { + CompiledRegistryChangeCode::ReferenceTargetChanged + } + _ => CompiledRegistryChangeCode::FieldTypeChanged, + }; + push_change( + changes, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + code, + target( + CompiledRegistryChangeTargetKind::Field, + Some(entity_id), + Some(field_id.as_str()), + ), + ); + } + if previous_field.required != candidate_field.required { + let class = if candidate_field.required { + CompiledRegistryChangeClass::DataBackfillRequired + } else { + CompiledRegistryChangeClass::DestructiveOrIrreversible + }; + push_change( + changes, + class, + CompiledRegistryChangeCode::FieldRequirednessChanged, + target( + CompiledRegistryChangeTargetKind::Field, + Some(entity_id), + Some(field_id.as_str()), + ), + ); + } + if previous_field.classification != candidate_field.classification { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::FieldClassificationChanged, + target( + CompiledRegistryChangeTargetKind::Field, + Some(entity_id), + Some(field_id.as_str()), + ), + ); + } + if previous_field.valid_time_role != candidate_field.valid_time_role { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::FieldTemporalRoleChanged, + target( + CompiledRegistryChangeTargetKind::Field, + Some(entity_id), + Some(field_id.as_str()), + ), + ); + } + } + + for (field_id, field) in &candidate.fields { + if previous.fields.contains_key(field_id) { + continue; + } + let class = if field.required { + CompiledRegistryChangeClass::DataBackfillRequired + } else { + CompiledRegistryChangeClass::CompatibleAdditive + }; + let code = if field.required { + CompiledRegistryChangeCode::FieldAddedRequired + } else { + CompiledRegistryChangeCode::FieldAddedOptional + }; + push_change( + changes, + class, + code, + target( + CompiledRegistryChangeTargetKind::Field, + Some(entity_id), + Some(field_id.as_str()), + ), + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn compare_map( + entity_id: &str, + previous: &BTreeMap, + candidate: &BTreeMap, + target_kind: CompiledRegistryChangeTargetKind, + added_code: CompiledRegistryChangeCode, + removed_code: CompiledRegistryChangeCode, + changed_code: CompiledRegistryChangeCode, + added_class: CompiledRegistryChangeClass, + removed_class: CompiledRegistryChangeClass, + changed_class: CompiledRegistryChangeClass, + changes: &mut Vec, +) { + for (id, previous_value) in previous { + match candidate.get(id) { + Some(candidate_value) if previous_value == candidate_value => {} + Some(_) => push_change( + changes, + changed_class, + changed_code, + target(target_kind, Some(entity_id), Some(id.as_str())), + ), + None => push_change( + changes, + removed_class, + removed_code, + target(target_kind, Some(entity_id), Some(id.as_str())), + ), + } + } + for id in candidate.keys() { + if !previous.contains_key(id) { + push_change( + changes, + added_class, + added_code, + target(target_kind, Some(entity_id), Some(id.as_str())), + ); + } + } +} + +fn compare_routes( + previous: &CompiledRegistryMigrationBaseline, + candidate: &CompiledRegistryMigrationBaseline, + changes: &mut Vec, +) { + let previous_routes = previous + .routes + .routes + .iter() + .map(|route| (route.id.as_str(), route)) + .collect::>(); + let candidate_routes = candidate + .routes + .routes + .iter() + .map(|route| (route.id.as_str(), route)) + .collect::>(); + for (route_id, previous_route) in &previous_routes { + match candidate_routes.get(route_id) { + Some(candidate_route) if previous_route == candidate_route => {} + Some(candidate_route) => { + if previous.entities.contains_key(&previous_route.entity_id) + && candidate.entities.contains_key(&candidate_route.entity_id) + { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::RouteChanged, + target( + CompiledRegistryChangeTargetKind::Route, + Some(candidate_route.entity_id.as_str()), + Some(route_id), + ), + ); + } + } + None => { + if previous.entities.contains_key(&previous_route.entity_id) + && candidate.entities.contains_key(&previous_route.entity_id) + { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::RouteRemoved, + target( + CompiledRegistryChangeTargetKind::Route, + Some(previous_route.entity_id.as_str()), + Some(route_id), + ), + ); + } + } + } + } + for (route_id, candidate_route) in &candidate_routes { + if !previous_routes.contains_key(route_id) + && previous.entities.contains_key(&candidate_route.entity_id) + { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::RouteAdded, + target( + CompiledRegistryChangeTargetKind::Route, + Some(candidate_route.entity_id.as_str()), + Some(route_id), + ), + ); + } + } +} + +fn compare_query_inventory( + previous: &CompiledRegistryMigrationBaseline, + candidate: &CompiledRegistryMigrationBaseline, + changes: &mut Vec, +) { + let previous_queries = previous + .queries + .operations + .iter() + .map(|query| (query.id.as_str(), query)) + .collect::>(); + let candidate_queries = candidate + .queries + .operations + .iter() + .map(|query| (query.id.as_str(), query)) + .collect::>(); + for (query_id, previous_query) in &previous_queries { + match candidate_queries.get(query_id) { + Some(candidate_query) if previous_query == candidate_query => {} + Some(candidate_query) + if previous.entities.contains_key(&previous_query.entity_id) + && candidate.entities.contains_key(&candidate_query.entity_id) => + { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::QueryInventoryChanged, + target( + CompiledRegistryChangeTargetKind::QueryInventory, + Some(candidate_query.entity_id.as_str()), + Some(query_id), + ), + ); + } + None if previous.entities.contains_key(&previous_query.entity_id) + && candidate.entities.contains_key(&previous_query.entity_id) => + { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::QueryInventoryChanged, + target( + CompiledRegistryChangeTargetKind::QueryInventory, + Some(previous_query.entity_id.as_str()), + Some(query_id), + ), + ); + } + _ => {} + } + } + for (query_id, candidate_query) in &candidate_queries { + if !previous_queries.contains_key(query_id) + && previous.entities.contains_key(&candidate_query.entity_id) + { + push_change( + changes, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::QueryInventoryChanged, + target( + CompiledRegistryChangeTargetKind::QueryInventory, + Some(candidate_query.entity_id.as_str()), + Some(query_id), + ), + ); + } + } +} + +fn additive_migration_plan( + previous: &CompiledRegistryMigrationBaseline, + candidate: &CompiledRegistry, + prior_package_revision: &str, + changes: Vec, +) -> MigrationPlan { + let mut new_statement_ids = BTreeSet::::new(); + let mut added_columns = BTreeMap::>::new(); + + for (entity_id, candidate_entity) in candidate.entities() { + if !previous.entities.contains_key(entity_id) { + let prefix = format!("entity.{entity_id}."); + new_statement_ids.extend( + candidate + .ddl() + .statements + .iter() + .filter(|statement| statement.id.starts_with(&prefix)) + .map(|statement| statement.id.clone()), + ); + continue; + } + let previous_entity = &previous.entities[entity_id]; + for (field_id, field) in &candidate_entity.fields { + if !previous_entity.fields.contains_key(field_id) && !field.required { + added_columns + .entry(entity_id.clone()) + .or_default() + .push(add_column_statement(candidate_entity, field)); + if matches!(field.field_type, FieldTypeSource::Reference { .. }) { + new_statement_ids + .insert(format!("entity.{entity_id}.field.{field_id}.reference")); + } + } + } + for constraint_id in candidate_entity.constraints.keys() { + if !previous_entity.constraints.contains_key(constraint_id) { + new_statement_ids.insert(format!("entity.{entity_id}.constraint.{constraint_id}")); + } + } + for index_id in candidate_entity.indexes.keys() { + if !previous_entity.indexes.contains_key(index_id) { + new_statement_ids.insert(format!("entity.{entity_id}.index.{index_id}")); + } + } + } + + let mut statements = Vec::new(); + for statement in &candidate.ddl().statements { + if let Some(entity_id) = table_statement_entity_id(&statement.id) { + if let Some(columns) = added_columns.get(entity_id) { + statements.extend(columns.iter().cloned()); + } + } + if new_statement_ids.contains(statement.id.as_str()) { + statements.push(statement.clone()); + } + } + MigrationPlan { + from_revision: Some(prior_package_revision.to_owned()), + prior_baseline: Some(previous.clone()), + changes, + statements, + reviewed_descriptors: Vec::new(), + prior_schema_fingerprint: None, + } +} + +fn initial_migration_plan(compiled: &CompiledRegistry) -> MigrationPlan { + MigrationPlan { + from_revision: None, + prior_baseline: None, + changes: Vec::new(), + statements: compiled.ddl().statements.clone(), + reviewed_descriptors: Vec::new(), + prior_schema_fingerprint: None, + } +} + +fn reviewed_successor_migration_plan( + baseline: &CompiledRegistryMigrationBaseline, + candidate: &CompiledRegistry, + change_set: &CompiledRegistryChangeSet, + descriptor_paths: Vec, + prior_schema_fingerprint: String, +) -> Result { + if descriptor_paths.is_empty() + || change_set + .changes + .iter() + .any(|change| change.class == CompiledRegistryChangeClass::Unsupported) + { + return Err(PackageError::MigrationPlan); + } + let additive_changes = change_set + .changes + .iter() + .filter(|change| change.class == CompiledRegistryChangeClass::CompatibleAdditive) + .cloned() + .collect::>(); + let additive = additive_migration_plan( + baseline, + candidate, + &change_set.from_revision, + additive_changes, + ); + Ok(MigrationPlan { + from_revision: Some(change_set.from_revision.clone()), + prior_baseline: Some(baseline.clone()), + changes: change_set.changes.clone(), + statements: additive.statements, + reviewed_descriptors: descriptor_paths, + prior_schema_fingerprint: Some(prior_schema_fingerprint), + }) +} + +fn table_statement_entity_id(statement_id: &str) -> Option<&str> { + statement_id + .strip_prefix("entity.") + .and_then(|suffix| suffix.strip_suffix(".table")) +} + +fn push_change( + changes: &mut Vec, + class: CompiledRegistryChangeClass, + code: CompiledRegistryChangeCode, + target: CompiledRegistryChangeTarget, +) { + changes.push(CompiledRegistryChange { + class, + code, + target, + }); +} + +fn sort_changes(changes: &mut [CompiledRegistryChange]) { + changes.sort_by(|left, right| { + left.target + .cmp(&right.target) + .then_with(|| left.code.cmp(&right.code)) + .then_with(|| left.class.cmp(&right.class)) + }); +} + +fn target( + kind: CompiledRegistryChangeTargetKind, + entity_id: Option<&str>, + member_id: Option<&str>, +) -> CompiledRegistryChangeTarget { + CompiledRegistryChangeTarget { + kind, + entity_id: entity_id.map(str::to_owned), + member_id: member_id.map(str::to_owned), + } +} + +pub fn prepare_package(request: PackageBuildRequest) -> Result { + validate_build_identity(&request)?; + validate_relative(&request.project.path)?; + if request.fixture_journeys.path != FIXTURE_JOURNEYS_PATH + || request.fixture_journeys.bytes.is_empty() + || request.fixture_journeys.bytes.len() as u64 > MAX_PACKAGE_SOURCE_FILE_BYTES + { + return Err(PackageError::Closure); + } + let project = + parse_project_yaml(&request.project.bytes).map_err(|_| PackageError::Derivation)?; + let modules = request + .modules + .iter() + .map(|source| { + validate_relative(&source.path)?; + if source.id.is_empty() { + return Err(PackageError::Derivation); + } + let module = parse_module_yaml(&source.bytes).map_err(|_| PackageError::Derivation)?; + if module.id != source.id { + return Err(PackageError::Derivation); + } + Ok(module) + }) + .collect::>>()?; + let compiled = compile_project(&project, &modules, CompileProfile::Production) + .map_err(|_| PackageError::Derivation)?; + validate_build_bindings(&request, &project, &compiled)?; + + let (migration_plan, reviewed_files): (MigrationPlan, BTreeMap>) = match request + .migration_plan + { + PackageMigrationPlanInput::InitialCompiledDdl => { + if request.sequence != 1 || request.prior_revision.is_some() { + return Err(PackageError::MigrationPlan); + } + (initial_migration_plan(&compiled), BTreeMap::new()) + } + PackageMigrationPlanInput::Successor { prior_registry } => { + if request.sequence == 1 || request.prior_revision.is_none() { + return Err(PackageError::MigrationPlan); + } + let prior_revision = request + .prior_revision + .as_deref() + .ok_or(PackageError::MigrationPlan)?; + let change_set = + compiled_registry_change_set(&prior_registry, &compiled, prior_revision); + ( + change_set_to_applicable_migration_plan(&change_set)?, + BTreeMap::new(), + ) + } + #[cfg(feature = "tooling")] + PackageMigrationPlanInput::ReviewedSuccessor { + prior_registry, + prior_schema_fingerprint, + migrations, + } => { + if request.sequence == 1 + || request.prior_revision.is_none() + || !valid_digest(&prior_schema_fingerprint) + { + return Err(PackageError::MigrationPlan); + } + let prior_revision = request + .prior_revision + .as_deref() + .ok_or(PackageError::MigrationPlan)?; + let baseline = + CompiledRegistryMigrationBaseline::from_compiled(prior_revision, &prior_registry); + let change_set = + compiled_registry_change_set(&prior_registry, &compiled, prior_revision); + let reviewed = prepare_reviewed_migration_plan( + &migrations, + &ReviewedPlanBindings { + prior_revision, + prior_schema_fingerprint: &prior_schema_fingerprint, + final_schema_fingerprint: &request.schema_fingerprint, + database_id: &request.database_id, + changes: &change_set.changes, + prior_entities: prior_registry.entities(), + candidate_entities: compiled.entities(), + prior_physical_names: prior_registry.physical_names(), + candidate_physical_names: compiled.physical_names(), + }, + ) + .map_err(|_| PackageError::MigrationPlan)?; + let PreparedReviewedMigrationPlan { + descriptor_paths, + files, + } = reviewed; + ( + reviewed_successor_migration_plan( + &baseline, + &compiled, + &change_set, + descriptor_paths, + prior_schema_fingerprint, + )?, + files, + ) + } + }; + + let mut files = BTreeMap::new(); + files.insert(request.project.path.clone(), request.project.bytes.clone()); + for module in &request.modules { + if files + .insert(module.path.clone(), module.bytes.clone()) + .is_some() + { + return Err(PackageError::Closure); + } + } + if files + .insert( + request.fixture_journeys.path.clone(), + request.fixture_journeys.bytes.clone(), + ) + .is_some() + { + return Err(PackageError::Closure); + } + for (path, bytes) in reviewed_files { + validate_relative(&path)?; + if files.insert(path, bytes).is_some() { + return Err(PackageError::Closure); + } + } + add_compiled_artifacts(&compiled, &migration_plan, &mut files)?; + + let mut entries = Vec::new(); + entries.push(file_entry( + &request.project.path, + PackageFileRole::SourceProject, + &request.project.bytes, + )?); + for module in &request.modules { + entries.push(file_entry( + &module.path, + PackageFileRole::SourceModule, + &module.bytes, + )?); + } + entries.push(file_entry( + &request.fixture_journeys.path, + PackageFileRole::FixtureJourneys, + &request.fixture_journeys.bytes, + )?); + for (path, bytes) in &files { + if path == &request.project.path + || path == &request.fixture_journeys.path + || request.modules.iter().any(|module| module.path == *path) + { + continue; + } + entries.push(file_entry(path, package_role_for_path(path)?, bytes)?); + } + entries.sort_by(|left, right| left.path.cmp(&right.path)); + ensure_unique_file_entries(&entries)?; + + let mut manifest = PackageManifest { + package_id: compiled.registry_id().to_owned(), + package_revision: String::new(), + environment: request.environment, + instance_id: request.instance_id, + database_id: request.database_id, + sequence: request.sequence, + prior_revision: request.prior_revision, + compiler: CompilerIdentity { + id: COMPILER_ID.to_owned(), + source_revision: request.compiler_source_revision, + profile: PackageCompileProfile::Production, + }, + schema_fingerprint: request.schema_fingerprint, + signature_policy: request.signature_policy, + sources: CapturedSources { + project: request.project.path, + modules: request + .modules + .into_iter() + .map(|module| CapturedModule { + id: module.id, + path: module.path, + }) + .collect(), + fixture_journeys: request.fixture_journeys.path, + }, + files: entries, + migration_plan, + }; + validate_migration_plan(&manifest, &compiled)?; + validate_source_inventory(&manifest)?; + manifest.package_revision = derive_package_revision(&manifest)?; + let signed_bytes = canonical_signed_bytes(&manifest)?; + Ok(PreparedPackage { + manifest, + registry: compiled, + files, + signed_bytes, + }) +} + +/// Return the exact canonical bytes signed by every package signer. +pub fn canonical_signed_bytes(manifest: &PackageManifest) -> Result> { + canonicalize_json(&serde_json::to_value(manifest).map_err(|_| PackageError::CanonicalJson)?) + .map_err(|_| PackageError::CanonicalJson) +} + +fn add_compiled_artifacts( + compiled: &CompiledRegistry, + migration_plan: &MigrationPlan, + files: &mut BTreeMap>, +) -> Result<()> { + insert_generated( + files, + "effective-model.json", + compiled + .artifacts() + .get("compiled/effective-model.json") + .ok_or(PackageError::Derivation)? + .bytes + .clone(), + )?; + insert_json_file( + files, + "inventories/physical-names.json", + compiled.physical_names(), + )?; + insert_json_file(files, "inventories/routes.json", compiled.routes())?; + insert_json_file(files, "inventories/access.json", compiled.access())?; + insert_json_file(files, "inventories/queries.json", compiled.queries())?; + insert_json_file( + files, + "inventories/events.json", + compiled.event_deliveries(), + )?; + insert_generated( + files, + "metadata/registry.json", + compiled + .artifacts() + .get(REGISTRY_METADATA_ARTIFACT_PATH) + .ok_or(PackageError::Derivation)? + .bytes + .clone(), + )?; + insert_generated( + files, + "database/ddl.sql", + compiled.ddl().script().into_bytes(), + )?; + insert_json_file(files, "database/migration-plan.json", migration_plan)?; + insert_generated( + files, + "openapi/openapi.json", + compiled + .artifacts() + .get("generated/openapi.json") + .ok_or(PackageError::Derivation)? + .bytes + .clone(), + )?; + insert_generated( + files, + "manifest/registry-manifest.json", + compiled + .artifacts() + .get("generated/manifest/registry-manifest.json") + .ok_or(PackageError::Derivation)? + .bytes + .clone(), + )?; + for (path, artifact) in compiled.artifacts().entries() { + let Some(schema_name) = path.strip_prefix("generated/schemas/") else { + continue; + }; + insert_generated( + files, + &format!("schemas/{schema_name}"), + artifact.bytes.clone(), + )?; + } + Ok(()) +} + +fn expected_artifact_bytes( + manifest: &PackageManifest, + compiled: &CompiledRegistry, +) -> Result>> { + let mut files = BTreeMap::new(); + add_compiled_artifacts(compiled, &manifest.migration_plan, &mut files)?; + Ok(files) +} + +fn insert_json_file( + files: &mut BTreeMap>, + path: &str, + value: &impl Serialize, +) -> Result<()> { + let bytes = + canonicalize_json(&serde_json::to_value(value).map_err(|_| PackageError::CanonicalJson)?) + .map_err(|_| PackageError::CanonicalJson)?; + insert_generated(files, path, bytes) +} + +fn insert_generated( + files: &mut BTreeMap>, + path: &str, + bytes: Vec, +) -> Result<()> { + validate_relative(path)?; + if files.insert(path.to_owned(), bytes).is_some() { + return Err(PackageError::Closure); + } + Ok(()) +} + +fn package_role_for_path(path: &str) -> Result { + if let Some(kind) = reviewed_artifact_kind(path) { + return Ok(match kind { + ReviewedArtifactKind::Descriptor => PackageFileRole::ReviewedMigrationDescriptor, + ReviewedArtifactKind::StepSql => PackageFileRole::ReviewedMigrationStepSql, + ReviewedArtifactKind::AssertionSql => PackageFileRole::ReviewedMigrationAssertionSql, + ReviewedArtifactKind::RehearsalReceipt => PackageFileRole::MigrationRehearsalReceipt, + ReviewedArtifactKind::BackupBinding => PackageFileRole::ExternalBackupBinding, + ReviewedArtifactKind::Fixture => PackageFileRole::MigrationRehearsalFixture, + }); + } + Ok(match path { + FIXTURE_JOURNEYS_PATH => PackageFileRole::FixtureJourneys, + "effective-model.json" => PackageFileRole::GovernedModel, + "inventories/physical-names.json" => PackageFileRole::PhysicalNameInventory, + "inventories/routes.json" => PackageFileRole::RouteInventory, + "inventories/access.json" => PackageFileRole::AccessInventory, + "inventories/queries.json" => PackageFileRole::QueryInventory, + "inventories/events.json" => PackageFileRole::EventInventory, + "metadata/registry.json" => PackageFileRole::CallerSafeMetadata, + "database/ddl.sql" => PackageFileRole::GeneratedDdl, + "database/migration-plan.json" => PackageFileRole::MigrationPlan, + "openapi/openapi.json" => PackageFileRole::GeneratedOpenapi, + path if path.starts_with("schemas/") && path.ends_with(".schema.json") => { + PackageFileRole::EntityJsonSchema + } + "manifest/registry-manifest.json" => PackageFileRole::LossyManifestProjection, + _ => return Err(PackageError::Closure), + }) +} + +fn reviewed_package_role(role: PackageFileRole) -> bool { + matches!( + role, + PackageFileRole::ReviewedMigrationDescriptor + | PackageFileRole::ReviewedMigrationStepSql + | PackageFileRole::ReviewedMigrationAssertionSql + | PackageFileRole::MigrationRehearsalReceipt + | PackageFileRole::ExternalBackupBinding + | PackageFileRole::MigrationRehearsalFixture + ) +} + +fn file_entry(path: &str, role: PackageFileRole, bytes: &[u8]) -> Result { + validate_relative(path)?; + Ok(PackageFile { + path: path.to_owned(), + role, + size: bytes.len() as u64, + sha256: digest(bytes), + }) +} + +fn ensure_unique_file_entries(entries: &[PackageFile]) -> Result<()> { + let mut previous = None; + let mut paths = BTreeSet::new(); + for entry in entries { + if previous.is_some_and(|path: &str| path >= entry.path.as_str()) + || !paths.insert(entry.path.as_str()) + { + return Err(PackageError::Closure); + } + previous = Some(entry.path.as_str()); + } + Ok(()) +} + +fn validate_build_identity(request: &PackageBuildRequest) -> Result<()> { + if !valid_build_id(&request.environment) + || !valid_build_id(&request.instance_id) + || !valid_build_id(&request.database_id) + || request.sequence == 0 + || request.compiler_source_revision.is_empty() + || !valid_digest(&request.schema_fingerprint) + { + return Err(PackageError::Binding); + } + validate_signature_policy(&request.environment, &request.signature_policy) +} + +fn valid_build_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +fn validate_signature_policy(environment: &str, policy: &SignaturePolicy) -> Result<()> { + let ids = exact_sorted_unique(policy.key_ids.iter().map(String::as_str))?; + if environment == "local" { + if policy.threshold != 0 || !ids.is_empty() { + return Err(PackageError::Signature); + } + } else if policy.threshold == 0 || usize::from(policy.threshold) > ids.len() { + return Err(PackageError::Signature); + } + Ok(()) +} + +fn validate_build_bindings( + request: &PackageBuildRequest, + project: &RegistryProject, + compiled: &CompiledRegistry, +) -> Result<()> { + let identity = project.package.as_ref().ok_or(PackageError::Derivation)?; + if project.registry.id != compiled.registry_id() + || identity.environment != request.environment + || identity.instance_id != request.instance_id + || identity.sequence != request.sequence + || identity.source_revision != request.compiler_source_revision + { + return Err(PackageError::Derivation); + } + let mut prior_id = None; + for module in &request.modules { + if prior_id.is_some_and(|id: &str| id >= module.id.as_str()) { + return Err(PackageError::Derivation); + } + prior_id = Some(module.id.as_str()); + } + Ok(()) +} + +fn validate_publication_signatures( + manifest: &PackageManifest, + signatures: &[PackageSignature], +) -> Result<()> { + validate_signature_policy(&manifest.environment, &manifest.signature_policy)?; + if manifest.environment == "local" { + if !signatures.is_empty() { + return Err(PackageError::Signature); + } + return Ok(()); + } + let policy_ids = manifest + .signature_policy + .key_ids + .iter() + .map(String::as_str) + .collect::>(); + let mut seen = BTreeSet::new(); + let mut previous = None; + for signature in signatures { + if previous.is_some_and(|id: &str| id >= signature.key_id.as_str()) + || !seen.insert(signature.key_id.as_str()) + || !policy_ids.contains(signature.key_id.as_str()) + { + return Err(PackageError::Signature); + } + previous = Some(signature.key_id.as_str()); + decode_hex(&signature.signature_hex)?; + } + if seen.len() < usize::from(manifest.signature_policy.threshold) { + return Err(PackageError::Signature); + } + Ok(()) +} + +fn write_new_file(path: &Path, bytes: &[u8], production: bool) -> Result<()> { + reject_symlink_components(path)?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|_| PackageError::Closure)?; + file.write_all(bytes).map_err(|_| PackageError::Read)?; + file.sync_all().map_err(|_| PackageError::Read)?; + if production { + set_safe_file_permissions(path)?; + } + Ok(()) +} + +#[cfg(unix)] +fn set_safe_directory_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .map_err(|_| PackageError::Permissions) +} + +#[cfg(not(unix))] +fn set_safe_directory_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn set_safe_file_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o644)) + .map_err(|_| PackageError::Permissions) +} + +#[cfg(not(unix))] +fn set_safe_file_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +fn remove_created_package_dir(path: &Path) -> Result<()> { + reject_symlink_components(path)?; + if path.is_dir() { + fs::remove_dir_all(path).map_err(|_| PackageError::Read)?; + } + Ok(()) +} + +/// Compute the package revision over the complete signed manifest with only +/// its self-referential revision member cleared. +pub fn derive_package_revision(manifest: &PackageManifest) -> Result { + let mut unsigned = manifest.clone(); + unsigned.package_revision.clear(); + Ok(digest(&canonical_signed_bytes(&unsigned)?)) +} + +/// Load one package from the caller-selected local root. This function performs +/// no network resolution and must complete before a database mutation or +/// listener construction is attempted. +pub fn load_package(root: &Path, context: &PackageLoadContext<'_>) -> Result { + validate_root(root)?; + let production = context.database_initialization_environment != "local"; + if production { + ensure_safe_permissions(root)?; + } + + let manifest_path = root.join(MANIFEST_PATH); + let manifest_bytes = read_bounded_regular(&manifest_path, MAX_MANIFEST_BYTES, production)?; + let envelope: PackageEnvelope = parse_canonical(&manifest_bytes)?; + if envelope.api_version != PACKAGE_API_VERSION + || envelope.signed.files.is_empty() + || envelope.signed.files.len() > MAX_PACKAGE_FILES + { + return Err(PackageError::Integrity); + } + + let signed_bytes = canonical_signed_bytes(&envelope.signed)?; + if derive_package_revision(&envelope.signed)? != envelope.signed.package_revision { + return Err(PackageError::Integrity); + } + validate_bindings(&envelope.signed, context)?; + let inspection_context = PackageInspectionContext { + environment: context.environment, + instance_id: context.instance_id, + database_id: context.database_id, + database_initialization_environment: context.database_initialization_environment, + compiler_source_revision: context.compiler_source_revision, + trust_anchor: context.trust_anchor, + expected_package_revision: &envelope.signed.package_revision, + expected_sequence: envelope.signed.sequence, + }; + verify_signatures(&envelope, &inspection_context, production, &signed_bytes)?; + let loaded = load_closure( + root, + &envelope.signed.files, + manifest_bytes.len(), + production, + )?; + let (registry, reviewed_migration_plan) = rederive(&envelope.signed, &loaded)?; + + Ok(VerifiedPackage { + manifest: envelope.signed, + registry, + intent: VerifiedPackageIntent::from_intent(context.intent), + reviewed_migration_plan, + }) +} + +/// Rederive a closed package for integrity-only comparison. +/// +/// Signatures are checked for structural consistency but are not treated as a +/// trust decision because this mode has no configured trust anchor. Safe +/// permissions are still mandatory. The returned type carries no startup or +/// activation authority. +pub fn inspect_package_integrity(root: &Path) -> Result { + inspect_package(root, None) +} + +/// Rederive a closed package and verify its configured deployment bindings and +/// signature policy without making a startup or activation claim. +pub fn inspect_package_with_context( + root: &Path, + context: &PackageInspectionContext<'_>, +) -> Result { + inspect_package(root, Some(context)) +} + +fn inspect_package( + root: &Path, + context: Option<&PackageInspectionContext<'_>>, +) -> Result { + validate_root(root)?; + ensure_safe_permissions(root)?; + + let manifest_path = root.join(MANIFEST_PATH); + let manifest_bytes = read_bounded_regular(&manifest_path, MAX_MANIFEST_BYTES, true)?; + let envelope: PackageEnvelope = parse_canonical(&manifest_bytes)?; + if envelope.api_version != PACKAGE_API_VERSION + || envelope.signed.files.is_empty() + || envelope.signed.files.len() > MAX_PACKAGE_FILES + { + return Err(PackageError::Integrity); + } + + let signed_bytes = canonical_signed_bytes(&envelope.signed)?; + if derive_package_revision(&envelope.signed)? != envelope.signed.package_revision { + return Err(PackageError::Integrity); + } + validate_intrinsic_bindings(&envelope.signed)?; + match context { + Some(context) => { + validate_inspection_bindings(&envelope.signed, context)?; + let production = context.database_initialization_environment != "local"; + verify_signatures(&envelope, context, production, &signed_bytes)?; + } + None => validate_publication_signatures(&envelope.signed, &envelope.signatures)?, + } + let loaded = load_closure(root, &envelope.signed.files, manifest_bytes.len(), true)?; + let (registry, _reviewed_migration_plan) = rederive(&envelope.signed, &loaded)?; + #[cfg(feature = "tooling")] + let migration = + migration_inspection_summary(&envelope.signed, _reviewed_migration_plan.as_ref())?; + + Ok(IntegrityInspectedPackage { + package_revision: envelope.signed.package_revision, + registry, + #[cfg(feature = "tooling")] + migration, + }) +} + +#[cfg(feature = "tooling")] +fn migration_inspection_summary( + manifest: &PackageManifest, + reviewed_plan: Option<&ValidatedReviewedMigrationPlan>, +) -> Result { + let plan = &manifest.migration_plan; + let plan_kind = if !plan.reviewed_descriptors.is_empty() { + MigrationInspectionPlanKind::Reviewed + } else if plan.from_revision.is_some() { + MigrationInspectionPlanKind::CompatibleAdditive + } else { + MigrationInspectionPlanKind::Initial + }; + let mut change_counts = MigrationInspectionChangeCounts::default(); + for change in &plan.changes { + change_counts.record(change.class); + } + let reviewed_migrations = match plan_kind { + MigrationInspectionPlanKind::Reviewed => { + let reviewed_plan = reviewed_plan.ok_or(PackageError::MigrationPlan)?; + if reviewed_plan.migrations().len() != plan.reviewed_descriptors.len() { + return Err(PackageError::MigrationPlan); + } + reviewed_plan + .migrations() + .iter() + .map(reviewed_migration_inspection_summary) + .collect() + } + MigrationInspectionPlanKind::Initial | MigrationInspectionPlanKind::CompatibleAdditive => { + if reviewed_plan.is_some() { + return Err(PackageError::MigrationPlan); + } + Vec::new() + } + }; + Ok(MigrationInspectionSummary { + plan_kind, + has_prior_revision: manifest.prior_revision.is_some(), + has_prior_baseline: plan.prior_baseline.is_some(), + change_count: plan.changes.len(), + change_counts, + generated_statement_count: plan.statements.len(), + reviewed_migrations, + }) +} + +#[cfg(feature = "tooling")] +fn reviewed_migration_inspection_summary( + migration: &crate::migration_plan::ValidatedReviewedMigration, +) -> ReviewedMigrationInspectionSummary { + let mut transactional_step_count = 0; + let mut chunked_step_count = 0; + let mut minimum_chunk_size = None; + let mut maximum_chunk_size = 0; + let mut maximum_total_rows = 0; + for step in &migration.steps { + match &step.descriptor { + ReviewedMigrationStepDescriptor::TransactionalSql { .. } => { + transactional_step_count += 1; + } + ReviewedMigrationStepDescriptor::ChunkedBackfill { + chunk_size, + max_total_rows, + .. + } => { + chunked_step_count += 1; + minimum_chunk_size = Some( + minimum_chunk_size.map_or(*chunk_size, |minimum: u32| minimum.min(*chunk_size)), + ); + maximum_chunk_size = maximum_chunk_size.max(*chunk_size); + maximum_total_rows = maximum_total_rows.max(*max_total_rows); + } + } + } + ReviewedMigrationInspectionSummary { + change_class: migration.descriptor.change_class, + recovery: migration.descriptor.recovery, + lock_timeout_ms: migration.descriptor.lock_timeout_ms, + statement_timeout_ms: migration.descriptor.statement_timeout_ms, + transactional_step_count, + chunked_step_count, + pre_assertion_count: migration.pre_assertions.len(), + post_assertion_count: migration.post_assertions.len(), + backup_required: migration.descriptor.change_class + == CompiledRegistryChangeClass::DestructiveOrIrreversible, + chunked_step_bounds: minimum_chunk_size.map(|minimum_chunk_size| { + ReviewedChunkedStepBounds { + minimum_chunk_size, + maximum_chunk_size, + maximum_total_rows, + } + }), + } +} + +fn validate_root(root: &Path) -> Result<()> { + if root.as_os_str().is_empty() { + return Err(PackageError::UnsafePath); + } + reject_symlink_components(root)?; + let metadata = fs::symlink_metadata(root).map_err(|_| PackageError::Read)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(PackageError::UnsafePath); + } + Ok(()) +} + +fn validate_bindings(manifest: &PackageManifest, context: &PackageLoadContext<'_>) -> Result<()> { + validate_intrinsic_bindings(manifest)?; + if manifest.environment != context.environment + || manifest.environment != context.database_initialization_environment + || manifest.instance_id != context.instance_id + || manifest.database_id != context.database_id + || manifest.compiler.source_revision != context.compiler_source_revision + { + return Err(PackageError::Binding); + } + match context.intent { + PackageIntent::InitialActivation => { + if manifest.sequence != 1 + || manifest.prior_revision.is_some() + || manifest.migration_plan.from_revision.is_some() + { + return Err(PackageError::Binding); + } + } + PackageIntent::Activation { + active_revision, + active_sequence, + } => { + if manifest.sequence <= active_sequence + || manifest.prior_revision.as_deref() != Some(active_revision) + || manifest.migration_plan.from_revision.as_deref() != Some(active_revision) + { + return Err(PackageError::Binding); + } + } + PackageIntent::Startup { + active_revision, + active_sequence, + } => { + if manifest.package_revision != active_revision || manifest.sequence != active_sequence + { + return Err(PackageError::Binding); + } + if manifest.sequence == 1 && manifest.prior_revision.is_some() { + return Err(PackageError::Binding); + } + if manifest.sequence > 1 && manifest.prior_revision.is_none() { + return Err(PackageError::Binding); + } + } + } + Ok(()) +} + +fn validate_intrinsic_bindings(manifest: &PackageManifest) -> Result<()> { + if manifest.package_id.is_empty() + || manifest.package_revision.is_empty() + || manifest.environment.is_empty() + || manifest.instance_id.is_empty() + || manifest.database_id.is_empty() + || manifest.sequence == 0 + || manifest.compiler.id != COMPILER_ID + || manifest.compiler.source_revision.is_empty() + || manifest.compiler.profile != PackageCompileProfile::Production + || !valid_digest(&manifest.schema_fingerprint) + || manifest.migration_plan.from_revision != manifest.prior_revision + || (manifest.sequence == 1 && manifest.prior_revision.is_some()) + || (manifest.sequence > 1 && manifest.prior_revision.is_none()) + { + return Err(PackageError::Binding); + } + Ok(()) +} + +fn validate_inspection_bindings( + manifest: &PackageManifest, + context: &PackageInspectionContext<'_>, +) -> Result<()> { + if manifest.environment != context.environment + || manifest.environment != context.database_initialization_environment + || manifest.instance_id != context.instance_id + || manifest.database_id != context.database_id + || manifest.compiler.source_revision != context.compiler_source_revision + || manifest.package_revision != context.expected_package_revision + || manifest.sequence != context.expected_sequence + { + return Err(PackageError::Binding); + } + Ok(()) +} + +fn verify_signatures( + envelope: &PackageEnvelope, + context: &PackageInspectionContext<'_>, + production: bool, + signed_bytes: &[u8], +) -> Result<()> { + if !production { + if context.trust_anchor.is_some() + || envelope.signed.signature_policy.threshold != 0 + || !envelope.signed.signature_policy.key_ids.is_empty() + || !envelope.signatures.is_empty() + { + return Err(PackageError::Signature); + } + return Ok(()); + } + + let anchor_path = context.trust_anchor.ok_or(PackageError::Signature)?; + reject_symlink_components(anchor_path)?; + let anchor_bytes = read_bounded_regular(anchor_path, MAX_MANIFEST_BYTES, true)?; + let anchor: PackageTrustAnchor = parse_canonical(&anchor_bytes)?; + if anchor.api_version != TRUST_ANCHOR_API_VERSION + || anchor.environment != context.database_initialization_environment + || anchor.instance_id != context.instance_id + || anchor.database_id != context.database_id + || anchor.threshold == 0 + || usize::from(anchor.threshold) > anchor.keys.len() + { + return Err(PackageError::Signature); + } + + let policy = &envelope.signed.signature_policy; + let anchor_ids = exact_sorted_unique(anchor.keys.iter().map(|key| key.key_id.as_str()))?; + let policy_ids = exact_sorted_unique(policy.key_ids.iter().map(String::as_str))?; + if policy.threshold != anchor.threshold || policy_ids != anchor_ids { + return Err(PackageError::Signature); + } + + let mut trusted = BTreeMap::new(); + for key in &anchor.keys { + let jwk = parse_public_jwk(&key.jwk)?; + if jwk.kid.as_deref() != Some(key.key_id.as_str()) { + return Err(PackageError::Signature); + } + trusted.insert(key.key_id.as_str(), jwk); + } + let mut verified = BTreeSet::new(); + let mut prior_signature_id = None; + for signature in &envelope.signatures { + if prior_signature_id.is_some_and(|prior: &str| prior >= signature.key_id.as_str()) + || !verified.insert(signature.key_id.as_str()) + { + return Err(PackageError::Signature); + } + prior_signature_id = Some(signature.key_id.as_str()); + let jwk = trusted + .get(signature.key_id.as_str()) + .ok_or(PackageError::Signature)?; + let bytes = decode_hex(&signature.signature_hex)?; + verify(signed_bytes, &bytes, jwk).map_err(|_| PackageError::Signature)?; + } + if verified.len() < usize::from(anchor.threshold) { + return Err(PackageError::Signature); + } + Ok(()) +} + +fn parse_public_jwk(value: &Value) -> Result { + let members = value.as_object().ok_or(PackageError::Signature)?; + let allowed = BTreeSet::from(["alg", "crv", "e", "kid", "kty", "n", "x", "y"]); + if members.keys().any(|key| !allowed.contains(key.as_str())) { + return Err(PackageError::Signature); + } + let bytes = canonicalize_json(value).map_err(|_| PackageError::Signature)?; + let text = std::str::from_utf8(&bytes).map_err(|_| PackageError::Signature)?; + PublicJwk::parse(text).map_err(|_| PackageError::Signature) +} + +fn load_closure( + root: &Path, + entries: &[PackageFile], + manifest_size: usize, + production: bool, +) -> Result>> { + let mut listed = BTreeSet::new(); + let mut loaded = BTreeMap::new(); + let mut total = u64::try_from(manifest_size).map_err(|_| PackageError::Bounds)?; + let mut previous = None; + for entry in entries { + validate_relative(&entry.path)?; + if previous.is_some_and(|path: &str| path >= entry.path.as_str()) + || !listed.insert(entry.path.as_str()) + || entry.size > MAX_FILE_BYTES + || !valid_digest(&entry.sha256) + { + return Err(PackageError::Closure); + } + previous = Some(entry.path.as_str()); + let relative = Path::new(&entry.path); + reject_relative_symlinks(root, relative)?; + let path = root.join(relative); + let bytes = read_bounded_regular(&path, MAX_FILE_BYTES, production)?; + if bytes.len() as u64 != entry.size || digest(&bytes) != entry.sha256 { + return Err(PackageError::Integrity); + } + total = total.checked_add(entry.size).ok_or(PackageError::Bounds)?; + if total > MAX_PACKAGE_BYTES { + return Err(PackageError::Bounds); + } + loaded.insert(entry.path.clone(), bytes); + } + let actual = enumerate_files(root, production)?; + let mut expected = listed + .into_iter() + .map(str::to_owned) + .collect::>(); + expected.insert(MANIFEST_PATH.to_owned()); + if actual != expected { + return Err(PackageError::Closure); + } + Ok(loaded) +} + +fn rederive( + manifest: &PackageManifest, + loaded: &BTreeMap>, +) -> Result<(CompiledRegistry, Option)> { + validate_source_inventory(manifest)?; + let fixture_journeys = loaded + .get(&manifest.sources.fixture_journeys) + .ok_or(PackageError::Derivation)?; + if fixture_journeys.is_empty() || fixture_journeys.len() as u64 > MAX_PACKAGE_SOURCE_FILE_BYTES + { + return Err(PackageError::Derivation); + } + let project_bytes = loaded + .get(&manifest.sources.project) + .ok_or(PackageError::Derivation)?; + let project = parse_project_yaml(project_bytes).map_err(|_| PackageError::Derivation)?; + let modules = manifest + .sources + .modules + .iter() + .map(|source| { + loaded + .get(&source.path) + .ok_or(PackageError::Derivation) + .and_then(|bytes| parse_module_yaml(bytes).map_err(|_| PackageError::Derivation)) + }) + .collect::>>()?; + validate_captured_bindings(manifest, &project, &modules)?; + let compiled = compile_project(&project, &modules, CompileProfile::Production) + .map_err(|_| PackageError::Derivation)?; + if compiled.registry_id() != manifest.package_id { + return Err(PackageError::Derivation); + } + + let expected_artifacts = expected_artifact_bytes(manifest, &compiled)?; + let packaged_artifacts = manifest + .files + .iter() + .filter(|entry| { + !matches!( + entry.role, + PackageFileRole::SourceProject + | PackageFileRole::SourceModule + | PackageFileRole::FixtureJourneys + ) && !reviewed_package_role(entry.role) + }) + .map(|entry| entry.path.as_str()) + .collect::>(); + if expected_artifacts + .keys() + .map(String::as_str) + .collect::>() + != packaged_artifacts + { + return Err(PackageError::Derivation); + } + for (path, bytes) in expected_artifacts { + if loaded.get(&path).map(Vec::as_slice) != Some(bytes.as_slice()) { + return Err(PackageError::Derivation); + } + } + validate_migration_plan(manifest, &compiled)?; + let reviewed_migration_plan = rederive_reviewed_migration_plan(manifest, loaded, &compiled)?; + Ok((compiled, reviewed_migration_plan)) +} + +fn reviewed_artifact_files( + manifest: &PackageManifest, + loaded: &BTreeMap>, +) -> Result>> { + manifest + .files + .iter() + .filter(|entry| reviewed_package_role(entry.role)) + .map(|entry| { + loaded + .get(&entry.path) + .cloned() + .map(|bytes| (entry.path.clone(), bytes)) + .ok_or(PackageError::Closure) + }) + .collect() +} + +#[cfg(feature = "tooling")] +fn rederive_reviewed_migration_plan( + manifest: &PackageManifest, + loaded: &BTreeMap>, + compiled: &CompiledRegistry, +) -> Result> { + let files = reviewed_artifact_files(manifest, loaded)?; + if manifest.migration_plan.reviewed_descriptors.is_empty() { + return if files.is_empty() { + Ok(None) + } else { + Err(PackageError::MigrationPlan) + }; + } + let baseline = manifest + .migration_plan + .prior_baseline + .as_ref() + .ok_or(PackageError::MigrationPlan)?; + let prior_revision = manifest + .prior_revision + .as_deref() + .ok_or(PackageError::MigrationPlan)?; + let prior_schema_fingerprint = manifest + .migration_plan + .prior_schema_fingerprint + .as_deref() + .ok_or(PackageError::MigrationPlan)?; + validate_reviewed_migration_plan( + &manifest.migration_plan.reviewed_descriptors, + &files, + &ReviewedPlanBindings { + prior_revision, + prior_schema_fingerprint, + final_schema_fingerprint: &manifest.schema_fingerprint, + database_id: &manifest.database_id, + changes: &manifest.migration_plan.changes, + prior_entities: &baseline.entities, + candidate_entities: compiled.entities(), + prior_physical_names: &baseline.physical_names, + candidate_physical_names: compiled.physical_names(), + }, + ) + .map(Some) + .map_err(|_| PackageError::MigrationPlan) +} + +#[cfg(not(feature = "tooling"))] +fn rederive_reviewed_migration_plan( + manifest: &PackageManifest, + loaded: &BTreeMap>, + _compiled: &CompiledRegistry, +) -> Result> { + let files = reviewed_artifact_files(manifest, loaded)?; + if manifest.migration_plan.reviewed_descriptors.is_empty() && files.is_empty() { + Ok(None) + } else if manifest.migration_plan.reviewed_descriptors.is_empty() != files.is_empty() { + Err(PackageError::MigrationPlan) + } else { + // The runtime graph intentionally carries no PostgreSQL parser. The + // tooling path that constructs and applies reviewed packages performs + // the AST and evidence validation and exposes the resolved packet. + Ok(None) + } +} + +fn validate_source_inventory(manifest: &PackageManifest) -> Result<()> { + validate_relative(&manifest.sources.project)?; + let project_entries = manifest + .files + .iter() + .filter(|entry| entry.role == PackageFileRole::SourceProject) + .collect::>(); + if project_entries.len() != 1 || project_entries[0].path != manifest.sources.project { + return Err(PackageError::Derivation); + } + if manifest.sources.fixture_journeys != FIXTURE_JOURNEYS_PATH { + return Err(PackageError::Derivation); + } + let fixture_journey_entries = manifest + .files + .iter() + .filter(|entry| entry.role == PackageFileRole::FixtureJourneys) + .collect::>(); + if fixture_journey_entries.len() != 1 + || fixture_journey_entries[0].path != manifest.sources.fixture_journeys + { + return Err(PackageError::Derivation); + } + let mut prior_id = None; + let mut module_paths = BTreeSet::new(); + for module in &manifest.sources.modules { + validate_relative(&module.path)?; + if module.id.is_empty() + || prior_id.is_some_and(|id: &str| id >= module.id.as_str()) + || !module_paths.insert(module.path.as_str()) + { + return Err(PackageError::Derivation); + } + prior_id = Some(module.id.as_str()); + } + let declared_paths = manifest + .sources + .modules + .iter() + .map(|module| module.path.as_str()) + .collect::>(); + let file_paths = manifest + .files + .iter() + .filter(|entry| entry.role == PackageFileRole::SourceModule) + .map(|entry| entry.path.as_str()) + .collect::>(); + if declared_paths != file_paths { + return Err(PackageError::Derivation); + } + for entry in &manifest.files { + if matches!( + entry.role, + PackageFileRole::SourceProject + | PackageFileRole::SourceModule + | PackageFileRole::FixtureJourneys + ) { + continue; + } + if package_role_for_path(&entry.path)? != entry.role { + return Err(PackageError::Derivation); + } + } + Ok(()) +} + +fn validate_captured_bindings( + manifest: &PackageManifest, + project: &RegistryProject, + modules: &[RegistryModule], +) -> Result<()> { + let identity = project.package.as_ref().ok_or(PackageError::Derivation)?; + if project.registry.id != manifest.package_id + || identity.environment != manifest.environment + || identity.instance_id != manifest.instance_id + || identity.sequence != manifest.sequence + || identity.source_revision != manifest.compiler.source_revision + { + return Err(PackageError::Derivation); + } + let source_ids = manifest + .sources + .modules + .iter() + .map(|module| module.id.as_str()) + .collect::>(); + let module_ids = modules + .iter() + .map(|module| module.id.as_str()) + .collect::>(); + let lock_ids = project + .modules + .iter() + .map(|module| module.id.as_str()) + .collect::>(); + if source_ids != module_ids || source_ids.into_iter().collect::>() != lock_ids { + return Err(PackageError::Derivation); + } + Ok(()) +} + +fn validate_migration_plan(manifest: &PackageManifest, compiled: &CompiledRegistry) -> Result<()> { + if manifest.migration_plan.statements.len() > MAX_MIGRATION_STATEMENTS { + return Err(PackageError::Bounds); + } + if manifest.migration_plan.changes.len() > MAX_MIGRATION_STATEMENTS { + return Err(PackageError::Bounds); + } + if manifest.migration_plan.reviewed_descriptors.len() > MAX_MIGRATION_STATEMENTS { + return Err(PackageError::Bounds); + } + let mut prior_descriptor = None; + for descriptor in &manifest.migration_plan.reviewed_descriptors { + validate_relative(descriptor)?; + if prior_descriptor.is_some_and(|prior: &str| prior >= descriptor.as_str()) { + return Err(PackageError::MigrationPlan); + } + prior_descriptor = Some(descriptor.as_str()); + } + if let Some(baseline) = &manifest.migration_plan.prior_baseline { + validate_migration_baseline(baseline)?; + } + let expected = expected_migration_plan(manifest, compiled)?; + if manifest.migration_plan != expected { + return Err(PackageError::MigrationPlan); + } + Ok(()) +} + +fn expected_migration_plan( + manifest: &PackageManifest, + compiled: &CompiledRegistry, +) -> Result { + match ( + manifest.prior_revision.as_deref(), + manifest.migration_plan.from_revision.as_deref(), + ) { + (None, None) => { + if manifest.migration_plan.prior_baseline.is_some() + || !manifest.migration_plan.changes.is_empty() + || !manifest.migration_plan.reviewed_descriptors.is_empty() + || manifest.migration_plan.prior_schema_fingerprint.is_some() + { + return Err(PackageError::MigrationPlan); + } + Ok(initial_migration_plan(compiled)) + } + (Some(prior_revision), Some(from_revision)) if prior_revision == from_revision => { + let baseline = manifest + .migration_plan + .prior_baseline + .as_ref() + .ok_or(PackageError::MigrationPlan)?; + if baseline.package_revision != prior_revision { + return Err(PackageError::MigrationPlan); + } + let change_set = + compiled_registry_change_set_from_baseline(baseline, compiled, prior_revision); + if manifest.migration_plan.reviewed_descriptors.is_empty() { + if manifest.migration_plan.prior_schema_fingerprint.is_some() { + return Err(PackageError::MigrationPlan); + } + change_set_to_applicable_migration_plan(&change_set) + } else { + let prior_schema_fingerprint = manifest + .migration_plan + .prior_schema_fingerprint + .clone() + .filter(|fingerprint| valid_digest(fingerprint)) + .ok_or(PackageError::MigrationPlan)?; + reviewed_successor_migration_plan( + baseline, + compiled, + &change_set, + manifest.migration_plan.reviewed_descriptors.clone(), + prior_schema_fingerprint, + ) + } + } + _ => Err(PackageError::MigrationPlan), + } +} + +fn validate_migration_baseline(baseline: &CompiledRegistryMigrationBaseline) -> Result<()> { + let bytes = canonicalize_json( + &serde_json::to_value(baseline).map_err(|_| PackageError::MigrationPlan)?, + ) + .map_err(|_| PackageError::MigrationPlan)?; + if bytes.len() > MAX_MIGRATION_BASELINE_BYTES { + return Err(PackageError::Bounds); + } + Ok(()) +} + +fn exact_sorted_unique<'a>(values: impl Iterator) -> Result> { + let mut result = Vec::new(); + for value in values { + if value.is_empty() || result.last().is_some_and(|prior| *prior >= value) { + return Err(PackageError::Signature); + } + result.push(value); + } + Ok(result) +} + +fn parse_canonical Deserialize<'de>>(bytes: &[u8]) -> Result { + let value = parse_json_strict(bytes).map_err(|_| PackageError::CanonicalJson)?; + let canonical = canonicalize_json(&value).map_err(|_| PackageError::CanonicalJson)?; + if canonical != bytes { + return Err(PackageError::CanonicalJson); + } + serde_json::from_value(value).map_err(|_| PackageError::CanonicalJson) +} + +fn validate_relative(value: &str) -> Result<()> { + if value.is_empty() + || value.len() > MAX_PATH_BYTES + || value.contains('\\') + || value.ends_with('/') + { + return Err(PackageError::UnsafePath); + } + let path = Path::new(value); + let components = path.components().collect::>(); + let canonical = components + .iter() + .filter_map(|component| match component { + Component::Normal(component) => component.to_str(), + _ => None, + }) + .collect::>() + .join("/"); + if path.is_absolute() + || components.len() > MAX_PATH_COMPONENTS + || components + .iter() + .any(|component| !matches!(component, Component::Normal(_))) + || path.to_str() != Some(value) + || canonical != value + { + return Err(PackageError::UnsafePath); + } + Ok(()) +} + +fn reject_relative_symlinks(root: &Path, relative: &Path) -> Result<()> { + let mut checked = root.to_path_buf(); + for component in relative.components() { + let Component::Normal(component) = component else { + return Err(PackageError::UnsafePath); + }; + checked.push(component); + let metadata = fs::symlink_metadata(&checked).map_err(|_| PackageError::Read)?; + if metadata.file_type().is_symlink() { + return Err(PackageError::UnsafePath); + } + } + Ok(()) +} + +fn reject_symlink_components(path: &Path) -> Result<()> { + let mut checked = PathBuf::new(); + for component in path.components() { + checked.push(component.as_os_str()); + if matches!(component, Component::RootDir | Component::Prefix(_)) { + continue; + } + match fs::symlink_metadata(&checked) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(PackageError::UnsafePath); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(_) => return Err(PackageError::Read), + } + } + Ok(()) +} + +fn enumerate_files(root: &Path, production: bool) -> Result> { + let mut result = BTreeSet::new(); + let mut pending = vec![(root.to_path_buf(), String::new())]; + let mut entry_count = 0_usize; + while let Some((directory, prefix)) = pending.pop() { + for entry in fs::read_dir(directory).map_err(|_| PackageError::Read)? { + let entry = entry.map_err(|_| PackageError::Read)?; + entry_count = entry_count.checked_add(1).ok_or(PackageError::Bounds)?; + if entry_count > MAX_PACKAGE_FILES * 2 { + return Err(PackageError::Bounds); + } + let name = entry + .file_name() + .into_string() + .map_err(|_| PackageError::UnsafePath)?; + let relative = if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + }; + validate_relative(&relative)?; + let file_type = entry.file_type().map_err(|_| PackageError::Read)?; + if production { + ensure_safe_permissions(&entry.path())?; + } + if file_type.is_symlink() { + return Err(PackageError::UnsafePath); + } + if file_type.is_dir() { + pending.push((entry.path(), relative)); + } else if file_type.is_file() { + result.insert(relative); + } else { + return Err(PackageError::Closure); + } + if result.len() > MAX_PACKAGE_FILES + 1 { + return Err(PackageError::Bounds); + } + } + } + Ok(result) +} + +fn read_bounded_regular(path: &Path, bound: u64, production: bool) -> Result> { + let before = fs::symlink_metadata(path).map_err(|_| PackageError::Read)?; + if before.file_type().is_symlink() || !before.is_file() { + return Err(PackageError::Closure); + } + if before.len() > bound { + return Err(PackageError::Bounds); + } + if production { + ensure_safe_permissions(path)?; + } + let file = fs::File::open(path).map_err(|_| PackageError::Read)?; + let opened = file.metadata().map_err(|_| PackageError::Read)?; + let after = fs::symlink_metadata(path).map_err(|_| PackageError::Read)?; + if after.file_type().is_symlink() || !same_file(&before, &opened) || !same_file(&opened, &after) + { + return Err(PackageError::UnsafePath); + } + let capacity = usize::try_from(opened.len()).map_err(|_| PackageError::Bounds)?; + let mut bytes = Vec::with_capacity(capacity); + file.take(bound.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| PackageError::Read)?; + if bytes.len() as u64 > bound { + return Err(PackageError::Bounds); + } + if bytes.len() as u64 != opened.len() { + return Err(PackageError::Integrity); + } + Ok(bytes) +} + +#[cfg(unix)] +fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(not(unix))] +fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.len() == right.len() + && left.modified().ok() == right.modified().ok() + && left.created().ok() == right.created().ok() +} + +#[cfg(unix)] +fn ensure_safe_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::symlink_metadata(path).map_err(|_| PackageError::Read)?; + if metadata.permissions().mode() & 0o022 != 0 { + return Err(PackageError::Permissions); + } + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_safe_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +fn valid_digest(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn digest(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut result = String::with_capacity(71); + result.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + write!(&mut result, "{byte:02x}").expect("writing to a String cannot fail"); + } + result +} + +fn decode_hex(value: &str) -> Result> { + if value.is_empty() + || value.len() > 32 * 1024 + || !value.len().is_multiple_of(2) + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(PackageError::Signature); + } + value + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let text = std::str::from_utf8(pair).map_err(|_| PackageError::Signature)?; + u8::from_str_radix(text, 16).map_err(|_| PackageError::Signature) + }) + .collect() +} diff --git a/crates/registry-server/src/physical_names.rs b/crates/registry-server/src/physical_names.rs new file mode 100644 index 0000000000..0ab50a9add --- /dev/null +++ b/crates/registry-server/src/physical_names.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::diagnostics::Diagnostic; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct EntityPhysicalNames { + pub table: String, + pub fields: BTreeMap, + pub constraints: BTreeMap, + pub indexes: BTreeMap, + pub policies: BTreeMap, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PhysicalNameInventory { + pub entities: BTreeMap, +} + +pub(crate) struct PhysicalNameBuilder { + used: BTreeSet, +} + +impl PhysicalNameBuilder { + pub(crate) fn new() -> Self { + Self { + used: BTreeSet::new(), + } + } + + pub(crate) fn derive( + &mut self, + kind: &str, + stable_id: &str, + path: &str, + ) -> Result { + let suffix_bytes = 8; + let suffix_len = suffix_bytes * 2; + let slug_limit = 63_usize + .saturating_sub("rs_".len() + kind.len() + 1 + 1 + suffix_len) + .max(1); + let slug: String = stable_id + .bytes() + .map(|byte| match byte { + b'A'..=b'Z' => (byte + 32) as char, + b'a'..=b'z' | b'0'..=b'9' | b'_' => byte as char, + _ => '_', + }) + .take(slug_limit) + .collect(); + let digest = Sha256::digest(format!("registry-server:{kind}:{stable_id}").as_bytes()); + let suffix = hex_prefix(&digest, suffix_bytes); + let name = format!("rs_{kind}_{slug}_{suffix}"); + if name.len() > 63 || !self.used.insert(name.clone()) { + return Err(Diagnostic::error( + "physical_name.collision", + path, + "stable identifiers do not produce a unique PostgreSQL name", + )); + } + Ok(name) + } +} + +pub(crate) fn hex_prefix(bytes: &[u8], count: usize) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(count * 2); + for byte in bytes.iter().take(count) { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} diff --git a/crates/registry-server/src/postgres/catalog.rs b/crates/registry-server/src/postgres/catalog.rs new file mode 100644 index 0000000000..f683a5e7cb --- /dev/null +++ b/crates/registry-server/src/postgres/catalog.rs @@ -0,0 +1,1070 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::{collections::BTreeSet, fmt::Write}; + +use sha2::{Digest, Sha256}; +use tokio_postgres::GenericClient; + +use crate::generated_ddl::{PolicyCommand, TablePrivilege}; +use crate::model::CompiledRegistry; + +use super::{ + migration_ledger::install_migration_ledger, verify_btree_gist, PostgresKernelError, Result, + SqlIdentifier, +}; + +const TABLE_OWNER_PRIVILEGES: &[&str] = &[ + "DELETE", + "INSERT", + "MAINTAIN", + "REFERENCES", + "SELECT", + "TRIGGER", + "TRUNCATE", + "UPDATE", +]; +const SEQUENCE_OWNER_PRIVILEGES: &[&str] = &["SELECT", "UPDATE", "USAGE"]; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum ManagedObjectKind { + Schema, + Table, + Sequence, +} + +impl ManagedObjectKind { + fn as_str(self) -> &'static str { + match self { + Self::Schema => "schema", + Self::Table => "table", + Self::Sequence => "sequence", + } + } +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct ManagedObject { + kind: ManagedObjectKind, + name: String, + runtime_privileges: BTreeSet, + row_security: Option<(bool, bool)>, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct ManagedPolicy { + table: String, + name: String, + command: String, + has_using: bool, + has_check: bool, +} + +/// Exact managed PostgreSQL inventory accepted by catalog verification. +/// +/// Construction is deliberately closed to either the explicit feasibility +/// kernel or one compiler-produced Registry plus the current product-owned +/// mutation tables. There is no wildcard or ambient-catalog mode. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExpectedManagedCatalog { + objects: BTreeSet, + policies: BTreeSet, +} + +impl ExpectedManagedCatalog { + /// Explicit compatibility inventory for the W2 feasibility kernel. + #[must_use] + pub fn kernel() -> Self { + let mut catalog = Self::base(); + catalog.table( + "registry_data.kernel_records", + ["DELETE", "INSERT", "SELECT", "UPDATE"], + Some((true, true)), + ); + catalog.policies.insert(ManagedPolicy { + table: "registry_data.kernel_records".to_owned(), + name: "registry_authority_policy".to_owned(), + command: "*".to_owned(), + has_using: true, + has_check: true, + }); + catalog + } + + /// Exact product inventory for one compiled Registry. + #[must_use] + pub fn compiled(registry: &CompiledRegistry) -> Self { + let mut catalog = Self::base(); + for (name, privileges) in [ + ( + "registry_internal.registry_revisions", + &["INSERT", "SELECT"][..], + ), + ( + "registry_internal.registry_outbox", + &["INSERT", "SELECT"][..], + ), + ( + "registry_internal.registry_webhook_deliveries", + &["INSERT", "SELECT"][..], + ), + ( + "registry_internal.registry_webhook_delivery_state", + &["INSERT", "SELECT", "UPDATE"][..], + ), + ( + "registry_internal.registry_audit", + &["INSERT", "SELECT"][..], + ), + ( + "registry_internal.registry_audit_head", + &["INSERT", "SELECT", "UPDATE"][..], + ), + ( + "registry_internal.registry_idempotency", + &["INSERT", "SELECT"][..], + ), + ] { + catalog.table(name, privileges.iter().copied(), Some((false, false))); + } + catalog.sequence( + "registry_internal.registry_outbox_outbox_id_seq", + ["SELECT", "USAGE"], + ); + + for table in ®istry.ddl().tables { + let name = format!("registry_data.{}", table.physical_name); + catalog.table( + &name, + table + .runtime_privileges + .iter() + .copied() + .map(TablePrivilege::as_sql), + Some((true, true)), + ); + for policy in &table.policies { + catalog.policies.insert(ManagedPolicy { + table: name.clone(), + name: policy.name.clone(), + command: policy_command_code(policy.command).to_owned(), + has_using: policy.using_expression.is_some(), + has_check: policy.check_expression.is_some(), + }); + } + } + catalog + } + + fn base() -> Self { + let mut catalog = Self { + objects: BTreeSet::new(), + policies: BTreeSet::new(), + }; + catalog.schema("registry_data"); + catalog.schema("registry_internal"); + catalog.table( + "registry_internal.registry_state", + ["SELECT"], + Some((false, false)), + ); + catalog.table( + "registry_internal.registry_migrations", + [], + Some((false, false)), + ); + catalog.table( + "registry_internal.registry_migration_steps", + [], + Some((false, false)), + ); + catalog + } + + fn schema(&mut self, name: &str) { + self.objects.insert(ManagedObject { + kind: ManagedObjectKind::Schema, + name: name.to_owned(), + runtime_privileges: BTreeSet::from(["USAGE".to_owned()]), + row_security: None, + }); + } + + fn table( + &mut self, + name: &str, + privileges: impl IntoIterator, + row_security: Option<(bool, bool)>, + ) { + self.objects.insert(ManagedObject { + kind: ManagedObjectKind::Table, + name: name.to_owned(), + runtime_privileges: privileges.into_iter().map(str::to_owned).collect(), + row_security, + }); + } + + fn sequence(&mut self, name: &str, privileges: impl IntoIterator) { + self.objects.insert(ManagedObject { + kind: ManagedObjectKind::Sequence, + name: name.to_owned(), + runtime_privileges: privileges.into_iter().map(str::to_owned).collect(), + row_security: None, + }); + } +} + +fn policy_command_code(command: PolicyCommand) -> &'static str { + match command { + PolicyCommand::Select => "r", + PolicyCommand::Insert => "a", + PolicyCommand::Update => "w", + } +} + +/// Package and schema identity expected by one loaded runtime. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExpectedRegistryIdentity { + pub package_id: String, + pub environment: String, + pub instance_id: String, + pub database_id: String, + pub package_revision: String, + pub schema_fingerprint: String, + pub package_sequence: i64, +} + +impl ExpectedRegistryIdentity { + pub fn validate(&self) -> Result<()> { + if self.package_id.is_empty() + || self.environment.is_empty() + || self.instance_id.is_empty() + || self.database_id.is_empty() + || self.package_revision.is_empty() + || self.schema_fingerprint.is_empty() + || self.package_sequence < 0 + { + return Err(PostgresKernelError::Configuration( + "Registry identity fields must be non-empty and sequence must be non-negative", + )); + } + Ok(()) + } +} + +/// Verified identity read from the managed PostgreSQL catalog. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CatalogIdentity { + pub package_id: String, + pub environment: String, + pub instance_id: String, + pub database_id: String, + pub package_revision: String, + pub schema_fingerprint: String, + pub package_sequence: i64, +} + +impl CatalogIdentity { + pub fn validate(&self) -> Result<()> { + if self.package_id.is_empty() + || self.environment.is_empty() + || self.instance_id.is_empty() + || self.database_id.is_empty() + || self.package_revision.is_empty() + || self.schema_fingerprint.is_empty() + || self.package_sequence < 0 + { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) + } +} + +#[cfg(feature = "postgres-test")] +struct InitialRegistryState<'a> { + package_id: &'a str, + environment: &'a str, + instance_id: &'a str, + database_id: &'a str, + package_revision: &'a str, + package_sequence: i64, +} + +#[cfg(feature = "postgres-test")] +impl InitialRegistryState<'_> { + fn validate(&self) -> Result<()> { + if self.package_id.is_empty() + || self.environment.is_empty() + || self.instance_id.is_empty() + || self.database_id.is_empty() + || self.package_revision.is_empty() + || self.package_sequence < 0 + { + return Err(PostgresKernelError::Configuration( + "initial Registry identity is incomplete", + )); + } + Ok(()) + } +} + +#[cfg(feature = "postgres-test")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[doc(hidden)] +pub struct RegistryStateTestIdentity<'a> { + pub package_id: &'a str, + pub environment: &'a str, + pub instance_id: &'a str, + pub database_id: &'a str, + pub package_revision: &'a str, + pub package_sequence: i64, +} + +/// Installs the minimal internal state and RLS-protected data surface used by +/// the feasibility proof. The caller must already be the verified migration role. +pub async fn install_kernel_schema( + migration: &impl GenericClient, + runtime_role: &SqlIdentifier, +) -> Result<()> { + verify_btree_gist(migration).await?; + install_registry_state_schema(migration, runtime_role).await?; + migration + .batch_execute( + "CREATE TABLE IF NOT EXISTS registry_data.kernel_records ( + record_id uuid PRIMARY KEY, + authority text NOT NULL + CONSTRAINT kernel_records_authority_nonempty CHECK (authority <> ''), + payload text NOT NULL, + package_revision text NOT NULL + CONSTRAINT kernel_records_package_revision_nonempty CHECK (package_revision <> '') + ); + ALTER TABLE registry_data.kernel_records ENABLE ROW LEVEL SECURITY; + ALTER TABLE registry_data.kernel_records FORCE ROW LEVEL SECURITY; + DROP POLICY IF EXISTS registry_authority_policy ON registry_data.kernel_records; + CREATE POLICY registry_authority_policy ON registry_data.kernel_records + USING ( + NULLIF(current_setting('registry.access_profile', true), '') = 'operator' + AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL + AND NULLIF(current_setting('registry.purpose', true), '') = 'registry-administration' + AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' + AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 + AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) = 'object' + AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb + AND NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0 ->> 'field' = 'authority' + AND NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0 ->> 'operator' = 'equals' + AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0 -> 'values') = 1 + AND authority = (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0 -> 'values' ->> 0) + ) + WITH CHECK ( + NULLIF(current_setting('registry.access_profile', true), '') = 'operator' + AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL + AND NULLIF(current_setting('registry.purpose', true), '') = 'registry-administration' + AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' + AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 + AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) = 'object' + AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb + AND NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0 ->> 'field' = 'authority' + AND NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0 ->> 'operator' = 'equals' + AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0 -> 'values') = 1 + AND authority = (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0 -> 'values' ->> 0) + );", + ) + .await?; + migration + .batch_execute(&format!( + "REVOKE ALL ON ALL TABLES IN SCHEMA registry_data FROM PUBLIC;\n\ + GRANT SELECT, INSERT, UPDATE, DELETE ON registry_data.kernel_records TO {};", + runtime_role.quoted(), + )) + .await?; + Ok(()) +} + +pub(crate) async fn install_registry_state_schema( + migration: &impl GenericClient, + runtime_role: &SqlIdentifier, +) -> Result<()> { + migration + .batch_execute( + "CREATE TABLE IF NOT EXISTS registry_internal.registry_state ( + singleton boolean PRIMARY KEY DEFAULT true + CONSTRAINT registry_state_singleton_true CHECK (singleton), + environment text NOT NULL + CONSTRAINT registry_state_environment_nonempty CHECK (environment <> ''), + package_id text NOT NULL + CONSTRAINT registry_state_package_id_nonempty CHECK (package_id <> ''), + instance_id text NOT NULL + CONSTRAINT registry_state_instance_id_nonempty CHECK (instance_id <> ''), + database_id text NOT NULL + CONSTRAINT registry_state_database_id_nonempty CHECK (database_id <> ''), + active_package_revision text NOT NULL + CONSTRAINT registry_state_package_revision_nonempty CHECK (active_package_revision <> ''), + schema_fingerprint text NOT NULL + CONSTRAINT registry_state_schema_fingerprint_nonempty CHECK (schema_fingerprint <> ''), + package_sequence bigint NOT NULL + CONSTRAINT registry_state_package_sequence_nonnegative CHECK (package_sequence >= 0), + maintenance_status text NOT NULL + CONSTRAINT registry_state_maintenance_status_closed + CHECK (maintenance_status IN ('ready', 'applying', 'failed')), + maintenance_target_revision text, + CONSTRAINT registry_state_maintenance_target_consistent CHECK ( + (maintenance_status = 'ready' AND maintenance_target_revision IS NULL) + OR (maintenance_status IN ('applying', 'failed') AND maintenance_target_revision IS NOT NULL) + ), + updated_at timestamptz NOT NULL DEFAULT transaction_timestamp() + ); + REVOKE ALL ON TABLE registry_internal.registry_state FROM PUBLIC;", + ) + .await?; + install_migration_ledger(migration, runtime_role).await?; + migration + .batch_execute(&format!( + "REVOKE ALL ON SCHEMA registry_internal, registry_data FROM PUBLIC, {};\n\ + GRANT USAGE ON SCHEMA registry_internal, registry_data TO {};\n\ + REVOKE ALL ON TABLE registry_internal.registry_state FROM {};\n\ + GRANT SELECT ON TABLE registry_internal.registry_state TO {};", + runtime_role.quoted(), + runtime_role.quoted(), + runtime_role.quoted(), + runtime_role.quoted(), + )) + .await?; + Ok(()) +} + +/// Initializes a Registry state row against an explicit closed catalog. +#[cfg(feature = "postgres-test")] +async fn initialize_registry_state_for_catalog( + migration: &impl GenericClient, + runtime_role: &SqlIdentifier, + expected_catalog: &ExpectedManagedCatalog, + initial: &InitialRegistryState<'_>, +) -> Result { + initial.validate()?; + let schema_fingerprint = + managed_schema_fingerprint(migration, runtime_role, expected_catalog).await?; + let changed = migration + .execute( + "INSERT INTO registry_internal.registry_state ( + singleton, package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status + ) VALUES (true, $1, $2, $3, $4, $5, $6, $7, 'ready') + ON CONFLICT (singleton) DO NOTHING", + &[ + &initial.package_id, + &initial.environment, + &initial.instance_id, + &initial.database_id, + &initial.package_revision, + &schema_fingerprint, + &initial.package_sequence, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::CatalogInvariant( + "Registry state is already initialized", + )); + } + Ok(ExpectedRegistryIdentity { + package_id: initial.package_id.to_owned(), + environment: initial.environment.to_owned(), + instance_id: initial.instance_id.to_owned(), + database_id: initial.database_id.to_owned(), + package_revision: initial.package_revision.to_owned(), + schema_fingerprint, + package_sequence: initial.package_sequence, + }) +} + +/// Test-only helper for integration fixtures that install a compiled catalog +/// directly instead of loading a verified initial package. +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +pub async fn initialize_registry_state_for_catalog_test( + migration: &impl GenericClient, + runtime_role: &SqlIdentifier, + expected_catalog: &ExpectedManagedCatalog, + identity: RegistryStateTestIdentity<'_>, +) -> Result { + let initial = InitialRegistryState { + package_id: identity.package_id, + environment: identity.environment, + instance_id: identity.instance_id, + database_id: identity.database_id, + package_revision: identity.package_revision, + package_sequence: identity.package_sequence, + }; + initialize_registry_state_for_catalog(migration, runtime_role, expected_catalog, &initial).await +} + +/// Test-only helper for the W2 feasibility kernel catalog. +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +pub async fn initialize_kernel_registry_state_for_test( + migration: &impl GenericClient, + runtime_role: &SqlIdentifier, + identity: RegistryStateTestIdentity<'_>, +) -> Result { + initialize_registry_state_for_catalog_test( + migration, + runtime_role, + &ExpectedManagedCatalog::kernel(), + identity, + ) + .await +} + +pub async fn verify_catalog_identity_for_catalog( + client: &impl GenericClient, + expected: &ExpectedRegistryIdentity, + expected_catalog: &ExpectedManagedCatalog, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, +) -> Result { + expected.validate()?; + let row = client + .query_opt( + "SELECT package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await? + .ok_or(PostgresKernelError::RegistryUnavailable)?; + let actual = CatalogIdentity { + package_id: row.get(0), + environment: row.get(1), + instance_id: row.get(2), + database_id: row.get(3), + package_revision: row.get(4), + schema_fingerprint: row.get(5), + package_sequence: row.get(6), + }; + actual.validate()?; + if actual.package_id != expected.package_id + || actual.environment != expected.environment + || actual.instance_id != expected.instance_id + || actual.database_id != expected.database_id + || actual.package_revision != expected.package_revision + || actual.schema_fingerprint != expected.schema_fingerprint + || actual.package_sequence != expected.package_sequence + { + return Err(PostgresKernelError::RegistryUnavailable); + } + verify_managed_catalog( + client, + expected, + expected_catalog, + migration_role, + runtime_role, + ) + .await?; + Ok(actual) +} + +/// Explicit W2 compatibility wrapper. New product startup paths must pass a +/// compiled expected catalog to [`verify_catalog_identity_for_catalog`]. +pub async fn verify_catalog_identity( + client: &impl GenericClient, + expected: &ExpectedRegistryIdentity, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, +) -> Result { + verify_catalog_identity_for_catalog( + client, + expected, + &ExpectedManagedCatalog::kernel(), + migration_role, + runtime_role, + ) + .await +} + +pub(crate) async fn verify_managed_catalog( + client: &impl GenericClient, + expected: &ExpectedRegistryIdentity, + expected_catalog: &ExpectedManagedCatalog, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, +) -> Result<()> { + verify_managed_owners_for_catalog(client, migration_role, expected_catalog).await?; + verify_closed_ambient_catalog(client).await?; + verify_exact_acl(client, runtime_role, expected_catalog).await?; + verify_row_security(client, expected_catalog).await?; + verify_policies(client, expected_catalog).await?; + let actual = fingerprint_catalog(client, runtime_role).await?; + if actual != expected.schema_fingerprint { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +async fn verify_closed_ambient_catalog(client: &impl GenericClient) -> Result<()> { + let row = client + .query_one( + "SELECT + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + AND c.relkind NOT IN ('r', 'S', 'i') + ), + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_trigger t + JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + AND NOT t.tgisinternal + ), + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_rewrite w + JOIN pg_catalog.pg_class c ON c.oid = w.ev_class + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + AND c.relkind = 'r' + ), + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + ), + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_publication_rel pr + JOIN pg_catalog.pg_class c ON c.oid = pr.prrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + ) OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_publication_namespace pn + JOIN pg_catalog.pg_namespace n ON n.oid = pn.pnnspid + WHERE n.nspname IN ('registry_internal', 'registry_data') + ) OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_publication WHERE puballtables + )", + &[], + ) + .await?; + if (0..5).any(|index| row.get::<_, bool>(index)) { + return Err(PostgresKernelError::CatalogInvariant( + "managed catalog contains unsupported executable objects", + )); + } + Ok(()) +} + +async fn verify_managed_owners_for_catalog( + client: &impl GenericClient, + migration_role: &SqlIdentifier, + expected_catalog: &ExpectedManagedCatalog, +) -> Result<()> { + let rows = client + .query( + "SELECT 'schema', n.nspname, r.rolname + FROM pg_catalog.pg_namespace n + JOIN pg_catalog.pg_roles r ON r.oid = n.nspowner + WHERE n.nspname IN ('registry_internal', 'registry_data') + UNION ALL + SELECT CASE c.relkind WHEN 'r' THEN 'table' ELSE 'sequence' END, + n.nspname || '.' || c.relname, + r.rolname + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_roles r ON r.oid = c.relowner + WHERE n.nspname IN ('registry_internal', 'registry_data') + AND c.relkind IN ('r', 'S')", + &[], + ) + .await?; + let actual: BTreeSet<(String, String, String)> = rows + .into_iter() + .map(|row| (row.get(0), row.get(1), row.get(2))) + .collect(); + let owner = migration_role.as_str().to_owned(); + let expected = expected_catalog + .objects + .iter() + .map(|object| { + ( + object.kind.as_str().to_owned(), + object.name.clone(), + owner.clone(), + ) + }) + .collect(); + if actual != expected { + return Err(PostgresKernelError::CatalogInvariant( + "managed object ownership differs from the closed catalog", + )); + } + Ok(()) +} + +async fn verify_row_security( + client: &impl GenericClient, + expected_catalog: &ExpectedManagedCatalog, +) -> Result<()> { + let rows = client + .query( + "SELECT n.nspname || '.' || c.relname, c.relrowsecurity, c.relforcerowsecurity + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') AND c.relkind = 'r'", + &[], + ) + .await?; + let actual: BTreeSet<(String, bool, bool)> = rows + .into_iter() + .map(|row| (row.get(0), row.get(1), row.get(2))) + .collect(); + let expected = expected_catalog + .objects + .iter() + .filter_map(|object| { + object + .row_security + .map(|(enabled, forced)| (object.name.clone(), enabled, forced)) + }) + .collect(); + if actual != expected { + return Err(PostgresKernelError::CatalogInvariant( + "managed row-security flags differ from the closed catalog", + )); + } + Ok(()) +} + +async fn verify_policies( + client: &impl GenericClient, + expected_catalog: &ExpectedManagedCatalog, +) -> Result<()> { + let rows = client + .query( + "SELECT n.nspname || '.' || c.relname, + p.polname, + p.polcmd::text, + p.polpermissive, + p.polroles = ARRAY[0::oid], + p.polqual IS NOT NULL, + p.polwithcheck IS NOT NULL + FROM pg_catalog.pg_policy p + JOIN pg_catalog.pg_class c ON c.oid = p.polrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data')", + &[], + ) + .await?; + let actual: BTreeSet = rows + .into_iter() + .map(|row| { + if !row.get::<_, bool>(3) || !row.get::<_, bool>(4) { + return Err(PostgresKernelError::CatalogInvariant( + "managed policy mode differs from the closed catalog", + )); + } + Ok(ManagedPolicy { + table: row.get(0), + name: row.get(1), + command: row.get(2), + has_using: row.get(5), + has_check: row.get(6), + }) + }) + .collect::>()?; + if actual != expected_catalog.policies { + return Err(PostgresKernelError::CatalogInvariant( + "managed policy inventory differs from the closed catalog", + )); + } + Ok(()) +} + +async fn query_categorized_acl( + client: &impl GenericClient, + runtime_role: &SqlIdentifier, +) -> Result> { + Ok(client + .query( + "WITH managed_objects(object_kind, object_name, owner_oid, acl) AS ( + SELECT 'schema'::text, + n.nspname, + n.nspowner, + COALESCE(n.nspacl, pg_catalog.acldefault('n', n.nspowner)) + FROM pg_catalog.pg_namespace n + WHERE n.nspname IN ('registry_internal', 'registry_data') + UNION ALL + SELECT CASE c.relkind WHEN 'r' THEN 'table' ELSE 'sequence' END, + n.nspname || '.' || c.relname, + c.relowner, + COALESCE( + c.relacl, + CASE c.relkind + WHEN 'r' THEN pg_catalog.acldefault('r', c.relowner) + ELSE pg_catalog.acldefault('S', c.relowner) + END + ) + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + AND c.relkind IN ('r', 'S') + ), runtime AS ( + SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $1 + ) + SELECT o.object_kind, + o.object_name, + CASE + WHEN a.grantee = 0 THEN 'public' + WHEN a.grantee = o.owner_oid THEN 'owner' + WHEN a.grantee = runtime.oid THEN 'runtime' + ELSE 'other' + END, + a.privilege_type, + a.is_grantable + FROM managed_objects o + CROSS JOIN runtime + CROSS JOIN LATERAL pg_catalog.aclexplode(o.acl) a + ORDER BY 1, 2, 3, 4, 5", + &[&runtime_role.as_str()], + ) + .await?) +} + +async fn verify_exact_acl( + client: &impl GenericClient, + runtime_role: &SqlIdentifier, + expected_catalog: &ExpectedManagedCatalog, +) -> Result<()> { + let actual: BTreeSet<(String, String, String, String, bool)> = + query_categorized_acl(client, runtime_role) + .await? + .into_iter() + .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3), row.get(4))) + .collect(); + let mut expected = BTreeSet::new(); + for object in &expected_catalog.objects { + let owner_privileges = match object.kind { + ManagedObjectKind::Schema => &["CREATE", "USAGE"][..], + ManagedObjectKind::Table => TABLE_OWNER_PRIVILEGES, + ManagedObjectKind::Sequence => SEQUENCE_OWNER_PRIVILEGES, + }; + for privilege in owner_privileges { + expected.insert(( + object.kind.as_str().to_owned(), + object.name.clone(), + "owner".to_owned(), + (*privilege).to_owned(), + false, + )); + } + for privilege in &object.runtime_privileges { + expected.insert(( + object.kind.as_str().to_owned(), + object.name.clone(), + "runtime".to_owned(), + privilege.clone(), + false, + )); + } + } + if actual != expected { + return Err(PostgresKernelError::CatalogInvariant( + "managed object privileges differ from the closed catalog", + )); + } + Ok(()) +} + +/// Computes a deterministic fingerprint over the exact expected managed +/// catalog, including sequences, indexes, constraints, policies, and ACLs. +pub async fn managed_schema_fingerprint( + client: &impl GenericClient, + runtime_role: &SqlIdentifier, + expected_catalog: &ExpectedManagedCatalog, +) -> Result { + let migration_role = current_role(client).await?; + verify_managed_owners_for_catalog(client, &migration_role, expected_catalog).await?; + verify_closed_ambient_catalog(client).await?; + verify_exact_acl(client, runtime_role, expected_catalog).await?; + verify_row_security(client, expected_catalog).await?; + verify_policies(client, expected_catalog).await?; + fingerprint_catalog(client, runtime_role).await +} + +async fn fingerprint_catalog( + client: &impl GenericClient, + runtime_role: &SqlIdentifier, +) -> Result { + let prior_search_path: String = client + .query_one("SELECT pg_catalog.current_setting('search_path')", &[]) + .await? + .get(0); + let column_rows = client + .query( + "WITH deparse_context AS MATERIALIZED ( + SELECT pg_catalog.set_config('search_path', 'pg_catalog', true) + ) + SELECT n.nspname, + c.relname, + c.relkind::text, + c.relrowsecurity, + c.relforcerowsecurity, + c.relowner = n.nspowner, + a.attnum, + a.attname, + pg_catalog.format_type(a.atttypid, a.atttypmod), + a.attnotnull, + COALESCE(pg_catalog.pg_get_expr(d.adbin, d.adrelid), '') + FROM deparse_context + CROSS JOIN pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_attribute a + ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped + LEFT JOIN pg_catalog.pg_attrdef d + ON d.adrelid = c.oid AND d.adnum = a.attnum + WHERE n.nspname IN ('registry_internal', 'registry_data') + AND c.relkind IN ('r', 'S') + ORDER BY n.nspname, c.relname, a.attnum", + &[], + ) + .await?; + let constraint_rows = client + .query( + "WITH deparse_context AS MATERIALIZED ( + SELECT pg_catalog.set_config('search_path', 'pg_catalog', true) + ) + SELECT n.nspname, + c.relname, + x.conname, + x.contype::text, + pg_catalog.pg_get_constraintdef(x.oid, false) + FROM deparse_context + CROSS JOIN pg_catalog.pg_constraint x + JOIN pg_catalog.pg_class c ON c.oid = x.conrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + ORDER BY n.nspname, c.relname, x.conname", + &[], + ) + .await?; + let index_rows = client + .query( + "WITH deparse_context AS MATERIALIZED ( + SELECT pg_catalog.set_config('search_path', 'pg_catalog', true) + ) + SELECT n.nspname, + table_class.relname, + index_class.relname, + pg_catalog.pg_get_indexdef(index_class.oid, 0, false) + FROM deparse_context + CROSS JOIN pg_catalog.pg_index x + JOIN pg_catalog.pg_class table_class ON table_class.oid = x.indrelid + JOIN pg_catalog.pg_class index_class ON index_class.oid = x.indexrelid + JOIN pg_catalog.pg_namespace n ON n.oid = table_class.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + ORDER BY n.nspname, table_class.relname, index_class.relname", + &[], + ) + .await?; + let policy_rows = client + .query( + "WITH deparse_context AS MATERIALIZED ( + SELECT pg_catalog.set_config('search_path', 'pg_catalog', true) + ) + SELECT n.nspname, + c.relname, + p.polname, + p.polcmd::text, + p.polpermissive, + p.polroles = ARRAY[0::oid], + COALESCE(pg_catalog.pg_get_expr(p.polqual, p.polrelid), ''), + COALESCE(pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid), '') + FROM deparse_context + CROSS JOIN pg_catalog.pg_policy p + JOIN pg_catalog.pg_class c ON c.oid = p.polrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + ORDER BY n.nspname, c.relname, p.polname", + &[], + ) + .await?; + let acl_rows = query_categorized_acl(client, runtime_role).await?; + client + .query_one( + "SELECT pg_catalog.set_config('search_path', $1, true)", + &[&prior_search_path], + ) + .await?; + let mut hasher = Sha256::new(); + hasher.update(b"registry-server/catalog/v3/columns"); + for row in column_rows { + for index in [0, 1, 2, 7, 8, 10] { + hash_text(&mut hasher, &row.get::<_, String>(index)); + } + for index in [3, 4, 5, 9] { + hash_bool(&mut hasher, row.get(index)); + } + hasher.update(row.get::<_, i16>(6).to_be_bytes()); + } + hasher.update(b"registry-server/catalog/v3/constraints"); + for row in constraint_rows { + for index in 0..5 { + hash_text(&mut hasher, &row.get::<_, String>(index)); + } + } + hasher.update(b"registry-server/catalog/v3/indexes"); + for row in index_rows { + for index in 0..4 { + hash_text(&mut hasher, &row.get::<_, String>(index)); + } + } + hasher.update(b"registry-server/catalog/v3/policies"); + for row in policy_rows { + for index in [0, 1, 2, 3, 6, 7] { + hash_text(&mut hasher, &row.get::<_, String>(index)); + } + hash_bool(&mut hasher, row.get(4)); + hash_bool(&mut hasher, row.get(5)); + } + hasher.update(b"registry-server/catalog/v3/acl"); + for row in acl_rows { + for index in 0..4 { + hash_text(&mut hasher, &row.get::<_, String>(index)); + } + hash_bool(&mut hasher, row.get(4)); + } + let digest = hasher.finalize(); + let mut fingerprint = String::with_capacity(7 + digest.len() * 2); + fingerprint.push_str("sha256:"); + for byte in digest { + write!(&mut fingerprint, "{byte:02x}").expect("writing to a String cannot fail"); + } + Ok(fingerprint) +} + +/// Explicit W2 compatibility wrapper. +pub async fn kernel_schema_fingerprint( + client: &impl GenericClient, + runtime_role: &SqlIdentifier, +) -> Result { + managed_schema_fingerprint(client, runtime_role, &ExpectedManagedCatalog::kernel()).await +} + +async fn current_role(client: &impl GenericClient) -> Result { + let role: String = client.query_one("SELECT current_user", &[]).await?.get(0); + SqlIdentifier::parse(&role) +} + +fn hash_text(hasher: &mut Sha256, value: &str) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value.as_bytes()); +} + +fn hash_bool(hasher: &mut Sha256, value: bool) { + hasher.update([u8::from(value)]); +} diff --git a/crates/registry-server/src/postgres/config.rs b/crates/registry-server/src/postgres/config.rs new file mode 100644 index 0000000000..bf15c5409f --- /dev/null +++ b/crates/registry-server/src/postgres/config.rs @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::{fmt, str::FromStr, time::Duration}; + +use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime}; +#[cfg(feature = "postgres-test")] +use tokio_postgres::NoTls; +use tokio_postgres::{config::SslMode, Config}; +use tokio_postgres_rustls::MakeRustlsConnect; + +use super::{PostgresKernelError, Result}; + +const MAX_POOL_SIZE: usize = 128; +const MAX_POOL_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_CUSTOM_CA_DER_BYTES: usize = 1024 * 1024; + +/// Explicit PostgreSQL TLS policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TlsPolicy { + /// Require TLS and validate the server certificate with native roots. + RequireNativeRoots, + /// Require TLS and validate the server certificate with one explicit CA. + RequireCustomCa, + /// Permit plaintext only in an isolated test environment. + #[cfg(feature = "postgres-test")] + TestOnlyPlaintext, +} + +/// Bounded runtime pool settings. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PoolBounds { + pub max_size: usize, + pub wait_timeout: Duration, + pub create_timeout: Duration, + pub recycle_timeout: Duration, +} + +impl PoolBounds { + pub fn new( + max_size: usize, + wait_timeout: Duration, + create_timeout: Duration, + recycle_timeout: Duration, + ) -> Result { + if max_size == 0 || max_size > MAX_POOL_SIZE { + return Err(PostgresKernelError::Configuration( + "pool size must be between 1 and 128", + )); + } + if [wait_timeout, create_timeout, recycle_timeout] + .into_iter() + .any(|timeout| timeout.is_zero() || timeout > MAX_POOL_TIMEOUT) + { + return Err(PostgresKernelError::Configuration( + "pool timeouts must be between 1 millisecond and 60 seconds", + )); + } + Ok(Self { + max_size, + wait_timeout, + create_timeout, + recycle_timeout, + }) + } +} + +/// Parsed connection configuration that never formats its secret material. +#[derive(Clone)] +pub struct ConnectionConfig { + postgres: Config, + transport: Transport, + pool_bounds: PoolBounds, +} + +#[derive(Clone)] +enum Transport { + Tls { + policy: TlsPolicy, + connector: MakeRustlsConnect, + }, + #[cfg(feature = "postgres-test")] + TestOnlyPlaintext, +} + +pub(crate) enum ConnectionTls { + Rustls(MakeRustlsConnect), + #[cfg(feature = "postgres-test")] + TestOnlyPlaintext, +} + +impl fmt::Debug for ConnectionConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ConnectionConfig") + .field("tls_policy", &self.tls_policy()) + .field("pool_bounds", &self.pool_bounds) + .finish_non_exhaustive() + } +} + +impl ConnectionConfig { + pub fn require_tls(url: &str, pool_bounds: PoolBounds) -> Result { + Self::require_tls_config(parse_connection(url)?, pool_bounds) + } + + pub fn require_tls_config(mut postgres: Config, pool_bounds: PoolBounds) -> Result { + postgres.ssl_mode(SslMode::Require); + if postgres.get_user().is_none() || postgres.get_dbname().is_none() { + return Err(PostgresKernelError::Configuration( + "database configuration requires an explicit user and database", + )); + } + let connector = native_roots_connector()?; + Ok(Self { + postgres, + transport: Transport::Tls { + policy: TlsPolicy::RequireNativeRoots, + connector, + }, + pool_bounds, + }) + } + + /// Requires TLS using one explicit DER-encoded CA certificate. + /// + /// Hostname and certificate validation remain enabled. The CA bytes are + /// bounded, parsed immediately, and never included in `Debug` output. + pub fn require_tls_with_custom_ca( + url: &str, + ca_der: &[u8], + pool_bounds: PoolBounds, + ) -> Result { + let mut postgres = parse_connection(url)?; + postgres.ssl_mode(SslMode::Require); + if postgres.get_user().is_none() || postgres.get_dbname().is_none() { + return Err(PostgresKernelError::Configuration( + "database configuration requires an explicit user and database", + )); + } + let connector = custom_ca_connector(ca_der)?; + Ok(Self { + postgres, + transport: Transport::Tls { + policy: TlsPolicy::RequireCustomCa, + connector, + }, + pool_bounds, + }) + } + + /// Constructs a plaintext connection for an isolated real-PostgreSQL test. + /// + /// Production configuration loaders must not expose this constructor. + #[cfg(feature = "postgres-test")] + pub fn test_only_plaintext(url: &str, pool_bounds: PoolBounds) -> Result { + let mut postgres = parse_connection(url)?; + postgres.ssl_mode(SslMode::Disable); + Self::from_test_config(postgres, pool_bounds) + } + + /// Constructs a plaintext connection from an already parsed configuration + /// for isolated real-PostgreSQL tests. + #[cfg(feature = "postgres-test")] + pub fn from_test_config(mut postgres: Config, pool_bounds: PoolBounds) -> Result { + postgres.ssl_mode(SslMode::Disable); + if postgres.get_user().is_none() || postgres.get_dbname().is_none() { + return Err(PostgresKernelError::Configuration( + "test database configuration requires an explicit user and database", + )); + } + Ok(Self { + postgres, + transport: Transport::TestOnlyPlaintext, + pool_bounds, + }) + } + + pub(crate) fn postgres(&self) -> Config { + self.postgres.clone() + } + + pub(crate) fn tls_policy(&self) -> TlsPolicy { + match &self.transport { + Transport::Tls { policy, .. } => *policy, + #[cfg(feature = "postgres-test")] + Transport::TestOnlyPlaintext => TlsPolicy::TestOnlyPlaintext, + } + } + + pub(crate) fn tls_connector(&self) -> ConnectionTls { + match &self.transport { + Transport::Tls { connector, .. } => ConnectionTls::Rustls(connector.clone()), + #[cfg(feature = "postgres-test")] + Transport::TestOnlyPlaintext => ConnectionTls::TestOnlyPlaintext, + } + } + + pub fn build_pool(&self) -> Result { + let manager_config = ManagerConfig { + recycling_method: RecyclingMethod::Verified, + }; + let manager = match self.tls_connector() { + ConnectionTls::Rustls(connector) => { + Manager::from_config(self.postgres.clone(), connector, manager_config) + } + #[cfg(feature = "postgres-test")] + ConnectionTls::TestOnlyPlaintext => { + Manager::from_config(self.postgres.clone(), NoTls, manager_config) + } + }; + let pool = Pool::builder(manager) + .max_size(self.pool_bounds.max_size) + .wait_timeout(Some(self.pool_bounds.wait_timeout)) + .create_timeout(Some(self.pool_bounds.create_timeout)) + .recycle_timeout(Some(self.pool_bounds.recycle_timeout)) + .runtime(Runtime::Tokio1) + .build() + .map_err(|_| PostgresKernelError::PoolBuild)?; + Ok(RuntimePool { pool }) + } +} + +fn parse_connection(url: &str) -> Result { + if url.trim().is_empty() { + return Err(PostgresKernelError::Configuration( + "database URL must not be empty", + )); + } + Config::from_str(url).map_err(|_| PostgresKernelError::Configuration("database URL is invalid")) +} + +pub(crate) fn ensure_crypto_provider() -> Result<()> { + let provider = rustls::crypto::ring::default_provider(); + if provider.install_default().is_err() + && rustls::crypto::CryptoProvider::get_default().is_none() + { + return Err(PostgresKernelError::Configuration( + "Rustls crypto provider could not be installed", + )); + } + Ok(()) +} + +fn native_roots_connector() -> Result { + ensure_crypto_provider()?; + MakeRustlsConnect::with_native_certs() + .map(|(connector, _certificate_errors)| connector) + .map_err(|_| { + PostgresKernelError::Configuration("native TLS certificate roots are unavailable") + }) +} + +fn custom_ca_connector(ca_der: &[u8]) -> Result { + if ca_der.is_empty() || ca_der.len() > MAX_CUSTOM_CA_DER_BYTES { + return Err(PostgresKernelError::Configuration( + "custom CA DER must be between 1 byte and 1 MiB", + )); + } + ensure_crypto_provider()?; + let mut roots = rustls::RootCertStore::empty(); + roots + .add(rustls::pki_types::CertificateDer::from(ca_der.to_vec())) + .map_err(|_| PostgresKernelError::Configuration("custom CA DER is invalid"))?; + let client = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + Ok(MakeRustlsConnect::new(client)) +} + +/// Runtime pool configured with verified recycling and finite acquisition bounds. +#[derive(Clone)] +pub struct RuntimePool { + pool: Pool, +} + +impl RuntimePool { + pub(crate) async fn get(&self) -> Result { + self.pool.get().await.map_err(|_| PostgresKernelError::Pool) + } + + pub fn status(&self) -> deadpool_postgres::Status { + self.pool.status() + } + + pub async fn startup_probe(&self) -> Result<()> { + let client = self.get().await?; + client.simple_query("SELECT 1").await?; + Ok(()) + } + + #[cfg(any(feature = "postgres-test", feature = "postgres-tls-test"))] + #[doc(hidden)] + pub async fn get_for_test(&self) -> Result { + self.get().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_bounds() -> PoolBounds { + PoolBounds::new( + 1, + Duration::from_secs(1), + Duration::from_secs(1), + Duration::from_secs(1), + ) + .expect("bounds are valid") + } + + #[test] + fn pool_bounds_refuse_unbounded_or_zero_values() { + assert!(PoolBounds::new( + 0, + Duration::from_secs(1), + Duration::from_secs(1), + Duration::from_secs(1) + ) + .is_err()); + assert!(PoolBounds::new( + 129, + Duration::from_secs(1), + Duration::from_secs(1), + Duration::from_secs(1) + ) + .is_err()); + assert!(PoolBounds::new( + 1, + Duration::ZERO, + Duration::from_secs(1), + Duration::from_secs(1) + ) + .is_err()); + assert!(PoolBounds::new( + 1, + Duration::from_secs(1), + Duration::from_secs(61), + Duration::from_secs(1) + ) + .is_err()); + } + + #[cfg(feature = "postgres-test")] + #[test] + fn connection_debug_never_contains_database_secrets() { + let config = ConnectionConfig::test_only_plaintext( + "postgresql://secret_user:secret_password@127.0.0.1/secret_database", + valid_bounds(), + ) + .expect("test connection configuration parses"); + let debug = format!("{config:?}"); + for secret in ["secret_user", "secret_password", "secret_database"] { + assert!(!debug.contains(secret)); + } + } + + #[test] + fn custom_ca_refuses_empty_invalid_or_oversized_der() { + let url = "postgresql://registry_runtime@registry.example/registry"; + assert!(ConnectionConfig::require_tls_with_custom_ca(url, &[], valid_bounds()).is_err()); + assert!( + ConnectionConfig::require_tls_with_custom_ca(url, &[1, 2, 3], valid_bounds()).is_err() + ); + + let oversized = vec![0; MAX_CUSTOM_CA_DER_BYTES + 1]; + assert!( + ConnectionConfig::require_tls_with_custom_ca(url, &oversized, valid_bounds()).is_err() + ); + } +} diff --git a/crates/registry-server/src/postgres/context.rs b/crates/registry-server/src/postgres/context.rs new file mode 100644 index 0000000000..2ca1c7d903 --- /dev/null +++ b/crates/registry-server/src/postgres/context.rs @@ -0,0 +1,722 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; +use std::fmt; +use std::time::Duration; + +use deadpool_postgres::{Client, Transaction}; +use registry_platform_canonical_json::canonicalize_json; +use serde_json::{json, Value}; + +use crate::contract::BoundaryOperator; +use crate::data::{validate_field_value as validate_data_field_value, FieldValue}; +use crate::model::CompiledRegistry; + +use super::{ExpectedRegistryIdentity, PostgresKernelError, RegistryLockKey, Result}; + +const MAX_CONTEXT_VALUE_BYTES: usize = 512; +const MAX_BOUNDARY_SET_VALUES: usize = 64; +const MAX_BOUNDARY_CONTEXT_BYTES: usize = 64 * 1024; +const MAX_ENTITY_ID_BYTES: usize = 256; + +/// One finite compiler-validated row boundary installed into PostgreSQL. +#[derive(Clone, Eq, PartialEq)] +pub enum RowBoundaryContext { + Equals { + field: String, + value: String, + }, + In { + field: String, + values: BTreeSet, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RowBoundaryOperator { + Equals, + In, +} + +impl RowBoundaryOperator { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Equals => "equals", + Self::In => "in", + } + } +} + +impl RowBoundaryContext { + #[must_use] + pub fn field(&self) -> &str { + match self { + Self::Equals { field, .. } | Self::In { field, .. } => field, + } + } + + #[must_use] + pub fn operator(&self) -> RowBoundaryOperator { + match self { + Self::Equals { .. } => RowBoundaryOperator::Equals, + Self::In { .. } => RowBoundaryOperator::In, + } + } + + #[must_use] + pub fn values(&self) -> Vec<&str> { + match self { + Self::Equals { value, .. } => vec![value], + Self::In { values, .. } => values.iter().map(String::as_str).collect(), + } + } +} + +impl fmt::Debug for RowBoundaryContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RowBoundaryContext") + .field("field", &self.field()) + .field("operator", &self.operator()) + .field("values", &"") + .finish() + } +} + +/// Complete verified authority installed into one PostgreSQL transaction. +/// +/// Production construction is possible only against an exact compiled entity +/// and access profile. Raw tokens, headers, query values, and dynamic setting +/// names never enter this type. +#[derive(Clone, Eq, PartialEq)] +pub struct ClaimContext { + entity_id: String, + principal: Option, + access_profile: String, + purpose: Option, + row_boundaries: Vec, + canonical_row_boundaries: String, +} + +impl ClaimContext { + pub fn for_compiled( + registry: &CompiledRegistry, + entity_id: &str, + principal: Option, + access_profile: &str, + purpose: Option, + row_boundaries: Vec, + ) -> Result { + if entity_id.is_empty() || entity_id.len() > MAX_ENTITY_ID_BYTES { + return Err(invalid_context()); + } + let entity = registry + .entities() + .get(entity_id) + .ok_or_else(invalid_context)?; + let profile = entity + .access_profiles + .get(access_profile) + .ok_or_else(invalid_context)?; + validate_required_context_value(access_profile)?; + principal + .as_deref() + .map(validate_required_context_value) + .transpose()?; + purpose + .as_deref() + .map(validate_required_context_value) + .transpose()?; + if !profile.anonymous && principal.is_none() { + return Err(invalid_context()); + } + if !profile.required_purposes.is_empty() + && !purpose + .as_ref() + .is_some_and(|value| profile.required_purposes.contains(value)) + { + return Err(invalid_context()); + } + if row_boundaries.len() != profile.row_boundaries.len() { + return Err(invalid_context()); + } + for (actual, expected) in row_boundaries.iter().zip(&profile.row_boundaries) { + let expected_operator = match expected.operator { + BoundaryOperator::Equals => RowBoundaryOperator::Equals, + BoundaryOperator::In => RowBoundaryOperator::In, + }; + if actual.field() != expected.field || actual.operator() != expected_operator { + return Err(invalid_context()); + } + validate_boundary(actual)?; + let field = entity + .fields + .get(&expected.field) + .ok_or_else(invalid_context)?; + for value in actual.values() { + validate_field_value(value, &field.field_type)?; + } + } + let canonical_row_boundaries = canonical_boundaries(&row_boundaries)?; + Ok(Self { + entity_id: entity_id.to_owned(), + principal, + access_profile: access_profile.to_owned(), + purpose, + row_boundaries, + canonical_row_boundaries, + }) + } + + #[cfg(feature = "postgres-test")] + #[doc(hidden)] + pub fn kernel_for_test( + principal: String, + access_profile: String, + purpose: Option, + authority: String, + ) -> Result { + validate_required_context_value(&principal)?; + validate_required_context_value(&access_profile)?; + purpose + .as_deref() + .map(validate_required_context_value) + .transpose()?; + validate_required_context_value(&authority)?; + let row_boundaries = vec![RowBoundaryContext::Equals { + field: "authority".to_owned(), + value: authority, + }]; + let canonical_row_boundaries = canonical_boundaries(&row_boundaries)?; + Ok(Self { + entity_id: "kernel_records".to_owned(), + principal: Some(principal), + access_profile, + purpose, + row_boundaries, + canonical_row_boundaries, + }) + } + + #[must_use] + pub fn entity_id(&self) -> &str { + &self.entity_id + } + + #[must_use] + pub fn principal(&self) -> Option<&str> { + self.principal.as_deref() + } + + #[must_use] + pub fn access_profile(&self) -> &str { + &self.access_profile + } + + #[must_use] + pub fn purpose(&self) -> Option<&str> { + self.purpose.as_deref() + } + + #[must_use] + pub fn row_boundaries(&self) -> &[RowBoundaryContext] { + &self.row_boundaries + } + + pub fn validate(&self) -> Result<()> { + validate_required_context_value(&self.entity_id)?; + self.principal + .as_deref() + .map(validate_required_context_value) + .transpose()?; + validate_required_context_value(&self.access_profile)?; + self.purpose + .as_deref() + .map(validate_required_context_value) + .transpose()?; + for boundary in &self.row_boundaries { + validate_boundary(boundary)?; + } + if canonical_boundaries(&self.row_boundaries)? != self.canonical_row_boundaries { + return Err(invalid_context()); + } + Ok(()) + } +} + +impl fmt::Debug for ClaimContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClaimContext") + .field("entity_id", &self.entity_id) + .field("principal", &self.principal.as_ref().map(|_| "")) + .field("access_profile", &self.access_profile) + .field("purpose", &self.purpose.as_ref().map(|_| "")) + .field("row_boundaries", &self.row_boundaries) + .finish() + } +} + +fn validate_boundary(boundary: &RowBoundaryContext) -> Result<()> { + validate_required_context_value(boundary.field())?; + let values = boundary.values(); + if values.is_empty() + || values.len() > MAX_BOUNDARY_SET_VALUES + || values + .iter() + .any(|value| validate_required_context_value(value).is_err()) + { + return Err(invalid_context()); + } + Ok(()) +} + +fn validate_required_context_value(value: &str) -> Result<()> { + if value.is_empty() + || value.len() > MAX_CONTEXT_VALUE_BYTES + || value.chars().any(char::is_control) + { + return Err(invalid_context()); + } + Ok(()) +} + +pub(crate) fn validate_field_value( + value: &str, + field_type: &crate::contract::FieldTypeSource, +) -> Result<()> { + if !validate_data_field_value(FieldValue::Text(value), field_type) { + return Err(invalid_context()); + } + Ok(()) +} + +fn canonical_boundaries(boundaries: &[RowBoundaryContext]) -> Result { + let value = Value::Array( + boundaries + .iter() + .map(|boundary| { + json!({ + "field": boundary.field(), + "operator": boundary.operator().as_str(), + "values": boundary.values(), + }) + }) + .collect(), + ); + let bytes = canonicalize_json(&value).map_err(|_| invalid_context())?; + if bytes.len() > MAX_BOUNDARY_CONTEXT_BYTES { + return Err(invalid_context()); + } + String::from_utf8(bytes).map_err(|_| invalid_context()) +} + +fn invalid_context() -> PostgresKernelError { + PostgresKernelError::Configuration("verified database context is incomplete or invalid") +} + +/// A record transaction that has passed maintenance, package, and claim gates. +pub struct GuardedTransaction<'a> { + transaction: Transaction<'a>, +} + +impl GuardedTransaction<'_> { + #[allow( + dead_code, + reason = "trusted transaction modules consume this crate-private handle" + )] + pub(crate) fn transaction(&self) -> &tokio_postgres::Transaction<'_> { + &self.transaction + } + + #[cfg(feature = "postgres-test")] + #[doc(hidden)] + pub fn transaction_for_test(&self) -> &tokio_postgres::Transaction<'_> { + self.transaction() + } + + pub async fn commit(self) -> Result<()> { + self.transaction.commit().await?; + Ok(()) + } + + pub async fn rollback(self) -> Result<()> { + self.transaction.rollback().await?; + Ok(()) + } +} + +/// Starts a record transaction and installs authority only after the shared +/// Registry lock and exact active-package checks succeed. +pub async fn begin_record_transaction<'a>( + client: &'a mut Client, + lock_key: RegistryLockKey, + lock_timeout: Duration, + expected: &ExpectedRegistryIdentity, + claims: &ClaimContext, +) -> Result> { + expected.validate()?; + claims.validate()?; + if lock_timeout.is_zero() || lock_timeout > Duration::from_secs(30) { + return Err(PostgresKernelError::Configuration( + "record lock timeout must be between 1 millisecond and 30 seconds", + )); + } + let transaction = client.transaction().await?; + let timeout_millis = i32::try_from(lock_timeout.as_millis()).map_err(|_| { + PostgresKernelError::Configuration("record lock timeout is outside PostgreSQL bounds") + })?; + transaction + .execute( + "SELECT set_config('lock_timeout', $1::text, true)", + &[&format!("{timeout_millis}ms")], + ) + .await?; + transaction + .execute( + "SELECT pg_advisory_xact_lock_shared($1)", + &[&lock_key.get()], + ) + .await + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + let state = transaction + .query_opt( + "SELECT package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await? + .ok_or(PostgresKernelError::RegistryUnavailable)?; + let ready = state.get::<_, String>(7) == "ready" + && state.get::<_, String>(0) == expected.package_id + && state.get::<_, String>(1) == expected.environment + && state.get::<_, String>(2) == expected.instance_id + && state.get::<_, String>(3) == expected.database_id + && state.get::<_, String>(4) == expected.package_revision + && state.get::<_, String>(5) == expected.schema_fingerprint + && state.get::<_, i64>(6) == expected.package_sequence; + if !ready { + return Err(PostgresKernelError::RegistryUnavailable); + } + transaction + .execute( + "SELECT set_config('registry.principal', $1, true), + set_config('registry.access_profile', $2, true), + set_config('registry.purpose', $3, true), + set_config('registry.row_boundaries', $4, true), + set_config('registry.active_package_revision', $5, true)", + &[ + &claims.principal.as_deref().unwrap_or(""), + &claims.access_profile, + &claims.purpose.as_deref().unwrap_or(""), + &claims.canonical_row_boundaries, + &expected.package_revision, + ], + ) + .await?; + Ok(GuardedTransaction { transaction }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use crate::compiler::{compile_project, CompileProfile}; + use crate::contract::{ + parse_project_json, AccessProfileSource, Classification, EntitySource, FieldSource, + FieldTypeSource, MutationMode, Operation, RegistryProject, RowBoundarySource, + }; + + use super::*; + + #[test] + fn compiled_context_is_exact_bounded_and_value_redacted() { + let registry = compiled_registry(); + let boundaries = vec![ + RowBoundaryContext::Equals { + field: "tenant".to_owned(), + value: "tenant-a".to_owned(), + }, + RowBoundaryContext::In { + field: "region".to_owned(), + values: BTreeSet::from(["north".to_owned(), "south".to_owned()]), + }, + ]; + let context = ClaimContext::for_compiled( + ®istry, + "entry", + Some("principal-canary".to_owned()), + "operator", + Some("operations".to_owned()), + boundaries.clone(), + ) + .expect("exact compiled context is accepted"); + assert_eq!(context.entity_id(), "entry"); + assert_eq!(context.access_profile(), "operator"); + assert_eq!(context.row_boundaries(), boundaries); + assert_eq!( + context.canonical_row_boundaries, + r#"[{"field":"tenant","operator":"equals","values":["tenant-a"]},{"field":"region","operator":"in","values":["north","south"]}]"# + ); + let debug = format!("{context:?}"); + assert!(!debug.contains("principal-canary")); + assert!(!debug.contains("tenant-a")); + + assert!(ClaimContext::for_compiled( + ®istry, + "entry", + None, + "operator", + Some("operations".to_owned()), + boundaries.clone(), + ) + .is_err()); + assert!(ClaimContext::for_compiled( + ®istry, + "entry", + Some("principal".to_owned()), + "operator", + Some("wrong".to_owned()), + boundaries.clone(), + ) + .is_err()); + assert!(ClaimContext::for_compiled( + ®istry, + "entry", + Some("principal".to_owned()), + "operator", + Some("operations".to_owned()), + boundaries.into_iter().rev().collect(), + ) + .is_err()); + } + + #[test] + fn compiled_context_rejects_every_noncanonical_field_value_before_postgres() { + let registry = compiled_typed_registry(); + let valid = typed_boundaries(); + ClaimContext::for_compiled( + ®istry, + "typed-entry", + Some("principal".to_owned()), + "typed", + None, + valid.clone(), + ) + .expect("canonical values for every compiled field type are accepted"); + + let invalid = [ + (0, equals("enabled", "TRUE")), + (1, in_values("count", &["01"])), + (1, in_values("count", &["9223372036854775808"])), + (2, equals("amount", "01.20")), + (2, equals("amount", "10.00")), + (3, equals("effective-on", "2023-02-29")), + (4, in_values("observed-at", &["2024-01-02 03:04:05+00"])), + (5, equals("identifier", "123e4567e89b12d3a456426614174000")), + ( + 5, + equals("identifier", "123E4567-E89B-12D3-A456-426614174000"), + ), + ( + 6, + in_values("parent", &["urn:uuid:123e4567-e89b-12d3-a456-426614174000"]), + ), + ( + 6, + in_values("parent", &["123E4567-E89B-12D3-A456-426614174000"]), + ), + (7, equals("short-name", "abcde")), + (8, in_values("notes", &["1234567"])), + (9, equals("color", "green")), + ]; + for (index, replacement) in invalid { + let mut boundaries = valid.clone(); + boundaries[index] = replacement; + let error = ClaimContext::for_compiled( + ®istry, + "typed-entry", + Some("principal".to_owned()), + "typed", + None, + boundaries, + ) + .expect_err("noncanonical typed value must be refused before a transaction"); + assert_eq!( + error.to_string(), + "invalid PostgreSQL configuration: verified database context is incomplete or invalid" + ); + assert!(!error.to_string().contains("green")); + } + } + + fn equals(field: &str, value: &str) -> RowBoundaryContext { + RowBoundaryContext::Equals { + field: field.to_owned(), + value: value.to_owned(), + } + } + + fn in_values(field: &str, values: &[&str]) -> RowBoundaryContext { + RowBoundaryContext::In { + field: field.to_owned(), + values: values.iter().map(|value| (*value).to_owned()).collect(), + } + } + + fn typed_boundaries() -> Vec { + vec![ + equals("enabled", "true"), + in_values("count", &["-1", "2"]), + equals("amount", "1.20"), + equals("effective-on", "2024-02-29"), + in_values("observed-at", &["2024-01-02T03:04:05Z"]), + equals("identifier", "123e4567-e89b-12d3-a456-426614174000"), + in_values("parent", &["123e4567-e89b-12d3-a456-426614174001"]), + equals("short-name", "abcd"), + in_values("notes", &["abcdef"]), + equals("color", "red"), + ] + } + + fn compiled_typed_registry() -> CompiledRegistry { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"typed-context","version":"1","defaultLanguage":"en"}, + "entities":[ + { + "id":"parent-entry","route":"parents","mutationMode":"mutable", + "fields":[{"id":"name","type":"string","minLength":1,"maxLength":8,"required":true,"classification":"internal"}], + "accessProfiles":[{ + "id":"typed","default":true,"principalClaim":"registry_principal", + "operations":["get"],"readableFields":["name"] + }] + }, + { + "id":"typed-entry","route":"typed","mutationMode":"mutable", + "fields":[ + {"id":"enabled","type":"boolean","required":true,"classification":"internal"}, + {"id":"count","type":"int64","required":true,"classification":"internal"}, + {"id":"amount","type":"decimal","precision":4,"scale":2,"minimum":"0.00","maximum":"9.99","required":true,"classification":"internal"}, + {"id":"effective-on","type":"date","required":true,"classification":"internal"}, + {"id":"observed-at","type":"timestamp","required":true,"classification":"internal"}, + {"id":"identifier","type":"uuid","required":true,"classification":"internal"}, + {"id":"parent","type":"reference","target":"parent-entry","required":true,"classification":"internal"}, + {"id":"short-name","type":"string","minLength":1,"maxLength":4,"required":true,"classification":"internal"}, + {"id":"notes","type":"text","maxLength":6,"required":true,"classification":"internal"}, + {"id":"color","type":"vocabulary-code","vocabulary":"colors","required":true,"classification":"internal"} + ], + "accessProfiles":[{ + "id":"typed","default":true,"principalClaim":"registry_principal", + "operations":["get"], + "readableFields":["enabled","count","amount","effective-on","observed-at","identifier","parent","short-name","notes","color"], + "rowBoundaries":[ + {"field":"enabled","claim":"enabled_claim","operator":"equals"}, + {"field":"count","claim":"count_claim","operator":"in"}, + {"field":"amount","claim":"amount_claim","operator":"equals"}, + {"field":"effective-on","claim":"date_claim","operator":"equals"}, + {"field":"observed-at","claim":"timestamp_claim","operator":"in"}, + {"field":"identifier","claim":"uuid_claim","operator":"equals"}, + {"field":"parent","claim":"reference_claim","operator":"in"}, + {"field":"short-name","claim":"string_claim","operator":"equals"}, + {"field":"notes","claim":"text_claim","operator":"in"}, + {"field":"color","claim":"vocabulary_claim","operator":"equals"} + ] + }] + } + ], + "vocabularies":[{"id":"colors","values":["red","blue"]}] + }"#, + ) + .expect("typed context project parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("typed context project compiles") + } + + fn compiled_registry() -> CompiledRegistry { + let mut operations = BTreeSet::new(); + operations.insert(Operation::Get); + let project = RegistryProject { + api_version: crate::compiler::AUTHORING_API_VERSION.to_owned(), + kind: "RegistryProject".to_owned(), + registry: crate::contract::RegistryIdentitySource { + id: "context-test".to_owned(), + version: "0.1.0".to_owned(), + default_language: "en".to_owned(), + }, + package: None, + manifest_projection: None, + modules: Vec::new(), + entities: vec![EntitySource { + id: "entry".to_owned(), + route: "entries".to_owned(), + mutation_mode: MutationMode::Mutable, + tombstone: false, + batch: None, + classification: Classification::Internal, + fields: vec![ + FieldSource { + id: "tenant".to_owned(), + field_type: FieldTypeSource::String { + min_length: 1, + max_length: 64, + }, + required: true, + classification: Classification::Internal, + valid_time_role: None, + }, + FieldSource { + id: "region".to_owned(), + field_type: FieldTypeSource::String { + min_length: 1, + max_length: 64, + }, + required: true, + classification: Classification::Internal, + valid_time_role: None, + }, + ], + constraints: Vec::new(), + temporal: None, + indexes: Vec::new(), + access_profiles: vec![AccessProfileSource { + id: "operator".to_owned(), + default: true, + anonymous: false, + principal_claim: Some("registry_principal".to_owned()), + required_scopes: BTreeSet::new(), + required_purposes: BTreeSet::from(["operations".to_owned()]), + operations, + readable_fields: BTreeSet::from(["tenant".to_owned(), "region".to_owned()]), + writable_fields: BTreeSet::new(), + filterable_fields: BTreeSet::new(), + sortable_fields: BTreeSet::new(), + row_boundaries: vec![ + RowBoundarySource { + field: "tenant".to_owned(), + claim: "tenant_claim".to_owned(), + operator: BoundaryOperator::Equals, + }, + RowBoundarySource { + field: "region".to_owned(), + claim: "region_claim".to_owned(), + operator: BoundaryOperator::In, + }, + ], + revision_access: false, + allow_data_export: false, + }], + events: Vec::new(), + }], + access_profiles: Vec::new(), + vocabularies: Vec::new(), + }; + compile_project(&project, &[], CompileProfile::Authoring).expect("test project compiles") + } +} diff --git a/crates/registry-server/src/postgres/interlock.rs b/crates/registry-server/src/postgres/interlock.rs new file mode 100644 index 0000000000..b5738f3af5 --- /dev/null +++ b/crates/registry-server/src/postgres/interlock.rs @@ -0,0 +1,1840 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::{collections::BTreeSet, time::Duration}; + +use sha2::{Digest, Sha256}; +use tokio::task::JoinHandle; +#[cfg(feature = "postgres-test")] +use tokio_postgres::NoTls; +use tokio_postgres::{Client, GenericClient}; +use uuid::Uuid; + +use crate::generated_ddl::DdlStatementKind; +use crate::migration_plan::{ + AffectedRowBounds, ReviewedMigrationStepDescriptor, ValidatedReviewedMigrationAssertion, + ValidatedReviewedMigrationPlan, ValidatedReviewedMigrationStep, +}; +use crate::model::CompiledRegistry; +use crate::mutation::install_mutation_schema; + +use super::{ + catalog::{install_registry_state_schema, verify_managed_catalog, ExpectedManagedCatalog}, + config::ConnectionTls, + migration_ledger::{ + migration_phase_state, record_applied, record_chunk_progress, record_failed, + record_postconditions_complete, record_preconditions_complete, record_started, + record_step_complete, statement_checksum, step_progress, verify_resumable, + MigrationLedgerEntry, MigrationLedgerStep, MigrationLedgerStepKind, + }, + schema::reconcile_compiled_runtime_acl, + verify_btree_gist, verify_migration_role, ConnectionConfig, ExpectedRegistryIdentity, + PostgresKernelError, Result, SqlIdentifier, +}; + +// These defense-in-depth bounds match the verified package manifest envelope: +// at most 1,024 migration statements inside at most 4 MiB of manifest bytes. +const MAX_VERIFIED_DDL_STATEMENTS: usize = 1024; +const MAX_VERIFIED_DDL_STATEMENT_BYTES: usize = 4 * 1024 * 1024; +const MAX_VERIFIED_DDL_STATEMENT_TIMEOUT: Duration = Duration::from_secs(60 * 60); + +pub(crate) struct PackageDdlStatement<'a> { + pub sql: &'a str, + pub checksum: &'a str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ReviewedExecutionOutcome { + Complete, + Interrupted, +} + +pub(crate) struct ReviewedPackageExecutionRequest<'a> { + pub registry: &'a CompiledRegistry, + pub plan: &'a ValidatedReviewedMigrationPlan, + pub compiler_statements: &'a [PackageDdlStatement<'a>], + pub ledger: &'a MigrationLedgerEntry, + pub prior_tables: &'a [String], + pub candidate_tables: &'a [String], + pub compiler_lock_timeout: Duration, + pub compiler_statement_timeout: Duration, + pub fault_after_committed_chunks: Option, +} + +struct ReviewedChunkExecutionRequest<'a> { + step: &'a ValidatedReviewedMigrationStep, + ledger: &'a MigrationLedgerEntry, + ledger_step: &'a MigrationLedgerStep, + table: &'a str, + chunk_size: u32, + max_total_rows: u64, + lock_timeout_ms: u64, + statement_timeout_ms: u64, +} + +/// Stable Registry-scoped PostgreSQL advisory lock key. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RegistryLockKey(i64); + +impl RegistryLockKey { + pub fn derive(registry_id: &str) -> Result { + if registry_id.is_empty() || registry_id.len() > 255 { + return Err(PostgresKernelError::Configuration( + "Registry id is missing or outside its bound", + )); + } + let digest = + Sha256::digest([b"registry-server/advisory-lock/v1/", registry_id.as_bytes()].concat()); + let mut bytes = [0_u8; 8]; + bytes.copy_from_slice(&digest[..8]); + Ok(Self(i64::from_be_bytes(bytes))) + } + + pub fn get(self) -> i64 { + self.0 + } +} + +/// One unpooled connection holding the session-level exclusive apply lock. +pub struct DedicatedApplyConnection { + client: Client, + connection_task: JoinHandle<()>, + lock_key: RegistryLockKey, + locked: bool, + verified_migration_role: bool, +} + +impl DedicatedApplyConnection { + #[cfg(feature = "postgres-test")] + pub async fn acquire( + config: &ConnectionConfig, + lock_key: RegistryLockKey, + lock_timeout: Duration, + ) -> Result { + Self::acquire_inner(config, lock_key, lock_timeout, None, None).await + } + + /// Acquires the product apply connection only after proving that it uses + /// the exact configured migration role. Role verification deliberately + /// precedes the maintenance transition, control-plane bootstrap, and DDL. + pub(crate) async fn acquire_for_verified_package( + config: &ConnectionConfig, + lock_key: RegistryLockKey, + migration_role: &SqlIdentifier, + lock_timeout: Duration, + statement_timeout: Duration, + ) -> Result { + Self::acquire_inner( + config, + lock_key, + lock_timeout, + Some(migration_role), + Some(statement_timeout), + ) + .await + } + + async fn acquire_inner( + config: &ConnectionConfig, + lock_key: RegistryLockKey, + lock_timeout: Duration, + migration_role: Option<&SqlIdentifier>, + statement_timeout: Option, + ) -> Result { + validate_timeout( + lock_timeout, + Duration::from_secs(300), + "apply lock timeout must be between 1 millisecond and 5 minutes", + )?; + if let Some(timeout) = statement_timeout { + validate_timeout( + timeout, + MAX_VERIFIED_DDL_STATEMENT_TIMEOUT, + "verified DDL statement timeout must be between 1 millisecond and 1 hour", + )?; + } + let (client, connection_task) = connect_dedicated(config).await?; + if let Some(role) = migration_role { + verify_migration_role(&client, role).await?; + } + client + .execute( + "SELECT pg_catalog.set_config('search_path', + 'pg_catalog, registry_internal, registry_data, pg_temp', false)", + &[], + ) + .await?; + set_session_timeout(&client, "lock_timeout", lock_timeout).await?; + if let Some(timeout) = statement_timeout { + set_session_timeout(&client, "statement_timeout", timeout).await?; + } + client + .execute("SELECT pg_catalog.pg_advisory_lock($1)", &[&lock_key.get()]) + .await + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + Ok(Self { + client, + connection_task, + lock_key, + locked: true, + verified_migration_role: migration_role.is_some(), + }) + } + + /// Executes already package-verified DDL atomically while retaining the + /// dedicated session-level apply lock. + #[cfg(any(test, feature = "postgres-test"))] + #[allow(dead_code)] + pub(crate) async fn execute_verified_ddl( + &mut self, + statements: &[&str], + statement_timeout: Duration, + ) -> Result<()> { + validate_verified_ddl_request(self.locked, statements, statement_timeout)?; + let transaction = self.client.transaction().await?; + let timeout_millis = u64::try_from(statement_timeout.as_millis()).map_err(|_| { + PostgresKernelError::Configuration( + "verified DDL statement timeout is outside PostgreSQL bounds", + ) + })?; + transaction + .execute( + "SELECT set_config('statement_timeout', $1, true)", + &[&format!("{timeout_millis}ms")], + ) + .await?; + for statement in statements { + if transaction.batch_execute(statement).await.is_err() { + transaction.rollback().await?; + return Err(PostgresKernelError::Connection); + } + } + transaction.commit().await?; + Ok(()) + } + + /// Executes only the ordered, checksum-bound successor statements carried + /// by a verified package. No caller SQL enters this path. + pub(crate) async fn execute_successor_package_ddl( + &mut self, + statements: &[PackageDdlStatement<'_>], + statement_timeout: Duration, + ) -> Result<()> { + ensure_verified_package_session(self.locked, self.verified_migration_role)?; + validate_package_ddl(statements, statement_timeout)?; + let transaction = self.client.transaction().await?; + set_local_statement_timeout(&transaction, statement_timeout).await?; + for statement in statements { + validate_statement_checksum(statement)?; + if transaction.batch_execute(statement.sql).await.is_err() { + transaction.rollback().await?; + return Err(PostgresKernelError::Connection); + } + } + transaction.commit().await?; + Ok(()) + } + + /// Executes the AST-validated reviewed plan and only its package-derived + /// compiler DDL. Every durable checkpoint is committed with the exact + /// chunk update that advances it. + pub(crate) async fn execute_reviewed_package_plan( + &mut self, + request: ReviewedPackageExecutionRequest<'_>, + ) -> Result { + let ReviewedPackageExecutionRequest { + registry, + plan, + compiler_statements, + ledger, + prior_tables, + candidate_tables, + compiler_lock_timeout, + compiler_statement_timeout, + fault_after_committed_chunks, + } = request; + ensure_verified_package_session(self.locked, self.verified_migration_role)?; + ledger.validate()?; + if plan.migrations().is_empty() { + return Err(PostgresKernelError::RegistryUnavailable); + } + + self.execute_reviewed_assertion_phase(plan, ledger, prior_tables, false) + .await?; + self.execute_reviewed_compiler_steps( + compiler_statements, + ledger, + compiler_lock_timeout, + compiler_statement_timeout, + ) + .await?; + + let mut committed_chunks = 0_u64; + for (migration_index, migration) in plan.migrations().iter().enumerate() { + let migration_ordinal = i32::try_from(migration_index + 1) + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + for (step_index, step) in migration.steps.iter().enumerate() { + let step_ordinal = i32::try_from(step_index) + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + let ledger_step = ledger_step(ledger, migration_ordinal, step_ordinal)?; + match &step.descriptor { + ReviewedMigrationStepDescriptor::TransactionalSql { affected_rows, .. } => { + if ledger_step.kind != MigrationLedgerStepKind::TransactionalSql { + return Err(PostgresKernelError::RegistryUnavailable); + } + self.execute_reviewed_transactional_step( + step, + affected_rows.as_ref(), + ledger, + ledger_step, + migration.descriptor.lock_timeout_ms, + migration.descriptor.statement_timeout_ms, + ) + .await?; + } + ReviewedMigrationStepDescriptor::ChunkedBackfill { + entity_id, + chunk_size, + max_total_rows, + lock_timeout_ms, + statement_timeout_ms, + .. + } => { + if ledger_step.kind != MigrationLedgerStepKind::ChunkedBackfill { + return Err(PostgresKernelError::RegistryUnavailable); + } + let table = ®istry + .entities() + .get(entity_id) + .ok_or(PostgresKernelError::RegistryUnavailable)? + .physical_table; + loop { + let advanced = self + .execute_reviewed_chunk(ReviewedChunkExecutionRequest { + step, + ledger, + ledger_step, + table, + chunk_size: *chunk_size, + max_total_rows: *max_total_rows, + lock_timeout_ms: *lock_timeout_ms, + statement_timeout_ms: *statement_timeout_ms, + }) + .await?; + if !advanced { + break; + } + committed_chunks = committed_chunks + .checked_add(1) + .ok_or(PostgresKernelError::RegistryUnavailable)?; + if fault_after_committed_chunks == Some(committed_chunks) { + return Ok(ReviewedExecutionOutcome::Interrupted); + } + } + } + } + } + } + + self.execute_reviewed_assertion_phase(plan, ledger, candidate_tables, true) + .await?; + Ok(ReviewedExecutionOutcome::Complete) + } + + async fn execute_reviewed_assertion_phase( + &mut self, + plan: &ValidatedReviewedMigrationPlan, + ledger: &MigrationLedgerEntry, + tables: &[String], + postconditions: bool, + ) -> Result<()> { + let transaction = self.client.transaction().await?; + let phase = migration_phase_state(&transaction, ledger).await?; + if if postconditions { + phase.postconditions_complete + } else { + phase.preconditions_complete + } { + transaction.commit().await?; + return Ok(()); + } + if postconditions && !phase.preconditions_complete { + return Err(PostgresKernelError::RegistryUnavailable); + } + + let first = plan + .migrations() + .first() + .ok_or(PostgresKernelError::RegistryUnavailable)?; + set_local_migration_timeouts( + &transaction, + first.descriptor.lock_timeout_ms, + first.descriptor.statement_timeout_ms, + ) + .await?; + set_force_row_security(&transaction, tables, false).await?; + for migration in plan.migrations() { + set_local_migration_timeouts( + &transaction, + migration.descriptor.lock_timeout_ms, + migration.descriptor.statement_timeout_ms, + ) + .await?; + let assertions = if postconditions { + &migration.post_assertions + } else { + &migration.pre_assertions + }; + for assertion in assertions { + execute_boolean_assertion(&transaction, assertion).await?; + } + } + set_force_row_security(&transaction, tables, true).await?; + if postconditions { + record_postconditions_complete(&transaction, ledger).await?; + } else { + record_preconditions_complete(&transaction, ledger).await?; + } + transaction.commit().await?; + Ok(()) + } + + async fn execute_reviewed_compiler_steps( + &mut self, + statements: &[PackageDdlStatement<'_>], + ledger: &MigrationLedgerEntry, + lock_timeout: Duration, + statement_timeout: Duration, + ) -> Result<()> { + validate_timeout( + lock_timeout, + Duration::from_secs(300), + "compiler DDL lock timeout is outside its bound", + )?; + validate_timeout( + statement_timeout, + MAX_VERIFIED_DDL_STATEMENT_TIMEOUT, + "compiler DDL statement timeout is outside its bound", + )?; + for (index, statement) in statements.iter().enumerate() { + validate_statement_checksum(statement)?; + let step_ordinal = + i32::try_from(index).map_err(|_| PostgresKernelError::RegistryUnavailable)?; + let ledger_step = ledger_step(ledger, 0, step_ordinal)?; + if ledger_step.kind != MigrationLedgerStepKind::CompilerDdl + || ledger_step.checksum != statement.checksum + { + return Err(PostgresKernelError::RegistryUnavailable); + } + let transaction = self.client.transaction().await?; + set_local_duration_timeouts(&transaction, lock_timeout, statement_timeout).await?; + if step_progress(&transaction, ledger, ledger_step) + .await? + .complete + { + transaction.commit().await?; + continue; + } + transaction + .batch_execute(statement.sql) + .await + .map_err(|_| PostgresKernelError::Connection)?; + record_step_complete(&transaction, ledger, ledger_step, 0).await?; + transaction.commit().await?; + } + Ok(()) + } + + async fn execute_reviewed_transactional_step( + &mut self, + step: &ValidatedReviewedMigrationStep, + affected_bounds: Option<&AffectedRowBounds>, + ledger: &MigrationLedgerEntry, + ledger_step: &MigrationLedgerStep, + lock_timeout_ms: u64, + statement_timeout_ms: u64, + ) -> Result<()> { + if step.sha256 != statement_checksum(&step.sql) || ledger_step.checksum != step.sha256 { + return Err(PostgresKernelError::RegistryUnavailable); + } + let transaction = self.client.transaction().await?; + set_local_migration_timeouts(&transaction, lock_timeout_ms, statement_timeout_ms).await?; + if step_progress(&transaction, ledger, ledger_step) + .await? + .complete + { + transaction.commit().await?; + return Ok(()); + } + + let affected = if let Some(bounds) = affected_bounds { + let tables = step_tables(step)?; + set_force_row_security(&transaction, &tables, false).await?; + let affected = transaction + .execute(&step.sql, &[]) + .await + .map_err(|_| PostgresKernelError::Connection)?; + set_force_row_security(&transaction, &tables, true).await?; + if affected < bounds.min || affected > bounds.max { + return Err(PostgresKernelError::RegistryUnavailable); + } + affected + } else { + transaction + .batch_execute(&step.sql) + .await + .map_err(|_| PostgresKernelError::Connection)?; + 0 + }; + record_step_complete(&transaction, ledger, ledger_step, affected).await?; + transaction.commit().await?; + Ok(()) + } + + async fn execute_reviewed_chunk( + &mut self, + request: ReviewedChunkExecutionRequest<'_>, + ) -> Result { + let ReviewedChunkExecutionRequest { + step, + ledger, + ledger_step, + table, + chunk_size, + max_total_rows, + lock_timeout_ms, + statement_timeout_ms, + } = request; + if step.sha256 != statement_checksum(&step.sql) + || ledger_step.checksum != step.sha256 + || chunk_size == 0 + || max_total_rows == 0 + { + return Err(PostgresKernelError::RegistryUnavailable); + } + let table = SqlIdentifier::parse(table)?; + let transaction = self.client.transaction().await?; + set_local_migration_timeouts(&transaction, lock_timeout_ms, statement_timeout_ms).await?; + let progress = step_progress(&transaction, ledger, ledger_step).await?; + if progress.complete { + transaction.commit().await?; + return Ok(false); + } + if progress.affected_rows > max_total_rows { + return Err(PostgresKernelError::RegistryUnavailable); + } + + set_force_row_security(&transaction, &[table.as_str().to_owned()], false).await?; + let limit = i64::from(chunk_size); + let select_sql = format!( + "SELECT record_id + FROM registry_data.{} + WHERE ($1::pg_catalog.uuid IS NULL OR record_id > $1) + ORDER BY record_id + LIMIT $2 + FOR UPDATE", + table.quoted() + ); + let rows = transaction + .query(&select_sql, &[&progress.checkpoint_record_id, &limit]) + .await + .map_err(|_| PostgresKernelError::Connection)?; + let ids = rows + .iter() + .map(|row| row.try_get::<_, Uuid>(0)) + .collect::, _>>() + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + if ids.is_empty() { + set_force_row_security(&transaction, &[table.as_str().to_owned()], true).await?; + record_step_complete(&transaction, ledger, ledger_step, progress.affected_rows).await?; + transaction.commit().await?; + return Ok(false); + } + let selected = + u64::try_from(ids.len()).map_err(|_| PostgresKernelError::RegistryUnavailable)?; + let total = progress + .affected_rows + .checked_add(selected) + .filter(|total| *total <= max_total_rows) + .ok_or(PostgresKernelError::RegistryUnavailable)?; + let affected = transaction + .execute(&step.sql, &[&ids]) + .await + .map_err(|_| PostgresKernelError::Connection)?; + set_force_row_security(&transaction, &[table.as_str().to_owned()], true).await?; + if affected != selected { + return Err(PostgresKernelError::RegistryUnavailable); + } + let checkpoint = ids + .last() + .copied() + .ok_or(PostgresKernelError::RegistryUnavailable)?; + record_chunk_progress(&transaction, ledger, ledger_step, checkpoint, total).await?; + transaction.commit().await?; + Ok(true) + } + + /// Installs the product-owned mutation tables and every compiler-produced + /// initial DDL statement in one bounded transaction. The state and ledger + /// control plane has already been committed before this method begins. + pub(crate) async fn execute_initial_package_ddl( + &mut self, + registry: &CompiledRegistry, + statements: &[PackageDdlStatement<'_>], + runtime_role: &SqlIdentifier, + statement_timeout: Duration, + ) -> Result<()> { + ensure_verified_package_session(self.locked, self.verified_migration_role)?; + validate_package_ddl(statements, statement_timeout)?; + if statements.len() != registry.ddl().statements.len() + || statements + .iter() + .zip(®istry.ddl().statements) + .any(|(package, compiled)| package.sql != compiled.sql) + { + return Err(PostgresKernelError::RegistryUnavailable); + } + let transaction = self.client.transaction().await?; + set_local_statement_timeout(&transaction, statement_timeout).await?; + if registry.ddl().requires_btree_gist { + verify_btree_gist(&transaction).await?; + } + install_mutation_schema(&transaction, runtime_role) + .await + .map_err(|_| PostgresKernelError::Connection)?; + for (statement, compiled) in statements.iter().zip(®istry.ddl().statements) { + validate_statement_checksum(statement)?; + // The two managed schemas are administrator-provisioned and owned + // by the migration role before apply. Requiring database CREATE + // here would violate that role boundary, so the exact compiler + // schema statement is checksum-validated above but not rerun. + if compiled.kind == DdlStatementKind::Schema { + continue; + } + if transaction.batch_execute(statement.sql).await.is_err() { + transaction.rollback().await?; + return Err(PostgresKernelError::Connection); + } + } + transaction.commit().await?; + Ok(()) + } + + /// Reconciles the exact compiler-owned runtime ACL inventory while the + /// dedicated session-level apply lock remains held. + pub(crate) async fn reconcile_runtime_acl( + &mut self, + registry: &CompiledRegistry, + runtime_role: &SqlIdentifier, + ) -> Result<()> { + validate_runtime_acl_reconciliation_request(self.locked)?; + let transaction = self.client.transaction().await?; + install_mutation_schema(&transaction, runtime_role) + .await + .map_err(|_| PostgresKernelError::Connection)?; + reconcile_compiled_runtime_acl(&transaction, registry, runtime_role).await?; + transaction.commit().await?; + Ok(()) + } + + /// Bootstraps only durable state and ledger structures, then records the + /// initial applying state before any entity or mutation DDL can run. + pub(crate) async fn begin_initial_package( + &mut self, + target: &ExpectedRegistryIdentity, + ledger: &MigrationLedgerEntry, + runtime_role: &SqlIdentifier, + ) -> Result<()> { + ensure_verified_package_session(self.locked, self.verified_migration_role)?; + target.validate()?; + ledger.validate()?; + if ledger.source_revision.is_some() + || ledger.target_revision != target.package_revision + || ledger.package_sequence != target.package_sequence + { + return Err(PostgresKernelError::Configuration( + "initial package and migration ledger differ", + )); + } + let transaction = self.client.transaction().await?; + install_registry_state_schema(&transaction, runtime_role).await?; + let changed = transaction + .execute( + "INSERT INTO registry_internal.registry_state ( + singleton, package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status, maintenance_target_revision + ) VALUES (true, $1, $2, $3, $4, $5, $6, $7, 'applying', $5) + ON CONFLICT (singleton) DO NOTHING", + &[ + &target.package_id, + &target.environment, + &target.instance_id, + &target.database_id, + &target.package_revision, + &target.schema_fingerprint, + &target.package_sequence, + ], + ) + .await?; + if changed == 1 { + record_started(&transaction, ledger).await?; + } else { + verify_initial_resumable_state(&transaction, target).await?; + verify_resumable(&transaction, ledger).await?; + } + transaction.commit().await?; + Ok(()) + } + + /// Records or resumes a successor only when the durable source identity, + /// exact target, ordered checksums, and package sequence all agree. + pub(crate) async fn begin_successor_package( + &mut self, + current: &ExpectedRegistryIdentity, + target: &ExpectedRegistryIdentity, + ledger: &MigrationLedgerEntry, + ) -> Result<()> { + ensure_verified_package_session(self.locked, self.verified_migration_role)?; + current.validate()?; + target.validate()?; + ledger.validate()?; + if ledger.source_revision.as_deref() != Some(current.package_revision.as_str()) + || ledger.target_revision != target.package_revision + || ledger.package_sequence != target.package_sequence + || target.package_sequence <= current.package_sequence + { + return Err(PostgresKernelError::Configuration( + "successor package and migration ledger differ", + )); + } + let transaction = self.client.transaction().await?; + let changed = transaction + .execute( + "UPDATE registry_internal.registry_state + SET maintenance_status = 'applying', maintenance_target_revision = $1, + updated_at = transaction_timestamp() + WHERE singleton + AND maintenance_status = 'ready' + AND package_id = $2 + AND environment = $3 + AND instance_id = $4 + AND database_id = $5 + AND active_package_revision = $6 + AND schema_fingerprint = $7 + AND package_sequence = $8", + &[ + &target.package_revision, + ¤t.package_id, + ¤t.environment, + ¤t.instance_id, + ¤t.database_id, + ¤t.package_revision, + ¤t.schema_fingerprint, + ¤t.package_sequence, + ], + ) + .await?; + if changed == 1 { + record_started(&transaction, ledger).await?; + } else { + verify_successor_resumable_state(&transaction, current, target).await?; + verify_resumable(&transaction, ledger).await?; + } + transaction.commit().await?; + Ok(()) + } + + /// Records maintenance in its own committed transaction while retaining + /// the session lock. + #[cfg(feature = "postgres-test")] + pub async fn mark_applying( + &mut self, + current: &ExpectedRegistryIdentity, + target_revision: &str, + ) -> Result<()> { + current.validate()?; + if target_revision.is_empty() || target_revision == current.package_revision { + return Err(PostgresKernelError::Configuration( + "apply target revision must be non-empty and different", + )); + } + let transaction = self.client.transaction().await?; + let changed = transaction + .execute( + "UPDATE registry_internal.registry_state + SET maintenance_status = 'applying', maintenance_target_revision = $1, + updated_at = transaction_timestamp() + WHERE singleton + AND maintenance_status = 'ready' + AND package_id = $2 + AND environment = $3 + AND instance_id = $4 + AND database_id = $5 + AND active_package_revision = $6 + AND schema_fingerprint = $7 + AND package_sequence = $8", + &[ + &target_revision, + ¤t.package_id, + ¤t.environment, + ¤t.instance_id, + ¤t.database_id, + ¤t.package_revision, + ¤t.schema_fingerprint, + ¤t.package_sequence, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + transaction.commit().await?; + Ok(()) + } + + /// Confirms that a durable failed apply is being resumed for the exact + /// previous active identity and the same maintenance target. The failed + /// state is deliberately left unchanged. + #[cfg(any(test, feature = "postgres-test"))] + #[allow(dead_code)] + pub(crate) async fn resume_failed( + &mut self, + current: &ExpectedRegistryIdentity, + target_revision: &str, + ) -> Result<()> { + validate_failed_resume_request(self.locked, current, target_revision)?; + let transaction = self.client.transaction().await?; + let accepted = transaction + .query_opt( + "SELECT 1 + FROM registry_internal.registry_state + WHERE singleton + AND maintenance_status = 'failed' + AND maintenance_target_revision = $1 + AND package_id = $2 + AND environment = $3 + AND instance_id = $4 + AND database_id = $5 + AND active_package_revision = $6 + AND schema_fingerprint = $7 + AND package_sequence = $8 + FOR UPDATE", + &[ + &target_revision, + ¤t.package_id, + ¤t.environment, + ¤t.instance_id, + ¤t.database_id, + ¤t.package_revision, + ¤t.schema_fingerprint, + ¤t.package_sequence, + ], + ) + .await?; + if accepted.is_none() { + return Err(PostgresKernelError::RegistryUnavailable); + } + transaction.commit().await?; + Ok(()) + } + + /// Explicit W2 compatibility wrapper for the feasibility kernel catalog. + #[cfg(feature = "postgres-test")] + pub async fn activate( + &mut self, + target: &ExpectedRegistryIdentity, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, + ) -> Result<()> { + self.activate_for_catalog( + target, + &ExpectedManagedCatalog::kernel(), + migration_role, + runtime_role, + ) + .await + } + + /// Atomically records the immutable applied-ledger outcome and makes the + /// exact signed package identity ready only after closed catalog, RLS, ACL, + /// ownership, and schema-fingerprint verification succeeds. + pub(crate) async fn activate_verified_package( + &mut self, + current: Option<&ExpectedRegistryIdentity>, + target: &ExpectedRegistryIdentity, + ledger: &MigrationLedgerEntry, + expected_catalog: &ExpectedManagedCatalog, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, + ) -> Result<()> { + ensure_verified_package_session(self.locked, self.verified_migration_role)?; + target.validate()?; + ledger.validate()?; + let transaction = self.client.transaction().await?; + verify_managed_catalog( + &transaction, + target, + expected_catalog, + migration_role, + runtime_role, + ) + .await?; + record_applied(&transaction, ledger).await?; + let changed = if let Some(current) = current { + current.validate()?; + transaction + .execute( + "UPDATE registry_internal.registry_state + SET active_package_revision = $1, + schema_fingerprint = $2, + package_sequence = $3, + maintenance_status = 'ready', + maintenance_target_revision = NULL, + updated_at = transaction_timestamp() + WHERE singleton + AND package_id = $4 + AND environment = $5 + AND instance_id = $6 + AND database_id = $7 + AND active_package_revision = $8 + AND schema_fingerprint = $9 + AND package_sequence = $10 + AND maintenance_status IN ('applying', 'failed') + AND maintenance_target_revision = $1", + &[ + &target.package_revision, + &target.schema_fingerprint, + &target.package_sequence, + &target.package_id, + &target.environment, + &target.instance_id, + &target.database_id, + ¤t.package_revision, + ¤t.schema_fingerprint, + ¤t.package_sequence, + ], + ) + .await? + } else { + transaction + .execute( + "UPDATE registry_internal.registry_state + SET maintenance_status = 'ready', + maintenance_target_revision = NULL, + updated_at = transaction_timestamp() + WHERE singleton + AND package_id = $1 + AND environment = $2 + AND instance_id = $3 + AND database_id = $4 + AND active_package_revision = $5 + AND schema_fingerprint = $6 + AND package_sequence = $7 + AND maintenance_status IN ('applying', 'failed') + AND maintenance_target_revision = $5", + &[ + &target.package_id, + &target.environment, + &target.instance_id, + &target.database_id, + &target.package_revision, + &target.schema_fingerprint, + &target.package_sequence, + ], + ) + .await? + }; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + transaction.commit().await?; + Ok(()) + } + + /// Activates the target only after exact package-catalog verification in + /// the same transaction as the Registry state transition. + #[cfg(feature = "postgres-test")] + pub(crate) async fn activate_for_catalog( + &mut self, + target: &ExpectedRegistryIdentity, + expected_catalog: &ExpectedManagedCatalog, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, + ) -> Result<()> { + ensure_apply_lock(self.locked)?; + let transaction = self.client.transaction().await?; + target.validate()?; + verify_managed_catalog( + &transaction, + target, + expected_catalog, + migration_role, + runtime_role, + ) + .await?; + let changed = transaction + .execute( + "UPDATE registry_internal.registry_state + SET active_package_revision = $1, + schema_fingerprint = $2, + package_sequence = $3, + maintenance_status = 'ready', + maintenance_target_revision = NULL, + updated_at = transaction_timestamp() + WHERE singleton + AND package_id = $4 + AND environment = $5 + AND instance_id = $6 + AND database_id = $7 + AND maintenance_status IN ('applying', 'failed') + AND maintenance_target_revision = $1 + AND package_sequence < $3", + &[ + &target.package_revision, + &target.schema_fingerprint, + &target.package_sequence, + &target.package_id, + &target.environment, + &target.instance_id, + &target.database_id, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + transaction.commit().await?; + Ok(()) + } + + /// Leaves a durable failed-maintenance state. There is intentionally no + /// API that clears failed maintenance without a reconciled activation. + #[cfg(feature = "postgres-test")] + pub async fn mark_failed(&mut self) -> Result<()> { + let transaction = self.client.transaction().await?; + let changed = transaction + .execute( + "UPDATE registry_internal.registry_state + SET maintenance_status = 'failed', updated_at = transaction_timestamp() + WHERE singleton AND maintenance_status = 'applying'", + &[], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + transaction.commit().await?; + Ok(()) + } + + /// Leaves both maintenance state and the exact package ledger durably + /// failed. Applied ledger rows cannot match the update predicate. + pub(crate) async fn mark_verified_package_failed( + &mut self, + target: &ExpectedRegistryIdentity, + ledger: &MigrationLedgerEntry, + ) -> Result<()> { + ensure_verified_package_session(self.locked, self.verified_migration_role)?; + target.validate()?; + ledger.validate()?; + let transaction = self.client.transaction().await?; + let changed = transaction + .execute( + "UPDATE registry_internal.registry_state + SET maintenance_status = 'failed', updated_at = transaction_timestamp() + WHERE singleton + AND package_id = $1 + AND environment = $2 + AND instance_id = $3 + AND database_id = $4 + AND maintenance_status IN ('applying', 'failed') + AND maintenance_target_revision = $5", + &[ + &target.package_id, + &target.environment, + &target.instance_id, + &target.database_id, + &target.package_revision, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + record_failed(&transaction, ledger).await?; + transaction.commit().await?; + Ok(()) + } + + pub async fn release(mut self) -> Result<()> { + let unlocked: bool = self + .client + .query_one("SELECT pg_advisory_unlock($1)", &[&self.lock_key.get()]) + .await? + .get(0); + if !unlocked { + return Err(PostgresKernelError::CatalogInvariant( + "dedicated apply connection did not hold its Registry lock", + )); + } + self.locked = false; + self.connection_task.abort(); + Ok(()) + } +} + +fn ledger_step( + ledger: &MigrationLedgerEntry, + migration_ordinal: i32, + step_ordinal: i32, +) -> Result<&MigrationLedgerStep> { + let matches = ledger + .steps + .iter() + .filter(|step| { + step.migration_ordinal == migration_ordinal && step.step_ordinal == step_ordinal + }) + .collect::>(); + if matches.len() != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(matches[0]) +} + +fn step_tables(step: &ValidatedReviewedMigrationStep) -> Result> { + let objects = match &step.descriptor { + ReviewedMigrationStepDescriptor::TransactionalSql { objects, .. } + | ReviewedMigrationStepDescriptor::ChunkedBackfill { objects, .. } => objects, + }; + let tables = objects + .iter() + .map(|object| object.table.clone()) + .collect::>() + .into_iter() + .collect::>(); + if tables.is_empty() { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(tables) +} + +async fn execute_boolean_assertion( + transaction: &impl GenericClient, + assertion: &ValidatedReviewedMigrationAssertion, +) -> Result<()> { + if assertion.sha256 != statement_checksum(&assertion.sql) { + return Err(PostgresKernelError::RegistryUnavailable); + } + let rows = transaction + .query(&assertion.sql, &[]) + .await + .map_err(|_| PostgresKernelError::Connection)?; + if rows.len() != 1 || rows[0].len() != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + let accepted = rows[0] + .try_get::<_, Option>(0) + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + if accepted != Some(true) { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +async fn set_force_row_security( + transaction: &impl GenericClient, + tables: &[String], + forced: bool, +) -> Result<()> { + let action = if forced { "FORCE" } else { "NO FORCE" }; + for table in tables { + let table = SqlIdentifier::parse(table)?; + transaction + .batch_execute(&format!( + "ALTER TABLE registry_data.{} {action} ROW LEVEL SECURITY", + table.quoted() + )) + .await + .map_err(|_| PostgresKernelError::Connection)?; + } + Ok(()) +} + +async fn set_local_migration_timeouts( + transaction: &impl GenericClient, + lock_timeout_ms: u64, + statement_timeout_ms: u64, +) -> Result<()> { + set_local_duration_timeouts( + transaction, + Duration::from_millis(lock_timeout_ms), + Duration::from_millis(statement_timeout_ms), + ) + .await +} + +async fn set_local_duration_timeouts( + transaction: &impl GenericClient, + lock_timeout: Duration, + statement_timeout: Duration, +) -> Result<()> { + validate_timeout( + lock_timeout, + Duration::from_secs(300), + "reviewed migration lock timeout is outside its bound", + )?; + validate_timeout( + statement_timeout, + MAX_VERIFIED_DDL_STATEMENT_TIMEOUT, + "reviewed migration statement timeout is outside its bound", + )?; + let lock_timeout = u64::try_from(lock_timeout.as_millis()) + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + let statement_timeout = u64::try_from(statement_timeout.as_millis()) + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + transaction + .execute( + "SELECT pg_catalog.set_config('lock_timeout', $1, true), + pg_catalog.set_config('statement_timeout', $2, true)", + &[ + &format!("{lock_timeout}ms"), + &format!("{statement_timeout}ms"), + ], + ) + .await?; + Ok(()) +} + +fn ensure_apply_lock(lock_held: bool) -> Result<()> { + if !lock_held { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +fn ensure_verified_package_session(lock_held: bool, role_verified: bool) -> Result<()> { + ensure_apply_lock(lock_held)?; + if !role_verified { + return Err(PostgresKernelError::RoleInvariant( + "package apply did not verify the configured migration role", + )); + } + Ok(()) +} + +fn validate_timeout(timeout: Duration, maximum: Duration, message: &'static str) -> Result<()> { + if timeout < Duration::from_millis(1) || timeout > maximum { + return Err(PostgresKernelError::Configuration(message)); + } + Ok(()) +} + +async fn set_session_timeout(client: &Client, name: &str, timeout: Duration) -> Result<()> { + let timeout_millis = u64::try_from(timeout.as_millis()).map_err(|_| { + PostgresKernelError::Configuration("apply timeout is outside PostgreSQL bounds") + })?; + client + .execute( + "SELECT pg_catalog.set_config($1, $2, false)", + &[&name, &format!("{timeout_millis}ms")], + ) + .await?; + Ok(()) +} + +async fn set_local_statement_timeout(client: &impl GenericClient, timeout: Duration) -> Result<()> { + let timeout_millis = u64::try_from(timeout.as_millis()).map_err(|_| { + PostgresKernelError::Configuration( + "verified DDL statement timeout is outside PostgreSQL bounds", + ) + })?; + client + .execute( + "SELECT pg_catalog.set_config('statement_timeout', $1, true)", + &[&format!("{timeout_millis}ms")], + ) + .await?; + Ok(()) +} + +fn validate_package_ddl( + statements: &[PackageDdlStatement<'_>], + statement_timeout: Duration, +) -> Result<()> { + let sql = statements + .iter() + .map(|statement| statement.sql) + .collect::>(); + validate_verified_ddl_request(true, &sql, statement_timeout)?; + if statements + .iter() + .any(|statement| statement.checksum != statement_checksum(statement.sql)) + { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +fn validate_statement_checksum(statement: &PackageDdlStatement<'_>) -> Result<()> { + if statement.checksum != statement_checksum(statement.sql) { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +async fn verify_initial_resumable_state( + client: &impl GenericClient, + target: &ExpectedRegistryIdentity, +) -> Result<()> { + let row = client + .query_opt( + "SELECT 1 + FROM registry_internal.registry_state + WHERE singleton + AND maintenance_status IN ('applying', 'failed') + AND maintenance_target_revision = $1 + AND package_id = $2 + AND environment = $3 + AND instance_id = $4 + AND database_id = $5 + AND active_package_revision = $1 + AND schema_fingerprint = $6 + AND package_sequence = $7 + FOR UPDATE", + &[ + &target.package_revision, + &target.package_id, + &target.environment, + &target.instance_id, + &target.database_id, + &target.schema_fingerprint, + &target.package_sequence, + ], + ) + .await?; + if row.is_none() { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +async fn verify_successor_resumable_state( + client: &impl GenericClient, + current: &ExpectedRegistryIdentity, + target: &ExpectedRegistryIdentity, +) -> Result<()> { + let row = client + .query_opt( + "SELECT 1 + FROM registry_internal.registry_state + WHERE singleton + AND maintenance_status IN ('applying', 'failed') + AND maintenance_target_revision = $1 + AND package_id = $2 + AND environment = $3 + AND instance_id = $4 + AND database_id = $5 + AND active_package_revision = $6 + AND schema_fingerprint = $7 + AND package_sequence = $8 + FOR UPDATE", + &[ + &target.package_revision, + ¤t.package_id, + ¤t.environment, + ¤t.instance_id, + ¤t.database_id, + ¤t.package_revision, + ¤t.schema_fingerprint, + ¤t.package_sequence, + ], + ) + .await?; + if row.is_none() { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +fn validate_runtime_acl_reconciliation_request(lock_held: bool) -> Result<()> { + ensure_apply_lock(lock_held) +} + +#[cfg(any(test, feature = "postgres-test"))] +#[allow(dead_code)] +fn validate_failed_resume_request( + lock_held: bool, + current: &ExpectedRegistryIdentity, + target_revision: &str, +) -> Result<()> { + ensure_apply_lock(lock_held)?; + current.validate()?; + if target_revision.is_empty() || target_revision == current.package_revision { + return Err(PostgresKernelError::Configuration( + "resume target revision must be non-empty and different", + )); + } + Ok(()) +} + +fn validate_verified_ddl_request( + lock_held: bool, + statements: &[&str], + statement_timeout: Duration, +) -> Result<()> { + ensure_apply_lock(lock_held)?; + if statement_timeout < Duration::from_millis(1) + || statement_timeout > MAX_VERIFIED_DDL_STATEMENT_TIMEOUT + { + return Err(PostgresKernelError::Configuration( + "verified DDL statement timeout must be between 1 millisecond and 1 hour", + )); + } + if statements.is_empty() || statements.len() > MAX_VERIFIED_DDL_STATEMENTS { + return Err(PostgresKernelError::Configuration( + "verified DDL statement count is outside its bound", + )); + } + if statements.iter().any(|statement| { + statement.trim().is_empty() || statement.len() > MAX_VERIFIED_DDL_STATEMENT_BYTES + }) { + return Err(PostgresKernelError::Configuration( + "verified DDL statement text is empty or outside its bound", + )); + } + Ok(()) +} + +impl Drop for DedicatedApplyConnection { + fn drop(&mut self) { + if self.locked { + self.connection_task.abort(); + } + } +} + +async fn connect_dedicated(config: &ConnectionConfig) -> Result<(Client, JoinHandle<()>)> { + match config.tls_connector() { + ConnectionTls::Rustls(connector) => { + let (client, connection) = config.postgres().connect(connector).await?; + let task = tokio::spawn(async move { + let _ = connection.await; + }); + Ok((client, task)) + } + #[cfg(feature = "postgres-test")] + ConnectionTls::TestOnlyPlaintext => { + let (client, connection) = config.postgres().connect(NoTls).await?; + let task = tokio::spawn(async move { + let _ = connection.await; + }); + Ok((client, task)) + } + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "postgres-test")] + use std::{env, str::FromStr, time::SystemTime}; + + #[cfg(feature = "postgres-test")] + use tokio_postgres::Config; + + #[cfg(feature = "postgres-test")] + use crate::postgres::PoolBounds; + + use super::*; + + #[test] + fn advisory_lock_key_is_deterministic_and_registry_scoped() { + let first = RegistryLockKey::derive("registry-a").expect("Registry id is valid"); + let repeated = RegistryLockKey::derive("registry-a").expect("Registry id is valid"); + let other = RegistryLockKey::derive("registry-b").expect("Registry id is valid"); + assert_eq!(first, repeated); + assert_ne!(first, other); + assert!(RegistryLockKey::derive("").is_err()); + } + + #[test] + fn verified_ddl_requires_the_apply_lock_and_bounded_nonempty_statements() { + assert!(matches!( + validate_verified_ddl_request( + false, + &["CREATE TABLE registry_data.probe (id int)"], + Duration::from_secs(1), + ), + Err(PostgresKernelError::RegistryUnavailable) + )); + assert!(matches!( + validate_verified_ddl_request(true, &[], Duration::from_secs(1)), + Err(PostgresKernelError::Configuration(_)) + )); + assert!(matches!( + validate_verified_ddl_request(true, &[" \n\t"], Duration::from_secs(1)), + Err(PostgresKernelError::Configuration(_)) + )); + + let excessive_count = + vec!["CREATE TABLE registry_data.probe (id int)"; MAX_VERIFIED_DDL_STATEMENTS + 1]; + assert!(matches!( + validate_verified_ddl_request(true, &excessive_count, Duration::from_secs(1)), + Err(PostgresKernelError::Configuration(_)) + )); + let oversized = "x".repeat(MAX_VERIFIED_DDL_STATEMENT_BYTES + 1); + assert!(matches!( + validate_verified_ddl_request(true, &[oversized.as_str()], Duration::from_secs(1)), + Err(PostgresKernelError::Configuration(_)) + )); + assert!(validate_verified_ddl_request( + true, + &["CREATE TABLE registry_data.probe (id int)"], + Duration::from_millis(1), + ) + .is_ok()); + assert!(validate_verified_ddl_request( + true, + &["CREATE TABLE registry_data.probe (id int)"], + MAX_VERIFIED_DDL_STATEMENT_TIMEOUT, + ) + .is_ok()); + assert!(matches!( + validate_verified_ddl_request( + true, + &["CREATE TABLE registry_data.probe (id int)"], + Duration::from_nanos(1), + ), + Err(PostgresKernelError::Configuration(_)) + )); + assert!(matches!( + validate_verified_ddl_request( + true, + &["CREATE TABLE registry_data.probe (id int)"], + MAX_VERIFIED_DDL_STATEMENT_TIMEOUT + Duration::from_millis(1), + ), + Err(PostgresKernelError::Configuration(_)) + )); + } + + #[test] + fn catalog_activation_requires_the_dedicated_apply_lock() { + assert!(matches!( + ensure_apply_lock(false), + Err(PostgresKernelError::RegistryUnavailable) + )); + assert!(ensure_apply_lock(true).is_ok()); + } + + #[test] + fn runtime_acl_reconciliation_requires_the_dedicated_apply_lock() { + assert!(matches!( + validate_runtime_acl_reconciliation_request(false), + Err(PostgresKernelError::RegistryUnavailable) + )); + assert!(validate_runtime_acl_reconciliation_request(true).is_ok()); + } + + #[cfg(feature = "postgres-test")] + #[tokio::test] + async fn failed_resume_and_ddl_timeout_are_fail_closed_on_real_postgres() { + let database = InterlockTestDatabase::create().await; + let current = ExpectedRegistryIdentity { + package_id: "package-under-test".to_owned(), + environment: "test".to_owned(), + instance_id: "instance-under-test".to_owned(), + database_id: "database-under-test".to_owned(), + package_revision: "revision-current".to_owned(), + schema_fingerprint: "fingerprint-current".to_owned(), + package_sequence: 7, + }; + let target_revision = "revision-target"; + database + .install_failed_state(¤t, target_revision) + .await; + let initial_state = database.state_snapshot().await; + + let lock_key = RegistryLockKey::derive(database.database.as_str()) + .expect("isolated database name is a valid Registry lock scope"); + let mut apply = DedicatedApplyConnection::acquire( + &database.migration_config, + lock_key, + Duration::from_secs(1), + ) + .await + .expect("isolated migration role can acquire the apply lock"); + + assert!(matches!( + apply.resume_failed(¤t, "wrong-target").await, + Err(PostgresKernelError::RegistryUnavailable) + )); + let mut wrong_current = current.clone(); + wrong_current.package_sequence += 1; + assert!(matches!( + apply.resume_failed(&wrong_current, target_revision).await, + Err(PostgresKernelError::RegistryUnavailable) + )); + assert_eq!(database.state_snapshot().await, initial_state); + apply + .resume_failed(¤t, target_revision) + .await + .expect("the exact durable failed apply can be resumed"); + assert_eq!(database.state_snapshot().await, initial_state); + + let timed_out = apply + .execute_verified_ddl( + &[ + "CREATE TEMP TABLE verified_ddl_timeout_probe (id integer)", + "SELECT pg_sleep(0.2)", + ], + Duration::from_millis(20), + ) + .await; + assert!(matches!(timed_out, Err(PostgresKernelError::Connection))); + assert_eq!(database.state_snapshot().await, initial_state); + apply + .resume_failed(¤t, target_revision) + .await + .expect("a timed-out DDL transaction leaves failed recovery state intact"); + + let competing_lock = DedicatedApplyConnection::acquire( + &database.migration_config, + lock_key, + Duration::from_millis(20), + ) + .await; + assert!(matches!( + competing_lock, + Err(PostgresKernelError::RegistryUnavailable) + )); + + apply + .execute_verified_ddl( + &[ + "CREATE TEMP TABLE verified_ddl_timeout_probe (id integer)", + "DROP TABLE verified_ddl_timeout_probe", + ], + Duration::from_secs(1), + ) + .await + .expect("the timed-out transaction rolls back and the locked session remains usable"); + apply + .release() + .await + .expect("the original apply connection releases its lock"); + + let mut reacquired = DedicatedApplyConnection::acquire( + &database.migration_config, + lock_key, + Duration::from_secs(1), + ) + .await + .expect("the released apply lock can be acquired again"); + reacquired + .resume_failed(¤t, target_revision) + .await + .expect("failed recovery remains exact after lock handoff"); + reacquired + .release() + .await + .expect("the replacement apply connection releases its lock"); + assert_eq!(database.state_snapshot().await, initial_state); + database.cleanup().await; + } + + #[cfg(feature = "postgres-test")] + struct InterlockTestDatabase { + admin_root: Config, + admin: Client, + admin_task: JoinHandle<()>, + migration_config: ConnectionConfig, + migration_raw: Config, + database: SqlIdentifier, + migration_role: SqlIdentifier, + } + + #[cfg(feature = "postgres-test")] + impl InterlockTestDatabase { + async fn create() -> Self { + let url = env::var("REGISTRY_SERVER_TEST_DATABASE_URL").expect( + "REGISTRY_SERVER_TEST_DATABASE_URL is required for the real interlock test", + ); + let admin_root = Config::from_str(&url) + .expect("REGISTRY_SERVER_TEST_DATABASE_URL must be a valid PostgreSQL URL"); + let nanos = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the Unix epoch") + .as_nanos(); + let suffix = format!("{}_{nanos}", std::process::id()); + let database = SqlIdentifier::parse(&format!("rs_interlock_{suffix}")) + .expect("generated database identifier is valid"); + let migration_role = SqlIdentifier::parse(&format!("rs_il_migration_{suffix}")) + .expect("generated migration role identifier is valid"); + let password = format!("rs{suffix}password"); + + let (root, root_task) = connect_plaintext(admin_root.clone()).await; + root.batch_execute(&format!( + "CREATE ROLE {} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{}';", + migration_role.quoted(), + password, + )) + .await + .expect("test administrator can create an isolated migration role"); + root.batch_execute(&format!("CREATE DATABASE {};", database.quoted())) + .await + .expect("test administrator can create an isolated database"); + root_task.abort(); + + let mut database_admin = admin_root.clone(); + database_admin.dbname(database.as_str()); + let (admin, admin_task) = connect_plaintext(database_admin).await; + admin + .batch_execute(&format!( + "REVOKE ALL ON DATABASE {} FROM PUBLIC; + GRANT CONNECT, TEMPORARY ON DATABASE {} TO {}; + CREATE SCHEMA registry_internal AUTHORIZATION {};", + database.quoted(), + database.quoted(), + migration_role.quoted(), + migration_role.quoted(), + )) + .await + .expect("test administrator can constrain and provision the isolated database"); + + let mut migration_raw = admin_root.clone(); + migration_raw.dbname(database.as_str()); + migration_raw.user(migration_role.as_str()); + migration_raw.password(password); + let bounds = PoolBounds::new( + 1, + Duration::from_secs(2), + Duration::from_secs(2), + Duration::from_secs(2), + ) + .expect("interlock test pool bounds are valid"); + let migration_config = + ConnectionConfig::from_test_config(migration_raw.clone(), bounds) + .expect("interlock test migration configuration is valid"); + Self { + admin_root, + admin, + admin_task, + migration_config, + migration_raw, + database, + migration_role, + } + } + + async fn install_failed_state( + &self, + current: &ExpectedRegistryIdentity, + target_revision: &str, + ) { + let (migration, migration_task) = connect_plaintext(self.migration_raw.clone()).await; + migration + .batch_execute( + "CREATE TABLE registry_internal.registry_state ( + singleton boolean PRIMARY KEY CHECK (singleton), + environment text NOT NULL, + package_id text NOT NULL, + instance_id text NOT NULL, + database_id text NOT NULL, + active_package_revision text NOT NULL, + schema_fingerprint text NOT NULL, + package_sequence bigint NOT NULL, + maintenance_status text NOT NULL, + maintenance_target_revision text, + updated_at timestamptz NOT NULL DEFAULT transaction_timestamp() + );", + ) + .await + .expect("isolated migration role can install the state fixture"); + migration + .execute( + "INSERT INTO registry_internal.registry_state ( + singleton, package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status, maintenance_target_revision + ) VALUES (true, $1, $2, $3, $4, $5, $6, $7, 'failed', $8)", + &[ + ¤t.package_id, + ¤t.environment, + ¤t.instance_id, + ¤t.database_id, + ¤t.package_revision, + ¤t.schema_fingerprint, + ¤t.package_sequence, + &target_revision, + ], + ) + .await + .expect("isolated migration role can seed durable failed state"); + migration_task.abort(); + } + + async fn state_snapshot( + &self, + ) -> ( + String, + String, + String, + String, + String, + String, + i64, + String, + Option, + ) { + let row = self + .admin + .query_one( + "SELECT package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status, maintenance_target_revision + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .expect("isolated failed state remains queryable"); + ( + row.get(0), + row.get(1), + row.get(2), + row.get(3), + row.get(4), + row.get(5), + row.get(6), + row.get(7), + row.get(8), + ) + } + + async fn cleanup(self) { + self.admin_task.abort(); + let (root, root_task) = connect_plaintext(self.admin_root).await; + root.batch_execute(&format!( + "DROP DATABASE {} WITH (FORCE);", + self.database.quoted(), + )) + .await + .expect("isolated interlock test database can be removed"); + root.batch_execute(&format!("DROP ROLE {};", self.migration_role.quoted())) + .await + .expect("isolated interlock test role can be removed"); + root_task.abort(); + } + } + + #[cfg(feature = "postgres-test")] + async fn connect_plaintext(config: Config) -> (Client, JoinHandle<()>) { + let (client, connection) = config + .connect(NoTls) + .await + .expect("real PostgreSQL interlock test connection succeeds"); + let task = tokio::spawn(async move { + let _ = connection.await; + }); + (client, task) + } +} diff --git a/crates/registry-server/src/postgres/migration_ledger.rs b/crates/registry-server/src/postgres/migration_ledger.rs new file mode 100644 index 0000000000..579c067695 --- /dev/null +++ b/crates/registry-server/src/postgres/migration_ledger.rs @@ -0,0 +1,734 @@ +// SPDX-License-Identifier: Apache-2.0 + +use sha2::{Digest, Sha256}; +use tokio_postgres::GenericClient; +use uuid::Uuid; + +use super::{PostgresKernelError, Result, SqlIdentifier}; + +const MAX_MIGRATION_STATEMENTS: usize = 1024; +const MAX_MIGRATION_ARTIFACTS: usize = 1024; +const MAX_MIGRATION_STEPS: usize = 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MigrationPlanKind { + CompiledAdditive, + Reviewed, +} + +impl MigrationPlanKind { + fn as_str(self) -> &'static str { + match self { + Self::CompiledAdditive => "compiled_additive", + Self::Reviewed => "reviewed", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct MigrationArtifactBinding { + pub path: String, + pub checksum: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MigrationLedgerStepKind { + CompilerDdl, + TransactionalSql, + ChunkedBackfill, +} + +impl MigrationLedgerStepKind { + fn as_str(self) -> &'static str { + match self { + Self::CompilerDdl => "compiler_ddl", + Self::TransactionalSql => "transactional_sql", + Self::ChunkedBackfill => "chunked_backfill", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct MigrationLedgerStep { + pub migration_ordinal: i32, + pub step_ordinal: i32, + pub step_id: String, + pub kind: MigrationLedgerStepKind, + pub checksum: String, +} + +/// Exact immutable identity of one package migration. Statement and artifact +/// digests are ordered because changing order changes the reviewed plan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct MigrationLedgerEntry { + pub source_revision: Option, + pub target_revision: String, + pub package_sequence: i64, + pub plan_kind: MigrationPlanKind, + pub statement_checksums: Vec, + pub artifact_bindings: Vec, + pub steps: Vec, +} + +impl MigrationLedgerEntry { + pub(crate) fn validate(&self) -> Result<()> { + if self.target_revision.is_empty() + || self.package_sequence <= 0 + || self + .source_revision + .as_deref() + .is_some_and(|source| source.is_empty() || source == self.target_revision) + || self.statement_checksums.is_empty() + || self.statement_checksums.len() > MAX_MIGRATION_STATEMENTS + || self + .statement_checksums + .iter() + .any(|checksum| !valid_sha256(checksum)) + || self.artifact_bindings.len() > MAX_MIGRATION_ARTIFACTS + || self.steps.len() > MAX_MIGRATION_STEPS + { + return invalid_identity(); + } + if self.artifact_bindings.iter().any(|binding| { + binding.path.is_empty() || binding.path.len() > 1024 || !valid_sha256(&binding.checksum) + }) || self + .artifact_bindings + .windows(2) + .any(|pair| pair[0].path >= pair[1].path) + { + return invalid_identity(); + } + if self.steps.iter().any(|step| { + step.migration_ordinal < 0 + || step.step_ordinal < 0 + || step.step_id.is_empty() + || step.step_id.len() > 255 + || !valid_sha256(&step.checksum) + }) || self.steps.windows(2).any(|pair| { + (pair[0].migration_ordinal, pair[0].step_ordinal) + >= (pair[1].migration_ordinal, pair[1].step_ordinal) + }) { + return invalid_identity(); + } + match self.plan_kind { + MigrationPlanKind::CompiledAdditive + if !self.artifact_bindings.is_empty() || !self.steps.is_empty() => + { + return invalid_identity(); + } + MigrationPlanKind::Reviewed + if self.artifact_bindings.is_empty() || self.steps.is_empty() => + { + return invalid_identity(); + } + _ => {} + } + Ok(()) + } + + fn artifact_paths(&self) -> Vec { + self.artifact_bindings + .iter() + .map(|binding| binding.path.clone()) + .collect() + } + + fn artifact_checksums(&self) -> Vec { + self.artifact_bindings + .iter() + .map(|binding| binding.checksum.clone()) + .collect() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct MigrationPhaseState { + pub preconditions_complete: bool, + pub postconditions_complete: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct MigrationStepProgress { + pub complete: bool, + pub checkpoint_record_id: Option, + pub affected_rows: u64, +} + +/// Installs only the product-owned durable migration ledger. This is part of +/// the initial control plane and intentionally contains no entity DDL. +pub(crate) async fn install_migration_ledger( + migration: &impl GenericClient, + runtime_role: &SqlIdentifier, +) -> Result<()> { + migration + .batch_execute( + "CREATE TABLE IF NOT EXISTS registry_internal.registry_migrations ( + target_package_revision text PRIMARY KEY + CONSTRAINT registry_migrations_target_nonempty + CHECK (target_package_revision <> ''), + source_package_revision text, + package_sequence bigint NOT NULL + CONSTRAINT registry_migrations_sequence_positive + CHECK (package_sequence > 0), + plan_kind text NOT NULL + CONSTRAINT registry_migrations_plan_kind_closed + CHECK (plan_kind IN ('compiled_additive', 'reviewed')), + statement_checksums text[] NOT NULL + CONSTRAINT registry_migrations_checksums_nonempty + CHECK ( + array_ndims(statement_checksums) = 1 + AND cardinality(statement_checksums) BETWEEN 1 AND 1024 + AND array_position(statement_checksums, '') IS NULL + ), + artifact_paths text[] NOT NULL, + artifact_checksums text[] NOT NULL, + preconditions_complete boolean NOT NULL DEFAULT false, + postconditions_complete boolean NOT NULL DEFAULT false, + outcome text NOT NULL + CONSTRAINT registry_migrations_outcome_closed + CHECK (outcome IN ('applying', 'failed', 'applied')), + started_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + completed_at timestamptz, + CONSTRAINT registry_migrations_source_target_distinct CHECK ( + source_package_revision IS NULL + OR source_package_revision <> target_package_revision + ), + CONSTRAINT registry_migrations_artifacts_consistent CHECK ( + COALESCE(array_ndims(artifact_paths), 1) = 1 + AND COALESCE(array_ndims(artifact_checksums), 1) = 1 + AND cardinality(artifact_paths) = cardinality(artifact_checksums) + AND cardinality(artifact_paths) BETWEEN 0 AND 1024 + AND array_position(artifact_paths, '') IS NULL + AND array_position(artifact_checksums, '') IS NULL + AND ( + (plan_kind = 'compiled_additive' AND cardinality(artifact_paths) = 0) + OR (plan_kind = 'reviewed' AND cardinality(artifact_paths) > 0) + ) + ), + CONSTRAINT registry_migrations_phases_consistent CHECK ( + plan_kind = 'reviewed' + OR (NOT preconditions_complete AND NOT postconditions_complete) + ), + CONSTRAINT registry_migrations_completion_consistent CHECK ( + (outcome = 'applying' AND completed_at IS NULL) + OR (outcome IN ('failed', 'applied') AND completed_at IS NOT NULL) + ) + ); + CREATE TABLE IF NOT EXISTS registry_internal.registry_migration_steps ( + target_package_revision text NOT NULL, + migration_ordinal integer NOT NULL CHECK (migration_ordinal >= 0), + step_ordinal integer NOT NULL CHECK (step_ordinal >= 0), + step_id text NOT NULL CHECK (step_id <> ''), + step_kind text NOT NULL + CHECK (step_kind IN ('compiler_ddl', 'transactional_sql', 'chunked_backfill')), + statement_checksum text NOT NULL CHECK (statement_checksum <> ''), + outcome text NOT NULL DEFAULT 'pending' + CHECK (outcome IN ('pending', 'applying', 'completed')), + checkpoint_record_id uuid, + affected_rows bigint NOT NULL DEFAULT 0 CHECK (affected_rows >= 0), + completed_at timestamptz, + PRIMARY KEY (target_package_revision, migration_ordinal, step_ordinal), + CONSTRAINT registry_migration_steps_state_consistent CHECK ( + (outcome = 'pending' AND checkpoint_record_id IS NULL + AND affected_rows = 0 AND completed_at IS NULL) + OR (outcome = 'applying' AND step_kind = 'chunked_backfill' + AND checkpoint_record_id IS NOT NULL AND completed_at IS NULL) + OR (outcome = 'completed' AND completed_at IS NOT NULL + AND (step_kind = 'chunked_backfill' OR checkpoint_record_id IS NULL)) + ) + ); + REVOKE ALL ON TABLE registry_internal.registry_migrations FROM PUBLIC; + REVOKE ALL ON TABLE registry_internal.registry_migration_steps FROM PUBLIC;", + ) + .await?; + migration + .batch_execute(&format!( + "REVOKE ALL ON TABLE registry_internal.registry_migrations FROM {}; + REVOKE ALL ON TABLE registry_internal.registry_migration_steps FROM {};", + runtime_role.quoted(), + runtime_role.quoted(), + )) + .await?; + Ok(()) +} + +pub(crate) async fn record_started( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, +) -> Result<()> { + entry.validate()?; + let artifact_paths = entry.artifact_paths(); + let artifact_checksums = entry.artifact_checksums(); + let changed = client + .execute( + "INSERT INTO registry_internal.registry_migrations ( + target_package_revision, source_package_revision, package_sequence, + plan_kind, statement_checksums, artifact_paths, artifact_checksums, outcome + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'applying') + ON CONFLICT (target_package_revision) DO NOTHING", + &[ + &entry.target_revision, + &entry.source_revision, + &entry.package_sequence, + &entry.plan_kind.as_str(), + &entry.statement_checksums, + &artifact_paths, + &artifact_checksums, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + for step in &entry.steps { + let changed = client + .execute( + "INSERT INTO registry_internal.registry_migration_steps ( + target_package_revision, migration_ordinal, step_ordinal, + step_id, step_kind, statement_checksum + ) VALUES ($1, $2, $3, $4, $5, $6)", + &[ + &entry.target_revision, + &step.migration_ordinal, + &step.step_ordinal, + &step.step_id, + &step.kind.as_str(), + &step.checksum, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + } + Ok(()) +} + +/// Accepts only the exact interrupted or failed target. An applied row is +/// immutable through this library and therefore cannot be resumed or cleared. +pub(crate) async fn verify_resumable( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, +) -> Result<()> { + entry.validate()?; + let artifact_paths = entry.artifact_paths(); + let artifact_checksums = entry.artifact_checksums(); + let row = client + .query_opt( + "SELECT 1 + FROM registry_internal.registry_migrations + WHERE target_package_revision = $1 + AND source_package_revision IS NOT DISTINCT FROM $2 + AND package_sequence = $3 + AND plan_kind = $4 + AND statement_checksums = $5 + AND artifact_paths = $6 + AND artifact_checksums = $7 + AND outcome IN ('applying', 'failed') + FOR UPDATE", + &[ + &entry.target_revision, + &entry.source_revision, + &entry.package_sequence, + &entry.plan_kind.as_str(), + &entry.statement_checksums, + &artifact_paths, + &artifact_checksums, + ], + ) + .await?; + if row.is_none() { + return Err(PostgresKernelError::RegistryUnavailable); + } + let rows = client + .query( + "SELECT migration_ordinal, step_ordinal, step_id, step_kind, statement_checksum + FROM registry_internal.registry_migration_steps + WHERE target_package_revision = $1 + ORDER BY migration_ordinal, step_ordinal", + &[&entry.target_revision], + ) + .await?; + let exact = rows.len() == entry.steps.len() + && rows.iter().zip(&entry.steps).all(|(row, step)| { + row.get::<_, i32>(0) == step.migration_ordinal + && row.get::<_, i32>(1) == step.step_ordinal + && row.get::<_, String>(2) == step.step_id + && row.get::<_, String>(3) == step.kind.as_str() + && row.get::<_, String>(4) == step.checksum + }); + if !exact { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +pub(crate) async fn migration_phase_state( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, +) -> Result { + require_reviewed(entry)?; + let row = client + .query_opt( + "SELECT preconditions_complete, postconditions_complete + FROM registry_internal.registry_migrations + WHERE target_package_revision = $1 + AND source_package_revision IS NOT DISTINCT FROM $2 + AND package_sequence = $3 + AND plan_kind = 'reviewed' + AND outcome IN ('applying', 'failed') + FOR UPDATE", + &[ + &entry.target_revision, + &entry.source_revision, + &entry.package_sequence, + ], + ) + .await?; + let row = row.ok_or(PostgresKernelError::RegistryUnavailable)?; + Ok(MigrationPhaseState { + preconditions_complete: row.get(0), + postconditions_complete: row.get(1), + }) +} + +pub(crate) async fn record_preconditions_complete( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, +) -> Result<()> { + update_phase(client, entry, false).await +} + +pub(crate) async fn record_postconditions_complete( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, +) -> Result<()> { + update_phase(client, entry, true).await +} + +async fn update_phase( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, + postconditions: bool, +) -> Result<()> { + require_reviewed(entry)?; + let (column, prerequisite) = if postconditions { + ( + "postconditions_complete", + "AND preconditions_complete + AND NOT EXISTS ( + SELECT 1 + FROM registry_internal.registry_migration_steps s + WHERE s.target_package_revision = registry_migrations.target_package_revision + AND s.outcome <> 'completed' + )", + ) + } else { + ("preconditions_complete", "") + }; + let sql = format!( + "UPDATE registry_internal.registry_migrations + SET {column} = true + WHERE target_package_revision = $1 + AND source_package_revision IS NOT DISTINCT FROM $2 + AND package_sequence = $3 + AND plan_kind = 'reviewed' + AND outcome IN ('applying', 'failed') + AND NOT {column} + {prerequisite}" + ); + let changed = client + .execute( + &sql, + &[ + &entry.target_revision, + &entry.source_revision, + &entry.package_sequence, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +pub(crate) async fn step_progress( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, + step: &MigrationLedgerStep, +) -> Result { + require_reviewed(entry)?; + let row = client + .query_opt( + "SELECT outcome, checkpoint_record_id, affected_rows + FROM registry_internal.registry_migration_steps + WHERE target_package_revision = $1 + AND migration_ordinal = $2 + AND step_ordinal = $3 + AND step_id = $4 + AND step_kind = $5 + AND statement_checksum = $6 + FOR UPDATE", + &[ + &entry.target_revision, + &step.migration_ordinal, + &step.step_ordinal, + &step.step_id, + &step.kind.as_str(), + &step.checksum, + ], + ) + .await?; + let row = row.ok_or(PostgresKernelError::RegistryUnavailable)?; + let affected_rows = u64::try_from(row.get::<_, i64>(2)) + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + Ok(MigrationStepProgress { + complete: row.get::<_, String>(0) == "completed", + checkpoint_record_id: row.get(1), + affected_rows, + }) +} + +pub(crate) async fn record_step_complete( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, + step: &MigrationLedgerStep, + affected_rows: u64, +) -> Result<()> { + let affected_rows = + i64::try_from(affected_rows).map_err(|_| PostgresKernelError::RegistryUnavailable)?; + let changed = client + .execute( + "UPDATE registry_internal.registry_migration_steps + SET outcome = 'completed', affected_rows = $1, + completed_at = transaction_timestamp() + WHERE target_package_revision = $2 + AND migration_ordinal = $3 + AND step_ordinal = $4 + AND step_id = $5 + AND step_kind = $6 + AND statement_checksum = $7 + AND outcome IN ('pending', 'applying')", + &[ + &affected_rows, + &entry.target_revision, + &step.migration_ordinal, + &step.step_ordinal, + &step.step_id, + &step.kind.as_str(), + &step.checksum, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +pub(crate) async fn record_chunk_progress( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, + step: &MigrationLedgerStep, + checkpoint_record_id: Uuid, + affected_rows: u64, +) -> Result<()> { + if step.kind != MigrationLedgerStepKind::ChunkedBackfill { + return Err(PostgresKernelError::RegistryUnavailable); + } + let affected_rows = + i64::try_from(affected_rows).map_err(|_| PostgresKernelError::RegistryUnavailable)?; + let changed = client + .execute( + "UPDATE registry_internal.registry_migration_steps + SET outcome = 'applying', checkpoint_record_id = $1, affected_rows = $2 + WHERE target_package_revision = $3 + AND migration_ordinal = $4 + AND step_ordinal = $5 + AND step_id = $6 + AND step_kind = 'chunked_backfill' + AND statement_checksum = $7 + AND outcome IN ('pending', 'applying')", + &[ + &checkpoint_record_id, + &affected_rows, + &entry.target_revision, + &step.migration_ordinal, + &step.step_ordinal, + &step.step_id, + &step.checksum, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +pub(crate) async fn record_failed( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, +) -> Result<()> { + update_outcome(client, entry, "failed").await +} + +pub(crate) async fn record_applied( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, +) -> Result<()> { + entry.validate()?; + let closure = match entry.plan_kind { + MigrationPlanKind::CompiledAdditive => "", + MigrationPlanKind::Reviewed => { + "AND preconditions_complete + AND postconditions_complete + AND NOT EXISTS ( + SELECT 1 FROM registry_internal.registry_migration_steps s + WHERE s.target_package_revision = registry_migrations.target_package_revision + AND s.outcome <> 'completed' + )" + } + }; + let sql = format!( + "UPDATE registry_internal.registry_migrations + SET outcome = 'applied', completed_at = transaction_timestamp() + WHERE target_package_revision = $1 + AND source_package_revision IS NOT DISTINCT FROM $2 + AND package_sequence = $3 + AND outcome IN ('applying', 'failed') + {closure}" + ); + let changed = client + .execute( + &sql, + &[ + &entry.target_revision, + &entry.source_revision, + &entry.package_sequence, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +async fn update_outcome( + client: &impl GenericClient, + entry: &MigrationLedgerEntry, + outcome: &'static str, +) -> Result<()> { + entry.validate()?; + let changed = client + .execute( + "UPDATE registry_internal.registry_migrations + SET outcome = $1, completed_at = transaction_timestamp() + WHERE target_package_revision = $2 + AND source_package_revision IS NOT DISTINCT FROM $3 + AND package_sequence = $4 + AND outcome IN ('applying', 'failed')", + &[ + &outcome, + &entry.target_revision, + &entry.source_revision, + &entry.package_sequence, + ], + ) + .await?; + if changed != 1 { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +fn require_reviewed(entry: &MigrationLedgerEntry) -> Result<()> { + entry.validate()?; + if entry.plan_kind != MigrationPlanKind::Reviewed { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + +fn invalid_identity() -> Result<()> { + Err(PostgresKernelError::Configuration( + "migration ledger identity is incomplete", + )) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..].bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +pub(crate) fn statement_checksum(sql: &str) -> String { + let digest = Sha256::digest(sql.as_bytes()); + let mut checksum = String::with_capacity(71); + checksum.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + write!(&mut checksum, "{byte:02x}").expect("writing to a String cannot fail"); + } + checksum +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migration_ledger_refuses_empty_or_unbound_statement_checksums() { + let entry = MigrationLedgerEntry { + source_revision: Some("prior".to_owned()), + target_revision: "target".to_owned(), + package_sequence: 2, + plan_kind: MigrationPlanKind::CompiledAdditive, + statement_checksums: Vec::new(), + artifact_bindings: Vec::new(), + steps: Vec::new(), + }; + assert!(matches!( + entry.validate(), + Err(PostgresKernelError::Configuration(_)) + )); + + let mut malformed = entry; + malformed.statement_checksums = vec!["sha256:not-a-digest".to_owned()]; + assert!(matches!( + malformed.validate(), + Err(PostgresKernelError::Configuration(_)) + )); + } + + #[test] + fn reviewed_migration_ledger_identity_requires_ordered_artifacts_and_steps() { + let mut entry = MigrationLedgerEntry { + source_revision: Some("prior".to_owned()), + target_revision: "target".to_owned(), + package_sequence: 2, + plan_kind: MigrationPlanKind::Reviewed, + statement_checksums: vec![statement_checksum("SELECT true")], + artifact_bindings: vec![MigrationArtifactBinding { + path: "modules/core/migrations/change/descriptor.json".to_owned(), + checksum: statement_checksum("descriptor"), + }], + steps: vec![MigrationLedgerStep { + migration_ordinal: 1, + step_ordinal: 0, + step_id: "backfill".to_owned(), + kind: MigrationLedgerStepKind::ChunkedBackfill, + checksum: statement_checksum("UPDATE"), + }], + }; + entry + .validate() + .expect("closed reviewed identity validates"); + entry + .artifact_bindings + .push(entry.artifact_bindings[0].clone()); + assert!(entry.validate().is_err()); + } +} diff --git a/crates/registry-server/src/postgres/mod.rs b/crates/registry-server/src/postgres/mod.rs new file mode 100644 index 0000000000..6319b71422 --- /dev/null +++ b/crates/registry-server/src/postgres/mod.rs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! PostgreSQL-only runtime and migration safety kernel. + +mod catalog; +mod config; +mod context; +mod interlock; +mod migration_ledger; +mod mutation; +mod read; +mod revision_read; +mod roles; +mod schema; + +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +pub use catalog::{ + initialize_kernel_registry_state_for_test, initialize_registry_state_for_catalog_test, + RegistryStateTestIdentity, +}; +pub use catalog::{ + install_kernel_schema, kernel_schema_fingerprint, managed_schema_fingerprint, + verify_catalog_identity, verify_catalog_identity_for_catalog, CatalogIdentity, + ExpectedManagedCatalog, ExpectedRegistryIdentity, +}; +pub use config::{ConnectionConfig, PoolBounds, RuntimePool, TlsPolicy}; +pub(crate) use context::validate_field_value; +pub use context::{ + begin_record_transaction, ClaimContext, GuardedTransaction, RowBoundaryContext, + RowBoundaryOperator, +}; +#[cfg(feature = "postgres-test")] +pub use interlock::DedicatedApplyConnection; +pub use interlock::RegistryLockKey; +pub(crate) use interlock::{ + DedicatedApplyConnection as VerifiedPackageApplyConnection, PackageDdlStatement, + ReviewedExecutionOutcome, ReviewedPackageExecutionRequest, +}; +pub(crate) use migration_ledger::{ + statement_checksum, MigrationArtifactBinding, MigrationLedgerEntry, MigrationLedgerStep, + MigrationLedgerStepKind, MigrationPlanKind, +}; +pub use mutation::PostgresRecordMutationService; +pub use read::PostgresRecordReadService; +#[cfg(feature = "postgres-test")] +pub use read::ReadFaultPoint; +pub use revision_read::PostgresRevisionReadService; +#[cfg(feature = "postgres-test")] +pub use revision_read::RevisionReadFaultPoint; +pub use roles::{ + provision_managed_schemas, verify_btree_gist, verify_migration_role, verify_runtime_role, + SqlIdentifier, +}; +pub use schema::install_compiled_schema; +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub(crate) use schema::rehearse_schema_fingerprint_with_connection; +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub(crate) use schema::PreparedSchemaTestCatalogVerifier; +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub use schema::{ + prepare_schema_test_database_with_connections, PreparedSchemaTestDatabase, + SchemaTestDatabaseIdentity, +}; + +use thiserror::Error; + +/// A value-free PostgreSQL kernel error suitable for an operational boundary. +#[derive(Debug, Error)] +pub enum PostgresKernelError { + #[error("invalid PostgreSQL configuration: {0}")] + Configuration(&'static str), + #[error("PostgreSQL connection failed")] + Connection, + #[error("PostgreSQL pool operation failed")] + Pool, + #[error("PostgreSQL pool construction failed")] + PoolBuild, + #[error("PostgreSQL role invariant failed: {0}")] + RoleInvariant(&'static str), + #[error("PostgreSQL catalog invariant failed: {0}")] + CatalogInvariant(&'static str), + #[error("Registry is unavailable for record operations")] + RegistryUnavailable, +} + +impl From for PostgresKernelError { + fn from(_error: tokio_postgres::Error) -> Self { + Self::Connection + } +} + +/// Result returned by PostgreSQL kernel operations. +pub type Result = std::result::Result; diff --git a/crates/registry-server/src/postgres/mutation.rs b/crates/registry-server/src/postgres/mutation.rs new file mode 100644 index 0000000000..c1350a564e --- /dev/null +++ b/crates/registry-server/src/postgres/mutation.rs @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Concrete PostgreSQL mutation runtime for the compiled HTTP surface. + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::Duration; + +use registry_platform_audit::AuditProfile; +use serde_json::Map; + +use crate::api::{ + AuthorizedRequestContext, BatchMutationInput, ConditionalMutationInput, + RowBoundaryOperator as ApiRowBoundaryOperator, VerifiedRowBoundary, +}; +use crate::audit::{record_http_refusal_audit, HttpRefusalAudit}; +use crate::event_destination::ActivatedEventDestinationRegistry; +use crate::model::{CompiledRegistry, HttpMethod}; +#[cfg(feature = "postgres-test")] +use crate::mutation::MutationFaultPoint; +use crate::mutation::{ + BatchMutationRequest, MutationBody, MutationCoordinator, MutationError, MutationOutcome, + MutationPlan, MutationRequest, PatchOperation, +}; + +use super::{ + ClaimContext, ExpectedRegistryIdentity, RegistryLockKey, RowBoundaryContext, RuntimePool, +}; + +#[derive(Clone)] +pub struct PostgresRecordMutationService { + pool: RuntimePool, + registry: Arc, + coordinator: MutationCoordinator, + expected: ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + lock_timeout: Duration, + audit_profile: AuditProfile, + fault: MutationFaultControl, +} + +impl PostgresRecordMutationService { + #[must_use] + pub fn new( + pool: RuntimePool, + registry: Arc, + expected: ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + lock_timeout: Duration, + audit_profile: AuditProfile, + ) -> Self { + Self::new_with_event_destinations( + pool, + registry, + expected, + lock_key, + lock_timeout, + audit_profile, + None, + ) + } + + #[must_use] + pub fn new_with_event_destinations( + pool: RuntimePool, + registry: Arc, + expected: ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + lock_timeout: Duration, + audit_profile: AuditProfile, + event_destinations: Option>, + ) -> Self { + let coordinator = MutationCoordinator::new_with_event_destinations( + lock_key, + lock_timeout, + expected.clone(), + audit_profile.clone(), + event_destinations, + ); + Self { + pool, + registry, + coordinator, + expected, + lock_key, + lock_timeout, + audit_profile, + fault: MutationFaultControl::Disabled, + } + } + + #[cfg(feature = "postgres-test")] + #[must_use] + #[doc(hidden)] + pub fn with_fault_for_test(mut self, fault: MutationFaultPoint) -> Self { + self.fault = MutationFaultControl::At(fault); + self + } + + #[cfg(feature = "postgres-test")] + #[must_use] + #[doc(hidden)] + pub fn with_refusal_audit_fault_for_test(mut self) -> Self { + self.fault = MutationFaultControl::RefusalAudit; + self + } + + pub async fn record_refusal( + &self, + method: HttpMethod, + operation_id: &str, + target_record: Option<&str>, + principal: Option<&str>, + selected_access_profile: Option<&str>, + purpose_present: bool, + ) -> Result<(), MutationError> { + #[cfg(feature = "postgres-test")] + if matches!(self.fault, MutationFaultControl::RefusalAudit) { + return Err(MutationError::Unavailable); + } + let mut client = self + .pool + .get() + .await + .map_err(|_| MutationError::Unavailable)?; + record_http_refusal_audit( + &mut client, + self.lock_key, + self.lock_timeout, + &self.expected, + &self.audit_profile, + HttpRefusalAudit { + method, + operation_id, + target_record, + principal, + selected_access_profile, + purpose_present, + }, + ) + .await + .map_err(MutationError::from) + } + + pub async fn create( + &self, + route_id: &str, + idempotency_key: &str, + context: &AuthorizedRequestContext, + entity_id: &str, + data: Map, + response_fields: BTreeSet, + ) -> Result { + let mut client = self + .pool + .get() + .await + .map_err(|_| MutationError::Unavailable)?; + let claims = strict_claim_context(&self.registry, context, entity_id)?; + let plan = MutationPlan::from_compiled(&self.registry, route_id)?; + self.execute_request( + &mut client, + MutationRequest { + plan: &plan, + idempotency_key, + claims: &claims, + record_id: None, + expected_etag: None, + body: MutationBody::Create(data), + response_fields, + }, + ) + .await + } + + pub async fn patch( + &self, + input: ConditionalMutationInput<'_>, + patch: Vec, + ) -> Result { + self.conditional_mutation(input, MutationBody::Patch(patch)) + .await + } + + pub async fn batch( + &self, + input: BatchMutationInput<'_>, + ) -> Result { + let mut client = self + .pool + .get() + .await + .map_err(|_| MutationError::Unavailable)?; + let claims = strict_claim_context(&self.registry, input.context, input.entity_id)?; + let plan = MutationPlan::from_compiled(&self.registry, input.route_id)?; + let request = BatchMutationRequest { + plan: &plan, + idempotency_key: input.idempotency_key, + claims: &claims, + items: input.items, + response_fields: input.response_fields, + body_bytes: input.body_bytes, + }; + #[cfg(feature = "postgres-test")] + if let MutationFaultControl::At(fault) = self.fault { + return self + .coordinator + .execute_batch_with_fault(&mut client, request, fault) + .await; + } + self.coordinator.execute_batch(&mut client, request).await + } + + pub async fn tombstone( + &self, + input: ConditionalMutationInput<'_>, + ) -> Result { + self.conditional_mutation(input, MutationBody::Tombstone) + .await + } + + async fn conditional_mutation( + &self, + input: ConditionalMutationInput<'_>, + body: MutationBody, + ) -> Result { + let mut client = self + .pool + .get() + .await + .map_err(|_| MutationError::Unavailable)?; + let claims = strict_claim_context(&self.registry, input.context, input.entity_id)?; + let plan = MutationPlan::from_compiled(&self.registry, input.route_id)?; + self.execute_request( + &mut client, + MutationRequest { + plan: &plan, + idempotency_key: input.idempotency_key, + claims: &claims, + record_id: Some(input.record_id), + expected_etag: Some(input.if_match), + body, + response_fields: input.response_fields, + }, + ) + .await + } + + async fn execute_request( + &self, + client: &mut deadpool_postgres::Client, + request: MutationRequest<'_>, + ) -> Result { + #[cfg(feature = "postgres-test")] + if let MutationFaultControl::At(fault) = self.fault { + return self + .coordinator + .execute_with_fault(client, request, fault) + .await; + } + let _ = self.fault; + self.coordinator.execute(client, request).await + } +} + +#[derive(Clone, Copy)] +enum MutationFaultControl { + Disabled, + #[cfg(feature = "postgres-test")] + At(MutationFaultPoint), + #[cfg(feature = "postgres-test")] + RefusalAudit, +} + +fn strict_claim_context( + registry: &CompiledRegistry, + context: &AuthorizedRequestContext, + entity_id: &str, +) -> Result { + let row_boundaries = context + .row_boundaries() + .iter() + .map(api_boundary) + .collect::, _>>()?; + ClaimContext::for_compiled( + registry, + entity_id, + context.principal().map(str::to_owned), + context.selected_profile(), + context.purpose().map(str::to_owned), + row_boundaries, + ) + .map_err(|_| MutationError::InvalidRequest) +} + +fn api_boundary(boundary: &VerifiedRowBoundary) -> Result { + match boundary.operator() { + ApiRowBoundaryOperator::Equals => { + let value = boundary + .values() + .iter() + .next() + .ok_or(MutationError::InvalidRequest)?; + if boundary.values().len() != 1 { + return Err(MutationError::InvalidRequest); + } + Ok(RowBoundaryContext::Equals { + field: boundary.field().to_owned(), + value: value.clone(), + }) + } + ApiRowBoundaryOperator::In => Ok(RowBoundaryContext::In { + field: boundary.field().to_owned(), + values: boundary.values().clone(), + }), + } +} diff --git a/crates/registry-server/src/postgres/read.rs b/crates/registry-server/src/postgres/read.rs new file mode 100644 index 0000000000..6105a36bc5 --- /dev/null +++ b/crates/registry-server/src/postgres/read.rs @@ -0,0 +1,1536 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Concrete PostgreSQL record read service with durable audit release gates. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::time::Duration; + +use registry_platform_audit::AuditProfile; +use registry_platform_canonical_json::canonicalize_json; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use tokio_postgres::types::ToSql; +use uuid::Uuid; + +use crate::api::{ + AuthorizedRequestContext, HeldReadResponse, ReadServiceError, RecordReadRequest, + RecordReadService, RowBoundaryOperator as ApiRowBoundaryOperator, ServiceFuture, +}; +use crate::audit::{ + append_read_terminal_audit, profile_is_keyed, record_pre_io_audit, PreIoAudit, PreIoAuditKind, + ReadTerminalAudit, TerminalAudit, TerminalAuditOutcome, +}; +use crate::contract::{FieldTypeSource, Operation}; +use crate::cursor::{now_unix_seconds, CursorCodec, CursorContinuation}; +use crate::model::{ + CompiledEntity, CompiledQueryFilterOperator, CompiledQueryKind, CompiledQueryOperation, + CompiledQuerySortDirection, CompiledRegistry, +}; +use crate::mutation::strong_record_etag; + +use super::{ + begin_record_transaction, validate_field_value, ClaimContext, ExpectedRegistryIdentity, + RegistryLockKey, RowBoundaryContext, RuntimePool, +}; + +const MAX_SQL_LIMIT: usize = 1000; + +/// Runtime PostgreSQL implementation of the read-only record surface. +#[derive(Clone)] +pub struct PostgresRecordReadService { + pool: RuntimePool, + registry: Arc, + expected: ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + lock_timeout: Duration, + audit_profile: AuditProfile, + cursors: Arc, + fault: ReadFaultControl, +} + +impl PostgresRecordReadService { + #[must_use] + pub fn new( + pool: RuntimePool, + registry: Arc, + expected: ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + lock_timeout: Duration, + audit_profile: AuditProfile, + cursors: Arc, + ) -> Self { + Self { + pool, + registry, + expected, + lock_key, + lock_timeout, + audit_profile, + cursors, + fault: ReadFaultControl::Disabled, + } + } + + #[cfg(feature = "postgres-test")] + #[must_use] + #[doc(hidden)] + pub fn with_fault_for_test(mut self, fault: ReadFaultPoint) -> Self { + self.fault = ReadFaultControl::At(fault); + self + } + + async fn execute( + &self, + request: RecordReadRequest, + operation: Operation, + ) -> Result { + if !profile_is_keyed(&self.audit_profile) { + return Err(ReadServiceError::Unavailable); + } + let mut client = self + .pool + .get() + .await + .map_err(|_| ReadServiceError::Unavailable)?; + let claims = strict_claim_context(&self.registry, &request.context, &request.entity_id)?; + let plan = match ReadPlan::from_request( + &self.registry, + &self.expected, + self.cursors.as_ref(), + &request, + operation, + ) { + Ok(plan) => plan, + Err(()) => { + record_pre_io_audit( + &mut client, + self.lock_key, + self.lock_timeout, + &self.expected, + &claims, + &self.audit_profile, + PreIoAudit { + kind: PreIoAuditKind::Refusal, + method: request.method, + operation_id: &request.operation_id, + target_record: request.record_id.as_deref(), + }, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + return Ok(ReadResult::empty_get()); + } + }; + if operation == Operation::Get + && !request + .record_id + .as_deref() + .is_some_and(valid_canonical_uuid) + { + record_pre_io_audit( + &mut client, + self.lock_key, + self.lock_timeout, + &self.expected, + &claims, + &self.audit_profile, + PreIoAudit { + kind: PreIoAuditKind::Refusal, + method: request.method, + operation_id: &request.operation_id, + target_record: request.record_id.as_deref(), + }, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + return Ok(ReadResult::empty_get()); + } + + record_pre_io_audit( + &mut client, + self.lock_key, + self.lock_timeout, + &self.expected, + &claims, + &self.audit_profile, + PreIoAudit { + kind: PreIoAuditKind::Attempt, + method: request.method, + operation_id: &request.operation_id, + target_record: request.record_id.as_deref(), + }, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + + let materialized = self.read_rows(&mut client, &request, &claims, &plan).await; + let materialized = match materialized { + Ok(materialized) => materialized, + Err(error) => { + let _ = self + .record_read_terminal_audit( + &mut client, + &claims, + &request, + self.terminal( + &request, + &claims, + &plan, + TerminalAuditOutcome::Refused, + 0, + None, + )?, + ) + .await; + return Err(error); + } + }; + let mut held = ReadResult::from_materialized(plan.operation, materialized)?; + if plan.operation == Operation::Get && held.response.is_some() { + let response = held.response.take().ok_or(ReadServiceError::Unavailable)?; + let record_id = request + .record_id + .as_deref() + .ok_or(ReadServiceError::Unavailable)?; + let record_revision = held.record_revision.ok_or(ReadServiceError::Unavailable)?; + let etag = strong_record_etag( + &self.audit_profile, + &claims, + &self.expected.package_revision, + record_id, + record_revision, + &request.selected_fields, + ) + .map_err(|_| ReadServiceError::Unavailable)?; + held.response = Some(response.with_strong_etag(etag)); + } + self.fault.fail_at(ReadFaultPoint::BeforeTerminalAudit)?; + let outcome = if held.result_count == 0 { + TerminalAuditOutcome::Empty + } else { + TerminalAuditOutcome::Returned + }; + self.record_read_terminal_audit( + &mut client, + &claims, + &request, + self.terminal( + &request, + &claims, + &plan, + outcome, + held.result_count, + held.record_revision, + )?, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + Ok(held) + } + + async fn record_read_terminal_audit( + &self, + client: &mut deadpool_postgres::Client, + claims: &ClaimContext, + request: &RecordReadRequest, + terminal: TerminalAudit, + ) -> Result<(), crate::audit::RegistryAuditError> { + let transaction = begin_record_transaction( + client, + self.lock_key, + self.lock_timeout, + &self.expected, + claims, + ) + .await + .map_err(|_| crate::audit::RegistryAuditError::Unavailable)?; + append_read_terminal_audit( + transaction.transaction(), + &self.audit_profile, + ReadTerminalAudit { + terminal, + query_reference: request + .query + .as_ref() + .map(|query| query.cursor_binding.query_reference.clone()), + row_boundary_reference: request + .query + .as_ref() + .map(|query| query.cursor_binding.row_boundary_reference.clone()), + }, + ) + .await?; + transaction + .commit() + .await + .map_err(|_| crate::audit::RegistryAuditError::Unavailable) + } + + async fn read_rows( + &self, + client: &mut deadpool_postgres::Client, + request: &RecordReadRequest, + claims: &ClaimContext, + plan: &ReadPlan, + ) -> Result { + let transaction = begin_record_transaction( + client, + self.lock_key, + self.lock_timeout, + &self.expected, + claims, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + let selected_fields = request.selected_fields.iter().cloned().collect::>(); + let projection = projection( + &plan.entity, + &selected_fields, + request + .query + .as_ref() + .and_then(|query| query.sort.as_deref()), + )?; + let table = quote_identifier(&plan.entity.physical_table); + let limit = + i64::try_from(request.maximum_records).map_err(|_| ReadServiceError::Unavailable)?; + let rows = match plan.operation { + Operation::Get => { + let sql = format!( + "SELECT {projection} + FROM registry_data.{table} + WHERE record_id = $1::text::uuid + AND record_lifecycle = 'active' + LIMIT 1" + ); + let record_id = request + .record_id + .as_deref() + .ok_or(ReadServiceError::Unavailable)?; + transaction + .transaction() + .query(&sql, &[&record_id]) + .await + .map_err(|_| ReadServiceError::Unavailable)? + } + Operation::List => { + let query = request + .query + .as_ref() + .ok_or(ReadServiceError::Unavailable)?; + let _compiled_query = plan + .query_operation + .as_ref() + .ok_or(ReadServiceError::Unavailable)?; + let (sql, values) = list_sql(&plan.entity, query, &projection, &table)?; + let mut params = values + .into_iter() + .map(|value| Box::new(value) as Box) + .collect::>(); + params.push(Box::new(limit)); + let refs = params + .iter() + .map(|value| &**value as &(dyn ToSql + Sync)) + .collect::>(); + transaction + .transaction() + .query(&sql, &refs) + .await + .map_err(|_| ReadServiceError::Unavailable)? + } + _ => return Err(ReadServiceError::Unavailable), + }; + let page_size = request + .query + .as_ref() + .map_or(request.maximum_records, |query| { + usize::from(query.page_size) + }); + let has_more = plan.operation == Operation::List && rows.len() > page_size; + let rows = if has_more { + &rows[..page_size] + } else { + rows.as_slice() + }; + let next_cursor = if has_more { + let query = request + .query + .as_ref() + .ok_or(ReadServiceError::Unavailable)?; + rows.last() + .map(|row| self.next_cursor(row, &selected_fields, query)) + .transpose()? + } else { + None + }; + let rows = rows + .iter() + .map(|row| row_to_record(row, &plan.entity, &selected_fields)) + .collect::, _>>()?; + transaction + .commit() + .await + .map_err(|_| ReadServiceError::Unavailable)?; + Ok(MaterializedRead { rows, next_cursor }) + } + + fn next_cursor( + &self, + row: &tokio_postgres::Row, + selected_fields: &[String], + query: &crate::api::CompiledReadQuery, + ) -> Result { + let last_record_id = row + .try_get::<_, String>(0) + .map_err(|_| ReadServiceError::Unavailable)?; + let sort_value = if query.sort.is_some() { + row.try_get::<_, Option>(selected_fields.len() + 2) + .map_err(|_| ReadServiceError::Unavailable)? + .and_then(cursor_sort_value) + } else { + None + }; + let payload = self + .cursors + .new_payload( + now_unix_seconds(), + query.cursor_binding.clone(), + query.cursor_query.clone(), + CursorContinuation { + last_record_id, + sort_value, + }, + ) + .map_err(|_| ReadServiceError::Unavailable)?; + self.cursors + .encode(&payload) + .map_err(|_| ReadServiceError::Unavailable) + } + + fn terminal( + &self, + request: &RecordReadRequest, + claims: &ClaimContext, + plan: &ReadPlan, + outcome: TerminalAuditOutcome, + result_count: usize, + record_revision: Option, + ) -> Result { + let key_hasher = self.audit_profile.key_hasher(); + let principal_reference = claims + .principal() + .map(|principal| { + key_hasher.audit_reference_hash( + "registry-server-principal-v1", + &self.expected.package_revision, + principal, + ) + }) + .transpose() + .map_err(|_| ReadServiceError::Unavailable)?; + let record_reference = request + .record_id + .as_deref() + .map(|record_id| { + key_hasher.audit_reference_hash( + "registry-server-record-v1", + &self.expected.package_revision, + record_id, + ) + }) + .transpose() + .map_err(|_| ReadServiceError::Unavailable)?; + let field_set_reference = field_set_reference( + &self.audit_profile, + &self.expected.package_revision, + &request.selected_fields, + )?; + Ok(TerminalAudit { + outcome, + method: request.method, + operation_id: request.operation_id.clone(), + entity_id: plan.entity.id.clone(), + package_revision: self.expected.package_revision.clone(), + selected_access_profile: claims.access_profile().to_owned(), + purpose_present: claims.purpose().is_some(), + principal_reference, + record_reference, + record_revision, + result_count: Some(result_count), + field_set_reference: Some(field_set_reference), + }) + } +} + +impl RecordReadService for PostgresRecordReadService { + fn get( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(async move { + let result = self.execute(request, Operation::Get).await?; + Ok(result.response) + }) + } + + fn list( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result> { + Box::pin(async move { + self.execute(request, Operation::List) + .await? + .response + .ok_or(ReadServiceError::Unavailable) + }) + } + + fn refusal( + &self, + request: crate::api::RecordReadRefusal, + ) -> ServiceFuture<'_, Result<(), ReadServiceError>> { + Box::pin(async move { + let mut client = self + .pool + .get() + .await + .map_err(|_| ReadServiceError::Unavailable)?; + crate::audit::record_http_refusal_audit( + &mut client, + self.lock_key, + self.lock_timeout, + &self.expected, + &self.audit_profile, + crate::audit::HttpRefusalAudit { + method: request.method, + operation_id: &request.operation_id, + target_record: request.target_record.as_deref(), + principal: request.principal.as_deref(), + selected_access_profile: request.selected_access_profile.as_deref(), + purpose_present: request.purpose_present, + }, + ) + .await + .map_err(|_| ReadServiceError::Unavailable) + }) + } +} + +struct ReadResult { + response: Option, + result_count: usize, + record_revision: Option, +} + +impl ReadResult { + fn empty_get() -> Self { + Self { + response: None, + result_count: 0, + record_revision: None, + } + } + + fn from_materialized( + operation: Operation, + materialized: MaterializedRead, + ) -> Result { + match operation { + Operation::Get => { + let Some(record) = materialized.rows.into_iter().next() else { + return Ok(Self::empty_get()); + }; + let revision = + i64::try_from(record.revision).map_err(|_| ReadServiceError::Unavailable)?; + let response = HeldReadResponse::from_json(&json!(record))?; + Ok(Self { + response: Some(response), + result_count: 1, + record_revision: Some(revision), + }) + } + Operation::List => { + let result_count = materialized.rows.len(); + let response = HeldReadResponse::from_json(&json!({ + "items": materialized.rows, + "pageInfo": {"nextCursor": materialized.next_cursor}, + }))?; + Ok(Self { + response: Some(response), + result_count, + record_revision: None, + }) + } + _ => Err(ReadServiceError::Unavailable), + } + } +} + +struct MaterializedRead { + rows: Vec, + next_cursor: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RecordEnvelope { + id: String, + revision: u64, + data: Map, +} + +struct ReadPlan { + operation: Operation, + entity: CompiledEntity, + query_operation: Option, +} + +impl ReadPlan { + fn from_request( + registry: &CompiledRegistry, + expected: &ExpectedRegistryIdentity, + cursors: &CursorCodec, + request: &RecordReadRequest, + operation: Operation, + ) -> Result { + let route = registry + .routes() + .routes + .iter() + .find(|route| route.id == request.operation_id) + .ok_or(())?; + let entity = registry.entities().get(&request.entity_id).ok_or(())?; + let profile = entity + .access_profiles + .get(request.context.selected_profile()) + .ok_or(())?; + let inventory = registry + .physical_names() + .entities + .get(&request.entity_id) + .ok_or(())?; + let query_operation = if operation == Operation::List { + let Some(query) = request.query.as_ref() else { + return Err(()); + }; + if route.query_kind != Some(query.kind) || query.route_id != route.id { + return Err(()); + } + let Some(operation) = registry.queries().operations.iter().find(|operation| { + operation.id == query.query_operation_id + && operation.route_id == route.id + && operation.entity_id == request.entity_id + && operation.profile_id == request.context.selected_profile() + && operation.kind == query.kind + }) else { + return Err(()); + }; + validate_compiled_query_request( + registry, expected, cursors, entity, operation, request, query, + )?; + Some(operation.clone()) + } else { + if request.query.is_some() { + return Err(()); + } + None + }; + if route.operation != operation + || route.method != request.method + || route.entity_id != request.entity_id + || !route + .access_profiles + .iter() + .any(|profile| profile == request.context.selected_profile()) + || !profile.operations.contains(&operation) + || request.maximum_records == 0 + || request.maximum_records > MAX_SQL_LIMIT + || operation == Operation::Get && request.maximum_records != 1 + || inventory.table != entity.physical_table + || !valid_physical_identifier(&entity.physical_table) + || entity.fields.iter().any(|(id, field)| { + inventory.fields.get(id) != Some(&field.physical_name) + || !valid_physical_identifier(&field.physical_name) + }) + || !request.selected_fields.is_subset(&profile.readable_fields) + || request + .selected_fields + .iter() + .any(|field| !entity.fields.contains_key(field)) + { + return Err(()); + } + Ok(Self { + operation, + entity: entity.clone(), + query_operation, + }) + } +} + +fn validate_compiled_query_request( + registry: &CompiledRegistry, + expected: &ExpectedRegistryIdentity, + cursors: &CursorCodec, + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + request: &RecordReadRequest, + query: &crate::api::CompiledReadQuery, +) -> Result<(), ()> { + let selected_fields = request.selected_fields.iter().cloned().collect::>(); + let expected_maximum = usize::from(query.page_size).checked_add(1).ok_or(())?; + if query.page_size == 0 + || query.page_size > operation.max_page_size + || request.maximum_records != expected_maximum + || request.maximum_records > MAX_SQL_LIMIT + || query.cursor_binding.package_revision != expected.package_revision + || query.cursor_binding.schema_fingerprint != expected.schema_fingerprint + || query.cursor_binding.registry_revision != registry.revision() + || query.cursor_binding.route_id != query.route_id + || query.cursor_binding.query_operation_id != query.query_operation_id + || query.cursor_binding.query_kind != query.kind + || query.cursor_binding.selected_profile != request.context.selected_profile() + || query.cursor_binding.page_size != query.page_size + || query.cursor_binding.temporal_instant != query.temporal_instant + || query.cursor_binding.selected_fields != selected_fields + || !valid_optional_cursor_reference(query.cursor_binding.principal_reference.as_deref()) + || !valid_optional_cursor_reference(query.cursor_binding.purpose_reference.as_deref()) + || !valid_cursor_reference(&query.cursor_binding.row_boundary_reference) + || !valid_cursor_reference(&query.cursor_binding.projection_reference) + || !valid_cursor_reference(&query.cursor_binding.query_reference) + || !valid_cursor_reference(&query.cursor_binding.sort_reference) + || !request + .selected_fields + .iter() + .all(|field| operation.projection_fields.contains(field)) + || !operation + .projection_fields + .iter() + .all(|field| entity.fields.contains_key(field)) + { + return Err(()); + } + match query.kind { + CompiledQueryKind::List => { + if query.temporal_instant.is_some() || operation.temporal.is_some() { + return Err(()); + } + } + CompiledQueryKind::Current | CompiledQueryKind::AsOf => { + let Some(binding) = operation.temporal.as_ref() else { + return Err(()); + }; + if query.temporal_instant.is_none() + || !entity.fields.contains_key(&binding.start_field) + || !entity.fields.contains_key(&binding.end_field) + { + return Err(()); + } + } + } + if query.filters.len() > 32 { + return Err(()); + } + let mut total_in_values = 0_usize; + for filter in &query.filters { + let Some(compiled_filter) = operation + .filter_fields + .iter() + .find(|candidate| candidate.field == filter.field) + else { + return Err(()); + }; + if !compiled_filter.operators.contains(&filter.operator) + || !entity.fields.contains_key(&filter.field) + { + return Err(()); + } + let field_type = &entity.fields[&filter.field].field_type; + match filter.operator { + CompiledQueryFilterOperator::Equals | CompiledQueryFilterOperator::Prefix => { + if filter.values.len() != 1 + || validate_field_value(&filter.values[0], field_type).is_err() + { + return Err(()); + } + } + CompiledQueryFilterOperator::In => { + if filter.values.is_empty() { + return Err(()); + } + total_in_values = total_in_values.checked_add(filter.values.len()).ok_or(())?; + if total_in_values > 100 + || filter + .values + .windows(2) + .any(|window| window[0] >= window[1]) + || filter + .values + .iter() + .any(|value| validate_field_value(value, field_type).is_err()) + { + return Err(()); + } + } + CompiledQueryFilterOperator::Range => { + if filter.values.len() != 2 + || filter + .values + .iter() + .any(|value| validate_field_value(value, field_type).is_err()) + { + return Err(()); + } + } + CompiledQueryFilterOperator::IsNull | CompiledQueryFilterOperator::IsNotNull => { + if filter.values.len() != 1 || filter.values[0] != "true" { + return Err(()); + } + } + } + } + if let Some(sort) = &query.sort { + let Some(compiled_sort) = operation + .sort_fields + .iter() + .find(|candidate| candidate.field == *sort) + else { + return Err(()); + }; + if !compiled_sort + .directions + .contains(&CompiledQuerySortDirection::Asc) + || !entity.fields.contains_key(sort) + { + return Err(()); + } + } + if let Some(continuation) = &query.continuation { + if !valid_canonical_uuid(&continuation.last_record_id) { + return Err(()); + } + match (&query.sort, &continuation.sort_value) { + (Some(sort), Some(value)) => { + let Some(field) = entity.fields.get(sort) else { + return Err(()); + }; + if validate_field_value(value, &field.field_type).is_err() { + return Err(()); + } + } + (Some(_), None) | (None, None) => {} + (None, Some(_)) => return Err(()), + } + } + let expected_filters = query + .filters + .iter() + .map(|filter| crate::cursor::CursorFilter { + field: filter.field.clone(), + operator: query_filter_operator_name(filter.operator).to_owned(), + values: filter.values.clone(), + }) + .collect::>(); + if query.cursor_query.filters != expected_filters || query.cursor_query.sort != query.sort { + return Err(()); + } + let references = cursor_binding_references(cursors, request, operation, query)?; + if query.cursor_binding.principal_reference != references.principal + || query.cursor_binding.purpose_reference != references.purpose + || query.cursor_binding.row_boundary_reference != references.row_boundary + || query.cursor_binding.projection_reference != references.projection + || query.cursor_binding.query_reference != references.query + || query.cursor_binding.sort_reference != references.sort + { + return Err(()); + } + Ok(()) +} + +struct CursorBindingReferences { + principal: Option, + purpose: Option, + row_boundary: String, + projection: String, + query: String, + sort: String, +} + +fn cursor_binding_references( + cursors: &CursorCodec, + request: &RecordReadRequest, + operation: &CompiledQueryOperation, + query: &crate::api::CompiledReadQuery, +) -> Result { + let principal = request + .context + .principal() + .map(|value| { + cursors.binding_digest_bytes(b"registry-server-cursor-principal-v1", value.as_bytes()) + }) + .transpose() + .map_err(|_| ())?; + let purpose = request + .context + .purpose() + .map(|value| { + cursors.binding_digest_bytes(b"registry-server-cursor-purpose-v1", value.as_bytes()) + }) + .transpose() + .map_err(|_| ())?; + let row_boundary = cursors + .binding_digest( + b"registry-server-cursor-row-boundary-v1", + &json!(request + .context + .row_boundaries() + .iter() + .map(|boundary| { + json!({ + "field": boundary.field(), + "operator": match boundary.operator() { + ApiRowBoundaryOperator::Equals => "equals", + ApiRowBoundaryOperator::In => "in", + }, + "values": boundary.values(), + }) + }) + .collect::>()), + ) + .map_err(|_| ())?; + let selected_fields = request.selected_fields.iter().cloned().collect::>(); + let projection = cursors + .binding_digest( + b"registry-server-cursor-projection-v1", + &json!({"selectedFields": selected_fields}), + ) + .map_err(|_| ())?; + let query_reference = cursors + .binding_digest( + b"registry-server-cursor-query-v1", + &json!({ + "filters": query.cursor_query.filters, + "temporalInstant": query.temporal_instant, + }), + ) + .map_err(|_| ())?; + let sort = cursors + .binding_digest( + b"registry-server-cursor-sort-v1", + &json!({ + "sort": query.sort, + "tieBreaker": operation.stable_tie_breaker, + }), + ) + .map_err(|_| ())?; + Ok(CursorBindingReferences { + principal, + purpose, + row_boundary, + projection, + query: query_reference, + sort, + }) +} + +fn query_filter_operator_name(operator: CompiledQueryFilterOperator) -> &'static str { + match operator { + CompiledQueryFilterOperator::Equals => "equals", + CompiledQueryFilterOperator::In => "in", + CompiledQueryFilterOperator::Range => "range", + CompiledQueryFilterOperator::IsNull => "is_null", + CompiledQueryFilterOperator::IsNotNull => "is_not_null", + CompiledQueryFilterOperator::Prefix => "prefix", + } +} + +fn valid_optional_cursor_reference(value: Option<&str>) -> bool { + value.is_none_or(valid_cursor_reference) +} + +fn valid_cursor_reference(value: &str) -> bool { + const PREFIX: &str = "hmac-sha256:"; + value.strip_prefix(PREFIX).is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) +} + +fn strict_claim_context( + registry: &CompiledRegistry, + context: &AuthorizedRequestContext, + entity_id: &str, +) -> Result { + let row_boundaries = context + .row_boundaries() + .iter() + .map(|boundary| match boundary.operator() { + ApiRowBoundaryOperator::Equals => { + let value = boundary + .values() + .iter() + .next() + .ok_or(ReadServiceError::Unavailable)?; + if boundary.values().len() != 1 { + return Err(ReadServiceError::Unavailable); + } + Ok(RowBoundaryContext::Equals { + field: boundary.field().to_owned(), + value: value.clone(), + }) + } + ApiRowBoundaryOperator::In => Ok(RowBoundaryContext::In { + field: boundary.field().to_owned(), + values: boundary.values().clone(), + }), + }) + .collect::, _>>()?; + ClaimContext::for_compiled( + registry, + entity_id, + context.principal().map(str::to_owned), + context.selected_profile(), + context.purpose().map(str::to_owned), + row_boundaries, + ) + .map_err(|_| ReadServiceError::Unavailable) +} + +fn projection( + entity: &CompiledEntity, + selected_fields: &[String], + sort: Option<&str>, +) -> Result { + let mut expressions = vec!["record_id::text".to_owned(), "record_revision".to_owned()]; + for field in selected_fields { + let Some(compiled_field) = entity.fields.get(field) else { + return Err(ReadServiceError::Unavailable); + }; + let column = quote_identifier(&compiled_field.physical_name); + if matches!(compiled_field.field_type, FieldTypeSource::Decimal { .. }) { + expressions.push(format!("to_jsonb({column}::text)")); + } else { + expressions.push(format!("to_jsonb({column})")); + } + } + if let Some(sort) = sort { + let Some(compiled_field) = entity.fields.get(sort) else { + return Err(ReadServiceError::Unavailable); + }; + let column = quote_identifier(&compiled_field.physical_name); + if matches!(compiled_field.field_type, FieldTypeSource::Decimal { .. }) { + expressions.push(format!("to_jsonb({column}::text)")); + } else { + expressions.push(format!("to_jsonb({column})")); + } + } + Ok(expressions.join(", ")) +} + +fn list_sql( + entity: &CompiledEntity, + query: &crate::api::CompiledReadQuery, + projection: &str, + table: &str, +) -> Result<(String, Vec), ReadServiceError> { + let mut values = Vec::new(); + let mut predicates = vec!["record_lifecycle = 'active'".to_owned()]; + let mut grouped_in: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for filter in &query.filters { + let Some(compiled_field) = entity.fields.get(&filter.field) else { + return Err(ReadServiceError::Unavailable); + }; + let column = quote_identifier(&compiled_field.physical_name); + let cast = postgres_cast(&compiled_field.field_type); + match filter.operator { + CompiledQueryFilterOperator::Equals => { + let parameter = push_value(&mut values, &filter.values[0]); + predicates.push(format!("{column} = ${parameter}::text::{cast}")); + } + CompiledQueryFilterOperator::In => { + if filter.values.is_empty() { + return Err(ReadServiceError::Unavailable); + } + grouped_in + .entry(&filter.field) + .or_default() + .extend(filter.values.iter().map(String::as_str)); + } + CompiledQueryFilterOperator::Range => { + let lower = push_value(&mut values, &filter.values[0]); + let upper = push_value(&mut values, &filter.values[1]); + predicates.push(format!( + "{column} >= ${lower}::text::{cast} AND {column} <= ${upper}::text::{cast}" + )); + } + CompiledQueryFilterOperator::IsNull => predicates.push(format!("{column} IS NULL")), + CompiledQueryFilterOperator::IsNotNull => { + predicates.push(format!("{column} IS NOT NULL")); + } + CompiledQueryFilterOperator::Prefix => { + let parameter = + push_value(&mut values, &format!("{}%", escape_like(&filter.values[0]))); + predicates.push(format!("{column} LIKE ${parameter}::text ESCAPE '\\'")); + } + } + } + for (field, finite_values) in grouped_in { + if finite_values.is_empty() { + return Err(ReadServiceError::Unavailable); + } + let Some(compiled_field) = entity.fields.get(field) else { + return Err(ReadServiceError::Unavailable); + }; + let column = quote_identifier(&compiled_field.physical_name); + let cast = postgres_cast(&compiled_field.field_type); + let placeholders = finite_values + .iter() + .map(|value| { + let parameter = push_value(&mut values, value); + format!("${parameter}::text::{cast}") + }) + .collect::>(); + predicates.push(format!("{column} IN ({})", placeholders.join(", "))); + } + if let Some(instant) = &query.temporal_instant { + let temporal = entity + .temporal + .as_ref() + .ok_or(ReadServiceError::Unavailable)?; + let start_field = entity + .fields + .get(&temporal.start_field) + .ok_or(ReadServiceError::Unavailable)?; + let end_field = entity + .fields + .get(&temporal.end_field) + .ok_or(ReadServiceError::Unavailable)?; + let start = quote_identifier(&start_field.physical_name); + let end = quote_identifier(&end_field.physical_name); + let parameter = push_value(&mut values, instant); + let instant_expression = + temporal_instant_expression(&start_field.field_type, &end_field.field_type, parameter)?; + predicates.push(format!( + "{start} <= {instant_expression} AND ({end} IS NULL OR {instant_expression} < {end})" + )); + } else if matches!( + query.kind, + CompiledQueryKind::Current | CompiledQueryKind::AsOf + ) { + return Err(ReadServiceError::Unavailable); + } + if let Some(continuation) = &query.continuation { + if !valid_canonical_uuid(&continuation.last_record_id) { + return Err(ReadServiceError::CursorInvalid); + } + let record_parameter = push_value(&mut values, &continuation.last_record_id); + if let Some(sort) = &query.sort { + let Some(compiled_field) = entity.fields.get(sort) else { + return Err(ReadServiceError::Unavailable); + }; + let column = quote_identifier(&compiled_field.physical_name); + let cast = postgres_cast(&compiled_field.field_type); + match &continuation.sort_value { + Some(value) => { + validate_field_value(value, &compiled_field.field_type) + .map_err(|_| ReadServiceError::CursorInvalid)?; + let sort_parameter = push_value(&mut values, value); + predicates.push(format!( + "({column} > ${sort_parameter}::text::{cast} OR {column} IS NULL OR ({column} = ${sort_parameter}::text::{cast} AND record_id > ${record_parameter}::text::uuid))" + )); + } + None => predicates.push(format!( + "({column} IS NULL AND record_id > ${record_parameter}::text::uuid)" + )), + } + } else { + predicates.push(format!("record_id > ${record_parameter}::text::uuid")); + } + } + let order = if let Some(sort) = &query.sort { + let column = quote_identifier( + &entity + .fields + .get(sort) + .ok_or(ReadServiceError::Unavailable)? + .physical_name, + ); + format!("{column} ASC NULLS LAST, record_id ASC") + } else { + "record_id ASC".to_owned() + }; + let limit_parameter = values.len() + 1; + Ok(( + format!( + "SELECT {projection} + FROM registry_data.{table} + WHERE {} + ORDER BY {order} + LIMIT ${limit_parameter}::bigint", + predicates.join(" AND ") + ), + values, + )) +} + +fn temporal_instant_expression( + start_type: &FieldTypeSource, + end_type: &FieldTypeSource, + parameter: usize, +) -> Result { + match (start_type, end_type) { + (FieldTypeSource::Date, FieldTypeSource::Date) => Ok(format!( + "((${parameter}::text::timestamptz AT TIME ZONE 'UTC')::date)" + )), + (FieldTypeSource::Timestamp, FieldTypeSource::Timestamp) => { + Ok(format!("${parameter}::text::timestamptz")) + } + _ => Err(ReadServiceError::Unavailable), + } +} + +fn push_value(values: &mut Vec, value: &str) -> usize { + values.push(value.to_owned()); + values.len() +} + +fn postgres_cast(field_type: &FieldTypeSource) -> &'static str { + match field_type { + FieldTypeSource::Boolean => "boolean", + FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::VocabularyCode { .. } => "text", + FieldTypeSource::Int64 => "bigint", + FieldTypeSource::Decimal { .. } => "numeric", + FieldTypeSource::Date => "date", + FieldTypeSource::Timestamp => "timestamptz", + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => "uuid", + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => "jsonb", + } +} + +fn escape_like(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if matches!(character, '%' | '_' | '\\') { + escaped.push('\\'); + } + escaped.push(character); + } + escaped +} + +fn cursor_sort_value(value: Value) -> Option { + match value { + Value::Null => None, + Value::Bool(value) => Some(value.to_string()), + Value::Number(value) => Some(value.to_string()), + Value::String(value) => Some(value), + Value::Array(_) | Value::Object(_) => None, + } +} + +fn row_to_record( + row: &tokio_postgres::Row, + entity: &CompiledEntity, + selected_fields: &[String], +) -> Result { + let id = row + .try_get::<_, String>(0) + .map_err(|_| ReadServiceError::Unavailable)?; + let revision = row + .try_get::<_, i64>(1) + .map_err(|_| ReadServiceError::Unavailable)?; + if !valid_canonical_uuid(&id) || revision <= 0 || row.len() < selected_fields.len() + 2 { + return Err(ReadServiceError::Unavailable); + } + let revision = u64::try_from(revision).map_err(|_| ReadServiceError::Unavailable)?; + let mut data = Map::new(); + for (index, field) in selected_fields.iter().enumerate() { + if !entity.fields.contains_key(field) { + return Err(ReadServiceError::Unavailable); + } + let value = row + .try_get::<_, Option>(index + 2) + .map_err(|_| ReadServiceError::Unavailable)? + .unwrap_or(Value::Null); + data.insert(field.clone(), value); + } + Ok(RecordEnvelope { id, revision, data }) +} + +fn field_set_reference( + profile: &AuditProfile, + package_revision: &str, + selected_fields: &BTreeSet, +) -> Result { + let canonical = canonicalize_json(&json!({ + "selectedFields": selected_fields, + })) + .map_err(|_| ReadServiceError::Unavailable)?; + let canonical = std::str::from_utf8(&canonical).map_err(|_| ReadServiceError::Unavailable)?; + profile + .key_hasher() + .audit_reference_hash( + "registry-server-read-field-set-v1", + package_revision, + canonical, + ) + .map_err(|_| ReadServiceError::Unavailable) +} + +fn valid_physical_identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + bytes + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_lowercase()) + && bytes.all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()) + && value.len() <= 63 +} + +fn quote_identifier(value: &str) -> String { + debug_assert!(valid_physical_identifier(value)); + format!("\"{value}\"") +} + +fn valid_canonical_uuid(value: &str) -> bool { + value.len() == 36 + && value.bytes().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => byte == b'-', + _ => byte.is_ascii_hexdigit(), + }) + && Uuid::parse_str(value).is_ok_and(|identifier| identifier.to_string() == value) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::time::Duration; + + use crate::api::{ + AuthorizedRequestContext, CompiledReadQuery, ReadFilterClause, RecordReadRequest, + }; + use crate::compiler::{compile_project, CompileProfile}; + use crate::contract::{parse_project_json, FieldTypeSource, Operation}; + use crate::cursor::{CursorBinding, CursorCodec, CursorContinuation, CursorQuery}; + use crate::model::{CompiledQueryFilterOperator, CompiledQueryKind, HttpMethod}; + use zeroize::Zeroizing; + + use super::{ + cursor_binding_references, temporal_instant_expression, ExpectedRegistryIdentity, ReadPlan, + ReadServiceError, + }; + + #[test] + fn temporal_query_instant_uses_utc_calendar_dates_without_session_timezone_dependence() { + assert_eq!( + temporal_instant_expression(&FieldTypeSource::Date, &FieldTypeSource::Date, 7) + .expect("date temporal fields are valid"), + "(($7::text::timestamptz AT TIME ZONE 'UTC')::date)" + ); + assert_eq!( + temporal_instant_expression( + &FieldTypeSource::Timestamp, + &FieldTypeSource::Timestamp, + 3, + ) + .expect("timestamp temporal fields are valid"), + "$3::text::timestamptz" + ); + assert!(matches!( + temporal_instant_expression(&FieldTypeSource::Date, &FieldTypeSource::Timestamp, 1,), + Err(ReadServiceError::Unavailable) + )); + } + + #[test] + fn forged_compiled_query_shapes_fail_before_sql_construction() { + let registry = compile_project( + &parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"plan-guard","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"case","route":"cases","mutationMode":"mutable","classification":"public", + "fields":[ + {"id":"label","type":"string","required":true,"maxLength":32,"classification":"public"}, + {"id":"secret","type":"string","required":true,"maxLength":32,"classification":"restricted"} + ], + "accessProfiles":[{ + "id":"public","default":true,"anonymous":true,"operations":["list"], + "readableFields":["label"],"filterableFields":["label"],"sortableFields":["label"] + }] + }] + }"#, + ) + .expect("fixture parses"), + &[], + CompileProfile::Authoring, + ) + .expect("fixture compiles"); + let expected = ExpectedRegistryIdentity { + package_id: "package".to_owned(), + environment: "local".to_owned(), + instance_id: "instance".to_owned(), + database_id: "database".to_owned(), + package_revision: "package-revision".to_owned(), + schema_fingerprint: "schema-fingerprint".to_owned(), + package_sequence: 1, + }; + let operation = registry + .queries() + .operations + .iter() + .find(|operation| operation.kind == CompiledQueryKind::List) + .expect("list query operation exists"); + let cursors = CursorCodec::new(Zeroizing::new(vec![0x19; 32]), Duration::from_secs(300)) + .expect("test cursor codec is valid"); + + let mut request = RecordReadRequest { + entity_id: "case".to_owned(), + operation_id: "records.case.list".to_owned(), + method: HttpMethod::Get, + record_id: None, + context: AuthorizedRequestContext::new(None, None, "public".to_owned(), Vec::new()), + selected_fields: BTreeSet::from(["label".to_owned()]), + query: Some(CompiledReadQuery { + route_id: operation.route_id.clone(), + query_operation_id: operation.id.clone(), + kind: CompiledQueryKind::List, + cursor_binding: CursorBinding { + package_revision: expected.package_revision.clone(), + schema_fingerprint: expected.schema_fingerprint.clone(), + registry_revision: registry.revision().to_owned(), + route_id: operation.route_id.clone(), + query_operation_id: operation.id.clone(), + query_kind: CompiledQueryKind::List, + selected_profile: "public".to_owned(), + principal_reference: None, + purpose_reference: None, + row_boundary_reference: digest(), + projection_reference: digest(), + query_reference: digest(), + sort_reference: digest(), + page_size: 10, + temporal_instant: None, + selected_fields: vec!["label".to_owned()], + }, + cursor_query: CursorQuery { + filters: Vec::new(), + sort: None, + }, + filters: vec![ReadFilterClause { + field: "secret".to_owned(), + operator: CompiledQueryFilterOperator::Equals, + values: vec!["hidden".to_owned()], + }], + sort: None, + page_size: 10, + temporal_instant: None, + continuation: None, + }), + maximum_records: 11, + }; + assert!( + ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) + .is_err() + ); + + let query = request.query.as_mut().expect("query present"); + query.filters.clear(); + query.sort = Some("secret".to_owned()); + query.cursor_query.sort = Some("secret".to_owned()); + assert!( + ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) + .is_err() + ); + + let query = request.query.as_mut().expect("query present"); + query.sort = None; + query.cursor_query.sort = None; + query.cursor_binding.query_reference = "hidden-raw-query-value".to_owned(); + assert!( + ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) + .is_err() + ); + + let query = request.query.as_mut().expect("query present"); + query.cursor_binding.query_reference = digest(); + query.continuation = Some(CursorContinuation { + last_record_id: "not-a-canonical-uuid".to_owned(), + sort_value: None, + }); + assert!( + ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) + .is_err() + ); + + let query = request.query.as_mut().expect("query present"); + query.continuation = None; + let references = cursor_binding_references( + &cursors, + &request, + operation, + request.query.as_ref().expect("query present"), + ) + .expect("bounded request context has cursor references"); + let query = request.query.as_mut().expect("query present"); + query.cursor_binding.principal_reference = references.principal; + query.cursor_binding.purpose_reference = references.purpose; + query.cursor_binding.row_boundary_reference = references.row_boundary; + query.cursor_binding.projection_reference = references.projection; + query.cursor_binding.query_reference = references.query; + query.cursor_binding.sort_reference = references.sort; + assert!( + ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) + .is_ok() + ); + + request + .query + .as_mut() + .expect("query present") + .cursor_binding + .query_reference = digest(); + assert!( + ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) + .is_err(), + "a well-shaped forged binding digest fails before SQL construction" + ); + } + + fn digest() -> String { + format!("hmac-sha256:{}", "0".repeat(64)) + } +} + +#[cfg(feature = "postgres-test")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReadFaultPoint { + BeforeTerminalAudit, +} + +#[cfg(not(feature = "postgres-test"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ReadFaultPoint { + BeforeTerminalAudit, +} + +#[derive(Clone, Copy)] +enum ReadFaultControl { + Disabled, + #[cfg(feature = "postgres-test")] + At(ReadFaultPoint), +} + +impl ReadFaultControl { + fn fail_at(self, point: ReadFaultPoint) -> Result<(), ReadServiceError> { + #[cfg(feature = "postgres-test")] + if matches!(self, Self::At(configured) if configured == point) { + return Err(ReadServiceError::Unavailable); + } + let _ = (self, point); + Ok(()) + } +} diff --git a/crates/registry-server/src/postgres/revision_read.rs b/crates/registry-server/src/postgres/revision_read.rs new file mode 100644 index 0000000000..b47ea278d3 --- /dev/null +++ b/crates/registry-server/src/postgres/revision_read.rs @@ -0,0 +1,796 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded PostgreSQL reads over the canonical internal revision journal. + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use registry_platform_audit::AuditProfile; +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use tokio_postgres::types::ToSql; +use uuid::Uuid; + +use crate::api::{ + AuthorizedRequestContext, HeldReadResponse, ReadServiceError, RevisionReadRequest, + RevisionReadService, RowBoundaryOperator as ApiRowBoundaryOperator, ServiceFuture, +}; +use crate::audit::{ + append_read_terminal_audit, profile_is_keyed, record_pre_io_audit, PreIoAudit, PreIoAuditKind, + ReadTerminalAudit, TerminalAudit, TerminalAuditOutcome, +}; +use crate::contract::{FieldTypeSource, Operation}; +use crate::model::{ + CompiledEntity, CompiledRegistry, CompiledRevisionKind, HttpMethod, + MAX_REVISION_HISTORY_RECORDS, +}; + +use super::{ + begin_record_transaction, validate_field_value, ClaimContext, ExpectedRegistryIdentity, + RegistryLockKey, RowBoundaryContext, RuntimePool, +}; + +const MAX_JOURNAL_TEXT_BYTES: usize = 512; +const MAX_SNAPSHOT_BYTES: usize = 2 * 1024 * 1024; + +/// Runtime revision-history implementation. Every list is the newest 100 +/// authorized journal entries and detail reads exactly one positive revision. +#[derive(Clone)] +pub struct PostgresRevisionReadService { + pool: RuntimePool, + registry: Arc, + expected: ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + lock_timeout: Duration, + audit_profile: AuditProfile, + fault: RevisionReadFaultControl, +} + +impl PostgresRevisionReadService { + #[must_use] + pub fn new( + pool: RuntimePool, + registry: Arc, + expected: ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + lock_timeout: Duration, + audit_profile: AuditProfile, + ) -> Self { + Self { + pool, + registry, + expected, + lock_key, + lock_timeout, + audit_profile, + fault: RevisionReadFaultControl::Disabled, + } + } + + #[cfg(feature = "postgres-test")] + #[must_use] + #[doc(hidden)] + pub fn with_fault_for_test(mut self, fault: RevisionReadFaultPoint) -> Self { + self.fault = RevisionReadFaultControl::At(fault); + self + } + + async fn execute( + &self, + request: RevisionReadRequest, + ) -> Result { + if !profile_is_keyed(&self.audit_profile) { + return Err(ReadServiceError::Unavailable); + } + let mut client = self + .pool + .get() + .await + .map_err(|_| ReadServiceError::Unavailable)?; + let claims = strict_claim_context(&self.registry, &request.context, &request.entity_id)?; + let plan = match RevisionReadPlan::from_request(&self.registry, &request) { + Ok(plan) => plan, + Err(()) => { + record_pre_io_audit( + &mut client, + self.lock_key, + self.lock_timeout, + &self.expected, + &claims, + &self.audit_profile, + PreIoAudit { + kind: PreIoAuditKind::Refusal, + method: request.method, + operation_id: &request.operation_id, + target_record: Some(&request.record_id), + }, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + return Ok(RevisionReadResult::missing()); + } + }; + + record_pre_io_audit( + &mut client, + self.lock_key, + self.lock_timeout, + &self.expected, + &claims, + &self.audit_profile, + PreIoAudit { + kind: PreIoAuditKind::Attempt, + method: request.method, + operation_id: &request.operation_id, + target_record: Some(&request.record_id), + }, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + + let materialized = self.read_rows(&mut client, &request, &claims, &plan).await; + let materialized = match materialized { + Ok(materialized) => materialized, + Err(error) => { + let _ = self + .record_terminal( + &mut client, + &claims, + &request, + &plan, + TerminalAuditOutcome::Refused, + 0, + &[], + ) + .await; + return Err(error); + } + }; + let held = RevisionReadResult::from_rows(plan.kind, materialized)?; + self.fault + .fail_at(RevisionReadFaultPoint::BeforeTerminalAudit)?; + let outcome = if held.result_count == 0 { + TerminalAuditOutcome::Empty + } else { + TerminalAuditOutcome::Returned + }; + self.record_terminal( + &mut client, + &claims, + &request, + &plan, + outcome, + held.result_count, + held.response.as_ref().map_or(&[], HeldReadResponse::body), + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + Ok(held) + } + + async fn read_rows( + &self, + client: &mut deadpool_postgres::Client, + request: &RevisionReadRequest, + claims: &ClaimContext, + plan: &RevisionReadPlan, + ) -> Result, ReadServiceError> { + let transaction = begin_record_transaction( + client, + self.lock_key, + self.lock_timeout, + &self.expected, + claims, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + let record_id = + Uuid::parse_str(&request.record_id).map_err(|_| ReadServiceError::Unavailable)?; + if record_id.to_string() != request.record_id { + return Err(ReadServiceError::Unavailable); + } + let (sql, parameters) = revision_sql(request, &plan.entity, record_id)?; + let parameter_refs = parameters + .iter() + .map(|value| &**value as &(dyn ToSql + Sync)) + .collect::>(); + let rows = transaction + .transaction() + .query(&sql, ¶meter_refs) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + let rows = rows + .iter() + .map(|row| { + revision_from_row( + row, + &plan.entity, + &request.context, + &request.selected_fields, + ) + }) + .collect::, _>>()?; + transaction + .commit() + .await + .map_err(|_| ReadServiceError::Unavailable)?; + Ok(rows) + } + + #[allow(clippy::too_many_arguments)] + async fn record_terminal( + &self, + client: &mut deadpool_postgres::Client, + claims: &ClaimContext, + request: &RevisionReadRequest, + plan: &RevisionReadPlan, + outcome: TerminalAuditOutcome, + result_count: usize, + _exact_response_bytes: &[u8], + ) -> Result<(), crate::audit::RegistryAuditError> { + let key_hasher = self.audit_profile.key_hasher(); + let principal_reference = claims + .principal() + .map(|principal| { + key_hasher.audit_reference_hash( + "registry-server-principal-v1", + &self.expected.package_revision, + principal, + ) + }) + .transpose() + .map_err(|_| crate::audit::RegistryAuditError::InvalidContext)?; + let record_reference = key_hasher + .audit_reference_hash( + "registry-server-record-v1", + &self.expected.package_revision, + &request.record_id, + ) + .map_err(|_| crate::audit::RegistryAuditError::InvalidContext)?; + let field_set_reference = field_set_reference( + &self.audit_profile, + &self.expected.package_revision, + &request.selected_fields, + )?; + let row_boundary_reference = + row_boundary_reference(&self.audit_profile, &self.expected.package_revision, claims)?; + let transaction = begin_record_transaction( + client, + self.lock_key, + self.lock_timeout, + &self.expected, + claims, + ) + .await + .map_err(|_| crate::audit::RegistryAuditError::Unavailable)?; + append_read_terminal_audit( + transaction.transaction(), + &self.audit_profile, + ReadTerminalAudit { + terminal: TerminalAudit { + outcome, + method: request.method, + operation_id: request.operation_id.clone(), + entity_id: plan.entity.id.clone(), + package_revision: self.expected.package_revision.clone(), + selected_access_profile: claims.access_profile().to_owned(), + purpose_present: claims.purpose().is_some(), + principal_reference, + record_reference: Some(record_reference), + // Revision values are intentionally absent from revision-read audit. + record_revision: None, + result_count: Some(result_count), + field_set_reference: Some(field_set_reference), + }, + query_reference: None, + row_boundary_reference: Some(row_boundary_reference), + }, + ) + .await?; + transaction + .commit() + .await + .map_err(|_| crate::audit::RegistryAuditError::Unavailable) + } +} + +impl RevisionReadService for PostgresRevisionReadService { + fn detail( + &self, + request: RevisionReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(async move { Ok(self.execute(request).await?.response) }) + } + + fn list( + &self, + request: RevisionReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(async move { Ok(self.execute(request).await?.response) }) + } + + fn refusal( + &self, + request: crate::api::RevisionReadRefusal, + ) -> ServiceFuture<'_, Result<(), ReadServiceError>> { + Box::pin(async move { + let mut client = self + .pool + .get() + .await + .map_err(|_| ReadServiceError::Unavailable)?; + crate::audit::record_http_refusal_audit( + &mut client, + self.lock_key, + self.lock_timeout, + &self.expected, + &self.audit_profile, + crate::audit::HttpRefusalAudit { + method: request.method, + operation_id: &request.operation_id, + target_record: request.target_record.as_deref(), + principal: request.principal.as_deref(), + selected_access_profile: request.selected_access_profile.as_deref(), + purpose_present: request.purpose_present, + }, + ) + .await + .map_err(|_| ReadServiceError::Unavailable) + }) + } +} + +struct RevisionReadPlan { + kind: CompiledRevisionKind, + entity: CompiledEntity, +} + +impl RevisionReadPlan { + fn from_request( + registry: &CompiledRegistry, + request: &RevisionReadRequest, + ) -> Result { + let route = registry + .routes() + .routes + .iter() + .find(|route| route.id == request.operation_id) + .ok_or(())?; + let entity = registry.entities().get(&request.entity_id).ok_or(())?; + let profile = entity + .access_profiles + .get(request.context.selected_profile()) + .ok_or(())?; + let kind = route.revision_kind.ok_or(())?; + let expected_maximum = match kind { + CompiledRevisionKind::List => usize::from(MAX_REVISION_HISTORY_RECORDS), + CompiledRevisionKind::Detail => 1, + }; + if route.operation != Operation::Revisions + || route.method != HttpMethod::Get + || route.entity_id != request.entity_id + || route.maximum_records.map(usize::from) != Some(expected_maximum) + || request.maximum_records != expected_maximum + || matches!(kind, CompiledRevisionKind::List) != request.revision.is_none() + || request.revision.is_some_and(|revision| revision <= 0) + || profile.anonymous + || !profile.revision_access + || !profile.operations.contains(&Operation::Revisions) + || !route + .access_profiles + .iter() + .any(|candidate| candidate == request.context.selected_profile()) + || !request.selected_fields.is_subset(&profile.readable_fields) + || request + .selected_fields + .iter() + .any(|field| !entity.fields.contains_key(field)) + { + return Err(()); + } + Ok(Self { + kind, + entity: entity.clone(), + }) + } +} + +fn revision_sql( + request: &RevisionReadRequest, + entity: &CompiledEntity, + record_id: Uuid, +) -> Result<(String, Vec>), ReadServiceError> { + let mut parameters: Vec> = + vec![Box::new(request.entity_id.clone()), Box::new(record_id)]; + let mut predicates = vec![ + "entity_id = $1::text".to_owned(), + "record_id = $2::uuid".to_owned(), + ]; + if let Some(revision) = request.revision { + parameters.push(Box::new(revision)); + predicates.push(format!("record_revision = ${}::bigint", parameters.len())); + } + for boundary in request.context.row_boundaries() { + let field = entity + .fields + .get(boundary.field()) + .ok_or(ReadServiceError::Unavailable)?; + parameters.push(Box::new(boundary.field().to_owned())); + let key_parameter = parameters.len(); + let mut values = Vec::new(); + for value in boundary.values() { + let canonical = canonical_boundary_value(value, &field.field_type)?; + parameters.push(Box::new(canonical)); + values.push(format!("${}::text::jsonb", parameters.len())); + } + if values.is_empty() { + return Err(ReadServiceError::Unavailable); + } + let snapshot_value = + format!("(convert_from(snapshot, 'UTF8')::jsonb -> ${key_parameter}::text)"); + match boundary.operator() { + ApiRowBoundaryOperator::Equals if values.len() == 1 => { + predicates.push(format!("{snapshot_value} = {}", values[0])); + } + ApiRowBoundaryOperator::In => { + predicates.push(format!("{snapshot_value} IN ({})", values.join(", "))); + } + ApiRowBoundaryOperator::Equals => return Err(ReadServiceError::Unavailable), + } + } + let limit = + i64::try_from(request.maximum_records).map_err(|_| ReadServiceError::Unavailable)?; + parameters.push(Box::new(limit)); + let limit_parameter = parameters.len(); + Ok(( + format!( + "SELECT record_id, record_revision, predecessor_revision, record_lifecycle, + package_revision, operation_id, mutation_kind, principal_reference, + request_reference, snapshot, created_at + FROM registry_internal.registry_revisions + WHERE {} + ORDER BY record_revision DESC + LIMIT ${limit_parameter}::bigint", + predicates.join(" AND ") + ), + parameters, + )) +} + +fn canonical_boundary_value( + value: &str, + field_type: &FieldTypeSource, +) -> Result { + validate_field_value(value, field_type).map_err(|_| ReadServiceError::Unavailable)?; + let value = match field_type { + FieldTypeSource::Boolean => Value::Bool(value == "true"), + FieldTypeSource::Int64 => json!(value + .parse::() + .map_err(|_| ReadServiceError::Unavailable)?), + FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::Decimal { .. } + | FieldTypeSource::Date + | FieldTypeSource::Timestamp + | FieldTypeSource::Uuid + | FieldTypeSource::Reference { .. } + | FieldTypeSource::VocabularyCode { .. } => Value::String(value.to_owned()), + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + return Err(ReadServiceError::Unavailable); + } + }; + let bytes = canonicalize_json(&value).map_err(|_| ReadServiceError::Unavailable)?; + String::from_utf8(bytes).map_err(|_| ReadServiceError::Unavailable) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RevisionEnvelope { + id: String, + revision: u64, + predecessor_revision: Option, + lifecycle: String, + package_revision: String, + operation_id: String, + mutation_kind: String, + created_at: String, + actor_reference: String, + request_reference: String, + data: Map, +} + +fn revision_from_row( + row: &tokio_postgres::Row, + entity: &CompiledEntity, + context: &AuthorizedRequestContext, + selected_fields: &BTreeSet, +) -> Result { + let record_id = row + .try_get::<_, Uuid>(0) + .map_err(|_| ReadServiceError::Unavailable)?; + let revision = row + .try_get::<_, i64>(1) + .map_err(|_| ReadServiceError::Unavailable)?; + let predecessor = row + .try_get::<_, Option>(2) + .map_err(|_| ReadServiceError::Unavailable)?; + let lifecycle = bounded_text(row, 3)?; + let package_revision = bounded_text(row, 4)?; + let operation_id = bounded_text(row, 5)?; + let mutation_kind = bounded_text(row, 6)?; + let actor_reference = bounded_text(row, 7)?; + let request_reference = bounded_text(row, 8)?; + let snapshot = row + .try_get::<_, Vec>(9) + .map_err(|_| ReadServiceError::Unavailable)?; + let created_at = row + .try_get::<_, SystemTime>(10) + .map_err(|_| ReadServiceError::Unavailable)?; + if revision <= 0 + || predecessor.is_some_and(|value| value <= 0 || value >= revision) + || !matches!(lifecycle.as_str(), "active" | "tombstoned") + || !matches!(mutation_kind.as_str(), "create" | "patch" | "tombstone") + || operation_id != format!("records.{}.{}", entity.id, mutation_kind) + || !valid_hmac_reference(&actor_reference) + || !valid_hmac_reference(&request_reference) + || snapshot.is_empty() + || snapshot.len() > MAX_SNAPSHOT_BYTES + { + return Err(ReadServiceError::Unavailable); + } + let parsed = parse_json_strict(&snapshot).map_err(|_| ReadServiceError::Unavailable)?; + let canonical = canonicalize_json(&parsed).map_err(|_| ReadServiceError::Unavailable)?; + if canonical != snapshot { + return Err(ReadServiceError::Unavailable); + } + let snapshot = parsed.as_object().ok_or(ReadServiceError::Unavailable)?; + let mut data = Map::new(); + for field_id in selected_fields { + let field = entity + .fields + .get(field_id) + .ok_or(ReadServiceError::Unavailable)?; + let value = snapshot + .get(field_id) + .ok_or(ReadServiceError::Unavailable)?; + validate_snapshot_value(value, &field.field_type, field.required)?; + data.insert(field_id.clone(), value.clone()); + } + for boundary in context.row_boundaries() { + let field = entity + .fields + .get(boundary.field()) + .ok_or(ReadServiceError::Unavailable)?; + let value = snapshot + .get(boundary.field()) + .ok_or(ReadServiceError::Unavailable)?; + validate_snapshot_value(value, &field.field_type, field.required)?; + } + let revision = u64::try_from(revision).map_err(|_| ReadServiceError::Unavailable)?; + let predecessor_revision = predecessor + .map(u64::try_from) + .transpose() + .map_err(|_| ReadServiceError::Unavailable)?; + let created_at = OffsetDateTime::from(created_at) + .format(&Rfc3339) + .map_err(|_| ReadServiceError::Unavailable)?; + Ok(RevisionEnvelope { + id: record_id.to_string(), + revision, + predecessor_revision, + lifecycle, + package_revision, + operation_id, + mutation_kind, + created_at, + actor_reference, + request_reference, + data, + }) +} + +fn bounded_text(row: &tokio_postgres::Row, index: usize) -> Result { + let value = row + .try_get::<_, String>(index) + .map_err(|_| ReadServiceError::Unavailable)?; + if value.is_empty() + || value.len() > MAX_JOURNAL_TEXT_BYTES + || value.chars().any(char::is_control) + { + return Err(ReadServiceError::Unavailable); + } + Ok(value) +} + +fn valid_hmac_reference(value: &str) -> bool { + value.len() == 76 + && value.starts_with("hmac-sha256:") + && value[12..] + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn validate_snapshot_value( + value: &Value, + field_type: &FieldTypeSource, + required: bool, +) -> Result<(), ReadServiceError> { + if value.is_null() { + return (!required) + .then_some(()) + .ok_or(ReadServiceError::Unavailable); + } + let text = match (field_type, value) { + (FieldTypeSource::Boolean, Value::Bool(value)) => value.to_string(), + (FieldTypeSource::Int64, Value::Number(value)) if value.as_i64().is_some() => { + value.to_string() + } + ( + FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::Decimal { .. } + | FieldTypeSource::Date + | FieldTypeSource::Timestamp + | FieldTypeSource::Uuid + | FieldTypeSource::Reference { .. } + | FieldTypeSource::VocabularyCode { .. }, + Value::String(value), + ) => value.clone(), + (FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. }, value) => { + let bytes = canonicalize_json(value).map_err(|_| ReadServiceError::Unavailable)?; + String::from_utf8(bytes).map_err(|_| ReadServiceError::Unavailable)? + } + _ => return Err(ReadServiceError::Unavailable), + }; + validate_field_value(&text, field_type).map_err(|_| ReadServiceError::Unavailable) +} + +struct RevisionReadResult { + response: Option, + result_count: usize, +} + +impl RevisionReadResult { + fn missing() -> Self { + Self { + response: None, + result_count: 0, + } + } + + fn from_rows( + kind: CompiledRevisionKind, + rows: Vec, + ) -> Result { + let result_count = rows.len(); + let response = match kind { + CompiledRevisionKind::List if rows.is_empty() => return Ok(Self::missing()), + CompiledRevisionKind::List => HeldReadResponse::from_json(&json!({"items": rows}))?, + CompiledRevisionKind::Detail => { + let Some(row) = rows.into_iter().next() else { + return Ok(Self::missing()); + }; + HeldReadResponse::from_json(&json!(row))? + } + }; + Ok(Self { + response: Some(response), + result_count, + }) + } +} + +fn strict_claim_context( + registry: &CompiledRegistry, + context: &AuthorizedRequestContext, + entity_id: &str, +) -> Result { + let row_boundaries = context + .row_boundaries() + .iter() + .map(|boundary| match boundary.operator() { + ApiRowBoundaryOperator::Equals => { + let values = boundary.values(); + if values.len() != 1 { + return Err(ReadServiceError::Unavailable); + } + Ok(RowBoundaryContext::Equals { + field: boundary.field().to_owned(), + value: values.first().ok_or(ReadServiceError::Unavailable)?.clone(), + }) + } + ApiRowBoundaryOperator::In => Ok(RowBoundaryContext::In { + field: boundary.field().to_owned(), + values: boundary.values().clone(), + }), + }) + .collect::, _>>()?; + ClaimContext::for_compiled( + registry, + entity_id, + context.principal().map(str::to_owned), + context.selected_profile(), + context.purpose().map(str::to_owned), + row_boundaries, + ) + .map_err(|_| ReadServiceError::Unavailable) +} + +fn field_set_reference( + profile: &AuditProfile, + package_revision: &str, + selected_fields: &BTreeSet, +) -> Result { + let canonical = canonicalize_json(&json!({"selectedFields": selected_fields})) + .map_err(|_| crate::audit::RegistryAuditError::InvalidContext)?; + let canonical = std::str::from_utf8(&canonical) + .map_err(|_| crate::audit::RegistryAuditError::InvalidContext)?; + profile + .key_hasher() + .audit_reference_hash( + "registry-server-read-field-set-v1", + package_revision, + canonical, + ) + .map_err(|_| crate::audit::RegistryAuditError::InvalidContext) +} + +fn row_boundary_reference( + profile: &AuditProfile, + package_revision: &str, + claims: &ClaimContext, +) -> Result { + let canonical = canonicalize_json(&json!(claims + .row_boundaries() + .iter() + .map(|boundary| json!({ + "field": boundary.field(), + "operator": boundary.operator().as_str(), + "values": boundary.values(), + })) + .collect::>())) + .map_err(|_| crate::audit::RegistryAuditError::InvalidContext)?; + let canonical = std::str::from_utf8(&canonical) + .map_err(|_| crate::audit::RegistryAuditError::InvalidContext)?; + profile + .key_hasher() + .audit_reference_hash( + "registry-server-revision-row-boundary-v1", + package_revision, + canonical, + ) + .map_err(|_| crate::audit::RegistryAuditError::InvalidContext) +} + +#[cfg(feature = "postgres-test")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RevisionReadFaultPoint { + BeforeTerminalAudit, +} + +#[cfg(not(feature = "postgres-test"))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RevisionReadFaultPoint { + BeforeTerminalAudit, +} + +#[derive(Clone, Copy)] +enum RevisionReadFaultControl { + Disabled, + #[cfg(feature = "postgres-test")] + At(RevisionReadFaultPoint), +} + +impl RevisionReadFaultControl { + fn fail_at(self, point: RevisionReadFaultPoint) -> Result<(), ReadServiceError> { + #[cfg(feature = "postgres-test")] + if matches!(self, Self::At(configured) if configured == point) { + return Err(ReadServiceError::Unavailable); + } + let _ = (self, point); + Ok(()) + } +} diff --git a/crates/registry-server/src/postgres/roles.rs b/crates/registry-server/src/postgres/roles.rs new file mode 100644 index 0000000000..f96ec40025 --- /dev/null +++ b/crates/registry-server/src/postgres/roles.rs @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; + +use tokio_postgres::GenericClient; + +use super::{PostgresKernelError, Result}; + +/// A validated PostgreSQL identifier used only for governed provisioning. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SqlIdentifier(String); + +impl SqlIdentifier { + pub fn parse(value: &str) -> Result { + let mut characters = value.chars(); + let valid_start = characters + .next() + .is_some_and(|character| character == '_' || character.is_ascii_lowercase()); + let valid_rest = characters.all(|character| { + character == '_' || character.is_ascii_lowercase() || character.is_ascii_digit() + }); + if !valid_start || !valid_rest || value.len() > 63 { + return Err(PostgresKernelError::Configuration( + "PostgreSQL identifier is outside the governed identifier grammar", + )); + } + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub(crate) fn quoted(&self) -> QuotedIdentifier<'_> { + QuotedIdentifier(self) + } +} + +pub(crate) struct QuotedIdentifier<'a>(&'a SqlIdentifier); + +impl fmt::Display for QuotedIdentifier<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "\"{}\"", self.0.as_str()) + } +} + +/// Admin-only provisioning of the two managed schemas. +pub async fn provision_managed_schemas( + admin: &impl GenericClient, + migration_role: &SqlIdentifier, +) -> Result<()> { + for schema in ["registry_internal", "registry_data"] { + admin + .batch_execute(&format!( + "CREATE SCHEMA {schema} AUTHORIZATION {};\n\ + REVOKE ALL ON SCHEMA {schema} FROM PUBLIC;", + migration_role.quoted(), + )) + .await?; + } + Ok(()) +} + +pub async fn verify_btree_gist(client: &impl GenericClient) -> Result<()> { + let installed = client + .query_opt( + "SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'btree_gist'", + &[], + ) + .await? + .is_some(); + if !installed { + return Err(PostgresKernelError::CatalogInvariant( + "administrator-installed btree_gist is required", + )); + } + Ok(()) +} + +pub async fn verify_migration_role( + client: &impl GenericClient, + expected_role: &SqlIdentifier, +) -> Result<()> { + let row = client + .query_one( + "SELECT current_user, + rolsuper, + rolbypassrls, + rolcreatedb, + rolcreaterole, + has_database_privilege(current_user, current_database(), 'CREATE') + FROM pg_catalog.pg_roles + WHERE rolname = current_user", + &[], + ) + .await?; + let role: String = row.get(0); + if role != expected_role.as_str() { + return Err(PostgresKernelError::RoleInvariant( + "migration connection uses the wrong role", + )); + } + let forbidden = (1..=5).any(|index| row.get::<_, bool>(index)); + if forbidden { + return Err(PostgresKernelError::RoleInvariant( + "migration role has database-level administrative authority", + )); + } + verify_schema_owner(client, expected_role).await +} + +pub async fn verify_runtime_role( + client: &impl GenericClient, + migration_role: &SqlIdentifier, +) -> Result<()> { + let row = client + .query_one( + "SELECT current_user, + rolsuper, + rolbypassrls, + rolcreatedb, + rolcreaterole, + current_user = $1, + pg_has_role(current_user, $1, 'MEMBER'), + has_database_privilege(current_user, current_database(), 'CREATE'), + has_schema_privilege(current_user, 'registry_internal', 'CREATE'), + has_schema_privilege(current_user, 'registry_data', 'CREATE'), + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + AND c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user) + ) + FROM pg_catalog.pg_roles + WHERE rolname = current_user", + &[&migration_role.as_str()], + ) + .await?; + let forbidden = (1..=10).any(|index| row.get::<_, bool>(index)); + if forbidden { + return Err(PostgresKernelError::RoleInvariant( + "runtime role has ownership, bypass, or DDL authority", + )); + } + let permissions = client + .query_one( + "SELECT + has_table_privilege(current_user, 'registry_internal.registry_state', 'SELECT'), + has_table_privilege(current_user, 'registry_internal.registry_state', 'INSERT'), + has_table_privilege(current_user, 'registry_internal.registry_state', 'UPDATE'), + has_table_privilege(current_user, 'registry_internal.registry_state', 'DELETE'), + has_table_privilege(current_user, 'registry_data.kernel_records', 'SELECT'), + has_table_privilege(current_user, 'registry_data.kernel_records', 'INSERT'), + has_table_privilege(current_user, 'registry_data.kernel_records', 'UPDATE'), + has_table_privilege(current_user, 'registry_data.kernel_records', 'DELETE'), + has_table_privilege(current_user, 'registry_data.kernel_records', 'TRUNCATE'), + has_table_privilege(current_user, 'registry_data.kernel_records', 'REFERENCES'), + has_table_privilege(current_user, 'registry_data.kernel_records', 'TRIGGER')", + &[], + ) + .await?; + let required = [0, 4, 5, 6, 7] + .into_iter() + .all(|index| permissions.get::<_, bool>(index)); + let denied = [1, 2, 3, 8, 9, 10] + .into_iter() + .all(|index| !permissions.get::<_, bool>(index)); + if !required || !denied { + return Err(PostgresKernelError::RoleInvariant( + "runtime role has an unexpected managed-table privilege set", + )); + } + Ok(()) +} + +async fn verify_schema_owner( + client: &impl GenericClient, + expected_role: &SqlIdentifier, +) -> Result<()> { + let managed_schemas: &[&str] = &["registry_data", "registry_internal"]; + let rows = client + .query( + "SELECT n.nspname, r.rolname + FROM pg_catalog.pg_namespace n + JOIN pg_catalog.pg_roles r ON r.oid = n.nspowner + WHERE n.nspname = ANY($1::text[]) + ORDER BY n.nspname", + &[&managed_schemas], + ) + .await?; + if rows.len() != 2 + || rows + .iter() + .any(|row| row.get::<_, String>(1) != expected_role.as_str()) + { + return Err(PostgresKernelError::RoleInvariant( + "migration role does not own every managed schema", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn governed_identifier_grammar_refuses_sql_syntax_and_case_folding() { + for invalid in [ + "", + "Uppercase", + "9prefix", + "has-hyphen", + "quoted\"", + "role;drop", + ] { + assert!( + SqlIdentifier::parse(invalid).is_err(), + "accepted {invalid:?}" + ); + } + assert!(SqlIdentifier::parse(&"a".repeat(64)).is_err()); + assert_eq!( + SqlIdentifier::parse("runtime_role_1") + .expect("governed identifier parses") + .as_str(), + "runtime_role_1" + ); + } +} diff --git a/crates/registry-server/src/postgres/schema.rs b/crates/registry-server/src/postgres/schema.rs new file mode 100644 index 0000000000..892e97d007 --- /dev/null +++ b/crates/registry-server/src/postgres/schema.rs @@ -0,0 +1,407 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Installation of the compiler-owned PostgreSQL data surface. + +#[cfg(not(all(feature = "runtime", feature = "tooling")))] +use tokio_postgres::GenericClient; +#[cfg(all(feature = "runtime", feature = "tooling", feature = "postgres-test"))] +use tokio_postgres::NoTls; +#[cfg(all(feature = "runtime", feature = "tooling"))] +use tokio_postgres::{Client, GenericClient}; + +use crate::generated_ddl::DdlStatementKind; +use crate::model::CompiledRegistry; +use crate::mutation::install_mutation_schema; + +#[cfg(all(feature = "runtime", feature = "tooling"))] +use super::config::ConnectionTls; +use super::{ + catalog::install_registry_state_schema, verify_btree_gist, PostgresKernelError, Result, + SqlIdentifier, +}; +#[cfg(all(feature = "runtime", feature = "tooling"))] +use super::{ + catalog::{ + managed_schema_fingerprint, verify_catalog_identity_for_catalog, ExpectedManagedCatalog, + ExpectedRegistryIdentity, + }, + verify_migration_role, ConnectionConfig, RegistryLockKey, RuntimePool, +}; + +/// Installs one exact compiled Registry data inventory and its closed runtime +/// privilege set. The caller must already be the verified migration role and +/// must own both managed schemas. +pub async fn install_compiled_schema( + migration: &impl GenericClient, + registry: &CompiledRegistry, + runtime_role: &SqlIdentifier, +) -> Result<()> { + if registry.ddl().requires_btree_gist { + verify_btree_gist(migration).await?; + } + + install_registry_state_schema(migration, runtime_role).await?; + install_mutation_schema(migration, runtime_role) + .await + .map_err(|_| PostgresKernelError::Connection)?; + + for statement in ®istry.ddl().statements { + if statement.kind == DdlStatementKind::Schema { + continue; + } + migration.batch_execute(&statement.sql).await?; + } + + reconcile_compiled_runtime_acl(migration, registry, runtime_role).await +} + +pub(crate) async fn reconcile_compiled_runtime_acl( + client: &impl GenericClient, + registry: &CompiledRegistry, + runtime_role: &SqlIdentifier, +) -> Result<()> { + client + .batch_execute(&format!( + "REVOKE ALL ON SCHEMA registry_data FROM PUBLIC, {}; + GRANT USAGE ON SCHEMA registry_data TO {};", + runtime_role.quoted(), + runtime_role.quoted(), + )) + .await?; + + for table in ®istry.ddl().tables { + let table_name = quote_compiled_identifier(&table.physical_name); + client + .batch_execute(&format!( + "REVOKE ALL ON TABLE registry_data.{table_name} FROM PUBLIC, {};", + runtime_role.quoted(), + )) + .await?; + if !table.runtime_privileges.is_empty() { + let privileges = table + .runtime_privileges + .iter() + .map(|privilege| privilege.as_sql()) + .collect::>() + .join(", "); + client + .batch_execute(&format!( + "GRANT {privileges} ON TABLE registry_data.{table_name} TO {};", + runtime_role.quoted(), + )) + .await?; + } + } + Ok(()) +} + +/// Candidate identity installed into a clean schema-test database. +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub struct SchemaTestDatabaseIdentity<'a> { + pub environment: &'a str, + pub instance_id: &'a str, + pub database_id: &'a str, + pub active_package_revision: &'a str, + pub active_sequence: u64, +} + +/// Opaque capability for the production pre-sign schema-test executor. +/// +/// It is intentionally not a prepared server: callers cannot obtain a pool, +/// client, router, listener, or response from it. The fixture executor consumes +/// it inside the crate and dispatches only validated journey requests. +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub struct PreparedSchemaTestDatabase { + pool: RuntimePool, + migration_connection: ConnectionConfig, + expected: ExpectedRegistryIdentity, + expected_catalog: ExpectedManagedCatalog, + migration_role: SqlIdentifier, + runtime_role: SqlIdentifier, + lock_key: RegistryLockKey, +} + +#[cfg(all(feature = "runtime", feature = "tooling"))] +#[derive(Clone)] +pub(crate) struct PreparedSchemaTestCatalogVerifier { + migration_connection: ConnectionConfig, + expected: ExpectedRegistryIdentity, + expected_catalog: ExpectedManagedCatalog, + migration_role: SqlIdentifier, + runtime_role: SqlIdentifier, + lock_key: RegistryLockKey, +} + +#[cfg(all(feature = "runtime", feature = "tooling"))] +impl PreparedSchemaTestDatabase { + pub(crate) fn pool(&self) -> RuntimePool { + self.pool.clone() + } + + pub(crate) fn expected(&self) -> &ExpectedRegistryIdentity { + &self.expected + } + + pub(crate) fn lock_key(&self) -> RegistryLockKey { + self.lock_key + } + + pub(crate) fn catalog_verifier(&self) -> PreparedSchemaTestCatalogVerifier { + PreparedSchemaTestCatalogVerifier { + migration_connection: self.migration_connection.clone(), + expected: self.expected.clone(), + expected_catalog: self.expected_catalog.clone(), + migration_role: self.migration_role.clone(), + runtime_role: self.runtime_role.clone(), + lock_key: self.lock_key, + } + } +} + +#[cfg(all(feature = "runtime", feature = "tooling"))] +impl PreparedSchemaTestCatalogVerifier { + pub(crate) async fn verify(&self) -> Result<()> { + let (mut client, connection_task) = connect_schema_test(&self.migration_connection).await?; + verify_migration_role(&client, &self.migration_role).await?; + let transaction = client.transaction().await?; + transaction + .batch_execute("SET LOCAL lock_timeout = '5s'") + .await?; + transaction + .execute( + "SELECT pg_advisory_xact_lock_shared($1)", + &[&self.lock_key.get()], + ) + .await + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + verify_catalog_identity_for_catalog( + &transaction, + &self.expected, + &self.expected_catalog, + &self.migration_role, + &self.runtime_role, + ) + .await?; + transaction.commit().await?; + connection_task.abort(); + Ok(()) + } +} + +/// Prepare a clean, pre-provisioned PostgreSQL target for a pre-sign schema +/// test. The caller supplies the explicit migration and runtime connection +/// configurations resolved from trusted runtime configuration. +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub async fn prepare_schema_test_database_with_connections( + migration_connection: &ConnectionConfig, + runtime_connection: &ConnectionConfig, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, + registry: &CompiledRegistry, + identity: SchemaTestDatabaseIdentity<'_>, +) -> Result { + let (mut migration, migration_task) = connect_schema_test(migration_connection).await?; + verify_migration_role(&migration, migration_role).await?; + let transaction = migration.transaction().await?; + refuse_existing_managed_objects(&transaction).await?; + install_compiled_schema(&transaction, registry, runtime_role).await?; + + let expected_catalog = ExpectedManagedCatalog::compiled(registry); + let schema_fingerprint = + managed_schema_fingerprint(&transaction, runtime_role, &expected_catalog).await?; + let package_sequence = i64::try_from(identity.active_sequence).map_err(|_| { + PostgresKernelError::Configuration("schema-test package sequence is out of range") + })?; + let expected = ExpectedRegistryIdentity { + package_id: registry.registry_id().to_owned(), + environment: identity.environment.to_owned(), + instance_id: identity.instance_id.to_owned(), + database_id: identity.database_id.to_owned(), + package_revision: identity.active_package_revision.to_owned(), + schema_fingerprint, + package_sequence, + }; + let inserted = transaction + .execute( + "INSERT INTO registry_internal.registry_state ( + singleton, package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status + ) VALUES (true, $1, $2, $3, $4, $5, $6, $7, 'ready')", + &[ + &expected.package_id, + &expected.environment, + &expected.instance_id, + &expected.database_id, + &expected.package_revision, + &expected.schema_fingerprint, + &expected.package_sequence, + ], + ) + .await?; + if inserted != 1 { + return Err(PostgresKernelError::CatalogInvariant( + "schema-test registry state was not installed exactly once", + )); + } + verify_catalog_identity_for_catalog( + &transaction, + &expected, + &expected_catalog, + migration_role, + runtime_role, + ) + .await?; + transaction.commit().await?; + migration_task.abort(); + + let pool = runtime_connection.build_pool()?; + let (runtime, runtime_task) = connect_schema_test(runtime_connection).await?; + verify_schema_test_runtime_role(&runtime, migration_role, runtime_role).await?; + verify_catalog_identity_for_catalog( + &runtime, + &expected, + &expected_catalog, + migration_role, + runtime_role, + ) + .await?; + runtime_task.abort(); + + let lock_key = RegistryLockKey::derive(&expected.package_id)?; + Ok(PreparedSchemaTestDatabase { + pool, + migration_connection: migration_connection.clone(), + expected, + expected_catalog, + migration_role: migration_role.clone(), + runtime_role: runtime_role.clone(), + lock_key, + }) +} + +/// Install one compiled Registry into an empty disposable database transaction, +/// verify the exact managed catalog, roll the transaction back, and return only +/// its schema fingerprint. This is a measurement path only: it never writes +/// active package identity and it never returns a pool, client, database id, +/// role, URL, or SQL. +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub(crate) async fn rehearse_schema_fingerprint_with_connection( + migration_connection: &ConnectionConfig, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, + registry: &CompiledRegistry, +) -> Result { + let (mut migration, migration_task) = connect_schema_test(migration_connection).await?; + let migration_result: Result = async { + verify_migration_role(&migration, migration_role).await?; + let transaction = migration.transaction().await?; + transaction + .batch_execute("SET LOCAL lock_timeout = '5s'") + .await?; + refuse_existing_managed_objects(&transaction).await?; + install_compiled_schema(&transaction, registry, runtime_role).await?; + let expected_catalog = ExpectedManagedCatalog::compiled(registry); + let fingerprint = + managed_schema_fingerprint(&transaction, runtime_role, &expected_catalog).await?; + transaction.rollback().await?; + Ok(fingerprint) + } + .await; + migration_task.abort(); + migration_result +} + +#[cfg(all(feature = "runtime", feature = "tooling"))] +async fn connect_schema_test( + config: &ConnectionConfig, +) -> Result<(Client, tokio::task::JoinHandle<()>)> { + match config.tls_connector() { + ConnectionTls::Rustls(connector) => { + let (client, connection) = config.postgres().connect(connector).await?; + let task = tokio::spawn(async move { + let _ = connection.await; + }); + Ok((client, task)) + } + #[cfg(feature = "postgres-test")] + ConnectionTls::TestOnlyPlaintext => { + let (client, connection) = config.postgres().connect(NoTls).await?; + let task = tokio::spawn(async move { + let _ = connection.await; + }); + Ok((client, task)) + } + } +} + +#[cfg(all(feature = "runtime", feature = "tooling"))] +async fn refuse_existing_managed_objects(client: &impl GenericClient) -> Result<()> { + client + .batch_execute("SAVEPOINT registry_empty_schema_probe") + .await?; + let empty = client + .batch_execute("DROP SCHEMA registry_internal RESTRICT; DROP SCHEMA registry_data RESTRICT") + .await + .is_ok(); + client + .batch_execute( + "ROLLBACK TO SAVEPOINT registry_empty_schema_probe; + RELEASE SAVEPOINT registry_empty_schema_probe", + ) + .await?; + if !empty { + return Err(PostgresKernelError::CatalogInvariant( + "schema-test database is not clean", + )); + } + Ok(()) +} + +#[cfg(all(feature = "runtime", feature = "tooling"))] +async fn verify_schema_test_runtime_role( + client: &impl GenericClient, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, +) -> Result<()> { + let row = client + .query_one( + "SELECT current_user, + rolsuper, + rolbypassrls, + rolcreatedb, + rolcreaterole, + current_user = $1, + pg_has_role(current_user, $1, 'MEMBER'), + has_database_privilege(current_user, current_database(), 'CREATE'), + has_schema_privilege(current_user, 'registry_internal', 'CREATE'), + has_schema_privilege(current_user, 'registry_data', 'CREATE'), + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + AND c.relowner = ( + SELECT oid + FROM pg_catalog.pg_roles + WHERE rolname = current_user + ) + ) + FROM pg_catalog.pg_roles + WHERE rolname = current_user", + &[&migration_role.as_str()], + ) + .await?; + if row.get::<_, String>(0) != runtime_role.as_str() + || (1..=10).any(|index| row.get::<_, bool>(index)) + { + return Err(PostgresKernelError::RoleInvariant( + "schema-test runtime connection uses unexpected authority", + )); + } + Ok(()) +} + +fn quote_compiled_identifier(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} diff --git a/crates/registry-server/src/revision.rs b/crates/registry-server/src/revision.rs new file mode 100644 index 0000000000..6a57e6d95d --- /dev/null +++ b/crates/registry-server/src/revision.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Complete canonical revision snapshots for Registry Server mutations. + +use registry_platform_canonical_json::canonicalize_json; +use serde_json::{Map, Value}; +use tokio_postgres::Transaction; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum RevisionError { + #[error("revision snapshot is invalid")] + InvalidSnapshot, + #[error("revision journal is unavailable")] + Unavailable, +} + +pub(crate) fn canonical_snapshot(data: &Map) -> Result, RevisionError> { + canonicalize_json(&Value::Object(data.clone())).map_err(|_| RevisionError::InvalidSnapshot) +} + +pub(crate) struct RevisionInsert<'a> { + pub entity_id: &'a str, + pub record_id: Uuid, + pub record_reference: &'a str, + pub record_revision: i64, + pub predecessor_revision: Option, + pub lifecycle: &'a str, + pub package_revision: &'a str, + pub operation_id: &'a str, + pub mutation_kind: &'a str, + pub principal_reference: &'a str, + pub request_reference: &'a str, + pub snapshot: &'a [u8], +} + +pub(crate) async fn insert_revision( + transaction: &Transaction<'_>, + revision: RevisionInsert<'_>, +) -> Result<(), RevisionError> { + if revision.entity_id.is_empty() + || revision.record_reference.is_empty() + || revision.record_revision <= 0 + || revision + .predecessor_revision + .is_some_and(|predecessor| predecessor <= 0 || predecessor >= revision.record_revision) + || !matches!(revision.lifecycle, "active" | "tombstoned") + || revision.package_revision.is_empty() + || revision.operation_id.is_empty() + || !matches!(revision.mutation_kind, "create" | "patch" | "tombstone") + || revision.principal_reference.is_empty() + || revision.request_reference.is_empty() + || revision.snapshot.is_empty() + || revision.snapshot.len() > 2 * 1024 * 1024 + { + return Err(RevisionError::InvalidSnapshot); + } + let changed = transaction + .execute( + "INSERT INTO registry_internal.registry_revisions + (entity_id, record_id, record_reference, record_revision, + predecessor_revision, record_lifecycle, package_revision, operation_id, + mutation_kind, principal_reference, request_reference, snapshot) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + &[ + &revision.entity_id, + &revision.record_id, + &revision.record_reference, + &revision.record_revision, + &revision.predecessor_revision, + &revision.lifecycle, + &revision.package_revision, + &revision.operation_id, + &revision.mutation_kind, + &revision.principal_reference, + &revision.request_reference, + &revision.snapshot, + ], + ) + .await + .map_err(|_| RevisionError::Unavailable)?; + if changed != 1 { + return Err(RevisionError::Unavailable); + } + Ok(()) +} diff --git a/crates/registry-server/src/runtime_config.rs b/crates/registry-server/src/runtime_config.rs new file mode 100644 index 0000000000..2b26f28c1c --- /dev/null +++ b/crates/registry-server/src/runtime_config.rs @@ -0,0 +1,1798 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Strict deployment-only runtime configuration for Registry Server. + +use std::{ + collections::HashSet, + fmt, fs, + io::Read, + net::SocketAddr, + path::{Component, Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use jsonwebtoken::jwk::JwkSet; +use registry_platform_audit::AuditProfile; +use registry_platform_config::{ + expand_config_env_vars_with, SecretError, SecretProvider, SecretReference, SecretResolver, +}; +use registry_platform_crypto::{parse_json_strict, PublicJwk, SigningAlgorithm}; +use registry_platform_oidc::{ + fetch_discovery, JwksFetcher, JwksFetcherConfig, OidcDiscoveryConfig, TokenVerifierConfig, +}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use thiserror::Error; +use zeroize::Zeroizing; + +use crate::{ + auth::{AuthorityClaimConfig, RowBoundaryClaimMapping, RowBoundaryClaimType}, + cursor::CursorCodec, + event_destination::{ + ActivatedEventDestinationRegistry, EventDestinationConfigs, RawEventDestinationConfigs, + }, + model::CompiledRegistry, + package::{PackageIntent, PackageLoadContext}, + postgres::{ConnectionConfig, PoolBounds, SqlIdentifier}, +}; + +const MAX_RUNTIME_CONFIG_BYTES: u64 = 64 * 1024; +const MAX_PATH_BYTES: usize = 512; +const MAX_DEPLOYMENT_VALUE_BYTES: usize = 256; +const MAX_OIDC_VALUE_BYTES: usize = 2048; +const MAX_LIST_ITEMS: usize = 128; +const MAX_LIST_VALUE_BYTES: usize = 512; +const MAX_JWKS_DOCUMENT_BYTES: u64 = 1024 * 1024; +const MIN_RSA_MODULUS_BITS: usize = 2048; +const MAX_RSA_MODULUS_BITS: usize = 8192; +const MAX_RSA_EXPONENT_BYTES: usize = 8; + +#[derive(Debug, Error, Clone, Copy, Eq, PartialEq)] +pub enum RuntimeConfigError { + #[error("the runtime configuration file is unavailable")] + Unavailable, + #[error("the runtime configuration file is unsafe")] + UnsafeFile, + #[error("the runtime configuration exceeds its resource bounds")] + Bounds, + #[error("runtime configuration environment expansion was refused")] + EnvExpansion, + #[error("the runtime configuration document is invalid")] + Document, + #[error("runtime configuration contains a governed member")] + GovernedMember, + #[error("runtime configuration contains an invalid deployment binding")] + InvalidBinding, + #[error("runtime configuration contains an invalid listener binding")] + InvalidListener, + #[error("runtime configuration contains an invalid secret provider binding")] + InvalidSecretProvider, + #[error("runtime configuration contains an invalid database binding")] + InvalidDatabase, + #[error("runtime configuration contains an invalid package binding")] + InvalidPackage, + #[error("runtime configuration contains an invalid OIDC binding")] + InvalidOidc, + #[error("runtime configuration contains an invalid audit binding")] + InvalidAudit, + #[error("runtime configuration contains an invalid cursor binding")] + InvalidCursor, + #[error("runtime configuration contains an invalid event destination binding")] + InvalidEventDestination, + #[error("runtime configuration contains invalid operational bounds")] + InvalidBounds, + #[error("runtime configuration secret resolution failed")] + Secret, +} + +impl From for RuntimeConfigError { + fn from(_error: SecretError) -> Self { + Self::Secret + } +} + +pub type Result = std::result::Result; + +pub fn load_runtime_config(path: &Path) -> Result { + load_runtime_config_with_env(path, |name| std::env::var(name).ok()) +} + +pub fn load_runtime_config_with_env( + path: &Path, + lookup: impl Fn(&str) -> Option, +) -> Result { + validate_absolute_lexical_path(path, RuntimeConfigError::UnsafeFile)?; + reject_symlink_components(path, RuntimeConfigError::UnsafeFile)?; + let bytes = read_bounded_runtime_config(path, MAX_RUNTIME_CONFIG_BYTES)?; + let raw = std::str::from_utf8(&bytes).map_err(|_| RuntimeConfigError::Document)?; + parse_runtime_config_with_env(raw, lookup).and_then(|config| { + config.validate_loaded_paths()?; + Ok(config) + }) +} + +pub fn parse_runtime_config(raw: &str) -> Result { + parse_runtime_config_with_env(raw, |name| std::env::var(name).ok()) +} + +pub fn parse_runtime_config_with_env( + raw: &str, + lookup: impl Fn(&str) -> Option, +) -> Result { + if raw.is_empty() || raw.len() > usize::try_from(MAX_RUNTIME_CONFIG_BYTES).unwrap_or(usize::MAX) + { + return Err(RuntimeConfigError::Bounds); + } + let expanded = + expand_config_env_vars_with(raw, lookup).map_err(|_| RuntimeConfigError::EnvExpansion)?; + if expanded.len() > usize::try_from(MAX_RUNTIME_CONFIG_BYTES).unwrap_or(usize::MAX) { + return Err(RuntimeConfigError::Bounds); + } + parse_expanded_runtime_config(&expanded) +} + +fn parse_expanded_runtime_config(expanded: &str) -> Result { + reject_governed_members(expanded)?; + let raw: RawRuntimeConfig = + serde_norway::from_str(expanded).map_err(|_| RuntimeConfigError::Document)?; + RuntimeConfig::from_raw(raw) +} + +fn reject_governed_members(raw: &str) -> Result<()> { + let value: serde_norway::Value = + serde_norway::from_str(raw).map_err(|_| RuntimeConfigError::Document)?; + if contains_governed_member(&value) { + return Err(RuntimeConfigError::GovernedMember); + } + Ok(()) +} + +fn contains_governed_member(value: &serde_norway::Value) -> bool { + const GOVERNED: &[&str] = &[ + "entities", + "fields", + "accessProfiles", + "routes", + "events", + "packages", + "sources", + "semantics", + "classifications", + "relationships", + "mutationMode", + "readableFields", + "writableFields", + "rowBoundaries", + "requiredScopes", + "requiredPurposes", + "retention", + "webhooks", + "telemetry", + "cors", + ]; + match value { + serde_norway::Value::Mapping(mapping) => mapping.iter().any(|(key, value)| { + key.as_str().is_some_and(|key| GOVERNED.contains(&key)) + // Destination-map keys are compiler-issued logical ids. Do not + // reinterpret an id such as `events` as a governed field; the + // strict destination value type rejects every undeployed key. + || (!key + .as_str() + .is_some_and(|key| key == "eventDestinations") + && contains_governed_member(value)) + }), + serde_norway::Value::Sequence(values) => values.iter().any(contains_governed_member), + _ => false, + } +} + +#[derive(Clone)] +pub struct RuntimeConfig { + listener: ListenerConfig, + identity: DeploymentIdentity, + secret_providers: SecretProvidersConfig, + database: DatabaseConfig, + package: PackageConfig, + authentication: AuthenticationConfig, + audit: AuditConfig, + cursor: CursorConfig, + event_destinations: EventDestinationConfigs, + operational_timeouts: OperationalTimeouts, +} + +impl RuntimeConfig { + fn from_raw(raw: RawRuntimeConfig) -> Result { + let listener = ListenerConfig::from_raw(raw.listener)?; + let identity = DeploymentIdentity::from_raw(raw.identity)?; + let secret_providers = SecretProvidersConfig::from_raw(raw.secret_providers)?; + let database = DatabaseConfig::from_raw(raw.database)?; + let package = PackageConfig::from_raw(raw.package)?; + let authentication = AuthenticationConfig::from_raw(raw.authentication)?; + let audit = AuditConfig::from_raw(raw.audit)?; + let cursor = CursorConfig::from_raw(raw.cursor)?; + let event_destinations = EventDestinationConfigs::from_raw(raw.event_destinations) + .map_err(|_| RuntimeConfigError::InvalidEventDestination)?; + let operational_timeouts = OperationalTimeouts::from_raw(raw.operational_timeouts)?; + Ok(Self { + listener, + identity, + secret_providers, + database, + package, + authentication, + audit, + cursor, + event_destinations, + operational_timeouts, + }) + } + + pub fn listener(&self) -> &ListenerConfig { + &self.listener + } + + pub fn identity(&self) -> &DeploymentIdentity { + &self.identity + } + + pub fn database(&self) -> &DatabaseConfig { + &self.database + } + + pub fn package(&self) -> &PackageConfig { + &self.package + } + + pub fn authentication(&self) -> &AuthenticationConfig { + &self.authentication + } + + pub fn audit(&self) -> &AuditConfig { + &self.audit + } + + pub fn cursor(&self) -> &CursorConfig { + &self.cursor + } + + pub async fn oidc_key_source(&self) -> Result> { + self.authentication + .oidc + .key_source(&self.secret_resolver()?) + .await + } + + /// Activate the exact deployment bindings required by the compiled Registry. + pub fn activate_event_destinations( + &self, + compiled: &CompiledRegistry, + ) -> std::result::Result< + ActivatedEventDestinationRegistry, + crate::event_destination::EventDestinationActivationError, + > { + let resolver = self + .secret_resolver() + .map_err(|_| crate::event_destination::EventDestinationActivationError::Secret)?; + ActivatedEventDestinationRegistry::activate(compiled, &self.event_destinations, &resolver) + } + + pub fn operational_timeouts(&self) -> &OperationalTimeouts { + &self.operational_timeouts + } + + pub fn secret_resolver(&self) -> Result { + self.secret_providers.resolver() + } + + pub fn runtime_database_connection_config(&self) -> Result { + self.database_connection_config_for( + &self.database.runtime_url_ref, + self.database.roles.runtime(), + ) + } + + pub fn migration_database_connection_config(&self) -> Result { + self.database_connection_config_for( + &self.database.migration_url_ref, + self.database.roles.migration(), + ) + } + + fn database_connection_config_for( + &self, + url_ref: &SecretReference, + expected_role: &SqlIdentifier, + ) -> Result { + let secret = self.secret_resolver()?.resolve_reference(url_ref)?; + let url = + std::str::from_utf8(secret.expose_secret()).map_err(|_| RuntimeConfigError::Secret)?; + let postgres = url + .parse::() + .map_err(|_| RuntimeConfigError::InvalidDatabase)?; + if postgres.get_user() != Some(expected_role.as_str()) { + return Err(RuntimeConfigError::InvalidDatabase); + } + ConnectionConfig::require_tls_config(postgres, self.database.pool_bounds) + .map_err(|_| RuntimeConfigError::InvalidDatabase) + } + + pub fn audit_profile(&self) -> Result { + let secret = self + .secret_resolver()? + .resolve_reference(&self.audit.hash_key_ref)?; + AuditProfile::production_from_secret_bytes(Zeroizing::new(secret.expose_secret().to_vec())) + .map_err(|_| RuntimeConfigError::InvalidAudit) + } + + pub fn cursor_codec(&self) -> Result { + let secret = self + .secret_resolver()? + .resolve_reference(&self.cursor.secret_ref)?; + CursorCodec::new( + Zeroizing::new(secret.expose_secret().to_vec()), + self.cursor.max_age, + ) + .map_err(|_| RuntimeConfigError::InvalidCursor) + } + + pub fn package_load_context(&self) -> PackageLoadContext<'_> { + PackageLoadContext { + environment: self.identity.environment.as_str(), + instance_id: self.identity.instance_id.as_str(), + database_id: self.identity.database_id.as_str(), + database_initialization_environment: self + .identity + .database_initialization_environment + .as_str(), + compiler_source_revision: self.package.compiler_source_revision.as_str(), + trust_anchor: self.package_trust_anchor(), + intent: PackageIntent::Startup { + active_revision: self.package.active_revision.as_str(), + active_sequence: self.package.active_sequence, + }, + } + } + + /// Production package verification is anchored. Local unsigned packages + /// must not carry trust authority into the package verifier. + pub fn package_trust_anchor(&self) -> Option<&Path> { + (self.identity.database_initialization_environment != "local") + .then_some(self.package.trust_anchor_path.as_path()) + } + + fn validate_loaded_paths(&self) -> Result<()> { + validate_existing_directory(&self.package.root, RuntimeConfigError::InvalidPackage)?; + if let Some(trust_anchor) = self.package_trust_anchor() { + validate_existing_file(trust_anchor, RuntimeConfigError::InvalidPackage)?; + } + if let Some(root) = self.secret_providers.file_root() { + validate_existing_directory(root, RuntimeConfigError::InvalidSecretProvider)?; + } + Ok(()) + } +} + +impl fmt::Debug for RuntimeConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RuntimeConfig") + .field("listener", &self.listener) + .field("identity", &self.identity) + .field("secret_providers", &self.secret_providers) + .field("database", &self.database) + .field("package", &self.package) + .field("authentication", &self.authentication) + .field("audit", &self.audit) + .field("cursor", &self.cursor) + .field("event_destinations", &self.event_destinations) + .field("operational_timeouts", &self.operational_timeouts) + .finish() + } +} + +#[derive(Clone)] +pub struct ListenerConfig { + bind: SocketAddr, + trusted_proxy: TrustedProxyPosture, +} + +impl ListenerConfig { + fn from_raw(raw: RawListenerConfig) -> Result { + let bind = raw + .bind + .parse::() + .map_err(|_| RuntimeConfigError::InvalidListener)?; + Ok(Self { + bind, + trusted_proxy: raw.trusted_proxy, + }) + } + + pub fn bind(&self) -> SocketAddr { + self.bind + } + + pub fn trusted_proxy(&self) -> TrustedProxyPosture { + self.trusted_proxy + } +} + +impl fmt::Debug for ListenerConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ListenerConfig") + .field("bind", &"") + .field("trusted_proxy", &self.trusted_proxy) + .finish() + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum TrustedProxyPosture { + Direct, + OperatorControlledUpstream, +} + +#[derive(Clone)] +pub struct DeploymentIdentity { + environment: String, + instance_id: String, + database_id: String, + database_initialization_environment: String, +} + +impl DeploymentIdentity { + fn from_raw(raw: RawDeploymentIdentity) -> Result { + validate_deployment_value(&raw.environment)?; + validate_deployment_value(&raw.instance_id)?; + validate_deployment_value(&raw.database_id)?; + validate_deployment_value(&raw.database_initialization_environment)?; + Ok(Self { + environment: raw.environment, + instance_id: raw.instance_id, + database_id: raw.database_id, + database_initialization_environment: raw.database_initialization_environment, + }) + } + + pub fn environment(&self) -> &str { + &self.environment + } + + pub fn instance_id(&self) -> &str { + &self.instance_id + } + + pub fn database_id(&self) -> &str { + &self.database_id + } + + pub fn database_initialization_environment(&self) -> &str { + &self.database_initialization_environment + } +} + +impl fmt::Debug for DeploymentIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DeploymentIdentity") + .field("environment", &"") + .field("instance_id", &"") + .field("database_id", &"") + .field("database_initialization_environment", &"") + .finish() + } +} + +#[derive(Clone)] +pub struct SecretProvidersConfig { + environment: bool, + file: Option, +} + +impl SecretProvidersConfig { + fn from_raw(raw: RawSecretProvidersConfig) -> Result { + if raw.environment.is_none() && raw.file.is_none() { + return Err(RuntimeConfigError::InvalidSecretProvider); + } + let file = raw + .file + .map(FileSecretProviderConfig::from_raw) + .transpose()?; + Ok(Self { + environment: raw.environment.is_some(), + file, + }) + } + + fn resolver(&self) -> Result { + let mut providers = Vec::new(); + if self.environment { + providers.push(SecretProvider::Environment); + } + if self.file.is_some() { + providers.push(SecretProvider::File); + } + let root = self + .file + .as_ref() + .map_or_else(PathBuf::new, |file| file.root.clone()); + SecretResolver::new(providers, root).map_err(Into::into) + } + + fn file_root(&self) -> Option<&Path> { + self.file.as_ref().map(|file| file.root.as_path()) + } +} + +impl fmt::Debug for SecretProvidersConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SecretProvidersConfig") + .field("environment", &self.environment) + .field("file", &self.file.as_ref().map(|_| "")) + .finish() + } +} + +#[derive(Clone)] +pub struct FileSecretProviderConfig { + root: PathBuf, +} + +impl FileSecretProviderConfig { + fn from_raw(raw: RawFileSecretProviderConfig) -> Result { + validate_absolute_lexical_path(&raw.root, RuntimeConfigError::InvalidSecretProvider)?; + Ok(Self { root: raw.root }) + } +} + +#[derive(Clone)] +pub struct DatabaseConfig { + runtime_url_ref: SecretReference, + migration_url_ref: SecretReference, + pool_bounds: PoolBounds, + roles: SqlRoles, +} + +impl DatabaseConfig { + fn from_raw(raw: RawDatabaseConfig) -> Result { + if raw.plaintext.is_some() || raw.url.is_some() || raw.password.is_some() { + return Err(RuntimeConfigError::InvalidDatabase); + } + let runtime_url_ref = + parse_secret_reference(raw.runtime_url_ref, RuntimeConfigError::InvalidDatabase)?; + let migration_url_ref = + parse_secret_reference(raw.migration_url_ref, RuntimeConfigError::InvalidDatabase)?; + if runtime_url_ref == migration_url_ref { + return Err(RuntimeConfigError::InvalidDatabase); + } + let pool_bounds = PoolBounds::new( + raw.pool.max_size, + millis(raw.pool.wait_timeout_milliseconds)?, + millis(raw.pool.create_timeout_milliseconds)?, + millis(raw.pool.recycle_timeout_milliseconds)?, + ) + .map_err(|_| RuntimeConfigError::InvalidBounds)?; + Ok(Self { + runtime_url_ref, + migration_url_ref, + pool_bounds, + roles: SqlRoles::from_raw(raw.roles)?, + }) + } + + pub fn pool_bounds(&self) -> PoolBounds { + self.pool_bounds + } +} + +impl fmt::Debug for DatabaseConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DatabaseConfig") + .field("runtime_url_ref", &"") + .field("migration_url_ref", &"") + .field("pool_bounds", &self.pool_bounds) + .finish() + } +} + +#[derive(Clone)] +pub struct PackageConfig { + root: PathBuf, + trust_anchor_path: PathBuf, + compiler_source_revision: String, + active_revision: String, + active_sequence: u64, +} + +impl PackageConfig { + fn from_raw(raw: RawPackageConfig) -> Result { + validate_absolute_lexical_path(&raw.root, RuntimeConfigError::InvalidPackage)?; + validate_absolute_lexical_path(&raw.trust_anchor_path, RuntimeConfigError::InvalidPackage)?; + validate_deployment_value(&raw.compiler_source_revision)?; + validate_deployment_value(&raw.active_revision)?; + if raw.active_sequence == 0 { + return Err(RuntimeConfigError::InvalidPackage); + } + Ok(Self { + root: raw.root, + trust_anchor_path: raw.trust_anchor_path, + compiler_source_revision: raw.compiler_source_revision, + active_revision: raw.active_revision, + active_sequence: raw.active_sequence, + }) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn trust_anchor_path(&self) -> &Path { + &self.trust_anchor_path + } + + pub fn compiler_source_revision(&self) -> &str { + &self.compiler_source_revision + } + + pub fn active_revision(&self) -> &str { + &self.active_revision + } + + pub fn active_sequence(&self) -> u64 { + self.active_sequence + } +} + +impl fmt::Debug for PackageConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PackageConfig") + .field("root", &"") + .field("trust_anchor_path", &"") + .field("compiler_source_revision", &"") + .field("active_revision", &"") + .field("active_sequence", &self.active_sequence) + .finish() + } +} + +#[derive(Clone)] +pub struct AuthenticationConfig { + oidc: OidcVerifierConfig, + authority_claims: AuthorityClaimsConfig, +} + +impl AuthenticationConfig { + fn from_raw(raw: RawAuthenticationConfig) -> Result { + Ok(Self { + oidc: OidcVerifierConfig::from_raw(raw.oidc)?, + authority_claims: AuthorityClaimsConfig::from_raw(raw.authority_claims)?, + }) + } + + pub fn oidc(&self) -> &OidcVerifierConfig { + &self.oidc + } + + pub fn authority_claim_config(&self) -> AuthorityClaimConfig { + self.authority_claims.to_platform_config() + } +} + +impl fmt::Debug for AuthenticationConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthenticationConfig") + .field("oidc", &self.oidc) + .field("authority_claims", &self.authority_claims) + .finish() + } +} + +#[derive(Clone)] +pub struct OidcVerifierConfig { + issuer: String, + audience: String, + allowed_algorithm: OidcAlgorithm, + access_token_type: String, + scope_claim: String, + scope_separator: char, + allowed_clients: Vec, + denied_kids: Vec, + max_token_lifetime: Duration, + leeway: Duration, + jwks_cache: JwksCacheConfig, + jwks_source: OidcJwksSource, +} + +impl OidcVerifierConfig { + fn from_raw(raw: RawOidcVerifierConfig) -> Result { + validate_oidc_value(&raw.issuer)?; + validate_oidc_value(&raw.audience)?; + validate_oidc_value(&raw.access_token_type)?; + validate_claim_name(&raw.scope_claim)?; + if raw.scope_separator.is_control() || raw.scope_separator.is_alphanumeric() { + return Err(RuntimeConfigError::InvalidOidc); + } + validate_bounded_list(&raw.allowed_clients)?; + validate_bounded_list(&raw.denied_kids)?; + let denied_unique = raw.denied_kids.iter().collect::>(); + if denied_unique.len() != raw.denied_kids.len() { + return Err(RuntimeConfigError::InvalidOidc); + } + let allowed_unique = raw.allowed_clients.iter().collect::>(); + if allowed_unique.len() != raw.allowed_clients.len() { + return Err(RuntimeConfigError::InvalidOidc); + } + let max_token_lifetime = seconds_bounded(raw.max_token_lifetime_seconds, 1, 3600)?; + let leeway = millis_bounded(raw.leeway_milliseconds, 0, 300_000)?; + Ok(Self { + issuer: raw.issuer, + audience: raw.audience, + allowed_algorithm: raw.allowed_algorithm, + access_token_type: raw.access_token_type, + scope_claim: raw.scope_claim, + scope_separator: raw.scope_separator, + allowed_clients: raw.allowed_clients, + denied_kids: raw.denied_kids, + max_token_lifetime, + leeway, + jwks_cache: JwksCacheConfig::from_raw(raw.jwks_cache)?, + jwks_source: raw + .jwks_source + .map(OidcJwksSource::from_raw) + .transpose()? + .unwrap_or(OidcJwksSource::Discovery), + }) + } + + pub fn discovery_config(&self) -> OidcDiscoveryConfig { + OidcDiscoveryConfig { + issuer: self.issuer.clone(), + jwks_uri_override: None, + discovery_timeout: self.jwks_cache.request_timeout, + max_doc_bytes: self.jwks_cache.max_document_bytes, + } + } + + pub fn jwks_fetcher_config(&self) -> JwksFetcherConfig { + JwksFetcherConfig { + cache_ttl: self.jwks_cache.cache_ttl, + negative_cache_ttl: self.jwks_cache.negative_cache_ttl, + refresh_cooldown: self.jwks_cache.refresh_cooldown, + max_doc_bytes: self.jwks_cache.max_document_bytes, + request_timeout: self.jwks_cache.request_timeout, + outage_tolerance: self.jwks_cache.outage_tolerance, + } + } + + pub fn token_verifier_config(&self) -> TokenVerifierConfig { + TokenVerifierConfig::access_token_profile( + self.issuer.clone(), + vec![self.audience.clone()], + vec![self.allowed_algorithm.as_jsonwebtoken()], + vec![self.access_token_type.clone()], + ) + .with_scope_claim(self.scope_claim.clone()) + .with_scope_separator(self.scope_separator) + .with_allowed_clients(self.allowed_clients.clone()) + .with_denied_kids(self.denied_kids.iter().cloned().collect()) + .with_max_token_lifetime(Some(self.max_token_lifetime)) + .with_leeway(self.leeway) + } + + async fn key_source(&self, resolver: &SecretResolver) -> Result> { + match &self.jwks_source { + OidcJwksSource::Discovery => { + let discovery = fetch_discovery(&self.discovery_config()) + .await + .map_err(|_| RuntimeConfigError::InvalidOidc)?; + Ok(Arc::new(JwksFetcher::new( + discovery.jwks_uri, + self.jwks_fetcher_config(), + ))) + } + OidcJwksSource::Static { document_ref } => { + let document = resolver + .resolve_reference(document_ref) + .map_err(|_| RuntimeConfigError::InvalidOidc)?; + if document.is_empty() + || u64::try_from(document.len()) + .map_or(true, |len| len > self.jwks_cache.max_document_bytes) + { + return Err(RuntimeConfigError::InvalidOidc); + } + let jwks = validate_static_jwks( + document.expose_secret(), + self.allowed_algorithm, + &self.denied_kids, + )?; + Ok(Arc::new(JwksFetcher::new_static( + jwks, + self.jwks_fetcher_config(), + ))) + } + } + } +} + +impl fmt::Debug for OidcVerifierConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OidcVerifierConfig") + .field("issuer", &"") + .field("audience", &"") + .field("allowed_algorithm", &self.allowed_algorithm) + .field("access_token_type", &"") + .field("scope_claim", &"") + .field("scope_separator", &"") + .field("allowed_clients_count", &self.allowed_clients.len()) + .field("denied_kids_count", &self.denied_kids.len()) + .field("max_token_lifetime", &self.max_token_lifetime) + .field("leeway", &self.leeway) + .field("jwks_cache", &self.jwks_cache) + .field("jwks_source", &self.jwks_source.kind()) + .finish() + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +pub enum OidcAlgorithm { + EdDSA, + ES256, + ES384, + RS256, + RS384, +} + +impl OidcAlgorithm { + fn as_jsonwebtoken(self) -> jsonwebtoken::Algorithm { + match self { + Self::EdDSA => jsonwebtoken::Algorithm::EdDSA, + Self::ES256 => jsonwebtoken::Algorithm::ES256, + Self::ES384 => jsonwebtoken::Algorithm::ES384, + Self::RS256 => jsonwebtoken::Algorithm::RS256, + Self::RS384 => jsonwebtoken::Algorithm::RS384, + } + } + + fn as_signing_algorithm(self) -> SigningAlgorithm { + match self { + Self::EdDSA => SigningAlgorithm::EdDsa, + Self::ES256 => SigningAlgorithm::Es256, + Self::ES384 => SigningAlgorithm::Es384, + Self::RS256 => SigningAlgorithm::Rs256, + Self::RS384 => SigningAlgorithm::Rs384, + } + } + + fn as_jwa_name(self) -> &'static str { + self.as_signing_algorithm().jwa_name() + } +} + +#[derive(Clone)] +enum OidcJwksSource { + Discovery, + Static { document_ref: SecretReference }, +} + +impl OidcJwksSource { + fn from_raw(raw: RawOidcJwksSource) -> Result { + match raw { + RawOidcJwksSource::Discovery {} => Ok(Self::Discovery), + RawOidcJwksSource::Static { document_ref } => Ok(Self::Static { + document_ref: parse_secret_reference( + document_ref, + RuntimeConfigError::InvalidOidc, + )?, + }), + } + } + + const fn kind(&self) -> OidcJwksSourceKind { + match self { + Self::Discovery => OidcJwksSourceKind::Discovery, + Self::Static { .. } => OidcJwksSourceKind::Static, + } + } +} + +#[derive(Clone, Copy, Debug)] +enum OidcJwksSourceKind { + Discovery, + Static, +} + +fn validate_static_jwks( + bytes: &[u8], + allowed_algorithm: OidcAlgorithm, + denied_kids: &[String], +) -> Result { + let value = parse_json_strict(bytes).map_err(|_| RuntimeConfigError::InvalidOidc)?; + let object = value.as_object().ok_or(RuntimeConfigError::InvalidOidc)?; + if object.len() != 1 || !object.contains_key("keys") { + return Err(RuntimeConfigError::InvalidOidc); + } + let keys = object + .get("keys") + .and_then(Value::as_array) + .ok_or(RuntimeConfigError::InvalidOidc)?; + if keys.is_empty() || keys.len() > MAX_LIST_ITEMS { + return Err(RuntimeConfigError::InvalidOidc); + } + let mut kids = HashSet::new(); + for key in keys { + validate_static_jwk(key, allowed_algorithm, denied_kids, &mut kids)?; + } + serde_json::from_value::(value).map_err(|_| RuntimeConfigError::InvalidOidc) +} + +fn validate_static_jwk( + value: &Value, + allowed_algorithm: OidcAlgorithm, + denied_kids: &[String], + kids: &mut HashSet, +) -> Result<()> { + let object = value.as_object().ok_or(RuntimeConfigError::InvalidOidc)?; + validate_static_jwk_members(object)?; + validate_static_jwk_use(object)?; + validate_static_jwk_key_ops(object)?; + let kid = object + .get("kid") + .and_then(Value::as_str) + .ok_or(RuntimeConfigError::InvalidOidc)?; + validate_bounded_list(&[kid.to_owned()])?; + if denied_kids.iter().any(|denied| denied == kid) || !kids.insert(kid.to_owned()) { + return Err(RuntimeConfigError::InvalidOidc); + } + if object.get("alg").and_then(Value::as_str) != Some(allowed_algorithm.as_jwa_name()) { + return Err(RuntimeConfigError::InvalidOidc); + } + let public_jwk = PublicJwk::parse( + std::str::from_utf8( + &serde_json::to_vec(value).map_err(|_| RuntimeConfigError::InvalidOidc)?, + ) + .map_err(|_| RuntimeConfigError::InvalidOidc)?, + ) + .map_err(|_| RuntimeConfigError::InvalidOidc)?; + if public_jwk + .algorithm() + .map_err(|_| RuntimeConfigError::InvalidOidc)? + != allowed_algorithm.as_signing_algorithm() + { + return Err(RuntimeConfigError::InvalidOidc); + } + validate_static_jwk_shape(object, allowed_algorithm)?; + Ok(()) +} + +fn validate_static_jwk_members(object: &Map) -> Result<()> { + const ALLOWED: &[&str] = &[ + "kty", "kid", "alg", "use", "key_ops", "crv", "x", "y", "n", "e", + ]; + if object + .keys() + .any(|member| !ALLOWED.contains(&member.as_str())) + { + return Err(RuntimeConfigError::InvalidOidc); + } + Ok(()) +} + +fn validate_static_jwk_use(object: &Map) -> Result<()> { + match object.get("use") { + None => Ok(()), + Some(Value::String(value)) if value == "sig" => Ok(()), + Some(_) => Err(RuntimeConfigError::InvalidOidc), + } +} + +fn validate_static_jwk_key_ops(object: &Map) -> Result<()> { + match object.get("key_ops") { + None => Ok(()), + Some(Value::Array(values)) => match values.as_slice() { + [Value::String(value)] if value == "verify" => Ok(()), + _ => Err(RuntimeConfigError::InvalidOidc), + }, + Some(_) => Err(RuntimeConfigError::InvalidOidc), + } +} + +fn validate_static_jwk_shape( + object: &Map, + allowed_algorithm: OidcAlgorithm, +) -> Result<()> { + match allowed_algorithm { + OidcAlgorithm::EdDSA => { + if object.get("kty").and_then(Value::as_str) != Some("OKP") + || object.get("crv").and_then(Value::as_str) != Some("Ed25519") + || object.contains_key("y") + || object.contains_key("n") + || object.contains_key("e") + { + return Err(RuntimeConfigError::InvalidOidc); + } + decode_exact_jwk_member(object, "x", 32)?; + } + OidcAlgorithm::ES256 | OidcAlgorithm::ES384 => { + let (curve, coordinate_len) = match allowed_algorithm { + OidcAlgorithm::ES256 => ("P-256", 32), + OidcAlgorithm::ES384 => ("P-384", 48), + _ => unreachable!("only EC algorithms enter this branch"), + }; + if object.get("kty").and_then(Value::as_str) != Some("EC") + || object.get("crv").and_then(Value::as_str) != Some(curve) + || object.contains_key("n") + || object.contains_key("e") + { + return Err(RuntimeConfigError::InvalidOidc); + } + decode_exact_jwk_member(object, "x", coordinate_len)?; + decode_exact_jwk_member(object, "y", coordinate_len)?; + } + OidcAlgorithm::RS256 | OidcAlgorithm::RS384 => { + if object.get("kty").and_then(Value::as_str) != Some("RSA") + || object.contains_key("crv") + || object.contains_key("x") + || object.contains_key("y") + { + return Err(RuntimeConfigError::InvalidOidc); + } + validate_static_rsa_members(object)?; + } + } + Ok(()) +} + +fn decode_exact_jwk_member( + object: &Map, + member: &'static str, + expected_len: usize, +) -> Result> { + let value = object + .get(member) + .and_then(Value::as_str) + .ok_or(RuntimeConfigError::InvalidOidc)?; + let decoded = URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| RuntimeConfigError::InvalidOidc)?; + if decoded.len() != expected_len { + return Err(RuntimeConfigError::InvalidOidc); + } + Ok(decoded) +} + +fn validate_static_rsa_members(object: &Map) -> Result<()> { + let modulus = decode_nonempty_jwk_member(object, "n")?; + let significant_bits = significant_bit_len(&modulus); + if !(MIN_RSA_MODULUS_BITS..=MAX_RSA_MODULUS_BITS).contains(&significant_bits) { + return Err(RuntimeConfigError::InvalidOidc); + } + let exponent = decode_nonempty_jwk_member(object, "e")?; + if exponent.len() > MAX_RSA_EXPONENT_BYTES { + return Err(RuntimeConfigError::InvalidOidc); + } + let exponent_value = exponent + .iter() + .fold(0_u64, |acc, byte| (acc << 8) | u64::from(*byte)); + if exponent_value < 3 || exponent_value % 2 == 0 { + return Err(RuntimeConfigError::InvalidOidc); + } + Ok(()) +} + +fn decode_nonempty_jwk_member( + object: &Map, + member: &'static str, +) -> Result> { + let value = object + .get(member) + .and_then(Value::as_str) + .ok_or(RuntimeConfigError::InvalidOidc)?; + let decoded = URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| RuntimeConfigError::InvalidOidc)?; + if decoded.is_empty() { + return Err(RuntimeConfigError::InvalidOidc); + } + Ok(decoded) +} + +fn significant_bit_len(bytes: &[u8]) -> usize { + let first_non_zero = bytes + .iter() + .position(|byte| *byte != 0) + .unwrap_or(bytes.len()); + let significant = &bytes[first_non_zero..]; + significant + .first() + .map(|first| (significant.len() - 1) * 8 + (8 - first.leading_zeros() as usize)) + .unwrap_or(0) +} + +#[derive(Clone)] +pub struct JwksCacheConfig { + cache_ttl: Duration, + negative_cache_ttl: Duration, + refresh_cooldown: Duration, + max_document_bytes: u64, + request_timeout: Duration, + outage_tolerance: Duration, +} + +impl JwksCacheConfig { + fn from_raw(raw: RawJwksCacheConfig) -> Result { + if raw.max_document_bytes == 0 || raw.max_document_bytes > MAX_JWKS_DOCUMENT_BYTES { + return Err(RuntimeConfigError::InvalidOidc); + } + Ok(Self { + cache_ttl: seconds_bounded(raw.cache_ttl_seconds, 1, 86_400)?, + negative_cache_ttl: seconds_bounded(raw.negative_cache_ttl_seconds, 1, 3_600)?, + refresh_cooldown: seconds_bounded(raw.refresh_cooldown_seconds, 1, 3_600)?, + max_document_bytes: raw.max_document_bytes, + request_timeout: millis_bounded(raw.request_timeout_milliseconds, 1, 30_000)?, + outage_tolerance: seconds_bounded(raw.outage_tolerance_seconds, 0, 86_400)?, + }) + } +} + +impl fmt::Debug for JwksCacheConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("JwksCacheConfig") + .field("cache_ttl", &self.cache_ttl) + .field("negative_cache_ttl", &self.negative_cache_ttl) + .field("refresh_cooldown", &self.refresh_cooldown) + .field("max_document_bytes", &self.max_document_bytes) + .field("request_timeout", &self.request_timeout) + .field("outage_tolerance", &self.outage_tolerance) + .finish() + } +} + +#[derive(Clone)] +pub struct AuthorityClaimsConfig { + principal: String, + purpose: Option, + row_boundary_claims: Vec, +} + +impl AuthorityClaimsConfig { + fn from_raw(raw: RawAuthorityClaimsConfig) -> Result { + validate_authority_claim_name(&raw.principal)?; + if let Some(purpose) = &raw.purpose { + validate_authority_claim_name(purpose)?; + } + if raw.row_boundary_claims.len() > MAX_LIST_ITEMS { + return Err(RuntimeConfigError::InvalidOidc); + } + let mut names = HashSet::new(); + names.insert(raw.principal.as_str()); + if let Some(purpose) = &raw.purpose { + if !names.insert(purpose.as_str()) { + return Err(RuntimeConfigError::InvalidOidc); + } + } + for mapping in &raw.row_boundary_claims { + validate_authority_claim_name(&mapping.name)?; + if !names.insert(mapping.name.as_str()) { + return Err(RuntimeConfigError::InvalidOidc); + } + } + Ok(Self { + principal: raw.principal, + purpose: raw.purpose, + row_boundary_claims: raw + .row_boundary_claims + .into_iter() + .map(RowBoundaryClaimConfig::from_raw) + .collect(), + }) + } + + fn to_platform_config(&self) -> AuthorityClaimConfig { + AuthorityClaimConfig::new( + self.principal.clone(), + self.purpose.clone(), + self.row_boundary_claims + .iter() + .map(RowBoundaryClaimConfig::to_platform_mapping) + .collect(), + ) + } +} + +impl fmt::Debug for AuthorityClaimsConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorityClaimsConfig") + .field("principal", &"") + .field("purpose", &self.purpose.as_ref().map(|_| "")) + .field("row_boundary_claim_count", &self.row_boundary_claims.len()) + .finish() + } +} + +#[derive(Clone)] +struct RowBoundaryClaimConfig { + name: String, + value_type: RowBoundaryClaimConfigType, +} + +impl RowBoundaryClaimConfig { + fn from_raw(raw: RawRowBoundaryClaimConfig) -> Self { + Self { + name: raw.name, + value_type: raw.value_type, + } + } + + fn to_platform_mapping(&self) -> RowBoundaryClaimMapping { + RowBoundaryClaimMapping::new(self.name.clone(), self.value_type.to_platform_type()) + } +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +enum RowBoundaryClaimConfigType { + DirectString, + DirectStringSet, +} + +impl RowBoundaryClaimConfigType { + fn to_platform_type(self) -> RowBoundaryClaimType { + match self { + Self::DirectString => RowBoundaryClaimType::DirectString, + Self::DirectStringSet => RowBoundaryClaimType::DirectStringSet, + } + } +} + +#[derive(Clone)] +pub struct AuditConfig { + hash_key_ref: SecretReference, +} + +impl AuditConfig { + fn from_raw(raw: RawAuditConfig) -> Result { + let hash_key_ref = + parse_secret_reference(raw.hash_key_ref, RuntimeConfigError::InvalidAudit)?; + Ok(Self { hash_key_ref }) + } +} + +impl fmt::Debug for AuditConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuditConfig") + .field("hash_key_ref", &"") + .finish() + } +} + +#[derive(Clone)] +pub struct CursorConfig { + secret_ref: SecretReference, + max_age: Duration, +} + +impl CursorConfig { + fn from_raw(raw: RawCursorConfig) -> Result { + Ok(Self { + secret_ref: parse_secret_reference(raw.secret_ref, RuntimeConfigError::InvalidCursor)?, + max_age: seconds_bounded(raw.max_age_seconds, 1, 86_400)?, + }) + } + + pub fn max_age(&self) -> Duration { + self.max_age + } +} + +impl fmt::Debug for CursorConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CursorConfig") + .field("secret_ref", &"") + .field("max_age", &self.max_age) + .finish() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OperationalTimeouts { + pub http_request: Duration, + pub shutdown_grace: Duration, + pub record_lock: Duration, + pub migration_lock: Duration, + pub migration_statement: Duration, +} + +impl OperationalTimeouts { + fn from_raw(raw: RawOperationalTimeouts) -> Result { + Ok(Self { + http_request: millis_bounded(raw.http_request_milliseconds, 1, 60_000)?, + shutdown_grace: millis_bounded(raw.shutdown_grace_milliseconds, 1, 300_000)?, + record_lock: millis_bounded(raw.record_lock_milliseconds, 1, 30_000)?, + migration_lock: millis_bounded(raw.migration_lock_milliseconds, 1, 300_000)?, + migration_statement: millis_bounded( + raw.migration_statement_milliseconds, + 1, + 3_600_000, + )?, + }) + } +} + +#[derive(Clone)] +pub struct SqlRoles { + migration: SqlIdentifier, + runtime: SqlIdentifier, +} + +impl SqlRoles { + fn from_raw(raw: RawSqlRoles) -> Result { + if raw.migration == raw.runtime { + return Err(RuntimeConfigError::InvalidDatabase); + } + Ok(Self { + migration: SqlIdentifier::parse(&raw.migration) + .map_err(|_| RuntimeConfigError::InvalidDatabase)?, + runtime: SqlIdentifier::parse(&raw.runtime) + .map_err(|_| RuntimeConfigError::InvalidDatabase)?, + }) + } + + pub fn migration(&self) -> &SqlIdentifier { + &self.migration + } + + pub fn runtime(&self) -> &SqlIdentifier { + &self.runtime + } +} + +impl fmt::Debug for SqlRoles { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SqlRoles") + .field("migration", &"") + .field("runtime", &"") + .finish() + } +} + +impl DatabaseConfig { + pub fn roles(&self) -> &SqlRoles { + &self.roles + } +} + +pub(crate) fn parse_secret_reference( + value: String, + error: RuntimeConfigError, +) -> Result { + SecretReference::parse(value).map_err(|_| error) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawRuntimeConfig { + listener: RawListenerConfig, + identity: RawDeploymentIdentity, + secret_providers: RawSecretProvidersConfig, + database: RawDatabaseConfig, + package: RawPackageConfig, + authentication: RawAuthenticationConfig, + audit: RawAuditConfig, + cursor: RawCursorConfig, + #[serde(default)] + event_destinations: RawEventDestinationConfigs, + operational_timeouts: RawOperationalTimeouts, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawListenerConfig { + bind: String, + trusted_proxy: TrustedProxyPosture, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawDeploymentIdentity { + environment: String, + instance_id: String, + database_id: String, + database_initialization_environment: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawSecretProvidersConfig { + environment: Option, + file: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawEnvironmentSecretProviderConfig {} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawFileSecretProviderConfig { + root: PathBuf, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawDatabaseConfig { + runtime_url_ref: String, + migration_url_ref: String, + pool: RawPoolBounds, + roles: RawSqlRoles, + #[serde(default)] + plaintext: Option, + #[serde(default)] + url: Option, + #[serde(default)] + password: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawPoolBounds { + max_size: usize, + wait_timeout_milliseconds: u64, + create_timeout_milliseconds: u64, + recycle_timeout_milliseconds: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawSqlRoles { + migration: String, + runtime: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawPackageConfig { + root: PathBuf, + trust_anchor_path: PathBuf, + compiler_source_revision: String, + active_revision: String, + active_sequence: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawAuthenticationConfig { + oidc: RawOidcVerifierConfig, + authority_claims: RawAuthorityClaimsConfig, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawOidcVerifierConfig { + issuer: String, + audience: String, + allowed_algorithm: OidcAlgorithm, + access_token_type: String, + scope_claim: String, + scope_separator: char, + #[serde(default)] + allowed_clients: Vec, + #[serde(default)] + denied_kids: Vec, + max_token_lifetime_seconds: u64, + leeway_milliseconds: u64, + jwks_cache: RawJwksCacheConfig, + #[serde(default)] + jwks_source: Option, +} + +#[derive(Deserialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields, + tag = "kind" +)] +enum RawOidcJwksSource { + Discovery {}, + Static { document_ref: String }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawJwksCacheConfig { + cache_ttl_seconds: u64, + negative_cache_ttl_seconds: u64, + refresh_cooldown_seconds: u64, + max_document_bytes: u64, + request_timeout_milliseconds: u64, + outage_tolerance_seconds: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawAuthorityClaimsConfig { + principal: String, + #[serde(default)] + purpose: Option, + #[serde(default)] + row_boundary_claims: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawRowBoundaryClaimConfig { + name: String, + #[serde(rename = "type")] + value_type: RowBoundaryClaimConfigType, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawAuditConfig { + hash_key_ref: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawCursorConfig { + secret_ref: String, + max_age_seconds: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawOperationalTimeouts { + http_request_milliseconds: u64, + shutdown_grace_milliseconds: u64, + record_lock_milliseconds: u64, + migration_lock_milliseconds: u64, + migration_statement_milliseconds: u64, +} + +fn read_bounded_runtime_config(path: &Path, maximum: u64) -> Result> { + let scanned = fs::symlink_metadata(path).map_err(|_| RuntimeConfigError::Unavailable)?; + if scanned.file_type().is_symlink() || !scanned.is_file() { + return Err(RuntimeConfigError::UnsafeFile); + } + if scanned.len() == 0 || scanned.len() > maximum { + return Err(RuntimeConfigError::Bounds); + } + let file = open_runtime_config_file(path)?; + let opened = file + .metadata() + .map_err(|_| RuntimeConfigError::Unavailable)?; + let current = fs::symlink_metadata(path).map_err(|_| RuntimeConfigError::Unavailable)?; + if current.file_type().is_symlink() + || !opened.is_file() + || !same_file(&scanned, &opened) + || !same_file(&opened, ¤t) + { + return Err(RuntimeConfigError::UnsafeFile); + } + if opened.len() == 0 || opened.len() > maximum { + return Err(RuntimeConfigError::Bounds); + } + let capacity = usize::try_from(opened.len()).map_err(|_| RuntimeConfigError::Bounds)?; + let mut bytes = Vec::new(); + bytes + .try_reserve(capacity) + .map_err(|_| RuntimeConfigError::Bounds)?; + let mut reader = file.take(maximum + 1); + reader + .read_to_end(&mut bytes) + .map_err(|_| RuntimeConfigError::Unavailable)?; + let after = reader + .get_ref() + .metadata() + .map_err(|_| RuntimeConfigError::Unavailable)?; + if bytes.is_empty() || bytes.len() as u64 > maximum { + return Err(RuntimeConfigError::Bounds); + } + if !same_file(&opened, &after) || bytes.len() as u64 != after.len() { + return Err(RuntimeConfigError::UnsafeFile); + } + Ok(bytes) +} + +fn open_runtime_config_file(path: &Path) -> Result { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + + options.custom_flags(runtime_config_no_follow_flag()); + } + options.open(path).map_err(|_| { + fs::symlink_metadata(path).map_or(RuntimeConfigError::Unavailable, |metadata| { + if metadata.file_type().is_symlink() || !metadata.is_file() { + RuntimeConfigError::UnsafeFile + } else { + RuntimeConfigError::Unavailable + } + }) + }) +} + +#[cfg(unix)] +fn runtime_config_no_follow_flag() -> i32 { + (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC).bits() as i32 +} + +#[cfg(unix)] +fn same_file(left: &fs::Metadata, right: &fs::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() + && left.mtime() == right.mtime() + && left.mtime_nsec() == right.mtime_nsec() + && left.ctime() == right.ctime() + && left.ctime_nsec() == right.ctime_nsec() +} + +#[cfg(not(unix))] +fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.len() == right.len() + && left.permissions().readonly() == right.permissions().readonly() + && left.modified().ok() == right.modified().ok() + && left.created().ok() == right.created().ok() +} + +fn validate_existing_directory(path: &Path, error: RuntimeConfigError) -> Result<()> { + reject_symlink_components(path, error)?; + let metadata = fs::symlink_metadata(path).map_err(|_| error)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(error); + } + Ok(()) +} + +fn validate_existing_file(path: &Path, error: RuntimeConfigError) -> Result<()> { + reject_symlink_components(path, error)?; + let metadata = fs::symlink_metadata(path).map_err(|_| error)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(error); + } + Ok(()) +} + +fn reject_symlink_components(path: &Path, error: RuntimeConfigError) -> Result<()> { + let mut checked = PathBuf::new(); + for component in path.components() { + checked.push(component.as_os_str()); + if matches!(component, Component::RootDir | Component::Prefix(_)) { + continue; + } + match fs::symlink_metadata(&checked) { + Ok(metadata) if metadata.file_type().is_symlink() => return Err(error), + Ok(_) => {} + Err(_) => return Err(error), + } + } + Ok(()) +} + +fn validate_absolute_lexical_path(path: &Path, error: RuntimeConfigError) -> Result<()> { + if path.as_os_str().is_empty() + || !path.is_absolute() + || path.to_string_lossy().len() > MAX_PATH_BYTES + { + return Err(error); + } + if path.components().any(|component| { + !matches!( + component, + Component::Prefix(_) | Component::RootDir | Component::Normal(_) + ) + }) { + return Err(error); + } + Ok(()) +} + +fn validate_deployment_value(value: &str) -> Result<()> { + if value.trim().is_empty() + || value.trim() != value + || value.len() > MAX_DEPLOYMENT_VALUE_BYTES + || value.chars().any(char::is_control) + { + return Err(RuntimeConfigError::InvalidBinding); + } + Ok(()) +} + +fn validate_oidc_value(value: &str) -> Result<()> { + if value.trim().is_empty() + || value.trim() != value + || value.len() > MAX_OIDC_VALUE_BYTES + || value.chars().any(char::is_control) + { + return Err(RuntimeConfigError::InvalidOidc); + } + Ok(()) +} + +fn validate_claim_name(value: &str) -> Result<()> { + if value.is_empty() + || value.len() > 128 + || !value.is_ascii() + || value + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err(RuntimeConfigError::InvalidOidc); + } + Ok(()) +} + +fn validate_authority_claim_name(value: &str) -> Result<()> { + const REGISTERED: &[&str] = &[ + "iss", + "aud", + "exp", + "iat", + "nbf", + "sub", + "client_id", + "azp", + "jti", + "cnf", + ]; + validate_claim_name(value)?; + if REGISTERED.contains(&value) { + return Err(RuntimeConfigError::InvalidOidc); + } + Ok(()) +} + +fn validate_bounded_list(values: &[String]) -> Result<()> { + if values.len() > MAX_LIST_ITEMS { + return Err(RuntimeConfigError::InvalidOidc); + } + for value in values { + if value.is_empty() + || value.len() > MAX_LIST_VALUE_BYTES + || value + .bytes() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return Err(RuntimeConfigError::InvalidOidc); + } + } + Ok(()) +} + +fn millis(value: u64) -> Result { + millis_bounded(value, 1, 60_000) +} + +fn millis_bounded(value: u64, min: u64, max: u64) -> Result { + if value < min || value > max { + return Err(RuntimeConfigError::InvalidBounds); + } + Ok(Duration::from_millis(value)) +} + +fn seconds_bounded(value: u64, min: u64, max: u64) -> Result { + if value < min || value > max { + return Err(RuntimeConfigError::InvalidBounds); + } + Ok(Duration::from_secs(value)) +} diff --git a/crates/registry-server/src/schema.rs b/crates/registry-server/src/schema.rs new file mode 100644 index 0000000000..787e9e19ce --- /dev/null +++ b/crates/registry-server/src/schema.rs @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Generated JSON Schema for Registry Server authoring documents. + +use std::collections::BTreeMap; + +use serde_json::{Map, Value}; + +use crate::contract::RegistryProject; + +const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; + +pub const REGISTRY_PROJECT_SCHEMA_FILE: &str = "registry-project.schema.json"; +pub const REGISTRY_PROJECT_SCHEMA_ID: &str = + "https://id.registrystack.org/schemas/registry-server/authoring/registry-project.v1alpha1.schema.json"; + +/// Every authoring schema under its committed artifact filename. +pub fn documents() -> Result, serde_json::Error> { + let entries = [( + REGISTRY_PROJECT_SCHEMA_FILE, + "Registry Server authored project", + REGISTRY_PROJECT_SCHEMA_ID, + serde_json::to_value(schemars::schema_for!(RegistryProject))?, + )]; + entries + .into_iter() + .map(|(file, title, identifier, derived)| { + Ok((file, render(published(derived, title, identifier))?)) + }) + .collect() +} + +fn published(derived: Value, title: &str, identifier: &str) -> Value { + let mut object = match derived { + Value::Object(object) => object, + other => { + let mut object = Map::new(); + object.insert("$comment".to_owned(), other); + object + } + }; + object.insert( + "$schema".to_owned(), + Value::String(SCHEMA_DIALECT.to_owned()), + ); + object.insert("$id".to_owned(), Value::String(identifier.to_owned())); + object.insert("title".to_owned(), Value::String(title.to_owned())); + Value::Object(object) +} + +fn render(value: Value) -> Result { + let mut rendered = serde_json::to_string_pretty(&value)?; + rendered.push('\n'); + Ok(rendered) +} + +#[cfg(test)] +mod tests { + use std::{fs, path::Path}; + + use jsonschema::{Draft, JSONSchema}; + use serde_json::Value; + + use super::{documents, REGISTRY_PROJECT_SCHEMA_FILE, REGISTRY_PROJECT_SCHEMA_ID}; + + const ACCEPTANCE_PROJECTS: &[&str] = &[ + "asset-site-placement", + "business", + "disability", + "farmer", + "publicschema-household", + ]; + + fn schema_document() -> String { + documents() + .expect("the Registry Server authoring schema generates") + .remove(REGISTRY_PROJECT_SCHEMA_FILE) + .expect("the RegistryProject schema is generated") + } + + fn compile(document: &str) -> JSONSchema { + let value: Value = serde_json::from_str(document).expect("a generated schema is JSON"); + JSONSchema::options() + .with_draft(Draft::Draft202012) + .compile(&value) + .expect("a generated schema compiles as 2020-12") + } + + fn acceptance_root() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../products/registry-server/acceptance") + } + + fn fixture(project: &str) -> Value { + let path = acceptance_root().join(project).join("registry.yaml"); + serde_norway::from_str(&fs::read_to_string(path).expect("the fixture exists")) + .expect("the fixture is well-formed YAML") + } + + #[test] + fn registry_project_schema_is_the_only_generated_authoring_document() { + let documents = documents().expect("the Registry Server authoring schema generates"); + assert_eq!( + documents.keys().copied().collect::>(), + vec![REGISTRY_PROJECT_SCHEMA_FILE], + ); + } + + #[test] + fn registry_project_schema_declares_the_published_dialect_identifier_and_title() { + let document = schema_document(); + let value: Value = serde_json::from_str(&document).expect("the schema is JSON"); + assert_eq!( + value.get("$schema").and_then(Value::as_str), + Some("https://json-schema.org/draft/2020-12/schema"), + ); + assert_eq!( + value.get("$id").and_then(Value::as_str), + Some(REGISTRY_PROJECT_SCHEMA_ID), + ); + assert_eq!( + value.get("title").and_then(Value::as_str), + Some("Registry Server authored project"), + ); + assert_eq!(value.get("additionalProperties"), Some(&Value::Bool(false))); + } + + #[test] + fn registry_project_schema_reproduces_byte_for_byte() { + assert_eq!( + documents().expect("the Registry Server authoring schema generates"), + documents().expect("the Registry Server authoring schema generates again"), + ); + } + + #[test] + fn registry_project_schema_is_pretty_json_with_one_trailing_newline() { + let document = schema_document(); + assert!(document.ends_with('\n') && !document.ends_with("\n\n")); + let value: Value = serde_json::from_str(&document).expect("the schema is JSON"); + let mut rendered = + serde_json::to_string_pretty(&value).expect("a parsed schema renders again"); + rendered.push('\n'); + assert_eq!(document, rendered); + } + + #[test] + fn schema_accepts_the_current_registry_yaml_fixtures() { + let document = schema_document(); + let schema = compile(&document); + for project in ACCEPTANCE_PROJECTS { + assert!( + schema.is_valid(&fixture(project)), + "{project}/registry.yaml" + ); + } + } + + #[test] + fn schema_rejects_unknown_top_level_keys() { + let document = schema_document(); + let schema = compile(&document); + let mut instance = fixture("asset-site-placement"); + instance["unexpected"] = Value::Bool(true); + + assert!(!schema.is_valid(&instance)); + } + + #[test] + fn schema_rejects_field_options_that_do_not_belong_to_the_field_type() { + let document = schema_document(); + let schema = compile(&document); + let mut instance = fixture("asset-site-placement"); + instance["entities"][0]["fields"][0]["precision"] = Value::from(2_u64); + + assert!(!schema.is_valid(&instance)); + } + + #[test] + fn schema_rejects_missing_type_options_required_by_the_field_type() { + let document = schema_document(); + let schema = compile(&document); + let mut instance = fixture("asset-site-placement"); + instance["entities"][0]["fields"][0] + .as_object_mut() + .expect("the field is an object") + .remove("maxLength"); + + assert!(!schema.is_valid(&instance)); + } + + #[test] + fn schema_rejects_unknown_field_kinds() { + let document = schema_document(); + let schema = compile(&document); + let mut instance = fixture("asset-site-placement"); + instance["entities"][0]["fields"][0]["type"] = Value::String("bytes".to_owned()); + + assert!(!schema.is_valid(&instance)); + } + + #[test] + fn committed_authoring_schema_matches_generated_bytes() { + let committed = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/registry-server/generated/authoring") + .join(REGISTRY_PROJECT_SCHEMA_FILE); + assert_eq!( + fs::read_to_string(committed).expect("the committed schema exists"), + schema_document(), + ); + } +} diff --git a/crates/registry-server/src/startup.rs b/crates/registry-server/src/startup.rs new file mode 100644 index 0000000000..9a8c32960a --- /dev/null +++ b/crates/registry-server/src/startup.rs @@ -0,0 +1,1111 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Startup ordering gate for one verified Registry package. + +use std::future::Future; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::middleware::Next; +use axum::response::Response; +use axum::{middleware, Router}; +use registry_platform_audit::AuditProfile; +use registry_platform_httpsec::Problem; +use registry_platform_oidc::JwksFetcher; +use thiserror::Error; +use tokio::net::TcpListener; +use tokio::sync::{oneshot, watch}; +use tokio_postgres::{Client, GenericClient}; +use tracing_subscriber::filter::LevelFilter; + +use crate::api::{ + authenticated_router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, +}; +use crate::auth::RegistryAuthenticator; +#[cfg(all(feature = "runtime", feature = "tooling"))] +use crate::model::CompiledRegistry; +use crate::package::{load_package, PackageIntent, PackageLoadContext, VerifiedPackage}; +use crate::postgres::{ + verify_catalog_identity_for_catalog, ExpectedManagedCatalog, ExpectedRegistryIdentity, + PostgresRecordMutationService, PostgresRecordReadService, PostgresRevisionReadService, + RegistryLockKey, RuntimePool, SqlIdentifier, +}; +use crate::runtime_config::{load_runtime_config, RuntimeConfig, RuntimeConfigError}; +use crate::webhook::{WebhookDeliveryService, WebhookWorker}; + +/// Value-free startup refusal. Package paths, database values, and physical +/// catalog details are intentionally unavailable through Display and Debug. +#[derive(Debug, Error, Clone, Copy, Eq, PartialEq)] +pub enum StartupError { + #[error("the Registry runtime configuration was refused")] + RuntimeConfig, + #[error("the Registry package was refused")] + PackageRefused, + #[error("the Registry database connection was refused")] + DatabaseConnection, + #[error("the Registry database is not ready for this package")] + DatabaseUnready, + #[error("the Registry audit profile was refused")] + Audit, + #[error("the Registry cursor profile was refused")] + Cursor, + #[error("the Registry OIDC key source was refused")] + Oidc, + #[error("the Registry authentication profile was refused")] + Authentication, + #[error("the Registry event destination bindings were refused")] + EventDestinations, + #[error("the Registry listener could not be started")] + Listener, + #[error("the Registry shutdown signal failed")] + Shutdown, + #[error("the Registry operational log level was refused")] + Logging, +} + +pub type Result = std::result::Result; + +/// The closed severity vocabulary emitted by Registry Server operational +/// events. Audit and Registry provenance use separate channels and types. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OperationalLogLevel { + Info, + Warn, + Error, +} + +/// Closed webhook state-transition failure codes. These codes identify only +/// the failed transition class and never carry destination or event values. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebhookStateTransitionCode { + ClaimIdentityRefused, + ClaimRecoveryFailed, + ClaimSelectFailed, + ClaimPolicyRefused, + ClaimUpdateFailed, + ClaimAuditFailed, + ClaimCommitFailed, +} + +impl WebhookStateTransitionCode { + /// Every allowed state-transition code, used by exhaustive operational-log + /// contract tests. + pub const ALL: [Self; 7] = [ + Self::ClaimIdentityRefused, + Self::ClaimRecoveryFailed, + Self::ClaimSelectFailed, + Self::ClaimPolicyRefused, + Self::ClaimUpdateFailed, + Self::ClaimAuditFailed, + Self::ClaimCommitFailed, + ]; + + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ClaimIdentityRefused => "webhook.claim.identity_refused", + Self::ClaimRecoveryFailed => "webhook.claim.recovery_failed", + Self::ClaimSelectFailed => "webhook.claim.select_failed", + Self::ClaimPolicyRefused => "webhook.claim.policy_refused", + Self::ClaimUpdateFailed => "webhook.claim.update_failed", + Self::ClaimAuditFailed => "webhook.claim.audit_failed", + Self::ClaimCommitFailed => "webhook.claim.commit_failed", + } + } +} + +/// A rendered operational event. Its fields are an allowlist of low-cardinality, +/// value-free process state. It is deliberately unrelated to Registry audit. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OperationalLogRecord { + level: OperationalLogLevel, + target: &'static str, + message: &'static str, + error: Option<&'static str>, + code: Option<&'static str>, +} + +impl OperationalLogRecord { + #[must_use] + pub const fn level(self) -> OperationalLogLevel { + self.level + } + + #[must_use] + pub const fn target(self) -> &'static str { + self.target + } + + #[must_use] + pub const fn message(self) -> &'static str { + self.message + } + + #[must_use] + pub const fn error(self) -> Option<&'static str> { + self.error + } + + #[must_use] + pub const fn code(self) -> Option<&'static str> { + self.code + } +} + +/// The complete production operational-event vocabulary. Variants accept only +/// closed errors or codes, so request, record, SQL, secret, path, destination, +/// payload, upstream, and caller trace values cannot reach the renderer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OperationalEvent { + StartupBegan, + Listening, + Stopped, + StoppedWithError(StartupError), + WebhookWorkerIterationFailed, + WebhookStateTransitionFailed(WebhookStateTransitionCode), +} + +impl OperationalEvent { + #[must_use] + pub const fn record(self) -> OperationalLogRecord { + match self { + Self::StartupBegan => OperationalLogRecord { + level: OperationalLogLevel::Info, + target: "registry_server::startup", + message: "Registry Server startup began", + error: None, + code: None, + }, + Self::Listening => OperationalLogRecord { + level: OperationalLogLevel::Info, + target: "registry_server::startup", + message: "Registry Server is listening", + error: None, + code: None, + }, + Self::Stopped => OperationalLogRecord { + level: OperationalLogLevel::Error, + target: "registry_server::startup", + message: "Registry Server stopped", + error: None, + code: None, + }, + Self::StoppedWithError(error) => OperationalLogRecord { + level: OperationalLogLevel::Error, + target: "registry_server::startup", + message: "Registry Server stopped", + error: Some(error.operational_message()), + code: None, + }, + Self::WebhookWorkerIterationFailed => OperationalLogRecord { + level: OperationalLogLevel::Warn, + target: "registry_server::webhook", + message: "webhook worker iteration failed", + error: None, + code: Some("webhook.worker.iteration_failed"), + }, + Self::WebhookStateTransitionFailed(code) => OperationalLogRecord { + level: OperationalLogLevel::Warn, + target: "registry_server::webhook", + message: "webhook state transition failed", + error: None, + code: Some(code.as_str()), + }, + } + } + + /// Emit one record through the production JSON tracing subscriber. This is + /// the only production tracing entry point in Registry Server. + pub fn emit(self) { + let record = self.record(); + match self { + Self::StartupBegan | Self::Listening => { + tracing::info!(target: "registry_server::startup", message = record.message); + } + Self::Stopped => { + tracing::error!(target: "registry_server::startup", message = record.message); + } + Self::StoppedWithError(_) => { + let error = record + .error + .expect("stopped-with-error records have a closed error"); + tracing::error!(target: "registry_server::startup", error, message = record.message); + } + Self::WebhookWorkerIterationFailed | Self::WebhookStateTransitionFailed(_) => { + let code = record.code.expect("webhook warning records have a code"); + tracing::warn!(target: "registry_server::webhook", code, message = record.message); + } + } + } +} + +impl StartupError { + const fn operational_message(self) -> &'static str { + match self { + Self::RuntimeConfig => "the Registry runtime configuration was refused", + Self::PackageRefused => "the Registry package was refused", + Self::DatabaseConnection => "the Registry database connection was refused", + Self::DatabaseUnready => "the Registry database is not ready for this package", + Self::Audit => "the Registry audit profile was refused", + Self::Cursor => "the Registry cursor profile was refused", + Self::Oidc => "the Registry OIDC key source was refused", + Self::Authentication => "the Registry authentication profile was refused", + Self::EventDestinations => "the Registry event destination bindings were refused", + Self::Listener => "the Registry listener could not be started", + Self::Shutdown => "the Registry shutdown signal failed", + Self::Logging => "the Registry operational log level was refused", + } + } +} + +/// Unforgeable listener gate produced only after package closure and database +/// readiness verification. Listener construction must consume this object. +pub struct VerifiedStartup { + package: VerifiedPackage, + expected: ExpectedRegistryIdentity, + expected_catalog: ExpectedManagedCatalog, + lock_key: RegistryLockKey, +} + +impl VerifiedStartup { + pub fn package(&self) -> &VerifiedPackage { + &self.package + } + + pub fn into_package(self) -> VerifiedPackage { + self.package + } + + pub fn expected_identity(&self) -> &ExpectedRegistryIdentity { + &self.expected + } + + pub fn expected_catalog(&self) -> &ExpectedManagedCatalog { + &self.expected_catalog + } + + pub fn lock_key(&self) -> RegistryLockKey { + self.lock_key + } +} + +/// Fully verified server state. Fields are private so production listeners can +/// only be created by consuming this value through [`serve`]. +pub struct PreparedServer { + bind: SocketAddr, + app: Router, + shutdown_grace: Duration, + webhook_worker: Option, + #[cfg(all(feature = "postgres-test", feature = "tooling"))] + fixture_pool: Option, +} + +impl PreparedServer { + pub fn app(&self) -> Router { + self.app.clone() + } + + #[must_use] + pub fn bind(&self) -> SocketAddr { + self.bind + } + + /// Return the Router and PostgreSQL pool only when both were assembled by + /// the verified startup path. Raw test-part constructors deliberately + /// carry no such capability, so fixture receipt code cannot attest canned + /// Routers or a caller-selected database. + #[cfg(all(feature = "postgres-test", feature = "tooling"))] + pub(crate) fn fixture_runtime(&self) -> Option<(Router, RuntimePool)> { + self.fixture_pool + .as_ref() + .map(|pool| (self.app.clone(), pool.clone())) + } + + #[cfg(feature = "postgres-test")] + #[doc(hidden)] + #[must_use] + pub fn from_parts_for_test(bind: SocketAddr, app: Router, shutdown_grace: Duration) -> Self { + Self { + bind, + app, + shutdown_grace, + webhook_worker: None, + #[cfg(feature = "tooling")] + fixture_pool: None, + } + } + + #[cfg(feature = "postgres-test")] + #[doc(hidden)] + #[must_use] + pub fn from_parts_with_webhook_worker_for_test( + bind: SocketAddr, + app: Router, + shutdown_grace: Duration, + webhook_worker: WebhookWorker, + ) -> Self { + Self { + bind, + app, + shutdown_grace, + webhook_worker: Some(webhook_worker), + #[cfg(feature = "tooling")] + fixture_pool: None, + } + } +} + +/// Production startup. The package is verified before any secret resolution, +/// database connection, OIDC discovery, audit profile, or listener bind. +pub async fn prepare(config_path: &Path) -> Result { + let config = load_runtime_config(config_path).map_err(map_runtime_config_error)?; + let package_root = config.package().root().to_path_buf(); + let package = { + let package_context = config.package_load_context(); + load_package(&package_root, &package_context).map_err(|_| StartupError::PackageRefused)? + }; + let connection = config + .runtime_database_connection_config() + .map_err(map_runtime_config_error)?; + prepare_verified_package_with_connection(config, package, connection).await +} + +/// Prepare the clean database capability consumed by the production pre-sign +/// schema-test executor. This is not a serving path and returns no listener, +/// router, pool, or client. +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub async fn prepare_schema_test_database( + config: &RuntimeConfig, + candidate: &crate::package::PreparedPackage, +) -> Result { + validate_schema_test_candidate_binding(config, candidate)?; + let migration = config + .migration_database_connection_config() + .map_err(map_runtime_config_error)?; + let runtime = config + .runtime_database_connection_config() + .map_err(map_runtime_config_error)?; + prepare_schema_test_database_with_connection_configs(config, candidate, &migration, &runtime) + .await +} + +/// Rehearse the managed schema fingerprint for one production-compiled +/// Registry using the configured migration and runtime roles. This boundary +/// validates deployment bindings before resolving database secrets and returns +/// only the measured fingerprint. +#[cfg(all(feature = "runtime", feature = "tooling"))] +pub async fn rehearse_schema_fingerprint( + config: &RuntimeConfig, + registry: &CompiledRegistry, +) -> Result { + validate_rehearsal_registry_binding(config, registry)?; + let migration = config + .migration_database_connection_config() + .map_err(map_runtime_config_error)?; + rehearse_schema_fingerprint_with_connection_config(config, registry, &migration).await +} + +#[cfg(all(feature = "runtime", feature = "tooling", feature = "postgres-test"))] +#[doc(hidden)] +pub async fn rehearse_schema_fingerprint_with_connection_config_for_test( + config: &RuntimeConfig, + registry: &CompiledRegistry, + migration: &crate::postgres::ConnectionConfig, +) -> Result { + validate_rehearsal_registry_binding(config, registry)?; + rehearse_schema_fingerprint_with_connection_config(config, registry, migration).await +} + +#[cfg(all(feature = "runtime", feature = "tooling"))] +async fn rehearse_schema_fingerprint_with_connection_config( + config: &RuntimeConfig, + registry: &CompiledRegistry, + migration: &crate::postgres::ConnectionConfig, +) -> Result { + crate::postgres::rehearse_schema_fingerprint_with_connection( + migration, + config.database().roles().migration(), + config.database().roles().runtime(), + registry, + ) + .await + .map_err(|_| StartupError::DatabaseUnready) +} + +#[cfg(all(feature = "runtime", feature = "tooling", feature = "postgres-test"))] +#[doc(hidden)] +pub async fn prepare_schema_test_database_with_connection_configs_for_test( + config: &RuntimeConfig, + candidate: &crate::package::PreparedPackage, + migration: &crate::postgres::ConnectionConfig, + runtime: &crate::postgres::ConnectionConfig, +) -> Result { + validate_schema_test_candidate_binding(config, candidate)?; + prepare_schema_test_database_with_connection_configs(config, candidate, migration, runtime) + .await +} + +#[cfg(all(feature = "runtime", feature = "tooling"))] +async fn prepare_schema_test_database_with_connection_configs( + config: &RuntimeConfig, + candidate: &crate::package::PreparedPackage, + migration: &crate::postgres::ConnectionConfig, + runtime: &crate::postgres::ConnectionConfig, +) -> Result { + let manifest = candidate.manifest(); + crate::postgres::prepare_schema_test_database_with_connections( + migration, + runtime, + config.database().roles().migration(), + config.database().roles().runtime(), + candidate.registry(), + crate::postgres::SchemaTestDatabaseIdentity { + environment: &manifest.environment, + instance_id: &manifest.instance_id, + database_id: &manifest.database_id, + active_package_revision: &manifest.package_revision, + active_sequence: manifest.sequence, + }, + ) + .await + .map_err(|_| StartupError::DatabaseUnready) +} + +#[cfg(all(feature = "runtime", feature = "tooling"))] +fn validate_schema_test_candidate_binding( + config: &RuntimeConfig, + candidate: &crate::package::PreparedPackage, +) -> Result<()> { + let manifest = candidate.manifest(); + if config.identity().environment() != manifest.environment + || config.identity().instance_id() != manifest.instance_id + || config.identity().database_id() != manifest.database_id + || config.package().compiler_source_revision() != manifest.compiler.source_revision + || candidate.registry().registry_id() != manifest.package_id + { + return Err(StartupError::PackageRefused); + } + Ok(()) +} + +#[cfg(all(feature = "runtime", feature = "tooling"))] +fn validate_rehearsal_registry_binding( + config: &RuntimeConfig, + registry: &CompiledRegistry, +) -> Result<()> { + let package = registry.package().ok_or(StartupError::PackageRefused)?; + if config.identity().environment() != package.environment + || config.identity().instance_id() != package.instance_id + || config.package().compiler_source_revision() != package.source_revision + { + return Err(StartupError::PackageRefused); + } + Ok(()) +} + +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +pub async fn prepare_with_connection_config_for_test( + config_path: &Path, + connection: crate::postgres::ConnectionConfig, +) -> Result { + let config = load_runtime_config(config_path).map_err(map_runtime_config_error)?; + let package_root = config.package().root().to_path_buf(); + let package = { + let package_context = config.package_load_context(); + load_package(&package_root, &package_context).map_err(|_| StartupError::PackageRefused)? + }; + prepare_verified_package_with_connection(config, package, connection).await +} + +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +pub async fn prepare_with_connection_and_key_source_for_test( + config_path: &Path, + connection: crate::postgres::ConnectionConfig, + key_source: Arc, +) -> Result { + let config = load_runtime_config(config_path).map_err(map_runtime_config_error)?; + let package_root = config.package().root().to_path_buf(); + let package = { + let package_context = config.package_load_context(); + load_package(&package_root, &package_context).map_err(|_| StartupError::PackageRefused)? + }; + prepare_verified_package_with_key_source(config, package, connection, key_source).await +} + +async fn prepare_verified_package_with_connection( + config: RuntimeConfig, + package: VerifiedPackage, + connection: crate::postgres::ConnectionConfig, +) -> Result { + let (pool, startup) = prepare_database_startup( + package, + &connection, + config.database().roles().migration(), + config.database().roles().runtime(), + ) + .await?; + let audit_profile = config.audit_profile().map_err(|_| StartupError::Audit)?; + let cursor_codec = Arc::new(config.cursor_codec().map_err(|_| StartupError::Cursor)?); + let key_source = config + .oidc_key_source() + .await + .map_err(map_runtime_config_error)?; + finish_prepared_server( + config, + startup, + pool, + key_source, + audit_profile, + cursor_codec, + ) + .await +} + +#[cfg(feature = "postgres-test")] +async fn prepare_verified_package_with_key_source( + config: RuntimeConfig, + package: VerifiedPackage, + connection: crate::postgres::ConnectionConfig, + key_source: Arc, +) -> Result { + let (pool, startup) = prepare_database_startup( + package, + &connection, + config.database().roles().migration(), + config.database().roles().runtime(), + ) + .await?; + let audit_profile = config.audit_profile().map_err(|_| StartupError::Audit)?; + let cursor_codec = Arc::new(config.cursor_codec().map_err(|_| StartupError::Cursor)?); + finish_prepared_server( + config, + startup, + pool, + key_source, + audit_profile, + cursor_codec, + ) + .await +} + +async fn prepare_database_startup( + package: VerifiedPackage, + connection: &crate::postgres::ConnectionConfig, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, +) -> Result<(RuntimePool, VerifiedStartup)> { + let pool = connection + .build_pool() + .map_err(|_| StartupError::DatabaseConnection)?; + let mut client = pool + .get() + .await + .map_err(|_| StartupError::DatabaseConnection)?; + let startup = verify_opened_startup(package, &mut client, migration_role, runtime_role).await?; + drop(client); + Ok((pool, startup)) +} + +async fn finish_prepared_server( + config: RuntimeConfig, + startup: VerifiedStartup, + pool: RuntimePool, + key_source: Arc, + audit_profile: AuditProfile, + cursor_codec: Arc, +) -> Result { + let oidc = config.authentication().oidc(); + key_source + .ensure_key_set() + .await + .map_err(|_| StartupError::Oidc)?; + + let registry = Arc::new(startup.package().registry().clone()); + #[cfg(all(feature = "postgres-test", feature = "tooling"))] + let fixture_pool = pool.clone(); + let event_destinations = Arc::new( + config + .activate_event_destinations(®istry) + .map_err(|_| StartupError::EventDestinations)?, + ); + let authenticator = Arc::new( + RegistryAuthenticator::new( + ®istry, + oidc.token_verifier_config(), + Arc::clone(&key_source), + config.authentication().authority_claim_config(), + ) + .map_err(|_| StartupError::Authentication)?, + ); + let expected = startup.expected_identity().clone(); + let expected_catalog = startup.expected_catalog().clone(); + let lock_key = startup.lock_key(); + let readiness = Arc::new(DynamicRuntimeReadiness::new( + pool.clone(), + expected.clone(), + expected_catalog.clone(), + config.database().roles().migration().clone(), + config.database().roles().runtime().clone(), + lock_key, + Arc::clone(&key_source), + )); + if !readiness.is_ready().await { + return Err(StartupError::DatabaseUnready); + } + + let records = Arc::new(PostgresRecordReadService::new( + pool.clone(), + Arc::clone(®istry), + expected.clone(), + lock_key, + config.operational_timeouts().record_lock, + audit_profile.clone(), + Arc::clone(&cursor_codec), + )); + let read_identity = ReadRuntimeIdentity { + package_revision: expected.package_revision.clone(), + schema_fingerprint: expected.schema_fingerprint.clone(), + }; + let revisions = Arc::new(PostgresRevisionReadService::new( + pool.clone(), + Arc::clone(®istry), + expected.clone(), + lock_key, + config.operational_timeouts().record_lock, + audit_profile.clone(), + )); + let webhook_worker = (!registry.event_deliveries().deliveries.is_empty()).then(|| { + WebhookWorker::new(WebhookDeliveryService::new( + pool.clone(), + Arc::clone(&event_destinations), + expected.clone(), + lock_key, + config.operational_timeouts().record_lock, + audit_profile.clone(), + )) + }); + let mutations = Arc::new(PostgresRecordMutationService::new_with_event_destinations( + pool, + Arc::clone(®istry), + expected, + lock_key, + config.operational_timeouts().record_lock, + audit_profile, + Some(event_destinations), + )); + let service = Arc::new( + HttpService::new(registry, read_identity, records, readiness, cursor_codec) + .with_postgres_revisions(revisions) + .with_postgres_mutations(mutations), + ); + let app = with_request_timeout( + authenticated_router(service, authenticator), + config.operational_timeouts().http_request, + ); + Ok(PreparedServer { + bind: config.listener().bind(), + app, + shutdown_grace: config.operational_timeouts().shutdown_grace, + webhook_worker, + #[cfg(all(feature = "postgres-test", feature = "tooling"))] + fixture_pool: Some(fixture_pool), + }) +} + +fn map_runtime_config_error(error: RuntimeConfigError) -> StartupError { + match error { + RuntimeConfigError::InvalidDatabase | RuntimeConfigError::Secret => { + StartupError::DatabaseConnection + } + RuntimeConfigError::InvalidAudit => StartupError::Audit, + RuntimeConfigError::InvalidCursor => StartupError::Cursor, + RuntimeConfigError::InvalidOidc => StartupError::Oidc, + _ => StartupError::RuntimeConfig, + } +} + +#[doc(hidden)] +pub fn with_request_timeout_for_test(app: Router, timeout: Duration) -> Router { + with_request_timeout(app, timeout) +} + +fn with_request_timeout(app: Router, timeout: Duration) -> Router { + app.layer(middleware::from_fn_with_state(timeout, request_timeout)) +} + +async fn request_timeout( + axum::extract::State(timeout): axum::extract::State, + request: Request, + next: Next, +) -> Response { + match tokio::time::timeout(timeout, next.run(request)).await { + Ok(response) => response, + Err(_) => timeout_problem(), + } +} + +fn timeout_problem() -> Response { + Problem::new( + "urn:registry-server:problem:request.timeout", + "Gateway Timeout", + StatusCode::GATEWAY_TIMEOUT, + ) + .detail("The request timed out.") + .with_extra( + "code", + serde_json::Value::String("request.timeout".to_owned()), + ) + .into_response() +} + +/// Bind and serve a previously prepared server. Binding consumes the +/// preparation gate, which prevents production from accepting an externally +/// opened database connection before package verification. +pub async fn serve(prepared: PreparedServer) -> Result<()> { + serve_until_shutdown(prepared, shutdown_signal()).await +} + +pub async fn serve_until_shutdown( + prepared: PreparedServer, + shutdown: impl Future>, +) -> Result<()> { + let listener = TcpListener::bind(prepared.bind) + .await + .map_err(|_| StartupError::Listener)?; + OperationalEvent::Listening.emit(); + let (worker_shutdown_tx, worker_shutdown_rx) = watch::channel(false); + let mut worker = prepared + .webhook_worker + .map(|worker| tokio::spawn(worker.run(worker_shutdown_rx))); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let mut server = tokio::spawn(async move { + axum::serve(listener, prepared.app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + }); + let exit = tokio::select! { + result = &mut server => ServeExit::Server(result), + signal = shutdown => ServeExit::Signal(signal), + }; + let mut server_joined = matches!(exit, ServeExit::Server(_)); + let _ = worker_shutdown_tx.send(true); + let _ = shutdown_tx.send(()); + let graceful = async { + let result = match exit { + ServeExit::Server(result) => map_server_result(result), + ServeExit::Signal(signal) => { + let server_result = map_server_result((&mut server).await); + server_joined = true; + signal.and(server_result) + } + }; + if let Some(worker) = worker.as_mut() { + let _ = worker.await; + } + result + }; + match tokio::time::timeout(prepared.shutdown_grace, graceful).await { + Ok(result) => result, + Err(_) => { + if !server_joined { + server.abort(); + let _ = (&mut server).await; + } + if let Some(worker) = worker.as_mut() { + worker.abort(); + let _ = worker.await; + } + Err(StartupError::Shutdown) + } + } +} + +enum ServeExit { + Server(std::result::Result, tokio::task::JoinError>), + Signal(Result<()>), +} + +async fn shutdown_signal() -> Result<()> { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .map_err(|_| StartupError::Shutdown)?; + first_shutdown_signal(tokio::signal::ctrl_c(), terminate.recv()).await + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c() + .await + .map_err(|_| StartupError::Shutdown) + } +} + +#[cfg(unix)] +async fn first_shutdown_signal(ctrl_c: C, terminate: T) -> Result<()> +where + C: Future>, + T: Future>, +{ + tokio::select! { + result = ctrl_c => result.map_err(|_| StartupError::Shutdown), + result = terminate => result.ok_or(StartupError::Shutdown), + } +} + +fn map_server_result( + result: std::result::Result, tokio::task::JoinError>, +) -> Result<()> { + match result { + Ok(Ok(())) => Ok(()), + _ => Err(StartupError::Listener), + } +} + +pub fn operational_log_level(value: Option<&str>) -> Result { + match value.unwrap_or("info") { + "error" => Ok(LevelFilter::ERROR), + "warn" => Ok(LevelFilter::WARN), + "info" => Ok(LevelFilter::INFO), + _ => Err(StartupError::Logging), + } +} + +/// Verify the complete local package first, then require the exact active +/// package, schema fingerprint, sequence, ready maintenance state, ownership, +/// RLS, and ACL catalog before returning a listener gate. +pub async fn prepare_startup( + package_root: &Path, + context: &PackageLoadContext<'_>, + client: &mut Client, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, +) -> Result { + if !matches!(context.intent, PackageIntent::Startup { .. }) { + return Err(StartupError::PackageRefused); + } + // Ordering is security-relevant: no database call precedes package closure, + // signature, binding, and compiler-derivation verification. + let package = load_package(package_root, context).map_err(|_| StartupError::PackageRefused)?; + verify_opened_startup(package, client, migration_role, runtime_role).await +} + +async fn verify_opened_startup( + package: VerifiedPackage, + client: &mut Client, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, +) -> Result { + let expected = expected_identity(&package)?; + let expected_catalog = ExpectedManagedCatalog::compiled(package.registry()); + let lock_key = + RegistryLockKey::derive(&expected.package_id).map_err(|_| StartupError::DatabaseUnready)?; + let transaction = client + .transaction() + .await + .map_err(|_| StartupError::DatabaseUnready)?; + transaction + .batch_execute("SET LOCAL lock_timeout = '5s'") + .await + .map_err(|_| StartupError::DatabaseUnready)?; + transaction + .execute( + "SELECT pg_advisory_xact_lock_shared($1)", + &[&lock_key.get()], + ) + .await + .map_err(|_| StartupError::DatabaseUnready)?; + verify_configured_runtime_role(&transaction, migration_role, runtime_role) + .await + .map_err(|_| StartupError::DatabaseUnready)?; + let maintenance = transaction + .query_opt( + "SELECT maintenance_status + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .map_err(|_| StartupError::DatabaseUnready)? + .ok_or(StartupError::DatabaseUnready)? + .get::<_, String>(0); + if maintenance != "ready" { + return Err(StartupError::DatabaseUnready); + } + verify_catalog_identity_for_catalog( + &transaction, + &expected, + &expected_catalog, + migration_role, + runtime_role, + ) + .await + .map_err(|_| StartupError::DatabaseUnready)?; + transaction + .commit() + .await + .map_err(|_| StartupError::DatabaseUnready)?; + Ok(VerifiedStartup { + package, + expected, + expected_catalog, + lock_key, + }) +} + +fn expected_identity(package: &VerifiedPackage) -> Result { + let manifest = package.manifest(); + let sequence = i64::try_from(manifest.sequence).map_err(|_| StartupError::DatabaseUnready)?; + Ok(ExpectedRegistryIdentity { + package_id: manifest.package_id.clone(), + environment: manifest.environment.clone(), + instance_id: manifest.instance_id.clone(), + database_id: manifest.database_id.clone(), + package_revision: manifest.package_revision.clone(), + schema_fingerprint: manifest.schema_fingerprint.clone(), + package_sequence: sequence, + }) +} + +struct DynamicRuntimeReadiness { + pool: RuntimePool, + expected: ExpectedRegistryIdentity, + expected_catalog: ExpectedManagedCatalog, + migration_role: SqlIdentifier, + runtime_role: SqlIdentifier, + lock_key: RegistryLockKey, + key_source: Arc, +} + +impl DynamicRuntimeReadiness { + fn new( + pool: RuntimePool, + expected: ExpectedRegistryIdentity, + expected_catalog: ExpectedManagedCatalog, + migration_role: SqlIdentifier, + runtime_role: SqlIdentifier, + lock_key: RegistryLockKey, + key_source: Arc, + ) -> Self { + Self { + pool, + expected, + expected_catalog, + migration_role, + runtime_role, + lock_key, + key_source, + } + } + + async fn check(&self) -> Result<()> { + let mut client = self + .pool + .get() + .await + .map_err(|_| StartupError::DatabaseUnready)?; + let transaction = client + .transaction() + .await + .map_err(|_| StartupError::DatabaseUnready)?; + transaction + .batch_execute("SET LOCAL lock_timeout = '5s'") + .await + .map_err(|_| StartupError::DatabaseUnready)?; + transaction + .execute( + "SELECT pg_advisory_xact_lock_shared($1)", + &[&self.lock_key.get()], + ) + .await + .map_err(|_| StartupError::DatabaseUnready)?; + verify_configured_runtime_role(&*transaction, &self.migration_role, &self.runtime_role) + .await + .map_err(|_| StartupError::DatabaseUnready)?; + let maintenance = transaction + .query_opt( + "SELECT maintenance_status + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .map_err(|_| StartupError::DatabaseUnready)? + .ok_or(StartupError::DatabaseUnready)? + .get::<_, String>(0); + if maintenance != "ready" { + return Err(StartupError::DatabaseUnready); + } + verify_catalog_identity_for_catalog( + &*transaction, + &self.expected, + &self.expected_catalog, + &self.migration_role, + &self.runtime_role, + ) + .await + .map_err(|_| StartupError::DatabaseUnready)?; + transaction + .commit() + .await + .map_err(|_| StartupError::DatabaseUnready)?; + self.key_source + .ensure_key_set() + .await + .map_err(|_| StartupError::Oidc)?; + Ok(()) + } +} + +impl ReadinessProbe for DynamicRuntimeReadiness { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async move { self.check().await.is_ok() }) + } +} + +async fn verify_configured_runtime_role( + client: &impl GenericClient, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, +) -> Result<()> { + let row = client + .query_one( + "SELECT current_user, + rolsuper, + rolbypassrls, + rolcreatedb, + rolcreaterole, + current_user = $1, + pg_has_role(current_user, $1, 'MEMBER'), + has_database_privilege(current_user, current_database(), 'CREATE'), + has_schema_privilege(current_user, 'registry_internal', 'CREATE'), + has_schema_privilege(current_user, 'registry_data', 'CREATE'), + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname IN ('registry_internal', 'registry_data') + AND c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user) + ) + FROM pg_catalog.pg_roles + WHERE rolname = current_user", + &[&migration_role.as_str()], + ) + .await + .map_err(|_| StartupError::DatabaseUnready)?; + let actual_role: String = row.get(0); + if actual_role != runtime_role.as_str() { + return Err(StartupError::DatabaseUnready); + } + if (1..=10).any(|index| row.get::<_, bool>(index)) { + return Err(StartupError::DatabaseUnready); + } + Ok(()) +} diff --git a/crates/registry-server/src/tooling.rs b/crates/registry-server/src/tooling.rs new file mode 100644 index 0000000000..8f22c4372a --- /dev/null +++ b/crates/registry-server/src/tooling.rs @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Read-only, value-free change classification for operator tooling. + +use serde::{Deserialize, Serialize}; + +use crate::model::CompiledRegistry; +use crate::package::{ + compiled_registry_change_set, CompiledRegistryChange, CompiledRegistryChangeClass, + CompiledRegistryChangeCode, +}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DiffClassification { + CompatibleAdditive, + DataBackfillRequired, + LockOrRewriteRisk, + AccessChange, + DisclosureWidening, + DisclosureNarrowing, + DestructiveOrIrreversible, + Unsupported, +} + +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClassifiedRegistryChange { + pub classification: DiffClassification, + pub change: CompiledRegistryChange, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledRegistryDiff { + pub baseline_package_revision: String, + pub baseline_registry_revision: String, + pub candidate_registry_revision: String, + pub changes: Vec, +} + +/// Compare the rederived package Registry with an authoring candidate. +/// +/// The compiler-owned inventory remains authoritative. This layer only refines +/// classifications that can be proven from the two compiled models. It never +/// inspects source values, generated SQL, records, or a database. +pub fn classify_registry_diff( + baseline: &CompiledRegistry, + candidate: &CompiledRegistry, + baseline_package_revision: &str, +) -> CompiledRegistryDiff { + let change_set = compiled_registry_change_set(baseline, candidate, baseline_package_revision); + let changes = change_set + .changes + .into_iter() + .map(|change| ClassifiedRegistryChange { + classification: classify_change(baseline, candidate, &change), + change, + }) + .collect(); + CompiledRegistryDiff { + baseline_package_revision: baseline_package_revision.to_owned(), + baseline_registry_revision: baseline.revision().to_owned(), + candidate_registry_revision: candidate.revision().to_owned(), + changes, + } +} + +fn classify_change( + baseline: &CompiledRegistry, + candidate: &CompiledRegistry, + change: &CompiledRegistryChange, +) -> DiffClassification { + use CompiledRegistryChangeClass as BaseClass; + use CompiledRegistryChangeCode as Code; + + match change.code { + Code::ConstraintAdded | Code::IndexAdded => DiffClassification::LockOrRewriteRisk, + Code::EntityClassificationChanged | Code::FieldClassificationChanged => { + classification_direction(baseline, candidate, change) + } + Code::AccessProfileChanged => access_profile_direction(baseline, candidate, change), + Code::EntityRouteChanged + | Code::EntityMutationModeChanged + | Code::AccessProfileAdded + | Code::AccessProfileRemoved + | Code::RouteAdded + | Code::RouteRemoved + | Code::RouteChanged => DiffClassification::AccessChange, + Code::QueryInventoryChanged + | Code::EventAdded + | Code::EventRemoved + | Code::EventChanged => DiffClassification::Unsupported, + _ => match change.class { + BaseClass::CompatibleAdditive => DiffClassification::CompatibleAdditive, + BaseClass::DataBackfillRequired => DiffClassification::DataBackfillRequired, + BaseClass::DestructiveOrIrreversible => DiffClassification::DestructiveOrIrreversible, + BaseClass::Unsupported => DiffClassification::Unsupported, + // A new compiler change code must be reviewed here rather than + // inheriting an access/disclosure guess. + BaseClass::AccessOrDisclosureChange => DiffClassification::Unsupported, + }, + } +} + +fn access_profile_direction( + baseline: &CompiledRegistry, + candidate: &CompiledRegistry, + change: &CompiledRegistryChange, +) -> DiffClassification { + let (Some(entity_id), Some(profile_id)) = ( + change.target.entity_id.as_deref(), + change.target.member_id.as_deref(), + ) else { + return DiffClassification::Unsupported; + }; + let (Some(before), Some(after)) = ( + baseline + .entities() + .get(entity_id) + .and_then(|entity| entity.access_profiles.get(profile_id)), + candidate + .entities() + .get(entity_id) + .and_then(|entity| entity.access_profiles.get(profile_id)), + ) else { + return DiffClassification::Unsupported; + }; + if before.readable_fields == after.readable_fields { + return DiffClassification::AccessChange; + } + let mut before_without_disclosure = before.clone(); + before_without_disclosure.readable_fields.clear(); + let mut after_without_disclosure = after.clone(); + after_without_disclosure.readable_fields.clear(); + if before_without_disclosure != after_without_disclosure { + return DiffClassification::Unsupported; + } + if before.readable_fields.is_subset(&after.readable_fields) { + DiffClassification::DisclosureWidening + } else if after.readable_fields.is_subset(&before.readable_fields) { + DiffClassification::DisclosureNarrowing + } else { + DiffClassification::Unsupported + } +} + +fn classification_direction( + baseline: &CompiledRegistry, + candidate: &CompiledRegistry, + change: &CompiledRegistryChange, +) -> DiffClassification { + let Some(entity_id) = change.change_target_entity_id() else { + return DiffClassification::Unsupported; + }; + let Some(before_entity) = baseline.entities().get(entity_id) else { + return DiffClassification::Unsupported; + }; + let Some(after_entity) = candidate.entities().get(entity_id) else { + return DiffClassification::Unsupported; + }; + let direction = match change.code { + CompiledRegistryChangeCode::EntityClassificationChanged => before_entity + .classification + .cmp(&after_entity.classification), + CompiledRegistryChangeCode::FieldClassificationChanged => { + let Some(field_id) = change.change_target_member_id() else { + return DiffClassification::Unsupported; + }; + let Some(before) = before_entity.fields.get(field_id) else { + return DiffClassification::Unsupported; + }; + let Some(after) = after_entity.fields.get(field_id) else { + return DiffClassification::Unsupported; + }; + before.classification.cmp(&after.classification) + } + _ => return DiffClassification::Unsupported, + }; + match direction { + // A lower candidate classification expands where the field/entity can + // be processed and disclosed. + std::cmp::Ordering::Greater => DiffClassification::DisclosureWidening, + std::cmp::Ordering::Less => DiffClassification::DisclosureNarrowing, + std::cmp::Ordering::Equal => DiffClassification::Unsupported, + } +} + +trait ChangeTargetIds { + fn change_target_entity_id(&self) -> Option<&str>; + fn change_target_member_id(&self) -> Option<&str>; +} + +impl ChangeTargetIds for CompiledRegistryChange { + fn change_target_entity_id(&self) -> Option<&str> { + self.target.entity_id.as_deref() + } + + fn change_target_member_id(&self) -> Option<&str> { + self.target.member_id.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compiler::{compile_project, module_digest, CompileProfile}; + use crate::contract::{parse_module_json, parse_project_json}; + use crate::package::CompiledRegistryChangeCode; + + const PACKAGE_REVISION: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + + #[test] + fn disclosure_direction_threat_is_enforced_by_exact_classification_order_negative() { + let internal = compiled("1", "internal", "", "", "principal"); + let public = compiled("1", "public", "", "", "principal"); + + let widening = classify_registry_diff(&internal, &public, PACKAGE_REVISION); + let widening_again = classify_registry_diff(&internal, &public, PACKAGE_REVISION); + assert_eq!(widening, widening_again, "diff order and bytes are stable"); + assert!(widening.changes.iter().any(|change| { + change.change.code == CompiledRegistryChangeCode::FieldClassificationChanged + && change.classification == DiffClassification::DisclosureWidening + })); + + let narrowing = classify_registry_diff(&public, &internal, PACKAGE_REVISION); + assert!(narrowing.changes.iter().any(|change| { + change.change.code == CompiledRegistryChangeCode::FieldClassificationChanged + && change.classification == DiffClassification::DisclosureNarrowing + })); + } + + #[test] + fn every_supported_diff_class_is_derived_from_an_exact_compiler_change() { + let baseline = compiled("1", "internal", "", "", "principal"); + let optional = compiled( + "1", + "internal", + r#",{"id":"optional","type":"string","maxLength":16,"classification":"internal"}"#, + "", + "principal", + ); + assert_class( + &baseline, + &optional, + CompiledRegistryChangeCode::FieldAddedOptional, + DiffClassification::CompatibleAdditive, + ); + + let required = compiled( + "1", + "internal", + r#",{"id":"required","type":"string","maxLength":16,"required":true,"classification":"internal"}"#, + "", + "principal", + ); + assert_class( + &baseline, + &required, + CompiledRegistryChangeCode::FieldAddedRequired, + DiffClassification::DataBackfillRequired, + ); + + let constrained = compiled( + "1", + "internal", + "", + r#", "constraints":[{"kind":"unique","id":"code-unique","fields":["code"]}],"indexes":[{"id":"code-index","fields":["code"]}]"#, + "principal", + ); + assert_class( + &baseline, + &constrained, + CompiledRegistryChangeCode::ConstraintAdded, + DiffClassification::LockOrRewriteRisk, + ); + assert_class( + &baseline, + &constrained, + CompiledRegistryChangeCode::IndexAdded, + DiffClassification::LockOrRewriteRisk, + ); + + let access = compiled("1", "internal", "", "", "subject"); + assert_class( + &baseline, + &access, + CompiledRegistryChangeCode::AccessProfileChanged, + DiffClassification::AccessChange, + ); + + assert_class( + &optional, + &baseline, + CompiledRegistryChangeCode::FieldRemoved, + DiffClassification::DestructiveOrIrreversible, + ); + + let identity_changed = compiled("2", "internal", "", "", "principal"); + assert_class( + &baseline, + &identity_changed, + CompiledRegistryChangeCode::RegistryIdentityChanged, + DiffClassification::Unsupported, + ); + } + + fn assert_class( + baseline: &CompiledRegistry, + candidate: &CompiledRegistry, + code: CompiledRegistryChangeCode, + classification: DiffClassification, + ) { + let diff = classify_registry_diff(baseline, candidate, PACKAGE_REVISION); + assert!(diff.changes.iter().any(|change| { + change.change.code == code && change.classification == classification + })); + } + + fn compiled( + version: &str, + classification: &str, + extra_fields: &str, + entity_members: &str, + principal_claim: &str, + ) -> CompiledRegistry { + let module_bytes = format!( + r#"{{"id":"core","version":"1","entities":[{{"id":"record","route":"records","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":16,"classification":"{classification}"}}{extra_fields}],"accessProfiles":[{{"id":"reader","principalClaim":"{principal_claim}","operations":["get"],"readableFields":["code"]}}]{entity_members}}}]}}"# + ); + let module = parse_module_json(module_bytes.as_bytes()).expect("module parses"); + let digest = module_digest(&module); + let project_bytes = format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"{version}","defaultLanguage":"en"}},"package":{{"environment":"local","instanceId":"instance-under-test","sequence":1,"sourceRevision":"compiler-source-revision"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"restricted","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"name":"Package Test Publisher"}}}},"dataset":{{"title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}}},"modules":[{{"id":"core","version":"1","digest":"{digest}"}}]}}"# + ); + let project = parse_project_json(project_bytes.as_bytes()).expect("project parses"); + compile_project(&project, &[module], CompileProfile::Production).expect("fixture compiles") + } +} diff --git a/crates/registry-server/src/webhook.rs b/crates/registry-server/src/webhook.rs new file mode 100644 index 0000000000..1c9f2d6c45 --- /dev/null +++ b/crates/registry-server/src/webhook.rs @@ -0,0 +1,1417 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Package-bound, at-least-once webhook delivery state machine. + +#[cfg(feature = "postgres-test")] +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use hmac::{Hmac, KeyInit, Mac}; +use registry_platform_audit::AuditProfile; +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use registry_platform_httputil::destination::{DestinationSendError, EventDeliveryHeaders}; +use sha2::{Digest, Sha256}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use tokio::sync::watch; +use tokio::time::Instant; +use tokio_postgres::Transaction; +use uuid::Uuid; + +use crate::audit::{ + append_webhook_audit, WebhookAudit, WebhookAuditDisposition, WebhookAuditOutcome, + WebhookAuditPhase, +}; +use crate::event_destination::ActivatedEventDestinationRegistry; +use crate::postgres::{ExpectedRegistryIdentity, RegistryLockKey, RuntimePool}; +use crate::startup::{OperationalEvent, WebhookStateTransitionCode}; + +const LEASE_FINALIZATION_ALLOWANCE: Duration = Duration::from_secs(5); +const WORKER_POLL_INTERVAL: Duration = Duration::from_millis(100); +const SIGNATURE_DOMAIN: &[u8] = b"registry-server-webhook-signature-v1"; +const IDEMPOTENCY_DOMAIN: &[u8] = b"registry-server-webhook-idempotency-v1"; + +type HmacSha256 = Hmac; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum WebhookDeliveryError { + #[error("webhook delivery is unavailable")] + Unavailable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebhookWorkOutcome { + Idle, + Delivered, + RetryScheduled, + DeadLettered, +} + +#[derive(Clone)] +pub struct WebhookDeliveryService { + pool: RuntimePool, + destinations: Arc, + expected: ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + lock_timeout: Duration, + audit_profile: AuditProfile, +} + +impl WebhookDeliveryService { + #[must_use] + pub fn new( + pool: RuntimePool, + destinations: Arc, + expected: ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + lock_timeout: Duration, + audit_profile: AuditProfile, + ) -> Self { + Self { + pool, + destinations, + expected, + lock_key, + lock_timeout, + audit_profile, + } + } + + /// Claim, audit, send, and finalize at most one due delivery. + /// + /// The pre-egress audit and lease commit before request rendering or + /// destination policy execution. Delivery is therefore explicitly + /// at-least-once when a process stops after network I/O and before CAS + /// finalization. + pub async fn deliver_once(&self) -> Result { + let Some(claim) = self.claim().await? else { + return Ok(WebhookWorkOutcome::Idle); + }; + let outcome = self.reload_and_send(&claim).await?; + self.finalize(&claim, outcome).await + } + + /// Reset one terminal delivery for an explicitly permitted operator replay. + /// + /// Every absent, stale, forbidden, or nonterminal target returns the same + /// value-free refusal. + pub async fn replay( + &self, + event_id: Uuid, + compiled_delivery_id: &str, + expected_generation: i64, + ) -> Result<(), WebhookDeliveryError> { + if compiled_delivery_id.is_empty() + || compiled_delivery_id.len() > 256 + || expected_generation <= 0 + { + return Err(WebhookDeliveryError::Unavailable); + } + let mut client = self + .pool + .get() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let transaction = client + .transaction() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + self.verify_transaction(&transaction).await?; + let row = transaction + .query_opt( + "SELECT state.generation, state.state, delivery.operator_replay + FROM registry_internal.registry_webhook_delivery_state AS state + JOIN registry_internal.registry_webhook_deliveries AS delivery + ON delivery.event_id = state.event_id + AND delivery.compiled_delivery_id = state.compiled_delivery_id + WHERE state.event_id = $1 + AND state.compiled_delivery_id = $2 + AND delivery.package_revision = $3 + AND delivery.schema_fingerprint = $4 + FOR UPDATE OF state", + &[ + &event_id, + &compiled_delivery_id, + &self.expected.package_revision, + &self.expected.schema_fingerprint, + ], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)? + .ok_or(WebhookDeliveryError::Unavailable)?; + let generation = row + .try_get::<_, i64>(0) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let state = row + .try_get::<_, String>(1) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let operator_replay = row + .try_get::<_, bool>(2) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + if generation != expected_generation + || !operator_replay + || !matches!(state.as_str(), "delivered" | "dead_lettered") + { + return Err(WebhookDeliveryError::Unavailable); + } + let next_generation = generation + .checked_add(1) + .ok_or(WebhookDeliveryError::Unavailable)?; + append_webhook_audit( + &transaction, + &self.audit_profile, + WebhookAudit { + event_id, + compiled_delivery_id, + package_revision: &self.expected.package_revision, + generation: next_generation, + attempt: 0, + phase: WebhookAuditPhase::Replay, + outcome: WebhookAuditOutcome::ReplayRequested, + disposition: WebhookAuditDisposition::ReplayPending, + }, + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let changed = transaction + .execute( + "UPDATE registry_internal.registry_webhook_delivery_state + SET generation = $4, + state = 'pending', + attempt = 0, + next_attempt_at = transaction_timestamp(), + attempt_started_at = NULL, + lease_expires_at = NULL, + lease_token = NULL, + delivered_at = NULL, + dead_lettered_at = NULL, + updated_at = transaction_timestamp() + WHERE event_id = $1 + AND compiled_delivery_id = $2 + AND generation = $3 + AND state IN ('delivered', 'dead_lettered')", + &[ + &event_id, + &compiled_delivery_id, + &generation, + &next_generation, + ], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + if changed != 1 { + return Err(WebhookDeliveryError::Unavailable); + } + transaction + .commit() + .await + .map_err(|_| WebhookDeliveryError::Unavailable) + } + + async fn claim(&self) -> Result, WebhookDeliveryError> { + let mut client = self + .pool + .get() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let transaction = client + .transaction() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + if self.verify_transaction(&transaction).await.is_err() { + webhook_failure(WebhookStateTransitionCode::ClaimIdentityRefused); + return Err(WebhookDeliveryError::Unavailable); + } + if self.reap_expired_leases(&transaction).await.is_err() { + webhook_failure(WebhookStateTransitionCode::ClaimRecoveryFailed); + return Err(WebhookDeliveryError::Unavailable); + } + let row = transaction + .query_opt( + "SELECT state.event_id, state.compiled_delivery_id, + state.generation, state.attempt, + delivery.deployed_attempt_timeout_ms, + delivery.deployed_maximum_attempts, + delivery.retry_delays_ms + FROM registry_internal.registry_webhook_delivery_state AS state + JOIN registry_internal.registry_webhook_deliveries AS delivery + ON delivery.event_id = state.event_id + AND delivery.compiled_delivery_id = state.compiled_delivery_id + WHERE state.state = 'pending' + AND state.next_attempt_at <= transaction_timestamp() + AND state.attempt < delivery.deployed_maximum_attempts + AND delivery.package_revision = $1 + AND delivery.schema_fingerprint = $2 + ORDER BY state.next_attempt_at, state.event_id, state.compiled_delivery_id + FOR UPDATE OF state SKIP LOCKED + LIMIT 1", + &[ + &self.expected.package_revision, + &self.expected.schema_fingerprint, + ], + ) + .await + .map_err(|_| { + webhook_failure(WebhookStateTransitionCode::ClaimSelectFailed); + WebhookDeliveryError::Unavailable + })?; + let Some(row) = row else { + transaction + .commit() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + return Ok(None); + }; + let event_id = row + .try_get::<_, Uuid>(0) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let compiled_delivery_id = bounded_delivery_id(&row, 1)?; + let generation = row + .try_get::<_, i64>(2) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let prior_attempt = row + .try_get::<_, i16>(3) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let deployed_attempt_timeout_ms = row + .try_get::<_, i64>(4) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let deployed_maximum_attempts = row + .try_get::<_, i16>(5) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let retry_delays_ms = row + .try_get::<_, Vec>(6) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let attempt = prior_attempt + .checked_add(1) + .filter(|attempt| *attempt <= deployed_maximum_attempts) + .ok_or(WebhookDeliveryError::Unavailable)?; + if validate_captured_policy( + deployed_attempt_timeout_ms, + deployed_maximum_attempts, + &retry_delays_ms, + ) + .is_err() + { + webhook_failure(WebhookStateTransitionCode::ClaimPolicyRefused); + return Err(WebhookDeliveryError::Unavailable); + } + let lease_token = Uuid::new_v4(); + let allowance_ms = i64::try_from(LEASE_FINALIZATION_ALLOWANCE.as_millis()) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let changed = transaction + .execute( + "UPDATE registry_internal.registry_webhook_delivery_state + SET state = 'leased', + attempt = $5, + next_attempt_at = NULL, + attempt_started_at = transaction_timestamp(), + lease_expires_at = transaction_timestamp() + + ($6::bigint + $7::bigint) * interval '1 millisecond', + lease_token = $8, + updated_at = transaction_timestamp() + WHERE event_id = $1 + AND compiled_delivery_id = $2 + AND generation = $3 + AND state = 'pending' + AND attempt = $4", + &[ + &event_id, + &compiled_delivery_id, + &generation, + &prior_attempt, + &attempt, + &deployed_attempt_timeout_ms, + &allowance_ms, + &lease_token, + ], + ) + .await + .map_err(|_| { + webhook_failure(WebhookStateTransitionCode::ClaimUpdateFailed); + WebhookDeliveryError::Unavailable + })?; + if changed != 1 { + return Err(WebhookDeliveryError::Unavailable); + } + let attempt_started_at = transaction + .query_one("SELECT transaction_timestamp()", &[]) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)? + .try_get::<_, SystemTime>(0) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + if append_webhook_audit( + &transaction, + &self.audit_profile, + WebhookAudit { + event_id, + compiled_delivery_id: &compiled_delivery_id, + package_revision: &self.expected.package_revision, + generation, + attempt, + phase: WebhookAuditPhase::Attempt, + outcome: WebhookAuditOutcome::AttemptStarted, + disposition: WebhookAuditDisposition::Leased, + }, + ) + .await + .is_err() + { + webhook_failure(WebhookStateTransitionCode::ClaimAuditFailed); + return Err(WebhookDeliveryError::Unavailable); + } + transaction.commit().await.map_err(|_| { + webhook_failure(WebhookStateTransitionCode::ClaimCommitFailed); + WebhookDeliveryError::Unavailable + })?; + Ok(Some(DeliveryClaim { + event_id, + compiled_delivery_id, + generation, + attempt, + attempt_started_at, + lease_token, + deployed_maximum_attempts, + retry_delays_ms, + })) + } + + async fn reap_expired_leases( + &self, + transaction: &Transaction<'_>, + ) -> Result<(), WebhookDeliveryError> { + let row = transaction + .query_opt( + "SELECT state.event_id, state.compiled_delivery_id, + state.generation, state.attempt, state.lease_token, + delivery.deployed_maximum_attempts, + delivery.retry_delays_ms + FROM registry_internal.registry_webhook_delivery_state AS state + JOIN registry_internal.registry_webhook_deliveries AS delivery + ON delivery.event_id = state.event_id + AND delivery.compiled_delivery_id = state.compiled_delivery_id + WHERE state.state = 'leased' + AND state.lease_expires_at <= transaction_timestamp() + AND delivery.package_revision = $1 + AND delivery.schema_fingerprint = $2 + ORDER BY state.lease_expires_at, state.event_id, state.compiled_delivery_id + FOR UPDATE OF state SKIP LOCKED + LIMIT 1", + &[ + &self.expected.package_revision, + &self.expected.schema_fingerprint, + ], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let Some(row) = row else { + return Ok(()); + }; + let event_id = row + .try_get::<_, Uuid>(0) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let compiled_delivery_id = bounded_delivery_id(&row, 1)?; + let generation = row + .try_get::<_, i64>(2) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let attempt = row + .try_get::<_, i16>(3) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let lease_token = row + .try_get::<_, Uuid>(4) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let deployed_maximum_attempts = row + .try_get::<_, i16>(5) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let retry_delays_ms = row + .try_get::<_, Vec>(6) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + validate_captured_policy(100, deployed_maximum_attempts, &retry_delays_ms)?; + let dead_lettered = attempt >= deployed_maximum_attempts; + append_webhook_audit( + transaction, + &self.audit_profile, + WebhookAudit { + event_id, + compiled_delivery_id: &compiled_delivery_id, + package_revision: &self.expected.package_revision, + generation, + attempt, + phase: WebhookAuditPhase::Terminal, + outcome: WebhookAuditOutcome::WorkerInterrupted, + disposition: if dead_lettered { + WebhookAuditDisposition::DeadLettered + } else { + WebhookAuditDisposition::RetryPending + }, + }, + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let changed = if dead_lettered { + transaction + .execute( + "UPDATE registry_internal.registry_webhook_delivery_state + SET state = 'dead_lettered', + next_attempt_at = NULL, + attempt_started_at = NULL, + lease_expires_at = NULL, + lease_token = NULL, + dead_lettered_at = transaction_timestamp(), + updated_at = transaction_timestamp() + WHERE event_id = $1 + AND compiled_delivery_id = $2 + AND generation = $3 + AND attempt = $4 + AND lease_token = $5 + AND state = 'leased' + AND lease_expires_at <= transaction_timestamp()", + &[ + &event_id, + &compiled_delivery_id, + &generation, + &attempt, + &lease_token, + ], + ) + .await + } else { + let delay_index = + usize::try_from(attempt - 1).map_err(|_| WebhookDeliveryError::Unavailable)?; + let delay_ms = *retry_delays_ms + .get(delay_index) + .filter(|delay| **delay > 0) + .ok_or(WebhookDeliveryError::Unavailable)?; + transaction + .execute( + "UPDATE registry_internal.registry_webhook_delivery_state + SET state = 'pending', + next_attempt_at = attempt_started_at + + $6::bigint * interval '1 millisecond', + attempt_started_at = NULL, + lease_expires_at = NULL, + lease_token = NULL, + updated_at = transaction_timestamp() + WHERE event_id = $1 + AND compiled_delivery_id = $2 + AND generation = $3 + AND attempt = $4 + AND lease_token = $5 + AND state = 'leased' + AND lease_expires_at <= transaction_timestamp()", + &[ + &event_id, + &compiled_delivery_id, + &generation, + &attempt, + &lease_token, + &delay_ms, + ], + ) + .await + } + .map_err(|_| WebhookDeliveryError::Unavailable)?; + if changed != 1 { + return Err(WebhookDeliveryError::Unavailable); + } + Ok(()) + } + + async fn reload_and_send( + &self, + claim: &DeliveryClaim, + ) -> Result { + let material = match self.reload_material(claim).await { + Ok(material) => material, + Err(MaterialLoadError::Unavailable) => return Err(WebhookDeliveryError::Unavailable), + Err(MaterialLoadError::PayloadRefused) => { + return Ok(WebhookAuditOutcome::PayloadRefused) + } + Err(MaterialLoadError::BindingRefused) => { + return Ok(WebhookAuditOutcome::DestinationBindingRefused) + } + }; + let Some(destination) = self.destinations.lookup(&material.logical_destination_id) else { + return Ok(WebhookAuditOutcome::DestinationBindingRefused); + }; + let deployed_timeout_ms = i64::try_from(destination.attempt_timeout().as_millis()) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + if destination.binding_digest() != material.destination_binding_digest + || deployed_timeout_ms != material.deployed_attempt_timeout_ms + || i16::from(destination.maximum_attempts()) != material.deployed_maximum_attempts + { + return Ok(WebhookAuditOutcome::DestinationBindingRefused); + } + let timestamp = OffsetDateTime::from(claim.attempt_started_at) + .format(&Rfc3339) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let event_id = claim.event_id.to_string(); + let generation = claim.generation.to_string(); + let attempt = claim.attempt.to_string(); + let idempotency_key = webhook_idempotency_key( + claim.event_id, + &claim.compiled_delivery_id, + claim.generation, + &material.payload_digest, + &material.destination_binding_digest, + ); + let signature = destination.with_hmac_sha256_key(|key| { + webhook_signature( + key, + SignatureFields { + event_id: &event_id, + event_type: &material.event_type, + generation: &generation, + attempt: &attempt, + timestamp: ×tamp, + idempotency_key: &idempotency_key, + body: &material.body, + }, + ) + }); + let Ok(signature) = signature else { + return Ok(WebhookAuditOutcome::DestinationPolicyRefused); + }; + let request = match destination.request_template().render_event( + EventDeliveryHeaders { + event_id: event_id.as_bytes(), + event_type: material.event_type.as_bytes(), + generation: generation.as_bytes(), + attempt: attempt.as_bytes(), + timestamp: timestamp.as_bytes(), + idempotency_key: idempotency_key.as_bytes(), + signature: signature.as_bytes(), + }, + material.body, + ) { + Ok(request) => request, + Err(_) => return Ok(WebhookAuditOutcome::DestinationPolicyRefused), + }; + let attempt_timeout = Duration::from_millis( + u64::try_from(material.deployed_attempt_timeout_ms) + .map_err(|_| WebhookDeliveryError::Unavailable)?, + ); + let elapsed = SystemTime::now() + .duration_since(claim.attempt_started_at) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let Some(remaining) = attempt_timeout.checked_sub(elapsed) else { + return Ok(WebhookAuditOutcome::DestinationTimeout); + }; + if remaining.is_zero() { + return Ok(WebhookAuditOutcome::DestinationTimeout); + } + let monotonic_deadline = Instant::now() + remaining; + match destination.policy().send(request, remaining).await { + Ok(response) if response.status().is_success() => Ok(WebhookAuditOutcome::Delivered), + Ok(_) => Ok(WebhookAuditOutcome::HttpNonSuccess), + Err(error) => Ok(classify_send_error( + error, + monotonic_deadline.saturating_duration_since(Instant::now()) + <= Duration::from_millis(1), + )), + } + } + + async fn reload_material( + &self, + claim: &DeliveryClaim, + ) -> Result { + let mut client = self + .pool + .get() + .await + .map_err(|_| MaterialLoadError::Unavailable)?; + let transaction = client + .transaction() + .await + .map_err(|_| MaterialLoadError::Unavailable)?; + self.verify_transaction(&transaction) + .await + .map_err(|_| MaterialLoadError::Unavailable)?; + let row = transaction + .query_opt( + "SELECT outbox.event_type, outbox.payload, + outbox.package_revision, outbox.schema_fingerprint, + delivery.destination_binding_digest, + delivery.logical_destination_id, + delivery.maximum_payload_bytes, + delivery.payload_digest, + delivery.deployed_attempt_timeout_ms, + delivery.deployed_maximum_attempts, + delivery.authentication_profile, + delivery.delivery_mode, + delivery.dead_letter + FROM registry_internal.registry_webhook_delivery_state AS state + JOIN registry_internal.registry_webhook_deliveries AS delivery + ON delivery.event_id = state.event_id + AND delivery.compiled_delivery_id = state.compiled_delivery_id + JOIN registry_internal.registry_outbox AS outbox + ON outbox.event_id = delivery.event_id + AND outbox.package_revision = delivery.package_revision + AND outbox.schema_fingerprint = delivery.schema_fingerprint + WHERE state.event_id = $1 + AND state.compiled_delivery_id = $2 + AND state.generation = $3 + AND state.attempt = $4 + AND state.lease_token = $5 + AND state.state = 'leased' + AND state.lease_expires_at > transaction_timestamp() + FOR SHARE OF state", + &[ + &claim.event_id, + &claim.compiled_delivery_id, + &claim.generation, + &claim.attempt, + &claim.lease_token, + ], + ) + .await + .map_err(|_| MaterialLoadError::Unavailable)? + .ok_or(MaterialLoadError::Unavailable)?; + let event_type = + bounded_text(&row, 0, 256).map_err(|_| MaterialLoadError::PayloadRefused)?; + let body = row + .try_get::<_, Vec>(1) + .map_err(|_| MaterialLoadError::PayloadRefused)?; + let outbox_package_revision = + bounded_text(&row, 2, 256).map_err(|_| MaterialLoadError::PayloadRefused)?; + let outbox_schema_fingerprint = + bounded_text(&row, 3, 256).map_err(|_| MaterialLoadError::PayloadRefused)?; + let destination_binding_digest = + bounded_text(&row, 4, 71).map_err(|_| MaterialLoadError::PayloadRefused)?; + let logical_destination_id = + bounded_text(&row, 5, 64).map_err(|_| MaterialLoadError::PayloadRefused)?; + let maximum_payload_bytes = row + .try_get::<_, i64>(6) + .map_err(|_| MaterialLoadError::PayloadRefused)?; + let payload_digest = row + .try_get::<_, Vec>(7) + .map_err(|_| MaterialLoadError::PayloadRefused)?; + let deployed_attempt_timeout_ms = row + .try_get::<_, i64>(8) + .map_err(|_| MaterialLoadError::PayloadRefused)?; + let deployed_maximum_attempts = row + .try_get::<_, i16>(9) + .map_err(|_| MaterialLoadError::PayloadRefused)?; + let authentication_profile = + bounded_text(&row, 10, 32).map_err(|_| MaterialLoadError::PayloadRefused)?; + let delivery_mode = + bounded_text(&row, 11, 32).map_err(|_| MaterialLoadError::PayloadRefused)?; + let dead_letter = + bounded_text(&row, 12, 32).map_err(|_| MaterialLoadError::PayloadRefused)?; + transaction + .commit() + .await + .map_err(|_| MaterialLoadError::Unavailable)?; + let digest = Sha256::digest(&body); + let parsed = parse_json_strict(&body).map_err(|_| MaterialLoadError::PayloadRefused)?; + let canonical = + canonicalize_json(&parsed).map_err(|_| MaterialLoadError::PayloadRefused)?; + if outbox_package_revision != self.expected.package_revision + || outbox_schema_fingerprint != self.expected.schema_fingerprint + || authentication_profile != "hmac_sha256_v1" + || delivery_mode != "after_commit" + || dead_letter != "required" + || !(100..=10_000).contains(&deployed_attempt_timeout_ms) + || !(1..=20).contains(&deployed_maximum_attempts) + { + return Err(MaterialLoadError::BindingRefused); + } + if body.is_empty() + || i64::try_from(body.len()).ok() > Some(maximum_payload_bytes) + || payload_digest.len() != 32 + || payload_digest.as_slice() != digest.as_slice() + || canonical != body + || !parsed.is_object() + { + return Err(MaterialLoadError::PayloadRefused); + } + Ok(DeliveryMaterial { + event_type, + body, + payload_digest, + destination_binding_digest, + logical_destination_id, + deployed_attempt_timeout_ms, + deployed_maximum_attempts, + }) + } + + async fn finalize( + &self, + claim: &DeliveryClaim, + outcome: WebhookAuditOutcome, + ) -> Result { + let mut client = self + .pool + .get() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let transaction = client + .transaction() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + self.verify_transaction(&transaction).await?; + let (disposition, work_outcome) = if outcome == WebhookAuditOutcome::Delivered { + ( + WebhookAuditDisposition::Delivered, + WebhookWorkOutcome::Delivered, + ) + } else if claim.attempt >= claim.deployed_maximum_attempts { + ( + WebhookAuditDisposition::DeadLettered, + WebhookWorkOutcome::DeadLettered, + ) + } else { + ( + WebhookAuditDisposition::RetryPending, + WebhookWorkOutcome::RetryScheduled, + ) + }; + append_webhook_audit( + &transaction, + &self.audit_profile, + WebhookAudit { + event_id: claim.event_id, + compiled_delivery_id: &claim.compiled_delivery_id, + package_revision: &self.expected.package_revision, + generation: claim.generation, + attempt: claim.attempt, + phase: WebhookAuditPhase::Terminal, + outcome, + disposition, + }, + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let changed = match work_outcome { + WebhookWorkOutcome::Delivered => { + self.update_terminal_state(&transaction, claim, "delivered") + .await? + } + WebhookWorkOutcome::DeadLettered => { + self.update_terminal_state(&transaction, claim, "dead_lettered") + .await? + } + WebhookWorkOutcome::RetryScheduled => { + let delay_index = usize::try_from(claim.attempt - 1) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let delay_ms = *claim + .retry_delays_ms + .get(delay_index) + .filter(|delay| **delay > 0) + .ok_or(WebhookDeliveryError::Unavailable)?; + transaction + .execute( + "UPDATE registry_internal.registry_webhook_delivery_state + SET state = 'pending', + next_attempt_at = attempt_started_at + + $6::bigint * interval '1 millisecond', + attempt_started_at = NULL, + lease_expires_at = NULL, + lease_token = NULL, + updated_at = transaction_timestamp() + WHERE event_id = $1 + AND compiled_delivery_id = $2 + AND generation = $3 + AND attempt = $4 + AND lease_token = $5 + AND state = 'leased'", + &[ + &claim.event_id, + &claim.compiled_delivery_id, + &claim.generation, + &claim.attempt, + &claim.lease_token, + &delay_ms, + ], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)? + } + WebhookWorkOutcome::Idle => return Err(WebhookDeliveryError::Unavailable), + }; + if changed != 1 { + return Err(WebhookDeliveryError::Unavailable); + } + transaction + .commit() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + Ok(work_outcome) + } + + async fn update_terminal_state( + &self, + transaction: &Transaction<'_>, + claim: &DeliveryClaim, + state: &str, + ) -> Result { + let timestamp_column = match state { + "delivered" => "delivered_at", + "dead_lettered" => "dead_lettered_at", + _ => return Err(WebhookDeliveryError::Unavailable), + }; + transaction + .execute( + &format!( + "UPDATE registry_internal.registry_webhook_delivery_state + SET state = '{state}', + next_attempt_at = NULL, + attempt_started_at = NULL, + lease_expires_at = NULL, + lease_token = NULL, + {timestamp_column} = transaction_timestamp(), + updated_at = transaction_timestamp() + WHERE event_id = $1 + AND compiled_delivery_id = $2 + AND generation = $3 + AND attempt = $4 + AND lease_token = $5 + AND state = 'leased'" + ), + &[ + &claim.event_id, + &claim.compiled_delivery_id, + &claim.generation, + &claim.attempt, + &claim.lease_token, + ], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable) + } + + async fn verify_transaction( + &self, + transaction: &Transaction<'_>, + ) -> Result<(), WebhookDeliveryError> { + if self.lock_timeout.is_zero() || self.lock_timeout > Duration::from_secs(30) { + return Err(WebhookDeliveryError::Unavailable); + } + let timeout_millis = i32::try_from(self.lock_timeout.as_millis()) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + transaction + .execute( + "SELECT set_config('lock_timeout', $1::text, true)", + &[&format!("{timeout_millis}ms")], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + transaction + .execute( + "SELECT pg_advisory_xact_lock_shared($1)", + &[&self.lock_key.get()], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let state = transaction + .query_opt( + "SELECT package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)? + .ok_or(WebhookDeliveryError::Unavailable)?; + let ready = state.try_get::<_, String>(7).ok().as_deref() == Some("ready") + && state.try_get::<_, String>(0).ok().as_deref() + == Some(self.expected.package_id.as_str()) + && state.try_get::<_, String>(1).ok().as_deref() + == Some(self.expected.environment.as_str()) + && state.try_get::<_, String>(2).ok().as_deref() + == Some(self.expected.instance_id.as_str()) + && state.try_get::<_, String>(3).ok().as_deref() + == Some(self.expected.database_id.as_str()) + && state.try_get::<_, String>(4).ok().as_deref() + == Some(self.expected.package_revision.as_str()) + && state.try_get::<_, String>(5).ok().as_deref() + == Some(self.expected.schema_fingerprint.as_str()) + && state.try_get::<_, i64>(6).ok() == Some(self.expected.package_sequence); + if !ready { + return Err(WebhookDeliveryError::Unavailable); + } + Ok(()) + } +} + +#[derive(Clone)] +pub struct WebhookWorker { + kind: WebhookWorkerKind, +} + +impl WebhookWorker { + #[must_use] + pub fn new(service: WebhookDeliveryService) -> Self { + Self { + kind: WebhookWorkerKind::Delivery(service), + } + } + + pub async fn run(self, mut shutdown: watch::Receiver) { + #[cfg(not(feature = "postgres-test"))] + let WebhookWorkerKind::Delivery(service) = self.kind; + #[cfg(feature = "postgres-test")] + let service = match self.kind { + WebhookWorkerKind::Delivery(service) => service, + WebhookWorkerKind::LifecycleProbe(probe) => { + probe.run(shutdown).await; + return; + } + }; + loop { + if *shutdown.borrow() { + return; + } + if service.deliver_once().await.is_err() { + OperationalEvent::WebhookWorkerIterationFailed.emit(); + } + tokio::select! { + changed = shutdown.changed() => { + if changed.is_err() || *shutdown.borrow() { + return; + } + } + () = tokio::time::sleep(WORKER_POLL_INTERVAL) => {} + } + } + } +} + +#[derive(Clone)] +enum WebhookWorkerKind { + Delivery(WebhookDeliveryService), + #[cfg(feature = "postgres-test")] + LifecycleProbe(WebhookWorkerLifecycleProbe), +} + +/// Test-only observation point for proving startup task ownership. +#[cfg(feature = "postgres-test")] +#[doc(hidden)] +#[derive(Clone)] +pub struct WebhookWorkerLifecycleProbe { + state: Arc, + hang: bool, +} + +#[cfg(feature = "postgres-test")] +struct WebhookWorkerLifecycleState { + started: AtomicBool, + running: AtomicBool, + stopped: AtomicBool, +} + +#[cfg(feature = "postgres-test")] +impl WebhookWorkerLifecycleProbe { + #[must_use] + pub fn new(hang: bool) -> Self { + Self { + state: Arc::new(WebhookWorkerLifecycleState { + started: AtomicBool::new(false), + running: AtomicBool::new(false), + stopped: AtomicBool::new(false), + }), + hang, + } + } + + #[must_use] + pub fn worker(&self) -> WebhookWorker { + WebhookWorker { + kind: WebhookWorkerKind::LifecycleProbe(self.clone()), + } + } + + #[must_use] + pub fn started(&self) -> bool { + self.state.started.load(Ordering::SeqCst) + } + + #[must_use] + pub fn running(&self) -> bool { + self.state.running.load(Ordering::SeqCst) + } + + #[must_use] + pub fn stopped(&self) -> bool { + self.state.stopped.load(Ordering::SeqCst) + } + + async fn run(self, mut shutdown: watch::Receiver) { + self.state.started.store(true, Ordering::SeqCst); + self.state.running.store(true, Ordering::SeqCst); + let _guard = WebhookWorkerLifecycleGuard(Arc::clone(&self.state)); + if self.hang { + std::future::pending::<()>().await; + } + while !*shutdown.borrow() { + if shutdown.changed().await.is_err() { + return; + } + } + } +} + +#[cfg(feature = "postgres-test")] +struct WebhookWorkerLifecycleGuard(Arc); + +#[cfg(feature = "postgres-test")] +impl Drop for WebhookWorkerLifecycleGuard { + fn drop(&mut self) { + self.0.running.store(false, Ordering::SeqCst); + self.0.stopped.store(true, Ordering::SeqCst); + } +} + +fn webhook_failure(code: WebhookStateTransitionCode) { + OperationalEvent::WebhookStateTransitionFailed(code).emit(); +} + +struct DeliveryClaim { + event_id: Uuid, + compiled_delivery_id: String, + generation: i64, + attempt: i16, + attempt_started_at: SystemTime, + lease_token: Uuid, + deployed_maximum_attempts: i16, + retry_delays_ms: Vec, +} + +struct DeliveryMaterial { + event_type: String, + body: Vec, + payload_digest: Vec, + destination_binding_digest: String, + logical_destination_id: String, + deployed_attempt_timeout_ms: i64, + deployed_maximum_attempts: i16, +} + +enum MaterialLoadError { + Unavailable, + BindingRefused, + PayloadRefused, +} + +fn classify_send_error(error: DestinationSendError, deadline_reached: bool) -> WebhookAuditOutcome { + match error { + DestinationSendError::DeadlineExceeded => WebhookAuditOutcome::DestinationTimeout, + DestinationSendError::ResolutionFailed + | DestinationSendError::TooManyResolverAnswers + | DestinationSendError::NoResolverAnswers + | DestinationSendError::ResolverPortMismatch + | DestinationSendError::ResolverAddressFamilyMismatch + | DestinationSendError::LiteralOriginMismatch + | DestinationSendError::CloudMetadataDenied + | DestinationSendError::AlwaysDeniedAddress + | DestinationSendError::PrivateAddressNotAllowed + | DestinationSendError::NonGlobalAddressDenied + | DestinationSendError::DevelopmentAddressDenied => { + WebhookAuditOutcome::DestinationResolutionRefused + } + DestinationSendError::ResolutionCapacityUnavailable + | DestinationSendError::TlsMaterialUnavailable + | DestinationSendError::ClientBuildFailed + | DestinationSendError::TooManyResponseHeaders + | DestinationSendError::ResponseHeaderBytesExceeded => { + WebhookAuditOutcome::DestinationTransportUnavailable + } + DestinationSendError::TransportFailed if deadline_reached => { + WebhookAuditOutcome::DestinationTimeout + } + DestinationSendError::TransportFailed => { + WebhookAuditOutcome::DestinationTransportUnavailable + } + DestinationSendError::InvalidRemainingTimeout + | DestinationSendError::InvalidFrozenPolicy + | DestinationSendError::InvalidFrozenRequest => { + WebhookAuditOutcome::DestinationPolicyRefused + } + } +} + +fn bounded_delivery_id( + row: &tokio_postgres::Row, + index: usize, +) -> Result { + bounded_text(row, index, 256) +} + +fn bounded_text( + row: &tokio_postgres::Row, + index: usize, + maximum: usize, +) -> Result { + let value = row + .try_get::<_, String>(index) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + if value.is_empty() || value.len() > maximum { + return Err(WebhookDeliveryError::Unavailable); + } + Ok(value) +} + +fn validate_captured_policy( + deployed_attempt_timeout_ms: i64, + deployed_maximum_attempts: i16, + retry_delays_ms: &[i64], +) -> Result<(), WebhookDeliveryError> { + let expected_delays = usize::try_from(deployed_maximum_attempts.saturating_sub(1)) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + if !(100..=10_000).contains(&deployed_attempt_timeout_ms) + || !(1..=20).contains(&deployed_maximum_attempts) + || retry_delays_ms.len() < expected_delays + || retry_delays_ms + .iter() + .any(|delay| !(100..=3_600_000).contains(delay)) + { + return Err(WebhookDeliveryError::Unavailable); + } + Ok(()) +} + +fn webhook_idempotency_key( + event_id: Uuid, + compiled_delivery_id: &str, + generation: i64, + payload_digest: &[u8], + destination_binding_digest: &str, +) -> String { + let mut input = Vec::new(); + input.extend_from_slice(IDEMPOTENCY_DOMAIN); + append_length_prefixed(&mut input, event_id.to_string().as_bytes()); + append_length_prefixed(&mut input, compiled_delivery_id.as_bytes()); + append_length_prefixed(&mut input, generation.to_string().as_bytes()); + append_length_prefixed(&mut input, payload_digest); + append_length_prefixed(&mut input, destination_binding_digest.as_bytes()); + format!("sha256:{}", hex::encode(Sha256::digest(input))) +} + +struct SignatureFields<'a> { + event_id: &'a str, + event_type: &'a str, + generation: &'a str, + attempt: &'a str, + timestamp: &'a str, + idempotency_key: &'a str, + body: &'a [u8], +} + +fn webhook_signature( + key: &[u8], + fields: SignatureFields<'_>, +) -> Result { + let mut input = Vec::new(); + input.extend_from_slice(SIGNATURE_DOMAIN); + for value in [ + fields.event_id.as_bytes(), + fields.event_type.as_bytes(), + fields.generation.as_bytes(), + fields.attempt.as_bytes(), + fields.timestamp.as_bytes(), + fields.idempotency_key.as_bytes(), + fields.body, + ] { + append_length_prefixed(&mut input, value); + } + let mut mac = HmacSha256::new_from_slice(key).map_err(|_| WebhookDeliveryError::Unavailable)?; + mac.update(&input); + Ok(format!( + "v1={}", + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) + )) +} + +fn append_length_prefixed(output: &mut Vec, value: &[u8]) { + output.extend_from_slice(&(value.len() as u64).to_be_bytes()); + output.extend_from_slice(value); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hmac_sha256_v1_binds_every_header_and_exact_canonical_body() { + let key = [0x5a; 32]; + let signature = |key: &[u8], + event_id: &str, + event_type: &str, + generation: &str, + attempt: &str, + timestamp: &str, + idempotency_key: &str, + body: &[u8]| { + webhook_signature( + key, + SignatureFields { + event_id, + event_type, + generation, + attempt, + timestamp, + idempotency_key, + body, + }, + ) + .expect("bounded signature computes") + }; + let event_id = "00000000-0000-4000-8000-000000000001"; + let event_type = "case-created"; + let generation = "1"; + let attempt = "1"; + let timestamp = "2026-08-30T00:00:00Z"; + let idempotency_key = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let body = br#"{"label":"value"}"#; + let baseline = signature( + &key, + event_id, + event_type, + generation, + attempt, + timestamp, + idempotency_key, + body, + ); + for changed in [ + signature( + &key, + "00000000-0000-4000-8000-000000000002", + event_type, + generation, + attempt, + timestamp, + idempotency_key, + body, + ), + signature( + &key, + event_id, + "case-patched", + generation, + attempt, + timestamp, + idempotency_key, + body, + ), + signature( + &key, + event_id, + event_type, + "2", + attempt, + timestamp, + idempotency_key, + body, + ), + signature( + &key, + event_id, + event_type, + generation, + "2", + timestamp, + idempotency_key, + body, + ), + signature( + &key, + event_id, + event_type, + generation, + attempt, + "2026-08-30T00:00:01Z", + idempotency_key, + body, + ), + signature( + &key, + event_id, + event_type, + generation, + attempt, + timestamp, + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + body, + ), + signature( + &key, + event_id, + event_type, + generation, + attempt, + timestamp, + idempotency_key, + br#"{"label":"changed"}"#, + ), + signature( + &[0x6b; 32], + event_id, + event_type, + generation, + attempt, + timestamp, + idempotency_key, + body, + ), + ] { + assert_ne!(baseline, changed); + } + assert!(baseline.starts_with("v1=")); + assert!(!baseline[3..].contains('=')); + } + + #[test] + fn idempotency_key_is_stable_across_retries_and_changes_on_replay_generation() { + let event_id = + Uuid::parse_str("00000000-0000-4000-8000-000000000001").expect("fixture UUID parses"); + let digest = Sha256::digest(br#"{"label":"value"}"#); + let first_key = webhook_idempotency_key( + event_id, + "events.case.created.webhook", + 1, + digest.as_slice(), + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let repeated_key = webhook_idempotency_key( + event_id, + "events.case.created.webhook", + 1, + digest.as_slice(), + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let replay_key = webhook_idempotency_key( + event_id, + "events.case.created.webhook", + 2, + digest.as_slice(), + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + assert_eq!(first_key, repeated_key); + assert_ne!(first_key, replay_key); + assert!(first_key.starts_with("sha256:")); + assert_eq!(first_key.len(), 71); + } + + #[test] + fn transport_failure_is_separate_from_a_reached_attempt_deadline() { + assert_eq!( + classify_send_error(DestinationSendError::TransportFailed, false), + WebhookAuditOutcome::DestinationTransportUnavailable + ); + assert_eq!( + classify_send_error(DestinationSendError::TransportFailed, true), + WebhookAuditOutcome::DestinationTimeout + ); + assert_eq!( + classify_send_error(DestinationSendError::DeadlineExceeded, false), + WebhookAuditOutcome::DestinationTimeout + ); + } +} diff --git a/crates/registry-server/tests/compiler_contract.rs b/crates/registry-server/tests/compiler_contract.rs new file mode 100644 index 0000000000..f6cc429f3b --- /dev/null +++ b/crates/registry-server/tests/compiler_contract.rs @@ -0,0 +1,2701 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; + +use registry_manifest_core::{compile_manifest, AccessRights, FieldType, MetadataManifest}; +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use registry_server::artifacts::REGISTRY_METADATA_ARTIFACT_PATH; +use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::contract::{ + parse_module_json, parse_module_yaml, parse_project_json, parse_project_yaml, + AccessProfileSource, BoundaryOperator, Classification, ComparisonOperator, ConstraintSource, + FieldTypeSource, Operation, PackageIdentitySource, ReferenceDelete, RegistryModule, + RowBoundarySource, UniqueWhenPredicate, +}; +use registry_server::diagnostics::CompileFailure; +use registry_server::generated_ddl::DdlStatementKind; +use registry_server::model::{ + CompiledMetadataInventory, CompiledQueryFilterOperator, CompiledQueryKind, + CompiledQuerySortDirection, CompiledQueryTemporalSemantics, CompiledRevisionKind, + MAX_REVISION_HISTORY_RECORDS, +}; +use serde_json::{json, Value}; + +fn asset_project() -> registry_server::contract::RegistryProject { + acceptance_project("asset-site-placement") +} + +fn asset_modules() -> Vec { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/registry-server/acceptance/asset-site-placement/modules") + .join("asset-site-placement-core/module.yaml"); + let bytes = fs::read(path).expect("committed acceptance module is readable"); + vec![parse_module_yaml(&bytes).expect("acceptance module follows the authoring contract")] +} + +fn acceptance_project(domain: &str) -> registry_server::contract::RegistryProject { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/registry-server/acceptance") + .join(domain) + .join("registry.yaml"); + let bytes = fs::read(path).expect("committed acceptance fixture is readable"); + parse_project_yaml(&bytes).expect("acceptance fixture follows the authoring contract") +} + +fn compile_json(source: &[u8]) -> Result { + let project = parse_project_json(source).expect("source shape parses"); + compile_project(&project, &[], CompileProfile::Authoring) +} + +#[test] +fn batch_route_requires_explicit_bounds_and_compiles_bounded_openapi() { + let source = |batch: &str, operations: &str| { + format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"batch-contract","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"record","route":"records","mutationMode":"mutable"{batch}, + "fields":[{{"id":"label","type":"string","maxLength":32,"required":true,"classification":"internal"}}], + "accessProfiles":[{{ + "id":"writer","principalClaim":"principal","operations":{operations}, + "readableFields":["label"],"writableFields":["label"] + }}] + }}] + }}"# + ) + }; + + let missing = compile_json(source("", r#"["create","batch"]"#).as_bytes()) + .expect_err("Batch grants require explicit entity-local bounds"); + assert!(missing + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "entity.batch.required")); + + for batch in [ + r#", "batch":{"maximumItems":0,"maximumBytes":1}"#, + r#", "batch":{"maximumItems":101,"maximumBytes":1}"#, + r#", "batch":{"maximumItems":1,"maximumBytes":0}"#, + r#", "batch":{"maximumItems":1,"maximumBytes":2097153}"#, + ] { + let failure = compile_json(source(batch, r#"["create","batch"]"#).as_bytes()) + .expect_err("out-of-range Batch bounds are refused"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "entity.batch.bounds_invalid")); + } + + let configured = compile_json( + source( + r#", "batch":{"maximumItems":37,"maximumBytes":65536}"#, + r#"["create","patch","batch"]"#, + ) + .as_bytes(), + ) + .expect("bounded Batch contract compiles"); + let route = configured + .routes() + .routes + .iter() + .find(|route| route.operation == Operation::Batch) + .expect("one Batch route is generated"); + assert_eq!(route.id, "records.record.batch"); + assert_eq!(route.path, "/v1/records/records:batch"); + let openapi = parse_json_strict( + &configured + .artifacts() + .get("generated/openapi.json") + .expect("OpenAPI is generated") + .bytes, + ) + .expect("OpenAPI is strict JSON"); + let batch_operation = &openapi["paths"]["/v1/records/records:batch"]["post"]; + assert_eq!(batch_operation["x-registry-maximumItems"], 37); + assert_eq!(batch_operation["x-registry-maximumBytes"], 65536); + assert_eq!( + batch_operation["requestBody"]["content"]["application/json"]["schema"]["properties"] + ["items"]["maxItems"], + 37 + ); + assert_eq!( + batch_operation["responses"]["200"]["content"]["application/json"]["schema"]["properties"] + ["results"]["maxItems"], + 37 + ); + + let configured_but_ungranted = compile_json( + source( + r#", "batch":{"maximumItems":10,"maximumBytes":4096}"#, + r#"["create"]"#, + ) + .as_bytes(), + ) + .expect("unused valid bounds do not create authority"); + assert!(configured_but_ungranted + .routes() + .routes + .iter() + .all(|route| route.operation != Operation::Batch)); + + let create_only = source( + r#", "batch":{"maximumItems":10,"maximumBytes":4096}"#, + r#"["create","batch"]"#, + ) + .replace( + r#""mutationMode":"mutable""#, + r#""mutationMode":"create_only""#, + ); + let create_only = compile_json(create_only.as_bytes()) + .expect("create-only entities may expose bounded batch create"); + assert!(create_only + .routes() + .routes + .iter() + .any(|route| route.operation == Operation::Batch)); + + let create_only_patch = source( + r#", "batch":{"maximumItems":10,"maximumBytes":4096}"#, + r#"["create","patch","batch"]"#, + ) + .replace( + r#""mutationMode":"mutable""#, + r#""mutationMode":"create_only""#, + ); + let unavailable = compile_json(create_only_patch.as_bytes()) + .expect_err("create-only Batch profiles can never grant patch"); + assert!(unavailable + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "access_profile.operation.unavailable")); +} + +#[test] +fn public_asset_fixture_compiles_to_coherent_deterministic_inventories() { + let project = asset_project(); + let modules = asset_modules(); + let first = compile_project(&project, &modules, CompileProfile::Production) + .expect("the closed asset fixture compiles in production mode"); + let second = compile_project(&project, &modules, CompileProfile::Production) + .expect("the same source compiles twice"); + + assert_eq!(first, second); + assert_eq!(first.entities().len(), 4); + assert!(first.ddl().requires_btree_gist); + assert!(first.ddl().script().contains("EXCLUDE USING gist")); + assert!(first.artifacts().get("generated/openapi.json").is_some()); + assert!(first + .artifacts() + .get("generated/manifest/registry-manifest.json") + .is_some()); + assert!(first + .artifacts() + .get("generated/schemas/asset-placement.schema.json") + .is_some()); + + let inspection_routes: Vec<_> = first + .routes() + .routes + .iter() + .filter(|route| route.entity_id == "inspection-event") + .collect(); + assert!(inspection_routes + .iter() + .all(|route| !matches!(route.operation, Operation::Patch | Operation::Tombstone))); + assert!(first.findings().is_empty()); +} + +#[test] +fn production_refuses_incomplete_authoring_closure() { + let mut incomplete = asset_project(); + incomplete.package = None; + incomplete.modules[0].digest = None; + let failure = compile_project(&incomplete, &asset_modules(), CompileProfile::Production) + .expect_err("the authoring fixture is not a production package"); + let codes: Vec<_> = failure + .diagnostics() + .iter() + .map(|diagnostic| diagnostic.code.as_str()) + .collect(); + assert!(codes.contains(&"package.identity.required")); + assert!(codes.contains(&"module.lock.digest_required")); + assert!(!codes.contains(&"manifest_projection.required")); +} + +#[test] +fn production_requires_explicit_manifest_projection() { + let failure = compile_project( + &parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"neutral","version":"1","defaultLanguage":"en"}, + "package":{"environment":"local","instanceId":"local_instance","sequence":1,"sourceRevision":"source"}, + "entities":[{ + "id":"record","route":"records","mutationMode":"create_only", + "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}], + "accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}] + }] + }"#, + ) + .expect("source parses"), + &[], + CompileProfile::Production, + ) + .expect_err("production requires a manifest projection"); + + assert!(failure.diagnostics().iter().any(|diagnostic| { + diagnostic.code == "manifest_projection.required" + && diagnostic.path == "project.manifestProjection" + })); +} + +#[test] +fn manifest_projection_unknown_nested_keys_are_rejected_without_values() { + let failure = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"neutral","version":"1","defaultLanguage":"en"}, + "manifestProjection":{ + "accessProfile":"reader", + "classificationCeiling":"internal", + "catalog":{ + "baseUrl":"https://registry.example.test", + "title":"Registry Catalog", + "publisher":{"name":"Publisher","privateKey":"do-not-echo"} + }, + "dataset":{"title":"Registry Dataset"} + } + }"#, + ) + .expect_err("unknown projection members are refused"); + + assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); + assert_eq!( + failure.diagnostics()[0].path, + "project.manifestProjection.catalog.publisher.privateKey" + ); + assert!(!serde_json::to_string(&failure) + .expect("diagnostic serializes") + .contains("do-not-echo")); +} + +#[test] +fn manifest_projection_compiles_to_deterministic_valid_manifest_core() { + let compiled = compile_project(&asset_project(), &[], CompileProfile::Authoring) + .expect("asset fixture compiles"); + let artifact = compiled + .artifacts() + .get("generated/manifest/registry-manifest.json") + .expect("Manifest projection is generated"); + let value = parse_json_strict(&artifact.bytes).expect("Manifest projection is strict JSON"); + assert_eq!( + canonicalize_json(&value).expect("Manifest projection canonicalizes"), + artifact.bytes + ); + let manifest: MetadataManifest = + serde_json::from_value(value).expect("generated Manifest source parses"); + let first = compile_manifest(&manifest).expect("generated Manifest compiles"); + let second = compile_manifest(&manifest).expect("generated Manifest compiles twice"); + assert_eq!(first, second); + + let dataset = first + .dataset("asset-site-placement") + .expect("stable dataset id is preserved"); + assert_eq!(dataset.access_rights, AccessRights::Restricted); + assert_eq!(dataset.entities.len(), 4); + assert!(dataset + .entities + .get("asset-placement") + .expect("placement entity is projected") + .relationships + .iter() + .any(|relationship| relationship.name == "asset" && relationship.target == "asset-item")); +} + +#[test] +fn all_acceptance_fixtures_compile_manifest_projection_under_production() { + for domain in [ + "asset-site-placement", + "publicschema-household", + "farmer", + "disability", + "business", + ] { + let mut project = acceptance_project(domain); + assert!( + project.manifest_projection.is_some(), + "{domain} declares explicit projection metadata" + ); + project + .package + .get_or_insert_with(|| PackageIdentitySource { + environment: "local".to_owned(), + instance_id: format!("{domain}-instance"), + sequence: 1, + source_revision: "acceptance-fixture-source".to_owned(), + }); + let mut modules = Vec::new(); + for lock in &mut project.modules { + let module_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/registry-server/acceptance") + .join(domain) + .join("modules") + .join(&lock.id) + .join("module.yaml"); + let module = if module_path.is_file() { + let bytes = fs::read(module_path).expect("locked acceptance module is readable"); + parse_module_yaml(&bytes).expect("locked acceptance module parses") + } else { + let module = RegistryModule { + id: lock.id.clone(), + version: lock.version.clone(), + dependencies: Vec::new(), + entities: Vec::new(), + extend_entities: Vec::new(), + }; + lock.digest = Some(module_digest(&module)); + module + }; + modules.push(module); + } + + let compiled = compile_project(&project, &modules, CompileProfile::Production) + .unwrap_or_else(|failure| panic!("{domain} production compile failed: {failure:?}")); + let artifact = compiled + .artifacts() + .get("generated/manifest/registry-manifest.json") + .unwrap_or_else(|| panic!("{domain} Manifest projection is generated")); + let manifest: MetadataManifest = serde_json::from_slice(&artifact.bytes) + .unwrap_or_else(|error| panic!("{domain} Manifest projection parses: {error}")); + compile_manifest(&manifest) + .unwrap_or_else(|error| panic!("{domain} Manifest projection compiles: {error:?}")); + } +} + +#[test] +fn manifest_projection_filters_by_selected_profile_and_classification_ceiling() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"public-slice","version":"1","defaultLanguage":"en"}, + "manifestProjection":{ + "accessProfile":"operator", + "classificationCeiling":"public", + "catalog":{"baseUrl":"https://public-slice.example.test","title":"Public Slice","publisher":{"name":"Publisher"}}, + "dataset":{"title":"Public Slice Dataset","status":"active"} + }, + "entities":[ + {"id":"visible-target","route":"visible-targets","mutationMode":"create_only","classification":"public", + "fields":[{"id":"label","type":"string","maxLength":64,"classification":"public"}], + "accessProfiles":[{"id":"operator","principalClaim":"principal","operations":["get"],"readableFields":["label"]}]}, + {"id":"hidden-target","route":"hidden-targets","mutationMode":"create_only","classification":"restricted", + "fields":[{"id":"label","type":"string","maxLength":64,"classification":"restricted"}], + "accessProfiles":[{"id":"operator","principalClaim":"principal","operations":["get"],"readableFields":["label"]}]}, + {"id":"link","route":"links","mutationMode":"create_only","classification":"public", + "fields":[ + {"id":"name","type":"string","maxLength":64,"classification":"public"}, + {"id":"operator-note","type":"string","maxLength":64,"classification":"internal"}, + {"id":"visible-ref","type":"reference","target":"visible-target","classification":"public"}, + {"id":"hidden-ref","type":"reference","target":"hidden-target","classification":"public"} + ], + "accessProfiles":[{"id":"operator","principalClaim":"principal","operations":["get"],"readableFields":["name","operator-note","visible-ref","hidden-ref"]}]} + ] + }"#, + ) + .expect("project parses"); + let compiled = + compile_project(&project, &[], CompileProfile::Authoring).expect("project compiles"); + let artifact = compiled + .artifacts() + .get("generated/manifest/registry-manifest.json") + .expect("Manifest projection is generated"); + let manifest: MetadataManifest = + serde_json::from_slice(&artifact.bytes).expect("generated Manifest projection parses"); + let projected = compile_manifest(&manifest).expect("generated Manifest projection compiles"); + let dataset = projected + .dataset("public-slice") + .expect("dataset id is stable"); + + assert_eq!(dataset.access_rights, AccessRights::Restricted); + assert!(dataset.entities.contains_key("visible-target")); + assert!(dataset.entities.contains_key("link")); + assert!(!dataset.entities.contains_key("hidden-target")); + let link = dataset.entities.get("link").expect("link is visible"); + assert!(link.fields.contains_key("name")); + assert_eq!(link.fields["name"].field_type, FieldType::String); + assert!(!link.fields.contains_key("operator-note")); + assert!(link + .relationships + .iter() + .any(|relationship| relationship.name == "visible-ref" + && relationship.target == "visible-target")); + assert!(!link + .relationships + .iter() + .any(|relationship| relationship.name == "hidden-ref")); +} + +#[test] +fn manifest_projection_omits_physical_runtime_and_security_terms() { + let compiled = compile_project(&asset_project(), &[], CompileProfile::Authoring) + .expect("asset fixture compiles"); + let artifact = compiled + .artifacts() + .get("generated/manifest/registry-manifest.json") + .expect("Manifest projection is generated"); + let rendered = std::str::from_utf8(&artifact.bytes).expect("Manifest is UTF-8"); + + for forbidden in [ + "postgres", + "physical", + "runtime", + "secret", + "authorization", + "migration", + "revision", + "rls", + ] { + assert!(!rendered.to_ascii_lowercase().contains(forbidden)); + } +} + +#[test] +fn independent_additive_modules_are_order_independent() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"neutral-registry","version":"1","defaultLanguage":"en"}, + "modules":[ + {"id":"core","version":"1"}, + {"id":"alpha","version":"1"}, + {"id":"beta","version":"1"} + ], + "entities":[{ + "id":"object","route":"objects","mutationMode":"mutable", + "fields":[{"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal","operations":["create","get","list","patch"], + "readableFields":["code"],"writableFields":["code"] + }] + }] + }"#, + ) + .expect("project parses"); + let alpha = parse_module_json( + br#"{ + "id":"alpha","version":"1","dependencies":["core"], + "extendEntities":[{"entity":"object","fields":[ + {"id":"alpha-field","type":"boolean","classification":"internal"} + ]}] + }"#, + ) + .expect("alpha module parses"); + let beta = parse_module_json( + br#"{ + "id":"beta","version":"1","dependencies":["core"], + "extendEntities":[{"entity":"object","fields":[ + {"id":"beta-field","type":"int64","classification":"internal"} + ]}] + }"#, + ) + .expect("beta module parses"); + + let left = compile_project( + &project, + &[alpha.clone(), beta.clone()], + CompileProfile::Authoring, + ) + .expect("first order compiles"); + let right = compile_project(&project, &[beta, alpha], CompileProfile::Authoring) + .expect("reverse order compiles"); + assert_eq!(left, right); + assert_eq!(left.module_order(), ["core", "alpha", "beta"]); +} + +#[test] +fn project_access_profile_required_scopes_compile_into_each_grant() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"scope-bound-registry","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"record","route":"records","mutationMode":"mutable", + "fields":[{"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}] + }], + "accessProfiles":[{ + "id":"operator","principalClaim":"registry_principal", + "requiredScopes":["registry:record:operate"], + "grants":[{ + "entity":"record","actions":["get"],"readableFields":["code"] + }] + }] + }"#, + ) + .expect("scope-bound project parses"); + + let compiled = compile_project(&project, &[], CompileProfile::Authoring) + .expect("scope-bound project compiles"); + let profile = compiled.entities()["record"] + .access_profiles + .get("operator") + .expect("project profile is compiled onto the grant"); + assert_eq!( + profile.required_scopes, + BTreeSet::from(["registry:record:operate".to_owned()]) + ); +} + +#[test] +fn strict_parse_refuses_unknown_and_duplicate_members_without_echoing_values() { + let unknown = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"neutral","version":"1","defaultLanguage":"en","secretField":"do-not-echo"} + }"#, + ) + .expect_err("unknown member is refused"); + assert_eq!(unknown.diagnostics()[0].code, "source.shape.invalid"); + assert!(unknown.diagnostics()[0] + .path + .starts_with("project.registry")); + let rendered = serde_json::to_string(&unknown).expect("diagnostic serializes"); + assert!(!rendered.contains("do-not-echo")); + assert!(unknown.diagnostics()[0].path.ends_with("secretField")); + + let duplicate = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "kind":"Other", + "registry":{"id":"neutral","version":"1","defaultLanguage":"en"} + }"#, + ) + .expect_err("duplicate member is refused"); + assert_eq!(duplicate.diagnostics()[0].code, "source.json.invalid"); + assert_eq!(duplicate.diagnostics()[0].path, "project"); +} + +#[test] +fn deferred_query_features_are_strictly_unknown_key_rejected() { + for (key, member, canary) in [ + ( + "joins", + r#""joins":[{"source":"join-source-canary"}]"#, + "join-source-canary", + ), + ( + "transforms", + r#""transforms":[{"source":"transform-source-canary"}]"#, + "transform-source-canary", + ), + ( + "countSources", + r#""countSources":[{"source":"count-source-canary"}]"#, + "count-source-canary", + ), + ( + "namedQueries", + r#""namedQueries":[{"source":"named-query-canary"}]"#, + "named-query-canary", + ), + ( + "spatialPredicates", + r#""spatialPredicates":[{"source":"spatial-predicate-canary"}]"#, + "spatial-predicate-canary", + ), + ] { + let source = format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"closed-query-grammar","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"record","route":"records","mutationMode":"create_only", + {member} + }}] + }}"# + ); + let failure = parse_project_json(source.as_bytes()) + .expect_err("deferred query features remain outside the strict authoring grammar"); + assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); + assert!(failure.diagnostics()[0].path.ends_with(key)); + let rendered = format!( + "{failure:?}\n{failure}\n{}", + serde_json::to_string(&failure).expect("diagnostic serializes") + ); + assert!(!rendered.contains(canary)); + } +} + +#[test] +fn strict_yaml_parse_refuses_duplicate_members() { + let failure = parse_project_yaml( + br#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +kind: AnotherKind +registry: + id: neutral + version: "1" + defaultLanguage: en +"#, + ) + .expect_err("duplicate YAML member is refused"); + assert_eq!(failure.diagnostics()[0].code, "source.yaml.invalid"); +} + +#[test] +fn generic_decimal_crs84_point_and_structured_fields_compile_to_deterministic_ddl_and_schema() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"generic-scalars","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"reading","route":"readings","mutationMode":"mutable", + "fields":[ + {"id":"amount","type":"decimal","precision":6,"scale":2,"minimum":"-10.00","maximum":"9999.99","classification":"internal"}, + {"id":"location","type":"crs84-point","precision":4,"bbox":{"west":"100.0000","south":"10.0000","east":"110.0000","north":"20.0000"},"classification":"internal"}, + {"id":"payload","type":"structured","maxBytes":256,"classification":"internal","schema":{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "type":"object", + "additionalProperties":false, + "properties":{"batch":{"type":"string","maxLength":32}}, + "required":["batch"] + }} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","operations":["create","get","list","patch"], + "readableFields":["amount","location","payload"], + "writableFields":["amount","location","payload"] + }] + }] + }"#, + ) + .expect("generic scalar source parses"); + let first = + compile_project(&project, &[], CompileProfile::Authoring).expect("project compiles"); + let second = + compile_project(&project, &[], CompileProfile::Authoring).expect("project compiles twice"); + assert_eq!(first, second); + + let ddl = first.ddl().script(); + assert!(ddl.contains("numeric(6,2)")); + assert!(ddl.contains("jsonb_typeof")); + assert!(ddl.contains("octet_length")); + assert!(ddl.contains("^-?(0|[1-9]|[1-8][0-9]|90)(\\.[0-9]{1,4})?$")); + let lower = ddl.to_ascii_lowercase(); + assert!(!lower.contains("postgis")); + assert!(!lower.contains("geometry")); + assert!(!lower.contains("geography")); + + let schema = first + .artifacts() + .get("generated/schemas/reading.schema.json") + .expect("entity schema generated"); + let schema: Value = parse_json_strict(&schema.bytes).expect("schema is strict JSON"); + assert_eq!(schema["properties"]["amount"]["type"], "string"); + assert_eq!(schema["properties"]["amount"]["x-registry-decimalScale"], 2); + assert_eq!( + schema["properties"]["location"]["description"], + "CRS84 GeoJSON Point with coordinates in [longitude, latitude] order." + ); + assert_eq!( + schema["properties"]["payload"]["properties"]["batch"]["type"], + "string" + ); + assert_eq!(schema["properties"]["payload"]["x-registry-maxBytes"], 256); +} + +#[test] +fn scalar_field_sources_reject_incompatible_type_options_during_strict_parse() { + let failure = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"generic-scalars","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"reading","route":"readings","mutationMode":"mutable", + "fields":[{"id":"flag","type":"boolean","precision":2,"classification":"internal"}] + }] + }"#, + ) + .expect_err("type-incompatible option is refused during parse"); + assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); +} + +#[test] +fn scalar_grammar_is_exactly_the_typed_allowlist_and_rejects_json_or_reference_lists() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"scalar-allowlist","version":"1","defaultLanguage":"en"}, + "entities":[ + {"id":"target","route":"targets","mutationMode":"create_only"}, + { + "id":"record","route":"records","mutationMode":"create_only", + "fields":[ + {"id":"flag","type":"boolean","classification":"internal"}, + {"id":"code","type":"string","maxLength":32,"classification":"internal"}, + {"id":"notes","type":"text","maxLength":1024,"classification":"internal"}, + {"id":"count","type":"int64","classification":"internal"}, + {"id":"amount","type":"decimal","precision":6,"scale":2,"classification":"internal"}, + {"id":"day","type":"date","classification":"internal"}, + {"id":"observed-at","type":"timestamp","classification":"internal"}, + {"id":"external-id","type":"uuid","classification":"internal"}, + {"id":"status","type":"vocabulary-code","vocabulary":"status","classification":"internal"}, + {"id":"target","type":"reference","target":"target","classification":"internal"}, + {"id":"location","type":"crs84-point","precision":4,"classification":"internal"}, + {"id":"payload","type":"structured","maxBytes":256,"classification":"internal","schema":{"type":"object","additionalProperties":false}} + ] + } + ], + "vocabularies":[{"id":"status","values":["active","closed"]}] + }"#, + ) + .expect("every approved scalar form parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("every approved scalar form compiles"); + + let approved: Vec<_> = project.entities[1] + .fields + .iter() + .map(|field| match &field.field_type { + FieldTypeSource::Boolean => "boolean", + FieldTypeSource::String { .. } => "string", + FieldTypeSource::Text { .. } => "text", + FieldTypeSource::Int64 => "int64", + FieldTypeSource::Decimal { .. } => "decimal", + FieldTypeSource::Date => "date", + FieldTypeSource::Timestamp => "timestamp", + FieldTypeSource::Uuid => "uuid", + FieldTypeSource::VocabularyCode { .. } => "vocabulary-code", + FieldTypeSource::Reference { .. } => "reference", + FieldTypeSource::Crs84Point { .. } => "crs84-point", + FieldTypeSource::Structured { .. } => "structured", + }) + .collect(); + assert_eq!( + approved, + [ + "boolean", + "string", + "text", + "int64", + "decimal", + "date", + "timestamp", + "uuid", + "vocabulary-code", + "reference", + "crs84-point", + "structured", + ] + ); + + let refused = [ + ( + r#"{"id":"unvalidated-json-canary","type":"json","classification":"internal"}"#, + "unvalidated-json-canary", + ), + ( + r#"{"id":"reference-list-canary","type":"reference-list","target":"target","classification":"internal"}"#, + "reference-list-canary", + ), + ( + r#"{"id":"reference-target-list-canary","type":"reference","target":["target"],"classification":"internal"}"#, + "reference-target-list-canary", + ), + ]; + for (field, canary) in refused { + let source = format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"scalar-allowlist","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"record","route":"records","mutationMode":"create_only", + "fields":[{field}] + }}] + }}"# + ); + let failure = parse_project_json(source.as_bytes()) + .expect_err("unapproved scalar or reference-list forms fail strict parsing"); + assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); + let rendered = format!( + "{failure:?}\n{failure}\n{}", + serde_json::to_string(&failure).expect("diagnostic serializes") + ); + assert!(!rendered.contains(canary)); + } +} + +#[test] +fn generic_scalar_option_and_schema_negatives_fail_before_ddl_generation() { + let cases = [ + ( + r#"{"id":"amount","type":"decimal","precision":39,"scale":2,"classification":"internal"}"#, + "field.decimal.bounds_invalid", + ), + ( + r#"{"id":"amount","type":"decimal","precision":4,"scale":2,"minimum":"01.00","classification":"internal"}"#, + "field.decimal.bounds_invalid", + ), + ( + r#"{"id":"location","type":"crs84-point","precision":10,"classification":"internal"}"#, + "field.crs84_point.bounds_invalid", + ), + ( + r#"{"id":"location","type":"crs84-point","precision":4,"bbox":{"west":"110.0000","south":"10.0000","east":"100.0000","north":"20.0000"},"classification":"internal"}"#, + "field.crs84_point.bounds_invalid", + ), + ( + r#"{"id":"payload","type":"structured","maxBytes":0,"classification":"internal","schema":{"type":"object","additionalProperties":false}}"#, + "field.structured.schema_invalid", + ), + ( + r#"{"id":"payload","type":"structured","maxBytes":256,"classification":"internal","schema":{"type":"object","properties":{"code":{"type":"string"}}}}"#, + "field.structured.schema_invalid", + ), + ( + r#"{"id":"payload","type":"structured","maxBytes":256,"classification":"internal","schema":{}}"#, + "field.structured.schema_invalid", + ), + ( + r#"{"id":"payload","type":"structured","maxBytes":256,"classification":"internal","schema":{"$ref":"https://schema.example.invalid/payload"}}"#, + "field.structured.schema_invalid", + ), + ]; + + for (field, code) in cases { + let source = format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"generic-scalars","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"reading","route":"readings","mutationMode":"mutable", + "fields":[{field}], + "accessProfiles":[{{"id":"operator","default":true,"principalClaim":"principal","operations":["get"],"readableFields":["{}"]}}] + }}] + }}"#, + if field.contains("\"amount\"") { + "amount" + } else if field.contains("\"location\"") { + "location" + } else { + "payload" + } + ); + let project = parse_project_json(source.as_bytes()).expect("source shape parses"); + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("invalid generic scalar configuration fails compilation"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == code)); + } +} + +#[test] +fn crs84_point_and_structured_fields_cannot_be_row_boundaries_until_equality_is_defined() { + for field in [ + r#"{"id":"location","type":"crs84-point","precision":4,"classification":"internal"}"#, + r#"{"id":"payload","type":"structured","maxBytes":256,"classification":"internal","schema":{"type":"object","additionalProperties":false}}"#, + ] { + let field_id = if field.contains("\"location\"") { + "location" + } else { + "payload" + }; + let source = format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"generic-scalars","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"reading","route":"readings","mutationMode":"mutable", + "fields":[{field}], + "accessProfiles":[{{ + "id":"operator","default":true,"principalClaim":"principal","operations":["get"], + "readableFields":["{field_id}"], + "rowBoundaries":[{{"field":"{field_id}","claim":"claim","operator":"equals"}}] + }}] + }}] + }}"# + ); + let project = parse_project_json(source.as_bytes()).expect("source shape parses"); + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("unsupported row-boundary field type fails compilation"); + assert!(failure.diagnostics().iter().any(|diagnostic| { + diagnostic.code == "access_profile.row_boundary.type_unsupported" + })); + } +} + +#[test] +fn closed_constraint_grammar_compiles_typed_checks_and_refuses_expression_escape_hatches() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"constraint-matrix","version":"1","defaultLanguage":"en"}, + "entities":[ + {"id":"parent","route":"parents","mutationMode":"create_only"}, + { + "id":"record","route":"records","mutationMode":"create_only", + "fields":[ + {"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"revoked-at","type":"timestamp","classification":"internal"}, + {"id":"status","type":"vocabulary-code","vocabulary":"status","required":true,"classification":"internal"}, + {"id":"count","type":"int64","required":true,"classification":"internal"}, + {"id":"capacity","type":"int64","required":true,"classification":"internal"}, + {"id":"floor","type":"int64","required":true,"classification":"internal"}, + {"id":"ceiling","type":"int64","required":true,"classification":"internal"}, + {"id":"starts-on","type":"date","required":true,"classification":"internal"}, + {"id":"ends-on","type":"date","required":true,"classification":"internal"}, + {"id":"observed-at","type":"timestamp","required":true,"classification":"internal"}, + {"id":"expires-at","type":"timestamp","required":true,"classification":"internal"}, + {"id":"quantity","type":"int64","required":true,"classification":"internal"}, + {"id":"amount","type":"decimal","precision":8,"scale":2,"minimum":"-100.00","maximum":"100.00","classification":"internal"}, + {"id":"parent","type":"reference","target":"parent","onDelete":"restrict","required":true,"classification":"internal"}, + {"id":"alternate-parent","type":"reference","target":"parent","onDelete":"restrict","classification":"internal"}, + {"id":"scope","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"valid-from","type":"timestamp","validTimeRole":"valid_from","required":true,"classification":"internal"}, + {"id":"valid-to","type":"timestamp","validTimeRole":"valid_to","classification":"internal"} + ], + "temporal":{"startField":"valid-from","endField":"valid-to","scopeFields":["scope"]}, + "constraints":[ + {"id":"composite-key","kind":"unique","fields":["jurisdiction","code"]}, + {"id":"active-code","kind":"unique","fields":["code"],"when":[ + {"kind":"field_equals","field":"status","value":"active"}, + {"kind":"field_is_null","field":"revoked-at"}, + {"kind":"active_lifecycle"} + ]}, + {"id":"int-less","kind":"compare","left":"count","operator":"less_than","right":"capacity"}, + {"id":"date-less-or-equal","kind":"compare","left":"starts-on","operator":"less_than_or_equal","right":"ends-on"}, + {"id":"timestamp-greater","kind":"compare","left":"expires-at","operator":"greater_than","right":"observed-at"}, + {"id":"int-greater-or-equal","kind":"compare","left":"ceiling","operator":"greater_than_or_equal","right":"floor"}, + {"id":"quantity-range","kind":"int_range","field":"quantity","minimum":0,"maximum":100}, + {"id":"status-membership","kind":"vocabulary","field":"status","values":["active","paused"]}, + {"id":"scope-time","kind":"temporal-non-overlap","scopeFields":["scope"],"startField":"valid-from","endField":"valid-to"} + ] + } + ], + "vocabularies":[{"id":"status","values":["active","paused","closed"]}] + }"#, + ) + .expect("the closed typed constraint matrix parses"); + let compiled = compile_project(&project, &[], CompileProfile::Authoring) + .expect("the closed typed constraint matrix compiles"); + let record = &compiled.entities()["record"]; + + let kinds = record + .constraints + .values() + .map(|constraint| match constraint { + ConstraintSource::Unique { when: None, .. } => "composite-unique", + ConstraintSource::Unique { when: Some(_), .. } => "partial-unique", + ConstraintSource::Compare { .. } => "compare", + ConstraintSource::IntRange { .. } => "int-range", + ConstraintSource::Vocabulary { .. } => "vocabulary", + ConstraintSource::TemporalNonOverlap { .. } => "temporal-non-overlap", + }) + .collect::>(); + assert_eq!( + kinds, + [ + "compare", + "composite-unique", + "int-range", + "partial-unique", + "temporal-non-overlap", + "vocabulary", + ] + .into_iter() + .collect() + ); + assert!(matches!( + &record.constraints["composite-key"], + ConstraintSource::Unique { fields, when: None, .. } + if fields == &["jurisdiction".to_owned(), "code".to_owned()] + )); + assert!(matches!( + &record.constraints["active-code"], + ConstraintSource::Unique { fields, when: Some(when), .. } + if fields == &["code".to_owned()] && when.len() == 3 + )); + + let comparison_operators = record + .constraints + .values() + .filter_map(|constraint| match constraint { + ConstraintSource::Compare { operator, .. } => Some(match operator { + ComparisonOperator::LessThan => "less_than", + ComparisonOperator::LessThanOrEqual => "less_than_or_equal", + ComparisonOperator::GreaterThan => "greater_than", + ComparisonOperator::GreaterThanOrEqual => "greater_than_or_equal", + }), + _ => None, + }) + .collect::>(); + assert_eq!( + comparison_operators, + [ + "greater_than", + "greater_than_or_equal", + "less_than", + "less_than_or_equal", + ] + .into_iter() + .collect() + ); + + for (field, required) in [("parent", true), ("alternate-parent", false)] { + let compiled_field = &record.fields[field]; + assert_eq!(compiled_field.required, required); + assert!(matches!( + &compiled_field.field_type, + FieldTypeSource::Reference { + target, + on_delete: ReferenceDelete::Restrict, + } if target == "parent" + )); + let reference = compiled + .ddl() + .statements + .iter() + .find(|statement| statement.id == format!("entity.record.field.{field}.reference")) + .expect("each reference has compiler-owned DDL"); + assert_eq!(reference.kind, DdlStatementKind::Reference); + assert!(reference.sql.ends_with("ON DELETE RESTRICT")); + } + + let table = compiled + .ddl() + .statements + .iter() + .find(|statement| statement.id == "entity.record.table") + .expect("record table DDL exists"); + let optional_reference = &record.fields["alternate-parent"].physical_name; + assert!(table + .sql + .contains(&format!("\"{optional_reference}\" uuid"))); + assert!(!table + .sql + .contains(&format!("\"{optional_reference}\" uuid NOT NULL"))); + let amount = &record.fields["amount"].physical_name; + assert!(table.sql.contains(&format!( + "\"{amount}\" numeric(8,2) CHECK (\"{amount}\" >= -100.00 AND \"{amount}\" <= 100.00)" + ))); + + let statement = |id: &str| { + compiled + .ddl() + .statements + .iter() + .find(|statement| statement.id == format!("entity.record.constraint.{id}")) + .unwrap_or_else(|| panic!("constraint DDL exists for {id}")) + }; + assert!(statement("composite-key").sql.contains(" UNIQUE (")); + assert!(statement("active-code") + .sql + .starts_with("CREATE UNIQUE INDEX ")); + for id in [ + "int-less", + "date-less-or-equal", + "timestamp-greater", + "int-greater-or-equal", + "quantity-range", + "status-membership", + ] { + assert!(statement(id).sql.contains(" CHECK (")); + } + assert!(statement("quantity-range").sql.contains(" >= 0 AND ")); + assert!(statement("quantity-range").sql.contains(" <= 100")); + assert!(statement("status-membership") + .sql + .contains(" IN ('active', 'paused')")); + assert!(statement("scope-time").sql.contains("EXCLUDE USING gist")); + assert!(statement("scope-time").sql.contains("tstzrange")); + assert!(compiled.ddl().statements.iter().any(|statement| { + statement.id == "entity.record.constraint.temporal-order" + && statement.sql.contains(" IS NULL OR ") + && statement.sql.contains(" < ") + })); + + for (constraint, canary) in [ + ( + r#"{"kind":"check","expression":"sql-expression-canary"}"#, + "sql-expression-canary", + ), + ( + r#"{"kind":"compare","left":"left","operator":"less_than","right":"right","sql":"sql-fragment-canary"}"#, + "sql-fragment-canary", + ), + ( + r#"{"kind":"int_range","field":"left","minimum":0,"expression":"general-expression-canary"}"#, + "general-expression-canary", + ), + ] { + let source = format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"constraint-matrix","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"record","route":"records","mutationMode":"create_only", + "fields":[ + {{"id":"left","type":"int64","classification":"internal"}}, + {{"id":"right","type":"int64","classification":"internal"}} + ], + "constraints":[{constraint}] + }}] + }}"# + ); + let failure = parse_project_json(source.as_bytes()) + .expect_err("SQL and general expression forms fail strict parsing"); + assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); + let rendered = format!( + "{failure:?}\n{failure}\n{}", + serde_json::to_string(&failure).expect("diagnostic serializes") + ); + assert!(!rendered.contains(canary)); + } + + let cascade = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"constraint-matrix","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"record","route":"records","mutationMode":"create_only", + "fields":[{"id":"cascade-reference-canary","type":"reference","target":"record","onDelete":"cascade","classification":"internal"}] + }] + }"#, + ) + .expect_err("reference deletion behavior is closed to restrict"); + assert_eq!(cascade.diagnostics()[0].code, "source.shape.invalid"); + let rendered = format!( + "{cascade:?}\n{cascade}\n{}", + serde_json::to_string(&cascade).expect("diagnostic serializes") + ); + assert!(!rendered.contains("cascade-reference-canary")); +} + +#[test] +fn partial_unique_when_predicates_are_strictly_tagged_and_closed() { + let unknown_member = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"partial-unique","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"status","type":"vocabulary-code","vocabulary":"status","required":true,"classification":"internal"} + ], + "constraints":[{ + "kind":"unique","fields":["code"], + "when":[{"kind":"field_equals","field":"status","value":"active","sql":"record_lifecycle = 'active'"}] + }] + }], + "vocabularies":[{"id":"status","values":["active","closed"]}] + }"#, + ) + .expect_err("predicate members are closed"); + assert_eq!(unknown_member.diagnostics()[0].code, "source.shape.invalid"); + assert!(!serde_json::to_string(&unknown_member) + .expect("diagnostic serializes") + .contains("record_lifecycle")); + + let arbitrary_lifecycle = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"partial-unique","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[{"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}], + "constraints":[{ + "kind":"unique","fields":["code"], + "when":[{"kind":"active_lifecycle","value":"tombstoned"}] + }] + }] + }"#, + ) + .expect_err("lifecycle predicates have no caller-provided value"); + assert_eq!( + arbitrary_lifecycle.diagnostics()[0].code, + "source.shape.invalid" + ); +} + +#[test] +fn partial_unique_typed_literals_are_canonical_for_each_supported_field_type() { + let source = br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"partial-unique","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"flag","type":"boolean","classification":"internal"}, + {"id":"count","type":"int64","classification":"internal"}, + {"id":"amount","type":"decimal","precision":6,"scale":2,"minimum":"0.00","maximum":"9999.99","classification":"internal"}, + {"id":"day","type":"date","classification":"internal"}, + {"id":"seen-at","type":"timestamp","classification":"internal"}, + {"id":"owner","type":"uuid","classification":"internal"}, + {"id":"status","type":"vocabulary-code","vocabulary":"status","classification":"internal"} + ], + "constraints":[{ + "kind":"unique","fields":["code"], + "when":[ + {"kind":"field_equals","field":"flag","value":true}, + {"kind":"field_equals","field":"count","value":42}, + {"kind":"field_equals","field":"amount","value":"12.30"}, + {"kind":"field_equals","field":"day","value":"2026-08-29"}, + {"kind":"field_equals","field":"seen-at","value":"2026-08-29T10:20:30Z"}, + {"kind":"field_equals","field":"owner","value":"123e4567-e89b-12d3-a456-426614174000"}, + {"kind":"field_equals","field":"status","value":"active"} + ] + }] + }], + "vocabularies":[{"id":"status","values":["active","closed"]}] + }"#; + let compiled = compile_json(source).expect("canonical literals compile"); + let ddl = compiled.ddl().script(); + assert!(ddl.contains("'true'::boolean")); + assert!(ddl.contains("'42'::bigint")); + assert!(ddl.contains("'12.30'::numeric(6,2)")); + assert!(ddl.contains("'2026-08-29'::date")); + assert!(ddl.contains("'2026-08-29T10:20:30Z'::timestamptz")); + assert!(ddl.contains("'123e4567-e89b-12d3-a456-426614174000'::uuid")); +} + +#[test] +fn partial_unique_rejects_invalid_literals_and_json_predicate_fields() { + let cases = [ + ( + r#"{"id":"amount","type":"decimal","precision":6,"scale":2,"classification":"internal"}"#, + r#"{"kind":"field_equals","field":"amount","value":"1.2"}"#, + "constraint.unique.when.literal_invalid", + ), + ( + r#"{"id":"seen-at","type":"timestamp","classification":"internal"}"#, + r#"{"kind":"field_equals","field":"seen-at","value":"2026-08-29T10:20:30+00:00"}"#, + "constraint.unique.when.literal_invalid", + ), + ( + r#"{"id":"owner","type":"uuid","classification":"internal"}"#, + r#"{"kind":"field_equals","field":"owner","value":"123E4567-E89B-12D3-A456-426614174000"}"#, + "constraint.unique.when.literal_invalid", + ), + ( + r#"{"id":"shape","type":"crs84-point","precision":4,"classification":"internal"}"#, + r#"{"kind":"field_is_not_null","field":"shape"}"#, + "constraint.unique.when.field_unsupported", + ), + ( + r#"{"id":"payload","type":"structured","maxBytes":256,"classification":"internal","schema":{"type":"object","additionalProperties":false}}"#, + r#"{"kind":"field_equals","field":"payload","value":{}}"#, + "constraint.unique.when.field_unsupported", + ), + ]; + + for (field, predicate, code) in cases { + let source = format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"partial-unique","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {{"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}}, + {field} + ], + "constraints":[{{"kind":"unique","fields":["code"],"when":[{predicate}]}}] + }}] + }}"# + ); + let failure = compile_json(source.as_bytes()).expect_err("invalid partial predicate fails"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == code)); + } +} + +#[test] +fn partial_unique_rejects_empty_unknown_duplicate_and_contradictory_when_predicates() { + let cases = [ + ("[]", "constraint.unique.when.empty"), + ( + r#"[{"kind":"active_lifecycle"},{"kind":"active_lifecycle"}]"#, + "constraint.unique.when.duplicate", + ), + ( + r#"[{"kind":"field_is_null","field":"optional"},{"kind":"field_is_not_null","field":"optional"}]"#, + "constraint.unique.when.contradiction", + ), + ( + r#"[{"kind":"field_equals","field":"optional","value":"one"},{"kind":"field_equals","field":"optional","value":"two"}]"#, + "constraint.unique.when.contradiction", + ), + ( + r#"[{"kind":"field_is_not_null","field":"required"}]"#, + "constraint.unique.when.null_invalid", + ), + ( + r#"[{"kind":"field_equals","field":"missing","value":"one"}]"#, + "constraint.unique.when.field_unknown", + ), + ]; + + for (when, code) in cases { + let source = format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"partial-unique","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {{"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}}, + {{"id":"required","type":"string","maxLength":32,"required":true,"classification":"internal"}}, + {{"id":"optional","type":"string","maxLength":32,"classification":"internal"}} + ], + "constraints":[{{"kind":"unique","fields":["code"],"when":{when}}}] + }}] + }}"# + ); + let failure = compile_json(source.as_bytes()).expect_err("invalid when fails"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == code)); + } +} + +#[test] +fn partial_unique_ddl_is_quoted_and_deterministic_across_predicate_order() { + let left = br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"partial-unique","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"ended-on","type":"date","classification":"internal"}, + {"id":"marker","type":"string","maxLength":96,"classification":"internal"} + ], + "constraints":[{ + "kind":"unique","fields":["code"], + "when":[ + {"kind":"active_lifecycle"}, + {"kind":"field_equals","field":"marker","value":"O'Hare'); DROP TABLE registry_data.x; --"}, + {"kind":"field_is_null","field":"ended-on"} + ] + }] + }] + }"#; + let right = br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"partial-unique","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"ended-on","type":"date","classification":"internal"}, + {"id":"marker","type":"string","maxLength":96,"classification":"internal"} + ], + "constraints":[{ + "kind":"unique","fields":["code"], + "when":[ + {"kind":"field_is_null","field":"ended-on"}, + {"kind":"field_equals","field":"marker","value":"O'Hare'); DROP TABLE registry_data.x; --"}, + {"kind":"active_lifecycle"} + ] + }] + }] + }"#; + + let left = compile_json(left).expect("left order compiles"); + let right = compile_json(right).expect("right order compiles"); + assert_eq!(left, right); + let statement = left + .ddl() + .statements + .iter() + .find(|statement| statement.kind == DdlStatementKind::Index) + .expect("partial unique renders as an index"); + assert!(statement.sql.starts_with("CREATE UNIQUE INDEX ")); + assert!(statement.sql.contains(" WHERE ")); + assert!(statement + .sql + .contains("'O''Hare''); DROP TABLE registry_data.x; --'")); + assert!(statement.sql.contains(" IS NULL")); + assert!(statement.sql.ends_with("record_lifecycle = 'active'")); +} + +#[test] +fn equivalent_partial_unique_extension_constraints_merge_deterministically() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject", + "registry":{"id":"partial-unique","version":"1","defaultLanguage":"en"}, + "modules":[{"id":"a","version":"1"},{"id":"b","version":"1"}], + "entities":[{"id":"entry","route":"entries","mutationMode":"mutable","fields":[ + {"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"ended-on","type":"date","classification":"internal"} + ]}] + }"#, + ) + .expect("project parses"); + let a = parse_module_json( + br#"{"id":"a","version":"1","extendEntities":[{"entity":"entry","constraints":[{ + "kind":"unique","fields":["code"],"when":[{"kind":"active_lifecycle"},{"kind":"field_is_null","field":"ended-on"}] + }]}]}"#, + ) + .expect("module parses"); + let b = parse_module_json( + br#"{"id":"b","version":"1","extendEntities":[{"entity":"entry","constraints":[{ + "kind":"unique","fields":["code"],"when":[{"kind":"field_is_null","field":"ended-on"},{"kind":"active_lifecycle"}] + }]}]}"#, + ) + .expect("module parses"); + + for modules in [vec![a.clone(), b.clone()], vec![b, a]] { + let failure = compile_project(&project, &modules, CompileProfile::Authoring) + .expect_err("equivalent partial unique extensions are duplicates"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "extension.constraint.duplicate")); + } +} + +#[test] +fn anonymous_profiles_cannot_inherit_partial_unique_processing_over_non_public_fields() { + let source = br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"partial-unique","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"mutable","classification":"public", + "fields":[ + {"id":"code","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"protected-marker","type":"string","maxLength":32,"classification":"restricted"} + ], + "constraints":[{ + "kind":"unique","fields":["code"], + "when":[{"kind":"field_is_not_null","field":"protected-marker"}] + }], + "accessProfiles":[{ + "id":"public-reader","anonymous":true,"default":true, + "operations":["get"],"readableFields":["code"] + }] + }] + }"#; + + let failure = compile_json(source) + .expect_err("anonymous profile cannot inherit hidden non-public predicate processing"); + assert!(failure.diagnostics().iter().any(|diagnostic| { + diagnostic.code == "access_profile.public.processing_non_public" + && diagnostic.path == "entities[].constraints[]" + })); +} + +#[test] +fn anonymous_public_surface_rejects_every_non_public_constraint_field() { + let source = br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"constraint-processing","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"record","route":"records","mutationMode":"mutable","classification":"public", + "fields":[ + {"id":"label","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"unique-field","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"partial-field","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"predicate-field","type":"string","maxLength":32,"classification":"public"}, + {"id":"compare-left","type":"int64","required":true,"classification":"public"}, + {"id":"compare-right","type":"int64","required":true,"classification":"public"}, + {"id":"range-field","type":"int64","classification":"public"}, + {"id":"vocabulary-field","type":"vocabulary-code","vocabulary":"status","classification":"public"}, + {"id":"temporal-start","type":"date","required":true,"classification":"public"}, + {"id":"temporal-end","type":"date","classification":"public"}, + {"id":"temporal-scope","type":"string","maxLength":32,"required":true,"classification":"public"} + ], + "temporal":{"startField":"temporal-start","endField":"temporal-end","scopeFields":["temporal-scope"]}, + "constraints":[ + {"kind":"unique","fields":["unique-field"]}, + {"kind":"unique","fields":["partial-field"],"when":[{"kind":"field_is_not_null","field":"predicate-field"}]}, + {"kind":"compare","left":"compare-left","operator":"less_than","right":"compare-right"}, + {"kind":"int_range","field":"range-field","minimum":0,"maximum":10}, + {"kind":"vocabulary","field":"vocabulary-field","values":["active"]}, + {"kind":"temporal-non-overlap","scopeFields":["temporal-scope"],"startField":"temporal-start","endField":"temporal-end"} + ], + "accessProfiles":[{ + "id":"public-reader","anonymous":true,"default":true, + "operations":["get"],"readableFields":["label"] + }] + }], + "vocabularies":[{"id":"status","values":["active","inactive"]}] + }"#; + let base = parse_project_json(source).expect("closed constraint processing fixture parses"); + compile_project(&base, &[], CompileProfile::Authoring) + .expect("an anonymous profile may process public constraint fields"); + + let cases = [ + ("full unique tuple", "unique-field"), + ("partial unique tuple", "partial-field"), + ("partial unique predicate", "predicate-field"), + ("compare left operand", "compare-left"), + ("compare right operand", "compare-right"), + ("integer range", "range-field"), + ("vocabulary", "vocabulary-field"), + ("temporal start", "temporal-start"), + ("temporal end", "temporal-end"), + ("temporal scope", "temporal-scope"), + ]; + for (case, field_id) in cases { + let mut project = base.clone(); + project.entities[0] + .fields + .iter_mut() + .find(|field| field.id == field_id) + .expect("constraint field exists") + .classification = Classification::Restricted; + + let failure = compile_project(&project, &[], CompileProfile::Authoring).expect_err( + "the anonymous public surface cannot process a non-public constraint field", + ); + let diagnostics = failure + .diagnostics() + .iter() + .filter(|diagnostic| { + diagnostic.code == "access_profile.public.processing_non_public" + && diagnostic.path == "entities[].constraints[]" + }) + .collect::>(); + assert_eq!(diagnostics.len(), 1, "missing exact negative for {case}"); + assert_eq!( + diagnostics[0].message, + "an anonymous profile is a public surface and may process only public constraint fields" + ); + assert!(!serde_json::to_string(diagnostics[0]) + .expect("diagnostic serializes") + .contains(field_id)); + } + + let mut authenticated = base; + let profile = &mut authenticated.entities[0].access_profiles[0]; + profile.anonymous = false; + profile.principal_claim = Some("principal".to_owned()); + for (_, field_id) in cases { + authenticated.entities[0] + .fields + .iter_mut() + .find(|field| field.id == field_id) + .expect("constraint field exists") + .classification = Classification::Restricted; + } + compile_project(&authenticated, &[], CompileProfile::Authoring) + .expect("authenticated entities may process governed non-public constraint fields"); +} + +#[test] +fn compiled_partial_unique_constraint_keeps_closed_predicates_in_the_model() { + let source = br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"partial-unique","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"mutable","classification":"public", + "fields":[ + {"id":"code","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"status","type":"vocabulary-code","vocabulary":"status","classification":"public"} + ], + "constraints":[{ + "kind":"unique","fields":["code"], + "when":[{"kind":"active_lifecycle"},{"kind":"field_equals","field":"status","value":"active"}] + }], + "accessProfiles":[{ + "id":"public-reader","anonymous":true,"default":true, + "operations":["get"],"readableFields":["code","status"],"filterableFields":["status"] + }] + }], + "vocabularies":[{"id":"status","values":["active","closed"]}] + }"#; + + let compiled = compile_json(source).expect("public partial unique compiles"); + let constraint = compiled + .entities() + .get("entry") + .expect("entity compiled") + .constraints + .values() + .find(|constraint| matches!(constraint, ConstraintSource::Unique { .. })) + .expect("unique constraint compiled"); + assert!(matches!( + constraint, + ConstraintSource::Unique { + when: Some(when), + .. + } if when == &[ + UniqueWhenPredicate::FieldEquals { + field: "status".to_owned(), + value: Value::String("active".to_owned()), + }, + UniqueWhenPredicate::ActiveLifecycle {}, + ] + )); +} + +#[test] +fn create_only_operation_conflict_fails_before_artifact_generation() { + let mut project = asset_project(); + let profile = project + .access_profiles + .first_mut() + .expect("fixture has an access profile"); + let grant = profile + .grants + .iter_mut() + .find(|grant| grant.entity == "inspection-event") + .expect("fixture grants the create-only entity"); + grant.actions.insert(Operation::Patch); + + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("create-only patch is refused"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "access_profile.operation.unavailable")); +} + +#[test] +fn generated_openapi_routes_and_physical_names_share_one_compiled_inventory() { + let compiled = compile_project(&asset_project(), &[], CompileProfile::Authoring) + .expect("asset fixture compiles"); + let openapi = compiled + .artifacts() + .get("generated/openapi.json") + .expect("OpenAPI is generated"); + let value = parse_json_strict(&openapi.bytes).expect("OpenAPI is strict JSON"); + assert_eq!( + canonicalize_json(&value).expect("OpenAPI canonicalizes"), + openapi.bytes + ); + let generated_operation_count: usize = value["paths"] + .as_object() + .expect("paths is an object") + .values() + .map(|entry| entry.as_object().expect("path item is an object").len()) + .sum(); + assert_eq!(generated_operation_count, compiled.routes().routes.len()); + + for names in compiled.physical_names().entities.values() { + let all = std::iter::once(&names.table) + .chain(names.fields.values()) + .chain(names.constraints.values()) + .chain(names.indexes.values()) + .chain(names.policies.values()); + for name in all { + assert!(name.len() <= 63); + assert!(name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')); + } + } +} + +#[test] +fn compiled_metadata_inventory_is_bijective_canonical_schema_bound_and_deterministic() { + let first = compile_project(&asset_project(), &[], CompileProfile::Authoring) + .expect("asset fixture compiles"); + let second = compile_project(&asset_project(), &[], CompileProfile::Authoring) + .expect("asset fixture compiles twice"); + + assert_eq!(first.metadata(), second.metadata()); + let expected_route_profiles = first + .routes() + .routes + .iter() + .flat_map(|route| { + route + .access_profiles + .iter() + .map(move |profile| (route.id.clone(), profile.clone())) + }) + .collect::>(); + let actual_route_profiles = first + .metadata() + .entities + .iter() + .flat_map(|entity| { + entity + .entries + .iter() + .map(|entry| (entry.route_id.clone(), entry.access_profile.clone())) + }) + .collect::>(); + assert_eq!( + actual_route_profiles, expected_route_profiles, + "metadata entries must be in bijection with compiled route/profile pairs" + ); + + for metadata_entity in &first.metadata().entities { + let entity = first + .entities() + .get(&metadata_entity.id) + .expect("metadata entity refers to a compiled entity"); + assert_eq!(metadata_entity.route, entity.route); + assert_eq!( + metadata_entity.schema_path, + format!("/v1/schemas/{}", metadata_entity.id) + ); + assert!(first + .artifacts() + .get(&format!( + "generated/schemas/{}.schema.json", + metadata_entity.id + )) + .is_some()); + for entry in &metadata_entity.entries { + let route = first + .routes() + .routes + .iter() + .find(|route| route.id == entry.route_id) + .expect("metadata route id refers to a compiled route"); + assert_eq!(route.entity_id, metadata_entity.id); + assert_eq!(route.operation, entry.operation); + assert!(route.access_profiles.contains(&entry.access_profile)); + let profile = entity + .access_profiles + .get(&entry.access_profile) + .expect("metadata access profile refers to a compiled profile"); + assert!(entry.readable_fields.is_subset(&profile.readable_fields)); + if profile.anonymous { + assert!(entry.readable_fields.iter().all(|field| { + entity + .fields + .get(field) + .is_some_and(|field| field.classification == Classification::Public) + })); + } + } + } + + for path in [ + "compiled/metadata-inventory.json", + REGISTRY_METADATA_ARTIFACT_PATH, + ] { + let first_artifact = first + .artifacts() + .get(path) + .expect("metadata artifact exists"); + let second_artifact = second + .artifacts() + .get(path) + .expect("metadata artifact exists on recompilation"); + assert_eq!(first_artifact.bytes, second_artifact.bytes); + let value = parse_json_strict(&first_artifact.bytes).expect("metadata is strict JSON"); + assert_eq!( + canonicalize_json(&value).expect("metadata canonicalizes"), + first_artifact.bytes + ); + assert!(value.get("revision").is_none()); + let parsed: CompiledMetadataInventory = + serde_json::from_value(value).expect("metadata artifact has the typed schema"); + assert_eq!(&parsed, first.metadata()); + } +} + +#[test] +fn compiler_produces_both_revision_routes_when_explicitly_configured() { + let compiled = compile_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"revision-surface","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"create_only","classification":"internal", + "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}], + "accessProfiles":[{ + "id":"auditor","default":true,"principalClaim":"principal", + "operations":["revisions"],"revisionAccess":true,"readableFields":["code"] + }] + }] + }"#, + ) + .expect("explicit authenticated revision access compiles"); + let routes = compiled + .routes() + .routes + .iter() + .filter(|route| route.operation == Operation::Revisions) + .collect::>(); + assert_eq!(routes.len(), 2); + let list = routes + .iter() + .find(|route| route.revision_kind == Some(CompiledRevisionKind::List)) + .expect("revision list route exists"); + assert_eq!(list.id, "records.entry.revisions.list"); + assert_eq!(list.path, "/v1/records/entries/{record_id}/revisions"); + assert_eq!(list.maximum_records, Some(MAX_REVISION_HISTORY_RECORDS)); + let detail = routes + .iter() + .find(|route| route.revision_kind == Some(CompiledRevisionKind::Detail)) + .expect("revision detail route exists"); + assert_eq!(detail.id, "records.entry.revisions.detail"); + assert_eq!( + detail.path, + "/v1/records/entries/{record_id}/revisions/{revision}" + ); + assert_eq!(detail.maximum_records, Some(1)); + + let openapi = compiled + .artifacts() + .get("generated/openapi.json") + .expect("OpenAPI is generated"); + let openapi = parse_json_strict(&openapi.bytes).expect("OpenAPI is strict JSON"); + assert_eq!( + openapi["paths"]["/v1/records/entries/{record_id}/revisions"]["get"]["operationId"], + "records.entry.revisions.list" + ); + assert_eq!( + openapi["paths"]["/v1/records/entries/{record_id}/revisions/{revision}"]["get"] + ["operationId"], + "records.entry.revisions.detail" + ); + assert_eq!( + query_parameter_names( + &openapi["paths"]["/v1/records/entries/{record_id}/revisions"]["get"]["parameters"] + ), + ["accessProfile", "record_id"] + ); +} + +#[test] +fn compiler_omits_revision_routes_when_not_configured_or_revision_access_is_false() { + for (operations, revision_access, anonymous) in [ + (r#"["get"]"#, "true", "false"), + (r#"["revisions"]"#, "false", "false"), + (r#"["revisions"]"#, "true", "true"), + ] { + let source = format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"revision-surface","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"entry","route":"entries","mutationMode":"create_only","classification":"public", + "fields":[{{"id":"code","type":"string","maxLength":32,"classification":"public"}}], + "accessProfiles":[{{ + "id":"reader","default":true,"anonymous":{anonymous},"principalClaim":"principal", + "operations":{operations},"revisionAccess":{revision_access},"readableFields":["code"] + }}] + }}] + }}"# + ); + let compiled = compile_json(source.as_bytes()).expect("fixture compiles"); + assert!(compiled + .routes() + .routes + .iter() + .all(|route| route.operation != Operation::Revisions)); + let openapi = compiled + .artifacts() + .get("generated/openapi.json") + .expect("OpenAPI is generated"); + let openapi = parse_json_strict(&openapi.bytes).expect("OpenAPI is strict JSON"); + assert!(openapi["paths"] + .as_object() + .expect("paths object") + .keys() + .all(|path| !path.contains("revisions"))); + } +} + +#[test] +fn public_profile_cannot_process_an_internal_field() { + let mut project = asset_project(); + project.access_profiles[0].default = true; + let entity = project + .entities + .iter_mut() + .find(|entity| entity.id == "asset-item") + .expect("asset entity exists"); + entity.access_profiles.push(AccessProfileSource { + id: "public-reader".to_owned(), + default: false, + anonymous: true, + principal_claim: None, + required_scopes: Default::default(), + required_purposes: Default::default(), + operations: [Operation::Get].into_iter().collect(), + readable_fields: ["asset-code".to_owned()].into_iter().collect(), + writable_fields: Default::default(), + filterable_fields: Default::default(), + sortable_fields: Default::default(), + row_boundaries: vec![RowBoundarySource { + field: "asset-code".to_owned(), + claim: "asset_code".to_owned(), + operator: BoundaryOperator::Equals, + }], + allow_data_export: false, + revision_access: false, + }); + + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("anonymous processing of internal data is refused"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| { diagnostic.code == "access_profile.public.processing_non_public" })); +} + +#[test] +fn anonymous_public_profile_cannot_filter_a_non_public_field() { + let failure = compile_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"public-filter","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"create_only","classification":"public", + "fields":[ + {"id":"label","type":"string","maxLength":32,"classification":"public"}, + {"id":"hidden-filter-canary","type":"string","maxLength":32,"classification":"restricted"} + ], + "accessProfiles":[{ + "id":"public-reader","anonymous":true,"default":true, + "operations":["list"],"readableFields":["label"], + "filterableFields":["hidden-filter-canary"] + }] + }] + }"#, + ) + .expect_err("an anonymous filter cannot process a non-public field"); + assert!(failure.diagnostics().iter().any(|diagnostic| { + diagnostic.code == "access_profile.public.processing_non_public" + && diagnostic.path == "entities[].accessProfiles[]" + })); + let rendered = serde_json::to_string(&failure).expect("diagnostics serialize"); + assert!(!rendered.contains("hidden-filter-canary")); +} + +#[test] +fn unresolved_reference_is_value_free_and_fails_before_ddl() { + let mut project = asset_project(); + let field = project + .entities + .iter_mut() + .find(|entity| entity.id == "inspection-event") + .and_then(|entity| entity.fields.iter_mut().find(|field| field.id == "asset")) + .expect("reference field exists"); + if let registry_server::contract::FieldTypeSource::Reference { target, .. } = + &mut field.field_type + { + *target = "classified-target-name".to_owned(); + } else { + panic!("fixture field is a reference"); + } + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("unresolved reference fails compilation"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "field.reference.target_unknown")); + assert!(!serde_json::to_string(&failure) + .expect("diagnostics serialize") + .contains("classified-target-name")); +} + +#[test] +fn additive_module_conflicts_fail_instead_of_using_input_precedence() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject", + "registry":{"id":"neutral","version":"1","defaultLanguage":"en"}, + "modules":[{"id":"core","version":"1"},{"id":"a","version":"1"},{"id":"b","version":"1"}], + "entities":[{"id":"object","route":"objects","mutationMode":"mutable","fields":[ + {"id":"code","type":"string","maxLength":8,"classification":"internal"} + ],"accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","operations":["get"],"readableFields":["code"]}]}] + }"#, + ) + .expect("project parses"); + let a = parse_module_json( + br#"{"id":"a","version":"1","dependencies":["core"],"extendEntities":[{"entity":"object","fields":[{"id":"collision","type":"boolean","classification":"internal"}]}]}"#, + ) + .expect("module parses"); + let b = parse_module_json( + br#"{"id":"b","version":"1","dependencies":["core"],"extendEntities":[{"entity":"object","fields":[{"id":"collision","type":"int64","classification":"internal"}]}]}"#, + ) + .expect("module parses"); + for modules in [vec![a.clone(), b.clone()], vec![b.clone(), a.clone()]] { + let failure = compile_project(&project, &modules, CompileProfile::Authoring) + .expect_err("conflicting additive extensions fail"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "extension.field.duplicate")); + } +} + +#[test] +fn operation_ids_preserve_distinct_valid_entity_ids_without_collisions() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject", + "registry":{"id":"neutral","version":"1","defaultLanguage":"en"}, + "entities":[ + {"id":"case-file","route":"case-files","mutationMode":"create_only","fields":[ + {"id":"code","type":"string","maxLength":8,"classification":"internal"} + ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]}, + {"id":"case_file","route":"case_file_records","mutationMode":"create_only","fields":[ + {"id":"code","type":"string","maxLength":8,"classification":"internal"} + ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]} + ] + }"#, + ) + .expect("project parses"); + let compiled = compile_project(&project, &[], CompileProfile::Authoring) + .expect("both valid entity identifiers compile"); + let operation_ids: Vec<_> = compiled + .routes() + .routes + .iter() + .map(|route| route.id.as_str()) + .collect(); + let unique: std::collections::BTreeSet<_> = operation_ids.iter().copied().collect(); + + assert_eq!(unique.len(), operation_ids.len()); + assert!(unique.contains("records.case-file.get")); + assert!(unique.contains("records.case_file.get")); +} + +#[test] +fn temporal_non_overlap_refuses_a_nullable_scope_field() { + let mut project = asset_project(); + let scope = project + .entities + .iter_mut() + .find(|entity| entity.id == "asset-placement") + .and_then(|entity| entity.fields.iter_mut().find(|field| field.id == "asset")) + .expect("temporal scope field exists"); + scope.required = false; + + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("nullable temporal scope is refused before DDL generation"); + let diagnostic = failure + .diagnostics() + .iter() + .find(|diagnostic| diagnostic.code == "constraint.temporal.scope_nullable") + .expect("nullable temporal scope has a stable diagnostic"); + assert_eq!(diagnostic.path, "entities[].constraints[].scopeFields"); + assert!(!serde_json::to_string(diagnostic) + .expect("diagnostic serializes") + .contains("asset-placement")); +} + +#[test] +fn temporal_non_overlap_refuses_structured_and_crs84_point_scope_fields() { + let unsupported = [ + FieldTypeSource::Structured { + max_bytes: 128, + schema: json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "scope-canary": {"type": "string"} + } + }), + }, + FieldTypeSource::Crs84Point { + precision: 4, + bbox: None, + }, + ]; + + for field_type in unsupported { + let mut project = asset_project(); + let scope = project + .entities + .iter_mut() + .find(|entity| entity.id == "asset-placement") + .and_then(|entity| entity.fields.iter_mut().find(|field| field.id == "asset")) + .expect("temporal scope field exists"); + scope.field_type = field_type; + + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("jsonb temporal scopes are refused before DDL generation"); + let diagnostics = failure + .diagnostics() + .iter() + .filter(|diagnostic| diagnostic.code == "constraint.temporal.scope_type_unsupported") + .collect::>(); + assert_eq!(diagnostics.len(), 1); + let diagnostic = diagnostics[0]; + assert_eq!(diagnostic.path, "entities[].constraints[].scopeFields"); + assert_eq!( + diagnostic.message, + "a temporal non-overlap scope field must use a supported scalar type" + ); + let serialized = serde_json::to_string(diagnostic).expect("diagnostic serializes"); + for value in ["asset-placement", "asset-item", "scope-canary"] { + assert!(!serialized.contains(value)); + } + } +} + +#[test] +fn temporal_non_overlap_accepts_every_btree_gist_equality_scalar_scope_type() { + let supported = [ + FieldTypeSource::Boolean, + FieldTypeSource::String { + min_length: 0, + max_length: 32, + }, + FieldTypeSource::Text { max_length: 32 }, + FieldTypeSource::Int64, + FieldTypeSource::Decimal { + precision: 8, + scale: 2, + minimum: None, + maximum: None, + }, + FieldTypeSource::Date, + FieldTypeSource::Timestamp, + FieldTypeSource::Uuid, + FieldTypeSource::VocabularyCode { + vocabulary: "asset-classification".to_owned(), + values: Vec::new(), + }, + FieldTypeSource::Reference { + target: "asset-item".to_owned(), + on_delete: ReferenceDelete::Restrict, + }, + ]; + + for field_type in supported { + let mut project = asset_project(); + let scope = project + .entities + .iter_mut() + .find(|entity| entity.id == "asset-placement") + .and_then(|entity| entity.fields.iter_mut().find(|field| field.id == "asset")) + .expect("temporal scope field exists"); + scope.field_type = field_type; + + let compiled = compile_project(&project, &[], CompileProfile::Authoring) + .expect("every btree_gist equality scalar compiles as a temporal scope"); + assert!(compiled.ddl().script().contains("EXCLUDE USING gist")); + } +} + +#[test] +fn compiled_query_inventory_is_profile_scoped_bounded_and_temporal() { + let mut project = asset_project(); + let grant = project + .access_profiles + .first_mut() + .expect("fixture has an access profile") + .grants + .iter_mut() + .find(|grant| grant.entity == "asset-placement") + .expect("fixture grants placement access"); + grant.filterable_fields = ["asset".to_owned(), "valid-from".to_owned()] + .into_iter() + .collect(); + grant.sortable_fields = ["valid-from".to_owned()].into_iter().collect(); + + let compiled = compile_project(&project, &[], CompileProfile::Authoring) + .expect("temporal list fixture compiles"); + let temporal = compiled + .entities() + .get("asset-placement") + .expect("placement entity compiled") + .temporal + .as_ref() + .expect("temporal declaration is preserved in the compiled model"); + assert_eq!(temporal.start_field, "valid-from"); + assert_eq!(temporal.end_field, "valid-to"); + + let operations = &compiled.queries().operations; + let route = compiled + .routes() + .routes + .iter() + .find(|route| route.id == "records.asset-placement.list") + .expect("base list route is compiled"); + assert_eq!(route.path, "/v1/records/placements"); + assert_eq!(route.query_kind, Some(CompiledQueryKind::List)); + let current_route = compiled + .routes() + .routes + .iter() + .find(|route| route.id == "records.asset-placement.current") + .expect("current temporal route is compiled"); + assert_eq!(current_route.path, "/v1/records/placements:current"); + assert_eq!(current_route.query_kind, Some(CompiledQueryKind::Current)); + let as_of_route = compiled + .routes() + .routes + .iter() + .find(|route| route.id == "records.asset-placement.as-of") + .expect("as-of temporal route is compiled"); + assert_eq!(as_of_route.path, "/v1/records/placements:as-of"); + assert_eq!(as_of_route.query_kind, Some(CompiledQueryKind::AsOf)); + assert!(compiled.routes().routes.iter().all(|route| { + !matches!( + route.entity_id.as_str(), + "asset-item" | "asset-site" | "inspection-event" + ) || route.query_kind != Some(CompiledQueryKind::Current) + && route.query_kind != Some(CompiledQueryKind::AsOf) + })); + + let base = operations + .iter() + .find(|operation| operation.id == "records.asset-placement.asset-operator.list") + .expect("base list query is compiled"); + assert_eq!(base.kind, CompiledQueryKind::List); + assert_eq!(base.route_id, "records.asset-placement.list"); + assert_eq!(base.profile_id, "asset-operator"); + assert_eq!(base.max_page_size, 100); + assert_eq!(base.stable_tie_breaker, "record_id"); + assert_eq!( + base.projection_fields, + ["asset", "site", "valid-from", "valid-to"] + ); + assert!(base.temporal.is_none()); + assert_eq!(base.sort_fields.len(), 1); + assert_eq!(base.sort_fields[0].field, "valid-from"); + assert_eq!( + base.sort_fields[0].directions, + [CompiledQuerySortDirection::Asc] + ); + let valid_from_filter = base + .filter_fields + .iter() + .find(|field| field.field == "valid-from") + .expect("configured date filter is present"); + assert!(valid_from_filter + .operators + .contains(&CompiledQueryFilterOperator::Range)); + let asset_filter = base + .filter_fields + .iter() + .find(|field| field.field == "asset") + .expect("configured reference filter is present"); + assert!(asset_filter + .operators + .contains(&CompiledQueryFilterOperator::Equals)); + assert!(!asset_filter + .operators + .contains(&CompiledQueryFilterOperator::Prefix)); + + let current = operations + .iter() + .find(|operation| operation.id == "records.asset-placement.asset-operator.current") + .expect("current temporal query is compiled"); + let as_of = operations + .iter() + .find(|operation| operation.id == "records.asset-placement.asset-operator.as-of") + .expect("as-of temporal query is compiled"); + assert_eq!(current.kind, CompiledQueryKind::Current); + assert_eq!(as_of.kind, CompiledQueryKind::AsOf); + assert_eq!(current.route_id, "records.asset-placement.current"); + assert_eq!(as_of.route_id, "records.asset-placement.as-of"); + for operation in [current, as_of] { + let binding = operation + .temporal + .as_ref() + .expect("temporal query carries a fixed temporal binding"); + assert_eq!(binding.start_field, "valid-from"); + assert_eq!(binding.end_field, "valid-to"); + assert_eq!(binding.scope_fields, ["asset"]); + assert_eq!( + binding.semantics, + CompiledQueryTemporalSemantics::StartInclusiveEndExclusive + ); + } + + assert!(compiled + .artifacts() + .get("compiled/query-inventory.json") + .is_some()); + let effective = compiled + .artifacts() + .get("compiled/effective-model.json") + .expect("effective model generated"); + let value = parse_json_strict(&effective.bytes).expect("effective model is strict JSON"); + assert_eq!( + value["queryInventory"]["operations"] + .as_array() + .expect("query operations are rendered") + .len(), + compiled.queries().operations.len() + ); + let openapi = compiled + .artifacts() + .get("generated/openapi.json") + .expect("OpenAPI is generated"); + let openapi = parse_json_strict(&openapi.bytes).expect("OpenAPI is strict JSON"); + assert_eq!( + openapi["paths"]["/v1/records/placements"]["get"]["x-registry-queryKind"], + "list" + ); + assert_eq!( + openapi["paths"]["/v1/records/placements:current"]["get"]["x-registry-queryKind"], + "current" + ); + assert_eq!( + openapi["paths"]["/v1/records/placements:as-of"]["get"]["x-registry-queryKind"], + "as_of" + ); + let list_parameter_names = + query_parameter_names(&openapi["paths"]["/v1/records/placements"]["get"]["parameters"]); + assert_eq!( + list_parameter_names, + [ + "accessProfile", + "cursor", + "fields", + "filter", + "pageSize", + "sort" + ] + ); + let as_of_parameter_names = query_parameter_names( + &openapi["paths"]["/v1/records/placements:as-of"]["get"]["parameters"], + ); + assert_eq!( + as_of_parameter_names, + [ + "accessProfile", + "asOf", + "cursor", + "fields", + "filter", + "pageSize", + "sort" + ] + ); + let as_of_parameters = openapi["paths"]["/v1/records/placements:as-of"]["get"]["parameters"] + .as_array() + .expect("as-of parameters are rendered"); + let as_of = as_of_parameters + .iter() + .find(|parameter| parameter["name"] == "asOf") + .expect("asOf parameter is rendered"); + assert_eq!(as_of["required"], true); + assert_eq!( + as_of["schema"], + json!({"type": "string", "format": "date-time"}) + ); + let page_size = as_of_parameters + .iter() + .find(|parameter| parameter["name"] == "pageSize") + .expect("pageSize parameter is rendered"); + assert_eq!( + page_size["schema"], + json!({"type": "integer", "minimum": 1}) + ); + assert!(compiled.ddl().statements.iter().any(|statement| { + statement.id == "entity.asset-placement.constraint.temporal-order" + && statement.sql.contains(" IS NULL OR ") + && statement.sql.contains(" < ") + })); +} + +fn query_parameter_names(parameters: &Value) -> Vec { + let mut names = parameters + .as_array() + .expect("parameters are an array") + .iter() + .map(|parameter| { + parameter["name"] + .as_str() + .expect("parameter has a name") + .to_owned() + }) + .collect::>(); + names.sort(); + names +} + +#[test] +fn query_inventory_rejects_unsupported_filter_and_sort_field_types() { + for (member, code) in [ + ( + r#""filterableFields":["payload"]"#, + "query.filter.field_type_unsupported", + ), + ( + r#""sortableFields":["payload"]"#, + "query.sort.field_type_unsupported", + ), + ] { + let source = format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"query-shape","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {{"id":"payload","type":"structured","maxBytes":256,"classification":"internal","schema":{{"type":"object","additionalProperties":false}}}} + ], + "accessProfiles":[{{ + "id":"operator","default":true,"principalClaim":"principal", + "operations":["list"],"readableFields":["payload"],{member} + }}] + }}] + }}"# + ); + let project = parse_project_json(source.as_bytes()).expect("project shape parses"); + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("unsupported query field type is refused"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == code)); + } +} + +#[test] +fn temporal_queries_require_profile_readable_boundary_fields() { + let mut project = asset_project(); + let grant = project + .access_profiles + .first_mut() + .expect("fixture has an access profile") + .grants + .iter_mut() + .find(|grant| grant.entity == "asset-placement") + .expect("fixture grants placement access"); + grant.readable_fields.remove("valid-to"); + + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("temporal query cannot process hidden boundary fields"); + assert!(failure.diagnostics().iter().any(|diagnostic| { + diagnostic.code == "query.temporal.field_not_readable" + && diagnostic.path == "entities[].accessProfiles[].readableFields" + })); +} + +#[test] +fn reordered_equivalent_query_authoring_has_the_same_revision() { + let left = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject", + "registry":{"id":"query-shape","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {"id":"code","type":"string","maxLength":32,"classification":"internal"}, + {"id":"count","type":"int64","classification":"internal"} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","operations":["list"], + "readableFields":["code","count"],"filterableFields":["count","code"],"sortableFields":["count","code"] + }] + }] + }"#, + ) + .expect("left source parses"); + let right = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject", + "registry":{"id":"query-shape","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {"id":"count","type":"int64","classification":"internal"}, + {"id":"code","type":"string","maxLength":32,"classification":"internal"} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","operations":["list"], + "readableFields":["count","code"],"filterableFields":["code","count"],"sortableFields":["code","count"] + }] + }] + }"#, + ) + .expect("right source parses"); + + let left = + compile_project(&left, &[], CompileProfile::Authoring).expect("left query source compiles"); + let right = compile_project(&right, &[], CompileProfile::Authoring) + .expect("right query source compiles"); + assert_eq!(left.queries(), right.queries()); + assert_eq!(left.revision(), right.revision()); +} + +#[test] +fn duplicate_routes_fail_before_artifact_generation() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject", + "registry":{"id":"neutral","version":"1","defaultLanguage":"en"}, + "entities":[ + {"id":"first-record","route":"hidden-route-value","mutationMode":"create_only","fields":[ + {"id":"code","type":"string","maxLength":8,"classification":"internal"} + ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]}, + {"id":"second-record","route":"hidden-route-value","mutationMode":"create_only","fields":[ + {"id":"code","type":"string","maxLength":8,"classification":"internal"} + ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]} + ] + }"#, + ) + .expect("project parses"); + + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("duplicate routes fail before artifact generation"); + let diagnostic = failure + .diagnostics() + .iter() + .find(|diagnostic| diagnostic.code == "entity.route.duplicate") + .expect("duplicate route has a stable diagnostic"); + assert_eq!(diagnostic.path, "entities[].route"); + let rendered = serde_json::to_string(&failure).expect("failure serializes"); + for authored_value in ["hidden-route-value", "first-record", "second-record"] { + assert!(!rendered.contains(authored_value)); + } +} + +#[test] +fn anonymous_profiles_cannot_grant_mutation_operations() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject", + "registry":{"id":"neutral","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"public-entry","route":"public-entries","mutationMode":"mutable","classification":"public", + "fields":[{"id":"label","type":"string","maxLength":32,"classification":"public"}], + "accessProfiles":[{ + "id":"anonymous-writer","anonymous":true,"default":true, + "operations":["create","patch"],"readableFields":["label"],"writableFields":["label"] + }] + }] + }"#, + ) + .expect("anonymous mutation fixture parses"); + + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("anonymous mutation authority is refused at compilation"); + let diagnostic = failure + .diagnostics() + .iter() + .find(|diagnostic| diagnostic.code == "access_profile.anonymous.mutation_forbidden") + .expect("anonymous mutation has a stable diagnostic"); + assert_eq!(diagnostic.path, "entities[].accessProfiles[].operations"); + assert!(!serde_json::to_string(diagnostic) + .expect("diagnostic serializes") + .contains("anonymous-writer")); +} + +#[test] +fn production_refuses_a_digest_present_lock_without_module_source() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject", + "registry":{"id":"neutral","version":"1","defaultLanguage":"en"}, + "package":{"environment":"production","instanceId":"neutral-instance","sequence":1,"sourceRevision":"revision-1"}, + "modules":[{"id":"missing-module","version":"1","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"}], + "entities":[{"id":"object","route":"objects","mutationMode":"create_only","fields":[ + {"id":"code","type":"string","maxLength":8,"classification":"internal"} + ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]}] + }"#, + ) + .expect("project parses"); + + let failure = compile_project(&project, &[], CompileProfile::Production) + .expect_err("a lock digest cannot substitute for loaded module source"); + let diagnostic = failure + .diagnostics() + .iter() + .find(|diagnostic| diagnostic.code == "module.source.required") + .expect("missing source has a stable production diagnostic"); + assert_eq!(diagnostic.path, "project.modules[].id"); + let rendered = serde_json::to_string(&failure).expect("failure serializes"); + for authored_value in ["missing-module", "sha256:000000"] { + assert!(!rendered.contains(authored_value)); + } +} + +#[test] +fn verified_module_digest_changes_compiled_closure_artifact_and_revision() { + let project_source = br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject", + "registry":{"id":"neutral","version":"1","defaultLanguage":"en"}, + "package":{"environment":"production","instanceId":"neutral-instance","sequence":1,"sourceRevision":"revision-1"}, + "manifestProjection":{"accessProfile":"reader","classificationCeiling":"internal","catalog":{"baseUrl":"https://neutral.example.test","title":"Neutral Catalog","publisher":{"name":"Neutral Publisher"}},"dataset":{"title":"Neutral Dataset"}}, + "modules":[{"id":"core","version":"1","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"}] + }"#; + let module_source = br#"{ + "id":"core","version":"1","entities":[ + {"id":"alpha-record","route":"alpha-records","mutationMode":"create_only","fields":[ + {"id":"code","type":"string","maxLength":8,"classification":"internal"} + ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]}, + {"id":"beta-record","route":"beta-records","mutationMode":"create_only","fields":[ + {"id":"code","type":"string","maxLength":8,"classification":"internal"} + ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]} + ] + }"#; + let first_module = parse_module_json(module_source).expect("module parses"); + let mut second_module = first_module.clone(); + second_module.entities.reverse(); + let first_digest = module_digest(&first_module); + let second_digest = module_digest(&second_module); + assert_ne!(first_digest, second_digest); + + let mut first_project = parse_project_json(project_source).expect("project parses"); + first_project.modules[0].digest = Some(first_digest); + let mut second_project = first_project.clone(); + second_project.modules[0].digest = Some(second_digest); + + let first = compile_project(&first_project, &[first_module], CompileProfile::Production) + .expect("first verified closure compiles"); + let second = compile_project( + &second_project, + &[second_module], + CompileProfile::Production, + ) + .expect("second verified closure compiles"); + + assert_ne!(first.module_closure(), second.module_closure()); + assert_ne!( + first + .artifacts() + .get("compiled/modules.json") + .expect("module closure artifact exists") + .bytes, + second + .artifacts() + .get("compiled/modules.json") + .expect("module closure artifact exists") + .bytes + ); + assert_ne!(first.revision(), second.revision()); +} diff --git a/crates/registry-server/tests/compiler_webhook.rs b/crates/registry-server/tests/compiler_webhook.rs new file mode 100644 index 0000000000..add6014884 --- /dev/null +++ b/crates/registry-server/tests/compiler_webhook.rs @@ -0,0 +1,464 @@ +// SPDX-License-Identifier: Apache-2.0 + +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::contract::{ + parse_module_json, parse_project_json, ModuleLockSource, RegistryModule, RegistryProject, + WebhookAuthenticationProfile, WebhookDeadLetterMode, +}; +use registry_server::diagnostics::CompileFailure; +use registry_server::model::CompiledWebhookDeliveryMode; +use serde_json::{json, Value}; + +fn project_value() -> Value { + json!({ + "apiVersion": "registry.registrystack.org/v1alpha1", + "kind": "RegistryProject", + "registry": {"id": "webhook-contract", "version": "1", "defaultLanguage": "en"}, + "entities": [{ + "id": "case", + "route": "cases", + "mutationMode": "mutable", + "tombstone": true, + "classification": "internal", + "fields": [ + {"id": "label", "type": "string", "maxLength": 64, "classification": "public"}, + {"id": "region", "type": "string", "maxLength": 32, "classification": "internal"}, + {"id": "secret", "type": "string", "maxLength": 64, "classification": "restricted"} + ], + "events": [{ + "id": "case-created", + "trigger": "created", + "projection": ["label", "region"], + "webhook": { + "destinationId": "case-operations", + "classificationCeiling": "internal", + "authenticationProfile": "hmac_sha256_v1", + "delivery": { + "attemptTimeoutMs": 5000, + "initialBackoffMs": 250, + "maximumBackoffMs": 2000, + "maximumAttempts": 5, + "deadLetter": "required", + "operatorReplay": false + } + } + }, { + "id": "case-patched-outbox", + "trigger": "patched", + "projection": ["label"] + }] + }] + }) +} + +fn parse_project(value: &Value) -> RegistryProject { + parse_project_json(&serde_json::to_vec(value).expect("test project serializes")) + .expect("test project parses") +} + +fn compile(value: &Value) -> Result { + compile_project(&parse_project(value), &[], CompileProfile::Authoring) +} + +fn assert_compile_code(value: &Value, code: &str) { + let failure = compile(value).expect_err("invalid webhook contract is refused"); + assert!( + failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == code), + "missing diagnostic {code:?}: {:?}", + failure.diagnostics() + ); +} + +fn webhook_mut(value: &mut Value) -> &mut serde_json::Map { + value["entities"][0]["events"][0]["webhook"] + .as_object_mut() + .expect("webhook object") +} + +fn delivery_mut(value: &mut Value) -> &mut serde_json::Map { + webhook_mut(value)["delivery"] + .as_object_mut() + .expect("delivery object") +} + +#[test] +fn governed_webhook_compiles_to_deterministic_destination_neutral_inventory() { + let source = project_value(); + let first = compile(&source).expect("governed webhook compiles"); + let second = compile(&source).expect("same governed webhook compiles twice"); + assert_eq!(first, second); + + let inventory = first.event_deliveries(); + assert_eq!(inventory.deliveries.len(), 1); + let delivery = &inventory.deliveries[0]; + assert_eq!(delivery.id, "events.case.case-created.webhook"); + assert_eq!(delivery.entity_id, "case"); + assert_eq!(delivery.event_id, "case-created"); + assert_eq!(delivery.destination_id, "case-operations"); + assert_eq!(delivery.projection_fields, ["label", "region"]); + assert_eq!( + delivery.authentication_profile, + WebhookAuthenticationProfile::HmacSha256V1 + ); + assert_eq!( + delivery.delivery_mode, + CompiledWebhookDeliveryMode::AfterCommit + ); + assert_eq!(delivery.exponential_backoff_multiplier, 2); + assert_eq!(delivery.retry_delays_ms, [250, 500, 1000, 2000]); + assert_eq!(delivery.maximum_payload_bytes, 600); + assert_eq!(delivery.dead_letter, WebhookDeadLetterMode::Required); + assert!(!delivery.operator_replay); + + let artifact = first + .artifacts() + .get("compiled/event-deliveries.json") + .expect("delivery inventory is captured as a compiler artifact"); + let parsed = parse_json_strict(&artifact.bytes).expect("inventory is strict JSON"); + assert_eq!( + canonicalize_json(&parsed).expect("inventory canonicalizes"), + artifact.bytes + ); + assert_eq!( + parsed, + serde_json::to_value(inventory).expect("inventory serializes") + ); + let text = String::from_utf8(artifact.bytes.clone()).expect("artifact is UTF-8"); + for forbidden in ["http://", "https://", "secret", "tls", "certificate"] { + assert!(!text.to_ascii_lowercase().contains(forbidden)); + } + + let entity = &first.entities()["case"]; + assert!(entity.events["case-created"].webhook.is_some()); + assert!(entity.events["case-patched-outbox"].webhook.is_none()); +} + +#[test] +fn destination_auth_delivery_and_deployed_members_are_closed_and_value_free() { + for destination in ["", "HTTPS://deployed.example/hook", "Uppercase", "bad.dot"] { + let mut source = project_value(); + webhook_mut(&mut source).insert("destinationId".to_owned(), json!(destination)); + let failure = compile(&source).expect_err("invalid logical destination is refused"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "event.webhook.destination.invalid")); + if !destination.is_empty() { + assert!(!serde_json::to_string(&failure) + .expect("failure serializes") + .contains(destination)); + } + } + + for (path, value) in [ + ("authenticationProfile", "bearer_token"), + ("delivery.mode", "before_commit"), + ] { + let mut source = project_value(); + if path == "authenticationProfile" { + webhook_mut(&mut source).insert(path.to_owned(), json!(value)); + } else { + delivery_mut(&mut source).insert("mode".to_owned(), json!(value)); + } + let failure = parse_project_json( + &serde_json::to_vec(&source).expect("unsupported profile source serializes"), + ) + .expect_err("unsupported closed mode is refused during strict parse"); + assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); + assert!(!serde_json::to_string(&failure) + .expect("failure serializes") + .contains(value)); + } + + for (member, canary) in [ + ("destinationUrl", "https://deployed.example/webhook-canary"), + ("secret", "raw-webhook-secret-canary"), + ("tlsCertificate", "raw-tls-certificate-canary"), + ] { + let mut source = project_value(); + webhook_mut(&mut source).insert(member.to_owned(), json!(canary)); + let failure = parse_project_json( + &serde_json::to_vec(&source).expect("forbidden deployed source serializes"), + ) + .expect_err("deployed transport or secret authority is not governed"); + assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); + let diagnostic = serde_json::to_string(&failure).expect("failure serializes"); + assert!(!diagnostic.contains(canary)); + } +} + +#[test] +fn webhook_projection_and_classification_ceiling_are_closed() { + let mut missing = project_value(); + missing["entities"][0]["events"][0] + .as_object_mut() + .expect("event object") + .remove("projection"); + let failure = parse_project_json(&serde_json::to_vec(&missing).expect("source serializes")) + .expect_err("a missing event projection is refused"); + assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); + + let mut empty = project_value(); + empty["entities"][0]["events"][0]["projection"] = json!([]); + assert_compile_code(&empty, "event.projection.empty"); + + let mut unknown = project_value(); + unknown["entities"][0]["events"][0]["projection"] = json!(["unknown-field"]); + assert_compile_code(&unknown, "event.projection.field_unknown"); + + let mut projected_above_ceiling = project_value(); + projected_above_ceiling["entities"][0]["events"][0]["projection"] = json!(["secret"]); + assert_compile_code( + &projected_above_ceiling, + "event.webhook.classification_ceiling.underdeclared", + ); + + let mut minimized = project_value(); + minimized["entities"][0]["classification"] = json!("restricted"); + minimized["entities"][0]["events"][0]["projection"] = json!(["label"]); + minimized["entities"][0]["events"][0]["webhook"]["classificationCeiling"] = json!("public"); + let minimized = compile(&minimized) + .expect("a restricted entity may deliver only explicitly projected public fields"); + assert_eq!( + minimized.event_deliveries().deliveries[0].projection_fields, + ["label"] + ); + + let mut oversized = project_value(); + oversized["entities"][0]["fields"][0]["maxLength"] = json!(300_000); + assert_compile_code(&oversized, "event.webhook.projection_too_large"); + + let mut exact_transport_mismatch = project_value(); + exact_transport_mismatch["entities"][0]["fields"][0] = json!({ + "id": "label", + "type": "structured", + "maxBytes": 1_048_576, + "schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "additionalProperties": false + }, + "classification": "public" + }); + assert_compile_code( + &exact_transport_mismatch, + "event.webhook.projection_too_large", + ); + + let mut decimal_quote_boundary = project_value(); + decimal_quote_boundary["entities"][0]["fields"] = json!([{ + "id": "label", + "type": "structured", + "maxBytes": 1_048_517, + "schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "additionalProperties": false + }, + "classification": "public" + }, { + "id": "amount", + "type": "decimal", + "precision": 38, + "scale": 0, + "classification": "public" + }]); + decimal_quote_boundary["entities"][0]["events"][0]["projection"] = json!(["amount", "label"]); + assert_compile_code( + &decimal_quote_boundary, + "event.webhook.projection_too_large", + ); + + let mut all_fractional_decimal_boundary = project_value(); + all_fractional_decimal_boundary["entities"][0]["fields"] = json!([{ + "id": "a", + "type": "structured", + "maxBytes": 1_048_518, + "schema": {"type": "string"}, + "required": true, + "classification": "public" + }, { + "id": "amount", + "type": "decimal", + "precision": 38, + "scale": 38, + "required": true, + "classification": "public" + }]); + all_fractional_decimal_boundary["entities"][0]["events"][0]["projection"] = + json!(["a", "amount"]); + assert_compile_code( + &all_fractional_decimal_boundary, + "event.webhook.projection_too_large", + ); + + let mut optional_null_boundary = project_value(); + optional_null_boundary["entities"][0]["fields"] = json!([{ + "id": "a", + "type": "structured", + "maxBytes": 1_048_564, + "schema": {"type": "string"}, + "required": true, + "classification": "public" + }, { + "id": "b", + "type": "structured", + "maxBytes": 1, + "schema": {"type": "string"}, + "classification": "public" + }]); + optional_null_boundary["entities"][0]["events"][0]["projection"] = json!(["a", "b"]); + assert_compile_code( + &optional_null_boundary, + "event.webhook.projection_too_large", + ); +} + +#[test] +fn webhook_timeout_backoff_attempt_and_dead_letter_bounds_are_closed() { + for (member, value, code) in [ + ("attemptTimeoutMs", 0_u32, "event.webhook.timeout.invalid"), + ("attemptTimeoutMs", 10_001, "event.webhook.timeout.invalid"), + ("initialBackoffMs", 0, "event.webhook.backoff.invalid"), + ( + "maximumBackoffMs", + 3_600_001, + "event.webhook.backoff.invalid", + ), + ("maximumAttempts", 0, "event.webhook.attempts.invalid"), + ("maximumAttempts", 21, "event.webhook.attempts.invalid"), + ] { + let mut source = project_value(); + delivery_mut(&mut source).insert(member.to_owned(), json!(value)); + assert_compile_code(&source, code); + } + + let mut incoherent = project_value(); + delivery_mut(&mut incoherent).insert("initialBackoffMs".to_owned(), json!(2001)); + assert_compile_code(&incoherent, "event.webhook.backoff.invalid"); + + let mut missing_dead_letter = project_value(); + delivery_mut(&mut missing_dead_letter).remove("deadLetter"); + assert_compile_code(&missing_dead_letter, "event.webhook.dead_letter.required"); + + let mut missing_replay = project_value(); + delivery_mut(&mut missing_replay).remove("operatorReplay"); + let failure = parse_project_json( + &serde_json::to_vec(&missing_replay).expect("missing replay source serializes"), + ) + .expect_err("operator replay permission must be explicit"); + assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); +} + +#[test] +fn additive_modules_add_nonconflicting_subscriptions_deterministically_and_refuse_conflicts() { + let mut project_value = project_value(); + project_value["entities"][0]["events"] = json!([]); + let mut project = parse_project(&project_value); + let module_a = webhook_module("module-a", "created-a", "destination-a"); + let module_b = webhook_module("module-b", "created-b", "destination-b"); + project.modules = vec![module_lock(&module_a), module_lock(&module_b)]; + + let first = compile_project( + &project, + &[module_a.clone(), module_b.clone()], + CompileProfile::Authoring, + ) + .expect("nonconflicting module subscriptions compile"); + let second = compile_project( + &project, + &[module_b.clone(), module_a.clone()], + CompileProfile::Authoring, + ) + .expect("module input order does not change compilation"); + assert_eq!(first, second); + assert_eq!( + first + .event_deliveries() + .deliveries + .iter() + .map(|delivery| delivery.id.as_str()) + .collect::>(), + [ + "events.case.created-a.webhook", + "events.case.created-b.webhook" + ] + ); + + let conflicting = webhook_module("module-b", "created-a", "destination-b"); + let mut conflicting_project = project; + conflicting_project.modules = vec![module_lock(&module_a), module_lock(&conflicting)]; + let failure = compile_project( + &conflicting_project, + &[module_a, conflicting], + CompileProfile::Authoring, + ) + .expect_err("module subscriptions cannot replace an existing event id"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "extension.event.duplicate")); +} + +#[test] +fn outbox_only_event_compatibility_emits_an_empty_delivery_inventory() { + let mut source = project_value(); + source["entities"][0]["events"][0] + .as_object_mut() + .expect("event object") + .remove("webhook"); + let compiled = compile(&source).expect("outbox-only events remain valid"); + assert!(compiled.event_deliveries().deliveries.is_empty()); + let artifact = compiled + .artifacts() + .get("compiled/event-deliveries.json") + .expect("empty delivery inventory remains explicit"); + assert_eq!(artifact.bytes, br#"{"deliveries":[]}"#); + assert!(compiled.entities()["case"].events["case-created"] + .webhook + .is_none()); +} + +fn webhook_module(id: &str, event_id: &str, destination_id: &str) -> RegistryModule { + parse_module_json( + &serde_json::to_vec(&json!({ + "id": id, + "version": "1", + "extendEntities": [{ + "entity": "case", + "events": [{ + "id": event_id, + "trigger": "created", + "projection": ["label"], + "webhook": { + "destinationId": destination_id, + "classificationCeiling": "internal", + "authenticationProfile": "hmac_sha256_v1", + "delivery": { + "attemptTimeoutMs": 1000, + "initialBackoffMs": 100, + "maximumBackoffMs": 1000, + "maximumAttempts": 3, + "deadLetter": "required", + "operatorReplay": true + } + } + }] + }] + })) + .expect("module serializes"), + ) + .expect("module parses") +} + +fn module_lock(module: &RegistryModule) -> ModuleLockSource { + ModuleLockSource { + id: module.id.clone(), + version: module.version.clone(), + digest: Some(module_digest(module)), + } +} diff --git a/crates/registry-server/tests/data_operations.rs b/crates/registry-server/tests/data_operations.rs new file mode 100644 index 0000000000..99929e9caf --- /dev/null +++ b/crates/registry-server/tests/data_operations.rs @@ -0,0 +1,465 @@ +// SPDX-License-Identifier: Apache-2.0 + +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::data::{ + DataError, DataExportCheckpoint, DataExportPlan, DataImportCheckpoint, DataImportOperation, + DataImportPlan, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +const ENTITY: &str = "entity-canary-9f31"; +const PROFILE: &str = "operator-canary"; +const PACKAGE: &str = "package-revision-canary"; +const SCHEMA: &str = "schema-fingerprint-canary"; + +fn compiled(allow_data_export: bool) -> registry_server::CompiledRegistry { + let source = json!({ + "apiVersion": "registry.registrystack.org/v1alpha1", + "kind": "RegistryProject", + "registry": {"id": "data-contract", "version": "1", "defaultLanguage": "en"}, + "entities": [{ + "id": ENTITY, + "route": "records", + "mutationMode": "mutable", + "batch": {"maximumItems": 2, "maximumBytes": 400}, + "fields": [ + {"id": "code", "type": "string", "minLength": 2, "maxLength": 16, + "required": true, "classification": "internal"}, + {"id": "count", "type": "int64", "classification": "internal"}, + {"id": "readonly", "type": "text", "maxLength": 32, + "classification": "internal"}, + {"id": "hidden", "type": "string", "maxLength": 16, + "classification": "restricted"} + ], + "accessProfiles": [{ + "id": PROFILE, + "principalClaim": "principal", + "operations": ["create", "patch", "batch", "list"], + "readableFields": ["code", "count", "readonly"], + "writableFields": ["code", "count"], + "allowDataExport": allow_data_export + }] + }] + }); + let project = parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(); + compile_project(&project, &[], CompileProfile::Authoring).unwrap() +} + +fn compile_source(source: Value) -> Result> { + let project = parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(); + compile_project(&project, &[], CompileProfile::Authoring).map_err(|failure| { + failure + .diagnostics() + .iter() + .map(|diagnostic| diagnostic.code.clone()) + .collect() + }) +} + +fn create_line(code: &str, count: i64) -> String { + serde_json::to_string(&json!({ + "operation": "create", + "data": {"code": code, "count": count} + })) + .unwrap() +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[test] +fn data_export_requires_explicit_nonanonymous_profile_permission() { + let ordinary_list = compiled(false); + assert_eq!( + DataExportPlan::from_compiled(&ordinary_list, ENTITY, PROFILE, ["code"]), + Err(DataError::InvalidBinding) + ); + + let base = |anonymous: bool, operations: Value, readable: Value| { + json!({ + "apiVersion": "registry.registrystack.org/v1alpha1", + "kind": "RegistryProject", + "registry": {"id": "export-contract", "version": "1", "defaultLanguage": "en"}, + "entities": [{ + "id": ENTITY, "route": "records", "mutationMode": "create_only", + "fields": [{"id": "code", "type": "string", "maxLength": 16, + "classification": "internal"}], + "accessProfiles": [{ + "id": PROFILE, "anonymous": anonymous, + "principalClaim": if anonymous { Value::Null } else { json!("principal") }, + "operations": operations, "readableFields": readable, + "allowDataExport": true + }] + }] + }) + }; + for invalid in [ + base(true, json!(["list"]), json!(["code"])), + base(false, json!(["get"]), json!(["code"])), + base(false, json!(["list"]), json!([])), + ] { + let diagnostics = compile_source(invalid).expect_err("invalid export authority is refused"); + assert!(diagnostics + .iter() + .any(|code| code == "access_profile.data_export.invalid")); + } + + let explicit = compiled(true); + let plan = + DataExportPlan::from_compiled(&explicit, ENTITY, PROFILE, ["readonly", "code"]).unwrap(); + assert_eq!(plan.requested_fields(), &["code", "readonly"]); + assert_eq!(plan.entity_id(), ENTITY); + assert_eq!(plan.profile_id(), PROFILE); + + let project_profile = json!({ + "apiVersion": "registry.registrystack.org/v1alpha1", + "kind": "RegistryProject", + "registry": {"id": "project-export", "version": "1", "defaultLanguage": "en"}, + "accessProfiles": [{ + "id": "project-exporter", "principalClaim": "principal", + "grants": [{"entity": ENTITY, "actions": ["list"], + "readableFields": ["code"], "allowDataExport": true}] + }], + "entities": [{ + "id": ENTITY, "route": "records", "mutationMode": "create_only", + "fields": [{"id": "code", "type": "string", "maxLength": 16, + "classification": "internal"}] + }] + }); + let project_compiled = compile_source(project_profile).unwrap(); + assert!( + project_compiled.entities()[ENTITY].access_profiles["project-exporter"].allow_data_export + ); +} + +#[test] +fn data_validate_and_chunk_plan_reuse_runtime_rules_and_compiled_batch_bounds() { + let registry = compiled(true); + let input = format!( + "{}\n{}\n{}\n", + create_line("AA", 1), + create_line("BB", 2), + create_line("CC", 3) + ); + let plan = DataImportPlan::from_jsonl( + ®istry, + ENTITY, + DataImportOperation::Create, + PROFILE, + input.as_bytes(), + ) + .unwrap(); + assert_eq!(plan.item_count(), 3); + assert_eq!(plan.maximum_items(), 2); + assert_eq!(plan.maximum_bytes(), 400); + assert_eq!(plan.chunks().len(), 2); + assert_eq!(plan.chunks()[0].item_range(), 0..2); + assert_eq!(plan.chunks()[1].item_range(), 2..3); + for chunk in plan.chunks() { + assert!(chunk.canonical_body().len() <= plan.maximum_bytes() as usize); + let body = parse_json_strict(chunk.canonical_body()).unwrap(); + assert!(body["items"].as_array().unwrap().len() <= plan.maximum_items() as usize); + } + + let invalid_create = [ + json!({"operation":"create","data":{"count":1}}), + json!({"operation":"create","data":{"code":"A","count":1}}), + json!({"operation":"create","data":{"code":"AA","count":"1"}}), + json!({"operation":"create","data":{"code":"AA","readonly":"no"}}), + json!({"operation":"create","data":{"code":"AA","hidden":"no"}}), + json!({"operation":"create","data":{"code":"AA","unknown":"no"}}), + ]; + for item in invalid_create { + let line = format!("{}\n", serde_json::to_string(&item).unwrap()); + assert_eq!( + DataImportPlan::from_jsonl( + ®istry, + ENTITY, + DataImportOperation::Create, + PROFILE, + line.as_bytes(), + ), + Err(DataError::InvalidItem) + ); + } + + let patch = |operation: Value| { + format!( + "{}\n", + serde_json::to_string(&json!({ + "operation":"patch", + "recordId":"018f06d6-0248-7c7f-8a7e-df9dfbd83d2c", + "ifMatch":"\"rs-revision\"", + "patch":[operation] + })) + .unwrap() + ) + }; + for invalid in [ + json!({"op":"remove","path":"/data/code"}), + json!({"op":"replace","path":"/data/readonly","value":"no"}), + json!({"op":"replace","path":"/data/hidden","value":"no"}), + json!({"op":"replace","path":"/data/unknown","value":"no"}), + json!({"op":"replace","path":"/data/count","value":"one"}), + json!({"op":"test","path":"/data/hidden","value":"no"}), + json!({"op":"move","path":"/data/count"}), + ] { + assert_eq!( + DataImportPlan::from_jsonl( + ®istry, + ENTITY, + DataImportOperation::Patch, + PROFILE, + patch(invalid).as_bytes(), + ), + Err(DataError::InvalidItem) + ); + } + + let duplicate = br#"{"operation":"create","operation":"create","data":{"code":"AA"}} +"#; + assert_eq!( + DataImportPlan::from_jsonl( + ®istry, + ENTITY, + DataImportOperation::Create, + PROFILE, + duplicate, + ), + Err(DataError::InvalidItem) + ); + let oversized = format!("{}\n", create_line(&"X".repeat(600), 1)); + assert_eq!( + DataImportPlan::from_jsonl( + ®istry, + ENTITY, + DataImportOperation::Create, + PROFILE, + oversized.as_bytes(), + ), + Err(DataError::InvalidItem), + "field bounds are checked before HTTP body bounds" + ); + let oversized_valid_field_registry = { + let source = json!({ + "apiVersion":"registry.registrystack.org/v1alpha1", "kind":"RegistryProject", + "registry":{"id":"oversize", "version":"1", "defaultLanguage":"en"}, + "entities":[{"id":ENTITY,"route":"records","mutationMode":"create_only", + "batch":{"maximumItems":2,"maximumBytes":100}, + "fields":[{"id":"code","type":"text","maxLength":1000,"required":true, + "classification":"internal"}], + "accessProfiles":[{"id":PROFILE,"principalClaim":"principal", + "operations":["create","batch"],"readableFields":["code"], + "writableFields":["code"]}]}] + }); + compile_source(source).unwrap() + }; + assert_eq!( + DataImportPlan::from_jsonl( + &oversized_valid_field_registry, + ENTITY, + DataImportOperation::Create, + PROFILE, + format!( + "{}\n", + serde_json::to_string(&json!({ + "operation":"create", "data":{"code":"X".repeat(200)} + })) + .unwrap() + ) + .as_bytes(), + ), + Err(DataError::ItemTooLarge) + ); +} + +#[test] +fn data_import_checkpoint_and_idempotency_are_exact_and_value_free() { + let registry = compiled(true); + let input = format!( + "{}\n{}\n{}\n", + create_line("ROW-CANARY-A", 1), + create_line("ROW-CANARY-B", 2), + create_line("ROW-CANARY-C", 3) + ); + let plan = DataImportPlan::from_jsonl( + ®istry, + ENTITY, + DataImportOperation::Create, + PROFILE, + input.as_bytes(), + ) + .unwrap(); + let mut checkpoint = DataImportCheckpoint::start(&plan, PACKAGE, SCHEMA).unwrap(); + let import_id = checkpoint.import_id().to_owned(); + let key = checkpoint + .idempotency_key(&plan, 0, PACKAGE, SCHEMA, &import_id) + .unwrap(); + assert_eq!( + key, + checkpoint + .idempotency_key(&plan, 0, PACKAGE, SCHEMA, &import_id) + .unwrap() + ); + assert_ne!( + key, + checkpoint + .idempotency_key(&plan, 1, PACKAGE, SCHEMA, &import_id) + .unwrap() + ); + checkpoint + .commit_chunk(&plan, PACKAGE, SCHEMA, 0, &import_id) + .unwrap(); + assert_eq!(checkpoint.completed_chunk_count(), 1); + assert_eq!(checkpoint.next_item_index(), 2); + assert!(checkpoint.next_byte_offset() > 0); + assert!(!checkpoint.is_complete()); + let canonical = checkpoint.canonical_json().unwrap(); + assert_eq!(canonical, checkpoint.canonical_json().unwrap()); + DataImportCheckpoint::from_json(&canonical, &plan, PACKAGE, SCHEMA, &import_id).unwrap(); + + for field in [ + "packageRevision", + "schemaFingerprint", + "profileId", + "inputDigest", + "inputLength", + "itemCount", + "maximumItems", + "maximumBytes", + "nextItemIndex", + "nextByteOffset", + "committedPrefixDigest", + "completedChunkCount", + "importId", + ] { + let mut substituted = parse_json_strict(&canonical).unwrap(); + substituted[field] = if field == "importId" { + json!("ce0a5a52-9ed4-4cc8-b71e-5311ed29709e") + } else { + match substituted[field] { + Value::Number(_) => json!(999999), + _ => json!("SUBSTITUTED-CANARY"), + } + }; + let bytes = canonicalize_json(&substituted).unwrap(); + let error = DataImportCheckpoint::from_json(&bytes, &plan, PACKAGE, SCHEMA, &import_id) + .expect_err("every checkpoint binding is exact"); + let rendered = format!("{error:?} {error}"); + assert!(!rendered.contains("SUBSTITUTED-CANARY")); + assert!(!rendered.contains(PACKAGE)); + assert!(!rendered.contains(PROFILE)); + } + let mut unknown = parse_json_strict(&canonical).unwrap(); + unknown["unknownCanary"] = json!(true); + assert_eq!( + DataImportCheckpoint::from_json( + &canonicalize_json(&unknown).unwrap(), + &plan, + PACKAGE, + SCHEMA, + &import_id, + ), + Err(DataError::CheckpointMismatch) + ); + let debug = format!("{plan:?} {checkpoint:?}"); + for canary in [ + "ROW-CANARY", + ENTITY, + PROFILE, + PACKAGE, + SCHEMA, + &key, + &import_id, + ] { + assert!(!debug.contains(canary), "Debug leaked canary {canary}"); + } +} + +#[test] +fn data_export_checkpoint_refuses_package_profile_projection_or_prefix_substitution() { + let registry = compiled(true); + let plan = + DataExportPlan::from_compiled(®istry, ENTITY, PROFILE, ["readonly", "code"]).unwrap(); + let (checkpoint, resume_state) = DataExportCheckpoint::start(&plan, PACKAGE, SCHEMA).unwrap(); + let first_prefix = b"{\"code\":\"OUTPUT-ROW-CANARY\"}\n"; + assert_eq!(checkpoint.output_length(), 0); + assert_eq!(checkpoint.record_count(), 0); + assert!(!checkpoint.is_complete()); + let canonical = checkpoint.canonical_json().unwrap(); + DataExportCheckpoint::from_json(&canonical, &plan, PACKAGE, SCHEMA, &[], &resume_state) + .unwrap(); + + assert_eq!( + checkpoint.validate_resume(&plan, "other-package", SCHEMA, &[], &resume_state,), + Err(DataError::CheckpointMismatch) + ); + assert_eq!( + checkpoint.validate_resume( + &plan, + PACKAGE, + SCHEMA, + b"{\"code\":\"SUBSTITUTED-PREFIX-CANARY\"}\n", + &resume_state, + ), + Err(DataError::CheckpointMismatch) + ); + for (field, replacement) in [ + ("profileId", json!("other-profile")), + ("requestedFields", json!(["code"])), + ("outputPrefixDigest", json!("substituted-prefix")), + ("recordCount", json!(99)), + ( + "nextCursor", + json!("SYNTACTICALLY-VALID-CURSOR-SUBSTITUTION"), + ), + ("complete", json!(true)), + ] { + let mut substituted = parse_json_strict(&canonical).unwrap(); + substituted[field] = replacement; + let bytes = canonicalize_json(&substituted).unwrap(); + let error = + DataExportCheckpoint::from_json(&bytes, &plan, PACKAGE, SCHEMA, &[], &resume_state) + .expect_err("export resume substitution is refused"); + let rendered = format!("{error:?} {error}"); + for canary in [ + PACKAGE, + PROFILE, + "OUTPUT-ROW-CANARY", + "SYNTACTICALLY-VALID-CURSOR-SUBSTITUTION", + ] { + assert!(!rendered.contains(canary)); + } + } + + let mut forged_terminal = parse_json_strict(&canonical).unwrap(); + forged_terminal["outputLength"] = json!(first_prefix.len()); + forged_terminal["outputPrefixDigest"] = json!(sha256_hex(first_prefix)); + forged_terminal["recordCount"] = json!(1); + forged_terminal["completedPageCount"] = json!(1); + forged_terminal["nextCursor"] = Value::Null; + forged_terminal["complete"] = json!(true); + assert_eq!( + DataExportCheckpoint::from_json( + &canonicalize_json(&forged_terminal).unwrap(), + &plan, + PACKAGE, + SCHEMA, + first_prefix, + &resume_state, + ), + Err(DataError::CheckpointMismatch), + "a caller cannot turn an initial or partial checkpoint into terminal authority" + ); + let debug = format!("{plan:?} {checkpoint:?}"); + for canary in [ENTITY, PROFILE, PACKAGE, SCHEMA, "OUTPUT-ROW-CANARY"] { + assert!(!debug.contains(canary), "Debug leaked canary {canary}"); + } +} diff --git a/crates/registry-server/tests/fixture_tooling.rs b/crates/registry-server/tests/fixture_tooling.rs new file mode 100644 index 0000000000..f69e299516 --- /dev/null +++ b/crates/registry-server/tests/fixture_tooling.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(all(feature = "runtime", feature = "tooling"))] + +use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::fixtures::{validate_fixture_journeys, FixtureError}; + +const PROJECT_TEMPLATE: &[u8] = include_bytes!("fixtures/fixture-tooling/project.yaml"); +const MODULE_SOURCE: &[u8] = include_bytes!("fixtures/fixture-tooling/module.yaml"); +const JOURNEY_SOURCE: &[u8] = include_bytes!("fixtures/fixture-tooling/journeys.yaml"); + +#[test] +fn fixture_tooling_strict_parser_refuses_unclosed_authority_and_source_shapes() { + let registry = compiled_fixture(); + let suite = validate_fixture_journeys(JOURNEY_SOURCE, ®istry).expect("strict suite"); + assert_eq!(suite.journey_ids(), ["widget-lifecycle"]); + + let unknown_key = String::from_utf8(JOURNEY_SOURCE.to_vec()) + .expect("fixture is UTF-8") + .replacen("journeys:", "unknownFixtureKey: refused\njourneys:", 1); + assert_eq!( + validate_fixture_journeys(unknown_key.as_bytes(), ®istry).unwrap_err(), + FixtureError::JourneyShapeRefused + ); + + let duplicate = String::from_utf8(JOURNEY_SOURCE.to_vec()) + .expect("fixture is UTF-8") + .replacen("id: get-widget", "id: create-widget", 1); + assert_eq!( + validate_fixture_journeys(duplicate.as_bytes(), ®istry).unwrap_err(), + FixtureError::DuplicateIdentifier + ); + + for (from, to) in [ + ("accessProfile: operator", "accessProfile: administrator"), + ("operation: create", "operation: tombstone"), + ("label: first", "record_id: first"), + ("entity: widget", "entity: registry_data"), + ] { + let changed = String::from_utf8(JOURNEY_SOURCE.to_vec()) + .expect("fixture is UTF-8") + .replacen(from, to, 1); + let error = validate_fixture_journeys(changed.as_bytes(), ®istry) + .expect_err("undeclared logical or physical reference is refused"); + assert!(matches!( + error, + FixtureError::JourneyShapeRefused | FixtureError::LogicalReferenceRefused + )); + assert!(!format!("{error:?}").contains(to)); + } + + let oversized = vec![b'x'; 1024 * 1024 + 1]; + assert_eq!( + validate_fixture_journeys(&oversized, ®istry).unwrap_err(), + FixtureError::JourneyTooLarge + ); +} + +#[test] +fn postgres_fixture_runner_has_no_caller_supplied_response_path() { + let implementation = include_str!("../src/fixtures.rs"); + let runner = implementation + .split_once("impl PostgresFixtureTestRunner {") + .and_then(|(_, tail)| tail.split_once("/// Completed result")) + .map(|(runner, _)| runner) + .expect("fixture runner implementation remains structurally visible"); + for forbidden in [ + "pub fn next_request", + "pub async fn accept_response", + "pub async fn accept_current_response", + "pub async fn finish", + ] { + assert!( + !runner.contains(forbidden), + "fixture execution exposed a caller-controlled completion seam" + ); + } + let public_methods = runner + .match_indices("pub async fn ") + .map(|(offset, _)| { + runner[offset + "pub async fn ".len()..] + .split_once('(') + .map(|(name, _)| name) + .expect("public async method has an argument list") + }) + .collect::>(); + assert_eq!(public_methods, ["prepare", "run_all"]); + assert!(!runner.contains("pub fn ")); + assert!(runner.contains("prepared: &PreparedServer")); + let prepare_signature = runner + .split_once("pub async fn prepare(") + .and_then(|(_, tail)| tail.split_once(") -> Result")) + .map(|(signature, _)| signature) + .expect("fixture prepare signature remains structurally visible"); + assert!(!prepare_signature.contains("pool: RuntimePool")); + assert!(!prepare_signature.contains("Router")); + assert!(!prepare_signature.contains("Response")); + assert!(runner.contains(".fixture_runtime()")); + assert!(runner.contains(".call(request)")); +} + +#[test] +fn production_schema_test_executor_has_no_raw_server_source_or_receipt_seams() { + let implementation = include_str!("../src/fixtures.rs"); + let executor = implementation + .split_once("pub async fn execute_schema_test(") + .and_then(|(_, tail)| tail.split_once("#[cfg(feature = \"postgres-test\")]")) + .map(|(executor, _)| executor) + .expect("production executor implementation remains structurally visible"); + let signature = executor + .split_once(") -> Result") + .map(|(signature, _)| signature) + .expect("production executor signature remains structurally visible"); + for forbidden in [ + "PreparedServer", + "Router", + "RuntimePool", + "Response", + "SchemaTestSources", + ] { + assert!( + !signature.contains(forbidden), + "production executor exposed a raw fixture authority seam" + ); + } + assert!(signature.contains("database: PreparedSchemaTestDatabase")); + assert!(signature.contains("package: &PreparedPackage")); + assert!(signature.contains("credentials: SchemaTestCredentialBindings")); + let internal_executor = implementation + .split_once("async fn execute_schema_test_with_key_source(") + .and_then(|(_, tail)| tail.split_once("struct SchemaTestRuntime")) + .map(|(executor, _)| executor) + .expect("private executor implementation remains structurally visible"); + assert!( + internal_executor + .find("let credential_map = credentials.into_map(suite)") + .expect("credentials are closed") + < internal_executor + .find("let pool = database.pool()") + .expect("database is first accessed"), + "credential shape and mode must fail before database I/O" + ); + assert!(internal_executor.contains("let final_facts = database_execution_facts(&runtime.pool)")); + assert!(internal_executor.contains("final_facts != runtime.initial_facts")); + assert!(internal_executor.contains("!runtime.readiness.is_ready().await")); + assert!(!implementation.contains("pub fn build_schema_test_receipt")); + assert!(!implementation.contains("pub struct SuccessfulFixtureJourneys")); + assert!(implementation.contains("pub fn validate_schema_test_receipt_for_package(")); +} + +fn compiled_fixture() -> registry_server::CompiledRegistry { + let module = parse_module_yaml(MODULE_SOURCE).expect("module fixture parses"); + let project_source = String::from_utf8(PROJECT_TEMPLATE.to_vec()) + .expect("project fixture is UTF-8") + .replace("MODULE_DIGEST", &module_digest(&module)) + .into_bytes(); + let project = parse_project_yaml(&project_source).expect("project fixture parses"); + compile_project(&project, &[module], CompileProfile::Production) + .expect("fixture project compiles in Production") +} diff --git a/crates/registry-server/tests/fixtures/fixture-tooling/journeys.yaml b/crates/registry-server/tests/fixtures/fixture-tooling/journeys.yaml new file mode 100644 index 0000000000..faeaacadfa --- /dev/null +++ b/crates/registry-server/tests/fixtures/fixture-tooling/journeys.yaml @@ -0,0 +1,70 @@ +apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: widget-lifecycle + steps: + - id: create-widget + entity: widget + accessProfile: operator + claims: &operator_claims + principal: fixture-operator + purpose: case-management + directClaims: {jurisdiction: zone-a} + request: + operation: create + data: {jurisdiction: zone-a, label: first, note: initial, quantity: 1} + expect: + outcome: success + status: 201 + fields: {jurisdiction: zone-a, label: first, note: initial, quantity: 1} + capture: first-widget + - id: get-widget + entity: widget + accessProfile: operator + claims: *operator_claims + request: {operation: get, recordRef: first-widget} + expect: + outcome: success + status: 200 + fields: {jurisdiction: zone-a, label: first, note: initial, quantity: 1} + - id: list-widgets + entity: widget + accessProfile: operator + claims: *operator_claims + request: {operation: list} + expect: {outcome: success, status: 200, count: 1} + - id: patch-widget + entity: widget + accessProfile: operator + claims: *operator_claims + request: + operation: patch + recordRef: first-widget + etagRef: first-widget + changes: + - {field: note, value: revised} + expect: + outcome: success + status: 200 + fields: {jurisdiction: zone-a, label: first, note: revised, quantity: 1} + capture: revised-widget + - id: batch-create-widgets + entity: widget + accessProfile: operator + claims: *operator_claims + request: + operation: batch + items: + - {operation: create, data: {jurisdiction: zone-a, label: second, quantity: 2}} + - {operation: create, data: {jurisdiction: zone-a, label: third, quantity: 3}} + expect: {outcome: success, status: 200, count: 2} + - id: concealed-without-purpose + entity: widget + accessProfile: operator + claims: + principal: fixture-operator + directClaims: {jurisdiction: zone-a} + request: {operation: get, recordRef: revised-widget} + expect: + outcome: refusal + status: 404 + problemCode: resource.not_found diff --git a/crates/registry-server/tests/fixtures/fixture-tooling/module.yaml b/crates/registry-server/tests/fixtures/fixture-tooling/module.yaml new file mode 100644 index 0000000000..38e0a94fde --- /dev/null +++ b/crates/registry-server/tests/fixtures/fixture-tooling/module.yaml @@ -0,0 +1,2 @@ +id: fixture-core +version: 1.0.0 diff --git a/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml b/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml new file mode 100644 index 0000000000..21f5f54520 --- /dev/null +++ b/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml @@ -0,0 +1,53 @@ +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: fixture-registry + version: 1.0.0 + defaultLanguage: en +package: + environment: local + instanceId: fixture-instance + sequence: 1 + sourceRevision: fixture-project-source +manifestProjection: + accessProfile: operator + classificationCeiling: public + catalog: + baseUrl: https://fixture.invalid + title: Fixture Registry + publisher: {name: Fixture Publisher} + dataset: + title: Fixture Dataset + owner: Fixture Publisher + status: active +modules: + - id: fixture-core + version: 1.0.0 + digest: MODULE_DIGEST +entities: + - id: widget + route: widgets + mutationMode: mutable + classification: public + batch: + maximumItems: 4 + maximumBytes: 16384 + fields: + - {id: jurisdiction, type: string, maxLength: 32, required: true, classification: public} + - {id: label, type: string, maxLength: 128, required: true, classification: public} + - {id: note, type: string, maxLength: 128, classification: public} + - {id: quantity, type: int64, required: true, classification: public} + constraints: + - {kind: unique, fields: [label]} + events: + - {id: widget-created, trigger: created, projection: [jurisdiction, label, quantity]} + accessProfiles: + - id: operator + default: true + principalClaim: registry_principal + requiredPurposes: [case-management] + operations: [create, get, list, patch, batch] + readableFields: [jurisdiction, label, note, quantity] + writableFields: [jurisdiction, label, note, quantity] + rowBoundaries: + - {field: jurisdiction, claim: jurisdiction, operator: equals} diff --git a/crates/registry-server/tests/fixtures/fixture-tooling/terminal-failure.yaml b/crates/registry-server/tests/fixtures/fixture-tooling/terminal-failure.yaml new file mode 100644 index 0000000000..3f640d6957 --- /dev/null +++ b/crates/registry-server/tests/fixtures/fixture-tooling/terminal-failure.yaml @@ -0,0 +1,29 @@ +apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: terminal-failure + steps: + - id: create-terminal-record + entity: widget + accessProfile: operator + claims: &operator_claims + principal: fixture-operator + purpose: case-management + directClaims: {jurisdiction: zone-a} + request: + operation: create + data: {jurisdiction: zone-a, label: terminal-first, quantity: 4} + expect: + outcome: success + status: 201 + fields: {jurisdiction: zone-a, label: terminal-first, quantity: 4} + - id: duplicate-terminal-record + entity: widget + accessProfile: operator + claims: *operator_claims + request: + operation: create + data: {jurisdiction: zone-a, label: terminal-first, quantity: 5} + expect: + outcome: success + status: 201 + fields: {jurisdiction: zone-a, label: terminal-first, quantity: 5} diff --git a/crates/registry-server/tests/http_auth.rs b/crates/registry-server/tests/http_auth.rs new file mode 100644 index 0000000000..6afeb6a70c --- /dev/null +++ b/crates/registry-server/tests/http_auth.rs @@ -0,0 +1,692 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "runtime")] + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::body::{to_bytes, Body}; +use axum::http::header::AUTHORIZATION; +use axum::http::{HeaderValue, Request, StatusCode}; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{ + JwksFetcher, JwksFetcherConfig, OidcError, TokenVerifier, TokenVerifierConfig, +}; +use registry_platform_testing::{ + fixtures, oidc_verifier_config, sign_ed25519_compact_jwt, MockIdp, +}; +use registry_server::api::{ + authenticated_router, HeldReadResponse, HttpService, ReadRuntimeIdentity, ReadServiceError, + ReadinessProbe, RecordReadRequest, RecordReadService, ServiceFuture, VerifiedRequestClaims, +}; +use registry_server::auth::{ + AuthenticationConfigError, AuthenticationError, AuthorityClaimConfig, RegistryAuthenticator, + RowBoundaryClaimMapping, RowBoundaryClaimType, +}; +use registry_server::cursor::CursorCodec; +use registry_server::{compile_project, parse_project_yaml, CompileProfile, CompiledRegistry}; +use serde_json::{json, Value}; +use tower::ServiceExt as _; +use zeroize::Zeroizing; + +const AUDIENCE: &str = "urn:example:registry-server"; +const PRINCIPAL: &str = "principal-value-never-rendered"; +const PURPOSE: &str = "case-management-never-rendered"; +const JURISDICTION: &str = "area-a-never-rendered"; +const TENANT: &str = "tenant-a-never-rendered"; + +const PROJECT: &str = r#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: authenticated-read-surface + version: 0.1.0 + defaultLanguage: en +entities: + - id: case + route: cases + mutationMode: mutable + tombstone: false + classification: public + fields: + - {id: label, type: string, required: true, maxLength: 100, classification: public} + - {id: secret, type: string, required: true, maxLength: 100, classification: restricted} + - {id: jurisdiction, type: string, required: true, maxLength: 100, classification: internal} + - {id: tenant, type: string, required: true, maxLength: 100, classification: internal} + accessProfiles: + - id: public + default: true + anonymous: true + operations: [get] + readableFields: [label] + - id: caseworker + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management-never-rendered] + operations: [get] + readableFields: [label, secret] + rowBoundaries: + - {field: jurisdiction, claim: jurisdictions, operator: in} + - {field: tenant, claim: tenant, operator: equals} +"#; + +#[derive(Default)] +struct RecordingReadService { + calls: AtomicUsize, + requests: Mutex>, +} + +impl RecordReadService for RecordingReadService { + fn get( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + let selected_fields = request.selected_fields.clone(); + self.calls.fetch_add(1, Ordering::SeqCst); + self.requests.lock().expect("record requests").push(request); + Box::pin(async move { + Ok(Some(held(project_fixture( + json!({ + "id": "case-1", + "revision": 1, + "data": { + "label": "Visible", + "secret": "SECRET-RESPONSE-CANARY" + } + }), + &selected_fields, + )))) + }) + } + + fn list( + &self, + _request: RecordReadRequest, + ) -> ServiceFuture<'_, Result> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(held(json!({"items": []}))) }) + } +} + +fn held(value: Value) -> HeldReadResponse { + HeldReadResponse::from_json(&value).expect("fake read response serializes") +} + +fn project_fixture(mut record: Value, selected_fields: &BTreeSet) -> Value { + record["data"] + .as_object_mut() + .expect("fixture data is an object") + .retain(|field, _| selected_fields.contains(field)); + record +} + +struct Ready; + +impl ReadinessProbe for Ready { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async { true }) + } +} + +struct Harness { + app: axum::Router, + authenticator: Arc, + records: Arc, + registry: Arc, + idp: MockIdp, +} + +impl Harness { + async fn new() -> Self { + let registry = compiled_registry(); + let idp = MockIdp::start().await; + let authenticator = authenticator(®istry, &idp, authority_claims()) + .expect("authentication config is valid"); + let records = Arc::new(RecordingReadService::default()); + let service = Arc::new(HttpService::new( + Arc::clone(®istry), + read_identity(), + records.clone(), + Arc::new(Ready), + cursor_codec(), + )); + let authenticator = Arc::new(authenticator); + let app = authenticated_router(service, Arc::clone(&authenticator)); + Self { + app, + authenticator, + records, + registry, + idp, + } + } + + async fn send( + &self, + uri: &str, + authorization: &[HeaderValue], + injected_claims: Option, + ) -> axum::response::Response { + let mut request = Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("request"); + for value in authorization { + request.headers_mut().append(AUTHORIZATION, value.clone()); + } + if let Some(claims) = injected_claims { + request.extensions_mut().insert(claims); + } + self.app + .clone() + .oneshot(request) + .await + .expect("router responds") + } + + fn valid_token(&self) -> String { + self.idp.mint_token(valid_claims()) + } + + fn signed_token(&self, mut claims: Value, typ: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs(); + let claims = claims + .as_object_mut() + .expect("fixture claims are an object"); + claims + .entry("iss") + .or_insert_with(|| json!(self.idp.issuer())); + claims.entry("iat").or_insert_with(|| json!(now)); + claims.entry("nbf").or_insert_with(|| json!(now)); + claims.entry("exp").or_insert_with(|| json!(now + 900)); + sign_ed25519_compact_jwt( + fixtures::ED25519_PRIVATE_JWK, + typ, + "registry-platform-testing-ed25519-1", + Value::Object(claims.clone()), + ) + } +} + +fn read_identity() -> ReadRuntimeIdentity { + ReadRuntimeIdentity { + package_revision: "package-auth-test".to_owned(), + schema_fingerprint: "schema-auth-test".to_owned(), + } +} + +fn cursor_codec() -> Arc { + Arc::new( + CursorCodec::new( + Zeroizing::new(vec![0x43; 32]), + std::time::Duration::from_secs(300), + ) + .expect("test cursor key is valid"), + ) +} + +#[tokio::test] +async fn verified_direct_authority_reaches_the_protected_record_service() { + let harness = Harness::new().await; + let token = harness.valid_token(); + let response = harness + .send( + "/v1/records/cases/case-1?accessProfile=caseworker", + &[bearer(&token)], + None, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + assert_eq!(body["data"]["secret"], "SECRET-RESPONSE-CANARY"); + assert_eq!(harness.records.calls.load(Ordering::SeqCst), 1); + + let requests = harness.records.requests.lock().expect("record requests"); + let context = &requests[0].context; + assert_eq!(context.principal(), Some(PRINCIPAL)); + assert_eq!(context.purpose(), Some(PURPOSE)); + assert_eq!(context.row_boundaries().len(), 2); + assert_eq!( + context + .row_boundaries() + .iter() + .find(|boundary| boundary.field() == "jurisdiction") + .expect("jurisdiction boundary") + .values(), + &BTreeSet::from([JURISDICTION.to_owned()]) + ); + assert_eq!( + context + .row_boundaries() + .iter() + .find(|boundary| boundary.field() == "tenant") + .expect("tenant boundary") + .values(), + &BTreeSet::from([TENANT.to_owned()]) + ); +} + +#[tokio::test] +async fn missing_malformed_or_fallback_only_principal_is_refused_before_record_io() { + let harness = Harness::new().await; + let mut cases = Vec::new(); + let mut missing = valid_claims(); + missing + .as_object_mut() + .unwrap() + .remove("registry_principal"); + cases.push(harness.signed_token(missing, "JWT")); + for value in [json!([PRINCIPAL]), json!({"id": PRINCIPAL}), Value::Null] { + let mut claims = valid_claims(); + claims["registry_principal"] = value; + cases.push(harness.signed_token(claims, "JWT")); + } + let mut fallback_only = valid_claims(); + fallback_only + .as_object_mut() + .unwrap() + .remove("registry_principal"); + fallback_only["sub"] = json!(PRINCIPAL); + fallback_only["client_id"] = json!(PRINCIPAL); + fallback_only["azp"] = json!(PRINCIPAL); + cases.push(harness.signed_token(fallback_only, "JWT")); + + for token in cases { + assert_refused_without_record_call(&harness, &token).await; + } +} + +#[tokio::test] +async fn malformed_purpose_and_row_boundary_shapes_are_refused_before_record_io() { + let harness = Harness::new().await; + let malformed = [ + ("purpose", json!([PURPOSE])), + ("purpose", Value::Null), + ("jurisdictions", json!(JURISDICTION)), + ("jurisdictions", json!([])), + ("jurisdictions", json!([JURISDICTION, {"id": JURISDICTION}])), + ("tenant", json!([TENANT])), + ("tenant", Value::Null), + ]; + for (name, value) in malformed { + let mut claims = valid_claims(); + claims[name] = value; + let token = harness.signed_token(claims, "JWT"); + assert_refused_without_record_call(&harness, &token).await; + } +} + +#[tokio::test] +async fn issuer_audience_algorithm_token_type_and_signature_are_all_verified() { + let harness = Harness::new().await; + let mut wrong_issuer = valid_claims(); + wrong_issuer["iss"] = json!("https://issuer.invalid/URL-CREDENTIAL-CANARY"); + let wrong_issuer = harness.signed_token(wrong_issuer, "JWT"); + + let mut wrong_audience = valid_claims(); + wrong_audience["iss"] = json!(harness.idp.issuer()); + wrong_audience["aud"] = json!("urn:wrong:AUDIENCE-CANARY"); + let wrong_audience = harness.signed_token(wrong_audience, "JWT"); + + let mut correctly_issued = valid_claims(); + correctly_issued["iss"] = json!(harness.idp.issuer()); + let wrong_type = harness.signed_token(correctly_issued.clone(), "id_token"); + let signed = harness.signed_token(correctly_issued, "JWT"); + let wrong_algorithm = format!( + "{}.{}", + "eyJhbGciOiJSUzI1NiIsImtpZCI6InJlZ2lzdHJ5LXBsYXRmb3JtLXRlc3RpbmctZWQyNTUxOS0xIiwidHlwIjoiSldUIn0", + signed.split_once('.').expect("compact token").1 + ); + let mut wrong_signature = signed; + let signature_start = wrong_signature.rfind('.').expect("signature separator") + 1; + let replacement = if wrong_signature.as_bytes()[signature_start] == b'A' { + "B" + } else { + "A" + }; + wrong_signature.replace_range(signature_start..=signature_start, replacement); + + let verifier = token_verifier(&harness.idp); + verifier + .key_source() + .ensure_key_set() + .await + .expect("MockIdP JWKS is reachable before verifier-negative assertions"); + assert_platform_refusal(&verifier, &wrong_issuer, |error| { + matches!(error, OidcError::IssuerMismatch { .. }) + }) + .await; + assert_platform_refusal(&verifier, &wrong_audience, |error| { + matches!(error, OidcError::AudienceMismatch) + }) + .await; + assert_platform_refusal(&verifier, &wrong_algorithm, |error| { + matches!(error, OidcError::AlgorithmNotAllowed) + }) + .await; + assert_platform_refusal(&verifier, &wrong_type, |error| { + matches!(error, OidcError::TokenTypeNotAllowed) + }) + .await; + assert_platform_refusal(&verifier, &wrong_signature, |error| { + matches!(error, OidcError::SignatureInvalid) + }) + .await; + + for token in [ + wrong_issuer, + wrong_audience, + wrong_algorithm, + wrong_type, + wrong_signature, + ] { + assert_refused_without_record_call(&harness, &token).await; + } +} + +#[tokio::test] +async fn malformed_or_duplicate_bearer_never_downgrades_to_anonymous() { + let harness = Harness::new().await; + for values in [ + vec![HeaderValue::from_static("Bearer malformed")], + vec![HeaderValue::from_static("Bearer malformed")], + vec![ + HeaderValue::from_static("Bearer one.two.three"), + HeaderValue::from_static("Bearer four.five.six"), + ], + ] { + let before = harness.records.calls.load(Ordering::SeqCst); + let response = harness + .send("/v1/records/cases/case-1", &values, None) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(body_json(response).await["code"], "authentication.refused"); + assert_eq!(harness.records.calls.load(Ordering::SeqCst), before); + } +} + +#[tokio::test] +async fn anonymous_without_a_token_succeeds_but_injected_authority_is_removed() { + let harness = Harness::new().await; + let public = harness.send("/v1/records/cases/case-1", &[], None).await; + assert_eq!(public.status(), StatusCode::OK); + assert_eq!(body_json(public).await["data"], json!({"label": "Visible"})); + + let before = harness.records.calls.load(Ordering::SeqCst); + let missing = harness + .send( + "/v1/records/cases/case-1?accessProfile=caseworker", + &[], + None, + ) + .await; + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + assert_eq!(harness.records.calls.load(Ordering::SeqCst), before); + + let injected = VerifiedRequestClaims::authenticated( + "registry_principal", + PRINCIPAL, + BTreeSet::from(["registry.read".to_owned()]), + Some(PURPOSE.to_owned()), + BTreeMap::new(), + ) + .expect("low-level fixture claims"); + let before = harness.records.calls.load(Ordering::SeqCst); + let response = harness + .send( + "/v1/records/cases/case-1?accessProfile=caseworker", + &[], + Some(injected), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(harness.records.calls.load(Ordering::SeqCst), before); +} + +#[tokio::test] +async fn refusals_and_debug_output_are_value_free() { + let harness = Harness::new().await; + let mut claims = valid_claims(); + claims["iss"] = json!(harness.idp.issuer()); + claims["registry_principal"] = json!({"value": PRINCIPAL}); + let token = harness.signed_token(claims, "JWT"); + let error = harness + .authenticator + .authenticate(&token) + .await + .expect_err("malformed authority claim is refused"); + assert_eq!(error, AuthenticationError::InvalidClaims); + let error_debug = format!("{error:?}"); + let authenticator_debug = format!("{:?}", harness.authenticator); + + let before = harness.records.calls.load(Ordering::SeqCst); + let response = harness + .send("/v1/records/cases/case-1", &[bearer(&token)], None) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = body_json(response).await.to_string(); + assert_eq!(harness.records.calls.load(Ordering::SeqCst), before); + for canary in [ + PRINCIPAL, + PURPOSE, + JURISDICTION, + TENANT, + AUDIENCE, + harness.idp.issuer().as_str(), + token.as_str(), + "URL-CREDENTIAL-CANARY", + ] { + assert!(!body.contains(canary), "problem body exposed {canary}"); + assert!( + !error_debug.contains(canary), + "error Debug exposed {canary}" + ); + assert!( + !authenticator_debug.contains(canary), + "authenticator Debug exposed {canary}" + ); + } +} + +#[tokio::test] +async fn constructor_rejects_empty_duplicate_reserved_and_incomplete_mappings() { + let harness = Harness::new().await; + let invalid = [ + AuthorityClaimConfig::new("", Some("purpose".to_owned()), row_claims()), + AuthorityClaimConfig::new("sub", Some("purpose".to_owned()), row_claims()), + AuthorityClaimConfig::new( + "registry_principal", + Some("registry_principal".to_owned()), + row_claims(), + ), + AuthorityClaimConfig::new( + "registry_principal", + Some("purpose".to_owned()), + vec![ + RowBoundaryClaimMapping::new( + "jurisdictions", + RowBoundaryClaimType::DirectStringSet, + ), + RowBoundaryClaimMapping::new( + "jurisdictions", + RowBoundaryClaimType::DirectStringSet, + ), + ], + ), + ]; + for claims in invalid { + let error = authenticator(&harness.registry, &harness.idp, claims) + .expect_err("unsafe claim mapping is refused"); + assert_eq!(error, AuthenticationConfigError::InvalidClaimMapping); + } + + for claims in [ + AuthorityClaimConfig::new("registry_principal", None, row_claims()), + AuthorityClaimConfig::new( + "registry_principal", + Some("purpose".to_owned()), + vec![RowBoundaryClaimMapping::new( + "jurisdictions", + RowBoundaryClaimType::DirectStringSet, + )], + ), + AuthorityClaimConfig::new( + "registry_principal", + Some("purpose".to_owned()), + vec![ + RowBoundaryClaimMapping::new("jurisdictions", RowBoundaryClaimType::DirectString), + RowBoundaryClaimMapping::new("tenant", RowBoundaryClaimType::DirectString), + ], + ), + ] { + let error = authenticator(&harness.registry, &harness.idp, claims) + .expect_err("incomplete compiled authority mapping is refused"); + assert_eq!(error, AuthenticationConfigError::CompiledAuthorityMismatch); + } +} + +#[tokio::test] +async fn constructor_requires_one_exact_bounded_verifier_profile() { + let harness = Harness::new().await; + let mut empty_issuer = verifier_config(&harness.idp); + empty_issuer.issuer = " ".to_owned(); + let mut duplicate_audience = verifier_config(&harness.idp); + duplicate_audience.audiences.push(AUDIENCE.to_owned()); + let mut duplicate_algorithm = verifier_config(&harness.idp); + duplicate_algorithm + .allowed_algorithms + .push(duplicate_algorithm.allowed_algorithms[0]); + let mut duplicate_type = verifier_config(&harness.idp); + duplicate_type.allowed_typ.push("at+jwt".to_owned()); + let mut reserved_scope = verifier_config(&harness.idp); + reserved_scope.scope_claim = "sub".to_owned(); + + for verifier in [ + empty_issuer, + duplicate_audience, + duplicate_algorithm, + duplicate_type, + reserved_scope, + ] { + let error = authenticator_with_verifier( + &harness.registry, + &harness.idp, + verifier, + authority_claims(), + ) + .expect_err("ambiguous verifier profile is refused"); + assert_eq!(error, AuthenticationConfigError::InvalidVerifierProfile); + } +} + +async fn assert_refused_without_record_call(harness: &Harness, token: &str) { + let before = harness.records.calls.load(Ordering::SeqCst); + let response = harness + .send( + "/v1/records/cases/case-1?accessProfile=caseworker", + &[bearer(token)], + None, + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = body_json(response).await; + assert_eq!(body["code"], "authentication.refused"); + assert_eq!(harness.records.calls.load(Ordering::SeqCst), before); +} + +async fn assert_platform_refusal( + verifier: &TokenVerifier, + token: &str, + expected: impl FnOnce(&OidcError) -> bool, +) { + let error = verifier + .verify(token) + .await + .expect_err("platform verifier refused token"); + assert!(expected(&error), "unexpected platform verifier refusal"); +} + +fn authenticator( + registry: &CompiledRegistry, + idp: &MockIdp, + claims: AuthorityClaimConfig, +) -> Result { + authenticator_with_verifier(registry, idp, verifier_config(idp), claims) +} + +fn authenticator_with_verifier( + registry: &CompiledRegistry, + idp: &MockIdp, + verifier: TokenVerifierConfig, + claims: AuthorityClaimConfig, +) -> Result { + let key_source = Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )); + RegistryAuthenticator::new(registry, verifier, key_source, claims) +} + +fn token_verifier(idp: &MockIdp) -> TokenVerifier { + TokenVerifier::new(verifier_config(idp), key_source(idp)) +} + +fn key_source(idp: &MockIdp) -> Arc { + Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )) +} + +fn verifier_config(idp: &MockIdp) -> TokenVerifierConfig { + oidc_verifier_config(idp.issuer(), vec![AUDIENCE.to_owned()]) +} + +fn authority_claims() -> AuthorityClaimConfig { + AuthorityClaimConfig::new( + "registry_principal", + Some("purpose".to_owned()), + row_claims(), + ) +} + +fn row_claims() -> Vec { + vec![ + RowBoundaryClaimMapping::new("jurisdictions", RowBoundaryClaimType::DirectStringSet), + RowBoundaryClaimMapping::new("tenant", RowBoundaryClaimType::DirectString), + ] +} + +fn valid_claims() -> Value { + json!({ + "aud": AUDIENCE, + "registry_principal": PRINCIPAL, + "scope": "registry.read", + "purpose": PURPOSE, + "jurisdictions": [JURISDICTION], + "tenant": TENANT, + }) +} + +fn compiled_registry() -> Arc { + let project = parse_project_yaml(PROJECT.as_bytes()).expect("project parses"); + Arc::new(compile_project(&project, &[], CompileProfile::Authoring).expect("project compiles")) +} + +fn bearer(token: &str) -> HeaderValue { + format!("Bearer {token}").parse().expect("bearer header") +} + +async fn body_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response body"); + serde_json::from_slice(&bytes).expect("JSON response") +} diff --git a/crates/registry-server/tests/http_read_only.rs b/crates/registry-server/tests/http_read_only.rs new file mode 100644 index 0000000000..1497ee4679 --- /dev/null +++ b/crates/registry-server/tests/http_read_only.rs @@ -0,0 +1,1281 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "runtime")] + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use axum::body::{to_bytes, Body}; +use axum::http::{Method, Request, StatusCode}; +use registry_platform_canonical_json::parse_json_strict; +use registry_server::api::{ + router, HeldReadResponse, HttpService, ReadRuntimeIdentity, ReadServiceError, ReadinessProbe, + RecordReadRequest, RecordReadService, RevisionReadRefusal, RevisionReadRequest, + RevisionReadService, ServiceFuture, VerifiedClaimValue, VerifiedRequestClaims, +}; +use registry_server::artifacts::REGISTRY_METADATA_ARTIFACT_PATH; +use registry_server::contract::Operation; +use registry_server::cursor::CursorCodec; +use registry_server::{compile_project, parse_project_yaml, CompileProfile}; +use serde_json::{json, Value}; +use tower::Service as _; +use zeroize::Zeroizing; + +const PROJECT: &str = r#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: read-surface + version: 0.1.0 + defaultLanguage: en +entities: + - id: case + route: cases + mutationMode: mutable + tombstone: true + batch: {maximumItems: 10, maximumBytes: 65536} + classification: public + fields: + - {id: label, type: string, required: true, maxLength: 100, classification: public} + - {id: secret, type: string, required: true, maxLength: 100, classification: restricted} + - {id: jurisdiction, type: string, required: true, maxLength: 32, classification: internal} + accessProfiles: + - id: public + default: true + anonymous: true + operations: [get, list] + readableFields: [label] + filterableFields: [label] + sortableFields: [label] + - id: caseworker + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + operations: [create, get, list, patch, tombstone, batch, revisions] + readableFields: [label, secret, jurisdiction] + writableFields: [label, secret, jurisdiction] + filterableFields: [label, jurisdiction] + sortableFields: [label] + rowBoundaries: + - {field: jurisdiction, claim: jurisdictions, operator: in} + - id: protected-note + route: notes + mutationMode: create_only + classification: restricted + fields: + - {id: text, type: text, required: true, maxLength: 200, classification: restricted} + accessProfiles: + - id: caseworker + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + operations: [create, get, list] + readableFields: [text] + writableFields: [text] +"#; + +const DISCOVERY_MATRIX_PROJECT: &str = r#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: discovery-matrix + version: 1 + defaultLanguage: en +entities: + - id: public-record + route: public-records + mutationMode: mutable + classification: public + fields: + - {id: label, type: string, required: true, maxLength: 100, classification: public} + - {id: restricted-canary-field, type: string, maxLength: 100, classification: restricted} + accessProfiles: + - id: public + default: true + anonymous: true + operations: [get, list] + readableFields: [label] + filterableFields: [label] + sortableFields: [label] + - id: caseworker + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + operations: [get, list] + readableFields: [label, restricted-canary-field] + filterableFields: [label] + sortableFields: [label] + - id: protected-ledger + route: classified-records + mutationMode: mutable + classification: restricted + fields: + - {id: classified-status, type: vocabulary-code, vocabulary: classified-status-vocabulary, required: true, classification: restricted} + - {id: valid-from, type: date, required: true, classification: restricted} + - {id: valid-to, type: date, classification: restricted} + temporal: + startField: valid-from + endField: valid-to + scopeFields: [classified-status] + constraints: + - {kind: temporal-non-overlap, scopeFields: [classified-status], startField: valid-from, endField: valid-to} + events: + - id: classified-created-event + trigger: created + projection: [classified-status, valid-from] + webhook: + destinationId: classified-operations-destination + classificationCeiling: restricted + authenticationProfile: hmac_sha256_v1 + delivery: + attemptTimeoutMs: 5000 + initialBackoffMs: 250 + maximumBackoffMs: 2000 + maximumAttempts: 5 + deadLetter: required + operatorReplay: false + accessProfiles: + - id: caseworker + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + operations: [get, list] + readableFields: [classified-status, valid-from, valid-to] + filterableFields: [classified-status] + sortableFields: [valid-from] +vocabularies: + - id: classified-status-vocabulary + values: [sealed-canary-value, retired-canary-value] +"#; + +#[derive(Default)] +struct RecordingReadService { + calls: AtomicUsize, + refusals: AtomicUsize, + refusal_fails: AtomicBool, + requests: Mutex>, +} + +#[derive(Default)] +struct RecordingRevisionReadService { + calls: AtomicUsize, + refusals: AtomicUsize, + refusal_fails: AtomicBool, + requests: Mutex>, +} + +impl RevisionReadService for RecordingRevisionReadService { + fn detail( + &self, + request: RevisionReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.requests.lock().expect("request lock").push(request); + Box::pin(async { + Ok(Some(held(json!({ + "id": "00000000-0000-4000-8000-000000000001", + "revision": 1, + "data": {"label": "Visible label"} + })))) + }) + } + + fn list( + &self, + request: RevisionReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.requests.lock().expect("request lock").push(request); + Box::pin(async { Ok(Some(held(json!({"items": []})))) }) + } + + fn refusal( + &self, + _request: RevisionReadRefusal, + ) -> ServiceFuture<'_, Result<(), ReadServiceError>> { + self.refusals.fetch_add(1, Ordering::SeqCst); + let fail = self.refusal_fails.load(Ordering::SeqCst); + Box::pin(async move { + if fail { + Err(ReadServiceError::Unavailable) + } else { + Ok(()) + } + }) + } +} + +#[tokio::test] +async fn closed_query_grammar_reaches_record_service_as_compiled_query() { + let harness = Harness::new(true); + let accepted = harness + .send( + Method::GET, + "/v1/records/cases?fields=label&filter=label:prefix:Visible&sort=label&pageSize=25", + None, + ) + .await; + assert_eq!(accepted.status(), StatusCode::OK); + assert_eq!(accepted.headers()["cache-control"], "no-store"); + let request = harness.records.last_request(); + let query = request.query.expect("list request carries compiled query"); + assert_eq!(query.route_id, "records.case.list"); + assert_eq!(query.query_operation_id, "records.case.public.list"); + assert_eq!(query.page_size, 25); + assert_eq!(request.maximum_records, 26); + assert_eq!(query.sort.as_deref(), Some("label")); + assert_eq!(query.filters.len(), 1); + assert_eq!(query.filters[0].field, "label"); + assert_eq!( + query.filters[0].operator, + registry_server::model::CompiledQueryFilterOperator::Prefix + ); + + let before = harness.records.calls(); + let bad_operator = harness + .send( + Method::GET, + "/v1/records/cases?filter=label:range:a..z", + None, + ) + .await; + assert_eq!(bad_operator.status(), StatusCode::BAD_REQUEST); + assert_eq!(body_json(bad_operator).await["code"], "query.invalid"); + assert_eq!(harness.records.calls(), before); +} + +#[tokio::test] +async fn repeated_in_filters_are_one_deterministic_finite_set() { + let harness = Harness::new(true); + let accepted = harness + .send( + Method::GET, + "/v1/records/cases?accessProfile=caseworker&filter=jurisdiction:in:area-b&filter=jurisdiction:in:area-a", + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(accepted.status(), StatusCode::OK); + let query = harness + .records + .last_request() + .query + .expect("list request carries compiled query"); + assert_eq!(query.filters.len(), 1); + assert_eq!(query.filters[0].field, "jurisdiction"); + assert_eq!( + query.filters[0].values, + vec!["area-a".to_owned(), "area-b".to_owned()] + ); + + let mixed = harness + .send( + Method::GET, + "/v1/records/cases?accessProfile=caseworker&filter=jurisdiction:in:area-a&filter=jurisdiction:equals:area-a", + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(mixed.status(), StatusCode::BAD_REQUEST); + assert_eq!(body_json(mixed).await["code"], "query.invalid"); +} + +#[tokio::test] +async fn continuation_requests_refuse_query_overrides_before_record_io() { + let harness = Harness::new(true); + let before = harness.records.calls(); + let response = harness + .send( + Method::GET, + "/v1/records/cases?cursor=opaque-token&fields=label", + None, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(body_json(response).await["code"], "query.invalid"); + assert_eq!(harness.records.calls(), before); +} + +#[tokio::test] +async fn known_route_malformed_query_is_refusal_audited_before_response() { + let harness = Harness::new(true); + harness.records.refusal_fails.store(true, Ordering::SeqCst); + let response = harness + .send(Method::GET, "/v1/records/cases?filter=label:equals", None) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_json(response).await["code"], "source.unavailable"); + assert_eq!(harness.records.calls(), 0); + assert_eq!(harness.records.refusal_calls(), 1); +} + +impl RecordingReadService { + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + fn refusal_calls(&self) -> usize { + self.refusals.load(Ordering::SeqCst) + } + + fn last_request(&self) -> RecordReadRequest { + self.requests + .lock() + .expect("request lock") + .last() + .expect("one request") + .clone() + } + + fn record(&self, request: RecordReadRequest) { + self.calls.fetch_add(1, Ordering::SeqCst); + self.requests.lock().expect("request lock").push(request); + } +} + +impl RecordReadService for RecordingReadService { + fn get( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + let selected_fields = request.selected_fields.clone(); + self.record(request); + Box::pin(async move { + Ok(Some(held(project_fixture( + json!({ + "id": "record-1", + "revision": 1, + "data": { + "label": "Visible label", + "secret": "DO-NOT-LEAK", + "jurisdiction": "area-a" + } + }), + &selected_fields, + )))) + }) + } + + fn list( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result> { + let selected_fields = request.selected_fields.clone(); + let maximum_records = request.maximum_records; + self.record(request); + Box::pin(async move { + let mut records = vec![project_fixture( + json!({ + "id": "record-1", + "revision": 1, + "data": { + "label": "Visible label", + "secret": "DO-NOT-LEAK", + "jurisdiction": "area-a" + } + }), + &selected_fields, + )]; + records.truncate(maximum_records); + Ok(held(json!({"items": records}))) + }) + } + + fn refusal( + &self, + _request: registry_server::api::RecordReadRefusal, + ) -> ServiceFuture<'_, Result<(), ReadServiceError>> { + let fail = self.refusal_fails.load(Ordering::SeqCst); + self.refusals.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + if fail { + Err(ReadServiceError::Unavailable) + } else { + Ok(()) + } + }) + } +} + +struct ControlledReadiness(AtomicBool); + +impl ReadinessProbe for ControlledReadiness { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + let ready = self.0.load(Ordering::SeqCst); + Box::pin(async move { ready }) + } +} + +struct Harness { + app: axum::Router, + records: Arc, + readiness: Arc, +} + +impl Harness { + fn new(ready: bool) -> Self { + let project = parse_project_yaml(PROJECT.as_bytes()).expect("project parses"); + let registry = Arc::new( + compile_project(&project, &[], CompileProfile::Authoring).expect("project compiles"), + ); + let records = Arc::new(RecordingReadService::default()); + let readiness = Arc::new(ControlledReadiness(AtomicBool::new(ready))); + let app = router(Arc::new(HttpService::new( + registry, + read_identity(), + records.clone(), + readiness.clone(), + cursor_codec(), + ))); + Self { + app, + records, + readiness, + } + } + + async fn send( + &self, + method: Method, + uri: &str, + claims: Option, + ) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("request"); + if let Some(claims) = claims { + request.extensions_mut().insert(claims); + } + let mut app = self.app.clone(); + app.call(request).await.expect("response") + } +} + +fn revision_harness() -> (axum::Router, Arc) { + let project = PROJECT.replace( + "operations: [create, get, list, patch, tombstone, batch, revisions]", + "operations: [create, get, list, patch, tombstone, batch, revisions]\n revisionAccess: true", + ); + let project = parse_project_yaml(project.as_bytes()).expect("revision project parses"); + let registry = Arc::new( + compile_project(&project, &[], CompileProfile::Authoring) + .expect("revision project compiles"), + ); + let records = Arc::new(RecordingReadService::default()); + let revisions = Arc::new(RecordingRevisionReadService::default()); + let readiness = Arc::new(ControlledReadiness(AtomicBool::new(true))); + let service = HttpService::new( + registry, + read_identity(), + records, + readiness, + cursor_codec(), + ) + .with_revisions(revisions.clone()); + (router(Arc::new(service)), revisions) +} + +async fn send_to( + app: &axum::Router, + method: Method, + uri: &str, + claims: Option, +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("request"); + if let Some(claims) = claims { + request.extensions_mut().insert(claims); + } + let mut app = app.clone(); + app.call(request).await.expect("response") +} + +fn read_identity() -> ReadRuntimeIdentity { + ReadRuntimeIdentity { + package_revision: "package-read-test".to_owned(), + schema_fingerprint: "schema-read-test".to_owned(), + } +} + +fn cursor_codec() -> Arc { + Arc::new( + CursorCodec::new( + Zeroizing::new(vec![0x42; 32]), + std::time::Duration::from_secs(300), + ) + .expect("test cursor key is valid"), + ) +} + +#[test] +fn verified_context_debug_output_redacts_authority_values() { + let claims = caseworker_claims("case-management"); + let rendered = format!("{claims:?}"); + assert!(!rendered.contains("principal-value-never-rendered")); + assert!(!rendered.contains("case-management")); + assert!(!rendered.contains("area-a")); + assert!(rendered.contains("registry_principal")); +} + +#[tokio::test] +async fn health_and_readiness_are_operational_and_independent() { + let harness = Harness::new(false); + let health = harness.send(Method::GET, "/healthz", None).await; + assert_eq!(health.status(), StatusCode::OK); + assert_eq!(body_json(health).await, json!({"status": "alive"})); + + let not_ready = harness.send(Method::GET, "/ready", None).await; + assert_eq!(not_ready.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_json(not_ready).await["code"], "runtime.not_ready"); + + harness.readiness.0.store(true, Ordering::SeqCst); + let ready = harness.send(Method::GET, "/ready", None).await; + assert_eq!(ready.status(), StatusCode::OK); + assert_eq!(body_json(ready).await, json!({"status": "ready"})); + assert_eq!(harness.records.calls(), 0); +} + +#[tokio::test] +async fn profile_and_resource_concealment_complete_before_record_io() { + let harness = Harness::new(true); + let wrong_purpose = caseworker_claims("another-purpose"); + let unauthorized = harness + .send( + Method::GET, + "/v1/records/cases/record-1?accessProfile=caseworker", + Some(wrong_purpose), + ) + .await; + let unknown_profile = harness + .send( + Method::GET, + "/v1/records/cases/record-1?accessProfile=missing", + Some(caseworker_claims("case-management")), + ) + .await; + let unknown_resource = harness + .send( + Method::GET, + "/v1/records/unknown/record-1?accessProfile=caseworker", + Some(caseworker_claims("case-management")), + ) + .await; + + assert_eq!(unauthorized.status(), StatusCode::NOT_FOUND); + assert_eq!(unknown_profile.status(), StatusCode::NOT_FOUND); + assert_eq!(unknown_resource.status(), StatusCode::NOT_FOUND); + let unauthorized = body_json(unauthorized).await; + assert_eq!(unauthorized, body_json(unknown_profile).await); + assert_eq!(unauthorized, body_json(unknown_resource).await); + assert_eq!(unauthorized["code"], "resource.not_found"); + assert!(!unauthorized.to_string().contains("caseworker")); + assert!(!unauthorized.to_string().contains("another-purpose")); + assert_eq!(harness.records.calls(), 0); + + let fallback_claim = VerifiedRequestClaims::authenticated( + "sub", + "principal-value-never-rendered", + BTreeSet::from(["registry.read".to_owned()]), + Some("case-management".to_owned()), + BTreeMap::new(), + ) + .expect("verified claim fixture"); + let fallback = harness + .send( + Method::GET, + "/v1/records/cases/record-1?accessProfile=caseworker", + Some(fallback_claim), + ) + .await; + assert_eq!(fallback.status(), StatusCode::NOT_FOUND); + assert_eq!(harness.records.calls(), 0); +} + +#[tokio::test] +async fn projection_can_only_reduce_the_authorized_profile() { + let harness = Harness::new(true); + let public = harness + .send(Method::GET, "/v1/records/cases/record-1?fields=label", None) + .await; + assert_eq!(public.status(), StatusCode::OK); + let public = body_json(public).await; + assert_eq!(public["data"], json!({"label": "Visible label"})); + assert!(!public.to_string().contains("DO-NOT-LEAK")); + + let public_list = harness + .send(Method::GET, "/v1/records/cases?fields=label", None) + .await; + assert_eq!(public_list.status(), StatusCode::OK); + let public_list = body_json(public_list).await; + assert_eq!( + public_list["items"][0]["data"], + json!({"label": "Visible label"}) + ); + assert!(!public_list.to_string().contains("DO-NOT-LEAK")); + let list_request = harness.records.last_request(); + assert_eq!( + list_request.selected_fields, + BTreeSet::from(["label".to_owned()]) + ); + assert_eq!(list_request.maximum_records, 101); + + let before = harness.records.calls(); + let caller_limit = harness + .send(Method::GET, "/v1/records/cases?pageSize=101", None) + .await; + assert_eq!(caller_limit.status(), StatusCode::BAD_REQUEST); + assert_eq!(harness.records.calls(), before); + + let widening = harness + .send( + Method::GET, + "/v1/records/cases/record-1?fields=secret", + None, + ) + .await; + assert_eq!(widening.status(), StatusCode::NOT_FOUND); + assert_eq!(harness.records.calls(), before); + + let protected = harness + .send( + Method::GET, + "/v1/records/cases/record-1?accessProfile=caseworker&fields=label,secret", + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(protected.status(), StatusCode::OK); + assert_eq!( + body_json(protected).await["data"], + json!({"label": "Visible label", "secret": "DO-NOT-LEAK"}) + ); + let request = harness.records.last_request(); + assert_eq!(request.context.selected_profile(), "caseworker"); + assert_eq!(request.context.purpose(), Some("case-management")); + assert_eq!(request.context.row_boundaries().len(), 1); + assert_eq!( + request.selected_fields, + BTreeSet::from(["label".to_owned(), "secret".to_owned()]) + ); + assert_eq!(request.maximum_records, 1); + assert_eq!(request.context.row_boundaries()[0].field(), "jurisdiction"); + assert_eq!( + request.context.row_boundaries()[0].values(), + &BTreeSet::from(["area-a".to_owned(), "area-b".to_owned()]) + ); +} + +#[tokio::test] +async fn discovery_surfaces_share_caller_filtered_routes_and_fields() { + let harness = Harness::new(true); + let public_openapi = body_json(harness.send(Method::GET, "/openapi.json", None).await).await; + assert!(public_openapi["paths"].get("/v1/records/cases").is_some()); + assert!(public_openapi["paths"].get("/v1/records/notes").is_none()); + assert_eq!( + public_openapi["paths"]["/v1/records/cases"]["get"]["x-registry-queryKind"], + "list" + ); + assert_eq!( + query_parameter_names(&public_openapi["paths"]["/v1/records/cases"]["get"]["parameters"]), + [ + "accessProfile", + "cursor", + "fields", + "filter", + "pageSize", + "sort" + ] + ); + let page_size = public_openapi["paths"]["/v1/records/cases"]["get"]["parameters"] + .as_array() + .expect("query parameters are rendered") + .iter() + .find(|parameter| parameter["name"] == "pageSize") + .expect("pageSize parameter is rendered"); + assert_eq!(page_size["required"], false); + assert_eq!( + page_size["schema"], + json!({"type": "integer", "minimum": 1}) + ); + assert_eq!( + public_openapi["components"]["schemas"]["case"]["properties"], + json!({"label": {"type": "string", "minLength": 0, "maxLength": 100}}) + ); + assert_no_mutation_methods(&public_openapi); + + let public_metadata = body_json(harness.send(Method::GET, "/v1/registry", None).await).await; + assert_eq!(public_metadata["entities"].as_array().unwrap().len(), 1); + assert_eq!( + public_metadata["entities"][0]["readableFields"], + json!(["label"]) + ); + assert_eq!( + public_metadata["entities"][0]["operations"], + json!([ + {"operation": "get", "accessProfile": "public"}, + {"operation": "list", "accessProfile": "public"} + ]) + ); + + let public_schema = body_json(harness.send(Method::GET, "/v1/schemas/case", None).await).await; + assert!(public_schema["properties"].get("label").is_some()); + assert!(public_schema["properties"].get("secret").is_none()); + + let protected_openapi = body_json( + harness + .send( + Method::GET, + "/openapi.json?accessProfile=caseworker", + Some(caseworker_claims("case-management")), + ) + .await, + ) + .await; + assert!(protected_openapi["paths"] + .get("/v1/records/notes") + .is_some()); + assert!( + protected_openapi["components"]["schemas"]["case"]["properties"] + .get("secret") + .is_some() + ); + assert_no_mutation_methods(&protected_openapi); + assert_eq!(harness.records.calls(), 0); +} + +#[tokio::test] +async fn caller_filtered_discovery_conceals_counts_vocabularies_events_queries_and_every_metadata_surface( +) { + let project = + parse_project_yaml(DISCOVERY_MATRIX_PROJECT.as_bytes()).expect("matrix project parses"); + let registry = Arc::new( + compile_project(&project, &[], CompileProfile::Authoring).expect("matrix project compiles"), + ); + let protected = registry + .entities() + .get("protected-ledger") + .expect("protected entity is compiled"); + match &protected.fields["classified-status"].field_type { + registry_server::contract::FieldTypeSource::VocabularyCode { vocabulary, values } => { + assert_eq!(vocabulary, "classified-status-vocabulary"); + assert_eq!(values, &["sealed-canary-value", "retired-canary-value"]); + } + _ => panic!("protected vocabulary field retains its closed type"), + } + assert!(protected.events.contains_key("classified-created-event")); + assert_eq!(registry.event_deliveries().deliveries.len(), 1); + assert_eq!( + registry.event_deliveries().deliveries[0].destination_id, + "classified-operations-destination" + ); + assert_eq!( + registry + .queries() + .operations + .iter() + .filter(|query| query.entity_id == "protected-ledger") + .map(|query| query.id.as_str()) + .collect::>(), + BTreeSet::from([ + "records.protected-ledger.caseworker.as-of", + "records.protected-ledger.caseworker.current", + "records.protected-ledger.caseworker.list", + ]) + ); + + let records = Arc::new(RecordingReadService::default()); + let app = router(Arc::new(HttpService::new( + registry.clone(), + read_identity(), + records.clone(), + Arc::new(ControlledReadiness(AtomicBool::new(true))), + cursor_codec(), + ))); + + let public_openapi = body_json(send_to(&app, Method::GET, "/openapi.json", None).await).await; + assert_eq!( + public_openapi["paths"] + .as_object() + .expect("public paths") + .keys() + .map(String::as_str) + .collect::>(), + BTreeSet::from([ + "/v1/records/public-records", + "/v1/records/public-records/{record_id}", + ]) + ); + assert_eq!( + public_openapi["components"]["schemas"] + .as_object() + .expect("public schemas") + .len(), + 1 + ); + assert_eq!( + public_openapi["paths"]["/v1/records/public-records"]["get"]["x-registry-accessProfile"], + "public" + ); + assert_eq!( + public_openapi["components"]["schemas"]["public-record"]["properties"], + json!({"label": {"type": "string", "minLength": 0, "maxLength": 100}}) + ); + + let public_metadata = body_json(send_to(&app, Method::GET, "/v1/registry", None).await).await; + assert_eq!(public_metadata["entities"].as_array().unwrap().len(), 1); + let metadata_artifact = registry + .artifacts() + .get(REGISTRY_METADATA_ARTIFACT_PATH) + .expect("compiler emits a canonical metadata artifact"); + let metadata_artifact = + parse_json_strict(&metadata_artifact.bytes).expect("metadata artifact is strict JSON"); + assert!(metadata_artifact.get("revision").is_none()); + assert_eq!(metadata_artifact["registryId"], "discovery-matrix"); + assert_eq!(public_metadata["revision"], registry.revision()); + assert_eq!( + public_metadata["entities"][0], + metadata_response_from_inventory(®istry, "public-record", "public") + ); + let public_schema = + body_json(send_to(&app, Method::GET, "/v1/schemas/public-record", None).await).await; + assert_eq!(public_schema["properties"].as_object().unwrap().len(), 1); + assert_eq!( + public_schema["properties"], + json!({"label": {"type": "string", "minLength": 0, "maxLength": 100}}) + ); + + for document in [&public_openapi, &public_metadata, &public_schema] { + let rendered = serde_json::to_string(document).expect("discovery document serializes"); + for canary in [ + "protected-ledger", + "classified-records", + "restricted-canary-field", + "classified-status-vocabulary", + "sealed-canary-value", + "retired-canary-value", + "classified-created-event", + "classified-operations-destination", + "records.protected-ledger.caseworker.list", + "records.protected-ledger.caseworker.current", + "records.protected-ledger.caseworker.as-of", + ] { + assert!( + !rendered.contains(canary), + "public discovery leaked {canary}" + ); + } + } + + let authorized_claims = Some(caseworker_claims("case-management")); + let protected_openapi = body_json( + send_to( + &app, + Method::GET, + "/openapi.json?accessProfile=caseworker", + authorized_claims.clone(), + ) + .await, + ) + .await; + assert_eq!( + protected_openapi["paths"] + .as_object() + .expect("caseworker paths") + .len(), + 6 + ); + for path in [ + "/v1/records/classified-records", + "/v1/records/classified-records/{record_id}", + "/v1/records/classified-records:current", + "/v1/records/classified-records:as-of", + ] { + assert!(protected_openapi["paths"].get(path).is_some(), "{path}"); + assert_eq!( + protected_openapi["paths"][path]["get"]["x-registry-accessProfile"], + "caseworker" + ); + } + assert_eq!( + protected_openapi["paths"]["/v1/records/classified-records:current"]["get"] + ["x-registry-queryKind"], + "current" + ); + assert_eq!( + protected_openapi["paths"]["/v1/records/classified-records:as-of"]["get"] + ["x-registry-queryKind"], + "as_of" + ); + assert_eq!( + protected_openapi["components"]["schemas"]["protected-ledger"]["properties"] + ["classified-status"], + json!({ + "type": "string", + "enum": ["sealed-canary-value", "retired-canary-value"], + "x-registry-vocabulary": "classified-status-vocabulary" + }) + ); + assert!(protected_openapi["paths"] + .as_object() + .expect("caseworker paths") + .values() + .flat_map(|path| path.as_object().expect("path methods").values()) + .all(|operation| operation["x-registry-accessProfile"] == "caseworker")); + let protected_metadata = body_json( + send_to( + &app, + Method::GET, + "/v1/registry?accessProfile=caseworker", + authorized_claims.clone(), + ) + .await, + ) + .await; + assert_eq!(protected_metadata["entities"].as_array().unwrap().len(), 2); + assert!(protected_metadata["entities"] + .as_array() + .unwrap() + .iter() + .flat_map(|entity| entity["operations"].as_array().unwrap()) + .all(|operation| operation["accessProfile"] == "caseworker")); + let protected_ledger_metadata = protected_metadata["entities"] + .as_array() + .expect("protected metadata entities") + .iter() + .find(|entity| entity["id"] == "protected-ledger") + .expect("protected-ledger metadata is visible to caseworker"); + assert_eq!( + protected_ledger_metadata, + &metadata_response_from_inventory(®istry, "protected-ledger", "caseworker") + ); + let protected_schema = body_json( + send_to( + &app, + Method::GET, + "/v1/schemas/protected-ledger?accessProfile=caseworker", + authorized_claims.clone(), + ) + .await, + ) + .await; + assert_eq!( + protected_schema["properties"]["classified-status"], + json!({ + "type": "string", + "enum": ["sealed-canary-value", "retired-canary-value"], + "x-registry-vocabulary": "classified-status-vocabulary" + }) + ); + + let authorized_rendered = + serde_json::to_string(&[protected_openapi, protected_metadata, protected_schema]) + .expect("authorized discovery serializes"); + for omitted in [ + "classified-created-event", + "classified-operations-destination", + "records.protected-ledger.caseworker.list", + "records.protected-ledger.caseworker.current", + "records.protected-ledger.caseworker.as-of", + ] { + assert!( + !authorized_rendered.contains(omitted), + "unapproved inventory surface exposed {omitted}" + ); + } + + let missing_profile_response = send_to( + &app, + Method::GET, + "/openapi.json?accessProfile=missing-profile-canary", + authorized_claims.clone(), + ) + .await; + assert_eq!(missing_profile_response.status(), StatusCode::NOT_FOUND); + let missing_profile = body_json(missing_profile_response).await; + for uri in [ + "/openapi.json?accessProfile=caseworker", + "/v1/registry?accessProfile=caseworker", + "/v1/schemas/public-record?accessProfile=caseworker", + "/v1/schemas/protected-ledger?accessProfile=caseworker", + ] { + let response = send_to( + &app, + Method::GET, + uri, + Some(caseworker_claims("forbidden-purpose-canary")), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); + assert_eq!(body_json(response).await, missing_profile, "{uri}"); + } + + for uri in ["/v1/vocabularies", "/v1/events", "/v1/queries"] { + for claims in [None, authorized_claims.clone()] { + let response = send_to(&app, Method::GET, uri, claims).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); + assert_eq!(body_json(response).await, missing_profile, "{uri}"); + } + } + let refusal = serde_json::to_string(&missing_profile).expect("refusal serializes"); + for canary in [ + "missing-profile-canary", + "forbidden-purpose-canary", + "protected-ledger", + "classified-status-vocabulary", + "classified-created-event", + "records.protected-ledger.caseworker.list", + ] { + assert!(!refusal.contains(canary)); + } + assert_eq!(records.calls(), 0); +} + +#[tokio::test] +async fn real_router_serves_only_authorized_explicit_revision_routes() { + let (app, revisions) = revision_harness(); + let record_id = "00000000-0000-4000-8000-000000000001"; + let list_path = format!("/v1/records/cases/{record_id}/revisions"); + let detail_path = format!("{list_path}/1"); + + let public = send_to(&app, Method::GET, &list_path, None).await; + assert_eq!(public.status(), StatusCode::NOT_FOUND); + assert_eq!(revisions.calls.load(Ordering::SeqCst), 0); + + let wrong_purpose = send_to( + &app, + Method::GET, + &list_path, + Some(caseworker_claims("wrong-purpose")), + ) + .await; + assert_eq!(wrong_purpose.status(), StatusCode::NOT_FOUND); + assert_eq!(revisions.calls.load(Ordering::SeqCst), 0); + + let extra_query = send_to( + &app, + Method::GET, + &format!("{list_path}?accessProfile=caseworker&pageSize=1"), + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(extra_query.status(), StatusCode::BAD_REQUEST); + assert_eq!(body_json(extra_query).await["code"], "query.invalid"); + assert_eq!(revisions.calls.load(Ordering::SeqCst), 0); + + let list = send_to( + &app, + Method::GET, + &format!("{list_path}?accessProfile=caseworker"), + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(list.status(), StatusCode::OK); + assert_eq!(list.headers()["cache-control"], "no-store"); + let detail = send_to( + &app, + Method::GET, + &format!("{detail_path}?accessProfile=caseworker"), + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(detail.status(), StatusCode::OK); + assert_eq!(revisions.calls.load(Ordering::SeqCst), 2); + let request_shapes = { + let requests = revisions.requests.lock().expect("request lock"); + requests + .iter() + .map(|request| (request.maximum_records, request.revision)) + .collect::>() + }; + assert_eq!(request_shapes, [(100, None), (1, Some(1))]); + + let public_openapi = body_json(send_to(&app, Method::GET, "/openapi.json", None).await).await; + assert!(public_openapi["paths"] + .as_object() + .expect("paths") + .keys() + .all(|path| !path.contains("revisions"))); + let protected_openapi = body_json( + send_to( + &app, + Method::GET, + "/openapi.json?accessProfile=caseworker", + Some(caseworker_claims("case-management")), + ) + .await, + ) + .await; + assert!(protected_openapi["paths"] + .get("/v1/records/cases/{record_id}/revisions") + .is_some()); + assert!(protected_openapi["paths"] + .get("/v1/records/cases/{record_id}/revisions/{revision}") + .is_some()); + let protected_metadata = body_json( + send_to( + &app, + Method::GET, + "/v1/registry?accessProfile=caseworker", + Some(caseworker_claims("case-management")), + ) + .await, + ) + .await; + let case = protected_metadata["entities"] + .as_array() + .expect("entities") + .iter() + .find(|entity| entity["id"] == "case") + .expect("case metadata"); + assert!(case["operations"] + .as_array() + .expect("operations") + .iter() + .any(|operation| operation["operation"] == "revisions")); + assert!(revisions.refusals.load(Ordering::SeqCst) >= 3); + + revisions.refusal_fails.store(true, Ordering::SeqCst); + let audit_failure = send_to( + &app, + Method::GET, + &format!("{list_path}?pageSize=2"), + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(audit_failure.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_json(audit_failure).await["code"], "source.unavailable"); + assert_eq!(revisions.calls.load(Ordering::SeqCst), 2); +} + +fn query_parameter_names(parameters: &Value) -> Vec { + let mut names = parameters + .as_array() + .expect("parameters are an array") + .iter() + .map(|parameter| { + parameter["name"] + .as_str() + .expect("parameter has a name") + .to_owned() + }) + .collect::>(); + names.sort(); + names +} + +fn metadata_response_from_inventory( + registry: ®istry_server::CompiledRegistry, + entity_id: &str, + access_profile: &str, +) -> Value { + let metadata_entity = registry + .metadata() + .entities + .iter() + .find(|entity| entity.id == entity_id) + .expect("compiled metadata entity exists"); + let mut operations = BTreeMap::new(); + let mut readable_fields: Option> = None; + for entry in metadata_entity + .entries + .iter() + .filter(|entry| entry.access_profile == access_profile) + { + operations.insert(entry.operation, entry.access_profile.clone()); + readable_fields = Some(match readable_fields { + Some(fields) => fields + .intersection(&entry.readable_fields) + .cloned() + .collect(), + None => entry.readable_fields.clone(), + }); + } + json!({ + "id": metadata_entity.id, + "route": metadata_entity.route, + "operations": operations.into_iter().map(|(operation, access_profile)| json!({ + "operation": operation_name(operation), + "accessProfile": access_profile, + })).collect::>(), + "readableFields": readable_fields.expect("access profile has metadata entries"), + "schema": metadata_entity.schema_path, + }) +} + +fn operation_name(operation: Operation) -> &'static str { + match operation { + Operation::Get => "get", + Operation::List => "list", + Operation::Create => "create", + Operation::Patch => "patch", + Operation::Tombstone => "tombstone", + Operation::Batch => "batch", + Operation::Revisions => "revisions", + } +} + +#[tokio::test] +async fn every_compiled_mutation_route_is_absent_from_the_served_router() { + let harness = Harness::new(true); + let claims = Some(caseworker_claims("case-management")); + for (method, uri) in [ + (Method::POST, "/v1/records/cases"), + (Method::PATCH, "/v1/records/cases/record-1"), + (Method::DELETE, "/v1/records/cases/record-1"), + (Method::POST, "/v1/records/cases:batch"), + (Method::GET, "/v1/records/cases/record-1/revisions"), + (Method::POST, "/v1/records/notes"), + (Method::PATCH, "/v1/records/notes/record-1"), + (Method::DELETE, "/v1/records/notes/record-1"), + ] { + let response = harness.send(method, uri, claims.clone()).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); + assert_eq!(body_json(response).await["code"], "resource.not_found"); + } + assert_eq!(harness.records.calls(), 0); +} + +fn caseworker_claims(purpose: &str) -> VerifiedRequestClaims { + VerifiedRequestClaims::authenticated( + "registry_principal", + "principal-value-never-rendered", + BTreeSet::from(["registry.read".to_owned()]), + Some(purpose.to_owned()), + BTreeMap::from([( + "jurisdictions".to_owned(), + VerifiedClaimValue::direct_string_set(["area-a", "area-b"]).expect("direct claims"), + )]), + ) + .expect("verified context") +} + +fn project_fixture(mut record: Value, selected_fields: &BTreeSet) -> Value { + record["data"] + .as_object_mut() + .expect("fixture data is an object") + .retain(|field, _| selected_fields.contains(field)); + record +} + +fn held(value: Value) -> HeldReadResponse { + HeldReadResponse::from_json(&value).expect("fake read response serializes") +} + +async fn body_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response body"); + serde_json::from_slice(&bytes).expect("JSON response") +} + +fn assert_no_mutation_methods(document: &Value) { + for path in document["paths"].as_object().unwrap().values() { + let methods = path.as_object().unwrap(); + assert!(methods.get("post").is_none()); + assert!(methods.get("patch").is_none()); + assert!(methods.get("delete").is_none()); + } +} diff --git a/crates/registry-server/tests/migration_plan.rs b/crates/registry-server/tests/migration_plan.rs new file mode 100644 index 0000000000..5cf78f7c73 --- /dev/null +++ b/crates/registry-server/tests/migration_plan.rs @@ -0,0 +1,878 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(all(feature = "runtime", feature = "tooling"))] + +use std::fs; + +use registry_platform_canonical_json::canonicalize_json; +use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::migration_plan::{ + ArtifactDigestBinding, ChunkCursorProtocol, ExternalBackupBinding, MigrationRehearsalReceipt, + RehearsalFixture, RehearsalProofs, RehearsalRowAssertion, ReviewedChangeCover, + ReviewedMigrationAssertionDescriptor, ReviewedMigrationDescriptor, ReviewedMigrationFile, + ReviewedMigrationObject, ReviewedMigrationObjectKind, ReviewedMigrationRecovery, + ReviewedMigrationSource, ReviewedMigrationStepDescriptor, +}; +use registry_server::package::{ + compiled_registry_change_set, inspect_package_integrity, prepare_package, + CompiledRegistryChangeClass, CompiledRegistryChangeCode, PackageBuildRequest, PackageError, + PackageFileRole, PackageMigrationPlanInput, PackageModuleSource, PackageSourceFile, + SignaturePolicy, +}; +use registry_server::CompiledRegistry; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +const INSTANCE: &str = "instance-under-test"; +const DATABASE: &str = "database-under-test"; +const SOURCE_REVISION: &str = "compiler-source-revision"; +const FIXTURE_JOURNEYS: &[u8] = br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: asset-list + steps: + - id: list-assets + entity: asset + accessProfile: reader + claims: {principal: package-reader} + request: {operation: list} + expect: {outcome: success, status: 200, count: 0} +"#; +const PRIOR_REVISION: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +const PRIOR_FINGERPRINT: &str = + "sha256:2222222222222222222222222222222222222222222222222222222222222222"; +const FINAL_FINGERPRINT: &str = + "sha256:3333333333333333333333333333333333333333333333333333333333333333"; + +#[test] +fn reviewed_migration_plan_closes_ast_sql_and_bound_evidence() { + let previous = compile_variant(Variant::Base, 1); + let candidate = compile_variant(Variant::RequiredField, 2); + let artifacts = backfill_artifacts("required-field", &previous, &candidate); + let prepared = + prepare_reviewed_package(Variant::RequiredField, previous, vec![artifacts.source()]) + .expect("reviewed successor package prepares"); + + assert!(prepared.manifest().migration_plan.statements.is_empty()); + assert_eq!( + prepared.manifest().migration_plan.reviewed_descriptors, + vec!["modules/core/migrations/required-field/descriptor.json"] + ); + assert_eq!( + prepared + .manifest() + .migration_plan + .prior_schema_fingerprint + .as_deref(), + Some(PRIOR_FINGERPRINT) + ); + for role in [ + PackageFileRole::ReviewedMigrationDescriptor, + PackageFileRole::ReviewedMigrationStepSql, + PackageFileRole::ReviewedMigrationAssertionSql, + PackageFileRole::MigrationRehearsalReceipt, + PackageFileRole::MigrationRehearsalFixture, + ] { + assert!(prepared + .manifest() + .files + .iter() + .any(|file| file.role == role)); + } + + let root = tempfile::Builder::new() + .prefix("registry-migration-plan-") + .tempdir_in("/private/tmp") + .expect("temporary package parent"); + let package = root.path().join("package"); + prepared + .publish_to_directory(&package, Vec::new()) + .expect("reviewed package publishes"); + let inspected = inspect_package_integrity(&package).expect("reviewed package rederives"); + assert_eq!(inspected.package_revision(), prepared.package_revision()); + + let destructive_candidate = compile_variant(Variant::FieldRemoved, 2); + let destructive = destructive_artifacts( + "remove-field", + &compile_variant(Variant::Base, 1), + &destructive_candidate, + ); + let destructive_package = prepare_reviewed_package( + Variant::FieldRemoved, + compile_variant(Variant::Base, 1), + vec![destructive.source()], + ) + .expect("destructive plan with exact backup binding prepares"); + assert!(destructive_package + .manifest() + .files + .iter() + .any(|file| file.role == PackageFileRole::ExternalBackupBinding)); +} + +#[test] +fn reviewed_migration_plan_rejects_uncovered_changes_forbidden_sql_and_unbound_evidence() { + let previous = compile_variant(Variant::Base, 1); + let candidate = compile_variant(Variant::RequiredField, 2); + let valid = backfill_artifacts("required-field", &previous, &candidate); + + assert_refused( + Variant::RequiredField, + previous.clone(), + Vec::new(), + "uncovered non-additive change", + ); + + let mut uncovered = valid.clone(); + uncovered.descriptor.covers.clear(); + uncovered.rebind(); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![uncovered.source()], + "missing covers set", + ); + + let mut orphan = valid.clone(); + orphan.descriptor.covers[0].code = CompiledRegistryChangeCode::EntityRemoved; + orphan.rebind(); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![orphan.source()], + "orphan cover", + ); + + let duplicate = backfill_artifacts("required-field-two", &previous, &candidate); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![valid.source(), duplicate.source()], + "duplicate cover", + ); + + let mut mismatch = valid.clone(); + mismatch.descriptor.change_class = CompiledRegistryChangeClass::DestructiveOrIrreversible; + mismatch.rebind(); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![mismatch.source()], + "class-mismatched cover", + ); + + let unsupported_previous = compile_variant(Variant::Base, 1); + let unsupported_candidate = compile_variant(Variant::DifferentRegistry, 2); + let mut unsupported = backfill_artifacts( + "unsupported-identity", + &unsupported_previous, + &unsupported_candidate, + ); + unsupported.descriptor.change_class = CompiledRegistryChangeClass::Unsupported; + unsupported.descriptor.covers = vec![ReviewedChangeCover::from( + &compiled_registry_change_set( + &unsupported_previous, + &unsupported_candidate, + PRIOR_REVISION, + ) + .changes[0], + )]; + unsupported.rebind(); + assert_refused( + Variant::DifferentRegistry, + unsupported_previous, + vec![unsupported.source()], + "unsupported compiler change", + ); + + let table = candidate.entities()["asset"].physical_table.clone(); + let site_table = candidate.entities()["site"].physical_table.clone(); + let site_code = candidate.entities()["site"].fields["code"] + .physical_name + .clone(); + let asset_rank = candidate.entities()["asset"].fields["rank"] + .physical_name + .clone(); + for (label, sql) in [ + ( + "multiple statements", + format!( + "UPDATE registry_data.{table} SET f_batch = 'x' WHERE record_id = ANY($1::pg_catalog.uuid[]); UPDATE registry_data.{table} SET f_batch = 'y' WHERE record_id = ANY($1::pg_catalog.uuid[])" + ), + ), + ("transaction", "BEGIN".to_owned()), + ("set", "SET search_path = registry_data".to_owned()), + ("role", "ALTER ROLE current_user SUPERUSER".to_owned()), + ("database", "CREATE DATABASE forbidden".to_owned()), + ("schema", "CREATE SCHEMA forbidden".to_owned()), + ( + "extension", + "CREATE EXTENSION IF NOT EXISTS pgcrypto".to_owned(), + ), + ( + "copy program", + format!("COPY registry_data.{table} TO PROGRAM 'canary-secret'"), + ), + ( + "temporary table", + "CREATE TEMP TABLE registry_data.forbidden(id integer)".to_owned(), + ), + ( + "function", + "CREATE FUNCTION registry_data.forbidden() RETURNS void LANGUAGE sql AS 'SELECT 1'" + .to_owned(), + ), + ( + "procedure", + "CREATE PROCEDURE registry_data.forbidden() LANGUAGE sql AS 'SELECT 1'".to_owned(), + ), + ( + "trigger", + format!( + "CREATE TRIGGER forbidden BEFORE UPDATE ON registry_data.{table} EXECUTE FUNCTION registry_data.forbidden()" + ), + ), + ( + "concurrent index", + format!("CREATE INDEX CONCURRENTLY forbidden ON registry_data.{table}(record_id)"), + ), + ( + "unqualified object", + format!( + "UPDATE {table} SET f_batch = 'x' WHERE record_id = ANY($1::pg_catalog.uuid[])" + ), + ), + ( + "product-owned schema", + "UPDATE registry_internal.registry_state SET status = 'ready' WHERE record_id = ANY($1::pg_catalog.uuid[])".to_owned(), + ), + ( + "undeclared object", + "UPDATE registry_data.not_declared SET value = 'x' WHERE record_id = ANY($1::pg_catalog.uuid[])".to_owned(), + ), + ( + "insert outside minimal DML", + format!("INSERT INTO registry_data.{table}(record_id) VALUES (gen_random_uuid())"), + ), + ( + "wrong cursor parameter", + format!( + "UPDATE registry_data.{table} SET f_batch = 'x' WHERE record_id = ANY($2::pg_catalog.uuid[])" + ), + ), + ( + "infrastructure record identifier write", + format!( + "UPDATE registry_data.{table} SET record_id = $1 WHERE record_id = ANY($1::pg_catalog.uuid[])" + ), + ), + ( + "infrastructure lifecycle write", + format!( + "UPDATE registry_data.{table} SET record_revision = 4 WHERE record_id = ANY($1::pg_catalog.uuid[])" + ), + ), + ( + "unrelated domain column", + format!( + "UPDATE registry_data.{table} SET {asset_rank} = 4 WHERE record_id = ANY($1::pg_catalog.uuid[])" + ), + ), + ( + "cross-entity managed table substitution", + format!( + "UPDATE registry_data.{site_table} SET {site_code} = 'x' WHERE record_id = ANY($1::pg_catalog.uuid[])" + ), + ), + ( + "unbound cursor type", + format!( + "UPDATE registry_data.{table} SET f_batch = 'x' WHERE record_id = ANY($1::uuid[])" + ), + ), + ] { + let mut forbidden = valid.clone(); + forbidden.step_sql = sql.into_bytes(); + forbidden.rebind(); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![forbidden.source()], + label, + ); + } + + let mut unbounded_dml = valid.clone(); + let objects = unbounded_dml.descriptor.steps[0].objects().to_vec(); + unbounded_dml.descriptor.steps[0] = ReviewedMigrationStepDescriptor::TransactionalSql { + id: "backfill".to_owned(), + sql_path: unbounded_dml.descriptor.steps[0].sql_path().to_owned(), + objects, + affected_rows: None, + }; + unbounded_dml.rebind(); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![unbounded_dml.source()], + "DML without affected-row bounds", + ); + + let mut non_boolean_assertion = valid.clone(); + non_boolean_assertion.pre_sql = b"SELECT 1".to_vec(); + non_boolean_assertion.rebind(); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![non_boolean_assertion.source()], + "assertion is not one declared read-only boolean SELECT", + ); + + let mut object_mismatch = valid.clone(); + object_mismatch.descriptor.steps[0].objects_mut()[0].member_id = Some("rank".to_owned()); + object_mismatch.rebind(); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![object_mismatch.source()], + "descriptor cover and parsed object inventory mismatch", + ); + + let mut unbound = valid.clone(); + unbound.receipt.final_schema_fingerprint = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![unbound.source()], + "rehearsal evidence bound to wrong target", + ); + + let destructive_candidate = compile_variant(Variant::FieldRemoved, 2); + let destructive = destructive_artifacts("remove-field", &previous, &destructive_candidate); + let mut no_backup = destructive.clone(); + no_backup.descriptor.backup_binding_path = None; + no_backup.backup = None; + no_backup.rebind(); + assert_refused( + Variant::FieldRemoved, + previous.clone(), + vec![no_backup.source()], + "destructive plan without external backup binding", + ); + + let mut wrong_backup = destructive; + wrong_backup + .backup + .as_mut() + .expect("destructive backup exists") + .database_id = "wrong-database-canary".to_owned(); + wrong_backup.rebind(); + assert_refused( + Variant::FieldRemoved, + previous.clone(), + vec![wrong_backup.source()], + "external backup bound to a different database", + ); + + let mut missing_fixture = valid.source(); + missing_fixture + .files + .retain(|file| !file.path.ends_with("fixtures/representative.jsonl")); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![missing_fixture], + "missing rehearsal fixture bytes", + ); + + let mut substituted_fixture = valid.source(); + substituted_fixture + .files + .iter_mut() + .find(|file| file.path.ends_with("fixtures/representative.jsonl")) + .expect("fixture exists") + .bytes = b"{\"fixture\":\"substituted-canary\"}\n".to_vec(); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![substituted_fixture], + "substituted rehearsal fixture bytes", + ); + + let mut extra_fixture = valid.source(); + extra_fixture.files.push(ReviewedMigrationFile { + path: "modules/core/migrations/required-field/fixtures/extra.jsonl".to_owned(), + bytes: b"{\"fixture\":\"extra-canary\"}\n".to_vec(), + }); + extra_fixture + .files + .sort_by(|left, right| left.path.cmp(&right.path)); + assert_refused( + Variant::RequiredField, + previous.clone(), + vec![extra_fixture], + "unbound extra rehearsal fixture", + ); + + let prepared = prepare_reviewed_package(Variant::RequiredField, previous, vec![valid.source()]) + .expect("valid reviewed package prepares before tamper"); + let root = tempfile::Builder::new() + .prefix("registry-migration-plan-") + .tempdir_in("/private/tmp") + .expect("temporary package parent"); + let package = root.path().join("package"); + prepared + .publish_to_directory(&package, Vec::new()) + .expect("valid reviewed package publishes"); + fs::write( + package.join("modules/core/migrations/required-field/steps/backfill.sql"), + b"SELECT 'source-path-record-sql-canary'", + ) + .expect("tamper reviewed SQL"); + assert_eq!( + inspect_package_integrity(&package).err(), + Some(PackageError::Integrity), + "hash-covered reviewed SQL tampering must refuse before disclosure" + ); +} + +#[derive(Clone)] +struct ReviewedArtifacts { + descriptor: ReviewedMigrationDescriptor, + receipt: MigrationRehearsalReceipt, + step_sql: Vec, + pre_sql: Vec, + post_sql: Vec, + backup: Option, + fixture_bytes: Vec, +} + +impl ReviewedArtifacts { + fn rebind(&mut self) { + let descriptor_bytes = canonical(&self.descriptor); + self.receipt.fixture_inventory[0].path = format!( + "modules/core/migrations/{}/fixtures/representative.jsonl", + self.descriptor.id + ); + self.receipt.plan_sha256 = digest(&descriptor_bytes); + self.receipt.sql_sha256 = vec![ArtifactDigestBinding { + path: self.descriptor.steps[0].sql_path().to_owned(), + sha256: digest(&self.step_sql), + }]; + self.receipt.assertion_sha256 = vec![ + ArtifactDigestBinding { + path: self.descriptor.pre_assertions[0].sql_path.clone(), + sha256: digest(&self.pre_sql), + }, + ArtifactDigestBinding { + path: self.descriptor.post_assertions[0].sql_path.clone(), + sha256: digest(&self.post_sql), + }, + ]; + self.receipt.fixture_inventory[0].sha256 = digest(&self.fixture_bytes); + self.receipt.fixture_inventory[0].row_count = self + .fixture_bytes + .iter() + .filter(|byte| **byte == b'\n') + .count() as u64; + } + + fn source(&self) -> ReviewedMigrationSource { + let descriptor_path = format!( + "modules/core/migrations/{}/descriptor.json", + self.descriptor.id + ); + let mut files = vec![ + ReviewedMigrationFile { + path: self.descriptor.steps[0].sql_path().to_owned(), + bytes: self.step_sql.clone(), + }, + ReviewedMigrationFile { + path: self.descriptor.pre_assertions[0].sql_path.clone(), + bytes: self.pre_sql.clone(), + }, + ReviewedMigrationFile { + path: self.descriptor.post_assertions[0].sql_path.clone(), + bytes: self.post_sql.clone(), + }, + ReviewedMigrationFile { + path: self.descriptor.rehearsal_receipt_path.clone(), + bytes: canonical(&self.receipt), + }, + ReviewedMigrationFile { + path: self.receipt.fixture_inventory[0].path.clone(), + bytes: self.fixture_bytes.clone(), + }, + ]; + if let (Some(path), Some(binding)) = (&self.descriptor.backup_binding_path, &self.backup) { + files.push(ReviewedMigrationFile { + path: path.clone(), + bytes: canonical(binding), + }); + } + files.sort_by(|left, right| left.path.cmp(&right.path)); + ReviewedMigrationSource { + module_id: "core".to_owned(), + descriptor: ReviewedMigrationFile { + path: descriptor_path, + bytes: canonical(&self.descriptor), + }, + files, + } + } +} + +trait StepPath { + fn sql_path(&self) -> &str; + fn objects(&self) -> &[ReviewedMigrationObject]; + fn objects_mut(&mut self) -> &mut [ReviewedMigrationObject]; +} + +impl StepPath for ReviewedMigrationStepDescriptor { + fn sql_path(&self) -> &str { + match self { + Self::TransactionalSql { sql_path, .. } | Self::ChunkedBackfill { sql_path, .. } => { + sql_path + } + } + } + + fn objects(&self) -> &[ReviewedMigrationObject] { + match self { + Self::TransactionalSql { objects, .. } | Self::ChunkedBackfill { objects, .. } => { + objects + } + } + } + + fn objects_mut(&mut self) -> &mut [ReviewedMigrationObject] { + match self { + Self::TransactionalSql { objects, .. } | Self::ChunkedBackfill { objects, .. } => { + objects + } + } + } +} + +fn backfill_artifacts( + id: &str, + previous: &CompiledRegistry, + candidate: &CompiledRegistry, +) -> ReviewedArtifacts { + let change_set = compiled_registry_change_set(previous, candidate, PRIOR_REVISION); + let change = change_set + .changes + .iter() + .find(|change| change.code == CompiledRegistryChangeCode::FieldAddedRequired) + .unwrap_or(&change_set.changes[0]); + let entity = &candidate.entities()["asset"]; + let field = entity + .fields + .get("batch") + .or_else(|| entity.fields.get("rank")) + .expect("reviewed target field exists"); + let base = format!("modules/core/migrations/{id}"); + let step_path = format!("{base}/steps/backfill.sql"); + let pre_path = format!("{base}/assertions/pre.sql"); + let post_path = format!("{base}/assertions/post.sql"); + let step_sql = format!( + "UPDATE registry_data.{} SET {} = 'reviewed-default' WHERE record_id = ANY($1::pg_catalog.uuid[])", + entity.physical_table, field.physical_name + ) + .into_bytes(); + let assertion_sql = format!( + "SELECT pg_catalog.count(*) >= 0 FROM registry_data.{}", + entity.physical_table + ) + .into_bytes(); + let descriptor = ReviewedMigrationDescriptor { + id: id.to_owned(), + change_class: change.class, + covers: vec![ReviewedChangeCover::from(change)], + recovery: ReviewedMigrationRecovery::ExactTargetResume, + lock_timeout_ms: 10_000, + statement_timeout_ms: 60_000, + steps: vec![ReviewedMigrationStepDescriptor::ChunkedBackfill { + id: "backfill".to_owned(), + entity_id: "asset".to_owned(), + sql_path: step_path, + objects: vec![ReviewedMigrationObject { + schema: "registry_data".to_owned(), + table: entity.physical_table.clone(), + entity_id: "asset".to_owned(), + kind: ReviewedMigrationObjectKind::Field, + member_id: Some("batch".to_owned()), + physical_name: field.physical_name.clone(), + }], + cursor: ChunkCursorProtocol::RecordIdUuidArray, + chunk_size: 100, + max_total_rows: 1_000, + lock_timeout_ms: 1_000, + statement_timeout_ms: 10_000, + exact_affected_rows: true, + }], + pre_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "pre".to_owned(), + sql_path: pre_path, + }], + post_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "post".to_owned(), + sql_path: post_path, + }], + rehearsal_receipt_path: format!("{base}/rehearsal.json"), + backup_binding_path: None, + }; + let mut artifacts = ReviewedArtifacts { + descriptor, + receipt: receipt( + false, + true, + vec![RehearsalRowAssertion { + step_id: "backfill".to_owned(), + affected_rows: 10, + }], + ), + step_sql, + pre_sql: assertion_sql.clone(), + post_sql: assertion_sql, + backup: None, + fixture_bytes: b"{\"fixture\":\"representative\"}\n".to_vec(), + }; + artifacts.rebind(); + artifacts +} + +fn destructive_artifacts( + id: &str, + previous: &CompiledRegistry, + candidate: &CompiledRegistry, +) -> ReviewedArtifacts { + let change_set = compiled_registry_change_set(previous, candidate, PRIOR_REVISION); + let change = change_set + .changes + .iter() + .find(|change| change.code == CompiledRegistryChangeCode::FieldRemoved) + .expect("field removal is classified"); + let entity = &previous.entities()["asset"]; + let field = &entity.fields["rank"]; + let base = format!("modules/core/migrations/{id}"); + let step_path = format!("{base}/steps/drop-field.sql"); + let pre_path = format!("{base}/assertions/pre.sql"); + let post_path = format!("{base}/assertions/post.sql"); + let assertion_sql = format!( + "SELECT pg_catalog.count(*) >= 0 FROM registry_data.{}", + entity.physical_table + ) + .into_bytes(); + let descriptor = ReviewedMigrationDescriptor { + id: id.to_owned(), + change_class: CompiledRegistryChangeClass::DestructiveOrIrreversible, + covers: vec![ReviewedChangeCover::from(change)], + recovery: ReviewedMigrationRecovery::ExactTargetResume, + lock_timeout_ms: 10_000, + statement_timeout_ms: 60_000, + steps: vec![ReviewedMigrationStepDescriptor::TransactionalSql { + id: "drop-field".to_owned(), + sql_path: step_path, + objects: vec![ReviewedMigrationObject { + schema: "registry_data".to_owned(), + table: entity.physical_table.clone(), + entity_id: "asset".to_owned(), + kind: ReviewedMigrationObjectKind::Field, + member_id: Some("rank".to_owned()), + physical_name: field.physical_name.clone(), + }], + affected_rows: None, + }], + pre_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "pre".to_owned(), + sql_path: pre_path, + }], + post_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "post".to_owned(), + sql_path: post_path, + }], + rehearsal_receipt_path: format!("{base}/rehearsal.json"), + backup_binding_path: Some(format!("{base}/backup.json")), + }; + let mut artifacts = ReviewedArtifacts { + descriptor, + receipt: receipt(true, false, Vec::new()), + step_sql: format!( + "ALTER TABLE registry_data.{} DROP COLUMN {}", + entity.physical_table, field.physical_name + ) + .into_bytes(), + pre_sql: assertion_sql.clone(), + post_sql: assertion_sql, + backup: Some(ExternalBackupBinding { + database_id: DATABASE.to_owned(), + prior_revision: PRIOR_REVISION.to_owned(), + prior_schema_fingerprint: PRIOR_FINGERPRINT.to_owned(), + sha256: "sha256:4444444444444444444444444444444444444444444444444444444444444444" + .to_owned(), + byte_length: 4096, + created_at: "2026-08-30T00:00:00Z".to_owned(), + max_age_seconds: 86_400, + }), + fixture_bytes: b"{\"fixture\":\"representative\"}\n".to_vec(), + }; + artifacts.rebind(); + artifacts +} + +fn receipt( + destructive_resume: bool, + chunk_resume: bool, + row_assertions: Vec, +) -> MigrationRehearsalReceipt { + MigrationRehearsalReceipt { + prior_revision: PRIOR_REVISION.to_owned(), + prior_schema_fingerprint: PRIOR_FINGERPRINT.to_owned(), + plan_sha256: String::new(), + sql_sha256: Vec::new(), + assertion_sha256: Vec::new(), + fixture_inventory: vec![RehearsalFixture { + id: "representative".to_owned(), + path: String::new(), + sha256: String::new(), + row_count: 0, + }], + postgres_major: 17, + row_assertions, + final_schema_fingerprint: FINAL_FINGERPRINT.to_owned(), + proofs: RehearsalProofs { + lock_timeout: true, + chunk_resume, + destructive_resume, + }, + } +} + +fn assert_refused( + candidate_variant: Variant, + previous: CompiledRegistry, + migrations: Vec, + label: &str, +) { + let result = prepare_reviewed_package(candidate_variant, previous, migrations); + assert_eq!( + result.err(), + Some(PackageError::MigrationPlan), + "{label} must be refused with a value-free error" + ); +} + +fn prepare_reviewed_package( + candidate_variant: Variant, + previous: CompiledRegistry, + migrations: Vec, +) -> registry_server::package::Result { + let source = source_for_variant(candidate_variant, 2); + prepare_package(PackageBuildRequest { + environment: "local".to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + sequence: 2, + prior_revision: Some(PRIOR_REVISION.to_owned()), + compiler_source_revision: SOURCE_REVISION.to_owned(), + schema_fingerprint: FINAL_FINGERPRINT.to_owned(), + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: source.project_bytes, + }, + modules: vec![PackageModuleSource { + id: "core".to_owned(), + path: "source/modules/core/module.yaml".to_owned(), + bytes: source.module_bytes, + }], + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: FIXTURE_JOURNEYS.to_vec(), + }, + migration_plan: PackageMigrationPlanInput::ReviewedSuccessor { + prior_registry: Box::new(previous), + prior_schema_fingerprint: PRIOR_FINGERPRINT.to_owned(), + migrations, + }, + }) +} + +#[derive(Clone, Copy)] +enum Variant { + Base, + RequiredField, + FieldRemoved, + DifferentRegistry, +} + +struct SourceFixture { + project_bytes: Vec, + module_bytes: Vec, +} + +fn compile_variant(variant: Variant, sequence: u64) -> CompiledRegistry { + let source = source_for_variant(variant, sequence); + let module = parse_module_yaml(&source.module_bytes).expect("fixture module parses"); + let project = parse_project_yaml(&source.project_bytes).expect("fixture project parses"); + compile_project(&project, &[module], CompileProfile::Production) + .expect("fixture compiles in production") +} + +fn source_for_variant(variant: Variant, sequence: u64) -> SourceFixture { + let module_bytes = module_bytes(variant); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses for digest"); + let module_digest = module_digest(&module); + let registry_id = if matches!(variant, Variant::DifferentRegistry) { + "different-registry" + } else { + "neutral-registry" + }; + SourceFixture { + project_bytes: format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"{registry_id}","version":"1","defaultLanguage":"en"}},"package":{{"environment":"local","instanceId":"{INSTANCE}","sequence":{sequence},"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"name":"Package Test Publisher"}}}},"dataset":{{"title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}}},"modules":[{{"id":"core","version":"1","digest":"{module_digest}"}}]}}"# + ) + .into_bytes(), + module_bytes, + } +} + +fn module_bytes(variant: Variant) -> Vec { + let fields = match variant { + Variant::RequiredField => { + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"batch","type":"string","maxLength":16,"required":true,"classification":"internal"}"# + } + Variant::FieldRemoved => { + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"}"# + } + Variant::Base | Variant::DifferentRegistry => { + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"}"# + } + }; + format!( + r#"{{"id":"core","version":"1","entities":[{{"id":"asset","route":"assets","mutationMode":"create_only","fields":[{fields}],"accessProfiles":[{{"id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}}]}},{{"id":"site","route":"sites","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}}],"accessProfiles":[{{"id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}}]}}]}}"# + ) + .into_bytes() +} + +fn canonical(value: &impl Serialize) -> Vec { + canonicalize_json(&serde_json::to_value(value).expect("test value serializes")) + .expect("test value canonicalizes") +} + +fn digest(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut result = String::with_capacity(71); + result.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + write!(&mut result, "{byte:02x}").expect("writing to String cannot fail"); + } + result +} diff --git a/crates/registry-server/tests/package_change_plan.rs b/crates/registry-server/tests/package_change_plan.rs new file mode 100644 index 0000000000..91d6a6b096 --- /dev/null +++ b/crates/registry-server/tests/package_change_plan.rs @@ -0,0 +1,1402 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "runtime")] + +#[cfg(feature = "tooling")] +use std::fs; + +use registry_platform_canonical_json::canonicalize_json; +use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +#[cfg(feature = "tooling")] +use registry_server::migration_plan::{ + ArtifactDigestBinding, ChunkCursorProtocol, ExternalBackupBinding, MigrationRehearsalReceipt, + RehearsalFixture, RehearsalProofs, RehearsalRowAssertion, ReviewedChangeCover, + ReviewedMigrationAssertionDescriptor, ReviewedMigrationDescriptor, ReviewedMigrationFile, + ReviewedMigrationObject, ReviewedMigrationObjectKind, ReviewedMigrationRecovery, + ReviewedMigrationSource, ReviewedMigrationStepDescriptor, +}; +use registry_server::package::{ + change_set_to_applicable_migration_plan, compiled_registry_change_set, + CompiledRegistryChangeClass, CompiledRegistryChangeCode, PackageBuildRequest, + PackageMigrationPlanInput, PackageModuleSource, PackageSourceFile, SignaturePolicy, +}; +#[cfg(feature = "tooling")] +use registry_server::package::{inspect_package_integrity, prepare_package, PreparedPackage}; +use registry_server::CompiledRegistry; +#[cfg(feature = "tooling")] +use serde::Serialize; +#[cfg(feature = "tooling")] +use serde_json::json; +#[cfg(feature = "tooling")] +use sha2::{Digest, Sha256}; + +const INSTANCE: &str = "instance-under-test"; +const DATABASE: &str = "database-under-test"; +const SOURCE_REVISION: &str = "compiler-source-revision"; +const FIXTURE_JOURNEYS: &[u8] = br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: asset-list + steps: + - id: list-assets + entity: asset + accessProfile: reader + claims: {principal: package-reader} + request: {operation: list} + expect: {outcome: success, status: 200, count: 0} +"#; +const PRIOR_REVISION: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +#[cfg(feature = "tooling")] +const PRIOR_FINGERPRINT: &str = + "sha256:3333333333333333333333333333333333333333333333333333333333333333"; +#[cfg(feature = "tooling")] +const FINAL_FINGERPRINT: &str = + "sha256:2222222222222222222222222222222222222222222222222222222222222222"; +#[cfg(feature = "tooling")] +const SUMMARY_CANARY: &str = "summary-canary"; +#[cfg(feature = "tooling")] +const SQL_CANARY: &str = "summary-sql-canary"; + +#[test] +fn new_optional_scalar_field_emits_only_closed_add_column() { + let previous = compile_variant(Variant::Base, 1); + let candidate = compile_variant(Variant::OptionalField, 2); + + let change_set = compiled_registry_change_set(&previous, &candidate, PRIOR_REVISION); + assert_eq!(change_set.changes.len(), 1); + assert_change( + &change_set, + CompiledRegistryChangeClass::CompatibleAdditive, + CompiledRegistryChangeCode::FieldAddedOptional, + ); + let plan = change_set_to_applicable_migration_plan(&change_set) + .expect("optional field change is applicable"); + assert_eq!(plan.from_revision.as_deref(), Some(PRIOR_REVISION)); + assert_eq!( + plan.prior_baseline + .as_ref() + .map(|baseline| baseline.package_revision.as_str()), + Some(PRIOR_REVISION) + ); + assert_eq!(plan.changes, change_set.changes); + assert_eq!(plan.statements.len(), 1); + assert_eq!(plan.statements[0].id, "entity.asset.field.color.column"); + assert!(plan.statements[0] + .sql + .starts_with("ALTER TABLE registry_data.")); + assert!(plan.statements[0].sql.contains(" ADD COLUMN ")); + assert!(plan.statements[0].sql.contains("varchar(16)")); + assert!(!plan.statements[0].sql.contains("CREATE TABLE")); + + let rendered_changes = serde_json::to_string(&change_set.changes).expect("changes serialize"); + for forbidden in [ + "registry_data", + "CREATE TABLE", + "ALTER TABLE", + "source/", + "f_", + ] { + assert!( + !rendered_changes.contains(forbidden), + "change diagnostics must stay value-free" + ); + } +} + +#[test] +fn new_entity_plan_uses_complete_candidate_ddl_in_dependency_order() { + let previous = compile_variant(Variant::Base, 1); + let candidate = compile_variant(Variant::NewEntity, 2); + let change_set = compiled_registry_change_set(&previous, &candidate, PRIOR_REVISION); + assert_change( + &change_set, + CompiledRegistryChangeClass::CompatibleAdditive, + CompiledRegistryChangeCode::EntityAdded, + ); + let plan = change_set_to_applicable_migration_plan(&change_set) + .expect("new entity change is applicable"); + let expected = candidate + .ddl() + .statements + .iter() + .filter(|statement| statement.id.starts_with("entity.placement.")) + .cloned() + .collect::>(); + assert_eq!(plan.statements, expected); + assert!(plan + .statements + .first() + .is_some_and(|statement| statement.id == "entity.placement.table")); + assert!(plan + .statements + .iter() + .any(|statement| statement.id == "entity.placement.field.asset.reference")); + assert!(plan + .statements + .iter() + .any(|statement| statement.id == "entity.placement.rls.force")); +} + +#[test] +fn new_reference_constraint_and_index_are_supported_additive_statements() { + let previous = compile_variant(Variant::Base, 1); + let candidate = compile_variant(Variant::ReferenceConstraintIndex, 2); + let change_set = compiled_registry_change_set(&previous, &candidate, PRIOR_REVISION); + assert_change( + &change_set, + CompiledRegistryChangeClass::CompatibleAdditive, + CompiledRegistryChangeCode::FieldAddedOptional, + ); + assert_change( + &change_set, + CompiledRegistryChangeClass::CompatibleAdditive, + CompiledRegistryChangeCode::ConstraintAdded, + ); + assert_change( + &change_set, + CompiledRegistryChangeClass::CompatibleAdditive, + CompiledRegistryChangeCode::IndexAdded, + ); + let plan = change_set_to_applicable_migration_plan(&change_set) + .expect("reference, constraint, and index additions are applicable"); + let ids = plan + .statements + .iter() + .map(|statement| statement.id.as_str()) + .collect::>(); + assert_eq!( + ids, + vec![ + "entity.asset.field.site.column", + "entity.asset.field.site.reference", + "entity.asset.constraint.code-unique", + "entity.asset.index.code-idx", + ] + ); +} + +#[test] +fn non_additive_changes_are_classified_and_cannot_create_applicable_plans() { + for (previous_variant, candidate_variant, class, code) in [ + ( + Variant::Base, + Variant::RequiredField, + CompiledRegistryChangeClass::DataBackfillRequired, + CompiledRegistryChangeCode::FieldAddedRequired, + ), + ( + Variant::Base, + Variant::FieldRemoved, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::FieldRemoved, + ), + ( + Variant::Base, + Variant::TypeChanged, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::FieldTypeChanged, + ), + ( + Variant::Base, + Variant::RouteChanged, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::EntityRouteChanged, + ), + ( + Variant::Base, + Variant::EntityClassificationChanged, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::EntityClassificationChanged, + ), + ( + Variant::Base, + Variant::ClassificationChanged, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::FieldClassificationChanged, + ), + ( + Variant::Base, + Variant::AuthorizationChanged, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::AccessProfileChanged, + ), + ( + Variant::Base, + Variant::MutationModeChanged, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::EntityMutationModeChanged, + ), + ( + Variant::Base, + Variant::TemporalChanged, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::EntityTemporalChanged, + ), + ( + Variant::TemporalRoleBase, + Variant::TemporalRoleChanged, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + CompiledRegistryChangeCode::FieldTemporalRoleChanged, + ), + ( + Variant::Base, + Variant::RankRequired, + CompiledRegistryChangeClass::DataBackfillRequired, + CompiledRegistryChangeCode::FieldRequirednessChanged, + ), + ( + Variant::RankRequired, + Variant::Base, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::FieldRequirednessChanged, + ), + ( + Variant::ReferenceTargetBase, + Variant::ReferenceTargetChanged, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::ReferenceTargetChanged, + ), + ] { + let previous = compile_variant(previous_variant, 1); + let candidate = compile_variant(candidate_variant, 2); + let change_set = compiled_registry_change_set(&previous, &candidate, PRIOR_REVISION); + assert_change(&change_set, class, code); + assert_eq!(change_set.migration_plan, None); + assert!(change_set_to_applicable_migration_plan(&change_set).is_err()); + } +} + +#[test] +fn complete_extension_surface_modules_are_order_independent() { + let field_module = parse_module_yaml(br#"{"id":"field-extension","version":"1","extendEntities":[{"entity":"asset","fields":[{"id":"status","type":"string","maxLength":16,"classification":"internal"}],"constraints":[{"kind":"unique","id":"status-unique","fields":["status"]}],"indexes":[{"id":"status-idx","fields":["status"]}]}]}"#) + .expect("field extension parses"); + let event_module = parse_module_yaml(br#"{"id":"event-extension","version":"1","extendEntities":[{"entity":"asset","accessProfiles":[{"id":"auditor","principalClaim":"principal","operations":["get","list"],"readableFields":["code","status"],"writableFields":[]}],"events":[{"id":"asset-created","trigger":"created","projection":["code","status"]}]}],"entities":[{"id":"site","route":"sites","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}]}]}"#) + .expect("event extension parses"); + let project_bytes = format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"local","instanceId":"{INSTANCE}","sequence":2,"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"name":"Package Test Publisher"}}}},"dataset":{{"title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}}},"entities":[{{"id":"asset","route":"assets","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}}],"accessProfiles":[{{"id":"reader","default":true,"principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}}]}}],"modules":[{{"id":"field-extension","version":"1","digest":"{}"}},{{"id":"event-extension","version":"1","digest":"{}"}}]}}"#, + module_digest(&field_module), + module_digest(&event_module) + ); + let project = parse_project_yaml(project_bytes.as_bytes()).expect("project parses"); + let first = compile_project( + &project, + &[field_module.clone(), event_module.clone()], + CompileProfile::Production, + ) + .expect("extension modules compile"); + let second = compile_project( + &project, + &[event_module, field_module], + CompileProfile::Production, + ) + .expect("extension modules compile in reverse input order"); + + let first_bytes = canonicalize_json(&serde_json::to_value(&first).expect("first serializes")) + .expect("first canonicalizes"); + let second_bytes = + canonicalize_json(&serde_json::to_value(&second).expect("second serializes")) + .expect("second canonicalizes"); + assert_eq!(first_bytes, second_bytes); + let asset = &first.entities()["asset"]; + assert!(asset.fields.contains_key("status")); + assert!(asset.constraints.contains_key("status-unique")); + assert!(asset.indexes.contains_key("status-idx")); + assert!(asset.access_profiles.contains_key("auditor")); + assert!(asset.events.contains_key("asset-created")); + assert!(first.entities().contains_key("site")); +} + +#[test] +fn equivalent_reordered_inputs_produce_byte_stable_change_sets() { + let previous = compile_variant(Variant::Base, 1); + let candidate = compile_variant(Variant::ReferenceConstraintIndex, 2); + let reordered = compile_variant(Variant::ReferenceConstraintIndexReordered, 2); + let first = compiled_registry_change_set(&previous, &candidate, PRIOR_REVISION); + let second = compiled_registry_change_set(&previous, &reordered, PRIOR_REVISION); + let first_bytes = + canonicalize_json(&serde_json::to_value(&first).expect("first change set serializes")) + .expect("first change set canonicalizes"); + let second_bytes = + canonicalize_json(&serde_json::to_value(&second).expect("second change set serializes")) + .expect("second change set canonicalizes"); + assert_eq!(first_bytes, second_bytes); +} + +#[test] +fn generated_successor_plan_passes_package_validation_with_prior_revision() { + let previous_source = source_for_variant(Variant::Base, 1); + let previous_package = registry_server::package::prepare_package(build_request( + 1, + None, + previous_source.project_bytes, + previous_source.module_bytes, + PackageMigrationPlanInput::InitialCompiledDdl, + )) + .expect("initial package prepares"); + let prior_revision = previous_package.package_revision().to_owned(); + + let previous = compile_variant(Variant::Base, 1); + let candidate = compile_variant(Variant::ReferenceConstraintIndex, 2); + let expected = compiled_registry_change_set(&previous, &candidate, &prior_revision); + let expected_plan = change_set_to_applicable_migration_plan(&expected) + .expect("candidate additive plan is applicable"); + + let candidate_source = source_for_variant(Variant::ReferenceConstraintIndex, 2); + let successor = registry_server::package::prepare_package(build_request( + 2, + Some(&prior_revision), + candidate_source.project_bytes, + candidate_source.module_bytes, + PackageMigrationPlanInput::Successor { + prior_registry: Box::new(previous), + }, + )) + .expect("successor closed plan validates"); + assert_eq!( + successor.manifest().migration_plan.from_revision.as_deref(), + Some(prior_revision.as_str()) + ); + assert_eq!(successor.manifest().migration_plan, expected_plan); +} + +#[cfg(feature = "tooling")] +#[test] +fn metadata_only_reviewed_migration_covers_non_sql_surface_without_dummy_sql() { + let previous = compile_variant(Variant::MetadataOnlyBase, 1); + let candidate = compile_variant(Variant::MetadataOnlyChanged, 2); + let change_set = compiled_registry_change_set(&previous, &candidate, PRIOR_REVISION); + for code in [ + CompiledRegistryChangeCode::EntityRouteChanged, + CompiledRegistryChangeCode::EntityMutationModeChanged, + CompiledRegistryChangeCode::EntityClassificationChanged, + CompiledRegistryChangeCode::FieldClassificationChanged, + CompiledRegistryChangeCode::FieldTemporalRoleChanged, + CompiledRegistryChangeCode::AccessProfileChanged, + CompiledRegistryChangeCode::RouteChanged, + CompiledRegistryChangeCode::EventChanged, + ] { + assert_change( + &change_set, + CompiledRegistryChangeClass::AccessOrDisclosureChange, + code, + ); + } + assert!(change_set_to_applicable_migration_plan(&change_set).is_err()); + + let source = source_for_variant(Variant::MetadataOnlyChanged, 2); + let reviewed = prepare_package(build_request( + 2, + Some(PRIOR_REVISION), + source.project_bytes, + source.module_bytes, + PackageMigrationPlanInput::ReviewedSuccessor { + prior_registry: Box::new(previous), + prior_schema_fingerprint: PRIOR_FINGERPRINT.to_owned(), + migrations: vec![metadata_only_source(&candidate)], + }, + )) + .expect("metadata-only reviewed successor prepares without SQL steps"); + assert!(reviewed.manifest().migration_plan.statements.is_empty()); + assert_eq!( + reviewed.manifest().migration_plan.reviewed_descriptors, + ["modules/core/migrations/metadata-only/descriptor.json"] + ); + for path in reviewed.file_bytes().keys() { + assert!( + !path.contains("/steps/"), + "metadata-only review used step SQL" + ); + assert!( + !path.contains("/assertions/"), + "metadata-only review used assertion SQL" + ); + assert!( + !path.contains("/fixtures/"), + "metadata-only review used fixture data" + ); + } +} + +#[cfg(feature = "tooling")] +#[test] +fn reference_target_change_can_be_reviewed_through_compiler_owned_fk_constraint() { + let previous = compile_variant(Variant::ReferenceTargetBase, 1); + let candidate = compile_variant(Variant::ReferenceTargetChanged, 2); + let change_set = compiled_registry_change_set(&previous, &candidate, PRIOR_REVISION); + assert_change( + &change_set, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::ReferenceTargetChanged, + ); + assert!(change_set_to_applicable_migration_plan(&change_set).is_err()); + + let source = source_for_variant(Variant::ReferenceTargetChanged, 2); + let reviewed = prepare_package(build_request( + 2, + Some(PRIOR_REVISION), + source.project_bytes, + source.module_bytes, + PackageMigrationPlanInput::ReviewedSuccessor { + prior_registry: Box::new(previous), + prior_schema_fingerprint: PRIOR_FINGERPRINT.to_owned(), + migrations: vec![reference_target_source(&candidate)], + }, + )) + .expect("reference target reviewed successor prepares"); + assert_eq!( + reviewed.manifest().migration_plan.reviewed_descriptors, + ["modules/core/migrations/reference-target/descriptor.json"] + ); +} + +#[cfg(feature = "tooling")] +#[test] +fn inspected_migration_summaries_are_exact_deterministic_and_value_free() { + let initial_source = source_for_variant(Variant::Base, 1); + let initial = prepare_package(build_request( + 1, + None, + initial_source.project_bytes, + initial_source.module_bytes, + PackageMigrationPlanInput::InitialCompiledDdl, + )) + .expect("initial package prepares"); + let inspected_initial = inspect_prepared(&initial); + let initial_summary = inspected_initial.migration_summary(); + let initial_statement_count = initial_summary.generated_statement_count(); + assert!(initial_statement_count > 0); + assert_eq!( + serde_json::to_value(initial_summary).expect("initial summary serializes"), + json!({ + "planKind": "initial", + "hasPriorRevision": false, + "hasPriorBaseline": false, + "changeCount": 0, + "changeCounts": { + "compatibleAdditive": 0, + "dataBackfillRequired": 0, + "accessOrDisclosureChange": 0, + "destructiveOrIrreversible": 0, + "unsupported": 0, + }, + "generatedStatementCount": initial_statement_count, + "reviewedMigrations": [], + }) + ); + + let additive_source = source_for_variant(Variant::OptionalField, 2); + let additive = prepare_package(build_request( + 2, + Some(PRIOR_REVISION), + additive_source.project_bytes, + additive_source.module_bytes, + PackageMigrationPlanInput::Successor { + prior_registry: Box::new(compile_variant(Variant::Base, 1)), + }, + )) + .expect("additive package prepares"); + let inspected_additive = inspect_prepared(&additive); + assert_eq!( + serde_json::to_value(inspected_additive.migration_summary()) + .expect("additive summary serializes"), + json!({ + "planKind": "compatible_additive", + "hasPriorRevision": true, + "hasPriorBaseline": true, + "changeCount": 1, + "changeCounts": { + "compatibleAdditive": 1, + "dataBackfillRequired": 0, + "accessOrDisclosureChange": 0, + "destructiveOrIrreversible": 0, + "unsupported": 0, + }, + "generatedStatementCount": 1, + "reviewedMigrations": [], + }) + ); + + let reviewed = reviewed_package_with_canaries(); + let first = inspect_prepared(&reviewed); + let second = inspect_prepared(&reviewed); + let expected = json!({ + "planKind": "reviewed", + "hasPriorRevision": true, + "hasPriorBaseline": true, + "changeCount": 2, + "changeCounts": { + "compatibleAdditive": 1, + "dataBackfillRequired": 1, + "accessOrDisclosureChange": 0, + "destructiveOrIrreversible": 0, + "unsupported": 0, + }, + "generatedStatementCount": 1, + "reviewedMigrations": [{ + "changeClass": "data_backfill_required", + "recovery": "exact_target_resume", + "lockTimeoutMs": 10_000, + "statementTimeoutMs": 60_000, + "transactionalStepCount": 0, + "chunkedStepCount": 1, + "preAssertionCount": 1, + "postAssertionCount": 1, + "backupRequired": false, + "chunkedStepBounds": { + "minimumChunkSize": 100, + "maximumChunkSize": 100, + "maximumTotalRows": 1_000, + }, + }], + }); + assert_eq!( + serde_json::to_value(first.migration_summary()).expect("reviewed summary serializes"), + expected + ); + let first_bytes = + serde_json::to_vec(first.migration_summary()).expect("first summary serializes"); + let second_bytes = + serde_json::to_vec(second.migration_summary()).expect("second summary serializes"); + assert_eq!(first_bytes, second_bytes, "summary bytes are deterministic"); + + let candidate = compile_variant(Variant::RequiredAndOptionalFields, 2); + let entity = &candidate.entities()["asset"]; + let rendered = String::from_utf8(first_bytes).expect("summary JSON is UTF-8"); + let debug = format!("{:?}", first.migration_summary()); + for forbidden in [ + SUMMARY_CANARY, + SQL_CANARY, + "step-canary", + "pre-canary", + "post-canary", + "fixture-canary", + "fixture-content-canary", + "UPDATE registry_data", + "SELECT pg_catalog", + INSTANCE, + DATABASE, + SOURCE_REVISION, + PRIOR_REVISION, + PRIOR_FINGERPRINT, + "source/registry.yaml", + "asset", + "batch", + entity.physical_table.as_str(), + entity.fields["batch"].physical_name.as_str(), + ] { + assert!(!rendered.contains(forbidden), "JSON leaked {forbidden}"); + assert!(!debug.contains(forbidden), "Debug leaked {forbidden}"); + } +} + +#[cfg(feature = "tooling")] +#[test] +fn tampered_package_never_returns_a_migration_summary_or_canary() { + let source = source_for_variant(Variant::Base, 1); + let prepared = prepare_package(build_request( + 1, + None, + source.project_bytes, + source.module_bytes, + PackageMigrationPlanInput::InitialCompiledDdl, + )) + .expect("initial package prepares"); + let root = tempfile::Builder::new() + .prefix("registry-package-summary-tamper-") + .tempdir_in("/private/tmp") + .expect("temporary package parent creates"); + let package = root.path().join("package"); + prepared + .publish_to_directory(&package, Vec::new()) + .expect("package publishes"); + let manifest = package.join("package.json"); + let mut bytes = fs::read(&manifest).expect("manifest reads"); + bytes.extend_from_slice(SUMMARY_CANARY.as_bytes()); + fs::write(&manifest, bytes).expect("manifest tampers"); + + let error = inspect_package_integrity(&package) + .err() + .expect("tampered package cannot return an inspected summary"); + let rendered = format!("{error:?}"); + assert!(!rendered.contains(SUMMARY_CANARY)); + assert!(!rendered.contains(package.to_string_lossy().as_ref())); +} + +fn assert_change( + change_set: ®istry_server::package::CompiledRegistryChangeSet, + class: CompiledRegistryChangeClass, + code: CompiledRegistryChangeCode, +) { + assert!( + change_set + .changes + .iter() + .any(|change| change.class == class && change.code == code), + "expected {class:?}/{code:?} in {:#?}", + change_set.changes + ); +} + +#[derive(Clone, Copy)] +enum Variant { + Base, + OptionalField, + RequiredField, + #[cfg_attr(not(feature = "tooling"), allow(dead_code))] + RequiredAndOptionalFields, + NewEntity, + ReferenceConstraintIndex, + ReferenceConstraintIndexReordered, + FieldRemoved, + TypeChanged, + RouteChanged, + EntityClassificationChanged, + ClassificationChanged, + AuthorizationChanged, + MutationModeChanged, + TemporalChanged, + TemporalRoleBase, + TemporalRoleChanged, + RankRequired, + ReferenceTargetBase, + ReferenceTargetChanged, + #[cfg_attr(not(feature = "tooling"), allow(dead_code))] + MetadataOnlyBase, + #[cfg_attr(not(feature = "tooling"), allow(dead_code))] + MetadataOnlyChanged, +} + +struct SourceFixture { + project_bytes: Vec, + module_bytes: Vec, +} + +fn compile_variant(variant: Variant, sequence: u64) -> CompiledRegistry { + let source = source_for_variant(variant, sequence); + let module = parse_module_yaml(&source.module_bytes).expect("fixture module parses"); + let project = parse_project_yaml(&source.project_bytes).expect("fixture project parses"); + compile_project(&project, &[module], CompileProfile::Production) + .expect("fixture compiles in production") +} + +fn source_for_variant(variant: Variant, sequence: u64) -> SourceFixture { + let module_bytes = module_bytes(variant); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses for digest"); + let digest = module_digest(&module); + SourceFixture { + project_bytes: project_bytes(sequence, &digest), + module_bytes, + } +} + +fn project_bytes(sequence: u64, module_digest: &str) -> Vec { + format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"local","instanceId":"{INSTANCE}","sequence":{sequence},"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"name":"Package Test Publisher"}}}},"dataset":{{"title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}}},"modules":[{{"id":"core","version":"1","digest":"{module_digest}"}}]}}"# + ) + .into_bytes() +} + +fn module_bytes(variant: Variant) -> Vec { + let asset = match variant { + Variant::OptionalField => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"color","type":"string","maxLength":16,"classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::RequiredField => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"batch","type":"string","maxLength":16,"required":true,"classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::RequiredAndOptionalFields => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"batch","type":"string","maxLength":16,"required":true,"classification":"internal"},{"id":"color","type":"string","maxLength":16,"classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::ReferenceConstraintIndex => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"site","type":"reference","target":"site","classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + r#","constraints":[{"kind":"unique","id":"code-unique","fields":["code"]}]"#, + r#","indexes":[{"id":"code-idx","fields":["code"]}]"#, + "", + ), + Variant::ReferenceConstraintIndexReordered => asset_entity( + r#"{"id":"site","type":"reference","target":"site","classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"code","type":"string","maxLength":8,"classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["list","get","create"],"writableFields":["code"],"readableFields":["code"]"#, + r#","constraints":[{"fields":["code"],"id":"code-unique","kind":"unique"}]"#, + r#","indexes":[{"fields":["code"],"id":"code-idx"}]"#, + "", + ), + Variant::FieldRemoved => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::TypeChanged => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"string","maxLength":8,"classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::RouteChanged => asset_entity( + base_asset_fields(), + r#""route":"equipment""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::EntityClassificationChanged => asset_entity( + base_asset_fields(), + r#""route":"assets","classification":"restricted""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::ClassificationChanged => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"restricted"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::AuthorizationChanged => asset_entity( + base_asset_fields(), + r#""route":"assets""#, + r#""id":"reader","principalClaim":"subject","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::MutationModeChanged => asset_entity_with_mode( + base_asset_fields(), + r#""route":"assets""#, + "mutable", + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::TemporalChanged => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"required":true,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"valid-from","type":"date","required":true,"classification":"internal"},{"id":"valid-to","type":"date","classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code","valid-from","valid-to"],"writableFields":["code","valid-from","valid-to"]"#, + r#","constraints":[{"kind":"temporal-non-overlap","id":"code-time","scopeFields":["code"],"startField":"valid-from","endField":"valid-to"}]"#, + "", + r#","temporal":{"startField":"valid-from","endField":"valid-to","scopeFields":["code"]}"#, + ), + Variant::TemporalRoleBase => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"valid-from","type":"date","required":true,"classification":"internal"},{"id":"valid-to","type":"date","classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code","valid-from","valid-to"],"writableFields":["code","valid-from","valid-to"]"#, + "", + "", + "", + ), + Variant::TemporalRoleChanged => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"valid-from","type":"date","required":true,"classification":"internal","validTimeRole":"valid_from"},{"id":"valid-to","type":"date","classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code","valid-from","valid-to"],"writableFields":["code","valid-from","valid-to"]"#, + "", + "", + "", + ), + Variant::RankRequired => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","required":true,"classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::ReferenceTargetBase => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"home-site","type":"reference","target":"site","classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::ReferenceTargetChanged => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"home-site","type":"reference","target":"location","classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + Variant::MetadataOnlyBase => asset_entity( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"},{"id":"valid-from","type":"date","required":true,"classification":"internal"},{"id":"valid-to","type":"date","classification":"internal"}"#, + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code","valid-from","valid-to"],"writableFields":["code","valid-from","valid-to"]"#, + "", + "", + r#","events":[{"id":"asset-created","trigger":"created","projection":["code"]}]"#, + ), + Variant::MetadataOnlyChanged => asset_entity_with_mode( + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"restricted"},{"id":"valid-from","type":"date","required":true,"classification":"internal","validTimeRole":"valid_from"},{"id":"valid-to","type":"date","classification":"internal"}"#, + r#""route":"equipment","classification":"restricted""#, + "mutable", + r#""id":"reader","principalClaim":"subject","operations":["create","get","list"],"readableFields":["code","valid-from","valid-to"],"writableFields":["code","valid-from","valid-to"]"#, + "", + "", + r#","events":[{"id":"asset-created","trigger":"created","projection":["code","rank"]}]"#, + ), + Variant::Base | Variant::NewEntity => asset_entity( + base_asset_fields(), + r#""route":"assets""#, + r#""id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]"#, + "", + "", + "", + ), + }; + let placement = if matches!(variant, Variant::NewEntity) { + format!(",{}", placement_entity()) + } else { + String::new() + }; + let location = if matches!( + variant, + Variant::ReferenceTargetBase | Variant::ReferenceTargetChanged + ) { + format!(",{}", location_entity()) + } else { + String::new() + }; + format!( + r#"{{"id":"core","version":"1","entities":[{asset},{site}{location}{placement}]}}"#, + site = site_entity() + ) + .into_bytes() +} + +fn base_asset_fields() -> &'static str { + r#"{"id":"code","type":"string","maxLength":8,"classification":"internal"},{"id":"rank","type":"int64","classification":"internal"}"# +} + +fn asset_entity( + fields: &str, + route: &str, + access: &str, + constraints: &str, + indexes: &str, + temporal: &str, +) -> String { + asset_entity_with_mode( + fields, + route, + "create_only", + access, + constraints, + indexes, + temporal, + ) +} + +fn asset_entity_with_mode( + fields: &str, + route: &str, + mutation_mode: &str, + access: &str, + constraints: &str, + indexes: &str, + temporal: &str, +) -> String { + format!( + r#"{{"id":"asset",{route},"mutationMode":"{mutation_mode}","fields":[{fields}]{constraints}{indexes},"accessProfiles":[{{{access}}}]{temporal}}}"# + ) +} + +fn site_entity() -> &'static str { + r#"{"id":"site","route":"sites","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}]}"# +} + +fn location_entity() -> &'static str { + r#"{"id":"location","route":"locations","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}]}"# +} + +fn placement_entity() -> &'static str { + r#"{"id":"placement","route":"placements","mutationMode":"create_only","fields":[{"id":"asset","type":"reference","target":"asset","required":true,"classification":"internal"},{"id":"site","type":"reference","target":"site","required":true,"classification":"internal"}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["asset","site"],"writableFields":["asset","site"]}]}"# +} + +fn build_request( + sequence: u64, + prior_revision: Option<&str>, + project_bytes: Vec, + module_bytes: Vec, + migration_plan: PackageMigrationPlanInput, +) -> PackageBuildRequest { + PackageBuildRequest { + environment: "local".to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + sequence, + prior_revision: prior_revision.map(str::to_owned), + compiler_source_revision: SOURCE_REVISION.to_owned(), + schema_fingerprint: + "sha256:2222222222222222222222222222222222222222222222222222222222222222".to_owned(), + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: project_bytes, + }, + modules: vec![PackageModuleSource { + id: "core".to_owned(), + path: "source/modules/core/module.yaml".to_owned(), + bytes: module_bytes, + }], + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: FIXTURE_JOURNEYS.to_vec(), + }, + migration_plan, + } +} + +#[cfg(feature = "tooling")] +fn metadata_only_source(candidate: &CompiledRegistry) -> ReviewedMigrationSource { + let previous = compile_variant(Variant::MetadataOnlyBase, 1); + let change_set = compiled_registry_change_set(&previous, candidate, PRIOR_REVISION); + let mut covers = change_set + .changes + .iter() + .filter(|change| change.class != CompiledRegistryChangeClass::CompatibleAdditive) + .map(ReviewedChangeCover::from) + .collect::>(); + covers.sort(); + assert!(covers.iter().all(|cover| { + change_set + .changes + .iter() + .find(|change| change.code == cover.code && change.target == cover.target) + .is_some_and(|change| { + change.class == CompiledRegistryChangeClass::AccessOrDisclosureChange + }) + })); + + let base = "modules/core/migrations/metadata-only"; + let descriptor = ReviewedMigrationDescriptor { + id: "metadata-only".to_owned(), + change_class: CompiledRegistryChangeClass::AccessOrDisclosureChange, + covers, + recovery: ReviewedMigrationRecovery::ExactTargetResume, + lock_timeout_ms: 10_000, + statement_timeout_ms: 60_000, + steps: Vec::new(), + pre_assertions: Vec::new(), + post_assertions: Vec::new(), + rehearsal_receipt_path: format!("{base}/rehearsal.json"), + backup_binding_path: None, + }; + let descriptor_bytes = canonical(&descriptor); + let receipt = MigrationRehearsalReceipt { + prior_revision: PRIOR_REVISION.to_owned(), + prior_schema_fingerprint: PRIOR_FINGERPRINT.to_owned(), + plan_sha256: digest(&descriptor_bytes), + sql_sha256: Vec::new(), + assertion_sha256: Vec::new(), + fixture_inventory: Vec::new(), + postgres_major: 16, + row_assertions: Vec::new(), + final_schema_fingerprint: FINAL_FINGERPRINT.to_owned(), + proofs: RehearsalProofs { + lock_timeout: true, + chunk_resume: false, + destructive_resume: false, + }, + }; + ReviewedMigrationSource { + module_id: "core".to_owned(), + descriptor: ReviewedMigrationFile { + path: format!("{base}/descriptor.json"), + bytes: descriptor_bytes, + }, + files: vec![ReviewedMigrationFile { + path: descriptor.rehearsal_receipt_path, + bytes: canonical(&receipt), + }], + } +} + +#[cfg(feature = "tooling")] +fn reference_target_source(candidate: &CompiledRegistry) -> ReviewedMigrationSource { + let previous = compile_variant(Variant::ReferenceTargetBase, 1); + let change_set = compiled_registry_change_set(&previous, candidate, PRIOR_REVISION); + let change = change_set + .changes + .iter() + .find(|change| change.code == CompiledRegistryChangeCode::ReferenceTargetChanged) + .expect("reference target change is classified"); + let entity = &candidate.entities()["asset"]; + let field = &entity.fields["home-site"]; + let target = &candidate.entities()["location"]; + let constraint_name = + &candidate.physical_names().entities["asset"].constraints["reference:home-site"]; + let base = "modules/core/migrations/reference-target"; + let drop_path = format!("{base}/steps/drop-reference.sql"); + let add_path = format!("{base}/steps/add-reference.sql"); + let pre_path = format!("{base}/assertions/pre.sql"); + let post_path = format!("{base}/assertions/post.sql"); + let fixture_path = format!("{base}/fixtures/reference-target.jsonl"); + let drop_sql = format!( + "ALTER TABLE registry_data.{} DROP CONSTRAINT {}", + entity.physical_table, constraint_name + ) + .into_bytes(); + let add_sql = format!( + "ALTER TABLE registry_data.{} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES registry_data.{} (record_id) ON DELETE RESTRICT", + entity.physical_table, constraint_name, field.physical_name, target.physical_table + ) + .into_bytes(); + let assertion_sql = format!( + "SELECT pg_catalog.count(*) >= 0 FROM registry_data.{}", + entity.physical_table + ) + .into_bytes(); + let fixture_bytes = b"{\"fixture\":\"reference-target\"}\n".to_vec(); + let object = ReviewedMigrationObject { + schema: "registry_data".to_owned(), + table: entity.physical_table.clone(), + entity_id: "asset".to_owned(), + kind: ReviewedMigrationObjectKind::Constraint, + member_id: Some("reference:home-site".to_owned()), + physical_name: constraint_name.clone(), + }; + let descriptor = ReviewedMigrationDescriptor { + id: "reference-target".to_owned(), + change_class: change.class, + covers: vec![ReviewedChangeCover::from(change)], + recovery: ReviewedMigrationRecovery::ExactTargetResume, + lock_timeout_ms: 10_000, + statement_timeout_ms: 60_000, + steps: vec![ + ReviewedMigrationStepDescriptor::TransactionalSql { + id: "drop-reference".to_owned(), + sql_path: drop_path.clone(), + objects: vec![object.clone()], + affected_rows: None, + }, + ReviewedMigrationStepDescriptor::TransactionalSql { + id: "add-reference".to_owned(), + sql_path: add_path.clone(), + objects: vec![object], + affected_rows: None, + }, + ], + pre_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "pre".to_owned(), + sql_path: pre_path.clone(), + }], + post_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "post".to_owned(), + sql_path: post_path.clone(), + }], + rehearsal_receipt_path: format!("{base}/rehearsal.json"), + backup_binding_path: Some(format!("{base}/backup.json")), + }; + let descriptor_bytes = canonical(&descriptor); + let receipt = MigrationRehearsalReceipt { + prior_revision: PRIOR_REVISION.to_owned(), + prior_schema_fingerprint: PRIOR_FINGERPRINT.to_owned(), + plan_sha256: digest(&descriptor_bytes), + sql_sha256: vec![ + ArtifactDigestBinding { + path: drop_path.clone(), + sha256: digest(&drop_sql), + }, + ArtifactDigestBinding { + path: add_path.clone(), + sha256: digest(&add_sql), + }, + ], + assertion_sha256: vec![ + ArtifactDigestBinding { + path: pre_path.clone(), + sha256: digest(&assertion_sql), + }, + ArtifactDigestBinding { + path: post_path.clone(), + sha256: digest(&assertion_sql), + }, + ], + fixture_inventory: vec![RehearsalFixture { + id: "reference-target".to_owned(), + path: fixture_path.clone(), + sha256: digest(&fixture_bytes), + row_count: 1, + }], + postgres_major: 16, + row_assertions: Vec::new(), + final_schema_fingerprint: FINAL_FINGERPRINT.to_owned(), + proofs: RehearsalProofs { + lock_timeout: true, + chunk_resume: false, + destructive_resume: true, + }, + }; + let backup = ExternalBackupBinding { + database_id: DATABASE.to_owned(), + prior_revision: PRIOR_REVISION.to_owned(), + prior_schema_fingerprint: PRIOR_FINGERPRINT.to_owned(), + sha256: "sha256:4444444444444444444444444444444444444444444444444444444444444444" + .to_owned(), + byte_length: 4096, + created_at: "2026-08-30T00:00:00Z".to_owned(), + max_age_seconds: 86_400, + }; + let mut files = vec![ + ReviewedMigrationFile { + path: drop_path, + bytes: drop_sql, + }, + ReviewedMigrationFile { + path: add_path, + bytes: add_sql, + }, + ReviewedMigrationFile { + path: pre_path, + bytes: assertion_sql.clone(), + }, + ReviewedMigrationFile { + path: post_path, + bytes: assertion_sql, + }, + ReviewedMigrationFile { + path: descriptor.rehearsal_receipt_path.clone(), + bytes: canonical(&receipt), + }, + ReviewedMigrationFile { + path: descriptor.backup_binding_path.clone().expect("backup path"), + bytes: canonical(&backup), + }, + ReviewedMigrationFile { + path: fixture_path, + bytes: fixture_bytes, + }, + ]; + files.sort_by(|left, right| left.path.cmp(&right.path)); + ReviewedMigrationSource { + module_id: "core".to_owned(), + descriptor: ReviewedMigrationFile { + path: format!("{base}/descriptor.json"), + bytes: descriptor_bytes, + }, + files, + } +} + +#[cfg(feature = "tooling")] +fn reviewed_package_with_canaries() -> PreparedPackage { + let previous = compile_variant(Variant::Base, 1); + let candidate = compile_variant(Variant::RequiredAndOptionalFields, 2); + let source = reviewed_source_with_canaries(&previous, &candidate); + let candidate_source = source_for_variant(Variant::RequiredAndOptionalFields, 2); + prepare_package(build_request( + 2, + Some(PRIOR_REVISION), + candidate_source.project_bytes, + candidate_source.module_bytes, + PackageMigrationPlanInput::ReviewedSuccessor { + prior_registry: Box::new(previous), + prior_schema_fingerprint: PRIOR_FINGERPRINT.to_owned(), + migrations: vec![source], + }, + )) + .expect("reviewed package with canaries prepares") +} + +#[cfg(feature = "tooling")] +fn reviewed_source_with_canaries( + previous: &CompiledRegistry, + candidate: &CompiledRegistry, +) -> ReviewedMigrationSource { + let change_set = compiled_registry_change_set(previous, candidate, PRIOR_REVISION); + let change = change_set + .changes + .iter() + .find(|change| change.code == CompiledRegistryChangeCode::FieldAddedRequired) + .expect("required field change is classified"); + let entity = &candidate.entities()["asset"]; + let field = &entity.fields["batch"]; + let base = format!("modules/core/migrations/{SUMMARY_CANARY}"); + let step_path = format!("{base}/steps/step-canary.sql"); + let pre_path = format!("{base}/assertions/pre-canary.sql"); + let post_path = format!("{base}/assertions/post-canary.sql"); + let fixture_path = format!("{base}/fixtures/fixture-canary.jsonl"); + let step_sql = format!( + "UPDATE registry_data.{} SET {} = '{SQL_CANARY}' WHERE record_id = ANY($1::pg_catalog.uuid[])", + entity.physical_table, field.physical_name + ) + .into_bytes(); + let assertion_sql = format!( + "SELECT pg_catalog.count(*) >= 0 FROM registry_data.{}", + entity.physical_table + ) + .into_bytes(); + let fixture_bytes = br#"{"fixture":"fixture-content-canary"} +"# + .to_vec(); + let descriptor = ReviewedMigrationDescriptor { + id: SUMMARY_CANARY.to_owned(), + change_class: change.class, + covers: vec![ReviewedChangeCover::from(change)], + recovery: ReviewedMigrationRecovery::ExactTargetResume, + lock_timeout_ms: 10_000, + statement_timeout_ms: 60_000, + steps: vec![ReviewedMigrationStepDescriptor::ChunkedBackfill { + id: "step-canary".to_owned(), + entity_id: "asset".to_owned(), + sql_path: step_path.clone(), + objects: vec![ReviewedMigrationObject { + schema: "registry_data".to_owned(), + table: entity.physical_table.clone(), + entity_id: "asset".to_owned(), + kind: ReviewedMigrationObjectKind::Field, + member_id: Some("batch".to_owned()), + physical_name: field.physical_name.clone(), + }], + cursor: ChunkCursorProtocol::RecordIdUuidArray, + chunk_size: 100, + max_total_rows: 1_000, + lock_timeout_ms: 1_000, + statement_timeout_ms: 10_000, + exact_affected_rows: true, + }], + pre_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "pre-canary".to_owned(), + sql_path: pre_path.clone(), + }], + post_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "post-canary".to_owned(), + sql_path: post_path.clone(), + }], + rehearsal_receipt_path: format!("{base}/rehearsal.json"), + backup_binding_path: None, + }; + let descriptor_bytes = canonical(&descriptor); + let receipt = MigrationRehearsalReceipt { + prior_revision: PRIOR_REVISION.to_owned(), + prior_schema_fingerprint: PRIOR_FINGERPRINT.to_owned(), + plan_sha256: digest(&descriptor_bytes), + sql_sha256: vec![ArtifactDigestBinding { + path: step_path.clone(), + sha256: digest(&step_sql), + }], + assertion_sha256: vec![ + ArtifactDigestBinding { + path: pre_path.clone(), + sha256: digest(&assertion_sql), + }, + ArtifactDigestBinding { + path: post_path.clone(), + sha256: digest(&assertion_sql), + }, + ], + fixture_inventory: vec![RehearsalFixture { + id: "fixture-canary".to_owned(), + path: fixture_path.clone(), + sha256: digest(&fixture_bytes), + row_count: 1, + }], + postgres_major: 16, + row_assertions: vec![RehearsalRowAssertion { + step_id: "step-canary".to_owned(), + affected_rows: 10, + }], + final_schema_fingerprint: + "sha256:2222222222222222222222222222222222222222222222222222222222222222".to_owned(), + proofs: RehearsalProofs { + lock_timeout: true, + chunk_resume: true, + destructive_resume: false, + }, + }; + let mut files = vec![ + ReviewedMigrationFile { + path: step_path, + bytes: step_sql, + }, + ReviewedMigrationFile { + path: pre_path, + bytes: assertion_sql.clone(), + }, + ReviewedMigrationFile { + path: post_path, + bytes: assertion_sql, + }, + ReviewedMigrationFile { + path: descriptor.rehearsal_receipt_path.clone(), + bytes: canonical(&receipt), + }, + ReviewedMigrationFile { + path: fixture_path, + bytes: fixture_bytes, + }, + ]; + files.sort_by(|left, right| left.path.cmp(&right.path)); + ReviewedMigrationSource { + module_id: "core".to_owned(), + descriptor: ReviewedMigrationFile { + path: format!("{base}/descriptor.json"), + bytes: descriptor_bytes, + }, + files, + } +} + +#[cfg(feature = "tooling")] +fn inspect_prepared( + prepared: &PreparedPackage, +) -> registry_server::package::IntegrityInspectedPackage { + let root = tempfile::Builder::new() + .prefix("registry-package-summary-") + .tempdir_in("/private/tmp") + .expect("temporary package parent creates"); + let package = root.path().join("package"); + prepared + .publish_to_directory(&package, Vec::new()) + .expect("package publishes"); + inspect_package_integrity(&package).expect("package inspects") +} + +#[cfg(feature = "tooling")] +fn canonical(value: &impl Serialize) -> Vec { + canonicalize_json(&serde_json::to_value(value).expect("value serializes")) + .expect("value canonicalizes") +} + +#[cfg(feature = "tooling")] +fn digest(bytes: &[u8]) -> String { + let value = Sha256::digest(bytes); + let mut rendered = String::with_capacity(value.len() * 2 + 7); + rendered.push_str("sha256:"); + for byte in value { + use std::fmt::Write as _; + + write!(&mut rendered, "{byte:02x}").expect("digest writes"); + } + rendered +} diff --git a/crates/registry-server/tests/pilot_acceptance_fixtures.rs b/crates/registry-server/tests/pilot_acceptance_fixtures.rs new file mode 100644 index 0000000000..fb9c0850ee --- /dev/null +++ b/crates/registry-server/tests/pilot_acceptance_fixtures.rs @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::PathBuf; + +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::{ + Classification, ConstraintSource, FieldTypeSource, MutationMode, Operation, RegistryModule, + RegistryProject, UniqueWhenPredicate, ValidTimeRole, +}; +use registry_server::generated_ddl::DdlStatementKind; +use registry_server::model::{CompiledEntity, CompiledRegistry}; + +fn fixture_sources(name: &str) -> (RegistryProject, Vec) { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/registry-server/acceptance") + .join(name); + let bytes = fs::read(root.join("registry.yaml")).expect("committed pilot fixture is readable"); + let project = registry_server::contract::parse_project_yaml(&bytes) + .expect("pilot fixture follows the authoring contract"); + let modules = project + .modules + .iter() + .map(|locked| { + let bytes = fs::read(root.join("modules").join(&locked.id).join("module.yaml")) + .expect("every locked pilot module source is readable"); + registry_server::contract::parse_module_yaml(&bytes) + .expect("pilot module follows the authoring contract") + }) + .collect(); + (project, modules) +} + +fn compile_fixture(name: &str) -> CompiledRegistry { + let (project, modules) = fixture_sources(name); + compile_project(&project, &modules, CompileProfile::Production) + .expect("pilot fixture compiles in production mode") +} + +fn entity<'a>(compiled: &'a CompiledRegistry, id: &str) -> &'a CompiledEntity { + compiled + .entities() + .get(id) + .expect("fixture entity compiled") +} + +fn field<'a>(entity: &'a CompiledEntity, id: &str) -> &'a registry_server::model::CompiledField { + entity.fields.get(id).expect("fixture field compiled") +} + +fn has_unique(entity: &CompiledEntity, fields: &[&str]) -> bool { + entity.constraints.values().any(|constraint| { + matches!( + constraint, + ConstraintSource::Unique { fields: declared, .. } + if declared.iter().map(String::as_str).eq(fields.iter().copied()) + ) + }) +} + +fn has_temporal_non_overlap(entity: &CompiledEntity, scope_fields: &[&str]) -> bool { + entity.constraints.values().any(|constraint| { + matches!( + constraint, + ConstraintSource::TemporalNonOverlap { scope_fields: declared, .. } + if declared.iter().map(String::as_str).eq(scope_fields.iter().copied()) + ) + }) +} + +fn has_current_unique(entity: &CompiledEntity, fields: &[&str], open_field: &str) -> bool { + entity.constraints.values().any(|constraint| { + matches!( + constraint, + ConstraintSource::Unique { + fields: declared, + when: Some(when), + .. + } if declared.iter().map(String::as_str).eq(fields.iter().copied()) + && when.iter().any(|predicate| matches!( + predicate, + UniqueWhenPredicate::FieldIsNull { field } if field == open_field + )) + && when.iter().any(|predicate| matches!( + predicate, + UniqueWhenPredicate::ActiveLifecycle {} + )) + ) + }) +} + +fn operations_for(compiled: &CompiledRegistry, entity_id: &str) -> Vec { + let mut operations = compiled + .routes() + .routes + .iter() + .filter(|route| route.entity_id == entity_id) + .map(|route| route.operation) + .collect::>(); + operations.sort(); + operations.dedup(); + operations +} + +#[test] +fn household_pilot_fixture_compiles_person_household_and_time_bounded_membership() { + let compiled = compile_fixture("publicschema-household"); + assert_eq!(compiled.registry_id(), "publicschema-household"); + assert_eq!(compiled.entities().len(), 3); + + let person = entity(&compiled, "person"); + assert!(person.fields.contains_key("residency-status")); + assert!(person.fields.contains_key("preferred-language")); + + let membership = entity(&compiled, "group-membership"); + assert_eq!(membership.mutation_mode, MutationMode::Mutable); + assert!(has_unique( + membership, + &["person", "household", "valid-from"] + )); + assert!(has_temporal_non_overlap(membership, &["person"])); + assert_eq!( + field(membership, "valid-from").valid_time_role, + Some(ValidTimeRole::ValidFrom) + ); + assert_eq!( + field(membership, "valid-to").valid_time_role, + Some(ValidTimeRole::ValidTo) + ); + assert!(compiled.ddl().requires_btree_gist); + assert!(compiled.routes().routes.iter().any(|route| { + route.entity_id == "group-membership" + && route.operation == Operation::Patch + && route.path == "/v1/records/group-memberships/{record_id}" + })); +} + +#[test] +fn disability_pilot_fixture_compiles_protected_observations_and_create_only_certification() { + let compiled = compile_fixture("disability"); + assert_eq!(compiled.registry_id(), "disability"); + assert_eq!(compiled.entities().len(), 3); + assert!(compiled + .entities() + .values() + .all(|entity| entity.classification == Classification::Restricted)); + assert!(compiled.entities().values().all(|entity| { + entity + .access_profiles + .values() + .all(|profile| !profile.anonymous) + })); + + let observation = entity(&compiled, "functioning-observation"); + assert!(observation.constraints.values().any(|constraint| { + matches!( + constraint, + ConstraintSource::IntRange { + field, + minimum: Some(0), + maximum: Some(4), + .. + } if field == "severity-score" + ) + })); + + let certification = entity(&compiled, "certification"); + assert_eq!(certification.mutation_mode, MutationMode::CreateOnly); + assert!(certification.fields.contains_key("corrected-certification")); + assert!(certification.fields.contains_key("correction-reason")); + assert!(certification.fields.contains_key("provenance-note")); + assert!(has_temporal_non_overlap( + certification, + &["assessment-episode"] + )); + assert_eq!( + operations_for(&compiled, "certification"), + [Operation::Create, Operation::Get, Operation::List] + ); +} + +#[test] +fn farmer_pilot_fixture_compiles_bounded_crs84_scalars_imports_and_temporal_activity() { + let compiled = compile_fixture("farmer"); + assert_eq!(compiled.registry_id(), "farmer"); + assert_eq!(compiled.entities().len(), 4); + + let plot = entity(&compiled, "plot"); + assert!(matches!( + field(plot, "centroid").field_type, + FieldTypeSource::Crs84Point { + precision: 7, + bbox: Some(_) + } + )); + assert!(matches!( + field(plot, "area-value").field_type, + FieldTypeSource::Decimal { + precision: 12, + scale: 4, + .. + } + )); + assert!(has_unique(plot, &["import-source", "source-record-id"])); + assert!(matches!( + field(plot, "area-unit").field_type, + FieldTypeSource::VocabularyCode { .. } + )); + assert!(matches!( + field(plot, "administrative-boundary").field_type, + FieldTypeSource::VocabularyCode { .. } + )); + + let activity = entity(&compiled, "seasonal-activity"); + assert!(has_temporal_non_overlap( + activity, + &["plot", "activity-type"] + )); + assert_eq!( + field(activity, "season-start").valid_time_role, + Some(ValidTimeRole::ValidFrom) + ); + assert_eq!( + field(activity, "season-end").valid_time_role, + Some(ValidTimeRole::ValidTo) + ); + assert!(matches!( + field(activity, "quantity-value").field_type, + FieldTypeSource::Decimal { + precision: 12, + scale: 3, + .. + } + )); + let ddl = compiled.ddl().script().to_ascii_lowercase(); + assert!(!ddl.contains("postgis")); + assert!(!ddl.contains("geometry")); + assert!(!ddl.contains("geography")); + assert!(ddl.contains("jsonb")); + assert!(ddl.contains("numeric(12,4)")); + assert!(ddl.contains("numeric(12,3)")); +} + +#[test] +fn business_pilot_fixture_compiles_composite_identifiers_temporal_appointments_and_public_views() { + let compiled = compile_fixture("business"); + assert_eq!(compiled.registry_id(), "business"); + assert_eq!(compiled.entities().len(), 3); + + let legal_entity = entity(&compiled, "legal-entity"); + assert_eq!(legal_entity.classification, Classification::Public); + assert!(has_unique( + legal_entity, + &["jurisdiction-code", "registration-number"] + )); + let public_entity = legal_entity + .access_profiles + .get("public-register") + .expect("public profile compiled"); + assert!(public_entity.anonymous); + assert!(public_entity.readable_fields.contains("legal-name")); + assert!(!public_entity.readable_fields.contains("protected-contact")); + assert!(!public_entity.readable_fields.contains("internal-case-note")); + + let registrar_entity = legal_entity + .access_profiles + .get("business-registrar") + .expect("registrar profile compiled"); + assert!(registrar_entity + .readable_fields + .contains("protected-contact")); + + let filing = entity(&compiled, "filing"); + assert_eq!(filing.mutation_mode, MutationMode::CreateOnly); + assert!(has_unique(filing, &["legal-entity", "filing-number"])); + assert!(has_unique(filing, &["source-system", "source-record-id"])); + assert_eq!( + operations_for(&compiled, "filing"), + [Operation::Create, Operation::Get, Operation::List] + ); + + let appointment = entity(&compiled, "officer-appointment"); + assert_eq!(appointment.classification, Classification::Public); + assert!(has_unique( + appointment, + &["legal-entity", "officer-code", "effective-from"] + )); + assert!(has_current_unique( + appointment, + &["legal-entity", "officer-role"], + "effective-to" + )); + assert!(has_temporal_non_overlap( + appointment, + &["legal-entity", "officer-code"] + )); + assert_eq!( + field(appointment, "effective-from").valid_time_role, + Some(ValidTimeRole::ValidFrom) + ); + assert_eq!( + field(appointment, "effective-to").valid_time_role, + Some(ValidTimeRole::ValidTo) + ); + let public_appointment = appointment + .access_profiles + .get("public-register") + .expect("appointment public profile compiled"); + assert!(public_appointment.anonymous); + assert!(public_appointment + .filterable_fields + .contains("effective-from")); + assert!(!public_appointment.readable_fields.contains("officer-code")); + assert!(!public_appointment + .readable_fields + .contains("protected-officer-id")); + + let get_access = compiled + .access() + .entries + .iter() + .find(|entry| entry.entity_id == "legal-entity" && entry.operation == Operation::Get) + .expect("legal entity read access compiles"); + assert_eq!(get_access.default_profile_id, "public-register"); + assert!(get_access.profile_ids.contains("business-registrar")); + assert!(get_access.profile_ids.contains("public-register")); + + assert!(compiled.ddl().statements.iter().any(|statement| { + statement.kind == DdlStatementKind::Constraint + && statement + .id + .contains("officer-appointment.constraint.temporal-non-overlap") + })); + assert!(compiled.ddl().statements.iter().any(|statement| { + statement.kind == DdlStatementKind::Index + && statement + .id + .contains("officer-appointment.constraint.unique") + && statement.sql.contains("CREATE UNIQUE INDEX") + && statement.sql.contains(" WHERE ") + && statement.sql.contains("record_lifecycle = 'active'") + })); +} diff --git a/crates/registry-server/tests/postgres_batch.rs b/crates/registry-server/tests/postgres_batch.rs new file mode 100644 index 0000000000..1011206c24 --- /dev/null +++ b/crates/registry-server/tests/postgres_batch.rs @@ -0,0 +1,746 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::{to_bytes, Body}; +use axum::http::{HeaderName, HeaderValue, Method, Request, StatusCode}; +use postgres_harness::TestDatabase; +use registry_platform_audit::AuditProfile; +use registry_server::api::{ + router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, VerifiedClaimValue, + VerifiedRequestClaims, +}; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::cursor::CursorCodec; +use registry_server::mutation::MutationFaultPoint; +use registry_server::postgres::{ + initialize_registry_state_for_catalog_test, install_compiled_schema, ExpectedManagedCatalog, + PostgresRecordMutationService, PostgresRecordReadService, RegistryLockKey, + RegistryStateTestIdentity, +}; +use serde_json::{json, Value}; +use tower::Service as _; +use zeroize::Zeroizing; + +const PRINCIPAL: &str = "batch-principal-must-not-enter-audit"; +const RECORD_CANARY: &str = "batch-record-value-must-not-enter-audit"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_batch_is_bounded_authorized_atomic_and_exactly_replayable() { + let database = TestDatabase::create(8).await; + let (migration, migration_task) = database.connect_migration().await; + let registry = Arc::new(compiled_registry()); + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .expect("migration installs the compiler-owned schema"); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(®istry), + RegistryStateTestIdentity { + package_id: "batch-registry", + environment: "local", + instance_id: "batch-instance", + database_id: "batch-database", + package_revision: "package-batch-1", + package_sequence: 1, + }, + ) + .await + .expect("active package identity is initialized"); + migration_task.abort(); + + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let lock_key = RegistryLockKey::derive("batch-registry").expect("lock key is valid"); + let profile = AuditProfile::production_from_secret_bytes(vec![0x6b; 32].into()) + .expect("test audit profile is keyed"); + let app = mutation_router( + pool.clone(), + registry.clone(), + identity.clone(), + lock_key, + profile.clone(), + None, + ); + let authorized_claims = claims(PRINCIPAL, "case-management", "zone-a"); + let table = ®istry.entities()["widget"].physical_table; + + let openapi = send( + &app, + Method::GET, + "/openapi.json", + Some(authorized_claims.clone()), + &[], + vec![], + ) + .await; + assert_eq!(openapi.status(), StatusCode::OK); + let openapi = body_json(openapi).await; + assert_eq!( + openapi["paths"]["/v1/records/widgets:batch"]["post"]["x-registry-maximumItems"], + 3 + ); + assert!(openapi["paths"]["/v1/records/widgets:batch"]["post"]["requestBody"].is_object()); + + let seed = send_json( + &app, + "/v1/records/widgets", + Some(authorized_claims.clone()), + "seed-key", + json!({"data": { + "jurisdiction": "zone-a", "label": RECORD_CANARY, "secret": "hidden", "quantity": 1 + }}), + ) + .await; + assert_eq!(seed.status(), StatusCode::CREATED); + let seed_etag = header(&seed, "etag"); + let seed_body = body_json(seed).await; + let seed_id = seed_body["id"].as_str().expect("seed id").to_owned(); + + let batch_body = json!({"items": [ + {"operation":"create", "data": { + "jurisdiction":"zone-a", "label":"batch-created", "secret":"not-disclosed", "quantity":2 + }}, + {"operation":"patch", "recordId":seed_id, "ifMatch":seed_etag, "patch":[ + {"op":"replace", "path":"/data/label", "value":"batch-patched"} + ]} + ]}); + let before = effect_counts(&database, table).await; + let first = send_json( + &app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + "batch-key", + batch_body.clone(), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + let first_bytes = response_bytes(first).await; + let first_json: Value = serde_json::from_slice(&first_bytes).expect("batch response JSON"); + let items = first_json["results"] + .as_array() + .expect("ordered batch results"); + assert_eq!(items.len(), 2); + assert_eq!(items[0]["operation"], "create"); + assert_eq!(items[0]["data"]["label"], "batch-created"); + assert_eq!(items[0]["revision"], 1); + assert_eq!(items[1]["id"], seed_id); + assert_eq!(items[1]["operation"], "patch"); + assert_eq!(items[1]["data"]["label"], "batch-patched"); + assert_eq!(items[1]["revision"], 2); + assert!(items + .iter() + .all(|item| item["data"].get("secret").is_none())); + let after = effect_counts(&database, table).await; + assert_eq!(after.current, before.current + 1); + assert_eq!(after.revisions, before.revisions + 2); + assert_eq!(after.outbox, before.outbox + 2); + assert_eq!(after.idempotency, before.idempotency + 1); + + let replay = send_json( + &app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + "batch-key", + batch_body.clone(), + ) + .await; + assert_eq!(replay.status(), StatusCode::OK); + assert_eq!(response_bytes(replay).await, first_bytes); + assert_eq!( + effect_counts(&database, table).await.without_audit(), + after.without_audit() + ); + + let concurrent_before = effect_counts(&database, table).await; + let left = send_json( + &app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + "batch-key", + batch_body.clone(), + ); + let right = send_json( + &app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + "batch-key", + batch_body.clone(), + ); + let (left, right) = tokio::join!(left, right); + assert_eq!(left.status(), StatusCode::OK); + assert_eq!(right.status(), StatusCode::OK); + assert_eq!(response_bytes(left).await, first_bytes); + assert_eq!(response_bytes(right).await, first_bytes); + assert_eq!( + effect_counts(&database, table).await.without_audit(), + concurrent_before.without_audit(), + "concurrent exact replay cannot repeat any mutation effect" + ); + + let changed_order = + json!({"items": [batch_body["items"][1].clone(), batch_body["items"][0].clone()]}); + let conflict = send_json( + &app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + "batch-key", + changed_order, + ) + .await; + assert_eq!(conflict.status(), StatusCode::CONFLICT); + assert_eq!(body_json(conflict).await["code"], "idempotency.conflict"); + + for (label, uri, changed_claims, changed_body) in [ + ( + "etag", + "/v1/records/widgets:batch", + authorized_claims.clone(), + json!({"items":[ + batch_body["items"][0].clone(), + {"operation":"patch", "recordId":seed_id, "ifMatch":"\"rs-different\"", "patch":[ + {"op":"replace", "path":"/data/label", "value":"batch-patched"} + ]} + ]}), + ), + ( + "principal", + "/v1/records/widgets:batch", + claims("different-principal", "case-management", "zone-a"), + batch_body.clone(), + ), + ( + "purpose", + "/v1/records/widgets:batch", + claims(PRINCIPAL, "case-review", "zone-a"), + batch_body.clone(), + ), + ( + "boundary", + "/v1/records/widgets:batch", + claims(PRINCIPAL, "case-management", "zone-b"), + batch_body.clone(), + ), + ( + "profile-and-projection", + "/v1/records/widgets:batch?accessProfile=operator-minimal", + authorized_claims.clone(), + batch_body.clone(), + ), + ] { + let response = send_json(&app, uri, Some(changed_claims), "batch-key", changed_body).await; + assert_eq!(response.status(), StatusCode::CONFLICT, "{label}"); + assert_eq!(body_json(response).await["code"], "idempotency.conflict"); + } + + let mut changed_identity = identity.clone(); + changed_identity.package_revision = "package-batch-2".to_owned(); + let changed_package_app = mutation_router( + pool.clone(), + registry.clone(), + changed_identity, + lock_key, + profile.clone(), + None, + ); + let changed_package = send_json( + &changed_package_app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + "batch-key", + batch_body.clone(), + ) + .await; + assert_eq!(changed_package.status(), StatusCode::SERVICE_UNAVAILABLE); + + let before_atomic = effect_counts(&database, table).await; + let invalid_later = json!({"items": [ + {"operation":"create", "data": { + "jurisdiction":"zone-a", "label":"must-roll-back", "secret":"x", "quantity":4 + }}, + {"operation":"patch", "recordId":seed_id, "ifMatch":"\"rs-stale\"", "patch":[ + {"op":"replace", "path":"/data/label", "value":"never"} + ]} + ]}); + let failed = send_json( + &app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + "rollback-key", + invalid_later, + ) + .await; + assert_eq!(failed.status(), StatusCode::PRECONDITION_FAILED); + assert_eq!( + effect_counts(&database, table).await.without_audit(), + before_atomic.without_audit(), + "a later invalid item cannot commit a valid prefix" + ); + + for (key, body) in [ + ("empty", json!({"items":[]})), + ( + "too-many", + json!({"items":[ + {"operation":"create","data":{}}, {"operation":"create","data":{}}, + {"operation":"create","data":{}}, {"operation":"create","data":{}} + ]}), + ), + ( + "extra-root", + json!({"items":[{"operation":"create","data":{}}],"extra":true}), + ), + ( + "client-id", + json!({"items":[{"operation":"create","recordId":seed_id,"data":{}}]}), + ), + ( + "tombstone", + json!({"items":[{"operation":"tombstone","recordId":seed_id}]}), + ), + ( + "invalid-uuid", + json!({"items":[{"operation":"patch","recordId":"NOT-A-UUID","ifMatch":"\"rs-etag\"","patch":[ + {"op":"replace","path":"/data/label","value":"never"} + ]}]}), + ), + ( + "unwritable-field", + json!({"items":[{"operation":"create","data":{ + "jurisdiction":"zone-a","label":"never-locked","locked":"forbidden","quantity":1 + }}]}), + ), + ] { + let refused = send_json( + &app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + key, + body, + ) + .await; + assert_eq!(refused.status(), StatusCode::BAD_REQUEST, "{key}"); + } + let oversized = json!({"items":[{"operation":"create","data":{ + "jurisdiction":"zone-a","label":"x","secret":"z".repeat(9000),"quantity":1 + }}]}); + let refused = send_json( + &app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + "oversized", + oversized, + ) + .await; + assert_eq!(refused.status(), StatusCode::BAD_REQUEST); + + let top_level_match = send( + &app, + Method::POST, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + &[ + ("content-type", "application/json"), + ("idempotency-key", "top-level-match"), + ("if-match", "\"rs-forbidden\""), + ], + serde_json::to_vec(&json!({"items":[{"operation":"create","data":{}}]})) + .expect("request JSON"), + ) + .await; + assert_eq!(top_level_match.status(), StatusCode::BAD_REQUEST); + let wrong_media = send( + &app, + Method::POST, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + &[ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "wrong-media"), + ], + br#"{"items":[]}"#.to_vec(), + ) + .await; + assert_eq!(wrong_media.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + + let before_hidden_unique = effect_counts(&database, table).await; + let hidden_unique = send_json( + &app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + "hidden-unique", + json!({"items":[{"operation":"create","data":{ + "jurisdiction":"zone-a","label":"batch-created","secret":"different","quantity":7 + }}]}), + ) + .await; + assert_eq!(hidden_unique.status(), StatusCode::CONFLICT); + assert_eq!(body_json(hidden_unique).await["code"], "mutation.conflict"); + assert_eq!( + effect_counts(&database, table).await.without_audit(), + before_hidden_unique.without_audit() + ); + + let patch_without_grant = send_json( + &app, + "/v1/records/widgets:batch?accessProfile=batch-creator", + Some(authorized_claims.clone()), + "create-only-profile", + json!({"items":[{"operation":"patch","recordId":seed_id,"ifMatch":"\"rs-stale\"","patch":[ + {"op":"replace","path":"/data/label","value":"never"} + ]}]}), + ) + .await; + assert_eq!(patch_without_grant.status(), StatusCode::BAD_REQUEST); + let wrong_purpose = send_json( + &app, + "/v1/records/widgets:batch", + Some(claims(PRINCIPAL, "wrong-purpose", "zone-a")), + "purpose", + json!({"items":[{"operation":"create","data":{ + "jurisdiction":"zone-a","label":"never","secret":"x","quantity":1 + }}]}), + ) + .await; + assert_eq!(wrong_purpose.status(), StatusCode::NOT_FOUND); + let wrong_boundary = send_json( + &app, + "/v1/records/widgets:batch", + Some(claims(PRINCIPAL, "case-management", "zone-b")), + "boundary", + json!({"items":[{"operation":"create","data":{ + "jurisdiction":"zone-a","label":"never-boundary","secret":"x","quantity":1 + }}]}), + ) + .await; + assert_eq!(wrong_boundary.status(), StatusCode::SERVICE_UNAVAILABLE); + let extra_query = send_json( + &app, + "/v1/records/widgets:batch?pageSize=1", + Some(authorized_claims.clone()), + "query", + json!({"items":[{"operation":"create","data":{}}]}), + ) + .await; + assert_eq!(extra_query.status(), StatusCode::NOT_FOUND); + + let fault_body = json!({"items":[ + {"operation":"create","data":{ + "jurisdiction":"zone-a","label":"fault-prefix","secret":"x","quantity":9 + }}, + {"operation":"create","data":{ + "jurisdiction":"zone-a","label":"fault-second","secret":"x","quantity":10 + }} + ]}); + for (index, fault) in [ + MutationFaultPoint::BeforeCurrentRow, + MutationFaultPoint::BeforeRevision, + MutationFaultPoint::BeforeOutbox, + MutationFaultPoint::AfterFirstBatchItem, + MutationFaultPoint::BeforeTerminalAudit, + MutationFaultPoint::BeforeIdempotency, + MutationFaultPoint::BeforeCommit, + ] + .into_iter() + .enumerate() + { + let fault_app = mutation_router( + pool.clone(), + registry.clone(), + identity.clone(), + lock_key, + profile.clone(), + Some(fault), + ); + let before_fault = effect_counts(&database, table).await; + let response = send_json( + &fault_app, + "/v1/records/widgets:batch", + Some(authorized_claims.clone()), + &format!("fault-{index}"), + fault_body.clone(), + ) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + effect_counts(&database, table).await.without_audit(), + before_fault.without_audit(), + "fault {fault:?} rolls back every batch effect" + ); + } + + let audit_rows = database + .admin + .query( + "SELECT convert_from(envelope, 'UTF8') FROM registry_internal.registry_audit", + &[], + ) + .await + .expect("administrator inspects minimized audit"); + let audit_text = audit_rows + .iter() + .map(|row| row.get::<_, String>(0)) + .collect::>() + .join("\n"); + assert!(audit_text.contains("\"resultCount\":2")); + assert!(!audit_text.contains(PRINCIPAL)); + assert!(!audit_text.contains(RECORD_CANARY)); + assert!(!audit_text.contains(&seed_id)); + assert!(!audit_text.contains("batch-created")); + assert!(!audit_text.contains("registry_data")); + let committed_terminals: i64 = database + .admin + .query_one( + "SELECT count(*) FROM registry_internal.registry_audit + WHERE convert_from(envelope, 'UTF8') LIKE '%\"operationId\":\"records.widget.batch\"%' + AND convert_from(envelope, 'UTF8') LIKE '%\"phase\":\"terminal\"%' + AND convert_from(envelope, 'UTF8') LIKE '%\"outcome\":\"committed\"%'", + &[], + ) + .await + .expect("administrator inspects batch terminal audit count") + .get(0); + assert_eq!(committed_terminals, 1); + + database.cleanup().await; +} + +fn compiled_registry() -> registry_server::CompiledRegistry { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"batch-registry","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"widget","route":"widgets","mutationMode":"mutable","classification":"public", + "batch":{"maximumItems":3,"maximumBytes":8192}, + "constraints":[{"kind":"unique","fields":["label"]}], + "fields":[ + {"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"label","type":"string","maxLength":128,"required":true,"classification":"public"}, + {"id":"locked","type":"string","maxLength":128,"classification":"internal"}, + {"id":"secret","type":"string","maxLength":128,"classification":"restricted"}, + {"id":"quantity","type":"int64","required":true,"classification":"public"} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management","case-review"], + "operations":["create","get","patch","batch"], + "readableFields":["jurisdiction","label","locked","quantity"], + "writableFields":["jurisdiction","label","secret","quantity"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + },{ + "id":"batch-creator","principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "operations":["create","batch"], + "readableFields":["jurisdiction","label","locked","quantity"], + "writableFields":["jurisdiction","label","secret","quantity"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + },{ + "id":"operator-minimal","principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "operations":["create","patch","batch"], + "readableFields":["label"], + "writableFields":["jurisdiction","label","secret","quantity"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }], + "events":[ + {"id":"widget-created","trigger":"created","projection":["label"]}, + {"id":"widget-patched","trigger":"patched","projection":["label","quantity"]} + ] + }] + }"#, + ) + .expect("batch fixture parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("batch fixture compiles to trusted inventories") +} + +fn mutation_router( + pool: registry_server::postgres::RuntimePool, + registry: Arc, + identity: registry_server::postgres::ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + profile: AuditProfile, + fault: Option, +) -> axum::Router { + let cursors = Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x4f; 32]), Duration::from_secs(300)) + .expect("cursor key is valid"), + ); + let records = Arc::new(PostgresRecordReadService::new( + pool.clone(), + registry.clone(), + identity.clone(), + lock_key, + Duration::from_secs(2), + profile.clone(), + cursors.clone(), + )); + let mutations = PostgresRecordMutationService::new( + pool, + registry.clone(), + identity.clone(), + lock_key, + Duration::from_secs(2), + profile, + ); + let mutations = match fault { + Some(fault) => mutations.with_fault_for_test(fault), + None => mutations, + }; + router(Arc::new( + HttpService::new( + registry, + ReadRuntimeIdentity { + package_revision: identity.package_revision, + schema_fingerprint: identity.schema_fingerprint, + }, + records, + Arc::new(AlwaysReady), + cursors, + ) + .with_postgres_mutations(Arc::new(mutations)), + )) +} + +struct AlwaysReady; + +impl ReadinessProbe for AlwaysReady { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async { true }) + } +} + +fn claims(principal: &str, purpose: &str, jurisdiction: &str) -> VerifiedRequestClaims { + VerifiedRequestClaims::authenticated( + "registry_principal", + principal, + BTreeSet::new(), + Some(purpose.to_owned()), + BTreeMap::from([( + "jurisdiction".to_owned(), + VerifiedClaimValue::direct_string(jurisdiction).expect("direct claim"), + )]), + ) + .expect("verified claims are bounded") +} + +async fn send_json( + app: &axum::Router, + uri: &str, + claims: Option, + key: &str, + body: Value, +) -> axum::response::Response { + send( + app, + Method::POST, + uri, + claims, + &[ + ("content-type", "application/json"), + ("idempotency-key", key), + ], + serde_json::to_vec(&body).expect("request JSON"), + ) + .await +} + +async fn send( + app: &axum::Router, + method: Method, + uri: &str, + claims: Option, + headers: &[(&str, &str)], + body: Vec, +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .body(Body::from(body)) + .expect("request"); + for (name, value) in headers { + request.headers_mut().append( + HeaderName::from_bytes(name.as_bytes()).expect("header name"), + HeaderValue::from_str(value).expect("header value"), + ); + } + if let Some(claims) = claims { + request.extensions_mut().insert(claims); + } + let mut app = app.clone(); + app.call(request).await.expect("response") +} + +fn header(response: &axum::response::Response, name: &str) -> String { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .expect("response header") + .to_owned() +} + +async fn body_json(response: axum::response::Response) -> Value { + serde_json::from_slice(&response_bytes(response).await).expect("JSON response") +} + +async fn response_bytes(response: axum::response::Response) -> Vec { + to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .expect("response body") + .to_vec() +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct EffectCounts { + current: i64, + revisions: i64, + outbox: i64, + audit: i64, + idempotency: i64, +} + +impl EffectCounts { + fn without_audit(self) -> (i64, i64, i64, i64) { + (self.current, self.revisions, self.outbox, self.idempotency) + } +} + +async fn effect_counts(database: &TestDatabase, table: &str) -> EffectCounts { + let row = database + .admin + .query_one( + &format!( + "SELECT + (SELECT count(*) FROM registry_data.\"{table}\"), + (SELECT count(*) FROM registry_internal.registry_revisions), + (SELECT count(*) FROM registry_internal.registry_outbox), + (SELECT count(*) FROM registry_internal.registry_audit), + (SELECT count(*) FROM registry_internal.registry_idempotency)" + ), + &[], + ) + .await + .expect("administrator inspects batch effects"); + EffectCounts { + current: row.get(0), + revisions: row.get(1), + outbox: row.get(2), + audit: row.get(3), + idempotency: row.get(4), + } +} diff --git a/crates/registry-server/tests/postgres_compiled_schema.rs b/crates/registry-server/tests/postgres_compiled_schema.rs new file mode 100644 index 0000000000..ddcfcf0960 --- /dev/null +++ b/crates/registry-server/tests/postgres_compiled_schema.rs @@ -0,0 +1,678 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::time::Duration; + +use postgres_harness::TestDatabase; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::{parse_project_json, parse_project_yaml}; +use registry_server::postgres::{ + begin_record_transaction, initialize_registry_state_for_catalog_test, install_compiled_schema, + verify_catalog_identity_for_catalog, ClaimContext, ExpectedManagedCatalog, RegistryLockKey, + RegistryStateTestIdentity, RowBoundaryContext, +}; + +const RECORD_ALPHA: &str = "00000000-0000-0000-0000-000000000201"; +const RECORD_BETA: &str = "00000000-0000-0000-0000-000000000202"; +const PACKAGE_ID: &str = "compiled-registry"; +const INSTANCE_ID: &str = "compiled-instance"; +const DATABASE_ID: &str = "compiled-database"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compiled_postgres_schema_enforces_context_rls_and_exact_catalog() { + let registry = compiled_registry(); + let database = TestDatabase::create(1).await; + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("administrator installs the declared prerequisite"); + let (migration, migration_task) = database.connect_migration().await; + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .expect("one product installer applies the exact compiled inventory"); + let catalog = ExpectedManagedCatalog::compiled(®istry); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision: "compiled-package-1", + package_sequence: 1, + }, + ) + .await + .expect("compiled catalog binds the active Registry identity"); + verify_catalog_identity_for_catalog( + &migration, + &identity, + &catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("the exact installed catalog passes startup verification"); + + let entity = ®istry.entities()["entry"]; + let table = quote_identifier(&entity.physical_table); + let tenant = quote_identifier(&entity.fields["tenant"].physical_name); + let region = quote_identifier(&entity.fields["region"].physical_name); + let label = quote_identifier(&entity.fields["label"].physical_name); + let event_table = ®istry.entities()["event"].physical_table; + migration_task.abort(); + + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let runtime = pool + .get_for_test() + .await + .expect("runtime connection is available"); + let missing: i64 = runtime + .query_one(&format!("SELECT count(*) FROM registry_data.{table}"), &[]) + .await + .expect("missing context is an empty RLS view") + .get(0); + assert_eq!(missing, 0); + assert!(runtime + .execute( + &format!( + "INSERT INTO registry_data.{table} (record_id, {tenant}, {region}, {label}) + VALUES ($1::text::uuid, $2, $3, $4)" + ), + &[&RECORD_ALPHA, &"tenant-a", &"north", &"forbidden"], + ) + .await + .is_err()); + let update_allowed: bool = runtime + .query_one( + "SELECT has_table_privilege(current_user, $1, 'UPDATE')", + &[&format!("registry_data.{event_table}")], + ) + .await + .expect("create-only privilege probe succeeds") + .get(0); + assert!(!update_allowed, "create-only tables omit UPDATE privilege"); + drop(runtime); + + let lock_key = RegistryLockKey::derive("compiled-schema-test").expect("lock key is bounded"); + let alpha = context( + ®istry, + "writer", + "operations", + "tenant-a", + &["north", "south"], + ); + let beta = context(®istry, "writer", "operations", "tenant-b", &["north"]); + let reviewer = context(®istry, "reviewer", "review", "tenant-b", &["north"]); + assert!(ClaimContext::for_compiled( + ®istry, + "entry", + Some("principal".to_owned()), + "writer", + Some("wrong-purpose".to_owned()), + boundaries("tenant-a", &["north"]), + ) + .is_err()); + + let mut client = pool + .get_for_test() + .await + .expect("pooled runtime client is available"); + insert_row( + &mut client, + lock_key, + &identity, + &alpha, + entity, + RECORD_ALPHA, + "tenant-a", + ) + .await; + insert_row( + &mut client, + lock_key, + &identity, + &beta, + entity, + RECORD_BETA, + "tenant-b", + ) + .await; + + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &alpha, + ) + .await + .expect("exact writer context starts"); + let visible: Vec = transaction + .transaction_for_test() + .query( + &format!("SELECT record_id::text FROM registry_data.{table} ORDER BY record_id"), + &[], + ) + .await + .expect("matching read succeeds") + .into_iter() + .map(|row| row.get(0)) + .collect(); + assert_eq!(visible, [RECORD_ALPHA]); + assert!(transaction + .transaction_for_test() + .execute( + &format!( + "INSERT INTO registry_data.{table} (record_id, {tenant}, {region}, {label}) + VALUES ('00000000-0000-0000-0000-000000000203', 'tenant-b', 'north', 'denied')" + ), + &[], + ) + .await + .is_err()); + transaction + .rollback() + .await + .expect("WITH CHECK refusal transaction rolls back"); + + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &alpha, + ) + .await + .expect("matching update context starts"); + let updated = transaction + .transaction_for_test() + .execute( + &format!( + "UPDATE registry_data.{table} + SET {label} = 'updated', updated_at = transaction_timestamp() + WHERE record_id = $1::text::uuid" + ), + &[&RECORD_ALPHA], + ) + .await + .expect("matching update succeeds"); + assert_eq!(updated, 1); + transaction.commit().await.expect("update commits"); + + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &reviewer, + ) + .await + .expect("read-only profile context starts"); + assert!(transaction + .transaction_for_test() + .execute( + &format!( + "INSERT INTO registry_data.{table} (record_id, {tenant}, {region}, {label}) + VALUES ('00000000-0000-0000-0000-000000000204', 'tenant-b', 'north', 'denied')" + ), + &[], + ) + .await + .is_err()); + transaction + .rollback() + .await + .expect("wrong-profile INSERT refusal rolls back"); + + for denied in [&reviewer, &beta] { + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + denied, + ) + .await + .expect("complete but nonmatching context starts"); + let count: i64 = transaction + .transaction_for_test() + .query_one( + &format!( + "SELECT count(*) FROM registry_data.{table} WHERE record_id = $1::text::uuid" + ), + &[&RECORD_ALPHA], + ) + .await + .expect("nonmatching context receives an empty view") + .get(0); + assert_eq!(count, 0); + transaction.rollback().await.expect("read proof rolls back"); + } + + let clean: bool = client + .query_one( + "SELECT NULLIF(current_setting('registry.principal', true), '') IS NULL + AND NULLIF(current_setting('registry.access_profile', true), '') IS NULL + AND NULLIF(current_setting('registry.purpose', true), '') IS NULL + AND NULLIF(current_setting('registry.row_boundaries', true), '') IS NULL + AND NULLIF(current_setting('registry.active_package_revision', true), '') IS NULL", + &[], + ) + .await + .expect("pool context probe succeeds") + .get(0); + assert!( + clean, + "transaction-local generic context is clean after reuse" + ); + client + .batch_execute( + "BEGIN; + SELECT set_config('registry.principal', 'principal', true); + SELECT set_config('registry.access_profile', 'writer', true); + SELECT set_config('registry.purpose', 'operations', true); + SELECT set_config('registry.row_boundaries', '{malformed', true);", + ) + .await + .expect("malformed context can be seeded only by the database credential holder"); + assert!(client + .query_one(&format!("SELECT count(*) FROM registry_data.{table}"), &[]) + .await + .is_err()); + client + .batch_execute("ROLLBACK") + .await + .expect("malformed context transaction rolls back"); + drop(client); + + assert_catalog_drift_is_rejected(&database, &catalog, &identity, &table).await; + database.cleanup().await; + + install_asset_fixture().await; +} + +async fn insert_row( + client: &mut deadpool_postgres::Client, + lock_key: RegistryLockKey, + identity: ®istry_server::postgres::ExpectedRegistryIdentity, + context: &ClaimContext, + entity: ®istry_server::model::CompiledEntity, + record_id: &str, + tenant_value: &str, +) { + let table = quote_identifier(&entity.physical_table); + let tenant = quote_identifier(&entity.fields["tenant"].physical_name); + let region = quote_identifier(&entity.fields["region"].physical_name); + let label = quote_identifier(&entity.fields["label"].physical_name); + let transaction = + begin_record_transaction(client, lock_key, Duration::from_secs(1), identity, context) + .await + .expect("complete context starts an insert transaction"); + transaction + .transaction_for_test() + .execute( + &format!( + "INSERT INTO registry_data.{table} (record_id, {tenant}, {region}, {label}) + VALUES ($1::text::uuid, $2, $3, $4)" + ), + &[&record_id, &tenant_value, &"north", &"created"], + ) + .await + .expect("matching INSERT policy permits the row"); + transaction.commit().await.expect("insert commits"); +} + +async fn assert_catalog_drift_is_rejected( + database: &TestDatabase, + catalog: &ExpectedManagedCatalog, + identity: ®istry_server::postgres::ExpectedRegistryIdentity, + table: &str, +) { + let (migration, task) = database.connect_migration().await; + database + .admin + .batch_execute(&format!("GRANT SELECT ON registry_data.{table} TO PUBLIC")) + .await + .expect("test administrator introduces PUBLIC grant drift"); + assert!(verify_catalog_identity_for_catalog( + &migration, + identity, + catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .is_err()); + database + .admin + .batch_execute(&format!( + "REVOKE SELECT ON registry_data.{table} FROM PUBLIC; + CREATE POLICY registry_unexpected_policy ON registry_data.{table} FOR SELECT USING (true)" + )) + .await + .expect("test administrator introduces policy drift"); + assert!(verify_catalog_identity_for_catalog( + &migration, + identity, + catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .is_err()); + database + .admin + .batch_execute(&format!( + "DROP POLICY registry_unexpected_policy ON registry_data.{table}; + CREATE TABLE registry_data.registry_unexpected_table (id integer)" + )) + .await + .expect("test administrator introduces table drift"); + assert!(verify_catalog_identity_for_catalog( + &migration, + identity, + catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .is_err()); + database + .admin + .batch_execute(&format!( + "DROP TABLE registry_data.registry_unexpected_table; + ALTER TABLE registry_data.{table} OWNER TO \"{}\"", + database.intruder_role.as_str(), + )) + .await + .expect("test administrator introduces owner drift"); + assert!(verify_catalog_identity_for_catalog( + &migration, + identity, + catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .is_err()); + database + .admin + .batch_execute(&format!( + "ALTER TABLE registry_data.{table} OWNER TO \"{}\"", + database.migration_role.as_str(), + )) + .await + .expect("test administrator restores owner"); + + database + .admin + .batch_execute(&format!( + "CREATE FUNCTION registry_internal.registry_unexpected_trigger() + RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END'; + CREATE TRIGGER registry_unexpected_trigger + BEFORE INSERT ON registry_data.{table} + FOR EACH ROW EXECUTE FUNCTION registry_internal.registry_unexpected_trigger()" + )) + .await + .expect("test administrator introduces trigger and routine drift"); + assert!(verify_catalog_identity_for_catalog( + &migration, + identity, + catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .is_err()); + database + .admin + .batch_execute(&format!( + "DROP TRIGGER registry_unexpected_trigger ON registry_data.{table}" + )) + .await + .expect("test administrator removes trigger drift"); + assert!(verify_catalog_identity_for_catalog( + &migration, + identity, + catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .is_err()); + database + .admin + .batch_execute("DROP FUNCTION registry_internal.registry_unexpected_trigger()") + .await + .expect("test administrator removes routine drift"); + + database + .admin + .batch_execute(&format!( + "CREATE RULE registry_unexpected_rule AS + ON UPDATE TO registry_data.{table} DO ALSO NOTHING" + )) + .await + .expect("test administrator introduces rewrite-rule drift"); + assert!(verify_catalog_identity_for_catalog( + &migration, + identity, + catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .is_err()); + database + .admin + .batch_execute(&format!( + "DROP RULE registry_unexpected_rule ON registry_data.{table}" + )) + .await + .expect("test administrator removes rewrite-rule drift"); + + database + .admin + .batch_execute("CREATE VIEW registry_data.registry_unexpected_view AS SELECT 1 AS value") + .await + .expect("test administrator introduces unsupported relation drift"); + assert!(verify_catalog_identity_for_catalog( + &migration, + identity, + catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .is_err()); + database + .admin + .batch_execute("DROP VIEW registry_data.registry_unexpected_view") + .await + .expect("test administrator removes unsupported relation drift"); + + database + .admin + .batch_execute(&format!( + "CREATE PUBLICATION registry_unexpected_publication FOR TABLE registry_data.{table}" + )) + .await + .expect("test PostgreSQL supports table publication drift"); + assert!(verify_catalog_identity_for_catalog( + &migration, + identity, + catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .is_err()); + database + .admin + .batch_execute("DROP PUBLICATION registry_unexpected_publication") + .await + .expect("test administrator removes publication drift"); + + verify_catalog_identity_for_catalog( + &migration, + identity, + catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("restored exact catalog verifies"); + task.abort(); +} + +async fn install_asset_fixture() { + let project = parse_project_yaml(include_bytes!( + "../../../products/registry-server/acceptance/asset-site-placement/registry.yaml" + )) + .expect("actual asset fixture parses"); + let registry = compile_project(&project, &[], CompileProfile::Authoring) + .expect("actual asset fixture compiles"); + assert!(registry.ddl().requires_btree_gist); + let database = TestDatabase::create(1).await; + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("asset database receives btree_gist"); + let (migration, task) = database.connect_migration().await; + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .expect("actual fixture DDL installs without production fixture types"); + let catalog = ExpectedManagedCatalog::compiled(®istry); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision: "asset-package-1", + package_sequence: 1, + }, + ) + .await + .expect("actual fixture catalog is fingerprinted"); + verify_catalog_identity_for_catalog( + &migration, + &identity, + &catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("actual fixture passes exact catalog startup"); + task.abort(); + database.cleanup().await; +} + +fn context( + registry: ®istry_server::CompiledRegistry, + profile: &str, + purpose: &str, + tenant: &str, + regions: &[&str], +) -> ClaimContext { + ClaimContext::for_compiled( + registry, + "entry", + Some("verified-principal".to_owned()), + profile, + Some(purpose.to_owned()), + boundaries(tenant, regions), + ) + .expect("test context exactly matches the compiled profile") +} + +fn boundaries(tenant: &str, regions: &[&str]) -> Vec { + vec![ + RowBoundaryContext::Equals { + field: "tenant".to_owned(), + value: tenant.to_owned(), + }, + RowBoundaryContext::In { + field: "region".to_owned(), + values: regions.iter().map(|value| (*value).to_owned()).collect(), + }, + ] +} + +fn quote_identifier(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn compiled_registry() -> registry_server::CompiledRegistry { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"compiled-postgres","version":"1","defaultLanguage":"en"}, + "entities":[ + { + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, + {"id":"region","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, + {"id":"label","type":"string","minLength":1,"maxLength":128,"required":true,"classification":"internal"} + ], + "accessProfiles":[ + { + "id":"writer","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["operations"], + "operations":["create","get","list","patch"], + "readableFields":["tenant","region","label"], + "writableFields":["tenant","region","label"], + "rowBoundaries":[ + {"field":"tenant","claim":"tenant_claim","operator":"equals"}, + {"field":"region","claim":"region_claim","operator":"in"} + ] + }, + { + "id":"reviewer","principalClaim":"registry_principal", + "requiredPurposes":["review"], + "operations":["get","list"], + "readableFields":["tenant","region","label"], + "rowBoundaries":[ + {"field":"tenant","claim":"tenant_claim","operator":"equals"}, + {"field":"region","claim":"region_claim","operator":"in"} + ] + } + ] + }, + { + "id":"event","route":"events","mutationMode":"create_only", + "fields":[ + {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"} + ], + "accessProfiles":[{ + "id":"writer","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["operations"], + "operations":["create","get","list"], + "readableFields":["tenant"],"writableFields":["tenant"] + }] + } + ] + }"#, + ) + .expect("compiled PostgreSQL fixture parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("compiled PostgreSQL fixture compiles") +} diff --git a/crates/registry-server/tests/postgres_constraint_races.rs b/crates/registry-server/tests/postgres_constraint_races.rs new file mode 100644 index 0000000000..5d37b38c50 --- /dev/null +++ b/crates/registry-server/tests/postgres_constraint_races.rs @@ -0,0 +1,724 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::collections::BTreeSet; +use std::time::Duration; + +use postgres_harness::TestDatabase; +use registry_platform_audit::AuditProfile; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::mutation::{ + MutationBody, MutationCoordinator, MutationError, MutationPlan, MutationRequest, +}; +use registry_server::postgres::{ + initialize_registry_state_for_catalog_test, install_compiled_schema, ClaimContext, + ExpectedManagedCatalog, RegistryLockKey, RegistryStateTestIdentity, +}; +use serde_json::{Map, Value}; +use tokio_postgres::Client; + +const PACKAGE_ID: &str = "constraint-race-registry"; +const PACKAGE_REVISION: &str = "constraint-race-package-1"; +const PARENT_KEY: &str = "parent-create-key"; +const CHILD_KEY_CANARY: &str = "reference-race-idempotency-canary"; +const UNIQUE_FIRST_KEY_CANARY: &str = "unique-race-first-idempotency-canary"; +const UNIQUE_SECOND_KEY_CANARY: &str = "unique-race-second-idempotency-canary"; +const UNIQUE_VALUE_CANARY: &str = "unique-race-value-canary"; +const TEMPORAL_FIRST_KEY_CANARY: &str = "temporal-race-first-idempotency-canary"; +const TEMPORAL_SECOND_KEY_CANARY: &str = "temporal-race-second-idempotency-canary"; +const TEMPORAL_NON_OVERLAP_KEY_CANARY: &str = "temporal-non-overlap-idempotency-canary"; +const PRINCIPAL_CANARY: &str = "constraint-race-principal-canary"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_reference_and_temporal_races_leave_no_dangling_or_overlapping_records() { + let mut database = TestDatabase::create(8).await; + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("administrator installs the compiled temporal prerequisite"); + let (migration, migration_task) = database.connect_migration().await; + let registry = compiled_registry(); + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .expect("migration installs the compiler-owned constraint schema"); + let catalog = ExpectedManagedCatalog::compiled(®istry); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: "constraint-race-instance", + database_id: "constraint-race-database", + package_revision: PACKAGE_REVISION, + package_sequence: 1, + }, + ) + .await + .expect("migration initializes the exact active package identity"); + migration_task.abort(); + + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let audit_profile = AuditProfile::production_from_secret_bytes(vec![0x71; 32].into()) + .expect("test owns a strong keyed audit profile"); + let coordinator = MutationCoordinator::new( + RegistryLockKey::derive(PACKAGE_ID).expect("registry lock key is bounded"), + Duration::from_secs(5), + identity, + audit_profile, + ); + let parent_plan = MutationPlan::from_compiled(®istry, "records.parent.create") + .expect("parent create plan is compiler-owned"); + let child_plan = MutationPlan::from_compiled(®istry, "records.child.create") + .expect("child create plan is compiler-owned"); + let unique_plan = MutationPlan::from_compiled(®istry, "records.unique-entry.create") + .expect("unique entry create plan is compiler-owned"); + let temporal_plan = MutationPlan::from_compiled(®istry, "records.period.create") + .expect("temporal create plan is compiler-owned"); + let parent_claims = claims(®istry, "parent"); + let child_claims = claims(®istry, "child"); + let unique_claims = claims(®istry, "unique-entry"); + let temporal_claims = claims(®istry, "period"); + + let mut parent_client = pool + .get_for_test() + .await + .expect("parent mutation connection is available"); + let parent = coordinator + .execute( + &mut parent_client, + create_request( + &parent_plan, + PARENT_KEY, + &parent_claims, + Map::from_iter([("name".to_owned(), Value::String("parent-a".to_owned()))]), + &["name"], + ), + ) + .await + .expect("parent exists before the competing removal"); + let parent_id = response_id(&parent); + + let parent_table = quoted(®istry.entities()["parent"].physical_table); + let child = ®istry.entities()["child"]; + let optional_reference = &child.fields["alternate-parent"].physical_name; + let optional_reference_is_required: bool = database + .admin + .query_one( + "SELECT attribute.attnotnull + FROM pg_catalog.pg_attribute attribute + JOIN pg_catalog.pg_class relation ON relation.oid = attribute.attrelid + JOIN pg_catalog.pg_namespace namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = 'registry_data' + AND relation.relname = $1 + AND attribute.attname = $2", + &[&child.physical_table, optional_reference], + ) + .await + .expect("installed optional reference column is visible") + .get(0); + assert!(!optional_reference_is_required); + let restrict_references: i64 = database + .admin + .query_one( + "SELECT count(*) + FROM pg_catalog.pg_constraint constraint_record + JOIN pg_catalog.pg_class relation ON relation.oid = constraint_record.conrelid + JOIN pg_catalog.pg_namespace namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = 'registry_data' + AND relation.relname = $1 + AND constraint_record.contype = 'f' + AND constraint_record.confdeltype = 'r'", + &[&child.physical_table], + ) + .await + .expect("installed reference deletion behavior is visible") + .get(0); + assert_eq!(restrict_references, 2); + let child_table = quoted(&child.physical_table); + let child_parent = quoted(&child.fields["parent"].physical_name); + let (observer, observer_task) = database.connect_migration().await; + let mut child_client = pool + .get_for_test() + .await + .expect("child mutation connection is available"); + let child_pid = backend_pid(&child_client).await; + + let removal = database + .admin + .transaction() + .await + .expect("administrator begins the competing parent removal"); + let removal_pid: i32 = removal + .query_one("SELECT pg_backend_pid()", &[]) + .await + .expect("parent removal backend pid is available") + .get(0); + assert_eq!( + removal + .execute( + &format!( + "DELETE FROM registry_data.{parent_table} WHERE record_id = $1::text::uuid" + ), + &[&parent_id], + ) + .await + .expect("administrator holds the parent removal open"), + 1 + ); + + let child_data = Map::from_iter([ + ("parent".to_owned(), Value::String(parent_id.clone())), + ("name".to_owned(), Value::String("child-a".to_owned())), + ]); + let child_create = coordinator.execute( + &mut child_client, + create_request( + &child_plan, + CHILD_KEY_CANARY, + &child_claims, + child_data, + &["parent", "name"], + ), + ); + let release_removal = async { + wait_until_blocked_by(&observer, &[child_pid], removal_pid).await; + removal + .commit() + .await + .expect("parent removal commits after the child reaches its foreign-key check"); + }; + let (child_result, ()) = tokio::join!(child_create, release_removal); + let child_error = child_result.expect_err("the committed parent removal wins the FK race"); + assert_value_free_conflict( + "reference race", + child_error, + ®istry, + &[CHILD_KEY_CANARY, &parent_id], + ); + + let state = database + .admin + .query_one( + &format!( + "SELECT + (SELECT count(*) FROM registry_data.{parent_table}), + (SELECT count(*) FROM registry_data.{child_table}), + (SELECT count(*) + FROM registry_data.{child_table} child + LEFT JOIN registry_data.{parent_table} parent + ON parent.record_id = child.{child_parent} + WHERE parent.record_id IS NULL)" + ), + &[], + ) + .await + .expect("administrator verifies the final reference state"); + assert_eq!(state.get::<_, i64>(0), 0, "the parent removal won"); + assert_eq!( + state.get::<_, i64>(1), + 0, + "the refused child did not commit" + ); + assert_eq!(state.get::<_, i64>(2), 0, "no dangling reference exists"); + + let unique_table = quoted(®istry.entities()["unique-entry"].physical_table); + let mut unique_first_client = pool + .get_for_test() + .await + .expect("first unique connection is available"); + let mut unique_second_client = pool + .get_for_test() + .await + .expect("second unique connection is available"); + let unique_first_pid = backend_pid(&unique_first_client).await; + let unique_second_pid = backend_pid(&unique_second_client).await; + let unique_barrier = database + .admin + .transaction() + .await + .expect("administrator begins the uniqueness race barrier"); + let unique_barrier_pid: i32 = unique_barrier + .query_one("SELECT pg_backend_pid()", &[]) + .await + .expect("uniqueness barrier backend pid is available") + .get(0); + unique_barrier + .batch_execute(&format!( + "LOCK TABLE registry_data.{unique_table} IN SHARE MODE" + )) + .await + .expect("table barrier holds both unique inserts at PostgreSQL"); + + let unique_data = || { + Map::from_iter([ + ("scope".to_owned(), Value::String("scope-a".to_owned())), + ( + "code".to_owned(), + Value::String(UNIQUE_VALUE_CANARY.to_owned()), + ), + ]) + }; + let unique_first = coordinator.execute( + &mut unique_first_client, + create_request( + &unique_plan, + UNIQUE_FIRST_KEY_CANARY, + &unique_claims, + unique_data(), + &["scope", "code"], + ), + ); + let unique_second = coordinator.execute( + &mut unique_second_client, + create_request( + &unique_plan, + UNIQUE_SECOND_KEY_CANARY, + &unique_claims, + unique_data(), + &["scope", "code"], + ), + ); + let release_unique_barrier = async { + wait_until_blocked_by( + &observer, + &[unique_first_pid, unique_second_pid], + unique_barrier_pid, + ) + .await; + unique_barrier + .commit() + .await + .expect("barrier releases both unique inserts together"); + }; + let (unique_first, unique_second, ()) = + tokio::join!(unique_first, unique_second, release_unique_barrier); + let unique_outcomes = [unique_first, unique_second]; + assert_eq!( + unique_outcomes + .iter() + .filter(|result| result.is_ok()) + .count(), + 1, + "PostgreSQL commits exactly one equal composite key" + ); + let unique_error = unique_outcomes + .into_iter() + .find_map(Result::err) + .expect("one equal composite key is refused"); + assert_value_free_conflict( + "unique race", + unique_error, + ®istry, + &[ + UNIQUE_FIRST_KEY_CANARY, + UNIQUE_SECOND_KEY_CANARY, + UNIQUE_VALUE_CANARY, + ], + ); + assert_eq!( + current_count(&database, &unique_table).await, + 1, + "the database unique constraint prevents a duplicate current row" + ); + + let period_table = quoted(®istry.entities()["period"].physical_table); + let period_scope = quoted(®istry.entities()["period"].fields["scope"].physical_name); + let period_start = quoted(®istry.entities()["period"].fields["valid-from"].physical_name); + let period_end = quoted(®istry.entities()["period"].fields["valid-to"].physical_name); + let mut first_client = pool + .get_for_test() + .await + .expect("first temporal connection is available"); + let mut second_client = pool + .get_for_test() + .await + .expect("second temporal connection is available"); + let first_pid = backend_pid(&first_client).await; + let second_pid = backend_pid(&second_client).await; + let barrier = database + .admin + .transaction() + .await + .expect("administrator begins the temporal race barrier"); + let barrier_pid: i32 = barrier + .query_one("SELECT pg_backend_pid()", &[]) + .await + .expect("temporal barrier backend pid is available") + .get(0); + barrier + .batch_execute(&format!( + "LOCK TABLE registry_data.{period_table} IN SHARE MODE" + )) + .await + .expect("table barrier holds both inserts at PostgreSQL"); + + let first_data = temporal_data("2026-01-01T00:00:00Z", "2026-01-10T00:00:00Z"); + let second_data = temporal_data("2026-01-05T00:00:00Z", "2026-01-15T00:00:00Z"); + let first_create = coordinator.execute( + &mut first_client, + create_request( + &temporal_plan, + TEMPORAL_FIRST_KEY_CANARY, + &temporal_claims, + first_data, + &["scope", "valid-from", "valid-to"], + ), + ); + let second_create = coordinator.execute( + &mut second_client, + create_request( + &temporal_plan, + TEMPORAL_SECOND_KEY_CANARY, + &temporal_claims, + second_data, + &["scope", "valid-from", "valid-to"], + ), + ); + let release_barrier = async { + wait_until_blocked_by(&observer, &[first_pid, second_pid], barrier_pid).await; + barrier + .commit() + .await + .expect("barrier releases both database inserts together"); + }; + let (first, second, ()) = tokio::join!(first_create, second_create, release_barrier); + let outcomes = [first, second]; + assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1); + let temporal_error = outcomes + .into_iter() + .find_map(Result::err) + .expect("one overlapping interval is refused"); + assert_value_free_temporal_refusal( + temporal_error, + ®istry, + &[TEMPORAL_FIRST_KEY_CANARY, TEMPORAL_SECOND_KEY_CANARY], + ); + assert_eq!( + current_count(&database, &period_table).await, + 1, + "the exclusion constraint commits exactly one overlapping interval" + ); + + let mut non_overlap_client = pool + .get_for_test() + .await + .expect("non-overlapping temporal connection is available"); + coordinator + .execute( + &mut non_overlap_client, + create_request( + &temporal_plan, + TEMPORAL_NON_OVERLAP_KEY_CANARY, + &temporal_claims, + temporal_data("2026-01-15T00:00:00Z", "2026-01-20T00:00:00Z"), + &["scope", "valid-from", "valid-to"], + ), + ) + .await + .expect("a non-overlapping interval commits after the race"); + assert_eq!(current_count(&database, &period_table).await, 2); + let overlaps: i64 = database + .admin + .query_one( + &format!( + "SELECT count(*) + FROM registry_data.{period_table} left_period + JOIN registry_data.{period_table} right_period + ON left_period.record_id < right_period.record_id + AND left_period.{period_scope} = right_period.{period_scope} + AND tstzrange(left_period.{period_start}, left_period.{period_end}, '[)') + && tstzrange(right_period.{period_start}, right_period.{period_end}, '[)')" + ), + &[], + ) + .await + .expect("administrator checks final temporal ranges") + .get(0); + assert_eq!(overlaps, 0, "no committed periods overlap in one scope"); + + let refusal_count: i64 = database + .admin + .query_one( + "SELECT count(*) + FROM registry_internal.registry_audit + WHERE convert_from(envelope, 'UTF8') LIKE '%\"phase\":\"refusal\"%'", + &[], + ) + .await + .expect("administrator verifies all constraint refusals were audited") + .get(0); + assert_eq!(refusal_count, 3); + assert_diagnostics_are_minimized(&database, &parent_id).await; + + observer_task.abort(); + database.cleanup().await; +} + +fn compiled_registry() -> registry_server::CompiledRegistry { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"constraint-race-registry","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"parent","route":"parents","mutationMode":"create_only","classification":"public", + "fields":[{"id":"name","type":"string","maxLength":64,"required":true,"classification":"public"}], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","requiredPurposes":["operations"], + "operations":["create","get"],"readableFields":["name"],"writableFields":["name"] + }] + },{ + "id":"child","route":"children","mutationMode":"create_only","classification":"public", + "fields":[ + {"id":"parent","type":"reference","target":"parent","onDelete":"restrict","required":true,"classification":"public"}, + {"id":"alternate-parent","type":"reference","target":"parent","onDelete":"restrict","classification":"public"}, + {"id":"name","type":"string","maxLength":64,"required":true,"classification":"public"} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","requiredPurposes":["operations"], + "operations":["create","get"],"readableFields":["parent","alternate-parent","name"],"writableFields":["parent","alternate-parent","name"] + }] + },{ + "id":"unique-entry","route":"unique-entries","mutationMode":"create_only","classification":"public", + "fields":[ + {"id":"scope","type":"string","maxLength":64,"required":true,"classification":"public"}, + {"id":"code","type":"string","maxLength":64,"required":true,"classification":"public"} + ], + "constraints":[{"kind":"unique","fields":["scope","code"]}], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","requiredPurposes":["operations"], + "operations":["create","get"],"readableFields":["scope","code"],"writableFields":["scope","code"] + }] + },{ + "id":"period","route":"periods","mutationMode":"create_only","classification":"public", + "fields":[ + {"id":"scope","type":"string","maxLength":64,"required":true,"classification":"public"}, + {"id":"valid-from","type":"timestamp","required":true,"classification":"public"}, + {"id":"valid-to","type":"timestamp","required":false,"classification":"public"} + ], + "temporal":{"startField":"valid-from","endField":"valid-to","scopeFields":["scope"]}, + "constraints":[{ + "kind":"temporal-non-overlap","scopeFields":["scope"], + "startField":"valid-from","endField":"valid-to" + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","requiredPurposes":["operations"], + "operations":["create","get"], + "readableFields":["scope","valid-from","valid-to"], + "writableFields":["scope","valid-from","valid-to"] + }] + }] + }"#, + ) + .expect("constraint race fixture parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("constraint race fixture compiles") +} + +fn claims(registry: ®istry_server::CompiledRegistry, entity_id: &str) -> ClaimContext { + ClaimContext::for_compiled( + registry, + entity_id, + Some(PRINCIPAL_CANARY.to_owned()), + "operator", + Some("operations".to_owned()), + Vec::new(), + ) + .expect("claim context is compiler-bound") +} + +fn create_request<'a>( + plan: &'a MutationPlan, + idempotency_key: &'a str, + claims: &'a ClaimContext, + data: Map, + response_fields: &[&str], +) -> MutationRequest<'a> { + MutationRequest { + plan, + idempotency_key, + claims, + record_id: None, + expected_etag: None, + body: MutationBody::Create(data), + response_fields: response_fields + .iter() + .map(|field| (*field).to_owned()) + .collect::>(), + } +} + +fn temporal_data(start: &str, end: &str) -> Map { + Map::from_iter([ + ("scope".to_owned(), Value::String("scope-a".to_owned())), + ("valid-from".to_owned(), Value::String(start.to_owned())), + ("valid-to".to_owned(), Value::String(end.to_owned())), + ]) +} + +fn response_id(outcome: ®istry_server::mutation::MutationOutcome) -> String { + let body: Value = serde_json::from_slice(outcome.response().body()) + .expect("mutation response is canonical JSON"); + body["id"] + .as_str() + .expect("create response contains its record id") + .to_owned() +} + +async fn backend_pid(client: &Client) -> i32 { + client + .query_one("SELECT pg_backend_pid()", &[]) + .await + .expect("backend pid is available") + .get(0) +} + +async fn wait_until_blocked_by(observer: &Client, blocked_pids: &[i32], blocker_pid: i32) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let mut all_blocked = true; + for blocked_pid in blocked_pids { + let blocked: bool = observer + .query_one( + "SELECT $1::integer = ANY(pg_blocking_pids($2::integer))", + &[&blocker_pid, blocked_pid], + ) + .await + .expect("observer checks PostgreSQL blockers") + .get(0); + all_blocked &= blocked; + } + if all_blocked { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("both operations reach the intended PostgreSQL race before release"); +} + +fn assert_value_free_conflict( + race: &str, + error: MutationError, + registry: ®istry_server::CompiledRegistry, + canaries: &[&str], +) { + assert_eq!(error, MutationError::Conflict, "{race} public error"); + assert_eq!(error.to_string(), "mutation conflicts with current state"); + let rendered = format!("{error:?} {error}"); + for canary in canaries.iter().copied().chain([PRINCIPAL_CANARY]) { + assert!(!rendered.contains(canary)); + } + for entity in registry.entities().values() { + assert!(!rendered.contains(&entity.physical_table)); + for field in entity.fields.values() { + assert!(!rendered.contains(&field.physical_name)); + } + } +} + +fn assert_value_free_temporal_refusal( + error: MutationError, + registry: ®istry_server::CompiledRegistry, + canaries: &[&str], +) { + let expected = match error { + MutationError::Conflict => "mutation conflicts with current state", + MutationError::Unavailable => "mutation service is unavailable", + other => panic!("temporal PostgreSQL race returned unexpected public error: {other}"), + }; + assert_eq!(error.to_string(), expected); + let rendered = format!("{error:?} {error}"); + for canary in canaries.iter().copied().chain([PRINCIPAL_CANARY]) { + assert!(!rendered.contains(canary)); + } + for entity in registry.entities().values() { + assert!(!rendered.contains(&entity.physical_table)); + for field in entity.fields.values() { + assert!(!rendered.contains(&field.physical_name)); + } + } +} + +async fn current_count(database: &TestDatabase, quoted_table: &str) -> i64 { + database + .admin + .query_one( + &format!("SELECT count(*) FROM registry_data.{quoted_table}"), + &[], + ) + .await + .expect("administrator counts final current rows") + .get(0) +} + +async fn assert_diagnostics_are_minimized(database: &TestDatabase, parent_id: &str) { + let audit = database + .admin + .query("SELECT envelope FROM registry_internal.registry_audit", &[]) + .await + .expect("administrator inspects minimized audit envelopes") + .into_iter() + .flat_map(|row| row.get::<_, Vec>(0)) + .collect::>(); + let audit = String::from_utf8_lossy(&audit); + for canary in [ + PRINCIPAL_CANARY, + CHILD_KEY_CANARY, + UNIQUE_FIRST_KEY_CANARY, + UNIQUE_SECOND_KEY_CANARY, + UNIQUE_VALUE_CANARY, + TEMPORAL_FIRST_KEY_CANARY, + TEMPORAL_SECOND_KEY_CANARY, + TEMPORAL_NON_OVERLAP_KEY_CANARY, + parent_id, + ] { + assert!(!audit.contains(canary)); + } + + let references = database + .admin + .query( + "SELECT key_reference, binding_reference + FROM registry_internal.registry_idempotency", + &[], + ) + .await + .expect("administrator inspects only keyed idempotency references"); + for row in references { + let key_reference: String = row.get(0); + let binding_reference: String = row.get(1); + for canary in [ + PRINCIPAL_CANARY, + PARENT_KEY, + CHILD_KEY_CANARY, + UNIQUE_FIRST_KEY_CANARY, + UNIQUE_SECOND_KEY_CANARY, + UNIQUE_VALUE_CANARY, + TEMPORAL_FIRST_KEY_CANARY, + TEMPORAL_SECOND_KEY_CANARY, + TEMPORAL_NON_OVERLAP_KEY_CANARY, + parent_id, + ] { + assert!(!key_reference.contains(canary)); + assert!(!binding_reference.contains(canary)); + } + } +} + +fn quoted(identifier: &str) -> String { + format!("\"{identifier}\"") +} diff --git a/crates/registry-server/tests/postgres_data_export.rs b/crates/registry-server/tests/postgres_data_export.rs new file mode 100644 index 0000000000..3febc1df28 --- /dev/null +++ b/crates/registry-server/tests/postgres_data_export.rs @@ -0,0 +1,540 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::sync::Arc; +use std::time::Duration; + +use axum::body::{to_bytes, Body}; +use axum::http::header::AUTHORIZATION; +use axum::http::{HeaderName, HeaderValue, Method, Request}; +use postgres_harness::TestDatabase; +use registry_platform_audit::AuditProfile; +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; +use registry_platform_testing::{oidc_verifier_config, MockIdp}; +use registry_server::api::{ + authenticated_router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, +}; +use registry_server::auth::{ + AuthorityClaimConfig, RegistryAuthenticator, RowBoundaryClaimMapping, RowBoundaryClaimType, +}; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::cursor::CursorCodec; +use registry_server::data::{ + execute_export_page, execute_import_chunk, DataError, DataExportCheckpoint, DataExportPlan, + DataHttpMethod, DataHttpRequest, DataHttpResponse, DataImportCheckpoint, DataImportOperation, + DataImportPlan, +}; +use registry_server::postgres::{ + initialize_registry_state_for_catalog_test, install_compiled_schema, ExpectedManagedCatalog, + PostgresRecordMutationService, PostgresRecordReadService, RegistryLockKey, + RegistryStateTestIdentity, +}; +use serde_json::{json, Value}; +use tower::ServiceExt as _; +use zeroize::Zeroizing; + +const AUDIENCE: &str = "urn:registry-server:data-export"; +const PROFILE: &str = "data-operator"; +const PACKAGE: &str = "package-data-export-1"; +const PRINCIPAL_CANARY: &str = "data-export-principal-must-not-enter-output-or-audit"; +const SECRET_CANARY: &str = "data-export-hidden-value-must-not-leak"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_export_is_authenticated_projected_audited_and_resumable() { + let database = TestDatabase::create(8).await; + let registry = Arc::new(compiled_registry()); + let (migration, migration_task) = database.connect_migration().await; + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .expect("data export schema installs"); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(®istry), + RegistryStateTestIdentity { + package_id: "data-export-registry", + environment: "local", + instance_id: "data-export-instance", + database_id: "data-export-database", + package_revision: PACKAGE, + package_sequence: 1, + }, + ) + .await + .expect("active data export identity initializes"); + drop(migration); + migration_task.abort(); + + let idp = MockIdp::start().await; + let app = authenticated_app(&database, registry.clone(), identity.clone(), &idp); + let token = idp.mint_token(json!({ + "aud":AUDIENCE, + "registry_principal":PRINCIPAL_CANARY, + "purpose":"data-export", + "jurisdictions":["north"] + })); + let wrong_purpose = idp.mint_token(json!({ + "aud":AUDIENCE, + "registry_principal":PRINCIPAL_CANARY, + "purpose":"other-purpose", + "jurisdictions":["north"] + })); + + let input = (0..101) + .map(|index| { + serde_json::to_string(&json!({"operation":"create", "data":{ + "code":format!("ROW-{index:03}"), "jurisdiction":"north", + "secret":SECRET_CANARY + }})) + .expect("seed item serializes") + }) + .collect::>() + .join("\n") + + "\n"; + let import_plan = DataImportPlan::from_jsonl( + ®istry, + "entry", + DataImportOperation::Create, + PROFILE, + input.as_bytes(), + ) + .expect("seed import closes against compiled batch authority"); + let before_import = durable_counts(&database, ®istry).await; + let mut import_checkpoint = DataImportCheckpoint::start( + &import_plan, + &identity.package_revision, + &identity.schema_fingerprint, + ) + .expect("seed checkpoint starts"); + let import_id = import_checkpoint.import_id().to_owned(); + while !import_checkpoint.is_complete() { + execute_import_chunk( + &import_plan, + &mut import_checkpoint, + &identity.package_revision, + &identity.schema_fingerprint, + &import_id, + |request| dispatch(&app, Some(&token), request), + ) + .await + .expect("ordinary authenticated batch path seeds one bounded chunk") + .expect("seed import has a remaining chunk"); + } + let after_import = durable_counts(&database, ®istry).await; + assert_eq!(after_import.current - before_import.current, 101); + assert_eq!(after_import.revisions - before_import.revisions, 101); + assert_eq!(after_import.outbox - before_import.outbox, 101); + assert_eq!(after_import.idempotency - before_import.idempotency, 2); + assert!(after_import.audit > before_import.audit); + + let export_plan = DataExportPlan::from_compiled(®istry, "entry", PROFILE, ["code"]) + .expect("explicit authenticated export permission compiles"); + let (mut checkpoint, initial_resume_state) = DataExportCheckpoint::start( + &export_plan, + &identity.package_revision, + &identity.schema_fingerprint, + ) + .expect("export checkpoint starts"); + let first = execute_export_page( + &export_plan, + &mut checkpoint, + &identity.package_revision, + &identity.schema_fingerprint, + &[], + &initial_resume_state, + |request| dispatch(&app, Some(&token), request), + ) + .await + .expect("first authorized export page succeeds") + .expect("first page exists"); + assert_eq!(first.added_record_count(), 100); + assert!(!first.is_complete()); + let cursor = first + .trusted_next_cursor() + .expect("bounded first page yields a cursor") + .to_owned(); + let (first_output, first_resume_state) = first.into_parts(); + let serialized = checkpoint.canonical_json().expect("checkpoint serializes"); + let partial = parse_json_strict(&serialized).expect("partial checkpoint is strict JSON"); + assert_eq!(partial["nextCursor"], cursor); + for (label, next_cursor, complete) in [ + ("partial-to-complete", Value::Null, true), + ("cursor-deletion", Value::Null, false), + ( + "cursor-substitution", + json!("SYNTACTICALLY-VALID-SUBSTITUTED-CURSOR"), + false, + ), + ] { + let mut forged = partial.clone(); + forged["nextCursor"] = next_cursor; + forged["complete"] = json!(complete); + let error = DataExportCheckpoint::from_json( + &canonicalize_json(&forged).unwrap(), + &export_plan, + &identity.package_revision, + &identity.schema_fingerprint, + &first_output, + &first_resume_state, + ) + .expect_err(label); + assert_eq!(error, DataError::CheckpointMismatch); + assert!(!format!("{error:?} {error}").contains("SUBSTITUTED-CURSOR")); + } + let mut resumed = DataExportCheckpoint::from_json( + &serialized, + &export_plan, + &identity.package_revision, + &identity.schema_fingerprint, + &first_output, + &first_resume_state, + ) + .expect("output and trusted cursor resume exactly"); + let second = execute_export_page( + &export_plan, + &mut resumed, + &identity.package_revision, + &identity.schema_fingerprint, + &first_output, + &first_resume_state, + |request| dispatch(&app, Some(&token), request), + ) + .await + .expect("resumed authorized export page succeeds") + .expect("second page exists"); + assert_eq!(second.added_record_count(), 1); + assert!(second.is_complete()); + assert!(second.trusted_next_cursor().is_none()); + let (output, terminal_resume_state) = second.into_parts(); + let complete_json = resumed + .canonical_json() + .expect("complete checkpoint serializes"); + DataExportCheckpoint::from_json( + &complete_json, + &export_plan, + &identity.package_revision, + &identity.schema_fingerprint, + &output, + &terminal_resume_state, + ) + .expect("the executor-observed terminal response validates once"); + let complete_reuse = execute_export_page( + &export_plan, + &mut resumed, + &identity.package_revision, + &identity.schema_fingerprint, + &output, + &terminal_resume_state, + |_| async { Err::(()) }, + ) + .await + .expect_err("a complete checkpoint cannot be reused as a second terminal success"); + assert_eq!(complete_reuse, DataError::CheckpointMismatch); + let records = output + .strip_suffix(b"\n") + .expect("canonical JSONL ends with newline") + .split(|byte| *byte == b'\n') + .map(|line| parse_json_strict(line).expect("export line is strict JSON")) + .collect::>(); + assert_eq!(records.len(), 101); + let mut exported_codes = Vec::new(); + for record in &records { + assert_eq!(record["data"].as_object().map(|data| data.len()), Some(1)); + exported_codes.push( + record["data"]["code"] + .as_str() + .expect("projected code is a string") + .to_owned(), + ); + assert!(record["id"].is_string()); + assert_eq!(record["revision"], 1); + } + exported_codes.sort(); + assert_eq!( + exported_codes, + (0..101) + .map(|index| format!("ROW-{index:03}")) + .collect::>() + ); + let output_text = String::from_utf8(output).expect("canonical JSONL is UTF-8"); + assert!(!output_text.contains(SECRET_CANARY)); + assert!(!output_text.contains(PRINCIPAL_CANARY)); + assert!(!output_text.contains("jurisdiction")); + + let (mut refused_checkpoint, refused_resume_state) = DataExportCheckpoint::start( + &export_plan, + &identity.package_revision, + &identity.schema_fingerprint, + ) + .unwrap(); + let refused = execute_export_page( + &export_plan, + &mut refused_checkpoint, + &identity.package_revision, + &identity.schema_fingerprint, + &[], + &refused_resume_state, + |request| dispatch(&app, Some(&wrong_purpose), request), + ) + .await + .expect_err("wrong verified purpose is concealed by the normal read path"); + assert_eq!(refused, DataError::OperationRefused); + assert_eq!(refused_checkpoint.output_length(), 0); + assert!(!format!("{refused:?} {refused}").contains(PRINCIPAL_CANARY)); + + let (mut widened_checkpoint, widened_resume_state) = DataExportCheckpoint::start( + &export_plan, + &identity.package_revision, + &identity.schema_fingerprint, + ) + .unwrap(); + let widened_body = canonicalize_json(&json!({ + "items":[{"id":"00000000-0000-4000-8000-000000000001","revision":1, + "data":{"code":"ROW-000","secret":SECRET_CANARY}}], + "pageInfo":{"nextCursor":null} + })) + .unwrap(); + let widened = execute_export_page( + &export_plan, + &mut widened_checkpoint, + &identity.package_revision, + &identity.schema_fingerprint, + &[], + &widened_resume_state, + |_| async { + Ok::<_, ()>( + DataHttpResponse::new( + 200, + Some("application/json".to_owned()), + widened_body.clone(), + ) + .unwrap(), + ) + }, + ) + .await + .expect_err("a widened transport response is discarded before output or checkpoint advance"); + assert_eq!(widened, DataError::InvalidResponse); + assert_eq!(widened_checkpoint.output_length(), 0); + assert!(!format!("{widened:?} {widened}").contains(SECRET_CANARY)); + + let audit: String = database + .admin + .query_one( + "SELECT coalesce(string_agg(convert_from(envelope, 'UTF8'), ''), '') + FROM registry_internal.registry_audit", + &[], + ) + .await + .expect("administrator inspects minimized audit") + .get(0); + assert!(!audit.contains(PRINCIPAL_CANARY)); + assert!(!audit.contains(SECRET_CANARY)); + assert!(!audit.contains("ROW-000")); + + idp.stop().await; + database.cleanup().await; +} + +fn compiled_registry() -> registry_server::CompiledRegistry { + let source = json!({ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"data-export-registry","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry", "route":"entries", "mutationMode":"create_only", + "batch":{"maximumItems":60,"maximumBytes":131072}, + "fields":[ + {"id":"code","type":"string","required":true,"maxLength":16, + "classification":"internal"}, + {"id":"jurisdiction","type":"string","required":true,"maxLength":16, + "classification":"internal"}, + {"id":"secret","type":"text","required":true,"maxLength":160, + "classification":"restricted"} + ], + "constraints":[{"kind":"unique","fields":["code"]}], + "events":[{"id":"entry-created","trigger":"created","projection":["code"]}], + "accessProfiles":[{ + "id":PROFILE, "principalClaim":"registry_principal", + "requiredPurposes":["data-export"], + "operations":["create","batch","list"], + "readableFields":["code"], + "writableFields":["code","jurisdiction","secret"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}], + "allowDataExport":true + }] + }] + }); + let project = parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(); + compile_project(&project, &[], CompileProfile::Authoring).expect("data export project compiles") +} + +fn authenticated_app( + database: &TestDatabase, + registry: Arc, + identity: registry_server::postgres::ExpectedRegistryIdentity, + idp: &MockIdp, +) -> axum::Router { + let pool = database + .runtime_config + .build_pool() + .expect("runtime pool builds"); + let lock_key = RegistryLockKey::derive("data-export-registry").expect("lock key derives"); + let audit = AuditProfile::production_from_secret_bytes(vec![0x61; 32].into()) + .expect("test audit profile is keyed"); + let cursors = Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x43; 32]), Duration::from_secs(300)) + .expect("cursor codec builds"), + ); + let reads = Arc::new(PostgresRecordReadService::new( + pool.clone(), + registry.clone(), + identity.clone(), + lock_key, + Duration::from_secs(2), + audit.clone(), + cursors.clone(), + )); + let mutations = Arc::new(PostgresRecordMutationService::new( + pool, + registry.clone(), + identity.clone(), + lock_key, + Duration::from_secs(2), + audit, + )); + let service = Arc::new( + HttpService::new( + registry.clone(), + ReadRuntimeIdentity { + package_revision: identity.package_revision, + schema_fingerprint: identity.schema_fingerprint, + }, + reads, + Arc::new(AlwaysReady), + cursors, + ) + .with_postgres_mutations(mutations), + ); + let key_source = Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )); + let authenticator = Arc::new( + RegistryAuthenticator::new( + ®istry, + oidc_verifier_config(idp.issuer(), vec![AUDIENCE.to_owned()]), + key_source, + AuthorityClaimConfig::new( + "registry_principal", + Some("purpose".to_owned()), + vec![RowBoundaryClaimMapping::new( + "jurisdictions", + RowBoundaryClaimType::DirectStringSet, + )], + ), + ) + .expect("OIDC authority matches the compiled Registry"), + ); + authenticated_router(service, authenticator) +} + +struct AlwaysReady; + +impl ReadinessProbe for AlwaysReady { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async { true }) + } +} + +#[derive(Clone, Copy)] +struct Counts { + current: i64, + revisions: i64, + outbox: i64, + audit: i64, + idempotency: i64, +} + +async fn durable_counts( + database: &TestDatabase, + registry: ®istry_server::CompiledRegistry, +) -> Counts { + let table = ®istry.entities()["entry"].physical_table; + let row = database + .admin + .query_one( + &format!( + "SELECT + (SELECT count(*) FROM registry_data.\"{table}\"), + (SELECT count(*) FROM registry_internal.registry_revisions), + (SELECT count(*) FROM registry_internal.registry_outbox), + (SELECT count(*) FROM registry_internal.registry_audit), + (SELECT count(*) FROM registry_internal.registry_idempotency)" + ), + &[], + ) + .await + .expect("administrator inspects durable data-operation effects"); + Counts { + current: row.get(0), + revisions: row.get(1), + outbox: row.get(2), + audit: row.get(3), + idempotency: row.get(4), + } +} + +async fn dispatch( + app: &axum::Router, + token: Option<&str>, + request: DataHttpRequest, +) -> Result { + let method = match request.method() { + DataHttpMethod::Get => Method::GET, + DataHttpMethod::Post => Method::POST, + }; + let mut http = Request::builder() + .method(method) + .uri(request.path_and_query()) + .body(Body::from(request.body().to_vec())) + .map_err(|_| ())?; + if let Some(token) = token { + http.headers_mut().insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| ())?, + ); + } + for (name, value) in [ + ("content-type", request.content_type()), + ("idempotency-key", request.idempotency_key()), + ] { + if let Some(value) = value { + http.headers_mut().insert( + HeaderName::from_static(name), + HeaderValue::from_str(value).map_err(|_| ())?, + ); + } + } + let response = app.clone().oneshot(http).await.map_err(|_| ())?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let body = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .map_err(|_| ())? + .to_vec(); + DataHttpResponse::new(status, content_type, body).map_err(|_| ()) +} diff --git a/crates/registry-server/tests/postgres_data_farmer.rs b/crates/registry-server/tests/postgres_data_farmer.rs new file mode 100644 index 0000000000..0754a528b6 --- /dev/null +++ b/crates/registry-server/tests/postgres_data_farmer.rs @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/pilot_acceptance_harness.rs"] +mod pilot_acceptance_harness; +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use axum::http::{Method, StatusCode}; +use pilot_acceptance_harness::{response_bytes, response_json, PilotHarness}; +use registry_server::data::{ + execute_import_chunk, DataError, DataHttpMethod, DataHttpRequest, DataHttpResponse, + DataImportCheckpoint, DataImportOperation, DataImportPlan, +}; +use serde_json::{json, Value}; + +const PROFILE: &str = "farmer-operator"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race_safe() { + let harness = PilotHarness::start("farmer").await; + let north_token = harness.token( + "farmer-registry", + &[("administrative_boundaries", json!(["north-district"]))], + ); + let south_token = harness.token( + "farmer-registry", + &[("administrative_boundaries", json!(["south-district"]))], + ); + let farmer_id = create( + &harness, + &north_token, + "/v1/records/farmers", + "data-farmer-seed", + json!({ + "farmer-code":"F-DATA", "display-name":"Data import operator", + "administrative-boundary":"north-district" + }), + ) + .await; + let holding_id = create( + &harness, + &north_token, + "/v1/records/holdings", + "data-holding-seed", + json!({ + "holding-code":"H-DATA", "farmer":farmer_id, "tenure-type":"owned", + "tenure-start":"2026-01-01", "administrative-boundary":"north-district", + "import-source":"data-seed", "source-record-id":"holding" + }), + ) + .await; + let (package_revision, schema_fingerprint) = active_identity(&harness).await; + + let input = (0..5) + .map(|index| { + serde_json::to_string(&plot_item( + &holding_id, + &format!("P-IMPORT-{index}"), + "bounded-import", + &format!("source-{index}"), + format!("30.{:02}", 10 + index) + .parse() + .expect("bounded longitude parses"), + )) + .expect("farmer import item serializes") + }) + .collect::>() + .join("\n") + + "\n"; + let plan = DataImportPlan::from_jsonl( + &harness.registry, + "plot", + DataImportOperation::Create, + PROFILE, + input.as_bytes(), + ) + .expect("farmer import validates against the compiled model"); + assert_eq!( + plan.chunks().len(), + 2, + "the compiled bound creates two chunks" + ); + let before = effect_counts(&harness).await; + let mut checkpoint = DataImportCheckpoint::start(&plan, &package_revision, &schema_fingerprint) + .expect("checkpoint binds active identity"); + let import_id = checkpoint.import_id().to_owned(); + + let first = execute_import_chunk( + &plan, + &mut checkpoint, + &package_revision, + &schema_fingerprint, + &import_id, + |request| dispatch(&harness, Some(&north_token), request), + ) + .await + .expect("first authenticated HTTP chunk commits") + .expect("one chunk remained"); + assert_eq!(first.chunk_index(), 0); + assert_eq!(first.committed_items(), 4); + assert!(!first.is_complete()); + let serialized = checkpoint + .canonical_json() + .expect("checkpoint serializes canonically"); + let mut resumed = DataImportCheckpoint::from_json( + &serialized, + &plan, + &package_revision, + &schema_fingerprint, + &import_id, + ) + .expect("exact checkpoint resumes"); + let second = execute_import_chunk( + &plan, + &mut resumed, + &package_revision, + &schema_fingerprint, + &import_id, + |request| dispatch(&harness, Some(&north_token), request), + ) + .await + .expect("resumed authenticated HTTP chunk commits") + .expect("one chunk remained"); + assert_eq!(second.chunk_index(), 1); + assert_eq!(second.committed_items(), 1); + assert!(second.is_complete()); + assert!(execute_import_chunk( + &plan, + &mut resumed, + &package_revision, + &schema_fingerprint, + &import_id, + |request| dispatch(&harness, Some(&north_token), request), + ) + .await + .expect("a completed checkpoint is stable") + .is_none()); + let after = effect_counts(&harness).await; + assert_eq!(after.current - before.current, 5); + assert_eq!(after.revisions - before.revisions, 5); + assert_eq!( + after.outbox - before.outbox, + 0, + "a fixture without configured events cannot gain an import-only outbox side path" + ); + assert_eq!(after.idempotency - before.idempotency, 2); + assert!(after.audit > before.audit); + + let unauthorized_input = serde_json::to_string(&plot_item( + &holding_id, + "P-CONCEALED-CANARY", + "concealed-import-source-canary", + "concealed-record-canary", + 30.70, + )) + .expect("negative item serializes") + + "\n"; + let unauthorized_plan = DataImportPlan::from_jsonl( + &harness.registry, + "plot", + DataImportOperation::Create, + PROFILE, + unauthorized_input.as_bytes(), + ) + .expect("offline validation does not invent caller authority"); + let mut unauthorized_checkpoint = + DataImportCheckpoint::start(&unauthorized_plan, &package_revision, &schema_fingerprint) + .expect("negative checkpoint starts"); + let unauthorized_import_id = unauthorized_checkpoint.import_id().to_owned(); + let unauthorized = execute_import_chunk( + &unauthorized_plan, + &mut unauthorized_checkpoint, + &package_revision, + &schema_fingerprint, + &unauthorized_import_id, + |request| dispatch(&harness, Some(&south_token), request), + ) + .await + .expect_err("a token outside the row boundary is refused by normal HTTP authorization"); + assert_eq!(unauthorized, DataError::OperationRefused); + assert_eq!(unauthorized_checkpoint.completed_chunk_count(), 0); + let rendered = format!("{unauthorized:?} {unauthorized}"); + for canary in [ + "P-CONCEALED-CANARY", + "concealed-import-source-canary", + "concealed-record-canary", + &holding_id, + &south_token, + ] { + assert!(!rendered.contains(canary)); + } + + let race_left = one_item_plan( + &harness, + &holding_id, + "P-RACE-LEFT", + "race-source", + "same-key", + 30.80, + ); + let race_right = one_item_plan( + &harness, + &holding_id, + "P-RACE-RIGHT", + "race-source", + "same-key", + 30.81, + ); + let mut left_checkpoint = + DataImportCheckpoint::start(&race_left, &package_revision, &schema_fingerprint).unwrap(); + let mut right_checkpoint = + DataImportCheckpoint::start(&race_right, &package_revision, &schema_fingerprint).unwrap(); + let left_import_id = left_checkpoint.import_id().to_owned(); + let right_import_id = right_checkpoint.import_id().to_owned(); + let left = execute_import_chunk( + &race_left, + &mut left_checkpoint, + &package_revision, + &schema_fingerprint, + &left_import_id, + |request| dispatch(&harness, Some(&north_token), request), + ); + let right = execute_import_chunk( + &race_right, + &mut right_checkpoint, + &package_revision, + &schema_fingerprint, + &right_import_id, + |request| dispatch(&harness, Some(&north_token), request), + ); + let (left, right) = tokio::join!(left, right); + let outcomes = [left, right]; + assert_eq!( + outcomes.iter().filter(|result| result.is_ok()).count(), + 1, + "exactly one independently keyed import wins the database uniqueness race" + ); + assert_eq!( + outcomes + .iter() + .filter(|result| matches!(result, Err(DataError::OperationRefused))) + .count(), + 1 + ); + assert_eq!( + left_checkpoint.completed_chunk_count() + right_checkpoint.completed_chunk_count(), + 1, + "only the committed HTTP response advances a checkpoint" + ); + assert_eq!(race_key_count(&harness).await, 1); + + harness.finish().await; +} + +fn one_item_plan( + harness: &PilotHarness, + holding_id: &str, + plot_code: &str, + source: &str, + source_record_id: &str, + longitude: f64, +) -> DataImportPlan { + let input = serde_json::to_string(&plot_item( + holding_id, + plot_code, + source, + source_record_id, + longitude, + )) + .unwrap() + + "\n"; + DataImportPlan::from_jsonl( + &harness.registry, + "plot", + DataImportOperation::Create, + PROFILE, + input.as_bytes(), + ) + .expect("race import validates") +} + +fn plot_item( + holding_id: &str, + plot_code: &str, + source: &str, + source_record_id: &str, + longitude: f64, +) -> Value { + json!({"operation":"create", "data":{ + "plot-code":plot_code, "holding":holding_id, + "administrative-boundary":"north-district", + "centroid":{"type":"Point","coordinates":[longitude,-9.5]}, + "area-value":"1.2500", "area-unit":"hectare", + "import-source":source, "source-record-id":source_record_id + }}) +} + +async fn dispatch( + harness: &PilotHarness, + token: Option<&str>, + request: DataHttpRequest, +) -> Result { + let method = match request.method() { + DataHttpMethod::Get => Method::GET, + DataHttpMethod::Post => Method::POST, + }; + let mut headers = Vec::new(); + if let Some(content_type) = request.content_type() { + headers.push(("content-type", content_type)); + } + if let Some(key) = request.idempotency_key() { + headers.push(("idempotency-key", key)); + } + let response = harness + .send( + method, + request.path_and_query(), + token, + &headers, + request.body().to_vec(), + ) + .await; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let body = response_bytes(response).await; + DataHttpResponse::new(status, content_type, body).map_err(|_| ()) +} + +async fn create( + harness: &PilotHarness, + token: &str, + route: &str, + key: &str, + data: Value, +) -> String { + let response = harness + .send_json( + Method::POST, + route, + Some(token), + Some(key), + json!({"data":data}), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + response_json(response).await["id"] + .as_str() + .expect("created record has id") + .to_owned() +} + +async fn active_identity(harness: &PilotHarness) -> (String, String) { + let row = harness + .database + .admin + .query_one( + "SELECT active_package_revision, schema_fingerprint FROM registry_internal.registry_state", + &[], + ) + .await + .expect("administrator reads active test identity"); + (row.get(0), row.get(1)) +} + +#[derive(Clone, Copy)] +struct Counts { + current: i64, + revisions: i64, + outbox: i64, + audit: i64, + idempotency: i64, +} + +async fn effect_counts(harness: &PilotHarness) -> Counts { + let table = &harness.registry.entities()["plot"].physical_table; + let row = harness + .database + .admin + .query_one( + &format!( + "SELECT + (SELECT count(*) FROM registry_data.\"{table}\"), + (SELECT count(*) FROM registry_internal.registry_revisions), + (SELECT count(*) FROM registry_internal.registry_outbox), + (SELECT count(*) FROM registry_internal.registry_audit), + (SELECT count(*) FROM registry_internal.registry_idempotency)" + ), + &[], + ) + .await + .expect("administrator inspects durable import effects"); + Counts { + current: row.get(0), + revisions: row.get(1), + outbox: row.get(2), + audit: row.get(3), + idempotency: row.get(4), + } +} + +async fn race_key_count(harness: &PilotHarness) -> i64 { + let entity = &harness.registry.entities()["plot"]; + let table = &entity.physical_table; + let source = &entity.fields["import-source"].physical_name; + let record = &entity.fields["source-record-id"].physical_name; + harness + .database + .admin + .query_one( + &format!( + "SELECT count(*) FROM registry_data.\"{table}\" + WHERE \"{source}\" = 'race-source' AND \"{record}\" = 'same-key'" + ), + &[], + ) + .await + .expect("administrator verifies unique import key") + .get(0) +} diff --git a/crates/registry-server/tests/postgres_fixture_journeys.rs b/crates/registry-server/tests/postgres_fixture_journeys.rs new file mode 100644 index 0000000000..c054091b08 --- /dev/null +++ b/crates/registry-server/tests/postgres_fixture_journeys.rs @@ -0,0 +1,944 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(all(feature = "postgres-test", feature = "tooling", unix))] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use axum::Router; +use postgres_harness::TestDatabase; +use registry_platform_audit::{verify_chain, AuditEnvelope, AuditProfile}; +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_crypto::{generate_private_jwk, sign, GeneratedKeyAlgorithm, PrivateJwk}; +use registry_platform_testing::{fixtures as testing_fixtures, jwks_from_private_jwk, MockIdp}; +use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::fixtures::{ + execute_schema_test, validate_fixture_journeys, validate_schema_test_receipt_for_package, + FixtureError, FixtureModuleSource, FixtureSourceFile, PostgresFixtureTestRunner, + SchemaTestCredentialBinding, SchemaTestCredentialBindings, SchemaTestSources, +}; +use registry_server::package::{ + load_package, prepare_package, PackageBuildRequest, PackageIntent, PackageLoadContext, + PackageMigrationPlanInput, PackageModuleSource, PackageSignature, PackageSourceFile, + PackageTrustAnchor, PreparedPackage, SignaturePolicy, TrustAnchorKey, VerifiedPackage, + FIXTURE_JOURNEYS_PATH, TRUST_ANCHOR_API_VERSION, +}; +use registry_server::postgres::{ + initialize_registry_state_for_catalog_test, install_compiled_schema, + managed_schema_fingerprint, ExpectedManagedCatalog, RegistryStateTestIdentity, +}; +use registry_server::runtime_config::load_runtime_config; +use registry_server::startup::{ + prepare_schema_test_database_with_connection_configs_for_test, + prepare_with_connection_config_for_test, PreparedServer, +}; +use serde::Serialize; +use serde_json::json; +use tempfile::TempDir; +use zeroize::Zeroizing; + +const PROJECT_TEMPLATE: &[u8] = include_bytes!("fixtures/fixture-tooling/project.yaml"); +const MODULE_SOURCE: &[u8] = include_bytes!("fixtures/fixture-tooling/module.yaml"); +const JOURNEY_SOURCE: &[u8] = include_bytes!("fixtures/fixture-tooling/journeys.yaml"); +const TERMINAL_FAILURE_SOURCE: &[u8] = + include_bytes!("fixtures/fixture-tooling/terminal-failure.yaml"); +const COMPILER_SOURCE_REVISION: &str = "fixture-project-source"; +const DATABASE_ID: &str = "fixture-database"; +const INSTANCE_ID: &str = "fixture-instance"; +const AUDIENCE: &str = "urn:registry-server:fixture-journeys"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn fixture_test_runs_strict_journeys_through_the_real_postgres_router() { + let database = TestDatabase::create(8).await; + let (migration, migration_task) = database.connect_migration().await; + let (compiled, project_source) = compiled_fixture(); + let registry = Arc::new(compiled); + let expected_catalog = ExpectedManagedCatalog::compiled(®istry); + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .expect("administrator installs the compiler-owned schema"); + let schema_fingerprint = + managed_schema_fingerprint(&migration, &database.runtime_role, &expected_catalog) + .await + .expect("closed managed schema fingerprint computes"); + let package = package_fixture(&project_source, &schema_fingerprint); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &expected_catalog, + RegistryStateTestIdentity { + package_id: &package.package.manifest().package_id, + environment: &package.package.manifest().environment, + instance_id: &package.package.manifest().instance_id, + database_id: &package.package.manifest().database_id, + package_revision: &package.package.manifest().package_revision, + package_sequence: 1, + }, + ) + .await + .expect("administrator activates the exact verified package identity"); + assert_eq!(identity.schema_fingerprint, schema_fingerprint); + drop(migration); + migration_task.abort(); + + let idp = MockIdp::start().await; + let config_path = package.write_runtime_config(&database, &idp); + let prepared = + prepare_with_connection_config_for_test(&config_path, database.runtime_config.clone()) + .await + .expect("verified startup constructs the authenticated fixture runtime"); + let audit = AuditProfile::production_from_secret_bytes(vec![0x71; 32].into()) + .expect("test audit profile is keyed"); + + let suite = validate_fixture_journeys(JOURNEY_SOURCE, ®istry).expect("journeys preflight"); + let raw = PreparedServer::from_parts_for_test( + "127.0.0.1:0".parse().expect("test address parses"), + Router::new(), + Duration::from_secs(1), + ); + assert_eq!( + prepare_runner(&package, &suite, &raw, successful_tokens(&idp)) + .await + .err(), + Some(FixtureError::ExecutionRefused), + "a raw caller-selected Router cannot obtain fixture runtime provenance" + ); + + let runner = prepare_runner(&package, &suite, &prepared, successful_tokens(&idp)) + .await + .expect("runner derives exact package and same-database facts"); + let completed = runner + .run_all() + .await + .expect("every journey passes through the prepared HTTP router"); + let receipt = completed + .build_receipt(&suite) + .expect("complete real-router journey emits a bound receipt"); + let receipt_bytes = receipt.canonical_bytes().expect("receipt canonicalizes"); + completed + .revalidate_receipt(&receipt_bytes, &suite) + .expect("exact real execution facts revalidate the receipt"); + assert_eq!(receipt.successful_journey_ids(), ["widget-lifecycle"]); + assert!(!format!("{receipt:?}").contains("zone-a")); + + let failure_suite = validate_fixture_journeys(TERMINAL_FAILURE_SOURCE, ®istry) + .expect("terminal-failure journey preflights"); + assert_eq!( + prepare_runner( + &package, + &failure_suite, + &prepared, + vec![operator_token(&idp, true), operator_token(&idp, true)], + ) + .await + .err(), + Some(FixtureError::CandidateBindingRefused), + "a journey suite outside the signed package closure cannot execute" + ); + + assert_exact_durable_journey_outcomes(&database, ®istry, &audit).await; + drop(prepared); + idp.stop().await; + drop(package); + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn production_schema_test_executor_uses_only_prepared_database_and_private_credentials() { + let (compiled, project_source) = compiled_fixture(); + let schema_fingerprint = measure_compiled_schema_fingerprint(&compiled).await; + let package = package_fixture(&project_source, &schema_fingerprint); + let suite = validate_fixture_journeys(JOURNEY_SOURCE, &compiled).expect("journeys preflight"); + let failure_suite = validate_fixture_journeys(TERMINAL_FAILURE_SOURCE, &compiled) + .expect("terminal failure journey preflights"); + let idp = MockIdp::start().await; + + let first = TestDatabase::create(8).await; + let first_config_path = package.write_runtime_config(&first, &idp); + let first_config = + load_runtime_config(&first_config_path).expect("strict runtime config loads"); + let first_database = prepare_schema_test_database_with_connection_configs_for_test( + &first_config, + &package.prepared, + &first.migration_config, + &first.runtime_config, + ) + .await + .expect("clean pre-provisioned database prepares"); + let first_receipt = execute_schema_test( + first_database, + &first_config, + &package.prepared, + &suite, + successful_credential_bindings(&suite, &idp), + ) + .await + .expect("production executor dispatches validated journeys"); + assert_eq!(first_receipt.successful_journey_ids(), ["widget-lifecycle"]); + let first_bytes = first_receipt + .canonical_bytes() + .expect("receipt canonicalizes"); + let first_value: serde_json::Value = + serde_json::from_slice(&first_bytes).expect("receipt JSON parses"); + validate_schema_test_receipt_for_package(&first_bytes, &package.prepared, &suite) + .expect("real PostgreSQL receipt revalidates against the exact unsigned package"); + assert!(first_value.get("registryRevision").is_some()); + assert!(first_value.get("candidatePackageRevision").is_some()); + assert!(first_value.get("signingInputSha256").is_some()); + assert!(first_value.get("currentDatabase").is_none()); + assert!(first_value.get("executionBinding").is_none()); + + let second = TestDatabase::create(8).await; + let second_config_path = package.write_runtime_config(&second, &idp); + let second_config = + load_runtime_config(&second_config_path).expect("second strict runtime config loads"); + let second_database = prepare_schema_test_database_with_connection_configs_for_test( + &second_config, + &package.prepared, + &second.migration_config, + &second.runtime_config, + ) + .await + .expect("second physical database prepares"); + let second_receipt = execute_schema_test( + second_database, + &second_config, + &package.prepared, + &suite, + successful_credential_bindings(&suite, &idp), + ) + .await + .expect("second production execution succeeds"); + assert_eq!( + first_bytes, + second_receipt + .canonical_bytes() + .expect("second receipt canonicalizes"), + "receipt must not bind to physical database or role names" + ); + + let dirty = TestDatabase::create(8).await; + dirty + .admin + .batch_execute("CREATE TABLE registry_data.existing_managed_object(id bigint)") + .await + .expect("administrator can create a dirty managed object"); + let dirty_config_path = package.write_runtime_config(&dirty, &idp); + let dirty_config = load_runtime_config(&dirty_config_path).expect("dirty runtime config loads"); + assert!( + prepare_schema_test_database_with_connection_configs_for_test( + &dirty_config, + &package.prepared, + &dirty.migration_config, + &dirty.runtime_config, + ) + .await + .is_err(), + "candidate DDL must not run against a nonempty managed database" + ); + + let wrong_role = TestDatabase::create(8).await; + let wrong_role_config_path = package.write_runtime_config(&wrong_role, &idp); + let wrong_role_config = + load_runtime_config(&wrong_role_config_path).expect("wrong-role runtime config loads"); + assert!( + prepare_schema_test_database_with_connection_configs_for_test( + &wrong_role_config, + &package.prepared, + &wrong_role.runtime_config, + &wrong_role.runtime_config, + ) + .await + .is_err(), + "runtime role cannot stand in for the migration role" + ); + + let claim_mismatch = TestDatabase::create(8).await; + let claim_mismatch_config_path = package.write_runtime_config(&claim_mismatch, &idp); + let claim_mismatch_config = load_runtime_config(&claim_mismatch_config_path) + .expect("claim-mismatch runtime config loads"); + let claim_mismatch_database = prepare_schema_test_database_with_connection_configs_for_test( + &claim_mismatch_config, + &package.prepared, + &claim_mismatch.migration_config, + &claim_mismatch.runtime_config, + ) + .await + .expect("claim-mismatch database prepares"); + assert_eq!( + execute_schema_test( + claim_mismatch_database, + &claim_mismatch_config, + &package.prepared, + &suite, + overprivileged_credential_bindings(&suite, &idp), + ) + .await + .unwrap_err(), + FixtureError::AuthorityWideningRefused + ); + + let substituted = TestDatabase::create(8).await; + let substituted_config_path = package.write_runtime_config(&substituted, &idp); + let substituted_config = + load_runtime_config(&substituted_config_path).expect("substituted runtime config loads"); + let substituted_database = prepare_schema_test_database_with_connection_configs_for_test( + &substituted_config, + &package.prepared, + &substituted.migration_config, + &substituted.runtime_config, + ) + .await + .expect("substitution database prepares"); + let other_package = package_fixture(&project_source, &schema_fingerprint); + assert_eq!( + execute_schema_test( + substituted_database, + &substituted_config, + &other_package.prepared, + &suite, + successful_credential_bindings(&suite, &idp), + ) + .await + .unwrap_err(), + FixtureError::CandidateBindingRefused + ); + + let terminal_package = package_fixture_with_journeys( + &project_source, + &schema_fingerprint, + TERMINAL_FAILURE_SOURCE, + ); + let terminal = TestDatabase::create(8).await; + let terminal_config_path = terminal_package.write_runtime_config(&terminal, &idp); + let terminal_config = + load_runtime_config(&terminal_config_path).expect("terminal runtime config loads"); + let terminal_database = prepare_schema_test_database_with_connection_configs_for_test( + &terminal_config, + &terminal_package.prepared, + &terminal.migration_config, + &terminal.runtime_config, + ) + .await + .expect("terminal database prepares"); + assert_eq!( + execute_schema_test( + terminal_database, + &terminal_config, + &terminal_package.prepared, + &failure_suite, + terminal_failure_credential_bindings(&failure_suite, &idp), + ) + .await + .unwrap_err(), + FixtureError::ExpectationMismatch + ); + + first.cleanup().await; + second.cleanup().await; + dirty.cleanup().await; + wrong_role.cleanup().await; + claim_mismatch.cleanup().await; + substituted.cleanup().await; + terminal.cleanup().await; + idp.stop().await; +} + +async fn prepare_runner( + package: &PackageFixture, + suite: ®istry_server::fixtures::ValidatedFixtureJourneys, + prepared: &PreparedServer, + bearer_tokens: Vec, +) -> Result { + let modules = [FixtureModuleSource { + id: "fixture-core", + path: "sources/modules/fixture-core.yaml", + bytes: MODULE_SOURCE, + }]; + PostgresFixtureTestRunner::prepare( + &package.package, + &SchemaTestSources { + project: FixtureSourceFile { + path: "sources/project.yaml", + bytes: &package.project, + }, + modules: &modules, + migration_plan: FixtureSourceFile { + path: "database/migration-plan.json", + bytes: &package.migration_plan, + }, + }, + suite, + prepared, + bearer_tokens, + ) + .await +} + +async fn assert_exact_durable_journey_outcomes( + database: &TestDatabase, + registry: ®istry_server::CompiledRegistry, + audit: &AuditProfile, +) { + let table = ®istry.physical_names().entities["widget"].table; + let fields = ®istry.physical_names().entities["widget"].fields; + let quoted = |value: &str| format!("\"{}\"", value.replace('"', "\"\"")); + let rows = database + .admin + .query( + &format!( + "SELECT {label}, {note}, {quantity}, record_revision, record_lifecycle + FROM registry_data.{table} + ORDER BY {label}", + label = quoted(&fields["label"]), + note = quoted(&fields["note"]), + quantity = quoted(&fields["quantity"]), + table = quoted(table), + ), + &[], + ) + .await + .expect("administrator inspects exact current records"); + assert_eq!(rows.len(), 3); + let current = rows + .iter() + .map(|row| { + ( + row.get::<_, String>(0), + row.get::<_, Option>(1), + row.get::<_, i64>(2), + row.get::<_, i64>(3), + row.get::<_, String>(4), + ) + }) + .collect::>(); + assert_eq!( + current, + vec![ + ( + "first".to_owned(), + Some("revised".to_owned()), + 1, + 2, + "active".to_owned() + ), + ("second".to_owned(), None, 2, 1, "active".to_owned()), + ("third".to_owned(), None, 3, 1, "active".to_owned()), + ] + ); + + let counts = database + .admin + .query_one( + "SELECT + (SELECT count(*) FROM registry_internal.registry_revisions), + (SELECT count(*) FROM registry_internal.registry_revisions + WHERE mutation_kind = 'create'), + (SELECT count(*) FROM registry_internal.registry_revisions + WHERE mutation_kind = 'patch'), + (SELECT count(*) FROM registry_internal.registry_idempotency), + (SELECT count(*) FROM registry_internal.registry_idempotency + WHERE result_kind = 'record'), + (SELECT count(*) FROM registry_internal.registry_idempotency + WHERE result_kind = 'batch' AND result_count = 2), + (SELECT count(*) FROM registry_internal.registry_idempotency + WHERE response_status = 201), + (SELECT count(*) FROM registry_internal.registry_idempotency + WHERE response_status = 200), + (SELECT count(*) FROM registry_internal.registry_outbox), + (SELECT count(*) FROM registry_internal.registry_outbox + WHERE event_type = 'widget-created' AND trigger = 'created'), + (SELECT count(*) FROM registry_internal.registry_audit)", + &[], + ) + .await + .expect("administrator inspects exact durable counts"); + assert_eq!(counts.get::<_, i64>(0), 4); + assert_eq!(counts.get::<_, i64>(1), 3); + assert_eq!(counts.get::<_, i64>(2), 1); + assert_eq!(counts.get::<_, i64>(3), 3); + assert_eq!(counts.get::<_, i64>(4), 2); + assert_eq!(counts.get::<_, i64>(5), 1); + assert_eq!(counts.get::<_, i64>(6), 1); + assert_eq!(counts.get::<_, i64>(7), 2); + assert_eq!(counts.get::<_, i64>(8), 3); + assert_eq!(counts.get::<_, i64>(9), 3); + assert_eq!(counts.get::<_, i64>(10), 11); + + let audit_rows = database + .admin + .query( + "SELECT record_hash, envelope FROM registry_internal.registry_audit", + &[], + ) + .await + .expect("administrator reads the closed audit chain"); + let mut by_previous = BTreeMap::, AuditEnvelope>::new(); + for row in audit_rows { + let stored = + <[u8; 32]>::try_from(row.get::<_, Vec>(0)).expect("stored audit hash is exact"); + let envelope: AuditEnvelope = serde_json::from_slice(&row.get::<_, Vec>(1)) + .expect("audit envelope is strict JSON"); + assert_eq!(stored, envelope.record_hash); + assert!(by_previous.insert(envelope.prev_hash, envelope).is_none()); + } + let mut ordered = Vec::new(); + let mut prior = None; + while let Some(envelope) = by_previous.remove(&prior) { + prior = Some(envelope.record_hash); + ordered.push(envelope); + } + assert!(by_previous.is_empty()); + assert_eq!(ordered.len(), 11); + verify_chain(&ordered, &audit.chain_hasher()).expect("keyed audit chain verifies exactly"); + let phases = ordered + .iter() + .fold(BTreeMap::<&str, usize>::new(), |mut counts, envelope| { + let phase = envelope.record["phase"] + .as_str() + .expect("audit phase is closed"); + *counts.entry(phase).or_default() += 1; + counts + }); + assert_eq!( + phases, + BTreeMap::from([("attempt", 5), ("refusal", 1), ("terminal", 5)]) + ); + let audit_bytes = serde_json::to_vec(&ordered).expect("audit inspection serializes"); + let audit_text = String::from_utf8(audit_bytes).expect("audit inspection is UTF-8"); + for canary in ["fixture-operator", "zone-a", "terminal-first"] { + assert!(!audit_text.contains(canary)); + } + + let head = database + .admin + .query_one( + "SELECT last_hash FROM registry_internal.registry_audit_head WHERE singleton", + &[], + ) + .await + .expect("audit head exists") + .get::<_, Vec>(0); + assert_eq!(head, ordered.last().unwrap().record_hash); +} + +struct PackageFixture { + _root: TempDir, + directory: PathBuf, + package_root: PathBuf, + anchor: PathBuf, + revision: String, + prepared: PreparedPackage, + package: VerifiedPackage, + project: Vec, + migration_plan: Vec, +} + +fn package_fixture(project: &[u8], schema_fingerprint: &str) -> PackageFixture { + package_fixture_with_journeys(project, schema_fingerprint, JOURNEY_SOURCE) +} + +fn package_fixture_with_journeys( + project: &[u8], + schema_fingerprint: &str, + journey_source: &[u8], +) -> PackageFixture { + let signing = + generate_private_jwk(GeneratedKeyAlgorithm::Es384).expect("package signing key generates"); + let key_id = signing.public().kid.expect("package signing key has an id"); + let prepared = prepare_package(PackageBuildRequest { + environment: "production".to_owned(), + instance_id: INSTANCE_ID.to_owned(), + database_id: DATABASE_ID.to_owned(), + sequence: 1, + prior_revision: None, + compiler_source_revision: COMPILER_SOURCE_REVISION.to_owned(), + schema_fingerprint: schema_fingerprint.to_owned(), + signature_policy: SignaturePolicy { + threshold: 1, + key_ids: vec![key_id.clone()], + }, + project: PackageSourceFile { + path: "sources/project.yaml".to_owned(), + bytes: project.to_vec(), + }, + modules: vec![PackageModuleSource { + id: "fixture-core".to_owned(), + path: "sources/modules/fixture-core.yaml".to_owned(), + bytes: MODULE_SOURCE.to_vec(), + }], + fixture_journeys: PackageSourceFile { + path: FIXTURE_JOURNEYS_PATH.to_owned(), + bytes: journey_source.to_vec(), + }, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + }) + .expect("fixture package prepares"); + let migration_plan = prepared + .file_bytes() + .get("database/migration-plan.json") + .expect("prepared package includes migration plan") + .clone(); + let root = tempfile::tempdir().expect("temporary package root creates"); + let directory = root + .path() + .canonicalize() + .expect("temporary package root canonicalizes"); + let package_root = directory.join("package"); + let revision = prepared.package_revision().to_owned(); + let signature = + sign(prepared.canonical_signed_bytes(), &signing).expect("package canonical bytes sign"); + prepared + .publish_to_directory( + &package_root, + vec![PackageSignature { + key_id: key_id.clone(), + signature_hex: hex(&signature), + }], + ) + .expect("Production package publishes"); + let anchor = directory.join("trust-anchor.json"); + write_json( + &anchor, + &PackageTrustAnchor { + api_version: TRUST_ANCHOR_API_VERSION.to_owned(), + environment: "production".to_owned(), + instance_id: INSTANCE_ID.to_owned(), + database_id: DATABASE_ID.to_owned(), + threshold: 1, + keys: vec![TrustAnchorKey { + key_id, + jwk: serde_json::to_value(signing.public()).expect("public JWK serializes"), + }], + }, + ); + let package = load_package( + &package_root, + &PackageLoadContext { + environment: "production", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + database_initialization_environment: "production", + compiler_source_revision: COMPILER_SOURCE_REVISION, + trust_anchor: Some(&anchor), + intent: PackageIntent::InitialActivation, + }, + ) + .expect("package closure rederives into VerifiedPackage"); + PackageFixture { + _root: root, + directory, + package_root, + anchor, + revision, + prepared, + package, + project: project.to_vec(), + migration_plan, + } +} + +impl PackageFixture { + fn write_runtime_config(&self, database: &TestDatabase, idp: &MockIdp) -> PathBuf { + let secrets = self.directory.join("secrets"); + fs::create_dir_all(&secrets).expect("fixture secret root creates"); + write_private(&secrets.join("database-url"), b"unused-by-test-startup"); + write_private(&secrets.join("audit-key"), &[0x71; 32]); + write_private(&secrets.join("cursor-key"), &[0x52; 32]); + write_private( + &secrets.join("oidc-jwks"), + &serde_json::to_vec(&jwks_from_private_jwk( + &PrivateJwk::parse(testing_fixtures::ED25519_PRIVATE_JWK) + .expect("test IdP key parses"), + )) + .expect("static JWKS serializes"), + ); + let path = self.directory.join("runtime.yaml"); + fs::write( + &path, + format!( + r#"listener: + bind: 127.0.0.1:9 + trustedProxy: direct +identity: + environment: production + instanceId: {INSTANCE_ID} + databaseId: {DATABASE_ID} + databaseInitializationEnvironment: production +secretProviders: + file: + root: {} +database: + runtimeUrlRef: secret:file/database-url + migrationUrlRef: secret:file/migration-database-url + pool: + maxSize: 8 + waitTimeoutMilliseconds: 2000 + createTimeoutMilliseconds: 2000 + recycleTimeoutMilliseconds: 2000 + roles: + migration: {} + runtime: {} +package: + root: {} + trustAnchorPath: {} + compilerSourceRevision: {COMPILER_SOURCE_REVISION} + activeRevision: {} + activeSequence: 1 +authentication: + oidc: + issuer: {} + audience: {AUDIENCE} + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + maxTokenLifetimeSeconds: 3600 + leewayMilliseconds: 60000 + jwksSource: + kind: static + documentRef: secret:file/oidc-jwks + jwksCache: + cacheTtlSeconds: 60 + negativeCacheTtlSeconds: 1 + refreshCooldownSeconds: 1 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 5000 + outageToleranceSeconds: 0 + authorityClaims: + principal: registry_principal + purpose: purpose + rowBoundaryClaims: + - name: jurisdiction + type: directString +audit: + hashKeyRef: secret:file/audit-key +cursor: + secretRef: secret:file/cursor-key + maxAgeSeconds: 300 +operationalTimeouts: + httpRequestMilliseconds: 5000 + shutdownGraceMilliseconds: 1000 + recordLockMilliseconds: 2000 + migrationLockMilliseconds: 2000 + migrationStatementMilliseconds: 5000 +"#, + secrets.display(), + database.migration_role.as_str(), + database.runtime_role.as_str(), + self.package_root.display(), + self.anchor.display(), + self.revision, + idp.issuer(), + ), + ) + .expect("strict fixture runtime configuration writes"); + set_private_permissions(&path); + path + } +} + +fn successful_tokens(idp: &MockIdp) -> Vec { + let mut tokens = (0..5) + .map(|_| operator_token(idp, true)) + .collect::>(); + tokens.push(operator_token(idp, false)); + tokens +} + +fn successful_credential_bindings( + suite: ®istry_server::fixtures::ValidatedFixtureJourneys, + idp: &MockIdp, +) -> SchemaTestCredentialBindings { + credential_bindings_for_tokens( + suite, + [ + ( + "widget-lifecycle", + "create-widget", + operator_token(idp, true), + ), + ("widget-lifecycle", "get-widget", operator_token(idp, true)), + ( + "widget-lifecycle", + "list-widgets", + operator_token(idp, true), + ), + ( + "widget-lifecycle", + "patch-widget", + operator_token(idp, true), + ), + ( + "widget-lifecycle", + "batch-create-widgets", + operator_token(idp, true), + ), + ( + "widget-lifecycle", + "concealed-without-purpose", + operator_token(idp, false), + ), + ], + ) +} + +fn overprivileged_credential_bindings( + suite: ®istry_server::fixtures::ValidatedFixtureJourneys, + idp: &MockIdp, +) -> SchemaTestCredentialBindings { + let mut overprivileged = json!({ + "aud": AUDIENCE, + "registry_principal": "fixture-operator", + "jurisdiction": "zone-a", + "purpose": "case-management", + "scope": "registry-admin" + }); + credential_bindings_for_tokens( + suite, + [ + ( + "widget-lifecycle", + "create-widget", + idp.mint_token(overprivileged.take()), + ), + ("widget-lifecycle", "get-widget", operator_token(idp, true)), + ( + "widget-lifecycle", + "list-widgets", + operator_token(idp, true), + ), + ( + "widget-lifecycle", + "patch-widget", + operator_token(idp, true), + ), + ( + "widget-lifecycle", + "batch-create-widgets", + operator_token(idp, true), + ), + ( + "widget-lifecycle", + "concealed-without-purpose", + operator_token(idp, false), + ), + ], + ) +} + +fn terminal_failure_credential_bindings( + suite: ®istry_server::fixtures::ValidatedFixtureJourneys, + idp: &MockIdp, +) -> SchemaTestCredentialBindings { + credential_bindings_for_tokens( + suite, + [ + ( + "terminal-failure", + "create-terminal-record", + operator_token(idp, true), + ), + ( + "terminal-failure", + "duplicate-terminal-record", + operator_token(idp, true), + ), + ], + ) +} + +fn credential_bindings_for_tokens( + suite: ®istry_server::fixtures::ValidatedFixtureJourneys, + tokens: [(&'static str, &'static str, String); N], +) -> SchemaTestCredentialBindings { + SchemaTestCredentialBindings::new( + suite, + tokens + .into_iter() + .map(|(journey, step, token)| { + SchemaTestCredentialBinding::bearer(journey, step, Zeroizing::new(token)) + }) + .collect(), + ) + .expect("credential bindings match validated journeys") +} + +fn operator_token(idp: &MockIdp, purpose: bool) -> String { + let mut claims = json!({ + "aud": AUDIENCE, + "registry_principal": "fixture-operator", + "jurisdiction": "zone-a", + }); + if purpose { + claims["purpose"] = json!("case-management"); + } + idp.mint_token(claims) +} + +async fn measure_compiled_schema_fingerprint( + registry: ®istry_server::CompiledRegistry, +) -> String { + let database = TestDatabase::create(2).await; + let (migration, migration_task) = database.connect_migration().await; + let expected_catalog = ExpectedManagedCatalog::compiled(registry); + install_compiled_schema(&migration, registry, &database.runtime_role) + .await + .expect("administrator installs candidate schema for fingerprinting"); + let fingerprint = + managed_schema_fingerprint(&migration, &database.runtime_role, &expected_catalog) + .await + .expect("candidate schema fingerprint computes"); + drop(migration); + migration_task.abort(); + database.cleanup().await; + fingerprint +} + +fn write_json(path: &Path, value: &impl Serialize) { + let bytes = canonicalize_json(&serde_json::to_value(value).expect("value serializes")) + .expect("value canonicalizes"); + write_private(path, &bytes); +} + +fn write_private(path: &Path, bytes: &[u8]) { + fs::write(path, bytes).expect("private fixture file writes"); + set_private_permissions(path); +} + +fn set_private_permissions(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("private fixture permissions set"); +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(DIGITS[usize::from(byte >> 4)] as char); + encoded.push(DIGITS[usize::from(byte & 0x0f)] as char); + } + encoded +} + +fn compiled_fixture() -> (registry_server::CompiledRegistry, Vec) { + let module = parse_module_yaml(MODULE_SOURCE).expect("module fixture parses"); + let project_source = String::from_utf8(PROJECT_TEMPLATE.to_vec()) + .expect("project fixture is UTF-8") + .replace("MODULE_DIGEST", &module_digest(&module)) + .replace("environment: local", "environment: production") + .into_bytes(); + let project = parse_project_yaml(&project_source).expect("project fixture parses"); + let registry = compile_project(&project, &[module], CompileProfile::Production) + .expect("fixture project compiles in Production"); + (registry, project_source) +} diff --git a/crates/registry-server/tests/postgres_kernel.rs b/crates/registry-server/tests/postgres_kernel.rs new file mode 100644 index 0000000000..77bd8d710b --- /dev/null +++ b/crates/registry-server/tests/postgres_kernel.rs @@ -0,0 +1,680 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "runtime")] + +#[path = "support/postgres_harness.rs"] +mod postgres_harness; + +use std::time::Duration; + +use postgres_harness::TestDatabase; +use registry_server::postgres::{ + begin_record_transaction, initialize_kernel_registry_state_for_test, install_kernel_schema, + verify_btree_gist, verify_catalog_identity, verify_migration_role, verify_runtime_role, + ClaimContext, DedicatedApplyConnection, ExpectedRegistryIdentity, PostgresKernelError, + RegistryLockKey, RegistryStateTestIdentity, +}; + +const RECORD_ALPHA: &str = "00000000-0000-0000-0000-000000000001"; +const PACKAGE_ID: &str = "kernel-registry"; +const INSTANCE_ID: &str = "kernel-instance"; +const DATABASE_ID: &str = "kernel-database"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation() { + let database = TestDatabase::create(1).await; + let (migration, migration_task) = database.connect_migration().await; + + let missing_extension = verify_btree_gist(&migration).await; + assert!(matches!( + missing_extension, + Err(PostgresKernelError::CatalogInvariant(_)) + )); + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("administrator installs the declared prerequisite"); + verify_btree_gist(&migration) + .await + .expect("migration preflight sees administrator-installed btree_gist"); + verify_migration_role(&migration, &database.migration_role) + .await + .expect("migration role owns schemas without administrative database authority"); + install_kernel_schema(&migration, &database.runtime_role) + .await + .expect("migration role installs managed kernel objects"); + let initial = initialize_kernel_registry_state_for_test( + &migration, + &database.runtime_role, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision: "package-1", + package_sequence: 1, + }, + ) + .await + .expect("migration role initializes exact Registry identity"); + migration_task.abort(); + + let pool = database + .runtime_config + .build_pool() + .expect("bounded verified-recycling pool builds"); + let server_tls: String = database + .admin + .query_one("SHOW ssl", &[]) + .await + .expect("test server reports its TLS posture") + .get(0); + assert_eq!(server_tls, "off", "the pinned test service is plaintext"); + let tls_pool = database + .tls_runtime_config + .build_pool() + .expect("strict TLS pool configuration builds"); + assert!( + tls_pool.get_for_test().await.is_err(), + "required TLS must not downgrade against a plaintext server" + ); + let runtime = pool + .get_for_test() + .await + .expect("runtime connection is available"); + verify_runtime_role(&**runtime, &database.migration_role) + .await + .expect("runtime role has no ownership, bypass, or DDL authority"); + assert!(runtime + .batch_execute("CREATE TABLE registry_data.forbidden (id integer)") + .await + .is_err()); + assert!(runtime + .batch_execute("CREATE EXTENSION hstore") + .await + .is_err()); + assert!(runtime + .batch_execute("UPDATE registry_internal.registry_state SET maintenance_status = 'failed'") + .await + .is_err()); + verify_catalog_identity( + &**runtime, + &initial, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("runtime verifies exact package and catalog identity"); + database + .admin + .batch_execute(&format!( + "GRANT USAGE ON SCHEMA registry_data TO \"{}\";\n\ + GRANT SELECT ON registry_data.kernel_records TO \"{}\";", + database.intruder_role.as_str(), + database.intruder_role.as_str(), + )) + .await + .expect("test administrator can seed unexpected ACL drift"); + let acl_drift = verify_catalog_identity( + &**runtime, + &initial, + &database.migration_role, + &database.runtime_role, + ) + .await; + assert!(matches!( + acl_drift, + Err(PostgresKernelError::CatalogInvariant(_)) + | Err(PostgresKernelError::RegistryUnavailable) + )); + database + .admin + .batch_execute(&format!( + "REVOKE SELECT ON registry_data.kernel_records FROM \"{}\";\n\ + REVOKE USAGE ON SCHEMA registry_data FROM \"{}\";", + database.intruder_role.as_str(), + database.intruder_role.as_str(), + )) + .await + .expect("test administrator can remove seeded ACL drift"); + verify_catalog_identity( + &**runtime, + &initial, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("exact ACL restoration returns the catalog to its package identity"); + + let invisible: i64 = runtime + .query_one("SELECT count(*) FROM registry_data.kernel_records", &[]) + .await + .expect("missing context remains a valid empty RLS view") + .get(0); + assert_eq!(invisible, 0); + assert!(runtime + .execute( + "INSERT INTO registry_data.kernel_records + (record_id, authority, payload, package_revision) + VALUES (CAST($1::text AS uuid), 'alpha', 'secret', 'package-1')", + &[&RECORD_ALPHA], + ) + .await + .is_err()); + drop(runtime); + + let lock_key = RegistryLockKey::derive("registry-under-test") + .expect("bounded Registry id derives a lock key"); + let alpha = claims("alpha"); + let beta = claims("beta"); + + let mut client = pool + .get_for_test() + .await + .expect("pooled client is available"); + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &initial, + &alpha, + ) + .await + .expect("matching package and complete claims pass the record gate"); + transaction + .transaction_for_test() + .execute( + "INSERT INTO registry_data.kernel_records + (record_id, authority, payload, package_revision) + VALUES (CAST($1::text AS uuid), 'alpha', 'secret', 'package-1')", + &[&RECORD_ALPHA], + ) + .await + .expect("RLS permits the matching authority"); + let dynamic = transaction + .transaction_for_test() + .query_one( + "SELECT record_id::text, authority, payload + FROM registry_data.kernel_records WHERE record_id = CAST($1::text AS uuid)", + &[&RECORD_ALPHA], + ) + .await + .expect("dynamic result query succeeds"); + let dynamic_columns: Vec<&str> = dynamic + .columns() + .iter() + .map(tokio_postgres::Column::name) + .collect(); + assert_eq!(dynamic_columns, ["record_id", "authority", "payload"]); + assert_eq!( + dynamic.try_get::<_, String>(1).expect("authority is text"), + "alpha" + ); + transaction + .commit() + .await + .expect("record transaction commits"); + drop(client); + assert_pool_context_clean(&pool).await; + + let mut client = pool + .get_for_test() + .await + .expect("same-size pool remains available"); + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &initial, + &beta, + ) + .await + .expect("a second authority obtains a fresh transaction"); + let count: i64 = transaction + .transaction_for_test() + .query_one("SELECT count(*) FROM registry_data.kernel_records", &[]) + .await + .expect("RLS query succeeds") + .get(0); + assert_eq!(count, 0, "alpha authority must not leak to beta"); + transaction + .rollback() + .await + .expect("explicit rollback succeeds"); + drop(client); + assert_pool_context_clean(&pool).await; + + sql_error_does_not_leak(&pool, lock_key, &initial, &alpha).await; + query_cancellation_does_not_leak(&pool, lock_key, &initial, &alpha).await; + task_cancellation_does_not_leak(&pool, lock_key, &initial, &alpha).await; + panic_does_not_leak(&pool, lock_key, &initial, &alpha).await; + forced_disconnect_is_recycled(&database, &pool).await; + + let target = ExpectedRegistryIdentity { + package_id: initial.package_id.clone(), + environment: initial.environment.clone(), + instance_id: initial.instance_id.clone(), + database_id: initial.database_id.clone(), + package_revision: "package-2".to_owned(), + schema_fingerprint: initial.schema_fingerprint.clone(), + package_sequence: 2, + }; + let mut apply = DedicatedApplyConnection::acquire( + &database.migration_config, + lock_key, + Duration::from_secs(2), + ) + .await + .expect("dedicated migration connection acquires exclusive lock"); + apply + .mark_applying(&initial, &target.package_revision) + .await + .expect("maintenance is durably committed while lock remains held"); + let pool_for_blocked_record = pool.clone(); + let old_for_blocked_record = initial.clone(); + let blocked = tokio::spawn(async move { + let mut client = pool_for_blocked_record + .get_for_test() + .await + .expect("pool get succeeds"); + begin_record_transaction( + &mut client, + lock_key, + Duration::from_millis(100), + &old_for_blocked_record, + &claims("alpha"), + ) + .await + .map(|_| ()) + }) + .await + .expect("blocked record task joins"); + assert!(matches!( + blocked, + Err(PostgresKernelError::RegistryUnavailable) + )); + database + .admin + .batch_execute(&format!( + "ALTER TABLE registry_data.kernel_records OWNER TO \"{}\";\n\ + ALTER TABLE registry_internal.registry_state OWNER TO \"{}\";\n\ + ALTER SCHEMA registry_data OWNER TO \"{}\";\n\ + ALTER SCHEMA registry_internal OWNER TO \"{}\";", + database.intruder_role.as_str(), + database.intruder_role.as_str(), + database.intruder_role.as_str(), + database.intruder_role.as_str(), + )) + .await + .expect("test administrator can seed coherent ownership drift"); + let owner_drift = apply + .activate(&target, &database.migration_role, &database.runtime_role) + .await; + assert!(matches!( + owner_drift, + Err(PostgresKernelError::CatalogInvariant(_)) + )); + let maintenance_status: String = database + .admin + .query_one( + "SELECT maintenance_status FROM registry_internal.registry_state WHERE singleton", + &[], + ) + .await + .expect("maintenance state remains readable") + .get(0); + assert_eq!(maintenance_status, "applying"); + database + .admin + .batch_execute(&format!( + "ALTER TABLE registry_data.kernel_records OWNER TO \"{}\";\n\ + ALTER TABLE registry_internal.registry_state OWNER TO \"{}\";\n\ + ALTER SCHEMA registry_data OWNER TO \"{}\";\n\ + ALTER SCHEMA registry_internal OWNER TO \"{}\";", + database.migration_role.as_str(), + database.migration_role.as_str(), + database.migration_role.as_str(), + database.migration_role.as_str(), + )) + .await + .expect("test administrator restores exact migration ownership"); + apply + .activate(&target, &database.migration_role, &database.runtime_role) + .await + .expect("target activates atomically"); + apply + .release() + .await + .expect("exclusive apply lock releases"); + + let mut client = pool + .get_for_test() + .await + .expect("pool recovers after activation"); + { + let old_runtime = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &initial, + &alpha, + ) + .await; + assert!(matches!( + old_runtime, + Err(PostgresKernelError::RegistryUnavailable) + )); + } + let current_runtime = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &target, + &alpha, + ) + .await; + current_runtime + .expect("runtime loaded with the activated package becomes usable") + .rollback() + .await + .expect("final rollback succeeds"); + drop(client); + + let failed_target = ExpectedRegistryIdentity { + package_revision: "package-3".to_owned(), + package_sequence: 3, + ..target.clone() + }; + let mut apply = DedicatedApplyConnection::acquire( + &database.migration_config, + lock_key, + Duration::from_secs(2), + ) + .await + .expect("second apply obtains the exclusive lock"); + apply + .mark_applying(&target, &failed_target.package_revision) + .await + .expect("second maintenance transition commits"); + apply + .mark_failed() + .await + .expect("failed maintenance is durable"); + apply + .release() + .await + .expect("failed apply releases its lock"); + let mut client = pool + .get_for_test() + .await + .expect("pool is reachable after failed apply"); + { + let unavailable = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &target, + &alpha, + ) + .await; + assert!(matches!( + unavailable, + Err(PostgresKernelError::RegistryUnavailable) + )); + } + drop(client); + + let mut recovery = DedicatedApplyConnection::acquire( + &database.migration_config, + lock_key, + Duration::from_secs(2), + ) + .await + .expect("recovery obtains the exclusive Registry lock"); + recovery + .activate( + &failed_target, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("failed maintenance clears only through reconciled activation"); + recovery + .release() + .await + .expect("recovery releases the Registry lock"); + let mut client = pool + .get_for_test() + .await + .expect("pool remains available after recovery"); + begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &failed_target, + &alpha, + ) + .await + .expect("reconciled package is record-ready") + .rollback() + .await + .expect("recovery proof rollback succeeds"); + drop(client); + + let crash_lock = DedicatedApplyConnection::acquire( + &database.migration_config, + lock_key, + Duration::from_secs(2), + ) + .await + .expect("dedicated apply connection acquires a final lock"); + drop(crash_lock); + let recovered_lock = DedicatedApplyConnection::acquire( + &database.migration_config, + lock_key, + Duration::from_secs(2), + ) + .await + .expect("connection loss releases the session advisory lock"); + recovered_lock + .release() + .await + .expect("recovered apply lock releases cleanly"); + + database.cleanup().await; +} + +fn claims(authority: &str) -> ClaimContext { + ClaimContext::kernel_for_test( + format!("principal-{authority}"), + "operator".to_owned(), + Some("registry-administration".to_owned()), + authority.to_owned(), + ) + .expect("kernel test claims are bounded") +} + +async fn assert_pool_context_clean(pool: ®istry_server::postgres::RuntimePool) { + let client = pool + .get_for_test() + .await + .expect("pool returns a connection"); + let clean: bool = client + .query_one( + "SELECT NULLIF(current_setting('registry.principal', true), '') IS NULL + AND NULLIF(current_setting('registry.access_profile', true), '') IS NULL + AND NULLIF(current_setting('registry.purpose', true), '') IS NULL + AND NULLIF(current_setting('registry.row_boundaries', true), '') IS NULL + AND NULLIF(current_setting('registry.active_package_revision', true), '') IS NULL", + &[], + ) + .await + .expect("context probe succeeds") + .get(0); + assert!( + clean, + "transaction-local claims must not survive pool return" + ); +} + +async fn sql_error_does_not_leak( + pool: ®istry_server::postgres::RuntimePool, + lock_key: RegistryLockKey, + identity: &ExpectedRegistryIdentity, + context: &ClaimContext, +) { + let mut client = pool + .get_for_test() + .await + .expect("pool returns a connection"); + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + identity, + context, + ) + .await + .expect("record transaction starts"); + assert!(transaction + .transaction_for_test() + .query_one("SELECT 'not-an-integer'::integer", &[]) + .await + .is_err()); + drop(transaction); + drop(client); + assert_pool_context_clean(pool).await; +} + +async fn query_cancellation_does_not_leak( + pool: ®istry_server::postgres::RuntimePool, + lock_key: RegistryLockKey, + identity: &ExpectedRegistryIdentity, + context: &ClaimContext, +) { + let mut client = pool + .get_for_test() + .await + .expect("pool returns a connection"); + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + identity, + context, + ) + .await + .expect("record transaction starts"); + let cancellation = transaction.transaction_for_test().client().cancel_token(); + { + let query = transaction + .transaction_for_test() + .query_one("SELECT pg_sleep(10)", &[]); + tokio::pin!(query); + tokio::select! { + result = &mut query => panic!("sleep query completed before cancellation: {result:?}"), + () = tokio::time::sleep(Duration::from_millis(25)) => { + cancellation + .cancel_query(tokio_postgres::NoTls) + .await + .expect("query cancellation reaches the same test server"); + } + } + assert!(query.await.is_err(), "cancelled query must fail"); + } + drop(transaction); + drop(client); + assert_pool_context_clean(pool).await; +} + +async fn task_cancellation_does_not_leak( + pool: ®istry_server::postgres::RuntimePool, + lock_key: RegistryLockKey, + identity: &ExpectedRegistryIdentity, + context: &ClaimContext, +) { + let task_pool = pool.clone(); + let identity = identity.clone(); + let context = context.clone(); + let (ready, started) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let mut client = task_pool + .get_for_test() + .await + .expect("pool returns a connection"); + let _transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &context, + ) + .await + .expect("record transaction starts"); + ready + .send(()) + .expect("cancellation proof receiver remains available"); + std::future::pending::<()>().await; + }); + started + .await + .expect("cancellation proof reaches the guarded transaction"); + task.abort(); + let _ = task.await; + assert_pool_context_clean(pool).await; +} + +async fn panic_does_not_leak( + pool: ®istry_server::postgres::RuntimePool, + lock_key: RegistryLockKey, + identity: &ExpectedRegistryIdentity, + context: &ClaimContext, +) { + let task_pool = pool.clone(); + let identity = identity.clone(); + let context = context.clone(); + let task = tokio::spawn(async move { + let mut client = task_pool + .get_for_test() + .await + .expect("pool returns a connection"); + let _transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &context, + ) + .await + .expect("record transaction starts"); + panic!("intentional pool-isolation proof panic"); + }); + assert!(task + .await + .expect_err("task intentionally panics") + .is_panic()); + assert_pool_context_clean(pool).await; +} + +async fn forced_disconnect_is_recycled( + database: &TestDatabase, + pool: ®istry_server::postgres::RuntimePool, +) { + let client = pool + .get_for_test() + .await + .expect("pool returns a connection"); + let process_id: i32 = client + .query_one("SELECT pg_backend_pid()", &[]) + .await + .expect("backend pid is available") + .get(0); + database + .admin + .execute("SELECT pg_terminate_backend($1)", &[&process_id]) + .await + .expect("test administrator can terminate the isolated runtime backend"); + drop(client); + assert_pool_context_clean(pool).await; +} diff --git a/crates/registry-server/tests/postgres_migration.rs b/crates/registry-server/tests/postgres_migration.rs new file mode 100644 index 0000000000..a382851325 --- /dev/null +++ b/crates/registry-server/tests/postgres_migration.rs @@ -0,0 +1,1622 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(all(feature = "postgres-test", feature = "tooling", unix))] + +#[path = "support/postgres_harness.rs"] +mod postgres_harness; + +use std::{fs, os::unix::fs::PermissionsExt as _, time::Duration}; + +use postgres_harness::TestDatabase; +use registry_platform_canonical_json::canonicalize_json; +use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::migration::{ + apply_verified_package, ApplyPrecondition, ApplyRoles, ApplyTimeouts, + ApplyVerifiedPackageRequest, DestructiveBackupEvidence, MigrationError, + ReviewedMigrationFaultPoint, +}; +use registry_server::migration_plan::{ + ArtifactDigestBinding, ChunkCursorProtocol, ExternalBackupBinding, MigrationRehearsalReceipt, + RehearsalFixture, RehearsalProofs, RehearsalRowAssertion, ReviewedChangeCover, + ReviewedMigrationAssertionDescriptor, ReviewedMigrationDescriptor, ReviewedMigrationFile, + ReviewedMigrationObject, ReviewedMigrationObjectKind, ReviewedMigrationRecovery, + ReviewedMigrationSource, ReviewedMigrationStepDescriptor, +}; +use registry_server::package::{ + compiled_registry_change_set, load_package, prepare_package, CompiledRegistryChangeClass, + CompiledRegistryChangeCode, PackageBuildRequest, PackageIntent, PackageLoadContext, + PackageMigrationPlanInput, PackageModuleSource, PackageSourceFile, SignaturePolicy, + VerifiedPackage, +}; +use registry_server::postgres::{ + install_compiled_schema, managed_schema_fingerprint, ExpectedManagedCatalog, + ExpectedRegistryIdentity, +}; +use registry_server::CompiledRegistry; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use uuid::Uuid; + +const INSTANCE: &str = "migration-instance"; +const DATABASE: &str = "migration-database"; +const SOURCE_REVISION: &str = "migration-source-revision"; +const FIXTURE_JOURNEYS: &[u8] = br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: asset-list + steps: + - id: list-assets + entity: asset + accessProfile: reader + claims: {principal: package-reader} + request: {operation: list} + expect: {outcome: success, status: 200, count: 0} +"#; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_postgres_backfill_and_destructive_recovery_are_bounded_resumable_and_activation_closed( +) { + let database = TestDatabase::create(1).await; + let _unused_harness_configs = (&database.runtime_config, &database.tls_runtime_config); + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("administrator installs the required extension"); + + let base = compile_variant(Variant::Base, 1); + let initial_fingerprint = initial_fingerprint(&database, &base).await; + let initial = prepare_and_load_initial(&base, &initial_fingerprint); + let active = apply(&database, &initial, ApplyPrecondition::InitialActivation) + .await + .expect("initial package activates through the library coordinator"); + seed_backfill_rows(&database, &base, 5).await; + + let required = compile_variant(Variant::RankRequired, 2); + let required_fingerprint = required_target_fingerprint(&database, &required).await; + let backfill = backfill_source(BackfillSourceRequest { + id: "rank-required", + current: &active, + prior: &base, + candidate: &required, + final_fingerprint: &required_fingerprint, + pre: AssertionMode::True, + post: AssertionMode::True, + rehearsed_rows: 5, + }); + let required_package = prepare_and_load_reviewed( + 2, + &active, + &base, + Variant::RankRequired, + &required_fingerprint, + backfill, + ); + + let interrupted = apply_verified_package( + request( + &database, + &required_package, + ApplyPrecondition::Successor { current: &active }, + ) + .with_fault_for_test(ReviewedMigrationFaultPoint::AfterCommittedChunk(1)), + ) + .await; + assert_value_free(interrupted.err(), MigrationError::ApplyFailed); + let first_checkpoint = step_snapshot(&database, &required_package, "backfill-rank").await; + assert_eq!(first_checkpoint.0, "applying"); + assert_eq!(first_checkpoint.2, 2); + assert!(first_checkpoint.1.is_some()); + assert_non_ready_target(&database, &active, &required_package, "applying").await; + + let wrong_source = backfill_source(BackfillSourceRequest { + id: "wrong-recovery-target", + current: &active, + prior: &base, + candidate: &required, + final_fingerprint: &required_fingerprint, + pre: AssertionMode::True, + post: AssertionMode::True, + rehearsed_rows: 5, + }); + let wrong_package = prepare_and_load_reviewed( + 2, + &active, + &base, + Variant::RankRequired, + &required_fingerprint, + wrong_source, + ); + let wrong_resume = apply( + &database, + &wrong_package, + ApplyPrecondition::Successor { current: &active }, + ) + .await; + assert_value_free(wrong_resume.err(), MigrationError::ApplyFailed); + assert_eq!( + step_snapshot(&database, &required_package, "backfill-rank").await, + first_checkpoint, + "a different reviewed target cannot advance the exact durable checkpoint" + ); + + let required_active = apply( + &database, + &required_package, + ApplyPrecondition::Successor { current: &active }, + ) + .await + .expect("the exact interrupted target resumes"); + let completed = step_snapshot(&database, &required_package, "backfill-rank").await; + assert_eq!(completed.0, "completed"); + assert_eq!(completed.2, 5); + assert_all_ranks(&database, &required, 1).await; + assert_ready_target(&database, &required_active).await; + + let ledger_before_destructive = ledger_snapshot(&database).await; + assert_eq!(ledger_before_destructive.len(), 2); + assert!(ledger_before_destructive + .iter() + .all(|entry| entry.2 == "applied")); + let immutable_replay = apply( + &database, + &required_package, + ApplyPrecondition::Successor { current: &active }, + ) + .await; + assert_value_free(immutable_replay.err(), MigrationError::ApplyFailed); + assert_eq!( + ledger_snapshot(&database).await, + ledger_before_destructive, + "an applied reviewed migration and its step checkpoints are immutable" + ); + + let removed = compile_variant(Variant::LegacyRemoved, 3); + let destructive_fingerprint = destructive_target_fingerprint(&database, &removed).await; + let backup_bytes = synthetic_backup_sql(&required, &required_active, &database.runtime_role, 5); + let backup_digest = digest(&backup_bytes); + let now = OffsetDateTime::now_utc() + .format(&Rfc3339) + .expect("current time formats"); + let binding = ExternalBackupBinding { + database_id: DATABASE.to_owned(), + prior_revision: required_active.package_revision.clone(), + prior_schema_fingerprint: required_active.schema_fingerprint.clone(), + sha256: backup_digest, + byte_length: backup_bytes.len() as u64, + created_at: now, + max_age_seconds: 3_600, + }; + let reviewed_destructive_source = destructive_recovery_source( + "remove-legacy", + &required_active, + &required, + &removed, + &destructive_fingerprint, + binding.clone(), + ); + let destructive_package = prepare_and_load_reviewed( + 3, + &required_active, + &required, + Variant::LegacyRemoved, + &destructive_fingerprint, + reviewed_destructive_source, + ); + let backup_root = tempfile::Builder::new() + .prefix("registry-backup-canary-") + .tempdir_in("/private/tmp") + .expect("backup temporary directory creates"); + let backup_path = backup_root.path().join("path-record-sql-canary.backup"); + fs::write(&backup_path, &backup_bytes).expect("restorable backup artifact writes"); + fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o600)) + .expect("backup evidence permissions close"); + let binding_path = destructive_package + .reviewed_migration_plan() + .expect("destructive plan remains validated") + .migrations()[0] + .descriptor + .backup_binding_path + .as_deref() + .expect("destructive binding path exists"); + + let missing = apply( + &database, + &destructive_package, + ApplyPrecondition::Successor { + current: &required_active, + }, + ) + .await; + assert_value_free(missing.err(), MigrationError::BackupEvidence); + assert_ready_target(&database, &required_active).await; + + let wrong_target_evidence = [DestructiveBackupEvidence::new( + "modules/core/migrations/wrong-target/backup.json", + &backup_path, + )]; + let wrong_target = apply_with_evidence( + &database, + &destructive_package, + &required_active, + &wrong_target_evidence, + ) + .await; + assert_value_free(wrong_target.err(), MigrationError::BackupEvidence); + assert_ready_target(&database, &required_active).await; + + fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o644)) + .expect("test opens backup permissions"); + let loose_evidence = [DestructiveBackupEvidence::new(binding_path, &backup_path)]; + let loose = apply_with_evidence( + &database, + &destructive_package, + &required_active, + &loose_evidence, + ) + .await; + assert_value_free(loose.err(), MigrationError::BackupEvidence); + assert_ready_target(&database, &required_active).await; + fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o600)) + .expect("test restores backup permissions"); + + let symlink_path = backup_root.path().join("backup-link"); + std::os::unix::fs::symlink(&backup_path, &symlink_path).expect("test symlink creates"); + let symlink_evidence = [DestructiveBackupEvidence::new(binding_path, &symlink_path)]; + let symlink = apply_with_evidence( + &database, + &destructive_package, + &required_active, + &symlink_evidence, + ) + .await; + assert_value_free(symlink.err(), MigrationError::BackupEvidence); + assert_ready_target(&database, &required_active).await; + + let digest_source = destructive_source( + "remove-legacy-wrong-digest", + &required_active, + &required, + &removed, + &destructive_fingerprint, + ExternalBackupBinding { + sha256: digest(b"different-backup"), + ..binding.clone() + }, + ); + let digest_package = prepare_and_load_reviewed( + 3, + &required_active, + &required, + Variant::LegacyRemoved, + &destructive_fingerprint, + digest_source, + ); + let digest_binding_path = digest_package + .reviewed_migration_plan() + .expect("digest plan validates") + .migrations()[0] + .descriptor + .backup_binding_path + .as_deref() + .expect("digest binding path exists"); + let digest_evidence = [DestructiveBackupEvidence::new( + digest_binding_path, + &backup_path, + )]; + let wrong_digest = apply_with_evidence( + &database, + &digest_package, + &required_active, + &digest_evidence, + ) + .await; + assert_value_free(wrong_digest.err(), MigrationError::BackupEvidence); + assert_ready_target(&database, &required_active).await; + + let stale_source = destructive_source( + "remove-legacy-stale", + &required_active, + &required, + &removed, + &destructive_fingerprint, + ExternalBackupBinding { + created_at: "2020-01-01T00:00:00Z".to_owned(), + max_age_seconds: 60, + ..binding.clone() + }, + ); + let stale_package = prepare_and_load_reviewed( + 3, + &required_active, + &required, + Variant::LegacyRemoved, + &destructive_fingerprint, + stale_source, + ); + let stale_binding_path = stale_package + .reviewed_migration_plan() + .expect("stale package remains structurally valid") + .migrations()[0] + .descriptor + .backup_binding_path + .as_deref() + .expect("stale binding path exists"); + let stale_evidence = [DestructiveBackupEvidence::new( + stale_binding_path, + &backup_path, + )]; + let stale = + apply_with_evidence(&database, &stale_package, &required_active, &stale_evidence).await; + assert_value_free(stale.err(), MigrationError::BackupEvidence); + assert_ready_target(&database, &required_active).await; + + let wrong_database_package = prepare_and_load_reviewed_for_database( + 3, + &required_active, + &required, + Variant::LegacyRemoved, + &destructive_fingerprint, + destructive_source( + "remove-legacy-wrong-database", + &ExpectedRegistryIdentity { + database_id: "other-database".to_owned(), + ..required_active.clone() + }, + &required, + &removed, + &destructive_fingerprint, + ExternalBackupBinding { + database_id: "other-database".to_owned(), + ..binding.clone() + }, + ), + "other-database", + ); + let wrong_database = apply( + &database, + &wrong_database_package, + ApplyPrecondition::Successor { + current: &required_active, + }, + ) + .await; + assert_value_free(wrong_database.err(), MigrationError::PackageBinding); + assert_ready_target(&database, &required_active).await; + + let valid_evidence = [DestructiveBackupEvidence::new(binding_path, &backup_path)]; + let destructive_fault = apply_with_evidence( + &database, + &destructive_package, + &required_active, + &valid_evidence, + ) + .await + .expect_err("the second reviewed drop deterministically faults after the first committed drop"); + assert_value_free(Some(destructive_fault), MigrationError::ApplyFailed); + assert_non_ready_target(&database, &required_active, &destructive_package, "failed").await; + assert_eq!( + step_snapshot(&database, &destructive_package, "drop-legacy") + .await + .0, + "completed" + ); + assert_eq!( + step_snapshot(&database, &destructive_package, "drop-legacy-after-restore") + .await + .0, + "pending" + ); + assert_legacy_column_absent(&database, &required).await; + + restore_synthetic_backup(&database, &binding, &backup_path).await; + assert_restored_prior_schema_and_rows(&database, &required, &required_active, 5).await; + + let active_noop = apply( + &database, + &required_package, + ApplyPrecondition::Successor { + current: &required_active, + }, + ) + .await; + assert_value_free(active_noop.err(), MigrationError::PackageBinding); + assert_non_ready_target(&database, &required_active, &destructive_package, "failed").await; + + let substituted_source = destructive_source( + "remove-legacy-substituted-target", + &required_active, + &required, + &removed, + &destructive_fingerprint, + binding.clone(), + ); + let substituted_package = prepare_and_load_reviewed( + 3, + &required_active, + &required, + Variant::LegacyRemoved, + &destructive_fingerprint, + substituted_source, + ); + let substituted_binding_path = substituted_package + .reviewed_migration_plan() + .expect("substituted plan validates") + .migrations()[0] + .descriptor + .backup_binding_path + .as_deref() + .expect("substituted binding path exists"); + let substituted_evidence = [DestructiveBackupEvidence::new( + substituted_binding_path, + &backup_path, + )]; + let substituted = apply_with_evidence( + &database, + &substituted_package, + &required_active, + &substituted_evidence, + ) + .await; + assert_value_free(substituted.err(), MigrationError::ApplyFailed); + assert_non_ready_target(&database, &required_active, &destructive_package, "failed").await; + assert_restored_prior_schema_and_rows(&database, &required, &required_active, 5).await; + + let destructive_active = apply_with_evidence( + &database, + &destructive_package, + &required_active, + &valid_evidence, + ) + .await + .expect("the exact reviewed failed target performs the bound fix-forward step and activates"); + assert_ready_target(&database, &destructive_active).await; + assert_legacy_column_absent(&database, &required).await; + assert_eq!(ledger_snapshot(&database).await.len(), 3); + assert!(ledger_snapshot(&database) + .await + .iter() + .all(|entry| entry.2 == "applied")); + + database.cleanup().await; + + false_assertion_refusals_are_closed().await; + row_count_mismatch_is_closed().await; + lock_timeout_is_bounded().await; +} + +#[derive(Clone, Copy)] +enum Variant { + Base, + RankRequired, + LegacyRemoved, +} + +#[derive(Clone, Copy)] +enum AssertionMode { + True, + False, +} + +async fn false_assertion_refusals_are_closed() { + for (pre, post) in [ + (AssertionMode::False, AssertionMode::True), + (AssertionMode::True, AssertionMode::False), + ] { + let database = TestDatabase::create(1).await; + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("administrator installs extension"); + let base = compile_variant(Variant::Base, 1); + let fingerprint = initial_fingerprint(&database, &base).await; + let initial = prepare_and_load_initial(&base, &fingerprint); + let active = apply(&database, &initial, ApplyPrecondition::InitialActivation) + .await + .expect("assertion scenario initial package activates"); + seed_backfill_rows(&database, &base, 1).await; + let required = compile_variant(Variant::RankRequired, 2); + let target_fingerprint = required_target_fingerprint(&database, &required).await; + let source = backfill_source(BackfillSourceRequest { + id: match pre { + AssertionMode::False => "false-pre", + AssertionMode::True => "false-post", + }, + current: &active, + prior: &base, + candidate: &required, + final_fingerprint: &target_fingerprint, + pre, + post, + rehearsed_rows: 1, + }); + let package = prepare_and_load_reviewed( + 2, + &active, + &base, + Variant::RankRequired, + &target_fingerprint, + source, + ); + let refused = apply( + &database, + &package, + ApplyPrecondition::Successor { current: &active }, + ) + .await; + assert_value_free(refused.err(), MigrationError::ApplyFailed); + assert_non_ready_target(&database, &active, &package, "failed").await; + let step = step_snapshot(&database, &package, "backfill-rank").await; + if matches!(pre, AssertionMode::False) { + assert_eq!(step.0, "pending"); + } else { + assert_eq!(step.0, "completed"); + } + database.cleanup().await; + } +} + +async fn row_count_mismatch_is_closed() { + let database = TestDatabase::create(1).await; + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("administrator installs extension"); + let base = compile_variant(Variant::Base, 1); + let fingerprint = initial_fingerprint(&database, &base).await; + let initial = prepare_and_load_initial(&base, &fingerprint); + let active = apply(&database, &initial, ApplyPrecondition::InitialActivation) + .await + .expect("row mismatch initial package activates"); + seed_backfill_rows(&database, &base, 2).await; + let required = compile_variant(Variant::RankRequired, 2); + let target_fingerprint = required_target_fingerprint(&database, &required).await; + let source = backfill_source(BackfillSourceRequest { + id: "row-count-mismatch", + current: &active, + prior: &base, + candidate: &required, + final_fingerprint: &target_fingerprint, + pre: AssertionMode::True, + post: AssertionMode::True, + rehearsed_rows: 2, + }); + let package = prepare_and_load_reviewed( + 2, + &active, + &base, + Variant::RankRequired, + &target_fingerprint, + source, + ); + let entity = &base.entities()["asset"]; + let table = quote(&entity.physical_table); + let rank = quote(&entity.fields["rank"].physical_name); + database + .admin + .batch_execute(&format!( + "CREATE FUNCTION registry_data.migration_test_skip_update() RETURNS trigger + LANGUAGE plpgsql AS 'BEGIN RETURN NULL; END'; + CREATE TRIGGER migration_test_skip_update + BEFORE UPDATE OF {rank} ON registry_data.{table} + FOR EACH ROW EXECUTE FUNCTION registry_data.migration_test_skip_update()" + )) + .await + .expect("administrator installs a row-count fault trigger"); + let refused = apply( + &database, + &package, + ApplyPrecondition::Successor { current: &active }, + ) + .await; + assert_value_free(refused.err(), MigrationError::ApplyFailed); + assert_non_ready_target(&database, &active, &package, "failed").await; + let step = step_snapshot(&database, &package, "backfill-rank").await; + assert_eq!(step.0, "pending"); + assert_eq!(step.2, 0); + database.cleanup().await; +} + +async fn lock_timeout_is_bounded() { + let database = TestDatabase::create(1).await; + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("administrator installs extension"); + let base = compile_variant(Variant::Base, 1); + let fingerprint = initial_fingerprint(&database, &base).await; + let initial = prepare_and_load_initial(&base, &fingerprint); + let active = apply(&database, &initial, ApplyPrecondition::InitialActivation) + .await + .expect("timeout scenario initial package activates"); + seed_backfill_rows(&database, &base, 1).await; + let required = compile_variant(Variant::RankRequired, 2); + let target_fingerprint = required_target_fingerprint(&database, &required).await; + let source = backfill_source(BackfillSourceRequest { + id: "lock-timeout", + current: &active, + prior: &base, + candidate: &required, + final_fingerprint: &target_fingerprint, + pre: AssertionMode::True, + post: AssertionMode::True, + rehearsed_rows: 1, + }); + let package = prepare_and_load_reviewed( + 2, + &active, + &base, + Variant::RankRequired, + &target_fingerprint, + source, + ); + let table = quote(&base.entities()["asset"].physical_table); + let (mut blocker_client, blocker_task) = database.connect_migration().await; + let blocker = blocker_client + .transaction() + .await + .expect("lock blocker transaction starts"); + blocker + .batch_execute(&format!( + "LOCK TABLE registry_data.{table} IN ACCESS EXCLUSIVE MODE" + )) + .await + .expect("lock blocker holds the entity table"); + let refused = apply( + &database, + &package, + ApplyPrecondition::Successor { current: &active }, + ) + .await; + assert_value_free(refused.err(), MigrationError::ApplyFailed); + blocker.rollback().await.expect("lock blocker rolls back"); + blocker_task.abort(); + assert_non_ready_target(&database, &active, &package, "failed").await; + database.cleanup().await; +} + +fn compile_variant(variant: Variant, sequence: u64) -> CompiledRegistry { + let module_bytes = module_bytes(variant); + let module = parse_module_yaml(&module_bytes).expect("test module parses"); + let project_bytes = project_bytes(sequence, &module_digest(&module)); + let project = parse_project_yaml(&project_bytes).expect("test project parses"); + compile_project(&project, &[module], CompileProfile::Production) + .expect("test Registry compiles") +} + +fn project_bytes(sequence: u64, digest: &str) -> Vec { + format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"migration-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"local","instanceId":"{INSTANCE}","sequence":{sequence},"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://migration.example.test","title":"Migration Registry","publisher":{{"name":"Migration Publisher"}}}},"dataset":{{"title":"Migration Dataset","owner":"Migration Publisher","status":"active"}}}},"modules":[{{"id":"core","version":"1","digest":"{digest}"}}]}}"# + ) + .into_bytes() +} + +fn module_bytes(variant: Variant) -> Vec { + let rank_required = if matches!(variant, Variant::RankRequired | Variant::LegacyRemoved) { + r#","required":true"# + } else { + "" + }; + let legacy = if matches!(variant, Variant::LegacyRemoved) { + "" + } else { + r#",{"id":"legacy","type":"string","maxLength":16,"classification":"internal"}"# + }; + format!( + r#"{{"id":"core","version":"1","entities":[{{"id":"asset","route":"assets","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}},{{"id":"rank","type":"int64","classification":"internal"{rank_required}}}{legacy}],"accessProfiles":[{{"id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}}]}}]}}"# + ) + .into_bytes() +} + +fn prepare_and_load_initial(registry: &CompiledRegistry, fingerprint: &str) -> VerifiedPackage { + let prepared = prepare_package(build_request( + Variant::Base, + 1, + None, + fingerprint, + PackageMigrationPlanInput::InitialCompiledDdl, + DATABASE, + )) + .expect("initial package prepares"); + let loaded = publish_and_load( + prepared, + local_context(DATABASE, PackageIntent::InitialActivation), + ); + assert_eq!(loaded.registry(), registry); + loaded +} + +fn prepare_and_load_reviewed( + sequence: u64, + current: &ExpectedRegistryIdentity, + prior: &CompiledRegistry, + variant: Variant, + fingerprint: &str, + source: ReviewedMigrationSource, +) -> VerifiedPackage { + prepare_and_load_reviewed_for_database( + sequence, + current, + prior, + variant, + fingerprint, + source, + DATABASE, + ) +} + +fn prepare_and_load_reviewed_for_database( + sequence: u64, + current: &ExpectedRegistryIdentity, + prior: &CompiledRegistry, + variant: Variant, + fingerprint: &str, + source: ReviewedMigrationSource, + database_id: &str, +) -> VerifiedPackage { + let prepared = prepare_package(build_request( + variant, + sequence, + Some(¤t.package_revision), + fingerprint, + PackageMigrationPlanInput::ReviewedSuccessor { + prior_registry: Box::new(prior.clone()), + prior_schema_fingerprint: current.schema_fingerprint.clone(), + migrations: vec![source], + }, + database_id, + )) + .expect("reviewed package prepares"); + publish_and_load( + prepared, + local_context( + database_id, + PackageIntent::Activation { + active_revision: ¤t.package_revision, + active_sequence: u64::try_from(current.package_sequence) + .expect("active sequence is positive"), + }, + ), + ) +} + +fn build_request( + variant: Variant, + sequence: u64, + prior_revision: Option<&str>, + schema_fingerprint: &str, + migration_plan: PackageMigrationPlanInput, + database_id: &str, +) -> PackageBuildRequest { + let module_bytes = module_bytes(variant); + let module = parse_module_yaml(&module_bytes).expect("package module parses"); + PackageBuildRequest { + environment: "local".to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: database_id.to_owned(), + sequence, + prior_revision: prior_revision.map(str::to_owned), + compiler_source_revision: SOURCE_REVISION.to_owned(), + schema_fingerprint: schema_fingerprint.to_owned(), + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: project_bytes(sequence, &module_digest(&module)), + }, + modules: vec![PackageModuleSource { + id: "core".to_owned(), + path: "source/modules/core/module.yaml".to_owned(), + bytes: module_bytes, + }], + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: FIXTURE_JOURNEYS.to_vec(), + }, + migration_plan, + } +} + +fn publish_and_load( + prepared: registry_server::package::PreparedPackage, + context: PackageLoadContext<'_>, +) -> VerifiedPackage { + let root = tempfile::Builder::new() + .prefix("registry-reviewed-runtime-") + .tempdir_in("/private/tmp") + .expect("package temporary directory creates"); + let package = root.path().join("package"); + prepared + .publish_to_directory(&package, Vec::new()) + .expect("package publishes"); + load_package(&package, &context).expect("published package loads with activation intent") +} + +fn local_context<'a>(database_id: &'a str, intent: PackageIntent<'a>) -> PackageLoadContext<'a> { + PackageLoadContext { + environment: "local", + instance_id: INSTANCE, + database_id, + database_initialization_environment: "local", + compiler_source_revision: SOURCE_REVISION, + trust_anchor: None, + intent, + } +} + +struct BackfillSourceRequest<'a> { + id: &'a str, + current: &'a ExpectedRegistryIdentity, + prior: &'a CompiledRegistry, + candidate: &'a CompiledRegistry, + final_fingerprint: &'a str, + pre: AssertionMode, + post: AssertionMode, + rehearsed_rows: u64, +} + +fn backfill_source(request: BackfillSourceRequest<'_>) -> ReviewedMigrationSource { + let BackfillSourceRequest { + id, + current, + prior, + candidate, + final_fingerprint, + pre, + post, + rehearsed_rows, + } = request; + let change = compiled_registry_change_set(prior, candidate, ¤t.package_revision) + .changes + .into_iter() + .find(|change| change.code == CompiledRegistryChangeCode::FieldRequirednessChanged) + .expect("rank requiredness change is classified"); + let entity = &candidate.entities()["asset"]; + let field = &entity.fields["rank"]; + let base = format!("modules/core/migrations/{id}"); + let update_path = format!("{base}/steps/backfill-rank.sql"); + let alter_path = format!("{base}/steps/set-rank-not-null.sql"); + let pre_path = format!("{base}/assertions/pre.sql"); + let post_path = format!("{base}/assertions/post.sql"); + let update_sql = format!( + "UPDATE registry_data.{} SET {} = 1 WHERE record_id = ANY($1::pg_catalog.uuid[])", + entity.physical_table, field.physical_name + ); + let alter_sql = format!( + "ALTER TABLE registry_data.{} ALTER COLUMN {} SET NOT NULL", + entity.physical_table, field.physical_name + ); + let true_pre = format!( + "SELECT pg_catalog.count(*) >= 0 FROM registry_data.{}", + entity.physical_table + ); + let true_post = format!( + "SELECT pg_catalog.count(*) = pg_catalog.count({}) FROM registry_data.{}", + field.physical_name, entity.physical_table + ); + let pre_sql = match pre { + AssertionMode::True => true_pre, + AssertionMode::False => "SELECT false".to_owned(), + }; + let post_sql = match post { + AssertionMode::True => true_post, + AssertionMode::False => "SELECT false".to_owned(), + }; + let object = ReviewedMigrationObject { + schema: "registry_data".to_owned(), + table: entity.physical_table.clone(), + entity_id: "asset".to_owned(), + kind: ReviewedMigrationObjectKind::Field, + member_id: Some("rank".to_owned()), + physical_name: field.physical_name.clone(), + }; + let descriptor = ReviewedMigrationDescriptor { + id: id.to_owned(), + change_class: CompiledRegistryChangeClass::DataBackfillRequired, + covers: vec![ReviewedChangeCover::from(&change)], + recovery: ReviewedMigrationRecovery::ExactTargetResume, + lock_timeout_ms: 50, + statement_timeout_ms: 5_000, + steps: vec![ + ReviewedMigrationStepDescriptor::ChunkedBackfill { + id: "backfill-rank".to_owned(), + entity_id: "asset".to_owned(), + sql_path: update_path.clone(), + objects: vec![object.clone()], + cursor: ChunkCursorProtocol::RecordIdUuidArray, + chunk_size: 2, + max_total_rows: 10, + lock_timeout_ms: 50, + statement_timeout_ms: 5_000, + exact_affected_rows: true, + }, + ReviewedMigrationStepDescriptor::TransactionalSql { + id: "set-rank-not-null".to_owned(), + sql_path: alter_path.clone(), + objects: vec![object], + affected_rows: None, + }, + ], + pre_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "pre".to_owned(), + sql_path: pre_path.clone(), + }], + post_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "post".to_owned(), + sql_path: post_path.clone(), + }], + rehearsal_receipt_path: format!("{base}/rehearsal.json"), + backup_binding_path: None, + }; + reviewed_source(ReviewedSourceRequest { + descriptor, + current, + final_fingerprint, + steps: vec![(update_path, update_sql), (alter_path, alter_sql)], + pre: (pre_path, pre_sql), + post: (post_path, post_sql), + backup: None, + row_assertions: vec![RehearsalRowAssertion { + step_id: "backfill-rank".to_owned(), + affected_rows: rehearsed_rows, + }], + }) +} + +fn destructive_source( + id: &str, + current: &ExpectedRegistryIdentity, + prior: &CompiledRegistry, + candidate: &CompiledRegistry, + final_fingerprint: &str, + backup: ExternalBackupBinding, +) -> ReviewedMigrationSource { + destructive_source_with_recovery_fault( + id, + current, + prior, + candidate, + final_fingerprint, + backup, + false, + ) +} + +fn destructive_recovery_source( + id: &str, + current: &ExpectedRegistryIdentity, + prior: &CompiledRegistry, + candidate: &CompiledRegistry, + final_fingerprint: &str, + backup: ExternalBackupBinding, +) -> ReviewedMigrationSource { + destructive_source_with_recovery_fault( + id, + current, + prior, + candidate, + final_fingerprint, + backup, + true, + ) +} + +fn destructive_source_with_recovery_fault( + id: &str, + current: &ExpectedRegistryIdentity, + prior: &CompiledRegistry, + candidate: &CompiledRegistry, + final_fingerprint: &str, + backup: ExternalBackupBinding, + recovery_fault: bool, +) -> ReviewedMigrationSource { + let change = compiled_registry_change_set(prior, candidate, ¤t.package_revision) + .changes + .into_iter() + .find(|change| change.code == CompiledRegistryChangeCode::FieldRemoved) + .expect("legacy removal is classified"); + let entity = &prior.entities()["asset"]; + let field = &entity.fields["legacy"]; + let base = format!("modules/core/migrations/{id}"); + let step_path = format!("{base}/steps/drop-legacy.sql"); + let recovery_step_path = format!("{base}/steps/drop-legacy-after-restore.sql"); + let pre_path = format!("{base}/assertions/pre.sql"); + let post_path = format!("{base}/assertions/post.sql"); + let assertion = format!( + "SELECT pg_catalog.count(*) >= 0 FROM registry_data.{}", + entity.physical_table + ); + let object = ReviewedMigrationObject { + schema: "registry_data".to_owned(), + table: entity.physical_table.clone(), + entity_id: "asset".to_owned(), + kind: ReviewedMigrationObjectKind::Field, + member_id: Some("legacy".to_owned()), + physical_name: field.physical_name.clone(), + }; + let mut steps = vec![ReviewedMigrationStepDescriptor::TransactionalSql { + id: "drop-legacy".to_owned(), + sql_path: step_path.clone(), + objects: vec![object.clone()], + affected_rows: None, + }]; + let mut step_files = vec![( + step_path, + format!( + "ALTER TABLE registry_data.{} DROP COLUMN {}", + entity.physical_table, field.physical_name + ), + )]; + if recovery_fault { + steps.push(ReviewedMigrationStepDescriptor::TransactionalSql { + id: "drop-legacy-after-restore".to_owned(), + sql_path: recovery_step_path.clone(), + objects: vec![object], + affected_rows: None, + }); + step_files.push(( + recovery_step_path, + format!( + "ALTER TABLE registry_data.{} DROP COLUMN {}", + entity.physical_table, field.physical_name + ), + )); + } + let descriptor = ReviewedMigrationDescriptor { + id: id.to_owned(), + change_class: CompiledRegistryChangeClass::DestructiveOrIrreversible, + covers: vec![ReviewedChangeCover::from(&change)], + recovery: ReviewedMigrationRecovery::ExactTargetResume, + lock_timeout_ms: 50, + statement_timeout_ms: 5_000, + steps, + pre_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "pre".to_owned(), + sql_path: pre_path.clone(), + }], + post_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "post".to_owned(), + sql_path: post_path.clone(), + }], + rehearsal_receipt_path: format!("{base}/rehearsal.json"), + backup_binding_path: Some(format!("{base}/backup.json")), + }; + reviewed_source(ReviewedSourceRequest { + descriptor, + current, + final_fingerprint, + steps: step_files, + pre: (pre_path, assertion.clone()), + post: (post_path, assertion), + backup: Some(backup), + row_assertions: Vec::new(), + }) +} + +struct ReviewedSourceRequest<'a> { + descriptor: ReviewedMigrationDescriptor, + current: &'a ExpectedRegistryIdentity, + final_fingerprint: &'a str, + steps: Vec<(String, String)>, + pre: (String, String), + post: (String, String), + backup: Option, + row_assertions: Vec, +} + +fn reviewed_source(request: ReviewedSourceRequest<'_>) -> ReviewedMigrationSource { + let ReviewedSourceRequest { + descriptor, + current, + final_fingerprint, + steps, + pre, + post, + backup, + row_assertions, + } = request; + let descriptor_path = format!("modules/core/migrations/{}/descriptor.json", descriptor.id); + let descriptor_bytes = canonical(&descriptor); + let fixture_path = format!( + "modules/core/migrations/{}/fixtures/representative.jsonl", + descriptor.id + ); + let fixture_bytes = b"{\"fixture\":\"representative\"}\n".to_vec(); + let receipt = MigrationRehearsalReceipt { + prior_revision: current.package_revision.clone(), + prior_schema_fingerprint: current.schema_fingerprint.clone(), + plan_sha256: digest(&descriptor_bytes), + sql_sha256: steps + .iter() + .map(|(path, sql)| ArtifactDigestBinding { + path: path.clone(), + sha256: digest(sql.as_bytes()), + }) + .collect(), + assertion_sha256: vec![ + ArtifactDigestBinding { + path: pre.0.clone(), + sha256: digest(pre.1.as_bytes()), + }, + ArtifactDigestBinding { + path: post.0.clone(), + sha256: digest(post.1.as_bytes()), + }, + ], + fixture_inventory: vec![RehearsalFixture { + id: "representative".to_owned(), + path: fixture_path.clone(), + sha256: digest(&fixture_bytes), + row_count: 1, + }], + postgres_major: 17, + row_assertions, + final_schema_fingerprint: final_fingerprint.to_owned(), + proofs: RehearsalProofs { + lock_timeout: true, + chunk_resume: descriptor.steps.iter().any(|step| { + matches!( + step, + ReviewedMigrationStepDescriptor::ChunkedBackfill { .. } + ) + }), + destructive_resume: backup.is_some(), + }, + }; + let mut files = steps + .into_iter() + .map(|(path, sql)| ReviewedMigrationFile { + path, + bytes: sql.into_bytes(), + }) + .collect::>(); + files.extend([ + ReviewedMigrationFile { + path: pre.0, + bytes: pre.1.into_bytes(), + }, + ReviewedMigrationFile { + path: post.0, + bytes: post.1.into_bytes(), + }, + ReviewedMigrationFile { + path: descriptor.rehearsal_receipt_path.clone(), + bytes: canonical(&receipt), + }, + ReviewedMigrationFile { + path: fixture_path, + bytes: fixture_bytes, + }, + ]); + if let (Some(path), Some(binding)) = (&descriptor.backup_binding_path, backup) { + files.push(ReviewedMigrationFile { + path: path.clone(), + bytes: canonical(&binding), + }); + } + files.sort_by(|left, right| left.path.cmp(&right.path)); + ReviewedMigrationSource { + module_id: "core".to_owned(), + descriptor: ReviewedMigrationFile { + path: descriptor_path, + bytes: descriptor_bytes, + }, + files, + } +} + +async fn initial_fingerprint(database: &TestDatabase, registry: &CompiledRegistry) -> String { + let (mut migration, task) = database.connect_migration().await; + let transaction = migration + .transaction() + .await + .expect("initial fingerprint transaction starts"); + install_compiled_schema(&transaction, registry, &database.runtime_role) + .await + .expect("initial schema rehearses"); + let fingerprint = managed_schema_fingerprint( + &transaction, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(registry), + ) + .await + .expect("initial fingerprint computes"); + transaction + .rollback() + .await + .expect("initial rehearsal rolls back"); + task.abort(); + fingerprint +} + +async fn required_target_fingerprint( + database: &TestDatabase, + candidate: &CompiledRegistry, +) -> String { + let entity = &candidate.entities()["asset"]; + let table = quote(&entity.physical_table); + let rank = quote(&entity.fields["rank"].physical_name); + let (mut migration, task) = database.connect_migration().await; + let transaction = migration + .transaction() + .await + .expect("required target fingerprint transaction starts"); + transaction + .batch_execute(&format!( + "ALTER TABLE registry_data.{table} NO FORCE ROW LEVEL SECURITY; + UPDATE registry_data.{table} SET {rank} = 1 WHERE {rank} IS NULL; + ALTER TABLE registry_data.{table} ALTER COLUMN {rank} SET NOT NULL; + ALTER TABLE registry_data.{table} FORCE ROW LEVEL SECURITY" + )) + .await + .expect("required target rehearses"); + let fingerprint = managed_schema_fingerprint( + &transaction, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(candidate), + ) + .await + .expect("required target fingerprint computes"); + transaction + .rollback() + .await + .expect("required target rehearsal rolls back"); + task.abort(); + fingerprint +} + +async fn destructive_target_fingerprint( + database: &TestDatabase, + candidate: &CompiledRegistry, +) -> String { + let entity = &candidate.entities()["asset"]; + let table = quote(&entity.physical_table); + let prior = compile_variant(Variant::RankRequired, 2); + let legacy = quote(&prior.entities()["asset"].fields["legacy"].physical_name); + let (mut migration, task) = database.connect_migration().await; + let transaction = migration + .transaction() + .await + .expect("destructive fingerprint transaction starts"); + transaction + .batch_execute(&format!( + "ALTER TABLE registry_data.{table} DROP COLUMN {legacy}" + )) + .await + .expect("destructive target rehearses"); + let fingerprint = managed_schema_fingerprint( + &transaction, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(candidate), + ) + .await + .expect("destructive target fingerprint computes"); + transaction + .rollback() + .await + .expect("destructive target rehearsal rolls back"); + task.abort(); + fingerprint +} + +async fn seed_backfill_rows(database: &TestDatabase, registry: &CompiledRegistry, count: u64) { + let entity = ®istry.entities()["asset"]; + let table = quote(&entity.physical_table); + let code = quote(&entity.fields["code"].physical_name); + let rank = quote(&entity.fields["rank"].physical_name); + let legacy = quote(&entity.fields["legacy"].physical_name); + let active_revision: String = database + .admin + .query_one( + "SELECT active_package_revision + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .expect("active revision reads for seed rows") + .get(0); + for index in 0..count { + database + .admin + .execute( + &format!( + "INSERT INTO registry_data.{table} + (record_id, active_package_revision, {code}, {rank}, {legacy}) + VALUES ($1, $2, $3, NULL, $4)" + ), + &[ + &Uuid::from_u128(index as u128 + 1), + &active_revision, + &format!("c{index}"), + &format!("legacy-{index}"), + ], + ) + .await + .expect("administrator seeds a backfill row"); + } +} + +fn synthetic_backup_sql( + registry: &CompiledRegistry, + active: &ExpectedRegistryIdentity, + runtime_role: ®istry_server::postgres::SqlIdentifier, + count: u64, +) -> Vec { + let entity = ®istry.entities()["asset"]; + let table = quote(&entity.physical_table); + let code = quote(&entity.fields["code"].physical_name); + let rank = quote(&entity.fields["rank"].physical_name); + let legacy = quote(&entity.fields["legacy"].physical_name); + let values = (0..count) + .map(|index| { + format!( + "('{}'::uuid, '{}', 'c{index}'::varchar(8), 1::bigint, 'legacy-{index}'::varchar(16))", + Uuid::from_u128(index as u128 + 1) + , active.package_revision.replace('\'', "''") + ) + }) + .collect::>() + .join(","); + let create = registry + .ddl() + .statements + .iter() + .find(|statement| statement.id == "entity.asset.table") + .expect("compiled table DDL exists"); + let mut sql = format!( + "DROP TABLE registry_data.{table};\n{};\n\ + INSERT INTO registry_data.{table}\n\ + (record_id, active_package_revision, {code}, {rank}, {legacy})\n\ + VALUES {values};\n", + create.sql + ); + for statement in registry.ddl().statements.iter().filter(|statement| { + statement.id.starts_with("entity.asset.") && statement.id != "entity.asset.table" + }) { + sql.push_str(&statement.sql); + sql.push_str(";\n"); + } + sql.push_str(&format!( + "REVOKE ALL ON TABLE registry_data.{table} FROM PUBLIC, {};\n\ + GRANT SELECT, INSERT ON TABLE registry_data.{table} TO {};\n", + quote(runtime_role.as_str()), + quote(runtime_role.as_str()) + )); + sql.into_bytes() +} + +async fn restore_synthetic_backup( + database: &TestDatabase, + binding: &ExternalBackupBinding, + backup_path: &std::path::Path, +) { + let bytes = fs::read(backup_path).expect("operator reads the retained backup artifact"); + assert_eq!(bytes.len() as u64, binding.byte_length); + assert_eq!(digest(&bytes), binding.sha256); + let sql = std::str::from_utf8(&bytes).expect("synthetic backup is exact UTF-8 SQL"); + let (migration, migration_task) = database.connect_migration().await; + migration + .batch_execute(sql) + .await + .expect("operator executes the digest-verified restoration bytes"); + migration_task.abort(); +} + +async fn assert_restored_prior_schema_and_rows( + database: &TestDatabase, + registry: &CompiledRegistry, + expected: &ExpectedRegistryIdentity, + count: u64, +) { + let (migration, migration_task) = database.connect_migration().await; + let fingerprint = managed_schema_fingerprint( + &migration, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(registry), + ) + .await + .expect("restored managed schema fingerprints"); + assert_eq!(fingerprint, expected.schema_fingerprint); + migration_task.abort(); + let entity = ®istry.entities()["asset"]; + let rows = database + .admin + .query( + &format!( + "SELECT record_id, {} FROM registry_data.{} ORDER BY record_id", + quote(&entity.fields["legacy"].physical_name), + quote(&entity.physical_table) + ), + &[], + ) + .await + .expect("restored rows read"); + assert_eq!(rows.len() as u64, count); + for (index, row) in rows.iter().enumerate() { + assert_eq!(row.get::<_, Uuid>(0), Uuid::from_u128(index as u128 + 1)); + assert_eq!(row.get::<_, String>(1), format!("legacy-{index}")); + } +} + +async fn assert_legacy_column_absent(database: &TestDatabase, prior: &CompiledRegistry) { + let entity = &prior.entities()["asset"]; + let row = database + .admin + .query_one( + "SELECT count(*) + FROM information_schema.columns + WHERE table_schema = 'registry_data' + AND table_name = $1 + AND column_name = $2", + &[ + &entity.physical_table, + &entity.fields["legacy"].physical_name, + ], + ) + .await + .expect("column absence reads"); + assert_eq!(row.get::<_, i64>(0), 0); +} + +async fn apply( + database: &TestDatabase, + package: &VerifiedPackage, + precondition: ApplyPrecondition<'_>, +) -> registry_server::migration::Result { + apply_verified_package(request(database, package, precondition)).await +} + +fn request<'a>( + database: &'a TestDatabase, + package: &'a VerifiedPackage, + precondition: ApplyPrecondition<'a>, +) -> ApplyVerifiedPackageRequest<'a> { + ApplyVerifiedPackageRequest::new( + &database.migration_config, + package, + precondition, + ApplyRoles::new(&database.migration_role, &database.runtime_role), + ApplyTimeouts::new(Duration::from_secs(1), Duration::from_secs(5)) + .expect("test timeouts are bounded"), + ) +} + +async fn apply_with_evidence( + database: &TestDatabase, + package: &VerifiedPackage, + current: &ExpectedRegistryIdentity, + evidence: &[DestructiveBackupEvidence<'_>], +) -> registry_server::migration::Result { + apply_verified_package( + request(database, package, ApplyPrecondition::Successor { current }) + .with_destructive_backup_evidence(evidence), + ) + .await +} + +async fn step_snapshot( + database: &TestDatabase, + package: &VerifiedPackage, + step_id: &str, +) -> (String, Option, i64) { + let row = database + .admin + .query_one( + "SELECT outcome, checkpoint_record_id, affected_rows + FROM registry_internal.registry_migration_steps + WHERE target_package_revision = $1 AND step_id = $2", + &[&package.manifest().package_revision, &step_id], + ) + .await + .expect("step state reads"); + (row.get(0), row.get(1), row.get(2)) +} + +async fn ledger_snapshot(database: &TestDatabase) -> Vec<(String, String, String)> { + database + .admin + .query( + "SELECT target_package_revision, plan_kind, outcome + FROM registry_internal.registry_migrations + ORDER BY package_sequence", + &[], + ) + .await + .expect("ledger reads") + .into_iter() + .map(|row| (row.get(0), row.get(1), row.get(2))) + .collect() +} + +async fn assert_non_ready_target( + database: &TestDatabase, + active: &ExpectedRegistryIdentity, + target: &VerifiedPackage, + expected_status: &str, +) { + let row = database + .admin + .query_one( + "SELECT active_package_revision, maintenance_status, maintenance_target_revision + FROM registry_internal.registry_state WHERE singleton", + &[], + ) + .await + .expect("maintenance state reads"); + assert_eq!(row.get::<_, String>(0), active.package_revision); + assert_eq!(row.get::<_, String>(1), expected_status); + assert_eq!( + row.get::<_, Option>(2).as_deref(), + Some(target.manifest().package_revision.as_str()) + ); +} + +async fn assert_ready_target(database: &TestDatabase, expected: &ExpectedRegistryIdentity) { + let row = database + .admin + .query_one( + "SELECT active_package_revision, schema_fingerprint, package_sequence, + maintenance_status, maintenance_target_revision + FROM registry_internal.registry_state WHERE singleton", + &[], + ) + .await + .expect("ready state reads"); + assert_eq!(row.get::<_, String>(0), expected.package_revision); + assert_eq!(row.get::<_, String>(1), expected.schema_fingerprint); + assert_eq!(row.get::<_, i64>(2), expected.package_sequence); + assert_eq!(row.get::<_, String>(3), "ready"); + assert_eq!(row.get::<_, Option>(4), None); +} + +async fn assert_all_ranks(database: &TestDatabase, registry: &CompiledRegistry, expected: i64) { + let entity = ®istry.entities()["asset"]; + let rows = database + .admin + .query( + &format!( + "SELECT {} FROM registry_data.{} ORDER BY record_id", + quote(&entity.fields["rank"].physical_name), + quote(&entity.physical_table) + ), + &[], + ) + .await + .expect("backfilled values read"); + assert_eq!(rows.len(), 5); + assert!(rows.iter().all(|row| row.get::<_, i64>(0) == expected)); +} + +fn assert_value_free(actual: Option, expected: MigrationError) { + let actual = actual.expect("operation must fail"); + assert_eq!(actual, expected); + let diagnostic = format!("{actual:?} {actual}"); + for canary in ["path-record-sql-canary", "legacy-0", "registry_data"] { + assert!(!diagnostic.contains(canary)); + } +} + +fn canonical(value: &impl Serialize) -> Vec { + canonicalize_json(&serde_json::to_value(value).expect("test value serializes")) + .expect("test value canonicalizes") +} + +fn digest(bytes: &[u8]) -> String { + let mut result = String::from("sha256:"); + for byte in Sha256::digest(bytes) { + use std::fmt::Write as _; + write!(&mut result, "{byte:02x}").expect("writing to String cannot fail"); + } + result +} + +fn quote(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} diff --git a/crates/registry-server/tests/postgres_mutation.rs b/crates/registry-server/tests/postgres_mutation.rs new file mode 100644 index 0000000000..934aa1633d --- /dev/null +++ b/crates/registry-server/tests/postgres_mutation.rs @@ -0,0 +1,2523 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::{to_bytes, Body}; +use axum::http::{HeaderName, HeaderValue, Method, Request, StatusCode}; +use postgres_harness::TestDatabase; +use registry_platform_audit::{verify_jsonl_lines_with_hasher, AuditEnvelope, AuditProfile}; +use registry_server::api::{ + router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, VerifiedClaimValue, + VerifiedRequestClaims, +}; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::{parse_project_json, Operation}; +use registry_server::cursor::CursorCodec; +use registry_server::idempotency::PermittedResponseHeader; +use registry_server::mutation::{ + MutationBody, MutationCoordinator, MutationError, MutationFaultPoint, MutationOutcome, + MutationPlan, MutationRequest, PatchOperation, +}; +use registry_server::postgres::{ + initialize_registry_state_for_catalog_test, install_compiled_schema, ClaimContext, + ExpectedManagedCatalog, PostgresRecordMutationService, PostgresRecordReadService, + RegistryLockKey, RegistryStateTestIdentity, RowBoundaryContext, +}; +use serde_json::{json, Map, Value}; +use tower::Service as _; +use uuid::Uuid; +use zeroize::Zeroizing; + +const PRINCIPAL_CANARY: &str = "principal-value-must-not-enter-journals"; +const PACKAGE_ID: &str = "mutation-registry"; +const INSTANCE_ID: &str = "mutation-instance"; +const DATABASE_ID: &str = "mutation-database"; +const RECORD_POSITIVE: &str = "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAA0101"; +const RECORD_PATCH: &str = "00000000-0000-0000-0000-000000000102"; +const RECORD_RECOVERY: &str = "00000000-0000-0000-0000-000000000103"; +const RECORD_CONCURRENT: &str = "00000000-0000-0000-0000-000000000104"; +const RS_SEC_13_PRINCIPAL_CANARY: &str = "rs-sec-13-principal-canary"; +const RS_SEC_13_TOKEN_CANARY: &str = "rs-sec-13-raw-token-canary"; +const RS_SEC_13_CREDENTIAL_CANARY: &str = "rs-sec-13-credential-canary"; +const RS_SEC_13_IDEMPOTENCY_CANARY: &str = "rs-sec-13-idempotency-key-conflict"; +const RS_SEC_13_ZONE_CANARY: &str = "rs-sec-13-zone-a"; +const RS_SEC_13_LABEL_CANARY: &str = "rs-sec-13-unique-label"; +const RS_SEC_13_QUANTITY_CANARY: &str = "4242"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable() { + let database = TestDatabase::create(10).await; + let (migration, migration_task) = database.connect_migration().await; + let compiled = compiled_registry(); + install_compiled_schema(&migration, &compiled, &database.runtime_role) + .await + .expect("migration installs the complete compiler-owned PostgreSQL schema"); + let catalog = ExpectedManagedCatalog::compiled(&compiled); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision: "package-mutation-1", + package_sequence: 1, + }, + ) + .await + .expect("migration initializes the active package after exact schema install"); + migration_task.abort(); + + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let profile = AuditProfile::production_from_secret_bytes(vec![0x5a; 32].into()) + .expect("test owns a strong keyed audit profile"); + let coordinator = MutationCoordinator::new( + RegistryLockKey::derive("mutation-registry").expect("lock id is bounded"), + Duration::from_secs(2), + identity.clone(), + profile.clone(), + ); + let create_plan = MutationPlan::from_compiled(&compiled, "records.widget.create") + .expect("create plan comes from the compiled inventory"); + let patch_plan = MutationPlan::from_compiled(&compiled, "records.widget.patch") + .expect("patch plan comes from the compiled inventory"); + let claims = mutation_claims(&compiled, PRINCIPAL_CANARY, "zone-a"); + let table = &compiled.entities()["widget"].physical_table; + let mut client = pool + .get_for_test() + .await + .expect("runtime connection is available"); + + let before_invalid = durable_counts(&database, table).await; + let invalid = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "invalid-key", + &claims, + "not-a-uuid", + "missing-required-fields", + None, + ), + ) + .await; + assert_eq!(invalid, Err(MutationError::InvalidRequest)); + assert_eq!( + durable_counts(&database, table).await, + DurableCounts { + audit: before_invalid.audit + 1, + ..before_invalid + }, + "the public mutation path persists a refusal before returning validation failure" + ); + + let anonymous_claims = ClaimContext::for_compiled( + &compiled, + "widget", + None, + "anonymous-reader", + None, + Vec::new(), + ) + .expect("anonymous read authority is compiler-bound"); + let before_anonymous = durable_counts(&database, table).await; + let anonymous_mutation = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "anonymous-key", + &anonymous_claims, + "00000000-0000-0000-0000-000000000105", + "anonymous-label", + Some(1), + ), + ) + .await; + assert_eq!(anonymous_mutation, Err(MutationError::InvalidRequest)); + assert_eq!( + durable_counts(&database, table).await, + DurableCounts { + audit: before_anonymous.audit + 1, + ..before_anonymous + }, + "anonymous read authority cannot cross the mutation boundary" + ); + + for (index, fault) in [ + MutationFaultPoint::BeforeCurrentRow, + MutationFaultPoint::BeforeRevision, + MutationFaultPoint::BeforeOutbox, + MutationFaultPoint::BeforeTerminalAudit, + MutationFaultPoint::BeforeIdempotency, + MutationFaultPoint::BeforeCommit, + ] + .into_iter() + .enumerate() + { + let record = format!("00000000-0000-0000-0000-0000000002{index:02}"); + let key = format!("rollback-key-{index}"); + let before = durable_counts(&database, table).await; + let failed = coordinator + .execute_with_fault( + &mut client, + create_request( + &create_plan, + &key, + &claims, + &record, + "rollback-domain-value", + Some(7), + ), + fault, + ) + .await; + assert_eq!(failed, Err(MutationError::Unavailable)); + assert_eq!( + durable_counts(&database, table).await, + DurableCounts { + audit: before.audit + 1, + ..before + }, + "fault {fault:?} retains only its unavoidable durable attempt" + ); + } + + let before_positive = durable_counts(&database, table).await; + let positive = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "positive-key", + &claims, + RECORD_POSITIVE, + "created-label", + Some(7), + ), + ) + .await + .expect("complete typed mutation commits"); + assert!(!positive.replayed()); + let positive_id = response_id(&positive); + assert_created_response(&positive, &positive_id, "created-label", 7); + assert_one_complete_effect( + before_positive, + durable_counts(&database, table).await, + 1, + 2, + ); + + let before_replay = durable_counts(&database, table).await; + let replay = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "positive-key", + &claims, + RECORD_POSITIVE, + "created-label", + Some(7), + ), + ) + .await + .expect("same authorized request replays"); + assert!(replay.replayed()); + assert_eq!(replay.response(), positive.response()); + assert_audited_replay_only(before_replay, durable_counts(&database, table).await); + + let other_profile_claims = ClaimContext::for_compiled( + &compiled, + "widget", + Some(PRINCIPAL_CANARY.to_owned()), + "review-operator", + Some("case-management".to_owned()), + vec![RowBoundaryContext::Equals { + field: "jurisdiction".to_owned(), + value: "zone-a".to_owned(), + }], + ) + .expect("alternate writer context is compiler-bound"); + let before_changed_profile = durable_counts(&database, table).await; + let before_changed_profile_refusals = refusal_audit_count(&database).await; + let changed_profile = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "positive-key", + &other_profile_claims, + RECORD_POSITIVE, + "created-label", + Some(7), + ), + ) + .await; + assert_idempotency_refusal_only( + changed_profile, + before_changed_profile, + before_changed_profile_refusals, + &database, + table, + ) + .await; + + let other_purpose_claims = ClaimContext::for_compiled( + &compiled, + "widget", + Some(PRINCIPAL_CANARY.to_owned()), + "operator", + Some("case-review".to_owned()), + vec![RowBoundaryContext::Equals { + field: "jurisdiction".to_owned(), + value: "zone-a".to_owned(), + }], + ) + .expect("alternate purpose context is compiler-bound"); + let before_changed_purpose = durable_counts(&database, table).await; + let before_changed_purpose_refusals = refusal_audit_count(&database).await; + let changed_purpose = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "positive-key", + &other_purpose_claims, + RECORD_POSITIVE, + "created-label", + Some(7), + ), + ) + .await; + assert_idempotency_refusal_only( + changed_purpose, + before_changed_purpose, + before_changed_purpose_refusals, + &database, + table, + ) + .await; + + let before_changed_projection = durable_counts(&database, table).await; + let before_changed_projection_refusals = refusal_audit_count(&database).await; + let changed_projection = coordinator + .execute( + &mut client, + MutationRequest { + response_fields: BTreeSet::from(["label".to_owned()]), + ..create_request( + &create_plan, + "positive-key", + &claims, + RECORD_POSITIVE, + "created-label", + Some(7), + ) + }, + ) + .await; + assert_idempotency_refusal_only( + changed_projection, + before_changed_projection, + before_changed_projection_refusals, + &database, + table, + ) + .await; + + let before_changed_request_context = durable_counts(&database, table).await; + let before_changed_request_context_refusals = refusal_audit_count(&database).await; + let changed_request_context = coordinator + .execute( + &mut client, + patch_request( + &patch_plan, + "positive-key", + &claims, + &positive_id, + &response_etag(&positive), + "created-label", + ), + ) + .await; + assert_idempotency_refusal_only( + changed_request_context, + before_changed_request_context, + before_changed_request_context_refusals, + &database, + table, + ) + .await; + + let before_changed_body = durable_counts(&database, table).await; + let before_changed_body_refusals = refusal_audit_count(&database).await; + let changed_body = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "positive-key", + &claims, + RECORD_POSITIVE, + "changed-request-body", + Some(7), + ), + ) + .await; + assert_idempotency_refusal_only( + changed_body, + before_changed_body, + before_changed_body_refusals, + &database, + table, + ) + .await; + + let other_authority = mutation_claims(&compiled, PRINCIPAL_CANARY, "zone-b"); + let before_authority = durable_counts(&database, table).await; + let before_authority_refusals = refusal_audit_count(&database).await; + let changed_authority = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "positive-key", + &other_authority, + RECORD_POSITIVE, + "created-label", + Some(7), + ), + ) + .await; + assert_idempotency_refusal_only( + changed_authority, + before_authority, + before_authority_refusals, + &database, + table, + ) + .await; + + let other_principal = mutation_claims(&compiled, "different-principal", "zone-a"); + let before_principal = durable_counts(&database, table).await; + let before_principal_refusals = refusal_audit_count(&database).await; + let changed_principal = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "positive-key", + &other_principal, + RECORD_POSITIVE, + "created-label", + Some(7), + ), + ) + .await; + assert_idempotency_refusal_only( + changed_principal, + before_principal, + before_principal_refusals, + &database, + table, + ) + .await; + + let before_patch_seed = durable_counts(&database, table).await; + let patch_seed = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "patch-seed-key", + &claims, + RECORD_PATCH, + "before-patch", + Some(41), + ), + ) + .await + .expect("patch seed commits"); + let patch_id = response_id(&patch_seed); + let patch_seed_etag = response_etag(&patch_seed); + assert_one_complete_effect( + before_patch_seed, + durable_counts(&database, table).await, + 1, + 2, + ); + let before_noncanonical_record = durable_counts(&database, table).await; + let noncanonical_record = coordinator + .execute( + &mut client, + patch_request( + &patch_plan, + "noncanonical-record-key", + &claims, + RECORD_POSITIVE, + "\"rs-noncanonical-regression\"", + "not-applied", + ), + ) + .await; + assert_eq!(noncanonical_record, Err(MutationError::InvalidRequest)); + assert_eq!( + durable_counts(&database, table).await, + DurableCounts { + audit: before_noncanonical_record.audit + 1, + ..before_noncanonical_record + }, + "noncanonical UUID spellings are refused before record I/O" + ); + let before_patch = durable_counts(&database, table).await; + let patched = coordinator + .execute( + &mut client, + patch_request( + &patch_plan, + "patch-key", + &claims, + &patch_id, + &patch_seed_etag, + "after-patch", + ), + ) + .await + .expect("nonempty authorized partial patch commits"); + assert!(!patched.replayed()); + assert_eq!(patched.response().status(), 200); + assert!(!patched + .response() + .headers() + .contains_key(&PermittedResponseHeader::Location)); + assert_eq!( + patched.response().body(), + format!( + "{{\"data\":{{\"label\":\"after-patch\",\"quantity\":41}},\"id\":\"{patch_id}\",\"revision\":2}}" + ) + .as_bytes() + ); + let patched_etag = response_etag(&patched); + assert_one_complete_effect(before_patch, durable_counts(&database, table).await, 0, 2); + assert_patch_preserved_omitted_field(&database, table, &patch_id).await; + + let label_editor = ClaimContext::for_compiled( + &compiled, + "widget", + Some(PRINCIPAL_CANARY.to_owned()), + "label-editor", + Some("case-management".to_owned()), + vec![RowBoundaryContext::Equals { + field: "jurisdiction".to_owned(), + value: "zone-a".to_owned(), + }], + ) + .expect("limited writer context is compiler-bound"); + let before_forbidden_field = durable_counts(&database, table).await; + let forbidden_field = coordinator + .execute( + &mut client, + MutationRequest { + plan: &patch_plan, + idempotency_key: "forbidden-field-key", + claims: &label_editor, + record_id: Some(&patch_id), + expected_etag: Some(&patched_etag), + body: MutationBody::Patch(vec![PatchOperation::Replace { + path: "/data/quantity".to_owned(), + value: json!(42), + }]), + response_fields: BTreeSet::from(["label".to_owned()]), + }, + ) + .await; + assert_eq!(forbidden_field, Err(MutationError::InvalidRequest)); + assert_eq!( + durable_counts(&database, table).await, + DurableCounts { + audit: before_forbidden_field.audit + 1, + ..before_forbidden_field + }, + "the selected profile writable-field set is enforced before record I/O" + ); + + let before_empty_patch = durable_counts(&database, table).await; + let empty_patch = coordinator + .execute( + &mut client, + MutationRequest { + plan: &patch_plan, + idempotency_key: "empty-patch-key", + claims: &claims, + record_id: Some(&patch_id), + expected_etag: Some(&patched_etag), + body: MutationBody::Patch(Vec::new()), + response_fields: BTreeSet::from(["label".to_owned()]), + }, + ) + .await; + assert_eq!(empty_patch, Err(MutationError::InvalidRequest)); + assert_eq!( + durable_counts(&database, table).await, + DurableCounts { + audit: before_empty_patch.audit + 1, + ..before_empty_patch + }, + "an empty patch is refused without a record effect" + ); + + let before_changed_revision = durable_counts(&database, table).await; + let changed_revision = coordinator + .execute( + &mut client, + patch_request( + &patch_plan, + "patch-key", + &claims, + &patch_id, + &patched_etag, + "after-patch", + ), + ) + .await; + assert_eq!(changed_revision, Err(MutationError::IdempotencyConflict)); + assert_audited_refusal_only( + before_changed_revision, + durable_counts(&database, table).await, + ); + + let before_patch_conflict = durable_counts(&database, table).await; + let patch_conflict = coordinator + .execute( + &mut client, + MutationRequest { + plan: &patch_plan, + idempotency_key: "patch-conflict-key", + claims: &claims, + record_id: Some(&patch_id), + expected_etag: Some(&patched_etag), + body: MutationBody::Patch(vec![ + PatchOperation::Test { + path: "/data/label".to_owned(), + value: Value::String("not-current".to_owned()), + }, + PatchOperation::Replace { + path: "/data/label".to_owned(), + value: Value::String("not-applied".to_owned()), + }, + ]), + response_fields: BTreeSet::from(["label".to_owned(), "quantity".to_owned()]), + }, + ) + .await; + assert_eq!(patch_conflict, Err(MutationError::Conflict)); + assert_eq!( + patch_conflict.expect_err("test op refuses").to_string(), + "mutation conflicts with current state" + ); + assert_audited_refusal_only( + before_patch_conflict, + durable_counts(&database, table).await, + ); + + let mut concurrent_one = pool + .get_for_test() + .await + .expect("first concurrent connection is available"); + let mut concurrent_two = pool + .get_for_test() + .await + .expect("second concurrent connection is available"); + let before_concurrent = durable_counts(&database, table).await; + let (first, second) = tokio::join!( + coordinator.execute( + &mut concurrent_one, + create_request( + &create_plan, + "concurrent-key", + &claims, + RECORD_CONCURRENT, + "concurrent-label", + Some(5), + ), + ), + coordinator.execute( + &mut concurrent_two, + create_request( + &create_plan, + "concurrent-key", + &claims, + RECORD_CONCURRENT, + "concurrent-label", + Some(5), + ), + ), + ); + let first = first.expect("one concurrent request completes"); + let second = second.expect("the serialized retry completes"); + assert_ne!(first.replayed(), second.replayed()); + assert_eq!(first.response(), second.response()); + assert_one_complete_effect( + before_concurrent, + durable_counts(&database, table).await, + 1, + 4, + ); + + let before_recovery = durable_counts(&database, table).await; + let lost = coordinator + .execute_with_fault( + &mut client, + create_request( + &create_plan, + "recovery-key", + &claims, + RECORD_RECOVERY, + "recovery-label", + Some(12), + ), + MutationFaultPoint::AfterCommitBeforeResponseRelease, + ) + .await; + assert_eq!(lost, Err(MutationError::Unavailable)); + assert_one_complete_effect( + before_recovery, + durable_counts(&database, table).await, + 1, + 2, + ); + let before_recovery_replay = durable_counts(&database, table).await; + let recovered = coordinator + .execute( + &mut client, + create_request( + &create_plan, + "recovery-key", + &claims, + RECORD_RECOVERY, + "recovery-label", + Some(12), + ), + ) + .await + .expect("authorized retry recovers exact committed response"); + assert!(recovered.replayed()); + let recovery_id = response_id(&recovered); + assert_created_response(&recovered, &recovery_id, "recovery-label", 12); + assert_audited_replay_only( + before_recovery_replay, + durable_counts(&database, table).await, + ); + + let mut changed_identity = identity.clone(); + changed_identity.package_revision = "package-mutation-2".to_owned(); + changed_identity.package_sequence = 2; + database + .admin + .execute( + "UPDATE registry_internal.registry_state + SET active_package_revision = $1, package_sequence = $2 + WHERE singleton", + &[ + &changed_identity.package_revision, + &changed_identity.package_sequence, + ], + ) + .await + .expect("test can simulate activation of a same-schema package revision"); + let changed_package_coordinator = MutationCoordinator::new( + RegistryLockKey::derive("mutation-registry").expect("lock id is bounded"), + Duration::from_secs(2), + changed_identity, + profile.clone(), + ); + let before_changed_package = durable_counts(&database, table).await; + let before_changed_package_refusals = refusal_audit_count(&database).await; + let changed_package = changed_package_coordinator + .execute( + &mut client, + create_request( + &create_plan, + "positive-key", + &claims, + RECORD_POSITIVE, + "created-label", + Some(7), + ), + ) + .await; + assert_idempotency_refusal_only( + changed_package, + before_changed_package, + before_changed_package_refusals, + &database, + table, + ) + .await; + + assert_journals_are_minimized_and_chained(&database, &profile).await; + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_http_mutations_are_guarded_and_exactly_replayable() { + let database = TestDatabase::create(12).await; + let (migration, migration_task) = database.connect_migration().await; + let compiled = Arc::new(compiled_registry()); + install_compiled_schema(&migration, &compiled, &database.runtime_role) + .await + .expect("migration installs schema"); + let catalog = ExpectedManagedCatalog::compiled(&compiled); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision: "package-http-mutation-1", + package_sequence: 1, + }, + ) + .await + .expect("migration initializes state"); + migration_task.abort(); + + let pool = database.runtime_config.build_pool().expect("pool builds"); + let profile = AuditProfile::production_from_secret_bytes(vec![0x6b; 32].into()) + .expect("test owns keyed audit"); + let lock_key = RegistryLockKey::derive("mutation-registry").expect("lock id is bounded"); + let app = mutation_router( + pool.clone(), + compiled.clone(), + identity.clone(), + lock_key, + profile.clone(), + None, + ); + let table = compiled.entities()["widget"].physical_table.clone(); + let claims = api_claims("case-management", Some("zone-a")); + let rs_sec_13_claims = api_claims_with_principal_and_scopes( + RS_SEC_13_PRINCIPAL_CANARY, + "case-management", + Some(RS_SEC_13_ZONE_CANARY), + BTreeSet::from(["rs-sec-13-scope-canary".to_owned()]), + ); + + let openapi = body_json( + send( + &app, + Method::GET, + "/openapi.json?accessProfile=operator", + Some(claims.clone()), + &[], + Vec::new(), + ) + .await, + ) + .await; + assert!(openapi["paths"]["/v1/records/widgets"] + .get("post") + .is_some()); + assert!(openapi["paths"]["/v1/records/widgets/{record_id}"] + .get("patch") + .is_some()); + assert!(openapi["paths"]["/v1/records/widgets/{record_id}"] + .get("delete") + .is_some()); + assert!(openapi["paths"]["/v1/records/logs"].get("post").is_some()); + assert!(openapi["paths"] + .get("/v1/records/logs/{record_id}") + .and_then(|path| path.get("patch")) + .is_none()); + assert!(openapi["paths"] + .get("/v1/records/logs/{record_id}") + .and_then(|path| path.get("delete")) + .is_none()); + assert!(openapi["paths"] + .get("/v1/records/archives/{record_id}") + .and_then(|path| path.get("delete")) + .is_none()); + + let metadata = body_json( + send( + &app, + Method::GET, + "/v1/registry?accessProfile=operator", + Some(claims.clone()), + &[], + Vec::new(), + ) + .await, + ) + .await; + let metadata_entities = metadata["entities"].as_array().expect("metadata entities"); + let metadata_operations = |entity_id: &str| { + metadata_entities + .iter() + .find(|entity| entity["id"] == entity_id) + .and_then(|entity| entity["operations"].as_array()) + .expect("entity metadata operations") + }; + assert!(metadata_operations("widget") + .iter() + .any(|operation| operation["operation"] == "tombstone")); + assert!(!metadata_operations("log") + .iter() + .any(|operation| operation["operation"] == "tombstone")); + assert!(!metadata_operations("archive") + .iter() + .any(|operation| operation["operation"] == "tombstone")); + + let create_body = + br#"{"data":{"jurisdiction":"zone-a","label":"http-created","quantity":3}}"#.to_vec(); + for (label, headers, body, expected, code) in [ + ( + "missing idempotency", + vec![("content-type", "application/json")], + create_body.clone(), + StatusCode::BAD_REQUEST, + "request.invalid", + ), + ( + "wrong media", + vec![ + ("content-type", "text/plain"), + ("idempotency-key", "bad-media"), + ], + create_body.clone(), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "unsupported.media_type", + ), + ( + "caller id", + vec![ + ("content-type", "application/json"), + ("idempotency-key", "caller-id-body"), + ], + br#"{"id":"00000000-0000-0000-0000-000000000001","data":{"jurisdiction":"zone-a","label":"bad","quantity":1}}"#.to_vec(), + StatusCode::BAD_REQUEST, + "request.invalid", + ), + ] { + let before = durable_counts(&database, &table).await; + let response = send( + &app, + Method::POST, + "/v1/records/widgets", + Some(claims.clone()), + &headers, + body, + ) + .await; + assert_eq!(response.status(), expected, "{label}"); + assert_eq!(body_json(response).await["code"], code, "{label}"); + assert_eq!( + durable_counts(&database, &table).await.current, + before.current + ); + assert_eq!( + durable_counts(&database, &table).await.audit, + before.audit + 1, + "{label}" + ); + } + let before_duplicate_key = durable_counts(&database, &table).await; + let duplicate_key = request_with_duplicate_header( + &app, + DuplicateHeaderRequest { + method: Method::POST, + uri: "/v1/records/widgets", + claims: Some(claims.clone()), + duplicate: ("idempotency-key", "dup-key"), + headers: vec![("content-type", "application/json")], + body: create_body.clone(), + }, + ) + .await; + assert_eq!(duplicate_key.status(), StatusCode::BAD_REQUEST); + assert_eq!( + durable_counts(&database, &table).await.audit, + before_duplicate_key.audit + 1 + ); + + let created = response_parts( + send( + &app, + Method::POST, + "/v1/records/widgets", + Some(claims.clone()), + &[ + ("content-type", "application/json"), + ("idempotency-key", "http-create-key"), + ], + create_body.clone(), + ) + .await, + ) + .await; + assert_eq!(created.status, StatusCode::CREATED); + let record_id = created.body["id"].as_str().expect("id").to_owned(); + assert!(Uuid::parse_str(&record_id).is_ok_and(|id| id.to_string() == record_id)); + assert_eq!(created.body["revision"], 1); + assert_eq!(created.body["data"]["label"], "http-created"); + assert_eq!(created.body["data"]["note"], Value::Null); + assert!(created.etag.starts_with("\"rs-")); + assert_eq!( + created.location, + Some(format!("/v1/records/widgets/{record_id}")) + ); + + let replay = response_parts( + send( + &app, + Method::POST, + "/v1/records/widgets", + Some(claims.clone()), + &[ + ("content-type", "application/json"), + ("idempotency-key", "http-create-key"), + ], + create_body, + ) + .await, + ) + .await; + assert_eq!(replay.status, created.status); + assert_eq!(replay.body_bytes, created.body_bytes); + assert_eq!(replay.content_type, created.content_type); + assert_eq!(replay.etag, created.etag); + assert_eq!(replay.location, created.location); + + let before_rs_sec_13_seed = durable_counts(&database, &table).await; + let rs_sec_13_seed_body = format!( + r#"{{"data":{{"jurisdiction":"{RS_SEC_13_ZONE_CANARY}","label":"{RS_SEC_13_LABEL_CANARY}","quantity":{RS_SEC_13_QUANTITY_CANARY}}}}}"# + ) + .into_bytes(); + let rs_sec_13_seed = response_parts( + send( + &app, + Method::POST, + "/v1/records/widgets", + Some(rs_sec_13_claims.clone()), + &[ + ("content-type", "application/json"), + ("idempotency-key", "rs-sec-13-idempotency-key-seed"), + ( + "authorization", + "Bearer rs-sec-13-credential-canary.rs-sec-13-raw-token-canary", + ), + ], + rs_sec_13_seed_body.clone(), + ) + .await, + ) + .await; + assert_eq!(rs_sec_13_seed.status, StatusCode::CREATED); + assert_one_complete_effect( + before_rs_sec_13_seed, + durable_counts(&database, &table).await, + 1, + 2, + ); + + let before_rs_sec_13_conflict = durable_counts(&database, &table).await; + let rs_sec_13_conflict = send( + &app, + Method::POST, + "/v1/records/widgets", + Some(rs_sec_13_claims), + &[ + ("content-type", "application/json"), + ("idempotency-key", RS_SEC_13_IDEMPOTENCY_CANARY), + ( + "authorization", + "Bearer rs-sec-13-credential-canary.rs-sec-13-raw-token-canary", + ), + ], + rs_sec_13_seed_body, + ) + .await; + assert_unique_violation_conflict_is_value_free( + rs_sec_13_conflict, + before_rs_sec_13_conflict, + durable_counts(&database, &table).await, + &compiled, + ) + .await; + + let fetched = response_parts( + send( + &app, + Method::GET, + &format!("/v1/records/widgets/{record_id}?accessProfile=operator"), + Some(claims.clone()), + &[], + Vec::new(), + ) + .await, + ) + .await; + assert_eq!(fetched.body["id"], record_id); + assert_eq!(fetched.body["revision"], 1); + assert_eq!(fetched.body["data"]["label"], "http-created"); + assert_eq!(fetched.etag, created.etag); + + let anonymous_fetched = response_parts( + send( + &app, + Method::GET, + &format!("/v1/records/widgets/{record_id}?accessProfile=anonymous-reader"), + None, + &[], + Vec::new(), + ) + .await, + ) + .await; + assert_eq!( + anonymous_fetched.body["data"], + json!({"label": "http-created"}) + ); + assert!(anonymous_fetched.etag.starts_with("\"rs-")); + assert_ne!(anonymous_fetched.etag, fetched.etag); + + let listed_response = send( + &app, + Method::GET, + "/v1/records/widgets?accessProfile=operator", + Some(claims.clone()), + &[], + Vec::new(), + ) + .await; + assert!(listed_response.headers().get("etag").is_none()); + let listed = body_json(listed_response).await; + assert!(listed["items"] + .as_array() + .expect("items") + .iter() + .any(|item| item["id"] == record_id)); + + let log_created = response_parts( + send( + &app, + Method::POST, + "/v1/records/logs", + Some(claims.clone()), + &[ + ("content-type", "application/json"), + ("idempotency-key", "http-log-create-key"), + ], + br#"{"data":{"jurisdiction":"zone-a","message":"create-only-log"}}"#.to_vec(), + ) + .await, + ) + .await; + assert_eq!(log_created.status, StatusCode::CREATED); + let log_id = log_created.body["id"].as_str().expect("log id"); + let log_patch = send( + &app, + Method::PATCH, + &format!("/v1/records/logs/{log_id}"), + Some(claims.clone()), + &[ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "log-patch-omitted"), + ("if-match", &log_created.etag), + ], + br#"[{"op":"replace","path":"/data/message","value":"nope"}]"#.to_vec(), + ) + .await; + assert_eq!(log_patch.status(), StatusCode::NOT_FOUND); + assert_eq!(body_json(log_patch).await["code"], "resource.not_found"); + let log_delete = send( + &app, + Method::DELETE, + &format!("/v1/records/logs/{log_id}"), + Some(claims.clone()), + &[ + ("idempotency-key", "log-delete-omitted"), + ("if-match", &log_created.etag), + ], + Vec::new(), + ) + .await; + assert_eq!(log_delete.status(), StatusCode::NOT_FOUND); + assert!(log_delete.headers().get("etag").is_none()); + let archive_delete = send( + &app, + Method::DELETE, + "/v1/records/archives/00000000-0000-0000-0000-000000000001", + Some(claims.clone()), + &[ + ("idempotency-key", "archive-delete-omitted"), + ("if-match", "\"rs-route-omitted\""), + ], + Vec::new(), + ) + .await; + assert_eq!(archive_delete.status(), StatusCode::NOT_FOUND); + assert!(archive_delete.headers().get("etag").is_none()); + + let before_missing_match = durable_counts(&database, &table).await; + let missing_match = send( + &app, + Method::PATCH, + &format!("/v1/records/widgets/{record_id}"), + Some(claims.clone()), + &[ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "missing-match"), + ], + br#"[{"op":"replace","path":"/data/label","value":"x"}]"#.to_vec(), + ) + .await; + assert_eq!(missing_match.status(), StatusCode::PRECONDITION_REQUIRED); + assert_eq!( + body_json(missing_match).await["code"], + "precondition.required" + ); + assert_eq!( + durable_counts(&database, &table).await.audit, + before_missing_match.audit + 1 + ); + + let before_bad_patch_body = durable_counts(&database, &table).await; + let bad_patch_body = send( + &app, + Method::PATCH, + &format!("/v1/records/widgets/{record_id}"), + Some(claims.clone()), + &[ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "bad-patch-body"), + ("if-match", &created.etag), + ], + br#"{"op":"replace","path":"/data/label","value":"x"}"#.to_vec(), + ) + .await; + assert_eq!(bad_patch_body.status(), StatusCode::BAD_REQUEST); + assert_eq!( + durable_counts(&database, &table).await.audit, + before_bad_patch_body.audit + 1 + ); + + let patched = response_parts( + send( + &app, + Method::PATCH, + &format!("/v1/records/widgets/{record_id}"), + Some(claims.clone()), + &[ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "http-patch-key"), + ("if-match", &created.etag), + ], + br#"[ + {"op":"test","path":"/data/label","value":"http-created"}, + {"op":"add","path":"/data/note","value":"temporary"}, + {"op":"replace","path":"/data/label","value":"http-patched"}, + {"op":"remove","path":"/data/note"} + ]"# + .to_vec(), + ) + .await, + ) + .await; + assert_eq!(patched.status, StatusCode::OK); + assert_eq!(patched.body["revision"], 2); + assert_eq!(patched.body["data"]["label"], "http-patched"); + assert_eq!(patched.body["data"]["quantity"], 3); + assert_eq!(patched.body["data"]["note"], Value::Null); + assert!(patched.location.is_none()); + + let stale = send( + &app, + Method::PATCH, + &format!("/v1/records/widgets/{record_id}"), + Some(claims.clone()), + &[ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "http-stale-key"), + ("if-match", &created.etag), + ], + br#"[{"op":"replace","path":"/data/label","value":"stale"}]"#.to_vec(), + ) + .await; + assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED); + assert_eq!(body_json(stale).await["code"], "precondition.failed"); + + let wrong_context = send( + &app, + Method::PATCH, + &format!("/v1/records/widgets/{record_id}"), + Some(api_claims("case-management", Some("zone-b"))), + &[ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "http-wrong-context"), + ("if-match", &patched.etag), + ], + br#"[{"op":"replace","path":"/data/label","value":"hidden"}]"#.to_vec(), + ) + .await; + assert_eq!(wrong_context.status(), StatusCode::PRECONDITION_FAILED); + + let before_duplicate_match = durable_counts(&database, &table).await; + let duplicate_match = request_with_duplicate_header( + &app, + DuplicateHeaderRequest { + method: Method::PATCH, + uri: &format!("/v1/records/widgets/{record_id}"), + claims: Some(claims.clone()), + duplicate: ("if-match", &patched.etag), + headers: vec![ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "duplicate-match"), + ], + body: br#"[{"op":"replace","path":"/data/label","value":"x"}]"#.to_vec(), + }, + ) + .await; + assert_eq!(duplicate_match.status(), StatusCode::PRECONDITION_REQUIRED); + assert_eq!( + durable_counts(&database, &table).await.audit, + before_duplicate_match.audit + 1 + ); + + let current = response_parts( + send( + &app, + Method::GET, + &format!("/v1/records/widgets/{record_id}?accessProfile=operator"), + Some(claims.clone()), + &[], + Vec::new(), + ) + .await, + ) + .await; + assert_eq!(current.body["revision"], 2); + assert_eq!(current.etag, patched.etag); + + for (label, headers, body, expected, code) in [ + ( + "missing idempotency", + vec![("if-match", current.etag.as_str())], + Vec::new(), + StatusCode::BAD_REQUEST, + "request.invalid", + ), + ( + "missing if-match", + vec![("idempotency-key", "delete-missing-match")], + Vec::new(), + StatusCode::PRECONDITION_REQUIRED, + "precondition.required", + ), + ( + "weak if-match", + vec![ + ("idempotency-key", "delete-weak-match"), + ("if-match", "W/\"rs-weak\""), + ], + Vec::new(), + StatusCode::PRECONDITION_FAILED, + "precondition.failed", + ), + ( + "content type is forbidden", + vec![ + ("content-type", "application/json"), + ("idempotency-key", "delete-content-type"), + ("if-match", current.etag.as_str()), + ], + Vec::new(), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "unsupported.media_type", + ), + ( + "body is forbidden", + vec![ + ("idempotency-key", "delete-body"), + ("if-match", current.etag.as_str()), + ], + br#"{}"#.to_vec(), + StatusCode::BAD_REQUEST, + "request.invalid", + ), + ] { + let response = send( + &app, + Method::DELETE, + &format!("/v1/records/widgets/{record_id}"), + Some(claims.clone()), + &headers, + body, + ) + .await; + assert_eq!(response.status(), expected, "{label}"); + assert!(response.headers().get("etag").is_none(), "{label}"); + assert_eq!(body_json(response).await["code"], code, "{label}"); + } + + let duplicate_delete_key = request_with_duplicate_header( + &app, + DuplicateHeaderRequest { + method: Method::DELETE, + uri: &format!("/v1/records/widgets/{record_id}"), + claims: Some(claims.clone()), + duplicate: ("idempotency-key", "duplicate-delete-key"), + headers: vec![("if-match", current.etag.as_str())], + body: Vec::new(), + }, + ) + .await; + assert_eq!(duplicate_delete_key.status(), StatusCode::BAD_REQUEST); + let duplicate_delete_match = request_with_duplicate_header( + &app, + DuplicateHeaderRequest { + method: Method::DELETE, + uri: &format!("/v1/records/widgets/{record_id}"), + claims: Some(claims.clone()), + duplicate: ("if-match", current.etag.as_str()), + headers: vec![("idempotency-key", "duplicate-delete-match")], + body: Vec::new(), + }, + ) + .await; + assert_eq!( + duplicate_delete_match.status(), + StatusCode::PRECONDITION_REQUIRED + ); + + let query_authority = send( + &app, + Method::DELETE, + &format!("/v1/records/widgets/{record_id}?fields=label"), + Some(claims.clone()), + &[ + ("idempotency-key", "delete-query-authority"), + ("if-match", ¤t.etag), + ], + Vec::new(), + ) + .await; + assert_eq!(query_authority.status(), StatusCode::NOT_FOUND); + assert!(query_authority.headers().get("etag").is_none()); + + for (label, blocked_claims) in [ + ("anonymous", None), + ( + "unauthorized", + Some(api_claims("wrong-purpose", Some("zone-a"))), + ), + ] { + let response = send( + &app, + Method::DELETE, + &format!("/v1/records/widgets/{record_id}"), + blocked_claims, + &[ + ("idempotency-key", label), + ("if-match", current.etag.as_str()), + ], + Vec::new(), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{label}"); + assert!(response.headers().get("etag").is_none(), "{label}"); + } + + let stale_delete = send( + &app, + Method::DELETE, + &format!("/v1/records/widgets/{record_id}"), + Some(claims.clone()), + &[ + ("idempotency-key", "delete-stale-etag"), + ("if-match", &fetched.etag), + ], + Vec::new(), + ) + .await; + assert_eq!(stale_delete.status(), StatusCode::PRECONDITION_FAILED); + let stale_bytes = response_bytes(stale_delete).await; + assert!(!stale_bytes + .windows(b"http-patched".len()) + .any(|window| window == b"http-patched")); + + let changed_context = send( + &app, + Method::DELETE, + &format!("/v1/records/widgets/{record_id}?accessProfile=review-operator"), + Some(claims.clone()), + &[ + ("idempotency-key", "delete-changed-context"), + ("if-match", ¤t.etag), + ], + Vec::new(), + ) + .await; + assert_eq!(changed_context.status(), StatusCode::PRECONDITION_FAILED); + let changed_context_bytes = response_bytes(changed_context).await; + assert!(!changed_context_bytes + .windows(b"http-patched".len()) + .any(|window| window == b"http-patched")); + + let tombstoned = response_parts( + send( + &app, + Method::DELETE, + &format!("/v1/records/widgets/{record_id}"), + Some(claims.clone()), + &[ + ("idempotency-key", "http-tombstone-key"), + ("if-match", ¤t.etag), + ], + Vec::new(), + ) + .await, + ) + .await; + assert_eq!(tombstoned.status, StatusCode::OK); + assert_eq!(tombstoned.body["id"], record_id); + assert_eq!(tombstoned.body["revision"], 3); + assert_eq!(tombstoned.body["data"]["label"], "http-patched"); + + let tombstone_replay = response_parts( + send( + &app, + Method::DELETE, + &format!("/v1/records/widgets/{record_id}"), + Some(claims.clone()), + &[ + ("idempotency-key", "http-tombstone-key"), + ("if-match", ¤t.etag), + ], + Vec::new(), + ) + .await, + ) + .await; + assert_eq!(tombstone_replay.status, tombstoned.status); + assert_eq!(tombstone_replay.body_bytes, tombstoned.body_bytes); + assert_eq!(tombstone_replay.content_type, tombstoned.content_type); + assert_eq!(tombstone_replay.etag, tombstoned.etag); + + let concealed_tombstone = send( + &app, + Method::GET, + &format!("/v1/records/widgets/{record_id}?accessProfile=operator"), + Some(claims.clone()), + &[], + Vec::new(), + ) + .await; + assert_eq!(concealed_tombstone.status(), StatusCode::NOT_FOUND); + assert!(concealed_tombstone.headers().get("etag").is_none()); + let concealed_tombstone_bytes = response_bytes(concealed_tombstone).await; + assert!(!concealed_tombstone_bytes + .windows(b"http-patched".len()) + .any(|window| window == b"http-patched")); + + let before_bad_query = durable_counts(&database, &table).await; + let bad_query = send( + &app, + Method::POST, + "/v1/records/widgets?fields=label", + Some(claims.clone()), + &[ + ("content-type", "application/json"), + ("idempotency-key", "bad-query"), + ], + br#"{"data":{"jurisdiction":"zone-a","label":"bad-query","quantity":1}}"#.to_vec(), + ) + .await; + assert_eq!(bad_query.status(), StatusCode::NOT_FOUND); + assert_eq!(body_json(bad_query).await["code"], "resource.not_found"); + assert_eq!( + durable_counts(&database, &table).await.audit, + before_bad_query.audit + 1 + ); + + let refusal_faulting = mutation_refusal_audit_fault_router( + pool.clone(), + compiled.clone(), + identity.clone(), + lock_key, + profile.clone(), + ); + let before_refusal_fault = durable_counts(&database, &table).await; + let refusal_fault = send( + &refusal_faulting, + Method::DELETE, + &format!("/v1/records/widgets/{record_id}"), + Some(claims.clone()), + &[ + ("content-type", "text/plain"), + ("idempotency-key", "refusal-audit-fault"), + ("if-match", ¤t.etag), + ], + Vec::new(), + ) + .await; + assert_eq!(refusal_fault.status(), StatusCode::SERVICE_UNAVAILABLE); + let refusal_fault_body = response_bytes(refusal_fault).await; + let refusal_fault_json: Value = + serde_json::from_slice(&refusal_fault_body).expect("problem JSON"); + assert_eq!(refusal_fault_json["code"], "service.unavailable"); + assert!(!refusal_fault_body + .windows(b"unsupported.media_type".len()) + .any(|window| window == b"unsupported.media_type")); + assert_eq!( + durable_counts(&database, &table).await, + before_refusal_fault, + "refusal audit failure releases no intended refusal and commits no mutation packet" + ); + + for (label, uri, blocked_claims) in [ + ( + "wrong purpose", + "/v1/records/widgets?accessProfile=operator", + api_claims("wrong-purpose", Some("zone-a")), + ), + ( + "wrong profile", + "/v1/records/widgets?accessProfile=missing", + api_claims("case-management", Some("zone-a")), + ), + ( + "missing boundary", + "/v1/records/widgets?accessProfile=operator", + api_claims("case-management", None), + ), + ] { + let before = durable_counts(&database, &table).await; + let response = send( + &app, + Method::POST, + uri, + Some(blocked_claims), + &[ + ("content-type", "application/json"), + ("idempotency-key", label), + ], + br#"{"data":{"jurisdiction":"zone-a","label":"blocked","quantity":1}}"#.to_vec(), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{label}"); + assert_eq!( + durable_counts(&database, &table).await.current, + before.current + ); + assert_eq!( + durable_counts(&database, &table).await.audit, + before.audit + 1 + ); + } + + let faulting = mutation_router( + pool, + compiled, + identity, + lock_key, + profile.clone(), + Some(MutationFaultPoint::BeforeTerminalAudit), + ); + let before_fault = durable_counts(&database, &table).await; + let faulted = send( + &faulting, + Method::POST, + "/v1/records/widgets", + Some(claims), + &[ + ("content-type", "application/json"), + ("idempotency-key", "http-terminal-fault"), + ], + br#"{"data":{"jurisdiction":"zone-a","label":"not-released","quantity":9}}"#.to_vec(), + ) + .await; + assert_eq!(faulted.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_json(faulted).await["code"], "service.unavailable"); + assert_eq!( + durable_counts(&database, &table).await, + DurableCounts { + audit: before_fault.audit + 1, + ..before_fault + }, + "terminal audit failure releases no success bytes and commits no mutation packet" + ); + + assert_journals_are_minimized_and_chained(&database, &profile).await; + database.cleanup().await; +} + +fn mutation_refusal_audit_fault_router( + pool: registry_server::postgres::RuntimePool, + registry: Arc, + identity: registry_server::postgres::ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + profile: AuditProfile, +) -> axum::Router { + let cursors = test_cursor_codec(); + let records = Arc::new(PostgresRecordReadService::new( + pool.clone(), + registry.clone(), + identity.clone(), + lock_key, + Duration::from_secs(2), + profile.clone(), + cursors.clone(), + )); + let read_identity = ReadRuntimeIdentity { + package_revision: identity.package_revision.clone(), + schema_fingerprint: identity.schema_fingerprint.clone(), + }; + let mutations = PostgresRecordMutationService::new( + pool, + registry.clone(), + identity, + lock_key, + Duration::from_secs(2), + profile, + ) + .with_refusal_audit_fault_for_test(); + router(Arc::new( + HttpService::new( + registry, + read_identity, + records, + Arc::new(AlwaysReady), + cursors, + ) + .with_postgres_mutations(Arc::new(mutations)), + )) +} + +fn mutation_router( + pool: registry_server::postgres::RuntimePool, + registry: Arc, + identity: registry_server::postgres::ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + profile: AuditProfile, + fault: Option, +) -> axum::Router { + let cursors = test_cursor_codec(); + let records = Arc::new(PostgresRecordReadService::new( + pool.clone(), + registry.clone(), + identity.clone(), + lock_key, + Duration::from_secs(2), + profile.clone(), + cursors.clone(), + )); + let read_identity = ReadRuntimeIdentity { + package_revision: identity.package_revision.clone(), + schema_fingerprint: identity.schema_fingerprint.clone(), + }; + let mutations = PostgresRecordMutationService::new( + pool, + registry.clone(), + identity, + lock_key, + Duration::from_secs(2), + profile, + ); + let mutations = match fault { + Some(fault) => mutations.with_fault_for_test(fault), + None => mutations, + }; + router(Arc::new( + HttpService::new( + registry, + read_identity, + records, + Arc::new(AlwaysReady), + cursors, + ) + .with_postgres_mutations(Arc::new(mutations)), + )) +} + +fn test_cursor_codec() -> Arc { + Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x63; 32]), Duration::from_secs(300)) + .expect("test cursor key is valid"), + ) +} + +struct AlwaysReady; + +impl ReadinessProbe for AlwaysReady { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async { true }) + } +} + +async fn send( + app: &axum::Router, + method: Method, + uri: &str, + claims: Option, + headers: &[(&str, &str)], + body: Vec, +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .body(Body::from(body)) + .expect("request"); + for (name, value) in headers { + request.headers_mut().append( + HeaderName::from_bytes(name.as_bytes()).expect("test header name"), + HeaderValue::from_str(value).expect("test header value"), + ); + } + if let Some(claims) = claims { + request.extensions_mut().insert(claims); + } + let mut app = app.clone(); + app.call(request).await.expect("response") +} + +struct DuplicateHeaderRequest<'a> { + method: Method, + uri: &'a str, + claims: Option, + duplicate: (&'a str, &'a str), + headers: Vec<(&'a str, &'a str)>, + body: Vec, +} + +async fn request_with_duplicate_header( + app: &axum::Router, + input: DuplicateHeaderRequest<'_>, +) -> axum::response::Response { + let mut request = Request::builder() + .method(input.method) + .uri(input.uri) + .body(Body::from(input.body)) + .expect("request"); + for (name, value) in input.headers { + request.headers_mut().append( + HeaderName::from_bytes(name.as_bytes()).expect("test header name"), + HeaderValue::from_str(value).expect("test header value"), + ); + } + for _ in 0..2 { + request.headers_mut().append( + HeaderName::from_bytes(input.duplicate.0.as_bytes()).expect("test header name"), + HeaderValue::from_str(input.duplicate.1).expect("test header value"), + ); + } + if let Some(claims) = input.claims { + request.extensions_mut().insert(claims); + } + let mut app = app.clone(); + app.call(request).await.expect("response") +} + +struct ResponseParts { + status: StatusCode, + body: Value, + body_bytes: Vec, + content_type: String, + etag: String, + location: Option, +} + +async fn response_parts(response: axum::response::Response) -> ResponseParts { + let status = response.status(); + let headers = response.headers().clone(); + let body_bytes = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .expect("body") + .to_vec(); + let body = serde_json::from_slice(&body_bytes).expect("JSON body"); + ResponseParts { + status, + body, + body_bytes, + content_type: header_string(&headers, "content-type"), + etag: header_string(&headers, "etag"), + location: headers + .get("location") + .map(|value| value.to_str().expect("location").to_owned()), + } +} + +async fn body_json(response: axum::response::Response) -> Value { + let bytes = response_bytes(response).await; + serde_json::from_slice(&bytes).expect("JSON response") +} + +async fn response_bytes(response: axum::response::Response) -> Vec { + to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response body") + .to_vec() +} + +fn header_string(headers: &axum::http::HeaderMap, name: &str) -> String { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .expect("header is present") + .to_owned() +} + +fn api_claims(purpose: &str, zone: Option<&str>) -> VerifiedRequestClaims { + api_claims_with_principal_and_scopes(PRINCIPAL_CANARY, purpose, zone, BTreeSet::new()) +} + +fn api_claims_with_principal_and_scopes( + principal: &str, + purpose: &str, + zone: Option<&str>, + scopes: BTreeSet, +) -> VerifiedRequestClaims { + let mut direct_claims = std::collections::BTreeMap::new(); + if let Some(zone) = zone { + direct_claims.insert( + "jurisdiction".to_owned(), + VerifiedClaimValue::direct_string(zone).expect("direct claim"), + ); + } + VerifiedRequestClaims::authenticated( + "registry_principal", + principal, + scopes, + Some(purpose.to_owned()), + direct_claims, + ) + .expect("verified context") +} + +fn compiled_registry() -> registry_server::CompiledRegistry { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"mutation-registry","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"widget","route":"widgets","mutationMode":"mutable","tombstone":true,"classification":"public", + "constraints":[{"kind":"unique","fields":["label"]}], + "fields":[ + {"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"label","type":"string","maxLength":128,"required":true,"classification":"public"}, + {"id":"note","type":"string","maxLength":128,"required":false,"classification":"public"}, + {"id":"quantity","type":"int64","required":true,"classification":"public"} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management","case-review"], + "operations":["create","get","list","patch","tombstone"], + "readableFields":["jurisdiction","label","note","quantity"], + "writableFields":["jurisdiction","label","note","quantity"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + },{ + "id":"review-operator","principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "operations":["create","get","list","patch","tombstone"], + "readableFields":["jurisdiction","label","note","quantity"], + "writableFields":["jurisdiction","label","note","quantity"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + },{ + "id":"anonymous-reader","anonymous":true, + "operations":["get","list"], + "readableFields":["label"] + },{ + "id":"label-editor","principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "operations":["get","patch"], + "readableFields":["label"], + "writableFields":["label"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }], + "events":[ + {"id":"widget-created","trigger":"created","projection":["label"]}, + {"id":"widget-patched","trigger":"patched","projection":["label","quantity"]}, + {"id":"widget-tombstoned","trigger":"tombstoned","projection":["label","quantity"]} + ] + },{ + "id":"log","route":"logs","mutationMode":"create_only","classification":"public", + "fields":[ + {"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"message","type":"string","maxLength":128,"required":true,"classification":"public"} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "operations":["create","get","list"], + "readableFields":["jurisdiction","message"], + "writableFields":["jurisdiction","message"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }] + },{ + "id":"archive","route":"archives","mutationMode":"mutable","classification":"public", + "fields":[ + {"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"name","type":"string","maxLength":128,"required":true,"classification":"public"} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "operations":["create","get","list","patch"], + "readableFields":["jurisdiction","name"], + "writableFields":["jurisdiction","name"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }] + }] + }"#, + ) + .expect("mutation fixture parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("mutation fixture compiles to trusted inventories") +} + +fn mutation_claims( + registry: ®istry_server::CompiledRegistry, + principal: &str, + zone: &str, +) -> ClaimContext { + ClaimContext::for_compiled( + registry, + "widget", + Some(principal.to_owned()), + "operator", + Some("case-management".to_owned()), + vec![RowBoundaryContext::Equals { + field: "jurisdiction".to_owned(), + value: zone.to_owned(), + }], + ) + .expect("claim context is compiler-bound") +} + +fn create_request<'a>( + plan: &'a MutationPlan, + key: &'a str, + claims: &'a ClaimContext, + _record_id: &'a str, + label: &str, + quantity: Option, +) -> MutationRequest<'a> { + let mut data = Map::from_iter([ + ( + "jurisdiction".to_owned(), + Value::String("zone-a".to_owned()), + ), + ("label".to_owned(), Value::String(label.to_owned())), + ]); + if let Some(quantity) = quantity { + data.insert("quantity".to_owned(), json!(quantity)); + } + MutationRequest { + plan, + idempotency_key: key, + claims, + record_id: None, + expected_etag: None, + body: MutationBody::Create(data), + response_fields: BTreeSet::from(["label".to_owned(), "quantity".to_owned()]), + } +} + +fn patch_request<'a>( + plan: &'a MutationPlan, + key: &'a str, + claims: &'a ClaimContext, + record_id: &'a str, + expected_etag: &'a str, + label: &str, +) -> MutationRequest<'a> { + MutationRequest { + plan, + idempotency_key: key, + claims, + record_id: Some(record_id), + expected_etag: Some(expected_etag), + body: MutationBody::Patch(vec![PatchOperation::Replace { + path: "/data/label".to_owned(), + value: Value::String(label.to_owned()), + }]), + response_fields: BTreeSet::from(["label".to_owned(), "quantity".to_owned()]), + } +} + +fn response_etag(outcome: &MutationOutcome) -> String { + String::from_utf8(outcome.response().headers()[&PermittedResponseHeader::Etag].clone()) + .expect("mutation response etag is UTF-8") +} + +fn response_id(outcome: &MutationOutcome) -> String { + let body: Value = + serde_json::from_slice(outcome.response().body()).expect("mutation response is JSON"); + body["id"] + .as_str() + .expect("mutation response includes id") + .to_owned() +} + +fn assert_created_response(outcome: &MutationOutcome, record_id: &str, label: &str, quantity: i64) { + assert_eq!(outcome.response().status(), 201); + assert_eq!( + outcome.response().body(), + format!( + "{{\"data\":{{\"label\":\"{label}\",\"quantity\":{quantity}}},\"id\":\"{record_id}\",\"revision\":1}}" + ) + .as_bytes() + ); + assert_eq!( + outcome.response().headers()[&PermittedResponseHeader::ContentType], + b"application/json" + ); + assert!(outcome.response().headers()[&PermittedResponseHeader::Etag].starts_with(b"\"rs-")); + assert_eq!( + outcome.response().headers()[&PermittedResponseHeader::Location], + format!("/v1/records/widgets/{record_id}").as_bytes() + ); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DurableCounts { + current: i64, + revisions: i64, + outbox: i64, + audit: i64, + idempotency: i64, +} + +fn assert_one_complete_effect( + before: DurableCounts, + after: DurableCounts, + current_delta: i64, + audit_delta: i64, +) { + assert_eq!( + after, + DurableCounts { + current: before.current + current_delta, + revisions: before.revisions + 1, + outbox: before.outbox + 1, + audit: before.audit + audit_delta, + idempotency: before.idempotency + 1, + }, + "one successful request creates one complete atomic packet" + ); +} + +fn assert_audited_replay_only(before: DurableCounts, after: DurableCounts) { + assert_eq!( + after, + DurableCounts { + audit: before.audit + 2, + ..before + } + ); +} + +fn assert_audited_refusal_only(before: DurableCounts, after: DurableCounts) { + assert_eq!( + after, + DurableCounts { + audit: before.audit + 2, + ..before + } + ); +} + +async fn assert_idempotency_refusal_only( + result: Result, + before: DurableCounts, + before_refusals: i64, + database: &TestDatabase, + table: &str, +) { + assert_eq!(result, Err(MutationError::IdempotencyConflict)); + assert_audited_refusal_only(before, durable_counts(database, table).await); + assert_eq!( + refusal_audit_count(database).await, + before_refusals + 1, + "idempotency conflict records exactly one minimized refusal audit" + ); +} + +async fn assert_unique_violation_conflict_is_value_free( + response: axum::response::Response, + before: DurableCounts, + after: DurableCounts, + compiled: ®istry_server::CompiledRegistry, +) { + let status = response.status(); + let headers = response.headers().clone(); + let body = response_bytes(response).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!( + headers + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("application/problem+json") + ); + assert!(headers.get("etag").is_none()); + assert!(headers.get("location").is_none()); + assert_eq!( + body.as_slice(), + br#"{"type":"urn:registry-server:problem:mutation.conflict","title":"Conflict","status":409,"detail":"The mutation conflicts with current state.","code":"mutation.conflict"}"# + ); + let problem: Value = serde_json::from_slice(&body).expect("problem JSON is valid"); + assert_eq!( + problem, + json!({ + "type": "urn:registry-server:problem:mutation.conflict", + "title": "Conflict", + "status": 409, + "detail": "The mutation conflicts with current state.", + "code": "mutation.conflict", + }) + ); + assert_eq!( + after, + DurableCounts { + audit: before.audit + 2, + ..before + }, + "a PostgreSQL uniqueness refusal leaves only minimized attempt/refusal audits" + ); + let public_error_text = format!("{} {:?}", MutationError::Conflict, MutationError::Conflict); + assert_diagnostic_text_excludes_canaries_and_database_details( + std::str::from_utf8(&body).expect("problem body is UTF-8"), + compiled, + ); + assert_diagnostic_text_excludes_canaries_and_database_details(&public_error_text, compiled); +} + +fn assert_diagnostic_text_excludes_canaries_and_database_details( + text: &str, + compiled: ®istry_server::CompiledRegistry, +) { + let lower_text = text.to_ascii_lowercase(); + for forbidden in forbidden_diagnostic_fragments(compiled) { + assert!( + !text.contains(&forbidden) && !lower_text.contains(&forbidden.to_ascii_lowercase()), + "public diagnostic text leaked forbidden fragment {forbidden:?}: {text}" + ); + } +} + +fn forbidden_diagnostic_fragments( + compiled: ®istry_server::CompiledRegistry, +) -> BTreeSet { + let mut forbidden = BTreeSet::from([ + PRINCIPAL_CANARY.to_owned(), + RS_SEC_13_PRINCIPAL_CANARY.to_owned(), + RS_SEC_13_TOKEN_CANARY.to_owned(), + RS_SEC_13_CREDENTIAL_CANARY.to_owned(), + RS_SEC_13_IDEMPOTENCY_CANARY.to_owned(), + RS_SEC_13_ZONE_CANARY.to_owned(), + RS_SEC_13_LABEL_CANARY.to_owned(), + RS_SEC_13_QUANTITY_CANARY.to_owned(), + "registry_data".to_owned(), + "registry_internal".to_owned(), + "insert into".to_owned(), + "update ".to_owned(), + "select ".to_owned(), + "returning".to_owned(), + "duplicate key".to_owned(), + "violates unique constraint".to_owned(), + "already exists".to_owned(), + "key (".to_owned(), + "sqlstate".to_owned(), + "23505".to_owned(), + ]); + let widget = &compiled.entities()["widget"]; + forbidden.insert(widget.physical_table.clone()); + forbidden.extend( + widget + .fields + .values() + .map(|field| field.physical_name.clone()), + ); + let widget_names = &compiled.physical_names().entities["widget"]; + forbidden.insert(widget_names.table.clone()); + forbidden.extend(widget_names.fields.values().cloned()); + forbidden.extend(widget_names.constraints.values().cloned()); + forbidden.extend(widget_names.indexes.values().cloned()); + forbidden.extend(widget_names.policies.values().cloned()); + forbidden +} + +async fn durable_counts(database: &TestDatabase, table: &str) -> DurableCounts { + let row = database + .admin + .query_one( + &format!( + "SELECT + (SELECT count(*) FROM registry_data.\"{table}\"), + (SELECT count(*) FROM registry_internal.registry_revisions), + (SELECT count(*) FROM registry_internal.registry_outbox), + (SELECT count(*) FROM registry_internal.registry_audit), + (SELECT count(*) FROM registry_internal.registry_idempotency)" + ), + &[], + ) + .await + .expect("administrator can inspect isolated durable state"); + DurableCounts { + current: row.get(0), + revisions: row.get(1), + outbox: row.get(2), + audit: row.get(3), + idempotency: row.get(4), + } +} + +async fn refusal_audit_count(database: &TestDatabase) -> i64 { + database + .admin + .query_one( + "SELECT count(*) + FROM registry_internal.registry_audit + WHERE convert_from(envelope, 'UTF8') LIKE '%\"phase\":\"refusal\"%'", + &[], + ) + .await + .expect("administrator can inspect minimized refusal audit events") + .get(0) +} + +async fn assert_patch_preserved_omitted_field( + database: &TestDatabase, + table: &str, + record_id: &str, +) { + let rows = database + .admin + .query( + "SELECT snapshot FROM registry_internal.registry_revisions + WHERE record_revision = 2", + &[], + ) + .await + .expect("administrator can inspect complete post-write revision"); + assert!(rows.iter().any(|row| { + row.get::<_, Vec>(0) + == br#"{"jurisdiction":"zone-a","label":"after-patch","note":null,"quantity":41}"# + .as_slice() + })); + let events = database + .admin + .query( + "SELECT payload FROM registry_internal.registry_outbox + WHERE event_type = 'widget-patched'", + &[], + ) + .await + .expect("administrator can inspect configured post-write event"); + assert!(events.iter().any(|row| { + row.get::<_, Vec>(0) == br#"{"label":"after-patch","quantity":41}"#.as_slice() + })); + let quantity_physical = compiled_registry().entities()["widget"].fields["quantity"] + .physical_name + .clone(); + let quantity: i64 = database + .admin + .query_one( + &format!( + "SELECT \"{quantity_physical}\" FROM registry_data.\"{table}\" + WHERE record_id = $1::text::uuid" + ), + &[&record_id], + ) + .await + .expect("typed current row retains omitted field") + .get(0); + assert_eq!(quantity, 41); +} + +async fn assert_journals_are_minimized_and_chained( + database: &TestDatabase, + profile: &AuditProfile, +) { + let audit_rows = database + .admin + .query("SELECT envelope FROM registry_internal.registry_audit", &[]) + .await + .expect("administrator can inspect audit envelopes"); + let mut envelopes = audit_rows + .iter() + .map(|row| { + serde_json::from_slice::(&row.get::<_, Vec>(0)) + .expect("audit envelope is canonical platform JSON") + }) + .collect::>(); + let mut ordered = Vec::with_capacity(envelopes.len()); + let mut predecessor = None; + while !envelopes.is_empty() { + let position = envelopes + .iter() + .position(|envelope| envelope.prev_hash == predecessor) + .expect("database audit chain has one next envelope"); + let envelope = envelopes.remove(position); + predecessor = Some(envelope.record_hash); + ordered.push(envelope); + } + let audit_lines = ordered + .iter() + .map(|envelope| serde_json::to_string(envelope).expect("audit envelope serializes")) + .collect::>(); + verify_jsonl_lines_with_hasher(audit_lines.iter(), &profile.chain_hasher()) + .expect("database audit envelopes form one keyed platform chain"); + let audit_text = audit_lines.join("\n"); + assert!(!audit_text.contains(PRINCIPAL_CANARY)); + assert!(!audit_text.contains(RS_SEC_13_PRINCIPAL_CANARY)); + assert!(!audit_text.contains(RS_SEC_13_TOKEN_CANARY)); + assert!(!audit_text.contains(RS_SEC_13_CREDENTIAL_CANARY)); + assert!(!audit_text.contains(RS_SEC_13_IDEMPOTENCY_CANARY)); + for record in [ + RECORD_POSITIVE, + RECORD_PATCH, + RECORD_RECOVERY, + RECORD_CONCURRENT, + ] { + assert!(!audit_text.contains(record)); + } + assert!(audit_text.contains("\"outcome\":\"replayed\"")); + assert!(audit_text.contains("principalReference")); + assert!(audit_text.contains("recordReference")); + + for table_and_column in [ + ("registry_revisions", "snapshot"), + ("registry_outbox", "payload"), + ] { + let rows = database + .admin + .query( + &format!( + "SELECT {column}, record_reference FROM registry_internal.{table}", + column = table_and_column.1, + table = table_and_column.0 + ), + &[], + ) + .await + .expect("administrator can inspect mutation journal"); + for row in rows { + let payload: Vec = row.get(0); + let reference: String = row.get(1); + let payload = String::from_utf8_lossy(&payload); + assert!(!payload.contains(PRINCIPAL_CANARY)); + assert!(!payload.contains(RS_SEC_13_PRINCIPAL_CANARY)); + assert!(!payload.contains(RS_SEC_13_TOKEN_CANARY)); + assert!(!payload.contains(RS_SEC_13_CREDENTIAL_CANARY)); + assert!(!payload.contains(RS_SEC_13_IDEMPOTENCY_CANARY)); + assert!(!reference.contains(PRINCIPAL_CANARY)); + assert!(!reference.contains(RS_SEC_13_PRINCIPAL_CANARY)); + assert!(!reference.contains(RS_SEC_13_TOKEN_CANARY)); + assert!(!reference.contains(RS_SEC_13_CREDENTIAL_CANARY)); + assert!(!reference.contains(RS_SEC_13_IDEMPOTENCY_CANARY)); + for record in [ + RECORD_POSITIVE, + RECORD_PATCH, + RECORD_RECOVERY, + RECORD_CONCURRENT, + ] { + assert!(!payload.contains(record)); + assert!(!reference.contains(record)); + } + } + } + + let references = database + .admin + .query( + "SELECT key_reference, binding_reference + FROM registry_internal.registry_idempotency", + &[], + ) + .await + .expect("administrator can inspect keyed idempotency references"); + for row in references { + let key_reference: String = row.get(0); + let binding_reference: String = row.get(1); + assert!(!key_reference.contains(PRINCIPAL_CANARY)); + assert!(!key_reference.contains(RS_SEC_13_PRINCIPAL_CANARY)); + assert!(!key_reference.contains(RS_SEC_13_TOKEN_CANARY)); + assert!(!key_reference.contains(RS_SEC_13_CREDENTIAL_CANARY)); + assert!(!key_reference.contains(RS_SEC_13_IDEMPOTENCY_CANARY)); + assert!(!binding_reference.contains(PRINCIPAL_CANARY)); + assert!(!binding_reference.contains(RS_SEC_13_PRINCIPAL_CANARY)); + assert!(!binding_reference.contains(RS_SEC_13_TOKEN_CANARY)); + assert!(!binding_reference.contains(RS_SEC_13_CREDENTIAL_CANARY)); + assert!(!binding_reference.contains(RS_SEC_13_IDEMPOTENCY_CANARY)); + for record in [ + RECORD_POSITIVE, + RECORD_PATCH, + RECORD_RECOVERY, + RECORD_CONCURRENT, + ] { + assert!(!binding_reference.contains(record)); + } + } +} + +#[test] +fn mutation_error_vocabulary_is_closed_and_value_free() { + for error in [ + MutationError::InvalidRequest, + MutationError::PreconditionFailed, + MutationError::Conflict, + MutationError::IdempotencyConflict, + MutationError::Unavailable, + ] { + let rendered = error.to_string(); + assert!(!rendered.contains("registry_")); + assert!(!rendered.contains("00000000")); + assert!(!rendered.contains(PRINCIPAL_CANARY)); + } +} + +#[test] +fn compiled_fixture_exposes_create_patch_and_configured_tombstone_plans() { + let compiled = compiled_registry(); + assert!(compiled.routes().routes.iter().any(|route| { + route.id == "records.widget.create" && route.operation == Operation::Create + })); + assert!(compiled + .routes() + .routes + .iter() + .any(|route| route.id == "records.widget.patch" && route.operation == Operation::Patch)); + assert!(compiled.routes().routes.iter().any(|route| { + route.id == "records.widget.tombstone" && route.operation == Operation::Tombstone + })); + assert!(!compiled + .routes() + .routes + .iter() + .any(|route| { route.entity_id == "log" && route.operation == Operation::Tombstone })); + assert!(!compiled + .routes() + .routes + .iter() + .any(|route| { route.entity_id == "archive" && route.operation == Operation::Tombstone })); +} diff --git a/crates/registry-server/tests/postgres_package.rs b/crates/registry-server/tests/postgres_package.rs new file mode 100644 index 0000000000..23a0710f60 --- /dev/null +++ b/crates/registry-server/tests/postgres_package.rs @@ -0,0 +1,2345 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "runtime")] + +#[path = "support/postgres_harness.rs"] +mod postgres_harness; + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use postgres_harness::TestDatabase; +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_crypto::{generate_private_jwk, sign, GeneratedKeyAlgorithm, PrivateJwk}; +use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::migration::{ + apply_verified_package, ApplyPrecondition, ApplyRoles, ApplyTimeouts, + ApplyVerifiedPackageRequest, MigrationError, +}; +use registry_server::package::{ + derive_package_revision, load_package, prepare_package, PackageBuildRequest, PackageEnvelope, + PackageError, PackageFileRole, PackageIntent, PackageLoadContext, PackageManifest, + PackageMigrationPlanInput, PackageModuleSource, PackageSignature, PackageSourceFile, + PackageTrustAnchor, SignaturePolicy, TrustAnchorKey, MAX_PACKAGE_SOURCE_FILE_BYTES, + TRUST_ANCHOR_API_VERSION, +}; +use registry_server::postgres::{ + begin_record_transaction, install_compiled_schema, managed_schema_fingerprint, ClaimContext, + ExpectedManagedCatalog, ExpectedRegistryIdentity, RegistryLockKey, +}; +use registry_server::startup::{prepare_startup, StartupError}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tokio_postgres::GenericClient; + +const INSTANCE: &str = "instance-under-test"; +const DATABASE: &str = "database-under-test"; +const SOURCE_REVISION: &str = "compiler-source-revision"; +const FIXTURE_JOURNEYS: &[u8] = br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: package-read + steps: + - id: list-neutral-records + entity: neutral-record + accessProfile: reader + claims: {principal: package-reader} + request: {operation: list} + expect: {outcome: success, status: 200, count: 0} +"#; +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[test] +fn package_builder_is_deterministic_and_local_publication_loads() { + let module_bytes = module_bytes(PlanChoice::Schema); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); + let project_bytes = project_bytes("local", 1, &module_digest(&module)); + let request = build_request(BuildRequestParts { + environment: "local", + sequence: 1, + prior_revision: None, + schema_fingerprint: fingerprint(1), + project_bytes, + module_bytes, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + }); + let first = prepare_package(request.clone()).expect("first package prepares"); + let second = prepare_package(request).expect("second package prepares"); + assert_eq!(first.manifest(), second.manifest()); + assert_eq!( + first.canonical_signed_bytes(), + second.canonical_signed_bytes() + ); + assert_eq!(first.file_bytes(), second.file_bytes()); + assert_eq!(first.registry(), second.registry()); + + let root = TempRoot::create(); + first + .publish_to_directory(root.path(), Vec::new()) + .expect("local package publishes"); + load_package( + root.path(), + &local_context(PackageIntent::InitialActivation), + ) + .expect("published local package loads"); + assert_eq!( + first + .publish_to_directory(root.path(), Vec::new()) + .expect_err("package publication refuses replacement"), + PackageError::Closure + ); +} + +#[test] +fn package_builder_refuses_successor_without_prior_compiled_registry() { + let module_bytes = module_bytes(PlanChoice::SecondTable); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); + let project_bytes = project_bytes("local", 2, &module_digest(&module)); + let refused = prepare_package(build_request(BuildRequestParts { + environment: "local", + sequence: 2, + prior_revision: Some( + "sha256:1111111111111111111111111111111111111111111111111111111111111111", + ), + schema_fingerprint: fingerprint(1), + project_bytes, + module_bytes, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + })); + assert_eq!(refused.err(), Some(PackageError::MigrationPlan)); +} + +#[test] +fn package_layout_contract_required_manifest_projection_is_in_prepared_closure() { + let layout = fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/registry-server/contracts/package-layout.yaml" + )) + .expect("package layout contract reads"); + assert!( + layout.contains("manifest/registry-manifest.json") + && layout.contains("lossy-manifest-projection"), + "package-layout.yaml requires a lossy manifest projection" + ); + + let module_bytes = module_bytes(PlanChoice::Schema); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); + let project_bytes = project_bytes("local", 1, &module_digest(&module)); + let package = prepare_package(build_request(BuildRequestParts { + environment: "local", + sequence: 1, + prior_revision: None, + schema_fingerprint: fingerprint(1), + project_bytes, + module_bytes, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + })) + .expect("maximal coherent package prepares"); + assert!(package + .manifest() + .files + .iter() + .any(|entry| entry.path == "manifest/registry-manifest.json" + && entry.role == PackageFileRole::LossyManifestProjection)); + assert!(package + .file_bytes() + .contains_key("manifest/registry-manifest.json")); + assert!(package + .manifest() + .files + .iter() + .any(|entry| entry.path == "inventories/events.json" + && entry.role == PackageFileRole::EventInventory)); + assert!(package.file_bytes().contains_key("inventories/events.json")); + assert!(layout.contains("metadata/registry.json") && layout.contains("caller-safe-metadata")); + assert!(layout.contains("tests/journeys.yaml") && layout.contains("fixture-journeys")); + assert!(package + .manifest() + .files + .iter() + .any(|entry| entry.path == "metadata/registry.json" + && entry.role == PackageFileRole::CallerSafeMetadata)); + assert!(package + .manifest() + .files + .iter() + .any(|entry| entry.path == "tests/journeys.yaml" + && entry.role == PackageFileRole::FixtureJourneys)); + assert_eq!( + package + .file_bytes() + .get("tests/journeys.yaml") + .map(Vec::as_slice), + Some(FIXTURE_JOURNEYS) + ); + let exact_paths = package + .file_bytes() + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + exact_paths, + BTreeSet::from([ + "database/ddl.sql", + "database/migration-plan.json", + "effective-model.json", + "inventories/access.json", + "inventories/events.json", + "inventories/physical-names.json", + "inventories/queries.json", + "inventories/routes.json", + "manifest/registry-manifest.json", + "metadata/registry.json", + "openapi/openapi.json", + "schemas/neutral-record.schema.json", + "source/modules/core/module.yaml", + "source/registry.yaml", + "tests/journeys.yaml", + ]) + ); +} + +#[test] +fn fixture_journeys_are_required_at_the_fixed_path_and_change_the_package_revision() { + let module_bytes = module_bytes(PlanChoice::Schema); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); + let project_bytes = project_bytes("local", 1, &module_digest(&module)); + let request = build_request(BuildRequestParts { + environment: "local", + sequence: 1, + prior_revision: None, + schema_fingerprint: fingerprint(1), + project_bytes, + module_bytes, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + }); + + let mut wrong_path = request.clone(); + wrong_path.fixture_journeys.path = "source/journeys.yaml".to_owned(); + assert_eq!( + prepare_package(wrong_path).err(), + Some(PackageError::Closure) + ); + let mut missing = request.clone(); + missing.fixture_journeys.bytes.clear(); + assert_eq!(prepare_package(missing).err(), Some(PackageError::Closure)); + let mut oversized = request.clone(); + oversized.fixture_journeys.bytes = + vec![b'x'; usize::try_from(MAX_PACKAGE_SOURCE_FILE_BYTES).unwrap() + 1]; + assert_eq!( + prepare_package(oversized).err(), + Some(PackageError::Closure) + ); + + let first = prepare_package(request.clone()).expect("first journey closure prepares"); + let mut changed = request; + changed.fixture_journeys.bytes.extend_from_slice(b"\n"); + let second = prepare_package(changed).expect("changed journey closure prepares"); + assert_ne!(first.package_revision(), second.package_revision()); + assert_eq!(first.registry(), second.registry()); +} + +#[test] +fn signed_package_refuses_missing_or_rehashed_substituted_fixture_journeys() { + let signing = + generate_private_jwk(GeneratedKeyAlgorithm::Es384).expect("package signing key generates"); + + let missing = PackageFixture::build( + "production", + 1, + None, + fingerprint(1), + PlanChoice::Schema, + Some(&signing), + ); + fs::remove_file(missing.root.path().join("tests/journeys.yaml")) + .expect("fixture journeys remove"); + assert_eq!( + load_error( + missing.root.path(), + &missing.context(PackageIntent::InitialActivation), + ), + PackageError::Read + ); + + let substituted = PackageFixture::build( + "production", + 1, + None, + fingerprint(1), + PlanChoice::Schema, + Some(&signing), + ); + let replacement = [FIXTURE_JOURNEYS, b"\n"].concat(); + fs::write( + substituted.root.path().join("tests/journeys.yaml"), + &replacement, + ) + .expect("fixture journeys substitution writes"); + rewrite_envelope(substituted.root.path(), |envelope| { + let entry = envelope + .signed + .files + .iter_mut() + .find(|entry| entry.role == PackageFileRole::FixtureJourneys) + .expect("fixture journey entry exists"); + entry.size = replacement.len() as u64; + entry.sha256 = format!("sha256:{}", hex(&Sha256::digest(&replacement))); + envelope.signed.package_revision.clear(); + envelope.signed.package_revision = + derive_package_revision(&envelope.signed).expect("substituted revision derives"); + }); + assert_eq!( + load_error( + substituted.root.path(), + &substituted.context(PackageIntent::InitialActivation), + ), + PackageError::Signature + ); +} + +#[test] +fn package_rederivation_refuses_a_rehashed_fixture_journey_role_substitution() { + let fixture = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + rewrite_unsigned(fixture.root.path(), |manifest| { + manifest + .files + .iter_mut() + .find(|entry| entry.path == "tests/journeys.yaml") + .expect("fixture journey entry exists") + .role = PackageFileRole::SourceModule; + }); + + assert_eq!( + load_error( + fixture.root.path(), + &local_context(PackageIntent::InitialActivation), + ), + PackageError::Derivation + ); +} + +#[test] +fn package_rederivation_refuses_rehashed_empty_fixture_journeys() { + let fixture = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + fs::write(fixture.root.path().join("tests/journeys.yaml"), b"") + .expect("empty fixture journeys write"); + rewrite_unsigned(fixture.root.path(), |manifest| { + let entry = manifest + .files + .iter_mut() + .find(|entry| entry.role == PackageFileRole::FixtureJourneys) + .expect("fixture journey entry exists"); + entry.size = 0; + entry.sha256 = format!("sha256:{}", hex(&Sha256::digest(b""))); + }); + + assert_eq!( + load_error( + fixture.root.path(), + &local_context(PackageIntent::InitialActivation), + ), + PackageError::Derivation + ); +} + +#[test] +fn package_rederivation_refuses_rehashed_substituted_caller_safe_metadata() { + let fixture = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + let path = fixture.root.path().join("metadata/registry.json"); + let substituted = br#"{"entities":[],"registryId":"neutral-registry","version":"1"}"#; + fs::write(&path, substituted).expect("caller-safe metadata substitution writes"); + rewrite_unsigned(fixture.root.path(), |manifest| { + let entry = manifest + .files + .iter_mut() + .find(|entry| entry.role == PackageFileRole::CallerSafeMetadata) + .expect("caller-safe metadata entry exists"); + entry.size = substituted.len() as u64; + entry.sha256 = format!("sha256:{}", hex(&Sha256::digest(substituted))); + }); + + assert_eq!( + load_error( + fixture.root.path(), + &local_context(PackageIntent::InitialActivation), + ), + PackageError::Derivation + ); +} + +#[test] +fn package_rederivation_refuses_a_rehashed_substituted_event_inventory() { + let fixture = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + let path = fixture.root.path().join("inventories/events.json"); + let substituted = br#"{"deliveries":[{"canary":"not-compiled"}]}"#; + fs::write(&path, substituted).expect("event inventory substitution writes"); + rewrite_unsigned(fixture.root.path(), |manifest| { + let entry = manifest + .files + .iter_mut() + .find(|entry| entry.role == PackageFileRole::EventInventory) + .expect("event inventory entry exists"); + entry.size = substituted.len() as u64; + entry.sha256 = format!("sha256:{}", hex(&Sha256::digest(substituted))); + }); + + assert_eq!( + load_error( + fixture.root.path(), + &local_context(PackageIntent::InitialActivation), + ), + PackageError::Derivation + ); +} + +#[test] +fn local_unsigned_package_rederives_every_artifact_and_refuses_filesystem_tampering() { + let fixture = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + let context = local_context(PackageIntent::InitialActivation); + let verified = + load_package(fixture.root.path(), &context).expect("local unsigned package verifies"); + assert_eq!(verified.manifest().package_id, "neutral-registry"); + assert_eq!(verified.registry().registry_id(), "neutral-registry"); + + let artifact_path = first_generated_path(fixture.root.path()); + let original = fs::read(&artifact_path).expect("artifact reads"); + fs::write(&artifact_path, b"tampered artifact").expect("artifact tamper writes"); + assert_eq!( + load_error(fixture.root.path(), &context), + PackageError::Integrity + ); + fs::write(&artifact_path, original).expect("artifact restores"); + + let manifest_projection_path = manifest_projection_path(fixture.root.path()); + let original_manifest_projection_bytes = + fs::read(&manifest_projection_path).expect("Manifest projection reads"); + let original_manifest_projection_entry = read_envelope(fixture.root.path()) + .signed + .files + .iter() + .find(|entry| entry.role == PackageFileRole::LossyManifestProjection) + .expect("original Manifest projection entry exists") + .clone(); + let tampered_manifest = br#"{"schema_version":"registry-manifest/v1","catalog":{"id":"neutral-registry","base_url":"https://package.example.test","title":"Tampered","publisher":{"name":"Publisher"}},"datasets":[],"codelists":[]}"#; + fs::write(&manifest_projection_path, tampered_manifest) + .expect("Manifest projection tamper writes"); + rewrite_unsigned(fixture.root.path(), |manifest| { + let entry = manifest + .files + .iter_mut() + .find(|entry| entry.role == PackageFileRole::LossyManifestProjection) + .expect("manifest projection entry exists"); + entry.size = tampered_manifest.len() as u64; + entry.sha256 = format!("sha256:{}", hex(&Sha256::digest(tampered_manifest))); + }); + assert_eq!( + load_error(fixture.root.path(), &context), + PackageError::Derivation + ); + fs::write( + &manifest_projection_path, + original_manifest_projection_bytes, + ) + .expect("Manifest projection restores"); + rewrite_unsigned(fixture.root.path(), |manifest| { + let entry = manifest + .files + .iter_mut() + .find(|entry| entry.role == PackageFileRole::LossyManifestProjection) + .expect("manifest projection entry exists"); + entry.size = original_manifest_projection_entry.size; + entry + .sha256 + .clone_from(&original_manifest_projection_entry.sha256); + }); + + let source = fixture.root.path().join("source/registry.yaml"); + let original = fs::read(&source).expect("source reads"); + fs::write(&source, b"tampered source").expect("source tamper writes"); + assert_eq!( + load_error(fixture.root.path(), &context), + PackageError::Integrity + ); + fs::write(&source, original).expect("source restores"); + + let module = fixture.root.path().join("source/modules/core/module.yaml"); + let original = fs::read(&module).expect("module reads"); + fs::write(&module, b"tampered module").expect("module tamper writes"); + assert_eq!( + load_error(fixture.root.path(), &context), + PackageError::Integrity + ); + fs::write(&module, original).expect("module restores"); + + fs::write(fixture.root.path().join("unlisted"), b"unlisted").expect("unlisted file writes"); + assert_eq!( + load_error(fixture.root.path(), &context), + PackageError::Closure + ); + fs::remove_file(fixture.root.path().join("unlisted")).expect("unlisted file removes"); + + fs::remove_file(&artifact_path).expect("listed artifact removes"); + assert!(matches!( + load_error(fixture.root.path(), &context), + PackageError::Read | PackageError::Closure + )); +} + +#[test] +fn package_manifest_refuses_ddl_checksum_path_and_canonical_json_tampering() { + let context = local_context(PackageIntent::InitialActivation); + + let ddl = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + rewrite_unsigned(ddl.root.path(), |manifest| { + manifest.migration_plan.statements[0] + .sql + .push_str(" SELECT 1"); + }); + assert_eq!( + load_error(ddl.root.path(), &context), + PackageError::MigrationPlan + ); + + let checksum = + PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + rewrite_unsigned(checksum.root.path(), |manifest| { + manifest.files[0].sha256 = fingerprint(9); + }); + assert_eq!( + load_error(checksum.root.path(), &context), + PackageError::Integrity + ); + + let duplicate = + PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + rewrite_unsigned(duplicate.root.path(), |manifest| { + manifest.files.insert(0, manifest.files[0].clone()); + }); + assert_eq!( + load_error(duplicate.root.path(), &context), + PackageError::Closure + ); + + let traversal = + PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + rewrite_unsigned(traversal.root.path(), |manifest| { + manifest.files[0].path = "../outside".to_owned(); + }); + assert_eq!( + load_error(traversal.root.path(), &context), + PackageError::UnsafePath + ); + + let absolute = + PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + rewrite_unsigned(absolute.root.path(), |manifest| { + manifest.files[0].path = "/absolute".to_owned(); + }); + assert_eq!( + load_error(absolute.root.path(), &context), + PackageError::UnsafePath + ); + + let noncanonical_path = + PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + rewrite_unsigned(noncanonical_path.root.path(), |manifest| { + manifest.files[0].path = "source//registry.yaml".to_owned(); + }); + assert_eq!( + load_error(noncanonical_path.root.path(), &context), + PackageError::UnsafePath + ); + + let nonregular = + PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + let path = first_generated_path(nonregular.root.path()); + fs::remove_file(&path).expect("listed file removes"); + fs::create_dir(&path).expect("non-regular replacement creates"); + assert_eq!( + load_error(nonregular.root.path(), &context), + PackageError::Closure + ); + + let noncanonical = + PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + let path = noncanonical.root.path().join("package.json"); + let mut bytes = fs::read(&path).expect("manifest reads"); + bytes.push(b'\n'); + fs::write(&path, bytes).expect("noncanonical manifest writes"); + assert_eq!( + load_error(noncanonical.root.path(), &context), + PackageError::CanonicalJson + ); +} + +#[test] +fn successor_migration_plan_rederivation_rejects_tampered_closure() { + let first = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + let prior_revision = read_envelope(first.root.path()).signed.package_revision; + let context = local_context(PackageIntent::Activation { + active_revision: &prior_revision, + active_sequence: 1, + }); + let build_successor = || { + PackageFixture::build( + "local", + 2, + Some(&prior_revision), + fingerprint(1), + PlanChoice::SecondTable, + None, + ) + }; + + let valid = build_successor(); + let valid_package = + load_package(valid.root.path(), &context).expect("untampered successor package loads"); + assert!(valid_package.manifest().migration_plan.statements.len() > 1); + assert_eq!( + valid_package + .manifest() + .migration_plan + .prior_baseline + .as_ref() + .map(|baseline| baseline.package_revision.as_str()), + Some(prior_revision.as_str()) + ); + + let omitted = build_successor(); + rewrite_unsigned(omitted.root.path(), |manifest| { + manifest.migration_plan.statements.pop(); + }); + assert_eq!( + load_error(omitted.root.path(), &context), + PackageError::MigrationPlan + ); + + let forged_baseline = build_successor(); + rewrite_unsigned(forged_baseline.root.path(), |manifest| { + manifest + .migration_plan + .prior_baseline + .as_mut() + .expect("successor carries prior baseline") + .package_revision = fingerprint(9); + }); + assert_eq!( + load_error(forged_baseline.root.path(), &context), + PackageError::MigrationPlan + ); + + let forged_changes = build_successor(); + rewrite_unsigned(forged_changes.root.path(), |manifest| { + manifest.migration_plan.changes.clear(); + }); + assert_eq!( + load_error(forged_changes.root.path(), &context), + PackageError::MigrationPlan + ); + + let reordered = build_successor(); + rewrite_unsigned(reordered.root.path(), |manifest| { + manifest.migration_plan.statements.swap(0, 1); + }); + assert_eq!( + load_error(reordered.root.path(), &context), + PackageError::MigrationPlan + ); + + let extra = build_successor(); + rewrite_unsigned(extra.root.path(), |manifest| { + let extra = manifest.migration_plan.statements[0].clone(); + manifest.migration_plan.statements.push(extra); + }); + assert_eq!( + load_error(extra.root.path(), &context), + PackageError::MigrationPlan + ); +} + +#[cfg(unix)] +#[test] +fn package_refuses_symlinks_and_production_writable_permissions() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let local = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + let artifact = first_generated_path(local.root.path()); + let target = local.root.path().join("ordinary-target"); + fs::write(&target, fs::read(&artifact).expect("artifact reads")).expect("target writes"); + fs::remove_file(&artifact).expect("artifact removes"); + symlink(&target, &artifact).expect("test symlink creates"); + let context = local_context(PackageIntent::InitialActivation); + assert_eq!( + load_error(local.root.path(), &context), + PackageError::UnsafePath + ); + + let signing = generate_private_jwk(GeneratedKeyAlgorithm::Es384).expect("test key generates"); + let production = PackageFixture::build( + "production", + 1, + None, + fingerprint(1), + PlanChoice::Schema, + Some(&signing), + ); + fs::set_permissions(production.root.path(), fs::Permissions::from_mode(0o777)) + .expect("test permissions change"); + let context = production.context(PackageIntent::InitialActivation); + assert_eq!( + load_error(production.root.path(), &context), + PackageError::Permissions + ); + + let anchor_permissions = PackageFixture::build( + "production", + 1, + None, + fingerprint(1), + PlanChoice::Schema, + Some(&signing), + ); + fs::set_permissions( + anchor_permissions + .anchor + .as_deref() + .expect("production fixture has anchor"), + fs::Permissions::from_mode(0o666), + ) + .expect("test anchor permissions change"); + assert_eq!( + load_error( + anchor_permissions.root.path(), + &anchor_permissions.context(PackageIntent::InitialActivation) + ), + PackageError::Permissions + ); +} + +#[test] +fn package_binding_refuses_wrong_environment_instance_database_sequence_and_prior() { + let fixture = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + let wrong_environment = PackageLoadContext { + environment: "production", + ..local_context(PackageIntent::InitialActivation) + }; + assert_eq!( + load_error(fixture.root.path(), &wrong_environment), + PackageError::Binding + ); + let wrong_instance = PackageLoadContext { + instance_id: "another-instance", + ..local_context(PackageIntent::InitialActivation) + }; + assert_eq!( + load_error(fixture.root.path(), &wrong_instance), + PackageError::Binding + ); + let wrong_database = PackageLoadContext { + database_id: "another-database", + ..local_context(PackageIntent::InitialActivation) + }; + assert_eq!( + load_error(fixture.root.path(), &wrong_database), + PackageError::Binding + ); + + let sequence = + PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + rewrite_unsigned(sequence.root.path(), |manifest| { + manifest.sequence = 2; + }); + assert_eq!( + load_error( + sequence.root.path(), + &local_context(PackageIntent::InitialActivation) + ), + PackageError::Binding + ); + + let successor = PackageFixture::build( + "local", + 2, + Some("expected-prior"), + fingerprint(1), + PlanChoice::Schema, + None, + ); + let wrong_prior = local_context(PackageIntent::Activation { + active_revision: "other-prior", + active_sequence: 1, + }); + assert_eq!( + load_error(successor.root.path(), &wrong_prior), + PackageError::Binding + ); + let stale = local_context(PackageIntent::Activation { + active_revision: "expected-prior", + active_sequence: 2, + }); + assert_eq!( + load_error(successor.root.path(), &stale), + PackageError::Binding + ); +} + +#[test] +fn production_package_requires_exact_trust_anchor_threshold_and_signature() { + let signing = generate_private_jwk(GeneratedKeyAlgorithm::Es384).expect("test key generates"); + let fixture = PackageFixture::build( + "production", + 1, + None, + fingerprint(1), + PlanChoice::Schema, + Some(&signing), + ); + let context = fixture.context(PackageIntent::InitialActivation); + load_package(fixture.root.path(), &context).expect("production-shaped signed package verifies"); + + let missing_anchor = PackageLoadContext { + trust_anchor: None, + ..fixture.context(PackageIntent::InitialActivation) + }; + assert_eq!( + load_error(fixture.root.path(), &missing_anchor), + PackageError::Signature + ); + + rewrite_envelope(fixture.root.path(), |envelope| { + let byte_length = envelope.signatures[0].signature_hex.len() / 2; + envelope.signatures[0].signature_hex = "00".repeat(byte_length); + }); + assert_eq!( + load_error(fixture.root.path(), &context), + PackageError::Signature + ); + + let insufficient = PackageFixture::build( + "production", + 1, + None, + fingerprint(1), + PlanChoice::Schema, + Some(&signing), + ); + rewrite_envelope(insufficient.root.path(), |envelope| { + envelope.signatures.clear(); + }); + assert_eq!( + load_error( + insufficient.root.path(), + &insufficient.context(PackageIntent::InitialActivation) + ), + PackageError::Signature + ); + + let duplicate = PackageFixture::build( + "production", + 1, + None, + fingerprint(1), + PlanChoice::Schema, + Some(&signing), + ); + rewrite_envelope(duplicate.root.path(), |envelope| { + envelope.signatures.push(envelope.signatures[0].clone()); + }); + assert_eq!( + load_error( + duplicate.root.path(), + &duplicate.context(PackageIntent::InitialActivation) + ), + PackageError::Signature + ); + + let untrusted = PackageFixture::build( + "production", + 1, + None, + fingerprint(1), + PlanChoice::Schema, + Some(&signing), + ); + rewrite_envelope(untrusted.root.path(), |envelope| { + envelope.signatures[0].key_id = "untrusted-key".to_owned(); + }); + assert_eq!( + load_error( + untrusted.root.path(), + &untrusted.context(PackageIntent::InitialActivation) + ), + PackageError::Signature + ); + + let local_runtime = PackageLoadContext { + environment: "local", + ..untrusted.context(PackageIntent::InitialActivation) + }; + assert_eq!( + load_error(untrusted.root.path(), &local_runtime), + PackageError::Binding + ); +} + +#[test] +fn package_file_count_and_size_are_bounded_before_payload_reads() { + let oversized = + PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + rewrite_unsigned(oversized.root.path(), |manifest| { + manifest.files[0].size = 16 * 1024 * 1024 + 1; + }); + assert_eq!( + load_error( + oversized.root.path(), + &local_context(PackageIntent::InitialActivation) + ), + PackageError::Closure + ); + + let excessive_count = + PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + rewrite_unsigned(excessive_count.root.path(), |manifest| { + let template = manifest.files[0].clone(); + while manifest.files.len() <= 1_024 { + let mut entry = template.clone(); + entry.path = format!("source/padding/{:04}", manifest.files.len()); + manifest.files.push(entry); + } + manifest + .files + .sort_by(|left, right| left.path.cmp(&right.path)); + }); + assert_eq!( + load_error( + excessive_count.root.path(), + &local_context(PackageIntent::InitialActivation) + ), + PackageError::Integrity + ); +} + +#[test] +fn package_apply_and_startup_errors_are_closed_and_value_free() { + let rendered = format!( + "{:?} {:?} {:?}", + PackageError::Signature, + MigrationError::ApplyFailed, + StartupError::DatabaseUnready + ); + for forbidden in [ + "source/modules/core", + "CREATE TABLE", + "signatureHex", + "public-key-canary", + "postgresql://", + "registry_data", + ] { + assert!(!rendered.contains(forbidden)); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn wrong_migration_role_is_refused_before_initial_control_plane_or_ddl() { + let database = TestDatabase::create(1).await; + let fixture = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + let package = load_package( + fixture.root.path(), + &local_context(PackageIntent::InitialActivation), + ) + .expect("initial package verifies before role enforcement"); + let refused = apply_verified_package(ApplyVerifiedPackageRequest::new( + &database.runtime_config, + &package, + ApplyPrecondition::InitialActivation, + ApplyRoles::new(&database.migration_role, &database.runtime_role), + ApplyTimeouts::new(Duration::from_secs(1), Duration::from_secs(1)) + .expect("test apply timeouts are bounded"), + )) + .await; + assert_eq!(refused.err(), Some(MigrationError::ApplyFailed)); + let state_table: Option = database + .admin + .query_one( + "SELECT to_regclass('registry_internal.registry_state')::text", + &[], + ) + .await + .expect("the administrator can inspect control-plane absence") + .get(0); + assert_eq!(state_table, None); + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn signed_schema_fingerprint_mismatch_is_durably_failed_and_never_ready() { + let database = TestDatabase::create(1).await; + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("administrator installs prerequisite"); + let signing = generate_private_jwk(GeneratedKeyAlgorithm::Es384) + .expect("production-shaped signing key generates"); + let fixture = PackageFixture::build( + "production", + 1, + None, + fingerprint(7), + PlanChoice::Schema, + Some(&signing), + ); + assert_eq!(read_envelope(fixture.root.path()).signatures.len(), 1); + let initial_context = fixture.context(PackageIntent::InitialActivation); + let package = load_package(fixture.root.path(), &initial_context) + .expect("signed production-shaped package verifies before catalog apply"); + let refused = apply_package( + &database, + &package, + ApplyPrecondition::InitialActivation, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await; + assert_eq!(refused.err(), Some(MigrationError::ApplyFailed)); + let state = registry_state_snapshot(&database.admin).await; + assert_eq!(state.7, "failed"); + assert_eq!( + state.8.as_deref(), + Some(package.manifest().package_revision.as_str()) + ); + let ledger = migration_ledger_snapshot(&database.admin).await; + assert_eq!(ledger.len(), 1); + assert_eq!(ledger[0].0, None); + assert_eq!(ledger[0].1, package.manifest().package_revision); + assert_eq!(ledger[0].4, "failed"); + + let pool = database + .runtime_config + .build_pool() + .expect("runtime pool builds"); + let mut runtime = pool.get_for_test().await.expect("runtime connects"); + let startup_context = fixture.context(PackageIntent::Startup { + active_revision: &package.manifest().package_revision, + active_sequence: 1, + }); + let startup = prepare_startup( + fixture.root.path(), + &startup_context, + &mut runtime, + &database.migration_role, + &database.runtime_role, + ) + .await; + assert_eq!(startup.err(), Some(StartupError::DatabaseUnready)); + drop(runtime); + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_postgres_package_startup_apply_failure_and_old_process_are_closed() { + let database = TestDatabase::create(1).await; + let _unused_by_this_slice = &database.tls_runtime_config; + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("administrator installs prerequisite"); + let (mut migration, migration_task) = database.connect_migration().await; + let first = PackageFixture::build("local", 1, None, fingerprint(1), PlanChoice::Schema, None); + let first_for_install = load_package( + first.root.path(), + &local_context(PackageIntent::InitialActivation), + ) + .expect("initial package verifies before database installation"); + let initial_fingerprint_transaction = migration + .transaction() + .await + .expect("initial fingerprint rehearsal transaction starts"); + install_compiled_schema( + &initial_fingerprint_transaction, + first_for_install.registry(), + &database.runtime_role, + ) + .await + .expect("initial fingerprint rehearsal installs the exact compiled Registry schema"); + let first_catalog = ExpectedManagedCatalog::compiled(first_for_install.registry()); + let schema = managed_schema_fingerprint( + &initial_fingerprint_transaction, + &database.runtime_role, + &first_catalog, + ) + .await + .expect("initial compiled fingerprint computes"); + initial_fingerprint_transaction + .rollback() + .await + .expect("initial fingerprint rehearsal leaves no target objects"); + rewrite_unsigned(first.root.path(), |manifest| { + manifest.schema_fingerprint.clone_from(&schema); + }); + let first_for_state = load_package( + first.root.path(), + &local_context(PackageIntent::InitialActivation), + ) + .expect("initial package reloads after finalized schema fingerprint"); + let first_manifest = first_for_state.manifest().clone(); + let successor_before_initialization = PackageFixture::build( + "local", + 2, + Some(&first_manifest.package_revision), + schema.clone(), + PlanChoice::Schema, + None, + ); + let successor_before_initialization_context = local_context(PackageIntent::Activation { + active_revision: &first_manifest.package_revision, + active_sequence: 1, + }); + let verified_successor_before_initialization = load_package( + successor_before_initialization.root.path(), + &successor_before_initialization_context, + ) + .expect("successor package verifies for activation before database initialization"); + assert_eq!( + verified_successor_before_initialization + .manifest() + .schema_fingerprint, + schema + ); + let refused_successor_initialization = apply_package( + &database, + &verified_successor_before_initialization, + ApplyPrecondition::InitialActivation, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await; + assert_eq!( + refused_successor_initialization.err(), + Some(MigrationError::PackageBinding) + ); + let wrong_migration_role = apply_verified_package(ApplyVerifiedPackageRequest::new( + &database.runtime_config, + &first_for_state, + ApplyPrecondition::InitialActivation, + ApplyRoles::new(&database.migration_role, &database.runtime_role), + ApplyTimeouts::new(Duration::from_secs(1), Duration::from_secs(1)) + .expect("test apply timeouts are bounded"), + )) + .await; + assert_eq!( + wrong_migration_role.err(), + Some(MigrationError::ApplyFailed), + "the exact configured migration role is verified before control-plane DDL" + ); + let state_table: Option = database + .admin + .query_one( + "SELECT to_regclass('registry_internal.registry_state')::text", + &[], + ) + .await + .expect("control-plane absence can be inspected after refused intent") + .get(0); + assert_eq!(state_table, None); + let initial = apply_package( + &database, + &first_for_state, + ApplyPrecondition::InitialActivation, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await + .expect("one coordinator durably applies and activates the initial verified package"); + let pool = database + .runtime_config + .build_pool() + .expect("runtime pool builds"); + let mut runtime = pool.get_for_test().await.expect("runtime connects"); + let first_startup = local_context(PackageIntent::Startup { + active_revision: &first_manifest.package_revision, + active_sequence: 1, + }); + prepare_startup( + first.root.path(), + &first_startup, + &mut runtime, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("matching package produces the listener gate"); + drop(runtime); + for (column, original) in [ + ("package_id", initial.package_id.as_str()), + ("instance_id", initial.instance_id.as_str()), + ("database_id", initial.database_id.as_str()), + ] { + database + .admin + .execute( + &format!( + "UPDATE registry_internal.registry_state SET {column} = $1 WHERE singleton" + ), + &[&format!("wrong-{column}")], + ) + .await + .expect("test can seed durable identity drift"); + let mut runtime = pool + .get_for_test() + .await + .expect("runtime reconnects for durable identity drift proof"); + let refused = prepare_startup( + first.root.path(), + &first_startup, + &mut runtime, + &database.migration_role, + &database.runtime_role, + ) + .await; + assert_eq!( + refused.err(), + Some(StartupError::DatabaseUnready), + "startup refuses durable {column} drift" + ); + drop(runtime); + database + .admin + .execute( + &format!( + "UPDATE registry_internal.registry_state SET {column} = $1 WHERE singleton" + ), + &[&original], + ) + .await + .expect("test restores durable identity"); + } + + let tampered_startup = + PackageFixture::build("local", 1, None, schema.clone(), PlanChoice::Schema, None); + let tampered_artifact = first_generated_path(tampered_startup.root.path()); + fs::write(tampered_artifact, b"pre-listener tamper").expect("startup artifact tamper writes"); + let mut runtime = pool.get_for_test().await.expect("runtime reconnects"); + let no_listener_gate = prepare_startup( + tampered_startup.root.path(), + &first_startup, + &mut runtime, + &database.migration_role, + &database.runtime_role, + ) + .await; + assert_eq!(no_listener_gate.err(), Some(StartupError::PackageRefused)); + drop(runtime); + + let second = PackageFixture::build( + "local", + 2, + Some(&first_manifest.package_revision), + schema.clone(), + PlanChoice::SecondTable, + None, + ); + let activation_context = local_context(PackageIntent::Activation { + active_revision: &first_manifest.package_revision, + active_sequence: 1, + }); + let provisional_second = load_package(second.root.path(), &activation_context) + .expect("successor package verifies before apply"); + let transaction = migration + .transaction() + .await + .expect("target fingerprint transaction starts"); + for statement in &provisional_second.manifest().migration_plan.statements { + transaction + .batch_execute(&statement.sql) + .await + .expect("exact additive target plan applies in fingerprint transaction"); + } + let provisional_second_table = + &provisional_second.registry().entities()["second-record"].physical_table; + transaction + .batch_execute(&format!( + "REVOKE ALL ON TABLE registry_data.{} FROM PUBLIC, \"{}\"; + GRANT SELECT, INSERT ON TABLE registry_data.{} TO \"{}\";", + quote_identifier(provisional_second_table), + database.runtime_role.as_str(), + quote_identifier(provisional_second_table), + database.runtime_role.as_str(), + )) + .await + .expect("target fingerprint transaction installs the exact compiled runtime ACL"); + let second_catalog = ExpectedManagedCatalog::compiled(provisional_second.registry()); + let target_schema = + managed_schema_fingerprint(&transaction, &database.runtime_role, &second_catalog) + .await + .expect("target compiled fingerprint computes from the exact plan"); + transaction + .rollback() + .await + .expect("fingerprint transaction rolls back"); + rewrite_unsigned(second.root.path(), |manifest| { + manifest.schema_fingerprint.clone_from(&target_schema); + }); + let verified_second = load_package(second.root.path(), &activation_context) + .expect("successor package verifies with its target fingerprint"); + migration_task.abort(); + + let claims = ClaimContext::for_compiled( + first_for_install.registry(), + "neutral-record", + Some("record-reader".to_owned()), + "reader", + None, + Vec::new(), + ) + .expect("compiled claims are accepted"); + let package_lock = RegistryLockKey::derive(&verified_second.manifest().package_id) + .expect("verified package lock key derives"); + let mut wrong_record_client = pool + .get_for_test() + .await + .expect("record client connects for durable identity refusal"); + let mut wrong_expected = initial.clone(); + wrong_expected.database_id = "wrong-database".to_owned(); + match begin_record_transaction( + &mut wrong_record_client, + package_lock, + Duration::from_secs(1), + &wrong_expected, + &claims, + ) + .await + { + Err(registry_server::postgres::PostgresKernelError::RegistryUnavailable) => {} + Err(_) => panic!("record transaction returned the wrong value-free error"), + Ok(transaction) => { + transaction + .rollback() + .await + .expect("unexpected transaction rolls back"); + panic!("record transaction accepted a wrong durable binding"); + } + } + drop(wrong_record_client); + let drift_before_apply = registry_state_snapshot(&database.admin).await; + database + .admin + .execute( + "UPDATE registry_internal.registry_state SET package_id = $1 WHERE singleton", + &[&"wrong-package"], + ) + .await + .expect("test can seed durable package_id drift"); + let drifted_state = registry_state_snapshot(&database.admin).await; + let refused_apply = apply_package( + &database, + &verified_second, + ApplyPrecondition::Successor { current: &initial }, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await; + assert_eq!(refused_apply.err(), Some(MigrationError::ApplyFailed)); + assert_eq!( + registry_state_snapshot(&database.admin).await, + drifted_state, + "apply refusal must not mutate a wrong durable binding" + ); + database + .admin + .execute( + "UPDATE registry_internal.registry_state SET package_id = $1 WHERE singleton", + &[&initial.package_id], + ) + .await + .expect("test restores durable package_id"); + assert_eq!( + registry_state_snapshot(&database.admin).await, + drift_before_apply + ); + let mut record_client = pool.get_for_test().await.expect("record client connects"); + let held_record = begin_record_transaction( + &mut record_client, + package_lock, + Duration::from_secs(1), + &initial, + &claims, + ) + .await + .expect("record transaction holds the package shared lock"); + let blocked = apply_package( + &database, + &verified_second, + ApplyPrecondition::Successor { current: &initial }, + Duration::from_millis(50), + Duration::from_secs(1), + ) + .await; + assert!( + blocked.is_err(), + "package apply cannot pass a concurrent record transaction" + ); + let blocked_state = database + .admin + .query_one( + "SELECT maintenance_status, active_package_revision + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .expect("blocked apply leaves state readable"); + assert_eq!(blocked_state.get::<_, String>(0), "ready"); + assert_eq!(blocked_state.get::<_, String>(1), initial.package_revision); + held_record + .rollback() + .await + .expect("record transaction releases the shared lock"); + drop(record_client); + + let (mut ddl_blocker, ddl_blocker_task) = database.connect_migration().await; + let ddl_blocker_pid: i32 = ddl_blocker + .query_one("SELECT pg_backend_pid()", &[]) + .await + .expect("DDL blocker backend identity reads") + .get(0); + let blocked_ddl = ddl_blocker + .transaction() + .await + .expect("DDL cancellation blocker transaction starts"); + blocked_ddl + .batch_execute(&verified_second.manifest().migration_plan.statements[0].sql) + .await + .expect("uncommitted target object deterministically blocks package DDL"); + let interrupted = { + let interrupted_apply = apply_package( + &database, + &verified_second, + ApplyPrecondition::Successor { current: &initial }, + Duration::from_secs(5), + Duration::from_secs(5), + ); + tokio::pin!(interrupted_apply); + tokio::select! { + result = &mut interrupted_apply => { + panic!("blocked apply completed before deterministic connection interruption: {result:?}") + } + () = wait_for_maintenance_status(&database.admin, "applying") => {} + } + let apply_pid = tokio::select! { + result = &mut interrupted_apply => { + panic!("apply completed before its dedicated connection could be interrupted: {result:?}") + } + pid = wait_for_blocked_apply_backend( + &database.admin, + database.migration_role.as_str(), + ddl_blocker_pid, + ) => pid, + }; + let terminated: bool = database + .admin + .query_one("SELECT pg_terminate_backend($1)", &[&apply_pid]) + .await + .expect("administrator interrupts the exact dedicated apply connection") + .get(0); + assert!(terminated); + interrupted_apply.await + }; + let interrupted_error = interrupted.expect_err("terminated apply connection fails closed"); + assert_eq!(interrupted_error, MigrationError::ApplyFailed); + let diagnostic = format!("{interrupted_error:?} {interrupted_error}"); + for forbidden in [ + verified_second.manifest().package_revision.as_str(), + provisional_second_table.as_str(), + "registry_data", + "pg_terminate_backend", + ] { + assert!(!diagnostic.contains(forbidden)); + } + blocked_ddl + .rollback() + .await + .expect("DDL blocker rolls back after apply connection interruption"); + ddl_blocker_task.abort(); + let interrupted_state = registry_state_snapshot(&database.admin).await; + assert_eq!(interrupted_state.4, initial.package_revision); + assert_eq!(interrupted_state.7, "applying"); + assert_eq!( + interrupted_state.8.as_deref(), + Some(verified_second.manifest().package_revision.as_str()) + ); + assert_eq!( + migration_ledger_snapshot(&database.admin) + .await + .last() + .map(|entry| entry.4.as_str()), + Some("applying"), + "connection loss leaves a durable non-ready target without unsafe activation" + ); + { + let mut interrupted_record_client = pool + .get_for_test() + .await + .expect("runtime reconnects after apply connection loss"); + match begin_record_transaction( + &mut interrupted_record_client, + package_lock, + Duration::from_secs(1), + &initial, + &claims, + ) + .await + { + Err(registry_server::postgres::PostgresKernelError::RegistryUnavailable) => {} + Err(_) => panic!("interrupted maintenance returned the wrong value-free refusal"), + Ok(transaction) => { + transaction + .rollback() + .await + .expect("unexpected interrupted record transaction rolls back"); + panic!("record work entered while interrupted maintenance was unavailable"); + } + }; + } + + let active = apply_package( + &database, + &verified_second, + ApplyPrecondition::Successor { current: &initial }, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await + .expect("exact rederived schema statement and activation succeed"); + assert_eq!( + active.package_revision, + verified_second.manifest().package_revision + ); + let activated_state = registry_state_snapshot(&database.admin).await; + assert_eq!(activated_state.0, active.package_id); + assert_eq!(activated_state.2, active.instance_id); + assert_eq!(activated_state.3, active.database_id); + let applied_ledger = migration_ledger_snapshot(&database.admin).await; + assert_eq!(applied_ledger.len(), 2); + assert_eq!(applied_ledger[0].0, None); + assert_eq!(applied_ledger[0].1, first_manifest.package_revision); + assert_eq!(applied_ledger[0].2, 1); + assert_eq!(applied_ledger[0].4, "applied"); + assert_eq!( + applied_ledger[1].0.as_deref(), + Some(initial.package_revision.as_str()) + ); + assert_eq!(applied_ledger[1].1, active.package_revision); + assert_eq!(applied_ledger[1].2, 2); + assert_eq!(applied_ledger[1].4, "applied"); + assert_eq!( + applied_ledger[1].3, + verified_second + .manifest() + .migration_plan + .statements + .iter() + .map(|statement| format!("sha256:{}", hex(&Sha256::digest(statement.sql.as_bytes())))) + .collect::>() + ); + let immutable_replay = apply_package( + &database, + &verified_second, + ApplyPrecondition::Successor { current: &initial }, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await; + assert_eq!(immutable_replay.err(), Some(MigrationError::ApplyFailed)); + assert_eq!( + migration_ledger_snapshot(&database.admin).await, + applied_ledger, + "an applied migration row is never rewritten" + ); + + let mut runtime = pool.get_for_test().await.expect("runtime reconnects"); + let second_startup = local_context(PackageIntent::Startup { + active_revision: &active.package_revision, + active_sequence: 2, + }); + prepare_startup( + second.root.path(), + &second_startup, + &mut runtime, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("activated package is listener-ready"); + let second_table = format!( + "registry_data.{}", + verified_second.registry().entities()["second-record"].physical_table + ); + let runtime_privileges = runtime + .query_one( + "SELECT has_table_privilege(current_user, $1, 'SELECT'), + has_table_privilege(current_user, $1, 'INSERT'), + has_table_privilege(current_user, $1, 'UPDATE')", + &[&second_table], + ) + .await + .expect("successor runtime privilege probe succeeds"); + assert!(runtime_privileges.get::<_, bool>(0)); + assert!(runtime_privileges.get::<_, bool>(1)); + assert!(!runtime_privileges.get::<_, bool>(2)); + let old_process = prepare_startup( + first.root.path(), + &first_startup, + &mut runtime, + &database.migration_role, + &database.runtime_role, + ) + .await; + assert!(matches!( + old_process, + Err(StartupError::DatabaseUnready) | Err(StartupError::PackageRefused) + )); + drop(runtime); + + let wrong_schema = PackageFixture::build( + "local", + 2, + Some(&first_manifest.package_revision), + fingerprint(7), + PlanChoice::SecondTable, + None, + ); + let wrong_manifest = read_envelope(wrong_schema.root.path()).signed; + let wrong_startup = local_context(PackageIntent::Startup { + active_revision: &wrong_manifest.package_revision, + active_sequence: 2, + }); + database + .admin + .execute( + "UPDATE registry_internal.registry_state + SET active_package_revision = $1 + WHERE singleton", + &[&wrong_manifest.package_revision], + ) + .await + .expect("test binds the active revision while leaving the real schema fingerprint intact"); + let mut runtime = pool.get_for_test().await.expect("runtime reconnects"); + let refused = prepare_startup( + wrong_schema.root.path(), + &wrong_startup, + &mut runtime, + &database.migration_role, + &database.runtime_role, + ) + .await; + assert_eq!(refused.err(), Some(StartupError::DatabaseUnready)); + drop(runtime); + database + .admin + .execute( + "UPDATE registry_internal.registry_state + SET active_package_revision = $1 + WHERE singleton", + &[&active.package_revision], + ) + .await + .expect("test restores the active revision after schema mismatch proof"); + + let third = PackageFixture::build( + "local", + 3, + Some(&active.package_revision), + target_schema.clone(), + PlanChoice::ThirdTable, + None, + ); + let third_context = local_context(PackageIntent::Activation { + active_revision: &active.package_revision, + active_sequence: 2, + }); + let provisional_third = + load_package(third.root.path(), &third_context).expect("third package verifies"); + let (mut fingerprint_connection, fingerprint_task) = database.connect_migration().await; + let transaction = fingerprint_connection + .transaction() + .await + .map_err(|_| ()) + .expect("third target fingerprint transaction starts"); + for statement in &provisional_third.manifest().migration_plan.statements { + transaction + .batch_execute(&statement.sql) + .await + .map_err(|_| ()) + .expect("third exact additive plan applies in fingerprint transaction"); + } + let third_catalog = ExpectedManagedCatalog::compiled(provisional_third.registry()); + let third_schema = + managed_schema_fingerprint(&transaction, &database.runtime_role, &third_catalog) + .await + .map_err(|_| ()) + .expect("third target fingerprint computes"); + transaction + .rollback() + .await + .map_err(|_| ()) + .expect("third target fingerprint transaction rolls back"); + fingerprint_task.abort(); + rewrite_unsigned(third.root.path(), |manifest| { + manifest.schema_fingerprint.clone_from(&third_schema); + }); + let verified_third = load_package(third.root.path(), &third_context) + .expect("third package verifies with its target fingerprint"); + let table_sql = &verified_third.manifest().migration_plan.statements[0].sql; + database + .admin + .batch_execute(table_sql) + .await + .map_err(|_| ()) + .expect("administrator injects an existing target table"); + let failed = apply_package( + &database, + &verified_third, + ApplyPrecondition::Successor { current: &active }, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await; + assert!( + failed.is_err(), + "injected exact DDL failure refuses activation" + ); + let status: String = database + .admin + .query_one( + "SELECT maintenance_status FROM registry_internal.registry_state WHERE singleton", + &[], + ) + .await + .expect("maintenance state reads") + .get(0); + assert_eq!(status, "failed"); + let failed_ledger = migration_ledger_snapshot(&database.admin).await; + let active_startup_package = load_package(second.root.path(), &second_startup) + .expect("the active package still verifies only for startup intent"); + let refused_noop_clear = apply_package( + &database, + &active_startup_package, + ApplyPrecondition::Successor { current: &active }, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await; + assert_eq!( + refused_noop_clear.err(), + Some(MigrationError::PackageBinding), + "a no-op startup package cannot clear failed maintenance" + ); + assert_eq!( + migration_ledger_snapshot(&database.admin).await, + failed_ledger + ); + + let wrong_recovery = PackageFixture::build( + "local", + 4, + Some(&active.package_revision), + third_schema.clone(), + PlanChoice::ThirdTable, + None, + ); + let verified_wrong_recovery = load_package(wrong_recovery.root.path(), &third_context) + .expect("different recovery target verifies as a package"); + let refused_recovery = apply_package( + &database, + &verified_wrong_recovery, + ApplyPrecondition::Successor { current: &active }, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await; + assert!( + refused_recovery.is_err(), + "failed maintenance refuses a different package target" + ); + let failed_target = database + .admin + .query_one( + "SELECT maintenance_status, maintenance_target_revision + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .expect("failed target remains readable"); + assert_eq!(failed_target.get::<_, String>(0), "failed"); + assert_eq!( + failed_target.get::<_, String>(1), + verified_third.manifest().package_revision + ); + + let third_table = &verified_third.registry().entities()["third-record"].physical_table; + database + .admin + .batch_execute(&format!( + "DROP TABLE registry_data.{}", + quote_identifier(third_table) + )) + .await + .map_err(|_| ()) + .expect("operator removes the conflicting object"); + let recovered = apply_package( + &database, + &verified_third, + ApplyPrecondition::Successor { current: &active }, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await + .expect("the exact failed package resumes after operator repair"); + assert_eq!( + recovered.package_revision, + verified_third.manifest().package_revision + ); + let recovered_status: String = database + .admin + .query_one( + "SELECT maintenance_status FROM registry_internal.registry_state WHERE singleton", + &[], + ) + .await + .expect("recovered maintenance state reads") + .get(0); + assert_eq!(recovered_status, "ready"); + + database.cleanup().await; +} + +#[derive(Clone, Copy)] +enum PlanChoice { + Schema, + SecondTable, + ThirdTable, +} + +fn predecessor_plan_choice(plan: PlanChoice) -> PlanChoice { + match plan { + PlanChoice::Schema | PlanChoice::SecondTable => PlanChoice::Schema, + PlanChoice::ThirdTable => PlanChoice::SecondTable, + } +} + +fn canonical_sequence_for_plan(plan: PlanChoice) -> u64 { + match plan { + PlanChoice::Schema => 1, + PlanChoice::SecondTable => 2, + PlanChoice::ThirdTable => 3, + } +} + +fn compile_fixture_registry( + environment: &str, + sequence: u64, + plan: PlanChoice, +) -> registry_server::CompiledRegistry { + let module_bytes = module_bytes(plan); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); + let project_bytes = project_bytes(environment, sequence, &module_digest(&module)); + let project = parse_project_yaml(&project_bytes).expect("fixture project parses"); + compile_project(&project, &[module], CompileProfile::Production) + .expect("fixture project compiles in production") +} + +struct PackageFixture { + root: TempRoot, + anchor: Option, +} + +impl PackageFixture { + fn build( + environment: &str, + sequence: u64, + prior_revision: Option<&str>, + schema_fingerprint: String, + plan: PlanChoice, + signing: Option<&PrivateJwk>, + ) -> Self { + let root = TempRoot::create(); + let module_bytes = module_bytes(plan); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); + let project_bytes = project_bytes(environment, sequence, &module_digest(&module)); + let (signature_policy, anchor) = if let Some(signing) = signing { + let key_id = signing.public().kid.expect("generated key has kid"); + ( + SignaturePolicy { + threshold: 1, + key_ids: vec![key_id.clone()], + }, + Some((key_id, signing.public())), + ) + } else { + ( + SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + None, + ) + }; + + let migration_plan = if prior_revision.is_none() { + PackageMigrationPlanInput::InitialCompiledDdl + } else { + let predecessor = predecessor_plan_choice(plan); + let prior_registry = compile_fixture_registry( + environment, + canonical_sequence_for_plan(predecessor), + predecessor, + ); + PackageMigrationPlanInput::Successor { + prior_registry: Box::new(prior_registry), + } + }; + let prepared = prepare_package(build_request(BuildRequestParts { + environment, + sequence, + prior_revision, + schema_fingerprint, + project_bytes, + module_bytes, + migration_plan, + signature_policy, + })) + .expect("fixture package prepares"); + let signatures = signing + .map(|key| { + let signature = + sign(prepared.canonical_signed_bytes(), key).expect("test package signs"); + vec![PackageSignature { + key_id: key.public().kid.expect("generated key has kid"), + signature_hex: hex(&signature), + }] + }) + .unwrap_or_default(); + prepared + .publish_to_directory(root.path(), signatures) + .expect("fixture package publishes"); + + let anchor_path = anchor.map(|(key_id, public)| { + let path = root.path().with_extension("trust.json"); + write_json( + &path, + &PackageTrustAnchor { + api_version: TRUST_ANCHOR_API_VERSION.to_owned(), + environment: environment.to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + threshold: 1, + keys: vec![TrustAnchorKey { + key_id, + jwk: serde_json::to_value(public).expect("public JWK serializes"), + }], + }, + ); + path + }); + Self { + root, + anchor: anchor_path, + } + } + + fn context<'a>(&'a self, intent: PackageIntent<'a>) -> PackageLoadContext<'a> { + PackageLoadContext { + environment: "production", + instance_id: INSTANCE, + database_id: DATABASE, + database_initialization_environment: "production", + compiler_source_revision: SOURCE_REVISION, + trust_anchor: self.anchor.as_deref(), + intent, + } + } +} + +impl Drop for PackageFixture { + fn drop(&mut self) { + if let Some(anchor) = &self.anchor { + let _ = fs::remove_file(anchor); + } + } +} + +struct TempRoot(PathBuf); + +impl TempRoot { + fn create() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock follows epoch") + .as_nanos(); + let parent = std::env::temp_dir() + .canonicalize() + .expect("temporary parent canonicalizes"); + let ordinal = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!( + "registry-server-package-{}-{nanos}-{ordinal}", + std::process::id(), + )); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempRoot { + fn drop(&mut self) { + if self + .0 + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("registry-server-package-")) + { + let _ = fs::remove_dir_all(&self.0); + } + } +} + +fn project_bytes(environment: &str, sequence: u64, module_digest: &str) -> Vec { + format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"{environment}","instanceId":"{INSTANCE}","sequence":{sequence},"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"name":"Package Test Publisher"}}}},"dataset":{{"title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}}},"modules":[{{"id":"core","version":"1","digest":"{module_digest}"}}]}}"# + ) + .into_bytes() +} + +fn module_bytes(plan: PlanChoice) -> Vec { + let second = if matches!(plan, PlanChoice::SecondTable | PlanChoice::ThirdTable) { + r#",{"id":"second-record","route":"second-records","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}],"accessProfiles":[{"id":"writer","principalClaim":"principal","operations":["get","create"],"readableFields":["code"],"writableFields":["code"]}]}"# + } else { + "" + }; + let third = if matches!(plan, PlanChoice::ThirdTable) { + r#",{"id":"third-record","route":"third-records","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}]}"# + } else { + "" + }; + format!( + r#"{{"id":"core","version":"1","entities":[{{"id":"neutral-record","route":"neutral-records","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}}],"accessProfiles":[{{"id":"reader","principalClaim":"principal","operations":["get","list"],"readableFields":["code"]}}]}}{second}{third}]}}"# + ) + .into_bytes() +} + +struct BuildRequestParts<'a> { + environment: &'a str, + sequence: u64, + prior_revision: Option<&'a str>, + schema_fingerprint: String, + project_bytes: Vec, + module_bytes: Vec, + migration_plan: PackageMigrationPlanInput, + signature_policy: SignaturePolicy, +} + +fn build_request(parts: BuildRequestParts<'_>) -> PackageBuildRequest { + PackageBuildRequest { + environment: parts.environment.to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + sequence: parts.sequence, + prior_revision: parts.prior_revision.map(str::to_owned), + compiler_source_revision: SOURCE_REVISION.to_owned(), + schema_fingerprint: parts.schema_fingerprint, + signature_policy: parts.signature_policy, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: parts.project_bytes, + }, + modules: vec![PackageModuleSource { + id: "core".to_owned(), + path: "source/modules/core/module.yaml".to_owned(), + bytes: parts.module_bytes, + }], + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: FIXTURE_JOURNEYS.to_vec(), + }, + migration_plan: parts.migration_plan, + } +} + +fn quote_identifier(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn local_context(intent: PackageIntent<'_>) -> PackageLoadContext<'_> { + PackageLoadContext { + environment: "local", + instance_id: INSTANCE, + database_id: DATABASE, + database_initialization_environment: "local", + compiler_source_revision: SOURCE_REVISION, + trust_anchor: None, + intent, + } +} + +async fn apply_package( + database: &TestDatabase, + package: ®istry_server::package::VerifiedPackage, + precondition: ApplyPrecondition<'_>, + lock_timeout: Duration, + statement_timeout: Duration, +) -> registry_server::migration::Result { + let timeouts = ApplyTimeouts::new(lock_timeout, statement_timeout) + .expect("test apply timeouts are bounded"); + apply_verified_package(ApplyVerifiedPackageRequest::new( + &database.migration_config, + package, + precondition, + ApplyRoles::new(&database.migration_role, &database.runtime_role), + timeouts, + )) + .await +} + +async fn registry_state_snapshot( + client: &impl GenericClient, +) -> ( + String, + String, + String, + String, + String, + String, + i64, + String, + Option, +) { + let row = client + .query_one( + "SELECT package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status, maintenance_target_revision + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .expect("Registry state snapshot reads"); + ( + row.get(0), + row.get(1), + row.get(2), + row.get(3), + row.get(4), + row.get(5), + row.get(6), + row.get(7), + row.get(8), + ) +} + +async fn migration_ledger_snapshot( + client: &impl GenericClient, +) -> Vec<( + Option, + String, + i64, + Vec, + String, + String, + Option, +)> { + client + .query( + "SELECT source_package_revision, target_package_revision, package_sequence, + statement_checksums, outcome, started_at::text, completed_at::text + FROM registry_internal.registry_migrations + ORDER BY package_sequence, target_package_revision", + &[], + ) + .await + .expect("migration ledger snapshot reads") + .into_iter() + .map(|row| { + ( + row.get(0), + row.get(1), + row.get(2), + row.get(3), + row.get(4), + row.get(5), + row.get(6), + ) + }) + .collect() +} + +async fn wait_for_maintenance_status(client: &impl GenericClient, expected: &str) { + for _ in 0..200 { + let status: Option = client + .query_opt( + "SELECT maintenance_status + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .expect("maintenance polling remains available") + .map(|row| row.get(0)); + if status.as_deref() == Some(expected) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("maintenance state did not reach the expected value"); +} + +async fn wait_for_blocked_apply_backend( + client: &impl GenericClient, + migration_role: &str, + excluded_pid: i32, +) -> i32 { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let rows = client + .query( + "SELECT pid + FROM pg_stat_activity + WHERE datname = current_database() + AND usename = $1 + AND pid <> $2 + AND backend_type = 'client backend' + AND wait_event_type = 'Lock'", + &[&migration_role, &excluded_pid], + ) + .await + .expect("administrator observes blocked database sessions"); + if let [row] = rows.as_slice() { + return row.get(0); + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the dedicated apply backend reaches its deterministic DDL wait") +} + +fn rewrite_unsigned(root: &Path, mutate: impl FnOnce(&mut PackageManifest)) { + let mut envelope = read_envelope(root); + mutate(&mut envelope.signed); + let migration_plan_bytes = canonicalize_json( + &serde_json::to_value(&envelope.signed.migration_plan).expect("value serializes"), + ) + .expect("value canonicalizes"); + let migration_plan_path = root.join("database/migration-plan.json"); + if migration_plan_path.is_file() { + fs::write(&migration_plan_path, &migration_plan_bytes).expect("migration plan file writes"); + if let Some(entry) = envelope + .signed + .files + .iter_mut() + .find(|entry| entry.path == "database/migration-plan.json") + { + entry.size = migration_plan_bytes.len() as u64; + entry.sha256 = format!("sha256:{}", hex(&Sha256::digest(&migration_plan_bytes))); + } + } + envelope.signed.package_revision.clear(); + envelope.signed.package_revision = + derive_package_revision(&envelope.signed).expect("mutated revision derives"); + envelope.signatures.clear(); + write_json(&root.join("package.json"), &envelope); +} + +fn rewrite_envelope(root: &Path, mutate: impl FnOnce(&mut PackageEnvelope)) { + let mut envelope = read_envelope(root); + mutate(&mut envelope); + write_json(&root.join("package.json"), &envelope); +} + +fn read_envelope(root: &Path) -> PackageEnvelope { + serde_json::from_slice(&fs::read(root.join("package.json")).expect("manifest reads")) + .expect("manifest parses") +} + +fn write_json(path: &Path, value: &impl Serialize) { + let bytes = canonicalize_json(&serde_json::to_value(value).expect("value serializes")) + .expect("value canonicalizes"); + write_file(path, &bytes); +} + +fn write_file(path: &Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("parent directories create"); + } + fs::write(path, bytes).expect("fixture file writes"); +} + +fn first_generated_path(root: &Path) -> PathBuf { + let envelope = read_envelope(root); + root.join( + &envelope + .signed + .files + .iter() + .find(|entry| entry.role == PackageFileRole::GeneratedOpenapi) + .expect("generated entry exists") + .path, + ) +} + +fn manifest_projection_path(root: &Path) -> PathBuf { + let envelope = read_envelope(root); + root.join( + &envelope + .signed + .files + .iter() + .find(|entry| entry.role == PackageFileRole::LossyManifestProjection) + .expect("Manifest projection entry exists") + .path, + ) +} + +fn load_error(root: &Path, context: &PackageLoadContext<'_>) -> PackageError { + load_package(root, context) + .err() + .expect("package is refused") +} + +fn fingerprint(byte: u8) -> String { + format!("sha256:{}", format!("{byte:02x}").repeat(32)) +} + +fn hex(bytes: &[u8]) -> String { + let mut result = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(&mut result, "{byte:02x}").expect("writing to String succeeds"); + } + result +} diff --git a/crates/registry-server/tests/postgres_partial_unique.rs b/crates/registry-server/tests/postgres_partial_unique.rs new file mode 100644 index 0000000000..17d1474848 --- /dev/null +++ b/crates/registry-server/tests/postgres_partial_unique.rs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use postgres_harness::TestDatabase; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::postgres::{ + initialize_registry_state_for_catalog_test, install_compiled_schema, + verify_catalog_identity_for_catalog, ExpectedManagedCatalog, RegistryStateTestIdentity, +}; + +const PACKAGE_ID: &str = "partial-unique-registry"; +const INSTANCE_ID: &str = "partial-unique-instance"; +const DATABASE_ID: &str = "partial-unique-database"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_postgres_partial_unique_index_enforces_only_the_closed_predicate() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"partial-unique","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"entry","route":"entries","mutationMode":"mutable", + "fields":[ + {"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"status","type":"vocabulary-code","vocabulary":"status","classification":"internal"}, + {"id":"ended-on","type":"date","classification":"internal"} + ], + "constraints":[{ + "kind":"unique","fields":["code"], + "when":[ + {"kind":"field_equals","field":"status","value":"active"}, + {"kind":"field_is_null","field":"ended-on"}, + {"kind":"active_lifecycle"} + ] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","operations":["get"],"readableFields":["code","status","ended-on"] + }] + }], + "vocabularies":[{"id":"status","values":["active","closed"]}] + }"#, + ) + .expect("partial unique source parses"); + let registry = compile_project(&project, &[], CompileProfile::Authoring) + .expect("partial unique source compiles"); + let database = TestDatabase::create(1).await; + let (migration, migration_task) = database.connect_migration().await; + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .expect("partial unique DDL installs"); + let catalog = ExpectedManagedCatalog::compiled(®istry); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision: "partial-unique-package-1", + package_sequence: 1, + }, + ) + .await + .expect("partial unique catalog identity initializes"); + verify_catalog_identity_for_catalog( + &migration, + &identity, + &catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("partial unique index is included in the exact catalog fingerprint"); + migration_task.abort(); + + let entity = ®istry.entities()["entry"]; + let table = quote_identifier(&entity.physical_table); + let code = quote_identifier(&entity.fields["code"].physical_name); + let status = quote_identifier(&entity.fields["status"].physical_name); + let ended_on = quote_identifier(&entity.fields["ended-on"].physical_name); + let indexdef: String = database + .admin + .query_one( + "SELECT indexdef FROM pg_catalog.pg_indexes + WHERE schemaname = 'registry_data' AND tablename = $1 AND indexdef LIKE '% WHERE %'", + &[&entity.physical_table], + ) + .await + .expect("partial unique index is visible in the PostgreSQL catalog") + .get(0); + assert!(indexdef.starts_with("CREATE UNIQUE INDEX ")); + assert!(indexdef.contains("record_lifecycle = 'active'::text")); + + let insert = format!( + "INSERT INTO registry_data.{table} + (record_id, active_package_revision, record_lifecycle, {code}, {status}, {ended_on}) + VALUES ($1::text::uuid, 'partial-unique-package-1', $2, $3, $4, $5::text::date)" + ); + database + .admin + .execute( + &insert, + &[ + &"00000000-0000-0000-0000-000000000701", + &"active", + &"A-1", + &"active", + &Option::<&str>::None, + ], + ) + .await + .expect("first active open row inserts"); + assert!(database + .admin + .execute( + &insert, + &[ + &"00000000-0000-0000-0000-000000000702", + &"active", + &"A-1", + &"active", + &Option::<&str>::None, + ], + ) + .await + .is_err()); + for (record_id, lifecycle, status_value, ended_value) in [ + ( + "00000000-0000-0000-0000-000000000703", + "active", + "closed", + None, + ), + ( + "00000000-0000-0000-0000-000000000704", + "active", + "active", + Some("2026-08-29"), + ), + ( + "00000000-0000-0000-0000-000000000705", + "tombstoned", + "active", + None, + ), + ] { + database + .admin + .execute( + &insert, + &[&record_id, &lifecycle, &"A-1", &status_value, &ended_value], + ) + .await + .expect("rows outside the closed partial predicate may reuse the key"); + } + database.cleanup().await; +} + +fn quote_identifier(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} diff --git a/crates/registry-server/tests/postgres_pilot_acceptance.rs b/crates/registry-server/tests/postgres_pilot_acceptance.rs new file mode 100644 index 0000000000..6ae2165141 --- /dev/null +++ b/crates/registry-server/tests/postgres_pilot_acceptance.rs @@ -0,0 +1,1032 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/pilot_acceptance_harness.rs"] +mod pilot_acceptance_harness; +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use axum::http::{Method, StatusCode}; +use pilot_acceptance_harness::{response_bytes, response_json, PilotHarness}; +use serde_json::{json, Value}; + +const FOREIGN_UUID: &str = "00000000-0000-4000-8000-000000000999"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral() { + let asset = PilotHarness::start("asset-site-placement").await; + asset_site_placement_journey(&asset).await; + asset.finish().await; + + let household = PilotHarness::start("publicschema-household").await; + household_journey(&household).await; + household.finish().await; + + let disability = PilotHarness::start("disability").await; + disability_journey(&disability).await; + disability.finish().await; + + let farmer = PilotHarness::start("farmer").await; + farmer_journey(&farmer).await; + farmer.finish().await; + + let business = PilotHarness::start("business").await; + business_journey(&business).await; + business.finish().await; +} + +async fn asset_site_placement_journey(harness: &PilotHarness) { + let token = harness.token("asset-management", &[]); + assert_fixture_surface(harness, "asset-operator", &token, "asset").await; + let planner_token = harness.token("site-planning", &[]); + let planner_openapi = + assert_fixture_surface(harness, "site-planner", &planner_token, "asset").await; + assert!(planner_openapi["paths"] + .get("/v1/records/inspections") + .is_none()); + assert!(planner_openapi["paths"]["/v1/records/assets"] + .get("post") + .is_none()); + assert!( + planner_openapi["components"]["schemas"]["asset-item"]["properties"] + .get("asset-class") + .is_none() + ); + + let asset = create_record( + harness, + "/v1/records/assets", + &token, + "asset-create", + json!({"asset-code":"A-100","label":"Portable pump","asset-class":"equipment"}), + ) + .await; + let old_site = create_record( + harness, + "/v1/records/sites", + &token, + "site-old-create", + json!({"site-code":"S-OLD","label":"Old depot"}), + ) + .await; + let current_site = create_record( + harness, + "/v1/records/sites", + &token, + "site-current-create", + json!({"site-code":"S-CURRENT","label":"Current depot"}), + ) + .await; + let old_placement = create_record( + harness, + "/v1/records/placements", + &token, + "placement-old-create", + json!({ + "asset": asset.id, + "site": old_site.id, + "valid-from":"2020-01-01", + "valid-to":"2021-01-01" + }), + ) + .await; + let current_placement = create_record( + harness, + "/v1/records/placements", + &token, + "placement-current-create", + json!({ + "asset": asset.id, + "site": current_site.id, + "valid-from":"2021-01-01" + }), + ) + .await; + assert_list_ids( + harness, + "/v1/records/placements:as-of?accessProfile=asset-operator&asOf=2020-12-31T23:59:59Z", + Some(&token), + &[&old_placement.id], + ) + .await; + assert_list_ids( + harness, + "/v1/records/placements:as-of?accessProfile=asset-operator&asOf=2021-01-01T00:00:00Z", + Some(&token), + &[¤t_placement.id], + ) + .await; + assert_list_ids( + harness, + "/v1/records/placements:current?accessProfile=asset-operator", + Some(&token), + &[¤t_placement.id], + ) + .await; + assert_list_ids( + harness, + "/v1/records/placements:current?accessProfile=site-planner", + Some(&planner_token), + &[¤t_placement.id], + ) + .await; + let wrong_purpose = harness + .send( + Method::GET, + "/v1/records/placements:current?accessProfile=site-planner", + Some(&token), + &[], + Vec::new(), + ) + .await; + assert_eq!(wrong_purpose.status(), StatusCode::NOT_FOUND); + + let overlap = harness + .send_json( + Method::POST, + "/v1/records/placements", + Some(&token), + Some("placement-overlap"), + json!({ + "data":{ + "asset":asset.id, + "site":old_site.id, + "valid-from":"2020-06-01", + "valid-to":"2021-06-01" + } + }), + ) + .await; + assert_eq!(overlap.status(), StatusCode::CONFLICT); + + let foreign_reference = harness + .send_json( + Method::POST, + "/v1/records/placements", + Some(&token), + Some("placement-foreign-reference"), + json!({ + "data":{ + "asset":FOREIGN_UUID, + "site":current_site.id, + "valid-from":"2030-01-01" + } + }), + ) + .await; + assert_eq!(foreign_reference.status(), StatusCode::CONFLICT); + + let inspection = create_record( + harness, + "/v1/records/inspections", + &token, + "inspection-create", + json!({ + "asset":asset.id, + "observed-at":"2026-08-30T10:00:00Z", + "result":"passed" + }), + ) + .await; + assert_create_only_refuses_patch_and_tombstone( + harness, + "/v1/records/inspections", + &inspection, + &token, + ) + .await; + let event_count: i64 = harness + .database + .admin + .query_one( + "SELECT count(*) FROM registry_internal.registry_outbox WHERE event_type = 'inspection-created'", + &[], + ) + .await + .expect("administrator samples the configured create event type") + .get(0); + assert_eq!(event_count, 1); +} + +async fn household_journey(harness: &PilotHarness) { + let token = harness.token("household-administration", &[]); + let openapi = assert_fixture_surface(harness, "household-operator", &token, "household").await; + assert_eq!( + openapi["components"]["schemas"]["person"]["properties"]["residency-status"] + ["x-registry-vocabulary"], + "residency-status" + ); + assert!(openapi["components"]["schemas"]["person"]["properties"] + .get("preferred-language") + .is_some()); + + let person = create_record( + harness, + "/v1/records/persons", + &token, + "person-create", + json!({ + "person-code":"P-100", + "legal-name":"Ada North", + "family-name":"North", + "date-of-birth":"1990-04-03", + "residency-status":"usual-resident", + "preferred-language":"en" + }), + ) + .await; + assert_eq!(person.body["data"]["residency-status"], "usual-resident"); + assert_eq!(person.body["data"]["preferred-language"], "en"); + let old_household = create_record( + harness, + "/v1/records/households", + &token, + "household-old-create", + json!({ + "household-code":"H-OLD", + "household-name":"Old household", + "administrative-area":"north", + "household-type":"private" + }), + ) + .await; + let current_household = create_record( + harness, + "/v1/records/households", + &token, + "household-current-create", + json!({ + "household-code":"H-CURRENT", + "household-name":"Current household", + "administrative-area":"north", + "household-type":"private" + }), + ) + .await; + let old_membership = create_record( + harness, + "/v1/records/group-memberships", + &token, + "membership-old-create", + json!({ + "person":person.id, + "household":old_household.id, + "relationship":"head", + "valid-from":"2019-01-01", + "valid-to":"2022-01-01" + }), + ) + .await; + let current_membership = create_record( + harness, + "/v1/records/group-memberships", + &token, + "membership-current-create", + json!({ + "person":person.id, + "household":current_household.id, + "relationship":"head", + "valid-from":"2022-01-01" + }), + ) + .await; + assert_list_ids( + harness, + "/v1/records/group-memberships:as-of?accessProfile=household-operator&asOf=2021-12-31T23:59:59Z", + Some(&token), + &[&old_membership.id], + ) + .await; + assert_list_ids( + harness, + "/v1/records/group-memberships:current?accessProfile=household-operator", + Some(&token), + &[¤t_membership.id], + ) + .await; + + let overlap = harness + .send_json( + Method::POST, + "/v1/records/group-memberships", + Some(&token), + Some("membership-overlap"), + json!({"data":{ + "person":person.id, + "household":old_household.id, + "relationship":"dependent", + "valid-from":"2021-06-01", + "valid-to":"2023-01-01" + }}), + ) + .await; + assert_eq!(overlap.status(), StatusCode::CONFLICT); +} + +async fn disability_journey(harness: &PilotHarness) { + let token = harness.token("disability-assessment", &[]); + let openapi = + assert_fixture_surface(harness, "disability-caseworker", &token, "disability").await; + assert_eq!( + openapi["components"]["schemas"]["functioning-observation"]["properties"] + ["observation-schema-metadata"]["additionalProperties"], + false + ); + let concealed_schema = harness + .send( + Method::GET, + "/openapi.json?accessProfile=disability-caseworker", + None, + &[], + Vec::new(), + ) + .await; + assert_eq!(concealed_schema.status(), StatusCode::NOT_FOUND); + + let assessment = create_record( + harness, + "/v1/records/assessment-episodes", + &token, + "assessment-create", + json!({ + "episode-code":"EP-100", + "subject-code":"SUBJECT-100", + "opened-on":"2026-01-10", + "assessment-source":"case-management" + }), + ) + .await; + let invalid_range = harness + .send_json( + Method::POST, + "/v1/records/functioning-observations", + Some(&token), + Some("observation-invalid-range"), + json!({"data":{ + "assessment-episode":assessment.id, + "observed-at":"2026-01-11T09:00:00Z", + "functioning-domain":"mobility", + "severity-score":5, + "observation-schema-metadata":{ + "schemaVersion":"1","vocabularyRelease":"2026-01","scoringScale":"zero-to-four" + } + }}), + ) + .await; + assert_eq!(invalid_range.status(), StatusCode::CONFLICT); + let invalid_structure = harness + .send_json( + Method::POST, + "/v1/records/functioning-observations", + Some(&token), + Some("observation-invalid-structure"), + json!({"data":{ + "assessment-episode":assessment.id, + "observed-at":"2026-01-11T09:00:00Z", + "functioning-domain":"mobility", + "severity-score":3, + "observation-schema-metadata":{ + "schemaVersion":"1","vocabularyRelease":"2026-01", + "scoringScale":"zero-to-four","undeclared":"refused" + } + }}), + ) + .await; + assert_eq!(invalid_structure.status(), StatusCode::BAD_REQUEST); + let observation = create_record( + harness, + "/v1/records/functioning-observations", + &token, + "observation-create", + json!({ + "assessment-episode":assessment.id, + "observed-at":"2026-01-11T09:00:00Z", + "functioning-domain":"mobility", + "severity-score":3, + "observation-schema-metadata":{ + "schemaVersion":"1","vocabularyRelease":"2026-01","scoringScale":"zero-to-four" + } + }), + ) + .await; + assert_eq!(observation.body["data"]["severity-score"], 3); + + let original = create_record( + harness, + "/v1/records/certifications", + &token, + "certification-original", + json!({ + "certification-code":"CERT-100", + "assessment-episode":assessment.id, + "certification-status":"corrected", + "valid-from":"2026-01-01", + "valid-to":"2026-07-01", + "validity-source":"review-board" + }), + ) + .await; + let correction = create_record( + harness, + "/v1/records/certifications", + &token, + "certification-correction", + json!({ + "certification-code":"CERT-101", + "assessment-episode":assessment.id, + "certification-status":"active", + "valid-from":"2026-07-01", + "corrected-certification":original.id, + "correction-reason":"reviewed correction", + "validity-source":"review-board", + "provenance-note":"signed review packet" + }), + ) + .await; + assert_eq!( + correction.body["data"]["corrected-certification"], + original.id + ); + assert!(correction.body["data"]["correction-reason"].is_string()); + assert!(correction.body["data"]["provenance-note"].is_string()); + assert_create_only_refuses_patch_and_tombstone( + harness, + "/v1/records/certifications", + &correction, + &token, + ) + .await; + + let anonymous = harness + .send( + Method::GET, + &format!("/v1/records/certifications/{}", correction.id), + None, + &[], + Vec::new(), + ) + .await; + assert_eq!(anonymous.status(), StatusCode::NOT_FOUND); + assert_eq!(response_json(anonymous).await["code"], "resource.not_found"); +} + +async fn farmer_journey(harness: &PilotHarness) { + let north_token = harness.token( + "farmer-registry", + &[("administrative_boundaries", json!(["north-district"]))], + ); + let south_token = harness.token( + "farmer-registry", + &[("administrative_boundaries", json!(["south-district"]))], + ); + let openapi = assert_fixture_surface(harness, "farmer-operator", &north_token, "farmer").await; + assert_eq!( + openapi["paths"]["/v1/records/plots:batch"]["post"]["x-registry-maximumItems"], + 4 + ); + let ddl = harness.registry.ddl().script().to_ascii_lowercase(); + assert!(!ddl.contains("postgis")); + assert!(!ddl.contains("geometry")); + assert!(!ddl.contains("geography")); + let postgis_count: i64 = harness + .database + .admin + .query_one( + "SELECT count(*) FROM pg_catalog.pg_extension WHERE extname = 'postgis'", + &[], + ) + .await + .expect("administrator samples installed extension inventory") + .get(0); + assert_eq!(postgis_count, 0); + + let north_farmer = create_record( + harness, + "/v1/records/farmers", + &north_token, + "north-farmer", + json!({ + "farmer-code":"F-NORTH","display-name":"North operator", + "administrative-boundary":"north-district" + }), + ) + .await; + let south_farmer = create_record( + harness, + "/v1/records/farmers", + &south_token, + "south-farmer", + json!({ + "farmer-code":"F-SOUTH","display-name":"South operator", + "administrative-boundary":"south-district" + }), + ) + .await; + let concealed = harness + .send( + Method::GET, + &format!( + "/v1/records/farmers/{}?accessProfile=farmer-operator", + south_farmer.id + ), + Some(&north_token), + &[], + Vec::new(), + ) + .await; + assert_eq!(concealed.status(), StatusCode::NOT_FOUND); + let north_list = response_json( + harness + .send( + Method::GET, + "/v1/records/farmers?accessProfile=farmer-operator", + Some(&north_token), + &[], + Vec::new(), + ) + .await, + ) + .await; + let north_ids = item_ids(&north_list); + assert!(north_ids.contains(&north_farmer.id.as_str())); + assert!(!north_ids.contains(&south_farmer.id.as_str())); + + let old_holding = create_record( + harness, + "/v1/records/holdings", + &north_token, + "holding-old", + json!({ + "holding-code":"H-NORTH","farmer":north_farmer.id,"tenure-type":"leased", + "tenure-start":"2020-01-01","tenure-end":"2024-01-01", + "administrative-boundary":"north-district","import-source":"survey-a", + "source-record-id":"holding-old" + }), + ) + .await; + let current_holding = create_record( + harness, + "/v1/records/holdings", + &north_token, + "holding-current", + json!({ + "holding-code":"H-NORTH","farmer":north_farmer.id,"tenure-type":"owned", + "tenure-start":"2024-01-01","administrative-boundary":"north-district", + "import-source":"survey-a","source-record-id":"holding-current" + }), + ) + .await; + assert_list_ids( + harness, + "/v1/records/holdings:as-of?accessProfile=farmer-operator&asOf=2023-12-31T23:59:59Z", + Some(&north_token), + &[&old_holding.id], + ) + .await; + assert_list_ids( + harness, + "/v1/records/holdings:current?accessProfile=farmer-operator", + Some(&north_token), + &[¤t_holding.id], + ) + .await; + + let invalid_point = harness + .send_json( + Method::POST, + "/v1/records/plots", + Some(&north_token), + Some("plot-invalid-point"), + json!({"data":{ + "plot-code":"P-BAD-POINT","holding":current_holding.id, + "administrative-boundary":"north-district", + "centroid":{"type":"Point","coordinates":[32.0,-9.5]}, + "area-value":"1.2500","area-unit":"hectare", + "import-source":"survey-a","source-record-id":"plot-bad-point" + }}), + ) + .await; + assert_eq!(invalid_point.status(), StatusCode::BAD_REQUEST); + let invalid_decimal = harness + .send_json( + Method::POST, + "/v1/records/plots", + Some(&north_token), + Some("plot-invalid-decimal"), + json!({"data":{ + "plot-code":"P-BAD-DECIMAL","holding":current_holding.id, + "administrative-boundary":"north-district", + "centroid":{"type":"Point","coordinates":[30.5,-9.5]}, + "area-value":"01.2500","area-unit":"hectare", + "import-source":"survey-a","source-record-id":"plot-bad-decimal" + }}), + ) + .await; + assert_eq!(invalid_decimal.status(), StatusCode::BAD_REQUEST); + let plot = create_record( + harness, + "/v1/records/plots", + &north_token, + "plot-create", + json!({ + "plot-code":"P-NORTH","holding":current_holding.id, + "administrative-boundary":"north-district", + "centroid":{"type":"Point","coordinates":[30.5,-9.5]}, + "area-value":"1.2500","area-unit":"hectare", + "import-source":"survey-a","source-record-id":"plot-primary" + }), + ) + .await; + assert_eq!(plot.body["data"]["area-value"], "1.2500"); + assert_eq!(plot.body["data"]["area-unit"], "hectare"); + + let old_activity = create_record( + harness, + "/v1/records/seasonal-activities", + &north_token, + "activity-old", + json!({ + "plot":plot.id,"administrative-boundary":"north-district", + "activity-type":"planting","season-start":"2024-01-01","season-end":"2024-07-01", + "quantity-value":"12.500","quantity-unit":"kilogram" + }), + ) + .await; + let current_activity = create_record( + harness, + "/v1/records/seasonal-activities", + &north_token, + "activity-current", + json!({ + "plot":plot.id,"administrative-boundary":"north-district", + "activity-type":"planting","season-start":"2024-07-01", + "quantity-value":"8.250","quantity-unit":"kilogram" + }), + ) + .await; + assert_eq!(current_activity.body["data"]["quantity-value"], "8.250"); + assert_list_ids( + harness, + "/v1/records/seasonal-activities:as-of?accessProfile=farmer-operator&asOf=2024-06-30T23:59:59Z", + Some(&north_token), + &[&old_activity.id], + ) + .await; + assert_list_ids( + harness, + "/v1/records/seasonal-activities:current?accessProfile=farmer-operator", + Some(&north_token), + &[¤t_activity.id], + ) + .await; + + let batch_body = json!({"items":[{"operation":"create","data":{ + "plot-code":"P-BATCH","holding":current_holding.id, + "administrative-boundary":"north-district", + "centroid":{"type":"Point","coordinates":[30.75,-9.25]}, + "area-value":"2.0000","area-unit":"hectare", + "import-source":"survey-b","source-record-id":"plot-batch" + }}]}); + let first_batch = harness + .send_json( + Method::POST, + "/v1/records/plots:batch", + Some(&north_token), + Some("plot-batch"), + batch_body.clone(), + ) + .await; + assert_eq!(first_batch.status(), StatusCode::OK); + let first_batch_bytes = response_bytes(first_batch).await; + let replay = harness + .send_json( + Method::POST, + "/v1/records/plots:batch", + Some(&north_token), + Some("plot-batch"), + batch_body, + ) + .await; + assert_eq!(replay.status(), StatusCode::OK); + assert_eq!(response_bytes(replay).await, first_batch_bytes); + + let duplicate_import = harness + .send_json( + Method::POST, + "/v1/records/plots", + Some(&north_token), + Some("plot-duplicate-import"), + json!({"data":{ + "plot-code":"P-DUPLICATE","holding":current_holding.id, + "administrative-boundary":"north-district", + "centroid":{"type":"Point","coordinates":[30.8,-9.2]}, + "area-value":"3.0000","area-unit":"hectare", + "import-source":"survey-b","source-record-id":"plot-batch" + }}), + ) + .await; + assert_eq!(duplicate_import.status(), StatusCode::CONFLICT); +} + +async fn business_journey(harness: &PilotHarness) { + let token = harness.token("business-registry", &[]); + assert_fixture_surface(harness, "business-registrar", &token, "business").await; + let legal_entity = create_record( + harness, + "/v1/records/legal-entities", + &token, + "legal-entity-create", + json!({ + "jurisdiction-code":"XY","registration-number":"10001", + "legal-name":"Example Cooperative","entity-status":"active", + "public-service-address":"Public office", + "protected-contact":"protected-contact@example.test", + "protected-ownership-reference":"ownership-10001", + "internal-case-note":"registrar review complete" + }), + ) + .await; + let public = response_json( + harness + .send( + Method::GET, + &format!("/v1/records/legal-entities/{}", legal_entity.id), + None, + &[], + Vec::new(), + ) + .await, + ) + .await; + assert_eq!(public["data"]["legal-name"], "Example Cooperative"); + assert!(public["data"].get("protected-contact").is_none()); + assert!(public["data"] + .get("protected-ownership-reference") + .is_none()); + assert!(public["data"].get("internal-case-note").is_none()); + let protected = response_json( + harness + .send( + Method::GET, + &format!( + "/v1/records/legal-entities/{}?accessProfile=business-registrar", + legal_entity.id + ), + Some(&token), + &[], + Vec::new(), + ) + .await, + ) + .await; + assert!(protected["data"]["protected-contact"].is_string()); + assert!(protected["data"]["protected-ownership-reference"].is_string()); + assert!(protected["data"]["internal-case-note"].is_string()); + + let duplicate_identifier = harness + .send_json( + Method::POST, + "/v1/records/legal-entities", + Some(&token), + Some("legal-entity-duplicate"), + json!({"data":{ + "jurisdiction-code":"XY","registration-number":"10001", + "legal-name":"Duplicate","entity-status":"active" + }}), + ) + .await; + assert_eq!(duplicate_identifier.status(), StatusCode::CONFLICT); + + let filing = create_record( + harness, + "/v1/records/filings", + &token, + "filing-create", + json!({ + "legal-entity":legal_entity.id,"filing-number":"F-100", + "filing-type":"incorporation","filed-date":"2020-01-01", + "source-system":"registrar","source-record-id":"filing-100", + "provenance-note":"accepted filing" + }), + ) + .await; + assert_create_only_refuses_patch_and_tombstone(harness, "/v1/records/filings", &filing, &token) + .await; + + let historical = create_record( + harness, + "/v1/records/officer-appointments", + &token, + "appointment-historical", + json!({ + "legal-entity":legal_entity.id,"officer-code":"OFFICER-A", + "officer-name":"First Director","officer-role":"director", + "effective-from":"2020-01-01","effective-to":"2022-01-01", + "protected-officer-id":"protected-a" + }), + ) + .await; + let current = create_record( + harness, + "/v1/records/officer-appointments", + &token, + "appointment-current", + json!({ + "legal-entity":legal_entity.id,"officer-code":"OFFICER-A", + "officer-name":"First Director","officer-role":"director", + "effective-from":"2022-01-01","protected-officer-id":"protected-a" + }), + ) + .await; + assert_list_ids( + harness, + "/v1/records/officer-appointments:as-of?asOf=2021-12-31T23:59:59Z", + None, + &[&historical.id], + ) + .await; + assert_list_ids( + harness, + "/v1/records/officer-appointments:as-of?asOf=2022-01-01T00:00:00Z", + None, + &[¤t.id], + ) + .await; + assert_list_ids( + harness, + "/v1/records/officer-appointments:current", + None, + &[¤t.id], + ) + .await; + + let partial_unique = harness + .send_json( + Method::POST, + "/v1/records/officer-appointments", + Some(&token), + Some("appointment-partial-unique"), + json!({"data":{ + "legal-entity":legal_entity.id,"officer-code":"OFFICER-B", + "officer-name":"Second Director","officer-role":"director", + "effective-from":"2023-01-01" + }}), + ) + .await; + assert_eq!(partial_unique.status(), StatusCode::CONFLICT); + let overlap = harness + .send_json( + Method::POST, + "/v1/records/officer-appointments", + Some(&token), + Some("appointment-overlap"), + json!({"data":{ + "legal-entity":legal_entity.id,"officer-code":"OFFICER-A", + "officer-name":"First Director","officer-role":"secretary", + "effective-from":"2021-01-01","effective-to":"2023-01-01" + }}), + ) + .await; + assert_eq!(overlap.status(), StatusCode::CONFLICT); +} + +async fn assert_fixture_surface( + harness: &PilotHarness, + profile: &str, + token: &str, + active_family: &str, +) -> Value { + let response = harness + .send( + Method::GET, + &format!("/openapi.json?accessProfile={profile}"), + Some(token), + &[], + Vec::new(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let openapi = response_json(response).await; + let families = [ + ("asset", "/v1/records/assets", "asset-item"), + ("household", "/v1/records/persons", "person"), + ("household", "/v1/records/households", "household"), + ( + "disability", + "/v1/records/assessment-episodes", + "assessment-episode", + ), + ("farmer", "/v1/records/farmers", "farmer"), + ("business", "/v1/records/legal-entities", "legal-entity"), + ]; + for (family, route, schema) in families { + if family == active_family { + assert!(openapi["paths"].get(route).is_some()); + assert!(openapi["components"]["schemas"].get(schema).is_some()); + } else { + assert!(openapi["paths"].get(route).is_none()); + assert!(openapi["components"]["schemas"].get(schema).is_none()); + let foreign_http = harness + .send(Method::GET, route, Some(token), &[], Vec::new()) + .await; + assert_eq!(foreign_http.status(), StatusCode::NOT_FOUND); + } + } + openapi +} + +async fn assert_list_ids( + harness: &PilotHarness, + uri: &str, + token: Option<&str>, + expected: &[&str], +) { + let response = harness.send(Method::GET, uri, token, &[], Vec::new()).await; + assert_eq!(response.status(), StatusCode::OK, "{uri}"); + let body = response_json(response).await; + assert_eq!(item_ids(&body), expected, "{uri}"); +} + +fn item_ids(body: &Value) -> Vec<&str> { + body["items"] + .as_array() + .expect("temporal/list response has items") + .iter() + .map(|item| item["id"].as_str().expect("listed item has id")) + .collect() +} + +async fn assert_create_only_refuses_patch_and_tombstone( + harness: &PilotHarness, + collection: &str, + record: &CreatedRecord, + token: &str, +) { + let target = format!("{collection}/{}", record.id); + let patch = harness + .send( + Method::PATCH, + &target, + Some(token), + &[ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "create-only-patch"), + ("if-match", &record.etag), + ], + br#"[{"op":"replace","path":"/data/provenance-note","value":"refused"}]"#.to_vec(), + ) + .await; + assert_eq!(patch.status(), StatusCode::NOT_FOUND); + let tombstone = harness + .send( + Method::DELETE, + &target, + Some(token), + &[ + ("idempotency-key", "create-only-tombstone"), + ("if-match", &record.etag), + ], + Vec::new(), + ) + .await; + assert_eq!(tombstone.status(), StatusCode::NOT_FOUND); +} + +struct CreatedRecord { + id: String, + etag: String, + body: Value, +} + +async fn create_record( + harness: &PilotHarness, + uri: &str, + token: &str, + idempotency_key: &str, + data: Value, +) -> CreatedRecord { + let response = harness + .send_json( + Method::POST, + uri, + Some(token), + Some(idempotency_key), + json!({"data":data}), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED, "{uri}"); + let etag = response + .headers() + .get("etag") + .and_then(|value| value.to_str().ok()) + .expect("created record has a strong ETag") + .to_owned(); + let body = response_json(response).await; + let id = body["id"] + .as_str() + .expect("created record has a server UUID") + .to_owned(); + CreatedRecord { id, etag, body } +} diff --git a/crates/registry-server/tests/postgres_read.rs b/crates/registry-server/tests/postgres_read.rs new file mode 100644 index 0000000000..5b77be7f85 --- /dev/null +++ b/crates/registry-server/tests/postgres_read.rs @@ -0,0 +1,1323 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::{to_bytes, Body}; +use axum::http::{Method, Request, Response, StatusCode}; +use postgres_harness::TestDatabase; +use registry_platform_audit::{verify_jsonl_lines_with_hasher, AuditEnvelope, AuditProfile}; +use registry_server::api::{ + router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, VerifiedClaimValue, + VerifiedRequestClaims, +}; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::cursor::CursorCodec; +use registry_server::postgres::{ + begin_record_transaction, initialize_registry_state_for_catalog_test, install_compiled_schema, + ClaimContext, ExpectedManagedCatalog, PostgresRecordReadService, ReadFaultPoint, + RegistryLockKey, RegistryStateTestIdentity, RowBoundaryContext, +}; +use serde_json::{json, Value}; +use tokio_postgres::Transaction; +use tower::Service as _; +use zeroize::Zeroizing; + +const PRINCIPAL_CANARY: &str = "principal-value-must-not-enter-read-audit"; +const SECRET_CANARY: &str = "SECRET-CANARY-MUST-NOT-LEAVE-PROJECTION"; +const PACKAGE_ID: &str = "read-registry"; +const INSTANCE_ID: &str = "read-instance"; +const DATABASE_ID: &str = "read-database"; +const VISIBLE_RECORD: &str = "00000000-0000-4000-8000-000000000001"; +const ALPHA_RECORD: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaa0001"; +const WILDCARD_RECORD: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbb0001"; +const SORT_DUP_A_RECORD: &str = "cccccccc-cccc-4ccc-8ccc-cccccccc0001"; +const SORT_DUP_B_RECORD: &str = "cccccccc-cccc-4ccc-8ccc-cccccccc0002"; +const SORT_NEXT_RECORD: &str = "cccccccc-cccc-4ccc-8ccc-cccccccc0003"; +const SORT_NULL_A_RECORD: &str = "cccccccc-cccc-4ccc-8ccc-cccccccc0004"; +const SORT_NULL_B_RECORD: &str = "cccccccc-cccc-4ccc-8ccc-cccccccc0005"; +const TEMPORAL_OLD_RECORD: &str = "11111111-1111-4111-8111-111111111111"; +const TEMPORAL_OPEN_RECORD: &str = "22222222-2222-4222-8222-222222222222"; +const TEMPORAL_OTHER_BOUNDARY_RECORD: &str = "33333333-3333-4333-8333-333333333333"; +const MISMATCH_RECORD: &str = "ffffffff-ffff-4fff-8fff-ffffffffffff"; +const TOMBSTONED_RECORD: &str = "00000000-0000-4000-8000-999999999999"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { + let database = TestDatabase::create(8).await; + database + .admin + .execute("CREATE EXTENSION IF NOT EXISTS btree_gist", &[]) + .await + .expect("administrator installs btree_gist for temporal exclusion constraints"); + let (migration, migration_task) = database.connect_migration().await; + let compiled = Arc::new(compiled_registry()); + install_compiled_schema(&migration, &compiled, &database.runtime_role) + .await + .expect("migration installs the complete compiled PostgreSQL schema"); + let catalog = ExpectedManagedCatalog::compiled(&compiled); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision: "package-read-1", + package_sequence: 1, + }, + ) + .await + .expect("migration initializes durable Registry identity"); + migration_task.abort(); + + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let lock_key = RegistryLockKey::derive(PACKAGE_ID).expect("lock identity is bounded"); + seed_records(&database, &pool, lock_key, &identity, &compiled, false).await; + + let profile = AuditProfile::production_from_secret_bytes(vec![0x7a; 32].into()) + .expect("test owns a strongly keyed audit profile"); + let app = read_router( + pool.clone(), + compiled.clone(), + identity.clone(), + lock_key, + profile.clone(), + None, + ); + + let get = send( + &app, + &format!("/v1/records/widgets/{VISIBLE_RECORD}?fields=label"), + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(get.status(), StatusCode::OK); + let get_bytes = body_bytes(get).await; + assert_eq!( + get_bytes, + format!( + "{{\"data\":{{\"label\":\"label-001\"}},\"id\":\"{VISIBLE_RECORD}\",\"revision\":1}}" + ) + .as_bytes() + ); + let body = json_from_bytes(&get_bytes); + assert_eq!(body["id"], VISIBLE_RECORD); + assert_eq!(body["revision"], 1); + assert_eq!(body["data"], json!({"label": "label-001"})); + assert!(!body.to_string().contains(SECRET_CANARY)); + + let decimal = send( + &app, + &format!("/v1/records/widgets/{VISIBLE_RECORD}?fields=amount"), + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(decimal.status(), StatusCode::OK); + let decimal_bytes = body_bytes(decimal).await; + assert_eq!( + decimal_bytes, + format!("{{\"data\":{{\"amount\":\"1.20\"}},\"id\":\"{VISIBLE_RECORD}\",\"revision\":1}}") + .as_bytes(), + "fixed-scale decimals are returned as exact strings, not JSON numbers" + ); + assert_eq!(json_from_bytes(&decimal_bytes)["data"]["amount"], "1.20"); + + let list = send( + &app, + "/v1/records/widgets?fields=label", + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(list.status(), StatusCode::OK); + assert_eq!(list.headers()["cache-control"], "no-store"); + let body = body_json(list).await; + let items = body["items"].as_array().expect("list returns items"); + assert_eq!( + items.len(), + 100, + "the SQL limit is applied before materializing" + ); + for window in items.windows(2) { + assert!( + window[0]["id"].as_str() <= window[1]["id"].as_str(), + "list ordering is deterministic by record id" + ); + } + assert_eq!(items[0]["id"], "00000000-0000-4000-8000-000000000000"); + assert_eq!(items[99]["id"], "00000000-0000-4000-8000-000000000099"); + let next_cursor = body["pageInfo"]["nextCursor"] + .as_str() + .expect("overfetch produces a cursor") + .to_owned(); + assert!(!body.to_string().contains(SECRET_CANARY)); + + let repeated_in = send( + &app, + "/v1/records/widgets?fields=label&filter=jurisdiction:in:zone-b&filter=jurisdiction:in:zone-a&pageSize=2", + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(repeated_in.status(), StatusCode::OK); + let repeated_in = body_json(repeated_in).await; + let repeated_items = repeated_in["items"] + .as_array() + .expect("repeated in returns items"); + assert_eq!( + repeated_items.len(), + 2, + "repeated in values are one finite set, not an impossible conjunction" + ); + assert_eq!( + repeated_items[0]["id"], + "00000000-0000-4000-8000-000000000000" + ); + assert_eq!(repeated_items[1]["id"], VISIBLE_RECORD); + assert!(!repeated_in.to_string().contains("zone-b-label")); + + let prefix = send( + &app, + "/v1/records/widgets?fields=label&filter=label:prefix:literal%25_%5C", + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(prefix.status(), StatusCode::OK); + let prefix = body_json(prefix).await; + let prefix_items = prefix["items"].as_array().expect("prefix returns items"); + assert_eq!(prefix_items.len(), 1); + assert_eq!(prefix_items[0]["id"], WILDCARD_RECORD); + assert_eq!(prefix_items[0]["data"]["label"], "literal%_\\value"); + + let continuation = send( + &app, + &format!("/v1/records/widgets?cursor={next_cursor}"), + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(continuation.status(), StatusCode::OK); + let continuation = body_json(continuation).await; + let continuation_items = continuation["items"] + .as_array() + .expect("cursor returns items"); + assert_eq!(continuation_items.len(), 3); + assert_eq!( + continuation_items + .iter() + .map(|item| item["id"].as_str().expect("record id")) + .collect::>(), + vec![ + "00000000-0000-4000-8000-000000000100", + ALPHA_RECORD, + WILDCARD_RECORD + ] + ); + + let mut tampered = next_cursor.into_bytes(); + let last = tampered.len() - 1; + tampered[last] = if tampered[last] == b'A' { b'B' } else { b'A' }; + let tampered = String::from_utf8(tampered).expect("cursor remains UTF-8"); + let refused_cursor = send( + &app, + &format!("/v1/records/widgets?cursor={tampered}"), + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(refused_cursor.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body_json(refused_cursor).await["code"], + "query.cursor_invalid" + ); + + let concealed = send( + &app, + &format!("/v1/records/widgets/{MISMATCH_RECORD}?fields=label"), + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(concealed.status(), StatusCode::NOT_FOUND); + let concealed_body = body_json(concealed).await; + assert_eq!(concealed_body["code"], "resource.not_found"); + assert!(!concealed_body.to_string().contains("zone-b")); + assert!(!concealed_body.to_string().contains(SECRET_CANARY)); + + let tombstoned = send( + &app, + &format!("/v1/records/widgets/{TOMBSTONED_RECORD}?fields=label"), + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(tombstoned.status(), StatusCode::NOT_FOUND); + assert!(!body_json(tombstoned) + .await + .to_string() + .contains("tombstoned-label")); + + let uppercase = send( + &app, + &format!( + "/v1/records/widgets/{}?fields=label", + ALPHA_RECORD.to_ascii_uppercase() + ), + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(uppercase.status(), StatusCode::NOT_FOUND); + assert_eq!(body_json(uppercase).await["code"], "resource.not_found"); + + let before_fault = audit_count(&database).await; + let faulting_app = read_router( + pool, + compiled.clone(), + identity, + lock_key, + profile.clone(), + Some(ReadFaultPoint::BeforeTerminalAudit), + ); + let faulted = send( + &faulting_app, + &format!("/v1/records/widgets/{VISIBLE_RECORD}?fields=label"), + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(faulted.status(), StatusCode::SERVICE_UNAVAILABLE); + let faulted_body = body_json(faulted).await; + assert_eq!(faulted_body["code"], "source.unavailable"); + assert!(!faulted_body.to_string().contains("label-001")); + assert_eq!( + audit_count(&database).await, + before_fault + 1, + "a terminal audit fault releases no protected data and commits only the prior attempt" + ); + + assert_read_audit_is_ordered_chained_and_minimized(&database, &profile, &compiled).await; + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { + let database = TestDatabase::create(8).await; + database + .admin + .execute("CREATE EXTENSION IF NOT EXISTS btree_gist", &[]) + .await + .expect("administrator installs btree_gist for temporal exclusion constraints"); + let (migration, migration_task) = database.connect_migration().await; + let compiled = Arc::new(compiled_registry()); + install_compiled_schema(&migration, &compiled, &database.runtime_role) + .await + .expect("migration installs the complete compiled PostgreSQL schema"); + let catalog = ExpectedManagedCatalog::compiled(&compiled); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision: "package-read-1", + package_sequence: 1, + }, + ) + .await + .expect("migration initializes durable Registry identity"); + migration_task.abort(); + + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let lock_key = RegistryLockKey::derive(PACKAGE_ID).expect("lock identity is bounded"); + seed_records(&database, &pool, lock_key, &identity, &compiled, true).await; + + let profile = AuditProfile::production_from_secret_bytes(vec![0x6b; 32].into()) + .expect("test owns a strongly keyed audit profile"); + let app = read_router( + pool.clone(), + compiled.clone(), + identity.clone(), + lock_key, + profile.clone(), + None, + ); + + assert_ids( + body_json( + send( + &app, + "/v1/records/assignments:as-of?fields=label&asOf=2020-05-31T23:59:59Z", + Some(read_claims(["zone-a"])), + ) + .await, + ) + .await, + &[TEMPORAL_OLD_RECORD], + ); + assert_ids( + body_json( + send( + &app, + "/v1/records/assignments:as-of?fields=label&asOf=2020-06-01T00:00:00Z", + Some(read_claims(["zone-a"])), + ) + .await, + ) + .await, + &[TEMPORAL_OPEN_RECORD], + ); + let current = send( + &app, + "/v1/records/assignments:current?fields=label,valid-from,valid-to", + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(current.status(), StatusCode::OK); + let current = body_json(current).await; + assert_ids(current.clone(), &[TEMPORAL_OPEN_RECORD]); + assert_eq!(current["items"][0]["data"]["label"], "lease-a"); + assert!(current["items"][0]["data"]["valid-from"].is_string()); + assert_eq!(current["items"][0]["data"]["valid-to"], Value::Null); + assert!(!current.to_string().contains(TEMPORAL_OTHER_BOUNDARY_RECORD)); + + let sorted_first = send( + &app, + "/v1/records/widgets?fields=label,rank&filter=label:prefix:sort-key-&sort=rank&pageSize=2", + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(sorted_first.status(), StatusCode::OK); + let sorted_first = body_json(sorted_first).await; + assert_ids( + sorted_first.clone(), + &[SORT_DUP_A_RECORD, SORT_DUP_B_RECORD], + ); + let sorted_second_cursor = sorted_first["pageInfo"]["nextCursor"] + .as_str() + .expect("duplicate sort page overfetches") + .to_owned(); + let sorted_second = send( + &app, + &format!("/v1/records/widgets?cursor={sorted_second_cursor}"), + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(sorted_second.status(), StatusCode::OK); + let sorted_second = body_json(sorted_second).await; + assert_ids( + sorted_second.clone(), + &[SORT_NEXT_RECORD, SORT_NULL_A_RECORD], + ); + assert_eq!(sorted_second["items"][1]["data"]["rank"], Value::Null); + let sorted_third_cursor = sorted_second["pageInfo"]["nextCursor"] + .as_str() + .expect("null sort page overfetches") + .to_owned(); + let sorted_third = send( + &app, + &format!("/v1/records/widgets?cursor={sorted_third_cursor}"), + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(sorted_third.status(), StatusCode::OK); + let sorted_third = body_json(sorted_third).await; + assert_ids(sorted_third.clone(), &[SORT_NULL_B_RECORD]); + assert!(sorted_third["pageInfo"]["nextCursor"].is_null()); + + let replay_cursor = next_cursor( + &app, + "/v1/records/widgets?fields=label,rank&filter=label:prefix:sort-key-&sort=rank&pageSize=2", + Some(read_claims(["zone-a"])), + ) + .await; + for (uri, claims) in [ + ( + format!("/v1/records/widgets?cursor={replay_cursor}"), + read_claims_with(PRINCIPAL_CANARY, "audit-review", ["zone-a"]), + ), + ( + format!("/v1/records/widgets?cursor={replay_cursor}"), + read_claims_with("other-principal-value", "case-management", ["zone-a"]), + ), + ( + format!("/v1/records/widgets?cursor={replay_cursor}"), + read_claims_with(PRINCIPAL_CANARY, "case-management", ["zone-b"]), + ), + ( + format!("/v1/records/widgets?accessProfile=auditor&cursor={replay_cursor}"), + read_claims(["zone-a"]), + ), + ( + format!("/v1/records/assignments?cursor={replay_cursor}"), + read_claims(["zone-a"]), + ), + ] { + assert_cursor_invalid(&app, &uri, Some(claims)).await; + } + + let package_changed_app = read_router_with_cursor_codec( + pool.clone(), + compiled.clone(), + identity.clone(), + lock_key, + profile.clone(), + None, + cursor_codec(), + Some(ReadRuntimeIdentity { + package_revision: "package-read-2".to_owned(), + schema_fingerprint: identity.schema_fingerprint.clone(), + }), + ); + assert_cursor_invalid( + &package_changed_app, + &format!("/v1/records/widgets?cursor={replay_cursor}"), + Some(read_claims(["zone-a"])), + ) + .await; + + let projection_cursor = next_cursor( + &app, + "/v1/records/widgets?fields=label,amount&filter=label:prefix:label-&sort=ordinal&pageSize=2", + Some(read_claims(["zone-a"])), + ) + .await; + let projection_changed_app = read_router_with_cursor_codec( + pool.clone(), + Arc::new(compiled_registry_without_amount_projection()), + identity.clone(), + lock_key, + profile.clone(), + None, + cursor_codec(), + None, + ); + assert_cursor_invalid( + &projection_changed_app, + &format!("/v1/records/widgets?cursor={projection_cursor}"), + Some(read_claims(["zone-a"])), + ) + .await; + + for changed_registry in [ + compiled_registry_without_label_filter(), + compiled_registry_without_rank_sort(), + ] { + let changed_app = read_router_with_cursor_codec( + pool.clone(), + Arc::new(changed_registry), + identity.clone(), + lock_key, + profile.clone(), + None, + cursor_codec(), + None, + ); + assert_cursor_invalid( + &changed_app, + &format!("/v1/records/widgets?cursor={replay_cursor}"), + Some(read_claims(["zone-a"])), + ) + .await; + } + + let expiring_app = read_router_with_cursor_codec( + pool, + compiled, + identity, + lock_key, + profile, + None, + immediately_expiring_cursor_codec(), + None, + ); + let expired_cursor = next_cursor( + &expiring_app, + "/v1/records/widgets?fields=label&sort=ordinal&pageSize=1", + Some(read_claims(["zone-a"])), + ) + .await; + assert_cursor_invalid( + &expiring_app, + &format!("/v1/records/widgets?cursor={expired_cursor}"), + Some(read_claims(["zone-a"])), + ) + .await; + + database.cleanup().await; +} + +fn read_router( + pool: registry_server::postgres::RuntimePool, + registry: Arc, + identity: registry_server::postgres::ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + profile: AuditProfile, + fault: Option, +) -> axum::Router { + read_router_with_cursor_codec( + pool, + registry, + identity, + lock_key, + profile, + fault, + cursor_codec(), + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn read_router_with_cursor_codec( + pool: registry_server::postgres::RuntimePool, + registry: Arc, + identity: registry_server::postgres::ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + profile: AuditProfile, + fault: Option, + cursors: Arc, + http_identity: Option, +) -> axum::Router { + let read_identity = http_identity.unwrap_or_else(|| ReadRuntimeIdentity { + package_revision: identity.package_revision.clone(), + schema_fingerprint: identity.schema_fingerprint.clone(), + }); + let records = PostgresRecordReadService::new( + pool, + registry.clone(), + identity, + lock_key, + Duration::from_secs(2), + profile, + cursors.clone(), + ); + let records = match fault { + Some(fault) => records.with_fault_for_test(fault), + None => records, + }; + router(Arc::new(HttpService::new( + registry, + read_identity, + Arc::new(records), + Arc::new(AlwaysReady), + cursors, + ))) +} + +fn cursor_codec() -> Arc { + Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x44; 32]), Duration::from_secs(300)) + .expect("test cursor key is valid"), + ) +} + +fn immediately_expiring_cursor_codec() -> Arc { + Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x45; 32]), Duration::from_nanos(1)) + .expect("subsecond max age creates deterministic expired test cursors"), + ) +} + +struct AlwaysReady; + +impl ReadinessProbe for AlwaysReady { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async { true }) + } +} + +async fn send( + app: &axum::Router, + uri: &str, + claims: Option, +) -> Response { + let mut request = Request::builder() + .method(Method::GET) + .uri(uri) + .body(Body::empty()) + .expect("request builds"); + if let Some(claims) = claims { + request.extensions_mut().insert(claims); + } + let mut app = app.clone(); + app.call(request).await.expect("router returns a response") +} + +async fn body_json(response: Response) -> Value { + let bytes = body_bytes(response).await; + json_from_bytes(&bytes) +} + +async fn body_bytes(response: Response) -> Vec { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response body is bounded"); + bytes.to_vec() +} + +fn json_from_bytes(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).expect("response is JSON") +} + +async fn next_cursor( + app: &axum::Router, + uri: &str, + claims: Option, +) -> String { + let response = send(app, uri, claims).await; + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + body["pageInfo"]["nextCursor"] + .as_str() + .expect("response carries a continuation cursor") + .to_owned() +} + +async fn assert_cursor_invalid( + app: &axum::Router, + uri: &str, + claims: Option, +) { + let response = send(app, uri, claims).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_json(response).await; + assert_eq!(body["code"], "query.cursor_invalid"); + let text = body.to_string(); + for canary in [ + PRINCIPAL_CANARY, + SECRET_CANARY, + "other-principal-value", + "audit-review", + "zone-a", + "zone-b", + "sort-key", + "package-read-2", + ] { + assert!(!text.contains(canary)); + } +} + +fn assert_ids(body: Value, expected: &[&str]) { + let ids = body["items"] + .as_array() + .expect("response carries items") + .iter() + .map(|item| item["id"].as_str().expect("item id")) + .collect::>(); + assert_eq!(ids, expected); +} + +async fn seed_records( + database: &TestDatabase, + pool: ®istry_server::postgres::RuntimePool, + lock_key: RegistryLockKey, + identity: ®istry_server::postgres::ExpectedRegistryIdentity, + registry: ®istry_server::CompiledRegistry, + include_edge_rows: bool, +) { + let mut client = pool + .get_for_test() + .await + .expect("runtime connection is available"); + for jurisdiction in ["zone-a", "zone-b"] { + let claims = seed_claims(registry, jurisdiction); + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(2), + identity, + &claims, + ) + .await + .expect("seed transaction installs RLS-safe context"); + if jurisdiction == "zone-a" { + for index in (0..101).rev() { + insert_seed_row( + transaction.transaction_for_test(), + registry, + SeedRow { + record_id: &format!("00000000-0000-4000-8000-{index:012}"), + jurisdiction, + label: &format!("label-{index:03}"), + secret: &format!("{SECRET_CANARY}-{index:03}"), + amount: if index == 1 { "1.20" } else { "2.00" }, + ordinal: index, + rank: Some(index), + }, + ) + .await; + } + insert_seed_row( + transaction.transaction_for_test(), + registry, + SeedRow { + record_id: TOMBSTONED_RECORD, + jurisdiction, + label: "tombstoned-label", + secret: &format!("{SECRET_CANARY}-tombstoned"), + amount: "3.00", + ordinal: 2000, + rank: Some(2000), + }, + ) + .await; + insert_seed_row( + transaction.transaction_for_test(), + registry, + SeedRow { + record_id: ALPHA_RECORD, + jurisdiction, + label: "alpha-label", + secret: &format!("{SECRET_CANARY}-alpha"), + amount: "4.00", + ordinal: 2001, + rank: Some(2001), + }, + ) + .await; + insert_seed_row( + transaction.transaction_for_test(), + registry, + SeedRow { + record_id: WILDCARD_RECORD, + jurisdiction, + label: "literal%_\\value", + secret: &format!("{SECRET_CANARY}-wildcard"), + amount: "6.00", + ordinal: 2002, + rank: Some(2002), + }, + ) + .await; + if include_edge_rows { + for row in [ + SeedRow { + record_id: SORT_DUP_A_RECORD, + jurisdiction, + label: "sort-key-duplicate-a", + secret: &format!("{SECRET_CANARY}-sort-a"), + amount: "7.00", + ordinal: 3001, + rank: Some(7), + }, + SeedRow { + record_id: SORT_DUP_B_RECORD, + jurisdiction, + label: "sort-key-duplicate-b", + secret: &format!("{SECRET_CANARY}-sort-b"), + amount: "7.00", + ordinal: 3002, + rank: Some(7), + }, + SeedRow { + record_id: SORT_NEXT_RECORD, + jurisdiction, + label: "sort-key-next", + secret: &format!("{SECRET_CANARY}-sort-next"), + amount: "8.00", + ordinal: 3003, + rank: Some(8), + }, + SeedRow { + record_id: SORT_NULL_A_RECORD, + jurisdiction, + label: "sort-key-null-a", + secret: &format!("{SECRET_CANARY}-sort-null-a"), + amount: "9.00", + ordinal: 3004, + rank: None, + }, + SeedRow { + record_id: SORT_NULL_B_RECORD, + jurisdiction, + label: "sort-key-null-b", + secret: &format!("{SECRET_CANARY}-sort-null-b"), + amount: "9.00", + ordinal: 3005, + rank: None, + }, + ] { + insert_seed_row(transaction.transaction_for_test(), registry, row).await; + } + } + } else { + insert_seed_row( + transaction.transaction_for_test(), + registry, + SeedRow { + record_id: MISMATCH_RECORD, + jurisdiction, + label: "zone-b-label", + secret: &format!("{SECRET_CANARY}-zone-b"), + amount: "5.00", + ordinal: 1000, + rank: Some(1000), + }, + ) + .await; + } + transaction + .commit() + .await + .expect("seed transaction commits through the guarded context"); + if include_edge_rows { + let claims = seed_claims_for(registry, "assignment", jurisdiction); + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(2), + identity, + &claims, + ) + .await + .expect("assignment seed transaction installs RLS-safe context"); + if jurisdiction == "zone-a" { + for row in [ + AssignmentRow { + record_id: TEMPORAL_OLD_RECORD, + jurisdiction, + label: "lease-a", + valid_from: "2020-01-01T00:00:00Z", + valid_to: Some("2020-06-01T00:00:00Z"), + }, + AssignmentRow { + record_id: TEMPORAL_OPEN_RECORD, + jurisdiction, + label: "lease-a", + valid_from: "2020-06-01T00:00:00Z", + valid_to: None, + }, + ] { + insert_assignment_row(transaction.transaction_for_test(), registry, row).await; + } + } + transaction + .commit() + .await + .expect("assignment seed transaction commits through the guarded context"); + } + } + tombstone_seed_row(database, registry, TOMBSTONED_RECORD).await; +} + +async fn insert_seed_row( + transaction: &Transaction<'_>, + registry: ®istry_server::CompiledRegistry, + row: SeedRow<'_>, +) { + let entity = ®istry.entities()["widget"]; + let table = quote_identifier(&entity.physical_table); + let jurisdiction = quote_identifier(&entity.fields["jurisdiction"].physical_name); + let label = quote_identifier(&entity.fields["label"].physical_name); + let secret = quote_identifier(&entity.fields["secret"].physical_name); + let amount = quote_identifier(&entity.fields["amount"].physical_name); + let ordinal = quote_identifier(&entity.fields["ordinal"].physical_name); + let rank = quote_identifier(&entity.fields["rank"].physical_name); + transaction + .execute( + &format!( + "INSERT INTO registry_data.{table} + (record_id, record_revision, record_lifecycle, + {jurisdiction}, {label}, {secret}, {amount}, {ordinal}, {rank}) + VALUES ($1::text::uuid, 1, 'active', $2, $3, $4, $5::text::numeric, $6, $7::bigint)" + ), + &[ + &row.record_id, + &row.jurisdiction, + &row.label, + &row.secret, + &row.amount, + &row.ordinal, + &row.rank, + ], + ) + .await + .expect("RLS-safe seed row is accepted"); +} + +struct SeedRow<'a> { + record_id: &'a str, + jurisdiction: &'a str, + label: &'a str, + secret: &'a str, + amount: &'a str, + ordinal: i64, + rank: Option, +} + +async fn insert_assignment_row( + transaction: &Transaction<'_>, + registry: ®istry_server::CompiledRegistry, + row: AssignmentRow<'_>, +) { + let entity = ®istry.entities()["assignment"]; + let table = quote_identifier(&entity.physical_table); + let jurisdiction = quote_identifier(&entity.fields["jurisdiction"].physical_name); + let label = quote_identifier(&entity.fields["label"].physical_name); + let valid_from = quote_identifier(&entity.fields["valid-from"].physical_name); + let valid_to = quote_identifier(&entity.fields["valid-to"].physical_name); + transaction + .execute( + &format!( + "INSERT INTO registry_data.{table} + (record_id, record_revision, record_lifecycle, + {jurisdiction}, {label}, {valid_from}, {valid_to}) + VALUES ($1::text::uuid, 1, 'active', $2, $3, $4::text::timestamptz, $5::text::timestamptz)" + ), + &[ + &row.record_id, + &row.jurisdiction, + &row.label, + &row.valid_from, + &row.valid_to, + ], + ) + .await + .expect("RLS-safe assignment seed row is accepted"); +} + +struct AssignmentRow<'a> { + record_id: &'a str, + jurisdiction: &'a str, + label: &'a str, + valid_from: &'a str, + valid_to: Option<&'a str>, +} + +async fn tombstone_seed_row( + database: &TestDatabase, + registry: ®istry_server::CompiledRegistry, + record_id: &str, +) { + let table = quote_identifier(®istry.entities()["widget"].physical_table); + database + .admin + .execute( + &format!( + "UPDATE registry_data.{table} + SET record_lifecycle = 'tombstoned', + record_revision = record_revision + 1 + WHERE record_id = $1::text::uuid" + ), + &[&record_id], + ) + .await + .expect("RLS-safe tombstone seed update is accepted"); +} + +fn seed_claims(registry: ®istry_server::CompiledRegistry, jurisdiction: &str) -> ClaimContext { + seed_claims_for(registry, "widget", jurisdiction) +} + +fn seed_claims_for( + registry: ®istry_server::CompiledRegistry, + entity_id: &str, + jurisdiction: &str, +) -> ClaimContext { + ClaimContext::for_compiled( + registry, + entity_id, + Some(PRINCIPAL_CANARY.to_owned()), + "operator", + Some("case-management".to_owned()), + vec![RowBoundaryContext::In { + field: "jurisdiction".to_owned(), + values: BTreeSet::from([jurisdiction.to_owned()]), + }], + ) + .expect("seed claims are compiler-bound") +} + +fn read_claims(jurisdictions: [&str; N]) -> VerifiedRequestClaims { + read_claims_with(PRINCIPAL_CANARY, "case-management", jurisdictions) +} + +fn read_claims_with( + principal: &str, + purpose: &str, + jurisdictions: [&str; N], +) -> VerifiedRequestClaims { + VerifiedRequestClaims::authenticated( + "registry_principal", + principal, + BTreeSet::from(["registry.read".to_owned()]), + Some(purpose.to_owned()), + BTreeMap::from([( + "jurisdictions".to_owned(), + VerifiedClaimValue::direct_string_set(jurisdictions) + .expect("jurisdictions are direct verified strings"), + )]), + ) + .expect("read claims are verified") +} + +async fn audit_count(database: &TestDatabase) -> i64 { + database + .admin + .query_one("SELECT count(*) FROM registry_internal.registry_audit", &[]) + .await + .expect("administrator can inspect audit count") + .get(0) +} + +async fn assert_read_audit_is_ordered_chained_and_minimized( + database: &TestDatabase, + profile: &AuditProfile, + registry: ®istry_server::CompiledRegistry, +) { + let envelopes = ordered_audit_envelopes(database, profile).await; + let records = envelopes + .iter() + .map(|envelope| envelope.record.clone()) + .collect::>(); + let phases = records + .iter() + .map(|record| { + ( + record["phase"].as_str().expect("phase is recorded"), + record["outcome"].as_str(), + ) + }) + .collect::>(); + assert_eq!( + phases, + vec![ + ("attempt", None), + ("terminal", Some("returned")), + ("attempt", None), + ("terminal", Some("returned")), + ("attempt", None), + ("terminal", Some("returned")), + ("attempt", None), + ("terminal", Some("returned")), + ("attempt", None), + ("terminal", Some("returned")), + ("attempt", None), + ("terminal", Some("returned")), + ("refusal", None), + ("attempt", None), + ("terminal", Some("empty")), + ("attempt", None), + ("terminal", Some("empty")), + ("refusal", None), + ("attempt", None), + ], + "durable read audit records bracket release in order" + ); + assert_eq!(records[1]["resultCount"], 1); + assert_eq!(records[3]["resultCount"], 1); + assert_eq!(records[5]["resultCount"], 100); + assert_eq!(records[7]["resultCount"], 2); + assert_eq!(records[9]["resultCount"], 1); + assert_eq!(records[11]["resultCount"], 3); + assert_eq!(records[14]["resultCount"], 0); + assert_eq!(records[16]["resultCount"], 0); + assert!(records[1].get("fieldSetReference").is_some()); + assert!(records[3].get("fieldSetReference").is_some()); + assert!(records[5].get("fieldSetReference").is_some()); + assert!(records[5].get("queryReference").is_some()); + assert!(records[5].get("rowBoundaryReference").is_some()); + + let audit_text = records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"); + assert!(!audit_text.contains(PRINCIPAL_CANARY)); + assert!(!audit_text.contains(SECRET_CANARY)); + assert!(!audit_text.contains(VISIBLE_RECORD)); + assert!(!audit_text.contains(ALPHA_RECORD)); + assert!(!audit_text.contains(WILDCARD_RECORD)); + assert!(!audit_text.contains(MISMATCH_RECORD)); + assert!(!audit_text.contains(TOMBSTONED_RECORD)); + for field in [ + "label", + "amount", + "secret", + "jurisdiction", + "ordinal", + "zone-a", + "zone-b", + ] { + assert!(!audit_text.contains(field)); + } + let entity = ®istry.entities()["widget"]; + assert!(!audit_text.contains(&entity.physical_table)); + for field in entity.fields.values() { + assert!(!audit_text.contains(&field.physical_name)); + } + assert!(audit_text.contains("principalReference")); + assert!(audit_text.contains("recordReference")); +} + +async fn ordered_audit_envelopes( + database: &TestDatabase, + profile: &AuditProfile, +) -> Vec { + let rows = database + .admin + .query("SELECT envelope FROM registry_internal.registry_audit", &[]) + .await + .expect("administrator can inspect audit envelopes"); + let mut envelopes = rows + .iter() + .map(|row| { + serde_json::from_slice::(&row.get::<_, Vec>(0)) + .expect("audit envelope is canonical platform JSON") + }) + .collect::>(); + let mut ordered = Vec::with_capacity(envelopes.len()); + let mut predecessor = None; + while !envelopes.is_empty() { + let position = envelopes + .iter() + .position(|envelope| envelope.prev_hash == predecessor) + .expect("database audit chain has one next envelope"); + let envelope = envelopes.remove(position); + predecessor = Some(envelope.record_hash); + ordered.push(envelope); + } + let audit_lines = ordered + .iter() + .map(|envelope| serde_json::to_string(envelope).expect("audit envelope serializes")) + .collect::>(); + verify_jsonl_lines_with_hasher(audit_lines.iter(), &profile.chain_hasher()) + .expect("database audit envelopes form one keyed platform chain"); + ordered +} + +fn quote_identifier(value: &str) -> String { + format!("\"{value}\"") +} + +fn compiled_registry() -> registry_server::CompiledRegistry { + compile_registry_source(®istry_source()) +} + +fn compiled_registry_without_amount_projection() -> registry_server::CompiledRegistry { + let source = registry_source() + .replace( + r#""readableFields":["label","secret","amount","jurisdiction","ordinal","rank"]"#, + r#""readableFields":["label","secret","jurisdiction","ordinal","rank"]"#, + ) + .replace( + r#""writableFields":["label","secret","amount","jurisdiction","ordinal","rank"]"#, + r#""writableFields":["label","secret","jurisdiction","ordinal","rank"]"#, + ); + compile_registry_source(&source) +} + +fn compiled_registry_without_label_filter() -> registry_server::CompiledRegistry { + compile_registry_source(®istry_source().replace( + r#""filterableFields":["jurisdiction","label","ordinal","rank"]"#, + r#""filterableFields":["jurisdiction","ordinal","rank"]"#, + )) +} + +fn compiled_registry_without_rank_sort() -> registry_server::CompiledRegistry { + compile_registry_source(®istry_source().replace( + r#""sortableFields":["ordinal","label","rank"]"#, + r#""sortableFields":["ordinal","label"]"#, + )) +} + +fn compile_registry_source(source: &str) -> registry_server::CompiledRegistry { + let project = parse_project_json(source.as_bytes()).expect("read fixture parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("read fixture compiles to trusted inventories") +} + +fn registry_source() -> String { + r#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"read-registry","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"widget", + "route":"widgets", + "mutationMode":"mutable", + "tombstone":true, + "classification":"restricted", + "fields":[ + {"id":"jurisdiction","type":"string","required":true,"maxLength":32,"classification":"internal"}, + {"id":"label","type":"string","required":true,"maxLength":100,"classification":"internal"}, + {"id":"secret","type":"string","required":true,"maxLength":100,"classification":"restricted"}, + {"id":"amount","type":"decimal","required":true,"precision":8,"scale":2,"classification":"internal"}, + {"id":"ordinal","type":"int64","required":true,"classification":"internal"}, + {"id":"rank","type":"int64","required":false,"classification":"internal"} + ], + "accessProfiles":[{ + "id":"operator", + "default":true, + "principalClaim":"registry_principal", + "requiredScopes":["registry.read"], + "requiredPurposes":["case-management","audit-review"], + "operations":["create","get","list","tombstone"], + "readableFields":["label","secret","amount","jurisdiction","ordinal","rank"], + "writableFields":["label","secret","amount","jurisdiction","ordinal","rank"], + "filterableFields":["jurisdiction","label","ordinal","rank"], + "sortableFields":["ordinal","label","rank"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] + },{ + "id":"auditor", + "principalClaim":"registry_principal", + "requiredScopes":["registry.read"], + "requiredPurposes":["case-management"], + "operations":["get","list"], + "readableFields":["label","jurisdiction"], + "filterableFields":["label"], + "sortableFields":["label"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] + }] + },{ + "id":"assignment", + "route":"assignments", + "mutationMode":"mutable", + "tombstone":true, + "classification":"restricted", + "fields":[ + {"id":"jurisdiction","type":"string","required":true,"maxLength":32,"classification":"internal"}, + {"id":"label","type":"string","required":true,"maxLength":100,"classification":"internal"}, + {"id":"valid-from","type":"timestamp","required":true,"classification":"internal"}, + {"id":"valid-to","type":"timestamp","required":false,"classification":"internal"} + ], + "temporal":{ + "startField":"valid-from", + "endField":"valid-to", + "scopeFields":["label"] + }, + "constraints":[{ + "kind":"temporal-non-overlap", + "scopeFields":["label"], + "startField":"valid-from", + "endField":"valid-to" + }], + "accessProfiles":[{ + "id":"operator", + "default":true, + "principalClaim":"registry_principal", + "requiredScopes":["registry.read"], + "requiredPurposes":["case-management","audit-review"], + "operations":["create","get","list"], + "readableFields":["label","jurisdiction","valid-from","valid-to"], + "writableFields":["label","jurisdiction","valid-from","valid-to"], + "filterableFields":["label","jurisdiction"], + "sortableFields":["label"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] + }] + }] + }"# + .to_owned() +} diff --git a/crates/registry-server/tests/postgres_revision_http.rs b/crates/registry-server/tests/postgres_revision_http.rs new file mode 100644 index 0000000000..6981fecc8a --- /dev/null +++ b/crates/registry-server/tests/postgres_revision_http.rs @@ -0,0 +1,585 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::{to_bytes, Body}; +use axum::http::{Method, Request, Response, StatusCode}; +use postgres_harness::TestDatabase; +use registry_platform_audit::{AuditEnvelope, AuditProfile}; +use registry_platform_canonical_json::canonicalize_json; +use registry_server::api::{ + router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, VerifiedClaimValue, + VerifiedRequestClaims, +}; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::cursor::CursorCodec; +use registry_server::postgres::{ + initialize_registry_state_for_catalog_test, install_compiled_schema, ExpectedManagedCatalog, + PostgresRecordReadService, PostgresRevisionReadService, RegistryLockKey, + RegistryStateTestIdentity, RevisionReadFaultPoint, +}; +use serde_json::{json, Value}; +use tower::Service as _; +use uuid::Uuid; +use zeroize::Zeroizing; + +const PACKAGE_ID: &str = "revision-http-registry"; +const INSTANCE_ID: &str = "revision-http-instance"; +const DATABASE_ID: &str = "revision-http-database"; +const PACKAGE_REVISION: &str = "package-revision-http-1"; +const PRINCIPAL_CANARY: &str = "principal-raw-must-not-enter-revision-audit"; +const SECRET_CANARY: &str = "snapshot-secret-must-not-leave-projection"; +const ACTOR_REFERENCE: &str = + "hmac-sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const REQUEST_REFERENCE: &str = + "hmac-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const RECORD_ID: &str = "00000000-0000-4000-8000-000000000001"; +const HIDDEN_RECORD_ID: &str = "00000000-0000-4000-8000-000000000002"; +const BOUNDED_RECORD_ID: &str = "00000000-0000-4000-8000-000000000003"; +const MALFORMED_RECORD_ID: &str = "00000000-0000-4000-8000-000000000004"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_revision_http_is_bounded_authorized_atomic_and_audit_gated() { + let database = TestDatabase::create(8).await; + let (migration, migration_task) = database.connect_migration().await; + let registry = Arc::new(compiled_registry()); + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .expect("migration installs the complete compiled PostgreSQL schema"); + let catalog = ExpectedManagedCatalog::compiled(®istry); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision: PACKAGE_REVISION, + package_sequence: 1, + }, + ) + .await + .expect("migration initializes durable Registry identity"); + seed_revision_history(&migration).await; + migration_task.abort(); + + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let lock_key = RegistryLockKey::derive(PACKAGE_ID).expect("lock identity is bounded"); + let audit_profile = AuditProfile::production_from_secret_bytes(vec![0x5d; 32].into()) + .expect("test owns a strongly keyed audit profile"); + let app = revision_router( + pool.clone(), + Arc::clone(®istry), + identity.clone(), + lock_key, + audit_profile.clone(), + None, + ); + + let list = send( + &app, + &format!("/v1/records/widgets/{RECORD_ID}/revisions"), + Some(history_claims("case-review", ["zone-a"])), + ) + .await; + assert_eq!(list.status(), StatusCode::OK); + assert_eq!(list.headers()["cache-control"], "no-store"); + let list_bytes = body_bytes(list).await; + let list = json_from_bytes(&list_bytes); + let items = list["items"].as_array().expect("list returns items"); + assert_eq!(items.len(), 3); + assert_eq!( + items + .iter() + .map(|item| item["revision"].as_u64().expect("revision")) + .collect::>(), + [3, 2, 1] + ); + assert_eq!(items[0]["lifecycle"], "tombstoned"); + assert_eq!(items[0]["mutationKind"], "tombstone"); + assert_eq!(items[1]["mutationKind"], "patch"); + assert_eq!(items[2]["mutationKind"], "create"); + assert_eq!(items[0]["predecessorRevision"], 2); + assert_eq!(items[2]["predecessorRevision"], Value::Null); + assert_eq!(items[0]["actorReference"], ACTOR_REFERENCE); + assert_eq!(items[0]["requestReference"], REQUEST_REFERENCE); + assert_eq!(items[0]["data"], json!({"label": "tombstoned"})); + assert!(!String::from_utf8(list_bytes) + .expect("response is UTF-8") + .contains(SECRET_CANARY)); + + let tombstoned_detail = send( + &app, + &format!("/v1/records/widgets/{RECORD_ID}/revisions/3"), + Some(history_claims("case-review", ["zone-a"])), + ) + .await; + assert_eq!(tombstoned_detail.status(), StatusCode::OK); + let detail = body_json(tombstoned_detail).await; + assert_eq!(detail["revision"], 3); + assert_eq!(detail["lifecycle"], "tombstoned"); + + let bounded = send( + &app, + &format!("/v1/records/widgets/{BOUNDED_RECORD_ID}/revisions"), + Some(history_claims("case-review", ["zone-a"])), + ) + .await; + assert_eq!(bounded.status(), StatusCode::OK); + let bounded = body_json(bounded).await; + let bounded = bounded["items"].as_array().expect("bounded items"); + assert_eq!(bounded.len(), 100); + assert_eq!(bounded.first().expect("newest")["revision"], 101); + assert_eq!(bounded.last().expect("oldest retained")["revision"], 2); + + for uri in [ + format!("/v1/records/widgets/{HIDDEN_RECORD_ID}/revisions"), + "/v1/records/widgets/00000000-0000-4000-8000-000000000099/revisions".to_owned(), + format!("/v1/records/widgets/{RECORD_ID}/revisions/99"), + "/v1/records/widgets/not-a-uuid/revisions".to_owned(), + format!("/v1/records/widgets/{RECORD_ID}/revisions/01"), + format!("/v1/records/widgets/{RECORD_ID}/revisions/0"), + ] { + let response = send(&app, &uri, Some(history_claims("case-review", ["zone-a"]))).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); + assert_eq!(body_json(response).await["code"], "resource.not_found"); + } + + let anonymous = send( + &app, + &format!("/v1/records/widgets/{RECORD_ID}/revisions"), + None, + ) + .await; + assert_eq!(anonymous.status(), StatusCode::NOT_FOUND); + let wrong_purpose = send( + &app, + &format!("/v1/records/widgets/{RECORD_ID}/revisions?accessProfile=operator"), + Some(history_claims("other-purpose", ["zone-a"])), + ) + .await; + assert_eq!(wrong_purpose.status(), StatusCode::NOT_FOUND); + let unknown_profile = send( + &app, + &format!("/v1/records/widgets/{RECORD_ID}/revisions?accessProfile=unknown"), + Some(history_claims("case-review", ["zone-a"])), + ) + .await; + assert_eq!(unknown_profile.status(), StatusCode::NOT_FOUND); + let extra_query = send( + &app, + &format!("/v1/records/widgets/{RECORD_ID}/revisions?pageSize=1"), + Some(history_claims("case-review", ["zone-a"])), + ) + .await; + assert_eq!(extra_query.status(), StatusCode::BAD_REQUEST); + assert_eq!(body_json(extra_query).await["code"], "query.invalid"); + + let malformed = send( + &app, + &format!("/v1/records/widgets/{MALFORMED_RECORD_ID}/revisions"), + Some(history_claims("case-review", ["zone-a"])), + ) + .await; + assert_eq!(malformed.status(), StatusCode::SERVICE_UNAVAILABLE); + let malformed = body_json(malformed).await; + assert_eq!(malformed["code"], "source.unavailable"); + assert!(!malformed.to_string().contains("wrong-type")); + assert!(!malformed + .to_string() + .contains("valid-row-must-not-be-released")); + + let before_fault = audit_count(&database).await; + let faulting = revision_router( + pool.clone(), + Arc::clone(®istry), + identity.clone(), + lock_key, + audit_profile.clone(), + Some(RevisionReadFaultPoint::BeforeTerminalAudit), + ); + let faulted = send( + &faulting, + &format!("/v1/records/widgets/{RECORD_ID}/revisions/2"), + Some(history_claims("case-review", ["zone-a"])), + ) + .await; + assert_eq!(faulted.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_json(faulted).await["code"], "source.unavailable"); + assert_eq!( + audit_count(&database).await, + before_fault + 1, + "terminal audit gate failure releases no held revision and leaves only the attempt" + ); + + let unkeyed = revision_router( + pool, + Arc::clone(®istry), + identity, + lock_key, + AuditProfile::unkeyed_dev_only(), + None, + ); + let before_unkeyed = audit_count(&database).await; + let refused_audit = send( + &unkeyed, + &format!("/v1/records/widgets/{RECORD_ID}/revisions/2"), + Some(history_claims("case-review", ["zone-a"])), + ) + .await; + assert_eq!(refused_audit.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(audit_count(&database).await, before_unkeyed); + + assert_revision_audit_is_ordered_and_minimized(&database, ®istry).await; + database.cleanup().await; +} + +#[allow(clippy::too_many_arguments)] +fn revision_router( + pool: registry_server::postgres::RuntimePool, + registry: Arc, + identity: registry_server::postgres::ExpectedRegistryIdentity, + lock_key: RegistryLockKey, + audit_profile: AuditProfile, + fault: Option, +) -> axum::Router { + let cursors = Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x3a; 32]), Duration::from_secs(300)) + .expect("cursor key is valid"), + ); + let records = Arc::new(PostgresRecordReadService::new( + pool.clone(), + Arc::clone(®istry), + identity.clone(), + lock_key, + Duration::from_secs(2), + audit_profile.clone(), + Arc::clone(&cursors), + )); + let revisions = PostgresRevisionReadService::new( + pool, + Arc::clone(®istry), + identity.clone(), + lock_key, + Duration::from_secs(2), + audit_profile, + ); + let revisions = match fault { + Some(fault) => revisions.with_fault_for_test(fault), + None => revisions, + }; + router(Arc::new( + HttpService::new( + registry, + ReadRuntimeIdentity { + package_revision: identity.package_revision, + schema_fingerprint: identity.schema_fingerprint, + }, + records, + Arc::new(AlwaysReady), + cursors, + ) + .with_postgres_revisions(Arc::new(revisions)), + )) +} + +struct AlwaysReady; + +impl ReadinessProbe for AlwaysReady { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async { true }) + } +} + +async fn send( + app: &axum::Router, + uri: &str, + claims: Option, +) -> Response { + let mut request = Request::builder() + .method(Method::GET) + .uri(uri) + .body(Body::empty()) + .expect("request builds"); + if let Some(claims) = claims { + request.extensions_mut().insert(claims); + } + let mut app = app.clone(); + app.call(request).await.expect("router returns a response") +} + +fn history_claims( + purpose: &str, + jurisdictions: [&str; N], +) -> VerifiedRequestClaims { + VerifiedRequestClaims::authenticated( + "registry_principal", + PRINCIPAL_CANARY, + BTreeSet::from(["history.read".to_owned()]), + Some(purpose.to_owned()), + BTreeMap::from([( + "jurisdictions".to_owned(), + VerifiedClaimValue::direct_string_set(jurisdictions) + .expect("row authority is a verified direct string set"), + )]), + ) + .expect("claims are verified") +} + +async fn seed_revision_history(migration: &tokio_postgres::Client) { + for (revision, predecessor, lifecycle, mutation, label) in [ + (1_i64, None, "active", "create", "created"), + (2, Some(1), "active", "patch", "patched"), + (3, Some(2), "tombstoned", "tombstone", "tombstoned"), + ] { + insert_revision( + migration, + RECORD_ID, + revision, + predecessor, + lifecycle, + mutation, + json!({ + "jurisdiction": "zone-a", + "label": label, + "secret": SECRET_CANARY, + }), + ) + .await; + } + insert_revision( + migration, + HIDDEN_RECORD_ID, + 1, + None, + "tombstoned", + "tombstone", + json!({ + "jurisdiction": "zone-b", + "label": "hidden-row", + "secret": SECRET_CANARY, + }), + ) + .await; + for revision in 1_i64..=101 { + insert_revision( + migration, + BOUNDED_RECORD_ID, + revision, + (revision > 1).then_some(revision - 1), + "active", + if revision == 1 { "create" } else { "patch" }, + json!({ + "jurisdiction": "zone-a", + "label": format!("bounded-{revision}"), + "secret": SECRET_CANARY, + }), + ) + .await; + } + insert_revision( + migration, + MALFORMED_RECORD_ID, + 1, + None, + "active", + "create", + json!({ + "jurisdiction": "zone-a", + "label": "valid-row-must-not-be-released", + "secret": SECRET_CANARY, + }), + ) + .await; + insert_revision( + migration, + MALFORMED_RECORD_ID, + 2, + Some(1), + "active", + "patch", + json!({ + "jurisdiction": "zone-a", + "label": 42, + "secret": "wrong-type", + }), + ) + .await; +} + +#[allow(clippy::too_many_arguments)] +async fn insert_revision( + migration: &tokio_postgres::Client, + record_id: &str, + revision: i64, + predecessor: Option, + lifecycle: &str, + mutation: &str, + snapshot: Value, +) { + let snapshot = canonicalize_json(&snapshot).expect("fixture snapshot canonicalizes"); + let record_id = Uuid::parse_str(record_id).expect("fixture UUID is valid"); + migration + .execute( + "INSERT INTO registry_internal.registry_revisions + (entity_id, record_id, record_reference, record_revision, + predecessor_revision, record_lifecycle, package_revision, operation_id, + mutation_kind, principal_reference, request_reference, snapshot) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + &[ + &"widget", + &record_id, + &"hmac-sha256:record-reference", + &revision, + &predecessor, + &lifecycle, + &PACKAGE_REVISION, + &format!("records.widget.{mutation}"), + &mutation, + &ACTOR_REFERENCE, + &REQUEST_REFERENCE, + &snapshot, + ], + ) + .await + .expect("migration seeds one canonical revision row"); +} + +async fn audit_count(database: &TestDatabase) -> i64 { + database + .admin + .query_one("SELECT count(*) FROM registry_internal.registry_audit", &[]) + .await + .expect("administrator inspects audit count") + .get(0) +} + +async fn assert_revision_audit_is_ordered_and_minimized( + database: &TestDatabase, + registry: ®istry_server::CompiledRegistry, +) { + let rows = database + .admin + .query("SELECT envelope FROM registry_internal.registry_audit", &[]) + .await + .expect("administrator inspects audit envelopes"); + let mut envelopes = rows + .iter() + .map(|row| { + serde_json::from_slice::(&row.get::<_, Vec>(0)) + .expect("audit envelope is canonical platform JSON") + }) + .collect::>(); + let mut records = Vec::with_capacity(envelopes.len()); + let mut predecessor = None; + while !envelopes.is_empty() { + let index = envelopes + .iter() + .position(|envelope| envelope.prev_hash == predecessor) + .expect("audit chain has one next record"); + let envelope = envelopes.remove(index); + predecessor = Some(envelope.record_hash); + records.push(envelope.record); + } + assert!(records.windows(2).any(|window| { + window[0]["phase"] == "attempt" + && window[1]["phase"] == "terminal" + && window[1]["outcome"] == "returned" + })); + assert_eq!(records.last().expect("fault attempt")["phase"], "attempt"); + assert!(records.iter().any(|record| record["phase"] == "refusal")); + assert!(records.iter().any(|record| { + record["phase"] == "terminal" + && record["outcome"] == "returned" + && record["resultCount"] == 100 + })); + for record in &records { + assert!(record.get("recordRevision").is_none()); + } + let audit_text = records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"); + for forbidden in [ + PRINCIPAL_CANARY, + SECRET_CANARY, + ACTOR_REFERENCE, + REQUEST_REFERENCE, + RECORD_ID, + HIDDEN_RECORD_ID, + BOUNDED_RECORD_ID, + MALFORMED_RECORD_ID, + "zone-a", + "zone-b", + "tombstoned", + "wrong-type", + "SELECT", + "registry_revisions", + ®istry.entities()["widget"].physical_table, + ] { + assert!(!audit_text.contains(forbidden), "audit leaked {forbidden}"); + } + assert!(audit_text.contains("principalReference")); + assert!(audit_text.contains("recordReference")); + assert!(audit_text.contains("fieldSetReference")); + assert!(audit_text.contains("rowBoundaryReference")); +} + +async fn body_json(response: Response) -> Value { + json_from_bytes(&body_bytes(response).await) +} + +async fn body_bytes(response: Response) -> Vec { + to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .expect("response body is bounded") + .to_vec() +} + +fn json_from_bytes(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).expect("response is JSON") +} + +fn compiled_registry() -> registry_server::CompiledRegistry { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"revision-http-registry","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"widget","route":"widgets","mutationMode":"mutable","tombstone":true, + "classification":"restricted", + "fields":[ + {"id":"jurisdiction","type":"string","required":true,"maxLength":32,"classification":"internal"}, + {"id":"label","type":"string","required":true,"maxLength":100,"classification":"internal"}, + {"id":"secret","type":"string","required":true,"maxLength":100,"classification":"restricted"} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredScopes":["history.read"],"requiredPurposes":["case-review"], + "operations":["revisions"],"revisionAccess":true, + "readableFields":["label"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] + }] + }] + }"#, + ) + .expect("revision HTTP fixture parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("revision HTTP fixture compiles") +} diff --git a/crates/registry-server/tests/postgres_startup.rs b/crates/registry-server/tests/postgres_startup.rs new file mode 100644 index 0000000000..0843ee0724 --- /dev/null +++ b/crates/registry-server/tests/postgres_startup.rs @@ -0,0 +1,1260 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::fs; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::body::{to_bytes, Body}; +use axum::http::header::AUTHORIZATION; +use axum::http::{Request, StatusCode}; +use postgres_harness::TestDatabase; +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_crypto::{generate_private_jwk, sign, GeneratedKeyAlgorithm, PrivateJwk}; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{ + fetch_discovery_with_policy, JwksFetcher, JwksFetcherConfig, OidcDiscoveryConfig, +}; +use registry_platform_testing::{ + fixtures as testing_fixtures, jwks_from_private_jwk, sign_ed25519_compact_jwt, MockIdp, +}; +use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::migration::{ + apply_verified_package, ApplyPrecondition, ApplyRoles, ApplyTimeouts, + ApplyVerifiedPackageRequest, +}; +use registry_server::package::{ + load_package, prepare_package, PackageBuildRequest, PackageIntent, PackageLoadContext, + PackageMigrationPlanInput, PackageModuleSource, PackageSignature, PackageSourceFile, + PackageTrustAnchor, SignaturePolicy, TrustAnchorKey, VerifiedPackage, TRUST_ANCHOR_API_VERSION, +}; +use registry_server::postgres::{ + initialize_registry_state_for_catalog_test, install_compiled_schema, + managed_schema_fingerprint, verify_runtime_role, ExpectedManagedCatalog, + ExpectedRegistryIdentity, RegistryStateTestIdentity, +}; +use registry_server::startup::{ + prepare_with_connection_and_key_source_for_test, prepare_with_connection_config_for_test, + serve_until_shutdown, PreparedServer, StartupError, +}; +use serde::Serialize; +use serde_json::json; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::sync::oneshot; +use tokio_postgres::GenericClient; +use tower::ServiceExt as _; + +const INSTANCE: &str = "startup-instance"; +const DATABASE: &str = "startup-database"; +const SOURCE_REVISION: &str = "startup-source-revision"; +const FIXTURE_JOURNEYS: &[u8] = br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: neutral-record-list + steps: + - id: list-neutral-records + entity: neutral-record + accessProfile: reader + claims: {principal: package-reader} + request: {operation: list} + expect: {outcome: success, status: 200, count: 0} +"#; +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn prepared_server_wires_services_and_static_jwks_readiness_tracks_database() { + let database = TestDatabase::create(4).await; + let (migration, migration_task) = database.connect_migration().await; + verify_runtime_role(&migration, &database.migration_role) + .await + .expect_err("migration connection is not accepted as runtime"); + + let fixture = StartupFixture::new(); + let signing = + generate_private_jwk(GeneratedKeyAlgorithm::Es384).expect("fixture signing key generates"); + let provisional = PackageFixture::build(&fixture.root, fingerprint(1), &signing); + let provisional_context = provisional.context(PackageIntent::InitialActivation); + let verified_provisional = load_package(&provisional.root, &provisional_context) + .expect("provisional package loads enough to install schema"); + install_compiled_schema( + &migration, + verified_provisional.registry(), + &database.runtime_role, + ) + .await + .expect("compiled schema installs"); + let expected_catalog = ExpectedManagedCatalog::compiled(verified_provisional.registry()); + let schema_fingerprint = + managed_schema_fingerprint(&migration, &database.runtime_role, &expected_catalog) + .await + .expect("compiled schema fingerprints"); + drop(provisional); + + let package = PackageFixture::build(&fixture.root, schema_fingerprint, &signing); + let context = package.context(PackageIntent::InitialActivation); + let verified = load_package(&package.root, &context).expect("final package verifies"); + initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(verified.registry()), + RegistryStateTestIdentity { + package_id: &verified.manifest().package_id, + environment: &verified.manifest().environment, + instance_id: &verified.manifest().instance_id, + database_id: &verified.manifest().database_id, + package_revision: &verified.manifest().package_revision, + package_sequence: i64::try_from(verified.manifest().sequence) + .expect("fixture sequence fits"), + }, + ) + .await + .expect("Registry state initializes"); + migration_task.abort(); + + let idp = MockIdp::start().await; + let config_path = fixture.write_static_jwks_config( + &package, + &database.migration_role, + &database.runtime_role, + &idp, + Some("0123456789abcdef0123456789abcdef"), + ); + let prepared = + prepare_with_connection_config_for_test(&config_path, database.runtime_config.clone()) + .await + .expect("prepared server verifies package, database, audit, and OIDC"); + assert_ready(&prepared, StatusCode::OK).await; + assert_unknown_static_kid_refuses_value_free(&prepared).await; + + let wrong_role_path = fixture.write_static_jwks_config( + &package, + &database.migration_role, + &database.intruder_role, + &idp, + Some("0123456789abcdef0123456789abcdef"), + ); + assert_eq!( + prepare_with_connection_config_for_test(&wrong_role_path, database.runtime_config.clone()) + .await + .err(), + Some(StartupError::DatabaseUnready) + ); + + database + .admin + .execute( + "UPDATE registry_internal.registry_state + SET active_package_revision = $1 + WHERE singleton", + &[&"sha256:0000000000000000000000000000000000000000000000000000000000000000"], + ) + .await + .expect("test invalidates active package"); + assert_ready(&prepared, StatusCode::SERVICE_UNAVAILABLE).await; + database + .admin + .execute( + "UPDATE registry_internal.registry_state + SET active_package_revision = $1 + WHERE singleton", + &[&verified.manifest().package_revision], + ) + .await + .expect("test restores active package"); + assert_ready(&prepared, StatusCode::OK).await; + idp.stop().await; + tokio::time::sleep(Duration::from_secs(2)).await; + assert_ready(&prepared, StatusCode::OK).await; + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 6)] +async fn live_old_server_drains_apply_and_exact_successor_restart_becomes_ready() { + let database = TestDatabase::create(4).await; + let (mut migration, migration_task) = database.connect_migration().await; + let fixture = StartupFixture::new(); + let signing = + generate_private_jwk(GeneratedKeyAlgorithm::Es384).expect("fixture signing key generates"); + + let provisional = PackageFixture::build(&fixture.root, fingerprint(1), &signing); + let verified_provisional = load_package( + &provisional.root, + &provisional.context(PackageIntent::InitialActivation), + ) + .expect("provisional initial package verifies"); + let transaction = migration + .transaction() + .await + .expect("initial fingerprint transaction starts"); + install_compiled_schema( + &transaction, + verified_provisional.registry(), + &database.runtime_role, + ) + .await + .expect("initial schema rehearses"); + let initial_fingerprint = managed_schema_fingerprint( + &transaction, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(verified_provisional.registry()), + ) + .await + .expect("initial target fingerprint computes"); + transaction + .rollback() + .await + .expect("initial rehearsal rolls back"); + drop(provisional); + + let initial_package = PackageFixture::build(&fixture.root, initial_fingerprint, &signing); + let verified_initial = load_package( + &initial_package.root, + &initial_package.context(PackageIntent::InitialActivation), + ) + .expect("final initial package verifies"); + let initial = apply_startup_package( + &database, + &verified_initial, + ApplyPrecondition::InitialActivation, + ) + .await; + + let provisional_successor = PackageFixture::build_successor( + &fixture.root, + fingerprint(2), + &signing, + &initial.package_revision, + ); + let activation_intent = PackageIntent::Activation { + active_revision: &initial.package_revision, + active_sequence: 1, + }; + let verified_provisional_successor = load_package( + &provisional_successor.root, + &provisional_successor.context(activation_intent), + ) + .expect("provisional successor verifies"); + let transaction = migration + .transaction() + .await + .expect("successor fingerprint transaction starts"); + for statement in &verified_provisional_successor + .manifest() + .migration_plan + .statements + { + transaction + .batch_execute(&statement.sql) + .await + .expect("successor statement rehearses"); + } + let added_table = + &verified_provisional_successor.registry().entities()["second-record"].physical_table; + transaction + .batch_execute(&format!( + "REVOKE ALL ON TABLE registry_data.{} FROM PUBLIC, \"{}\"; + GRANT SELECT ON TABLE registry_data.{} TO \"{}\";", + quote(added_table), + database.runtime_role.as_str(), + quote(added_table), + database.runtime_role.as_str(), + )) + .await + .expect("successor rehearsal installs the compiled runtime ACL"); + let successor_fingerprint = managed_schema_fingerprint( + &transaction, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(verified_provisional_successor.registry()), + ) + .await + .expect("successor target fingerprint computes"); + transaction + .rollback() + .await + .expect("successor rehearsal rolls back"); + drop(provisional_successor); + + let successor_package = PackageFixture::build_successor( + &fixture.root, + successor_fingerprint, + &signing, + &initial.package_revision, + ); + let verified_successor = load_package( + &successor_package.root, + &successor_package.context(activation_intent), + ) + .expect("final successor verifies"); + migration_task.abort(); + + let record_id = uuid::Uuid::from_u128(1); + let old_entity = &verified_initial.registry().entities()["neutral-record"]; + database + .admin + .execute( + &format!( + "INSERT INTO registry_data.{} (record_id, active_package_revision, {}) + VALUES ($1, $2, $3)", + quote(&old_entity.physical_table), + quote(&old_entity.fields["code"].physical_name), + ), + &[&record_id, &initial.package_revision, &"old-row"], + ) + .await + .expect("old package row seeds"); + + let idp = MockIdp::start().await; + let key_source = mock_idp_key_source(&idp).await; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock follows epoch") + .as_secs(); + let token = idp.mint_token(json!({ + "aud": "urn:registry-server:test", + "principal": "recovery-operator", + "iat": now, + "nbf": now, + "exp": now + 120 + })); + let old_address = reserve_address(); + let old_config = fixture.write_config_at( + &initial_package, + &database.migration_role, + &database.runtime_role, + &idp.issuer(), + Some("0123456789abcdef0123456789abcdef"), + old_address, + ); + let old_prepared = prepare_with_connection_and_key_source_for_test( + &old_config, + database.runtime_config.clone(), + Arc::clone(&key_source), + ) + .await + .expect("old exact package prepares"); + let (old_shutdown, old_server) = spawn_live_server(old_prepared, old_address).await; + + let (mut record_blocker_connection, record_blocker_task) = database.connect_migration().await; + let record_blocker = record_blocker_connection + .transaction() + .await + .expect("record blocker transaction starts"); + record_blocker + .batch_execute(&format!( + "LOCK TABLE registry_data.{} IN ACCESS EXCLUSIVE MODE", + quote(&old_entity.physical_table) + )) + .await + .expect("record blocker owns the old data table"); + + let (mut ddl_blocker_connection, ddl_blocker_task) = database.connect_migration().await; + let ddl_blocker = ddl_blocker_connection + .transaction() + .await + .expect("DDL blocker transaction starts"); + let create_table_sql = verified_successor + .manifest() + .migration_plan + .statements + .iter() + .find(|statement| statement.sql.starts_with("CREATE TABLE ")) + .expect("successor has one table creation") + .sql + .clone(); + ddl_blocker + .batch_execute(&create_table_sql) + .await + .expect("uncommitted successor table blocks exact apply DDL"); + + let record_path = format!("/v1/records/neutral-records/{record_id}?accessProfile=reader"); + let in_flight_address = old_address; + let in_flight_path = record_path.clone(); + let in_flight_token = token.clone(); + let mut in_flight = tokio::spawn(async move { + http_get(in_flight_address, &in_flight_path, Some(&in_flight_token)).await + }); + tokio::select! { + result = &mut in_flight => { + let response = result + .expect("premature in-flight task joins") + .expect("premature in-flight HTTP exchange completes"); + panic!( + "old record request returned before reaching its deterministic table wait with status {}", + response.status + ); + } + () = wait_for_role_relation_wait(&database.admin, database.runtime_role.as_str()) => {} + } + + let active = { + let apply = apply_startup_package_result( + &database, + &verified_successor, + ApplyPrecondition::Successor { current: &initial }, + ); + tokio::pin!(apply); + tokio::select! { + result = &mut apply => panic!("apply passed the prior in-flight record operation: {result:?}"), + () = wait_for_role_advisory_wait(&database.admin, database.migration_role.as_str()) => {} + } + record_blocker + .rollback() + .await + .expect("operator releases the deterministic record blocker"); + record_blocker_task.abort(); + let drained = tokio::time::timeout(Duration::from_secs(2), in_flight) + .await + .expect("prior in-flight request drains within the bound") + .expect("prior in-flight task joins") + .expect("prior in-flight HTTP exchange completes"); + // Apply wins only after the record transaction releases its shared + // lock. If that happens before the terminal audit can gate release, + // the held old-package bytes are discarded as a value-free refusal. + assert_eq!(drained.status, 503); + assert!(!drained.body.contains("old-row")); + assert!(!drained.body.contains("recovery-operator")); + assert!(!drained.body.contains(&token)); + + tokio::select! { + result = &mut apply => panic!("apply escaped the deterministic successor DDL blocker: {result:?}"), + () = wait_for_maintenance_without_sleep(&database.admin, "applying") => {} + } + let refused_during_apply = tokio::time::timeout( + Duration::from_secs(2), + http_get(old_address, &record_path, Some(&token)), + ) + .await + .expect("new record work fails within the configured lock bound") + .expect("old server returns a value-free refusal"); + assert_eq!(refused_during_apply.status, 503); + assert!(!refused_during_apply.body.contains("old-row")); + assert!(!refused_during_apply.body.contains("recovery-operator")); + assert!(!refused_during_apply.body.contains(&token)); + + ddl_blocker + .rollback() + .await + .expect("operator releases the deterministic successor DDL blocker"); + ddl_blocker_task.abort(); + apply + .await + .expect("exact successor applies after prior work drains") + }; + assert_eq!( + active.package_revision, + verified_successor.manifest().package_revision + ); + + let old_ready = http_get(old_address, "/ready", None) + .await + .expect("old process readiness responds after activation"); + assert_eq!(old_ready.status, 503); + + let (mut post_activation_blocker, post_activation_blocker_task) = + database.connect_migration().await; + let post_activation_lock = post_activation_blocker + .transaction() + .await + .expect("post-activation record blocker starts"); + post_activation_lock + .batch_execute(&format!( + "LOCK TABLE registry_data.{} IN ACCESS EXCLUSIVE MODE", + quote(&old_entity.physical_table) + )) + .await + .expect("old record table is unavailable to prove pre-I/O refusal"); + let old_refusal = tokio::time::timeout( + Duration::from_millis(750), + http_get(old_address, &record_path, Some(&token)), + ) + .await + .expect("old process refuses before attempting blocked record I/O") + .expect("old process returns its refusal"); + assert_eq!(old_refusal.status, 503); + assert!(!old_refusal.body.contains("old-row")); + assert!(!old_refusal.body.contains("recovery-operator")); + assert!(!old_refusal.body.contains(&token)); + post_activation_lock + .rollback() + .await + .expect("post-activation record blocker rolls back"); + post_activation_blocker_task.abort(); + + old_shutdown + .send(()) + .expect("old process shutdown signal sends"); + old_server + .await + .expect("old server task joins") + .expect("old server shuts down cleanly"); + + let new_address = reserve_address(); + let new_config = fixture.write_config_at( + &successor_package, + &database.migration_role, + &database.runtime_role, + &idp.issuer(), + Some("0123456789abcdef0123456789abcdef"), + new_address, + ); + let new_prepared = prepare_with_connection_and_key_source_for_test( + &new_config, + database.runtime_config.clone(), + key_source, + ) + .await + .expect("restart accepts only the exact active successor package"); + let (new_shutdown, new_server) = spawn_live_server(new_prepared, new_address).await; + assert_eq!( + http_get(new_address, "/ready", None) + .await + .expect("new process readiness responds") + .status, + 200 + ); + new_shutdown + .send(()) + .expect("new process shutdown signal sends"); + new_server + .await + .expect("new server task joins") + .expect("new server shuts down cleanly"); + + idp.stop().await; + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn audit_and_oidc_failures_refuse_before_listener_bind() { + let database = TestDatabase::create(2).await; + let (migration, migration_task) = database.connect_migration().await; + let fixture = StartupFixture::new(); + let signing = + generate_private_jwk(GeneratedKeyAlgorithm::Es384).expect("fixture signing key generates"); + let provisional = PackageFixture::build(&fixture.root, fingerprint(1), &signing); + let verified_provisional = load_package( + &provisional.root, + &provisional.context(PackageIntent::InitialActivation), + ) + .expect("provisional package loads"); + install_compiled_schema( + &migration, + verified_provisional.registry(), + &database.runtime_role, + ) + .await + .expect("compiled schema installs"); + let catalog = ExpectedManagedCatalog::compiled(verified_provisional.registry()); + let schema_fingerprint = + managed_schema_fingerprint(&migration, &database.runtime_role, &catalog) + .await + .expect("compiled schema fingerprints"); + drop(provisional); + + let package = PackageFixture::build(&fixture.root, schema_fingerprint, &signing); + let verified = load_package( + &package.root, + &package.context(PackageIntent::InitialActivation), + ) + .expect("final package verifies"); + initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(verified.registry()), + RegistryStateTestIdentity { + package_id: &verified.manifest().package_id, + environment: &verified.manifest().environment, + instance_id: &verified.manifest().instance_id, + database_id: &verified.manifest().database_id, + package_revision: &verified.manifest().package_revision, + package_sequence: 1, + }, + ) + .await + .expect("Registry state initializes"); + migration_task.abort(); + + let missing_audit_path = fixture.write_config( + &package, + &database.migration_role, + &database.runtime_role, + "http://127.0.0.1:9", + None, + ); + assert_eq!( + prepare_with_connection_config_for_test( + &missing_audit_path, + database.runtime_config.clone() + ) + .await + .err(), + Some(StartupError::Audit) + ); + + let bad_oidc_path = fixture.write_config( + &package, + &database.migration_role, + &database.runtime_role, + "http://127.0.0.1:9", + Some("0123456789abcdef0123456789abcdef"), + ); + assert_eq!( + prepare_with_connection_config_for_test(&bad_oidc_path, database.runtime_config.clone()) + .await + .err(), + Some(StartupError::Oidc) + ); + database.cleanup().await; +} + +async fn apply_startup_package( + database: &TestDatabase, + package: &VerifiedPackage, + precondition: ApplyPrecondition<'_>, +) -> ExpectedRegistryIdentity { + apply_startup_package_result(database, package, precondition) + .await + .expect("verified package applies") +} + +async fn apply_startup_package_result( + database: &TestDatabase, + package: &VerifiedPackage, + precondition: ApplyPrecondition<'_>, +) -> registry_server::migration::Result { + apply_verified_package(ApplyVerifiedPackageRequest::new( + &database.migration_config, + package, + precondition, + ApplyRoles::new(&database.migration_role, &database.runtime_role), + ApplyTimeouts::new(Duration::from_secs(5), Duration::from_secs(5)) + .expect("test apply timeouts are bounded"), + )) + .await +} + +struct LiveHttpResponse { + status: u16, + body: String, +} + +fn reserve_address() -> SocketAddr { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("loopback address reservation binds"); + listener + .local_addr() + .expect("loopback reservation address reads") +} + +async fn spawn_live_server( + prepared: PreparedServer, + address: SocketAddr, +) -> ( + oneshot::Sender<()>, + tokio::task::JoinHandle>, +) { + assert_eq!(prepared.bind(), address); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let mut task = tokio::spawn(serve_until_shutdown(prepared, async move { + let _ = shutdown_rx.await; + Ok(()) + })); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + tokio::select! { + result = &mut task => { + panic!("live PreparedServer stopped before its health route was reachable: {result:?}") + } + connection = tokio::net::TcpStream::connect(address) => { + if connection.is_ok() { + return; + } + } + } + tokio::task::yield_now().await; + } + }) + .await + .expect("live PreparedServer accepts HTTP without timing sleeps"); + (shutdown_tx, task) +} + +async fn http_get( + address: SocketAddr, + path: &str, + token: Option<&str>, +) -> std::io::Result { + let mut stream = tokio::net::TcpStream::connect(address).await?; + let authorization = token + .map(|token| format!("Authorization: Bearer {token}\r\n")) + .unwrap_or_default(); + let request = format!( + "GET {path} HTTP/1.1\r\nHost: {address}\r\n{authorization}Connection: close\r\n\r\n" + ); + stream.write_all(request.as_bytes()).await?; + let mut response = Vec::new(); + loop { + let read = stream.read_buf(&mut response).await?; + if read == 0 || complete_http_response(&response) { + break; + } + } + let response = String::from_utf8(response) + .map_err(|_| std::io::Error::other("HTTP response is not UTF-8"))?; + let (head, body) = response + .split_once("\r\n\r\n") + .ok_or_else(|| std::io::Error::other("HTTP response is incomplete"))?; + let status = head + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|status| status.parse::().ok()) + .ok_or_else(|| std::io::Error::other("HTTP response status is invalid"))?; + Ok(LiveHttpResponse { + status, + body: body.to_owned(), + }) +} + +fn complete_http_response(response: &[u8]) -> bool { + let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + let headers = String::from_utf8_lossy(&response[..header_end]); + let length = headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }); + length.is_some_and(|length| response.len() >= header_end + 4 + length) +} + +async fn wait_for_role_relation_wait(client: &impl GenericClient, role: &str) { + wait_for_role_lock(client, role, "relation").await; +} + +async fn wait_for_role_advisory_wait(client: &impl GenericClient, role: &str) { + wait_for_role_lock(client, role, "advisory").await; +} + +async fn wait_for_role_lock(client: &impl GenericClient, role: &str, lock_type: &str) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let waiting: bool = client + .query_one( + "SELECT EXISTS ( + SELECT 1 + FROM pg_locks AS lock + JOIN pg_stat_activity AS activity USING (pid) + WHERE activity.datname = current_database() + AND activity.usename = $1 + AND lock.locktype = $2 + AND NOT lock.granted + )", + &[&role, &lock_type], + ) + .await + .expect("administrator observes lock waits") + .get(0); + if waiting { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the expected database lock wait is reached without timing sleeps"); +} + +async fn wait_for_maintenance_without_sleep(client: &impl GenericClient, expected: &str) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let status: String = client + .query_one( + "SELECT maintenance_status + FROM registry_internal.registry_state + WHERE singleton", + &[], + ) + .await + .expect("maintenance state reads") + .get(0); + if status == expected { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("maintenance reaches its durable state without timing sleeps"); +} + +async fn assert_ready(prepared: &PreparedServer, expected: StatusCode) { + let response = prepared + .app() + .oneshot( + Request::builder() + .uri("/ready") + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("router responds"); + assert_eq!(response.status(), expected); +} + +async fn assert_unknown_static_kid_refuses_value_free(prepared: &PreparedServer) { + const UNKNOWN_KID_CANARY: &str = "unknown-static-kid-canary"; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock follows epoch") + .as_secs(); + let token = sign_ed25519_compact_jwt( + testing_fixtures::ED25519_PRIVATE_JWK, + "JWT", + UNKNOWN_KID_CANARY, + json!({ + "aud": "urn:registry-server:test", + "principal": "package-reader", + "iat": now, + "nbf": now, + "exp": now + 120 + }), + ); + let response = prepared + .app() + .oneshot( + Request::builder() + .uri("/v1/records/neutral-records?accessProfile=reader") + .header(AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("router responds"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let mut rendered = response + .headers() + .iter() + .map(|(name, value)| format!("{}:{}\n", name, value.to_str().unwrap_or(""))) + .collect::(); + rendered.push_str( + std::str::from_utf8( + &to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response body reads"), + ) + .expect("response body is UTF-8"), + ); + assert!(!rendered.contains(UNKNOWN_KID_CANARY)); +} + +async fn mock_idp_key_source(idp: &MockIdp) -> Arc { + let discovery = fetch_discovery_with_policy( + &OidcDiscoveryConfig { + issuer: idp.issuer(), + jwks_uri_override: None, + discovery_timeout: Duration::from_secs(5), + max_doc_bytes: 16 * 1024, + }, + &FetchUrlPolicy::dev(), + ) + .await + .expect("MockIdp discovery fetch succeeds"); + Arc::new(JwksFetcher::new_with_fetch_url_policy( + discovery.jwks_uri, + JwksFetcherConfig { + cache_ttl: Duration::from_secs(1), + negative_cache_ttl: Duration::from_secs(1), + refresh_cooldown: Duration::from_secs(1), + max_doc_bytes: 16 * 1024, + request_timeout: Duration::from_secs(5), + outage_tolerance: Duration::ZERO, + }, + FetchUrlPolicy::dev(), + )) +} + +struct StartupFixture { + root: PathBuf, + secret_root: PathBuf, +} + +impl StartupFixture { + fn new() -> Self { + let parent = std::env::temp_dir() + .canonicalize() + .expect("temporary parent canonicalizes"); + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock follows epoch") + .as_nanos(); + let ordinal = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let root = parent.join(format!( + "registry-server-postgres-startup-{}-{suffix}-{ordinal}", + std::process::id(), + )); + fs::create_dir(&root).expect("fixture root creates"); + let secret_root = root.join("secrets"); + fs::create_dir(&secret_root).expect("secret root creates"); + Self { root, secret_root } + } + + fn write_config( + &self, + package: &PackageFixture, + migration_role: ®istry_server::postgres::SqlIdentifier, + runtime_role: ®istry_server::postgres::SqlIdentifier, + issuer: &str, + audit_key: Option<&str>, + ) -> PathBuf { + self.write_config_at( + package, + migration_role, + runtime_role, + issuer, + audit_key, + "127.0.0.1:9".parse().expect("fixture listener parses"), + ) + } + + fn write_static_jwks_config( + &self, + package: &PackageFixture, + migration_role: ®istry_server::postgres::SqlIdentifier, + runtime_role: ®istry_server::postgres::SqlIdentifier, + idp: &MockIdp, + audit_key: Option<&str>, + ) -> PathBuf { + let public_jwks = jwks_from_private_jwk( + &PrivateJwk::parse(testing_fixtures::ED25519_PRIVATE_JWK).expect("test IdP key parses"), + ); + let jwks_path = self.secret_root.join("oidc-jwks"); + fs::write( + &jwks_path, + serde_json::to_vec(&public_jwks).expect("static JWKS serializes"), + ) + .expect("static JWKS writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&jwks_path, fs::Permissions::from_mode(0o600)) + .expect("static JWKS permissions set"); + } + let path = self.write_config( + package, + migration_role, + runtime_role, + &idp.issuer(), + audit_key, + ); + let raw = fs::read_to_string(&path).expect("runtime config reads"); + fs::write( + &path, + raw.replace( + " jwksCache:\n", + " jwksSource:\n kind: static\n documentRef: secret:file/oidc-jwks\n jwksCache:\n", + ), + ) + .expect("static JWKS runtime config writes"); + path + } + + fn write_config_at( + &self, + package: &PackageFixture, + migration_role: ®istry_server::postgres::SqlIdentifier, + runtime_role: ®istry_server::postgres::SqlIdentifier, + issuer: &str, + audit_key: Option<&str>, + listener: SocketAddr, + ) -> PathBuf { + let hash_key_ref = if let Some(audit_key) = audit_key { + let audit_key_path = self.secret_root.join("audit-key"); + fs::write(&audit_key_path, audit_key).expect("audit key writes"); + let cursor_key_path = self.secret_root.join("cursor-key"); + fs::write(&cursor_key_path, [0x53_u8; 32]).expect("cursor key writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&audit_key_path, fs::Permissions::from_mode(0o600)) + .expect("audit key permissions set"); + fs::set_permissions(&cursor_key_path, fs::Permissions::from_mode(0o600)) + .expect("cursor key permissions set"); + } + "secret:file/audit-key" + } else { + "secret:file/missing-audit-key" + }; + let ordinal = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = self.root.join(format!("runtime-{ordinal}.yaml")); + fs::write( + &path, + format!( + r#" +listener: + bind: {listener} + trustedProxy: direct +identity: + environment: production + instanceId: {INSTANCE} + databaseId: {DATABASE} + databaseInitializationEnvironment: production +secretProviders: + file: + root: {} +database: + runtimeUrlRef: secret:file/database-url + migrationUrlRef: secret:file/migration-database-url + pool: + maxSize: 1 + waitTimeoutMilliseconds: 1000 + createTimeoutMilliseconds: 1000 + recycleTimeoutMilliseconds: 1000 + roles: + migration: {} + runtime: {} +package: + root: {} + trustAnchorPath: {} + compilerSourceRevision: {SOURCE_REVISION} + activeRevision: {} + activeSequence: {} +authentication: + oidc: + issuer: {} + audience: urn:registry-server:test + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 600 + negativeCacheTtlSeconds: 60 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 200 + outageToleranceSeconds: 0 + authorityClaims: + principal: principal +audit: + hashKeyRef: {hash_key_ref} +cursor: + secretRef: secret:file/cursor-key + maxAgeSeconds: 300 +operationalTimeouts: + httpRequestMilliseconds: 5000 + shutdownGraceMilliseconds: 1000 + recordLockMilliseconds: 1000 + migrationLockMilliseconds: 1000 + migrationStatementMilliseconds: 1000 +"#, + self.secret_root.display(), + migration_role.as_str(), + runtime_role.as_str(), + package.root.display(), + package.anchor.display(), + package.revision, + package.sequence, + issuer + ), + ) + .expect("runtime config writes"); + fs::write(self.secret_root.join("database-url"), "unused") + .expect("unused DB URL secret writes"); + path + } +} + +impl Drop for StartupFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +struct PackageFixture { + root: PathBuf, + anchor: PathBuf, + revision: String, + sequence: u64, +} + +impl PackageFixture { + fn build(parent: &Path, schema_fingerprint: String, signing: &PrivateJwk) -> Self { + Self::build_version(parent, schema_fingerprint, signing, 1, None, false) + } + + fn build_successor( + parent: &Path, + schema_fingerprint: String, + signing: &PrivateJwk, + prior_revision: &str, + ) -> Self { + Self::build_version( + parent, + schema_fingerprint, + signing, + 2, + Some(prior_revision), + true, + ) + } + + fn build_version( + parent: &Path, + schema_fingerprint: String, + signing: &PrivateJwk, + sequence: u64, + prior_revision: Option<&str>, + successor: bool, + ) -> Self { + let ordinal = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let root = parent.join(format!("package-{ordinal}")); + let module_source = module_bytes(successor); + let module = parse_module_yaml(&module_source).expect("fixture module parses"); + let project_source = project_bytes(sequence, &module_digest(&module)); + let key_id = signing.public().kid.expect("generated key has kid"); + let migration_plan = if successor { + let prior_module_bytes = module_bytes(false); + let prior_module = + parse_module_yaml(&prior_module_bytes).expect("prior fixture module parses"); + let prior_project_bytes = project_bytes(1, &module_digest(&prior_module)); + let prior_project = + parse_project_yaml(&prior_project_bytes).expect("prior fixture project parses"); + let prior_registry = + compile_project(&prior_project, &[prior_module], CompileProfile::Production) + .expect("prior fixture Registry compiles"); + PackageMigrationPlanInput::Successor { + prior_registry: Box::new(prior_registry), + } + } else { + PackageMigrationPlanInput::InitialCompiledDdl + }; + let prepared = prepare_package(PackageBuildRequest { + environment: "production".to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + sequence, + prior_revision: prior_revision.map(str::to_owned), + compiler_source_revision: SOURCE_REVISION.to_owned(), + schema_fingerprint, + signature_policy: SignaturePolicy { + threshold: 1, + key_ids: vec![key_id.clone()], + }, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: project_source, + }, + modules: vec![PackageModuleSource { + id: "core".to_owned(), + path: "source/modules/core/module.yaml".to_owned(), + bytes: module_source, + }], + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: FIXTURE_JOURNEYS.to_vec(), + }, + migration_plan, + }) + .expect("fixture package prepares"); + let signature = + sign(prepared.canonical_signed_bytes(), signing).expect("fixture package signs"); + prepared + .publish_to_directory( + &root, + vec![PackageSignature { + key_id: key_id.clone(), + signature_hex: hex(&signature), + }], + ) + .expect("fixture package publishes"); + let anchor = parent.join(format!("trust-anchor-{ordinal}.json")); + write_json( + &anchor, + &PackageTrustAnchor { + api_version: TRUST_ANCHOR_API_VERSION.to_owned(), + environment: "production".to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + threshold: 1, + keys: vec![TrustAnchorKey { + key_id, + jwk: serde_json::to_value(signing.public()).expect("public JWK serializes"), + }], + }, + ); + Self { + root, + anchor, + revision: prepared.package_revision().to_owned(), + sequence, + } + } + + fn context<'a>(&'a self, intent: PackageIntent<'a>) -> PackageLoadContext<'a> { + PackageLoadContext { + environment: "production", + instance_id: INSTANCE, + database_id: DATABASE, + database_initialization_environment: "production", + compiler_source_revision: SOURCE_REVISION, + trust_anchor: Some(&self.anchor), + intent, + } + } +} + +fn project_bytes(sequence: u64, module_digest: &str) -> Vec { + let project = format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"production","instanceId":"{INSTANCE}","sequence":{sequence},"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"name":"Package Test Publisher"}}}},"dataset":{{"title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}}},"modules":[{{"id":"core","version":"1","digest":"{module_digest}"}}]}}"# + ); + parse_project_yaml(project.as_bytes()).expect("project fixture parses"); + project.into_bytes() +} + +fn module_bytes(successor: bool) -> Vec { + let second = if successor { + r#",{"id":"second-record","route":"second-records","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]}"# + } else { + "" + }; + format!( + r#"{{"id":"core","version":"1","entities":[{{"id":"neutral-record","route":"neutral-records","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}}],"accessProfiles":[{{"id":"reader","principalClaim":"principal","operations":["get","list"],"readableFields":["code"]}}]}}{second}]}}"# + ) + .into_bytes() +} + +fn write_json(path: &Path, value: &impl Serialize) { + let bytes = canonicalize_json(&serde_json::to_value(value).expect("value serializes")) + .expect("value canonicalizes"); + fs::write(path, bytes).expect("fixture JSON writes"); +} + +fn fingerprint(byte: u8) -> String { + format!("sha256:{}", format!("{byte:02x}").repeat(32)) +} + +fn hex(bytes: &[u8]) -> String { + let mut result = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(&mut result, "{byte:02x}").expect("writing to String succeeds"); + } + result +} + +fn quote(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} diff --git a/crates/registry-server/tests/postgres_tls.rs b/crates/registry-server/tests/postgres_tls.rs new file mode 100644 index 0000000000..d99ecd95e5 --- /dev/null +++ b/crates/registry-server/tests/postgres_tls.rs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-tls-test")] + +use std::{env, fs, str::FromStr, time::Duration}; + +use registry_server::postgres::{ConnectionConfig, PoolBounds}; +use tokio::{net::TcpStream, time::timeout}; +use tokio_postgres::{config::Host, Config}; + +fn required_env(name: &str) -> String { + env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| panic!("{name} must be set for the real PostgreSQL TLS test")) +} + +fn read_required_der(path_env: &str) -> Vec { + let path = required_env(path_env); + fs::read(&path).unwrap_or_else(|error| panic!("failed to read {path_env} at {path}: {error}")) +} + +fn tcp_connection_identity(url: &str) -> (String, u16, Option, Option) { + let config = Config::from_str(url).expect("TLS test database URL must parse"); + let hosts = config.get_hosts(); + assert_eq!(hosts.len(), 1, "TLS test URL must name exactly one host"); + let host = match &hosts[0] { + Host::Tcp(host) => host.clone(), + #[cfg(unix)] + Host::Unix(_) => panic!("TLS test URL must use a TCP host"), + }; + let ports = config.get_ports(); + assert!(ports.len() <= 1, "TLS test URL must name at most one port"); + let port = ports.first().copied().unwrap_or(5432); + ( + host, + port, + config.get_user().map(str::to_owned), + config.get_dbname().map(str::to_owned), + ) +} + +#[tokio::test] +async fn custom_ca_requires_a_trusted_tls_server() { + let database_url = required_env("REGISTRY_SERVER_TEST_TLS_DATABASE_URL"); + let hostname_mismatch_url = + required_env("REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL"); + let trusted_ca = read_required_der("REGISTRY_SERVER_TEST_TLS_CA_DER_PATH"); + let wrong_ca = read_required_der("REGISTRY_SERVER_TEST_TLS_WRONG_CA_DER_PATH"); + assert_ne!( + trusted_ca, wrong_ca, + "the wrong-root fixture must differ from the trusted CA" + ); + + let bounds = PoolBounds::new( + 1, + Duration::from_secs(5), + Duration::from_secs(5), + Duration::from_secs(5), + ) + .expect("TLS test pool bounds are valid"); + let trusted = ConnectionConfig::require_tls_with_custom_ca(&database_url, &trusted_ca, bounds) + .expect("trusted CA DER and database URL must parse"); + assert_eq!( + format!("{trusted:?}"), + format!("ConnectionConfig {{ tls_policy: RequireCustomCa, pool_bounds: {bounds:?}, .. }}"), + "Debug must not disclose the URL or CA certificate" + ); + + let trusted_pool = trusted.build_pool().expect("TLS pool must build"); + trusted_pool + .startup_probe() + .await + .expect("trusted custom CA must establish a PostgreSQL connection"); + let client = trusted_pool + .get_for_test() + .await + .expect("trusted TLS connection must be reusable"); + let ssl: bool = client + .query_one( + "SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()", + &[], + ) + .await + .expect("PostgreSQL must report the transport state") + .get(0); + assert!(ssl, "the accepted PostgreSQL connection must use TLS"); + drop(client); + drop(trusted_pool); + + let (trusted_host, trusted_port, trusted_user, trusted_database) = + tcp_connection_identity(&database_url); + let (mismatch_host, mismatch_port, mismatch_user, mismatch_database) = + tcp_connection_identity(&hostname_mismatch_url); + assert_ne!( + trusted_host, mismatch_host, + "the hostname-mismatch URL must use a different host" + ); + assert_eq!( + (trusted_port, trusted_user, trusted_database), + (mismatch_port, mismatch_user, mismatch_database), + "the hostname-mismatch URL may differ only in its host" + ); + let mismatch_tcp = timeout( + Duration::from_secs(5), + TcpStream::connect((mismatch_host.as_str(), mismatch_port)), + ) + .await + .expect("hostname-mismatch TCP reachability probe must not time out") + .expect("hostname-mismatch URL must reach the PostgreSQL listener"); + drop(mismatch_tcp); + + let hostname_mismatch = + ConnectionConfig::require_tls_with_custom_ca(&hostname_mismatch_url, &trusted_ca, bounds) + .expect("trusted CA and hostname-mismatch URL must parse"); + let hostname_mismatch_pool = hostname_mismatch + .build_pool() + .expect("hostname-mismatch pool must build"); + assert!( + hostname_mismatch_pool.startup_probe().await.is_err(), + "a trusted chain with a hostname absent from the certificate SAN must be refused" + ); + + let untrusted = ConnectionConfig::require_tls_with_custom_ca(&database_url, &wrong_ca, bounds) + .expect("the wrong-root fixture must still be valid DER"); + let untrusted_pool = untrusted.build_pool().expect("wrong-root pool must build"); + assert!( + untrusted_pool.startup_probe().await.is_err(), + "a valid but untrusted CA must not establish a PostgreSQL connection" + ); +} diff --git a/crates/registry-server/tests/postgres_tombstone_revision.rs b/crates/registry-server/tests/postgres_tombstone_revision.rs new file mode 100644 index 0000000000..7af247622d --- /dev/null +++ b/crates/registry-server/tests/postgres_tombstone_revision.rs @@ -0,0 +1,1010 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::collections::BTreeSet; +use std::time::Duration; + +use postgres_harness::TestDatabase; +use registry_platform_audit::AuditProfile; +use registry_platform_canonical_json::canonicalize_json; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::{parse_project_json, Operation}; +use registry_server::idempotency::PermittedResponseHeader; +use registry_server::mutation::{ + MutationBody, MutationCoordinator, MutationError, MutationFaultPoint, MutationOutcome, + MutationPlan, MutationRequest, PatchOperation, +}; +use registry_server::postgres::{ + initialize_registry_state_for_catalog_test, install_compiled_schema, ClaimContext, + ExpectedManagedCatalog, ExpectedRegistryIdentity, RegistryLockKey, RegistryStateTestIdentity, + RowBoundaryContext, +}; +use serde_json::{json, Map, Value}; + +const PRINCIPAL_CANARY: &str = "tombstone-principal-canary"; +const IDEMPOTENCY_CANARY: &str = "tombstone-idempotency-canary"; +const PACKAGE_ID: &str = "tombstone-registry"; +const INSTANCE_ID: &str = "tombstone-instance"; +const DATABASE_ID: &str = "tombstone-database"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn tombstone_revisions_survive_package_upgrade_and_replay_exactly() { + let fixture = Fixture::start(41, "package-tombstone-1", 1).await; + let mut client = fixture + .database + .runtime_config + .build_pool() + .expect("pool builds") + .get_for_test() + .await + .expect("runtime connection is available"); + let create_plan = MutationPlan::from_compiled(&fixture.compiled, "records.widget.create") + .expect("create route is compiled"); + let patch_plan = MutationPlan::from_compiled(&fixture.compiled, "records.widget.patch") + .expect("patch route is compiled"); + let tombstone_plan = MutationPlan::from_compiled(&fixture.compiled, "records.widget.tombstone") + .expect("tombstone route is compiled"); + let claims = mutation_claims(&fixture.compiled, PRINCIPAL_CANARY); + let response_fields = response_fields(); + + let created = fixture + .coordinator + .execute( + &mut client, + create_request(&create_plan, "create-key", &claims, "original-label"), + ) + .await + .expect("create commits"); + let record_id = response_id(&created); + let mut upgraded_identity = fixture.identity.clone(); + upgraded_identity.package_revision = "package-tombstone-2".to_owned(); + upgraded_identity.package_sequence = 2; + fixture + .database + .admin + .execute( + "UPDATE registry_internal.registry_state + SET active_package_revision = $1, package_sequence = $2 + WHERE singleton", + &[ + &upgraded_identity.package_revision, + &upgraded_identity.package_sequence, + ], + ) + .await + .expect("test simulates a same-schema package upgrade"); + let upgraded = MutationCoordinator::new( + fixture.lock_key, + Duration::from_secs(2), + upgraded_identity, + fixture.profile.clone(), + ); + let package_two_etag = response_etag( + &fixture.profile, + &claims, + "package-tombstone-2", + &record_id, + 1, + &response_fields, + ); + let patched = upgraded + .execute( + &mut client, + patch_request( + &patch_plan, + "patch-key", + &claims, + &record_id, + &package_two_etag, + "patched-label", + ), + ) + .await + .expect("patch commits under the upgraded package"); + assert_eq!(response_revision(&patched), 2); + let before_tombstone = durable_counts(&fixture.database, &fixture.table).await; + let tombstoned = upgraded + .execute( + &mut client, + tombstone_request( + &tombstone_plan, + IDEMPOTENCY_CANARY, + &claims, + &record_id, + &response_header(&patched, PermittedResponseHeader::Etag), + ), + ) + .await + .expect("tombstone commits"); + assert!(!tombstoned.replayed()); + assert_eq!(response_revision(&tombstoned), 3); + assert_one_complete_effect( + before_tombstone, + durable_counts(&fixture.database, &fixture.table).await, + 0, + ); + assert_current_row_tombstoned(&fixture.database, &fixture.table, &record_id).await; + assert_three_revisions_one_record_across_package_upgrade(&fixture.database, &record_id).await; + assert_tombstone_event_is_canonical(&fixture.database).await; + let event_id = tombstone_event_id(&fixture.database).await; + + let before_replay = durable_counts(&fixture.database, &fixture.table).await; + let replay = upgraded + .execute( + &mut client, + tombstone_request( + &tombstone_plan, + IDEMPOTENCY_CANARY, + &claims, + &record_id, + &response_header(&patched, PermittedResponseHeader::Etag), + ), + ) + .await + .expect("exact tombstone replay succeeds"); + assert!(replay.replayed()); + assert!( + replay.response() == tombstoned.response(), + "exact tombstone replay response changed" + ); + assert_eq!(tombstone_event_id(&fixture.database).await, event_id); + assert_audited_replay_only( + before_replay, + durable_counts(&fixture.database, &fixture.table).await, + ); + assert_revision_provenance_is_keyed(&fixture.database).await; + assert_audit_excludes_raw_values( + &fixture.database, + &[&record_id, PRINCIPAL_CANARY, IDEMPOTENCY_CANARY], + ) + .await; + + fixture.database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn tombstone_refusals_faults_and_concurrency_have_no_duplicate_effects() { + let fixture = Fixture::start(42, "package-tombstone-faults-1", 1).await; + let pool = fixture + .database + .runtime_config + .build_pool() + .expect("pool builds"); + let mut client = pool + .get_for_test() + .await + .expect("runtime connection is available"); + let create_plan = MutationPlan::from_compiled(&fixture.compiled, "records.widget.create") + .expect("create route is compiled"); + let tombstone_plan = MutationPlan::from_compiled(&fixture.compiled, "records.widget.tombstone") + .expect("tombstone route is compiled"); + let claims = mutation_claims(&fixture.compiled, PRINCIPAL_CANARY); + + assert!(matches!( + MutationPlan::from_compiled(&fixture.compiled, "records.log.tombstone"), + Err(MutationError::InvalidRequest) + )); + let without_tombstone = compiled_registry(false); + assert!(matches!( + MutationPlan::from_compiled(&without_tombstone, "records.widget.tombstone"), + Err(MutationError::InvalidRequest) + )); + + let stale_seed = fixture + .coordinator + .execute( + &mut client, + create_request(&create_plan, "stale-seed-key", &claims, "stale-seed"), + ) + .await + .expect("seed create commits"); + let stale_id = response_id(&stale_seed); + let before_stale = durable_counts(&fixture.database, &fixture.table).await; + let stale = fixture + .coordinator + .execute( + &mut client, + tombstone_request( + &tombstone_plan, + "stale-key", + &claims, + &stale_id, + "\"rs-stale\"", + ), + ) + .await; + assert!( + matches!(stale, Err(MutationError::PreconditionFailed)), + "stale tombstone ETag was not refused value-free" + ); + assert_audited_refusal_only( + before_stale, + durable_counts(&fixture.database, &fixture.table).await, + ); + + let changed_context = fixture + .coordinator + .execute( + &mut client, + tombstone_request( + &tombstone_plan, + "changed-context-seed-key", + &claims, + &stale_id, + &response_header(&stale_seed, PermittedResponseHeader::Etag), + ), + ) + .await + .expect("first tombstone commits"); + let other_claims = mutation_claims(&fixture.compiled, "other-principal"); + let before_changed_context = durable_counts(&fixture.database, &fixture.table).await; + let changed_context_reuse = fixture + .coordinator + .execute( + &mut client, + tombstone_request( + &tombstone_plan, + "changed-context-seed-key", + &other_claims, + &stale_id, + &response_header(&stale_seed, PermittedResponseHeader::Etag), + ), + ) + .await; + assert!( + matches!( + changed_context_reuse, + Err(MutationError::IdempotencyConflict) + ), + "changed idempotency context was not refused value-free" + ); + assert_audited_refusal_only( + before_changed_context, + durable_counts(&fixture.database, &fixture.table).await, + ); + + let before_already = durable_counts(&fixture.database, &fixture.table).await; + let already = fixture + .coordinator + .execute( + &mut client, + tombstone_request( + &tombstone_plan, + "already-key", + &claims, + &stale_id, + &response_header(&changed_context, PermittedResponseHeader::Etag), + ), + ) + .await; + assert!( + matches!(already, Err(MutationError::PreconditionFailed)), + "already tombstoned row was not refused value-free" + ); + assert_audited_refusal_only( + before_already, + durable_counts(&fixture.database, &fixture.table).await, + ); + + for (index, fault) in [ + MutationFaultPoint::BeforeCurrentRow, + MutationFaultPoint::BeforeRevision, + MutationFaultPoint::BeforeOutbox, + MutationFaultPoint::BeforeTerminalAudit, + MutationFaultPoint::BeforeIdempotency, + MutationFaultPoint::BeforeCommit, + ] + .into_iter() + .enumerate() + { + let seed = fixture + .coordinator + .execute( + &mut client, + create_request( + &create_plan, + &format!("fault-seed-key-{index}"), + &claims, + &format!("fault-seed-{index}"), + ), + ) + .await + .expect("fault seed commits"); + let seed_id = response_id(&seed); + let before = durable_counts(&fixture.database, &fixture.table).await; + let failed = fixture + .coordinator + .execute_with_fault( + &mut client, + tombstone_request( + &tombstone_plan, + &format!("fault-key-{index}"), + &claims, + &seed_id, + &response_header(&seed, PermittedResponseHeader::Etag), + ), + fault, + ) + .await; + assert!( + matches!(failed, Err(MutationError::Unavailable)), + "fault injection did not fail value-free" + ); + assert_eq!( + durable_counts(&fixture.database, &fixture.table).await, + DurableCounts { + audit: before.audit + 1, + ..before + } + ); + assert_current_row_active(&fixture.database, &fixture.table, &seed_id).await; + } + + let recovery_seed = fixture + .coordinator + .execute( + &mut client, + create_request(&create_plan, "recovery-seed-key", &claims, "recovery-seed"), + ) + .await + .expect("recovery seed commits"); + let recovery_id = response_id(&recovery_seed); + let before_recovery = durable_counts(&fixture.database, &fixture.table).await; + let lost = fixture + .coordinator + .execute_with_fault( + &mut client, + tombstone_request( + &tombstone_plan, + "recovery-key", + &claims, + &recovery_id, + &response_header(&recovery_seed, PermittedResponseHeader::Etag), + ), + MutationFaultPoint::AfterCommitBeforeResponseRelease, + ) + .await; + assert!( + matches!(lost, Err(MutationError::Unavailable)), + "post-commit lost response fault did not fail value-free" + ); + assert_one_complete_effect( + before_recovery, + durable_counts(&fixture.database, &fixture.table).await, + 0, + ); + let recovery_replay = fixture + .coordinator + .execute( + &mut client, + tombstone_request( + &tombstone_plan, + "recovery-key", + &claims, + &recovery_id, + &response_header(&recovery_seed, PermittedResponseHeader::Etag), + ), + ) + .await + .expect("post-commit lost response replays"); + assert!(recovery_replay.replayed()); + + let concurrent_seed = fixture + .coordinator + .execute( + &mut client, + create_request( + &create_plan, + "concurrent-seed-key", + &claims, + "concurrent-seed", + ), + ) + .await + .expect("concurrent seed commits"); + let concurrent_id = response_id(&concurrent_seed); + let concurrent_etag = response_header(&concurrent_seed, PermittedResponseHeader::Etag); + let before_concurrent = durable_counts(&fixture.database, &fixture.table).await; + let mut first = pool + .get_for_test() + .await + .expect("first concurrent connection is available"); + let mut second = pool + .get_for_test() + .await + .expect("second concurrent connection is available"); + let (left, right) = tokio::join!( + fixture.coordinator.execute( + &mut first, + tombstone_request( + &tombstone_plan, + "concurrent-key-one", + &claims, + &concurrent_id, + &concurrent_etag, + ), + ), + fixture.coordinator.execute( + &mut second, + tombstone_request( + &tombstone_plan, + "concurrent-key-two", + &claims, + &concurrent_id, + &concurrent_etag, + ), + ), + ); + let successes = [&left, &right] + .iter() + .filter(|result| result.is_ok()) + .count(); + let stale = [&left, &right] + .iter() + .filter(|result| matches!(result, Err(MutationError::PreconditionFailed))) + .count(); + assert_eq!(successes, 1); + assert_eq!(stale, 1); + assert_eq!( + durable_counts(&fixture.database, &fixture.table).await, + DurableCounts { + revisions: before_concurrent.revisions + 1, + outbox: before_concurrent.outbox + 1, + audit: before_concurrent.audit + 4, + idempotency: before_concurrent.idempotency + 1, + ..before_concurrent + } + ); + + fixture.database.cleanup().await; +} + +struct Fixture { + database: TestDatabase, + compiled: registry_server::CompiledRegistry, + identity: ExpectedRegistryIdentity, + coordinator: MutationCoordinator, + lock_key: RegistryLockKey, + profile: AuditProfile, + table: String, +} + +impl Fixture { + async fn start(pool_size: usize, package_revision: &str, package_sequence: i64) -> Self { + let database = TestDatabase::create(pool_size).await; + let (migration, migration_task) = database.connect_migration().await; + let compiled = compiled_registry(true); + install_compiled_schema(&migration, &compiled, &database.runtime_role) + .await + .expect("migration installs schema"); + let catalog = ExpectedManagedCatalog::compiled(&compiled); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision, + package_sequence, + }, + ) + .await + .expect("migration initializes state"); + migration_task.abort(); + let profile = AuditProfile::production_from_secret_bytes(vec![0x63; 32].into()) + .expect("test owns keyed audit profile"); + let lock_key = RegistryLockKey::derive(PACKAGE_ID).expect("lock id is bounded"); + let coordinator = MutationCoordinator::new( + lock_key, + Duration::from_secs(2), + identity.clone(), + profile.clone(), + ); + let table = compiled.entities()["widget"].physical_table.clone(); + Self { + database, + compiled, + identity, + coordinator, + lock_key, + profile, + table, + } + } +} + +fn compiled_registry(tombstone: bool) -> registry_server::CompiledRegistry { + let tombstone_fragment = if tombstone { + r#","tombstone":true"# + } else { + "" + }; + let operations = if tombstone { + r#""create","get","list","patch","tombstone""# + } else { + r#""create","get","list","patch""# + }; + let events = if tombstone { + r#", + "events":[ + {"id":"widget-created","trigger":"created","projection":["label"]}, + {"id":"widget-patched","trigger":"patched","projection":["label","quantity"]}, + {"id":"widget-tombstoned","trigger":"tombstoned","projection":["label","quantity"]} + ]"# + } else { + r#", + "events":[ + {"id":"widget-created","trigger":"created","projection":["label"]}, + {"id":"widget-patched","trigger":"patched","projection":["label","quantity"]} + ]"# + }; + let project = parse_project_json( + format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"tombstone-registry","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"widget","route":"widgets","mutationMode":"mutable"{tombstone_fragment},"classification":"public", + "fields":[ + {{"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}}, + {{"id":"label","type":"string","maxLength":128,"required":true,"classification":"public"}}, + {{"id":"quantity","type":"int64","required":true,"classification":"public"}} + ], + "accessProfiles":[{{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "operations":[{operations}], + "readableFields":["jurisdiction","label","quantity"], + "writableFields":["jurisdiction","label","quantity"], + "rowBoundaries":[{{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}}] + }}]{events} + }},{{ + "id":"log","route":"logs","mutationMode":"create_only","classification":"public", + "fields":[ + {{"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}}, + {{"id":"message","type":"string","maxLength":128,"required":true,"classification":"public"}} + ], + "accessProfiles":[{{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "operations":["create","get","list"], + "readableFields":["jurisdiction","message"], + "writableFields":["jurisdiction","message"], + "rowBoundaries":[{{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}}] + }}] + }}] + }}"# + ) + .as_bytes(), + ) + .expect("fixture parses"); + let compiled = compile_project(&project, &[], CompileProfile::Authoring) + .expect("fixture compiles to trusted inventories"); + assert_eq!( + compiled.routes().routes.iter().any(|route| { + route.id == "records.widget.tombstone" && route.operation == Operation::Tombstone + }), + tombstone + ); + compiled +} + +fn mutation_claims(registry: ®istry_server::CompiledRegistry, principal: &str) -> ClaimContext { + ClaimContext::for_compiled( + registry, + "widget", + Some(principal.to_owned()), + "operator", + Some("case-management".to_owned()), + vec![RowBoundaryContext::Equals { + field: "jurisdiction".to_owned(), + value: "zone-a".to_owned(), + }], + ) + .expect("claim context is compiler-bound") +} + +fn create_request<'a>( + plan: &'a MutationPlan, + key: &'a str, + claims: &'a ClaimContext, + label: &str, +) -> MutationRequest<'a> { + MutationRequest { + plan, + idempotency_key: key, + claims, + record_id: None, + expected_etag: None, + body: MutationBody::Create(Map::from_iter([ + ( + "jurisdiction".to_owned(), + Value::String("zone-a".to_owned()), + ), + ("label".to_owned(), Value::String(label.to_owned())), + ("quantity".to_owned(), json!(7)), + ])), + response_fields: response_fields(), + } +} + +fn patch_request<'a>( + plan: &'a MutationPlan, + key: &'a str, + claims: &'a ClaimContext, + record_id: &'a str, + expected_etag: &'a str, + label: &str, +) -> MutationRequest<'a> { + MutationRequest { + plan, + idempotency_key: key, + claims, + record_id: Some(record_id), + expected_etag: Some(expected_etag), + body: MutationBody::Patch(vec![PatchOperation::Replace { + path: "/data/label".to_owned(), + value: Value::String(label.to_owned()), + }]), + response_fields: response_fields(), + } +} + +fn tombstone_request<'a>( + plan: &'a MutationPlan, + key: &'a str, + claims: &'a ClaimContext, + record_id: &'a str, + expected_etag: &'a str, +) -> MutationRequest<'a> { + MutationRequest { + plan, + idempotency_key: key, + claims, + record_id: Some(record_id), + expected_etag: Some(expected_etag), + body: MutationBody::Tombstone, + response_fields: response_fields(), + } +} + +fn response_fields() -> BTreeSet { + BTreeSet::from(["label".to_owned(), "quantity".to_owned()]) +} + +fn response_id(outcome: &MutationOutcome) -> String { + let body: Value = + serde_json::from_slice(outcome.response().body()).expect("mutation response is JSON"); + body["id"] + .as_str() + .expect("response includes id") + .to_owned() +} + +fn response_revision(outcome: &MutationOutcome) -> i64 { + let body: Value = + serde_json::from_slice(outcome.response().body()).expect("mutation response is JSON"); + body["revision"] + .as_i64() + .expect("response includes revision") +} + +fn response_header(outcome: &MutationOutcome, header: PermittedResponseHeader) -> String { + String::from_utf8(outcome.response().headers()[&header].clone()).expect("header is UTF-8") +} + +fn response_etag( + profile: &AuditProfile, + claims: &ClaimContext, + package_revision: &str, + record_id: &str, + record_revision: i64, + response_fields: &BTreeSet, +) -> String { + let authorization_context = canonical_claim_context(profile, claims, package_revision); + let etag_input = canonicalize_json(&json!({ + "authorizationContext": authorization_context, + "packageRevision": package_revision, + "recordId": record_id, + "recordRevision": record_revision, + "responseFields": response_fields, + })) + .expect("etag input is canonical"); + let etag_input = std::str::from_utf8(&etag_input).expect("canonical JSON is UTF-8"); + let hash = profile + .key_hasher() + .audit_reference_hash( + "registry-server-response-etag-v1", + package_revision, + etag_input, + ) + .expect("etag reference hashes"); + format!("\"rs-{hash}\"") +} + +fn canonical_claim_context( + profile: &AuditProfile, + context: &ClaimContext, + package_revision: &str, +) -> Value { + let principal_reference = profile + .key_hasher() + .audit_reference_hash( + "registry-server-principal-v1", + package_revision, + context.principal().expect("principal is present"), + ) + .expect("principal reference hashes"); + let row_boundaries = context + .row_boundaries() + .iter() + .map(|boundary| { + let reference_context = format!( + "{package_revision}:{}:{}", + boundary.field(), + boundary.operator().as_str() + ); + let value_references = boundary + .values() + .into_iter() + .map(|value| { + profile.key_hasher().audit_reference_hash( + "registry-server-row-boundary-value-v1", + &reference_context, + value, + ) + }) + .collect::, _>>() + .expect("row-boundary references hash"); + json!({ + "field": boundary.field(), + "operator": boundary.operator().as_str(), + "valueReferences": value_references, + }) + }) + .collect::>(); + json!({ + "entityId": context.entity_id(), + "principalReference": principal_reference, + "selectedAccessProfile": context.access_profile(), + "verifiedPurpose": context.purpose(), + "rowBoundaries": row_boundaries, + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DurableCounts { + current: i64, + revisions: i64, + outbox: i64, + audit: i64, + idempotency: i64, +} + +fn assert_one_complete_effect(before: DurableCounts, after: DurableCounts, current_delta: i64) { + assert_eq!( + after, + DurableCounts { + current: before.current + current_delta, + revisions: before.revisions + 1, + outbox: before.outbox + 1, + audit: before.audit + 2, + idempotency: before.idempotency + 1, + } + ); +} + +fn assert_audited_replay_only(before: DurableCounts, after: DurableCounts) { + assert_eq!( + after, + DurableCounts { + audit: before.audit + 2, + ..before + } + ); +} + +fn assert_audited_refusal_only(before: DurableCounts, after: DurableCounts) { + assert_eq!( + after, + DurableCounts { + audit: before.audit + 2, + ..before + } + ); +} + +async fn durable_counts(database: &TestDatabase, table: &str) -> DurableCounts { + let row = database + .admin + .query_one( + &format!( + "SELECT + (SELECT count(*) FROM registry_data.\"{table}\"), + (SELECT count(*) FROM registry_internal.registry_revisions), + (SELECT count(*) FROM registry_internal.registry_outbox), + (SELECT count(*) FROM registry_internal.registry_audit), + (SELECT count(*) FROM registry_internal.registry_idempotency)" + ), + &[], + ) + .await + .expect("administrator can inspect durable state"); + DurableCounts { + current: row.get(0), + revisions: row.get(1), + outbox: row.get(2), + audit: row.get(3), + idempotency: row.get(4), + } +} + +async fn assert_current_row_tombstoned(database: &TestDatabase, table: &str, record_id: &str) { + let lifecycle: String = database + .admin + .query_one( + &format!( + "SELECT record_lifecycle FROM registry_data.\"{table}\" + WHERE record_id = $1::text::uuid" + ), + &[&record_id], + ) + .await + .expect("administrator can inspect current row") + .get(0); + assert_eq!(lifecycle, "tombstoned"); +} + +async fn assert_current_row_active(database: &TestDatabase, table: &str, record_id: &str) { + let lifecycle: String = database + .admin + .query_one( + &format!( + "SELECT record_lifecycle FROM registry_data.\"{table}\" + WHERE record_id = $1::text::uuid" + ), + &[&record_id], + ) + .await + .expect("administrator can inspect current row") + .get(0); + assert_eq!(lifecycle, "active"); +} + +async fn assert_three_revisions_one_record_across_package_upgrade( + database: &TestDatabase, + record_id: &str, +) { + let rows = database + .admin + .query( + "SELECT record_id::text, record_reference, record_revision, + predecessor_revision, record_lifecycle, package_revision, + operation_id, mutation_kind, snapshot + FROM registry_internal.registry_revisions + WHERE entity_id = 'widget' + ORDER BY record_revision", + &[], + ) + .await + .expect("administrator can inspect revisions"); + assert_eq!(rows.len(), 3); + let mut references = BTreeSet::new(); + for row in &rows { + assert!(row.get::<_, String>(0) == record_id); + references.insert(row.get::<_, String>(1)); + } + assert!(references.len() >= 2); + assert_eq!(rows[0].get::<_, i64>(2), 1); + assert_eq!(rows[0].get::<_, Option>(3), None); + assert_eq!(rows[0].get::<_, String>(4), "active"); + assert_eq!(rows[0].get::<_, String>(5), "package-tombstone-1"); + assert_eq!(rows[0].get::<_, String>(6), "records.widget.create"); + assert_eq!(rows[0].get::<_, String>(7), "create"); + assert_eq!(rows[1].get::<_, i64>(2), 2); + assert_eq!(rows[1].get::<_, Option>(3), Some(1)); + assert_eq!(rows[1].get::<_, String>(5), "package-tombstone-2"); + assert_eq!(rows[1].get::<_, String>(7), "patch"); + assert_eq!(rows[2].get::<_, i64>(2), 3); + assert_eq!(rows[2].get::<_, Option>(3), Some(2)); + assert_eq!(rows[2].get::<_, String>(4), "tombstoned"); + assert_eq!(rows[2].get::<_, String>(5), "package-tombstone-2"); + assert_eq!(rows[2].get::<_, String>(6), "records.widget.tombstone"); + assert_eq!(rows[2].get::<_, String>(7), "tombstone"); + assert!( + rows[2].get::<_, Vec>(8) + == br#"{"jurisdiction":"zone-a","label":"patched-label","quantity":7}"#.as_slice(), + "canonical tombstone revision snapshot did not match expected bytes" + ); +} + +async fn assert_tombstone_event_is_canonical(database: &TestDatabase) { + let row = database + .admin + .query_one( + "SELECT event_id::text, event_type, trigger, entity_id, record_revision, + package_revision, schema_fingerprint, payload + FROM registry_internal.registry_outbox + WHERE event_type = 'widget-tombstoned'", + &[], + ) + .await + .expect("administrator can inspect outbox"); + let event_id: String = row.get(0); + assert_eq!(event_id.len(), 36); + assert_eq!(row.get::<_, String>(1), "widget-tombstoned"); + assert_eq!(row.get::<_, String>(2), "tombstoned"); + assert_eq!(row.get::<_, String>(3), "widget"); + assert_eq!(row.get::<_, i64>(4), 3); + assert_eq!(row.get::<_, String>(5), "package-tombstone-2"); + assert!(row.get::<_, String>(6).starts_with("sha256:")); + assert!( + row.get::<_, Vec>(7) == br#"{"label":"patched-label","quantity":7}"#.as_slice(), + "canonical tombstone outbox projection did not match expected bytes" + ); +} + +async fn tombstone_event_id(database: &TestDatabase) -> String { + database + .admin + .query_one( + "SELECT event_id::text + FROM registry_internal.registry_outbox + WHERE event_type = 'widget-tombstoned'", + &[], + ) + .await + .expect("administrator can inspect event id") + .get(0) +} + +async fn assert_revision_provenance_is_keyed(database: &TestDatabase) { + let rows = database + .admin + .query( + "SELECT principal_reference, request_reference + FROM registry_internal.registry_revisions", + &[], + ) + .await + .expect("administrator can inspect revision provenance"); + assert!(!rows.is_empty()); + let mut requests = BTreeSet::new(); + for row in rows { + let principal: String = row.get(0); + let request: String = row.get(1); + assert_eq!(principal.len(), 76); + assert_eq!(request.len(), 76); + assert!(principal.starts_with("hmac-sha256:")); + assert!(request.starts_with("hmac-sha256:")); + assert!(!principal.contains(PRINCIPAL_CANARY)); + assert!(!request.contains(IDEMPOTENCY_CANARY)); + requests.insert(request); + } + assert!(requests.len() >= 3); +} + +async fn assert_audit_excludes_raw_values(database: &TestDatabase, forbidden: &[&str]) { + let rows = database + .admin + .query("SELECT envelope FROM registry_internal.registry_audit", &[]) + .await + .expect("administrator can inspect audit"); + let audit_text = rows + .iter() + .map(|row| String::from_utf8(row.get::<_, Vec>(0)).expect("audit is UTF-8")) + .collect::>() + .join("\n"); + for value in forbidden { + assert!(!audit_text.contains(value)); + } +} diff --git a/crates/registry-server/tests/postgres_webhook_delivery.rs b/crates/registry-server/tests/postgres_webhook_delivery.rs new file mode 100644 index 0000000000..6eda17dda5 --- /dev/null +++ b/crates/registry-server/tests/postgres_webhook_delivery.rs @@ -0,0 +1,1438 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::Engine as _; +use hmac::{Hmac, KeyInit, Mac}; +use postgres_harness::TestDatabase; +use rcgen::{generate_simple_self_signed, CertifiedKey}; +use registry_platform_audit::AuditProfile; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::event_destination::ActivatedEventDestinationRegistry; +use registry_server::mutation::{MutationBody, MutationCoordinator, MutationPlan, MutationRequest}; +use registry_server::postgres::{ + install_compiled_schema, ClaimContext, ExpectedRegistryIdentity, RegistryLockKey, + RowBoundaryContext, +}; +use registry_server::runtime_config::parse_runtime_config; +use registry_server::webhook::{WebhookDeliveryError, WebhookDeliveryService, WebhookWorkOutcome}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::{oneshot, Mutex, Notify}; +use tokio_rustls::rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; +use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::TlsAcceptor; +use uuid::Uuid; + +const PACKAGE_REVISION: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SCHEMA_FINGERPRINT: &str = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const DESTINATION_ID: &str = "case-operations"; +const DELIVERY_PATH: &str = "/registry-events"; +const HMAC_KEY: &[u8] = b"webhook-delivery-signing-key-0123456789abcdef"; +const RECORD_VALUE_CANARY: &str = "restricted-record-value-canary"; +const KEY_REF_CANARY: &str = "webhook-signing-key-canary"; +const CA_REF_CANARY: &str = "webhook-ca-bundle-canary"; +const SIGNATURE_DOMAIN: &[u8] = b"registry-server-webhook-signature-v1"; + +type HmacSha256 = Hmac; + +#[tokio::test(flavor = "multi_thread", worker_threads = 6)] +async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined( +) { + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::WARN) + .with_test_writer() + .try_init(); + let receiver = HttpsReceiver::start().await; + let database = TestDatabase::create(12).await; + let (migration, migration_task) = database.connect_migration().await; + let compiled = compiled_registry(); + install_compiled_schema(&migration, &compiled, &database.runtime_role) + .await + .expect("migration installs delivery state with the compiled schema"); + let identity = expected_identity(); + initialize_registry_state(&migration, &identity).await; + migration_task.abort(); + + let fixture = DestinationFixture::new(&receiver); + let destinations = Arc::new(fixture.activate(&compiled)); + let compiled_delivery = compiled.event_deliveries().deliveries[0].clone(); + let destination_binding_digest = destinations + .lookup(DESTINATION_ID) + .expect("the exact compiled destination is activated") + .binding_digest() + .to_owned(); + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let audit_profile = AuditProfile::production_from_secret_bytes(vec![0x6b; 32].into()) + .expect("test owns a keyed audit profile"); + let lock_key = RegistryLockKey::derive("webhook-delivery-registry") + .expect("test lock identity is bounded"); + let coordinator = MutationCoordinator::new_with_event_destinations( + lock_key, + Duration::from_secs(2), + identity.clone(), + audit_profile.clone(), + Some(Arc::clone(&destinations)), + ); + let service = WebhookDeliveryService::new( + pool.clone(), + Arc::clone(&destinations), + identity.clone(), + lock_key, + Duration::from_secs(2), + audit_profile.clone(), + ); + let plan = MutationPlan::from_compiled(&compiled, "records.case.create") + .expect("create plan retains the exact compiler delivery"); + let claims = mutation_claims(&compiled); + let mut mutation_client = pool + .get_for_test() + .await + .expect("runtime mutation connection is available"); + + receiver.enqueue(ResponsePlan::Status(500)).await; + receiver.enqueue(ResponsePlan::Status(204)).await; + let first = create_event( + &database, + &coordinator, + &mut mutation_client, + &plan, + &claims, + "delivery-first", + "first", + ) + .await; + assert_seed_is_exact( + &database, + &first, + &compiled_delivery, + &destination_binding_digest, + &identity, + ) + .await; + + let (worker_a, worker_b) = tokio::join!(service.deliver_once(), service.deliver_once()); + let outcomes = [ + worker_a.expect("first worker returns a closed outcome"), + worker_b.expect("second worker returns a closed outcome"), + ]; + assert!(outcomes.contains(&WebhookWorkOutcome::RetryScheduled)); + assert!(outcomes.contains(&WebhookWorkOutcome::Idle)); + receiver.wait_for_count(1).await; + let first_attempt = receiver.request(0).await; + assert_exact_request(&first_attempt, &first).await; + let next_attempt_at = delivery_next_attempt_at(&database, &first).await; + let attempt_started_at = header_time(&first_attempt, "x-registry-event-timestamp"); + assert_eq!( + next_attempt_at + .duration_since(attempt_started_at) + .expect("retry is scheduled after its exact attempt start"), + Duration::from_millis(100), + "retry uses the exact compiler-produced delay from the original attempt start" + ); + + tokio::time::sleep(Duration::from_millis(120)).await; + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::Delivered) + ); + receiver.wait_for_count(2).await; + let second_attempt = receiver.request(1).await; + assert_exact_request(&second_attempt, &first).await; + assert_eq!( + header(&first_attempt, "idempotency-key"), + header(&second_attempt, "idempotency-key"), + "idempotency is stable across attempts in one generation" + ); + assert_eq!( + delivery_state(&database, &first).await, + (1, "delivered".to_owned(), 2) + ); + assert_exact_audit_outcome( + &database, + &audit_profile, + &first, + 1, + 1, + "terminal", + "http_non_success", + ) + .await; + assert_exact_audit_outcome( + &database, + &audit_profile, + &first, + 1, + 2, + "terminal", + "delivered", + ) + .await; + + receiver + .enqueue(ResponsePlan::Delay(Duration::from_millis(250), 204)) + .await; + receiver + .enqueue(ResponsePlan::Delay(Duration::from_millis(250), 204)) + .await; + let timeout_event = create_event( + &database, + &coordinator, + &mut mutation_client, + &plan, + &claims, + "delivery-timeout", + "timeout", + ) + .await; + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::RetryScheduled) + ); + tokio::time::sleep(Duration::from_millis(120)).await; + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::DeadLettered) + ); + receiver.wait_for_count(4).await; + let timeout_first = receiver.request(2).await; + let timeout_second = receiver.request(3).await; + assert_eq!( + header(&timeout_first, "idempotency-key"), + header(&timeout_second, "idempotency-key") + ); + assert_eq!( + delivery_state(&database, &timeout_event).await, + (1, "dead_lettered".to_owned(), 2) + ); + assert_exact_audit_outcome( + &database, + &audit_profile, + &timeout_event, + 1, + 1, + "terminal", + "destination_timeout", + ) + .await; + assert_exact_audit_outcome( + &database, + &audit_profile, + &timeout_event, + 1, + 2, + "terminal", + "destination_timeout", + ) + .await; + + service + .replay( + timeout_event.event_id, + &timeout_event.compiled_delivery_id, + 1, + ) + .await + .expect("compiled operator replay resets one terminal generation"); + assert_eq!( + service + .replay( + timeout_event.event_id, + &timeout_event.compiled_delivery_id, + 1, + ) + .await, + Err(WebhookDeliveryError::Unavailable), + "a stale generation and a nonterminal generation share one refusal" + ); + receiver.enqueue(ResponsePlan::Status(204)).await; + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::Delivered) + ); + receiver.wait_for_count(5).await; + let replay_request = receiver.request(4).await; + assert_ne!( + header(&timeout_first, "idempotency-key"), + header(&replay_request, "idempotency-key"), + "operator replay changes the deterministic generation binding" + ); + assert_exact_audit_outcome( + &database, + &audit_profile, + &timeout_event, + 2, + 0, + "replay", + "replay_requested", + ) + .await; + assert_exact_audit_outcome( + &database, + &audit_profile, + &timeout_event, + 2, + 1, + "terminal", + "delivered", + ) + .await; + database + .admin + .execute( + "UPDATE registry_internal.registry_webhook_deliveries + SET operator_replay = false + WHERE event_id = $1 AND compiled_delivery_id = $2", + &[&timeout_event.event_id, &timeout_event.compiled_delivery_id], + ) + .await + .expect("administrator installs a compiled-forbidden replay canary"); + assert_eq!( + service + .replay( + timeout_event.event_id, + &timeout_event.compiled_delivery_id, + 2, + ) + .await, + Err(WebhookDeliveryError::Unavailable) + ); + + receiver.enqueue(ResponsePlan::Status(204)).await; + let recovered = create_event( + &database, + &coordinator, + &mut mutation_client, + &plan, + &claims, + "delivery-recovery", + "recovered", + ) + .await; + let stale_token = Uuid::new_v4(); + database + .admin + .execute( + "UPDATE registry_internal.registry_webhook_delivery_state + SET state = 'leased', attempt = 1, next_attempt_at = NULL, + attempt_started_at = transaction_timestamp() - interval '10 seconds', + lease_expires_at = transaction_timestamp() - interval '5 seconds', + lease_token = $3, updated_at = transaction_timestamp() + WHERE event_id = $1 AND compiled_delivery_id = $2", + &[ + &recovered.event_id, + &recovered.compiled_delivery_id, + &stale_token, + ], + ) + .await + .expect("administrator simulates one expired post-audit lease"); + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::Delivered), + "recovery consumes the interrupted attempt and claims the next bounded attempt" + ); + receiver.wait_for_count(6).await; + assert_eq!( + header(&receiver.request(5).await, "x-registry-delivery-attempt"), + "2" + ); + let stale_changed = database + .admin + .execute( + "UPDATE registry_internal.registry_webhook_delivery_state + SET state = 'pending', next_attempt_at = transaction_timestamp(), + attempt_started_at = NULL, lease_expires_at = NULL, lease_token = NULL, + delivered_at = NULL, updated_at = transaction_timestamp() + WHERE event_id = $1 AND compiled_delivery_id = $2 + AND generation = 1 AND attempt = 1 AND state = 'leased' + AND lease_token = $3", + &[ + &recovered.event_id, + &recovered.compiled_delivery_id, + &stale_token, + ], + ) + .await + .expect("stale-worker CAS probe executes"); + assert_eq!( + stale_changed, 0, + "an expired worker token has no transition authority" + ); + assert_exact_audit_outcome( + &database, + &audit_profile, + &recovered, + 1, + 1, + "terminal", + "worker_interrupted", + ) + .await; + assert_exact_audit_outcome( + &database, + &audit_profile, + &recovered, + 1, + 2, + "terminal", + "delivered", + ) + .await; + + receiver.enqueue(ResponsePlan::Break).await; + receiver.enqueue(ResponsePlan::Break).await; + let transport_event = create_event( + &database, + &coordinator, + &mut mutation_client, + &plan, + &claims, + "delivery-transport-unavailable", + "transport", + ) + .await; + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::RetryScheduled) + ); + tokio::time::sleep(Duration::from_millis(120)).await; + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::DeadLettered) + ); + receiver.wait_for_count(8).await; + assert_exact_audit_outcome( + &database, + &audit_profile, + &transport_event, + 1, + 1, + "terminal", + "destination_transport_unavailable", + ) + .await; + assert_exact_audit_outcome( + &database, + &audit_profile, + &transport_event, + 1, + 2, + "terminal", + "destination_transport_unavailable", + ) + .await; + + let egress_before_refusals = receiver.count().await; + let binding_refused = create_event( + &database, + &coordinator, + &mut mutation_client, + &plan, + &claims, + "delivery-binding-refused", + "binding", + ) + .await; + database + .admin + .execute( + "UPDATE registry_internal.registry_webhook_deliveries + SET destination_binding_digest = $3 + WHERE event_id = $1 AND compiled_delivery_id = $2", + &[ + &binding_refused.event_id, + &binding_refused.compiled_delivery_id, + &"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + ], + ) + .await + .expect("administrator installs a binding mismatch canary"); + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::RetryScheduled) + ); + assert_eq!(receiver.count().await, egress_before_refusals); + assert_exact_audit_outcome( + &database, + &audit_profile, + &binding_refused, + 1, + 1, + "terminal", + "destination_binding_refused", + ) + .await; + tokio::time::sleep(Duration::from_millis(120)).await; + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::DeadLettered) + ); + assert_eq!(receiver.count().await, egress_before_refusals); + assert_exact_audit_outcome( + &database, + &audit_profile, + &binding_refused, + 1, + 2, + "terminal", + "destination_binding_refused", + ) + .await; + + let payload_refused = create_event( + &database, + &coordinator, + &mut mutation_client, + &plan, + &claims, + "delivery-payload-refused", + "payload", + ) + .await; + database + .admin + .execute( + "UPDATE registry_internal.registry_outbox + SET payload = $2 + WHERE event_id = $1", + &[ + &payload_refused.event_id, + &br#"{"label":"tampered"}"#.as_slice(), + ], + ) + .await + .expect("administrator installs a payload tamper canary"); + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::RetryScheduled) + ); + assert_eq!(receiver.count().await, egress_before_refusals); + assert_exact_audit_outcome( + &database, + &audit_profile, + &payload_refused, + 1, + 1, + "terminal", + "payload_refused", + ) + .await; + tokio::time::sleep(Duration::from_millis(120)).await; + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::DeadLettered) + ); + assert_eq!(receiver.count().await, egress_before_refusals); + assert_exact_audit_outcome( + &database, + &audit_profile, + &payload_refused, + 1, + 2, + "terminal", + "payload_refused", + ) + .await; + + let audit_refused = create_event( + &database, + &coordinator, + &mut mutation_client, + &plan, + &claims, + "delivery-audit-refused", + "audit", + ) + .await; + revoke_audit_insert(&database).await; + assert_eq!( + service.deliver_once().await, + Err(WebhookDeliveryError::Unavailable) + ); + assert_eq!(receiver.count().await, egress_before_refusals); + assert_eq!( + delivery_state(&database, &audit_refused).await, + (1, "pending".to_owned(), 0) + ); + grant_audit_insert(&database).await; + receiver.enqueue(ResponsePlan::Status(204)).await; + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::Delivered) + ); + receiver.wait_for_count(egress_before_refusals + 1).await; + assert_eq!( + delivery_state(&database, &audit_refused).await, + (1, "delivered".to_owned(), 1) + ); + assert_exact_audit_outcome( + &database, + &audit_profile, + &audit_refused, + 1, + 1, + "terminal", + "delivered", + ) + .await; + let terminal_egress_before = receiver.count().await; + + receiver + .enqueue(ResponsePlan::Delay(Duration::from_millis(50), 204)) + .await; + let terminal_audit_refused = create_event( + &database, + &coordinator, + &mut mutation_client, + &plan, + &claims, + "delivery-terminal-audit-refused", + "terminal-audit", + ) + .await; + let service_for_terminal_fault = service.clone(); + let attempt = tokio::spawn(async move { service_for_terminal_fault.deliver_once().await }); + receiver.wait_for_count(terminal_egress_before + 1).await; + revoke_audit_insert(&database).await; + assert_eq!( + attempt.await.expect("terminal audit fault task joins"), + Err(WebhookDeliveryError::Unavailable) + ); + grant_audit_insert(&database).await; + assert_eq!( + delivery_state(&database, &terminal_audit_refused).await, + (1, "leased".to_owned(), 1), + "terminal audit refusal leaves the committed lease for expiry recovery" + ); + assert_no_audit_outcome( + &database, + &audit_profile, + &terminal_audit_refused, + 1, + 1, + "terminal", + ) + .await; + + assert_webhook_audits_are_closed_and_value_free(&database).await; + + drop(mutation_client); + drop(service); + drop(pool); + receiver.stop().await; + database.cleanup().await; +} + +#[derive(Clone)] +struct CapturedEvent { + event_id: Uuid, + compiled_delivery_id: String, + payload: Vec, +} + +async fn create_event( + database: &TestDatabase, + coordinator: &MutationCoordinator, + client: &mut deadpool_postgres::Client, + plan: &MutationPlan, + claims: &ClaimContext, + idempotency_key: &str, + label: &str, +) -> CapturedEvent { + coordinator + .execute( + client, + MutationRequest { + plan, + idempotency_key, + claims, + record_id: None, + expected_etag: None, + body: MutationBody::Create(Map::from_iter([ + ("jurisdiction".to_owned(), json!("zone-a")), + ("label".to_owned(), json!(label)), + ("restricted_note".to_owned(), json!(RECORD_VALUE_CANARY)), + ])), + response_fields: BTreeSet::from([ + "jurisdiction".to_owned(), + "label".to_owned(), + "restricted_note".to_owned(), + ]), + }, + ) + .await + .expect("record mutation atomically captures one webhook delivery"); + let row = database + .admin + .query_one( + "SELECT outbox.event_id, delivery.compiled_delivery_id, outbox.payload + FROM registry_internal.registry_outbox AS outbox + JOIN registry_internal.registry_webhook_deliveries AS delivery + ON delivery.event_id = outbox.event_id + ORDER BY outbox.outbox_id DESC + LIMIT 1", + &[], + ) + .await + .expect("administrator can inspect the newest capture identity"); + CapturedEvent { + event_id: row.get(0), + compiled_delivery_id: row.get(1), + payload: row.get(2), + } +} + +async fn assert_seed_is_exact( + database: &TestDatabase, + event: &CapturedEvent, + compiled: ®istry_server::model::CompiledEventDelivery, + binding_digest: &str, + identity: &ExpectedRegistryIdentity, +) { + let row = database + .admin + .query_one( + "SELECT delivery.destination_binding_digest, delivery.package_revision, + delivery.schema_fingerprint, delivery.payload_digest, + delivery.deployed_attempt_timeout_ms, + delivery.deployed_maximum_attempts, + delivery.retry_delays_ms, state.generation, state.state, state.attempt + FROM registry_internal.registry_webhook_deliveries AS delivery + JOIN registry_internal.registry_webhook_delivery_state AS state + ON state.event_id = delivery.event_id + AND state.compiled_delivery_id = delivery.compiled_delivery_id + WHERE delivery.event_id = $1 AND delivery.compiled_delivery_id = $2", + &[&event.event_id, &event.compiled_delivery_id], + ) + .await + .expect("one immutable capture and one mutable state seed join exactly"); + assert_eq!(row.get::<_, String>(0), binding_digest); + assert_eq!(row.get::<_, String>(1), identity.package_revision); + assert_eq!(row.get::<_, String>(2), identity.schema_fingerprint); + assert_eq!( + row.get::<_, Vec>(3), + Sha256::digest(&event.payload).to_vec() + ); + assert_eq!(row.get::<_, i64>(4), 100); + assert_eq!(row.get::<_, i16>(5), 2); + assert_eq!( + row.get::<_, Vec>(6), + compiled + .retry_delays_ms + .iter() + .copied() + .map(i64::from) + .collect::>() + ); + assert_eq!(row.get::<_, i64>(7), 1); + assert_eq!(row.get::<_, String>(8), "pending"); + assert_eq!(row.get::<_, i16>(9), 0); +} + +async fn delivery_state(database: &TestDatabase, event: &CapturedEvent) -> (i64, String, i16) { + let row = database + .admin + .query_one( + "SELECT generation, state, attempt + FROM registry_internal.registry_webhook_delivery_state + WHERE event_id = $1 AND compiled_delivery_id = $2", + &[&event.event_id, &event.compiled_delivery_id], + ) + .await + .expect("administrator can inspect the bounded delivery state"); + (row.get(0), row.get(1), row.get(2)) +} + +async fn delivery_next_attempt_at(database: &TestDatabase, event: &CapturedEvent) -> SystemTime { + database + .admin + .query_one( + "SELECT next_attempt_at + FROM registry_internal.registry_webhook_delivery_state + WHERE event_id = $1 AND compiled_delivery_id = $2", + &[&event.event_id, &event.compiled_delivery_id], + ) + .await + .expect("administrator can inspect the exact retry schedule") + .get(0) +} + +async fn revoke_audit_insert(database: &TestDatabase) { + database + .admin + .batch_execute(&format!( + "REVOKE INSERT ON registry_internal.registry_audit FROM \"{}\";", + database.runtime_role.as_str() + )) + .await + .expect("administrator injects an audit write fault"); +} + +async fn grant_audit_insert(database: &TestDatabase) { + database + .admin + .batch_execute(&format!( + "GRANT INSERT ON registry_internal.registry_audit TO \"{}\";", + database.runtime_role.as_str() + )) + .await + .expect("administrator restores audit write authority"); +} + +async fn assert_exact_audit_outcome( + database: &TestDatabase, + profile: &AuditProfile, + event: &CapturedEvent, + generation: i64, + attempt: i64, + phase: &str, + expected: &str, +) { + assert_eq!( + audit_outcomes(database, profile, event, generation, attempt, phase,).await, + [expected.to_owned()], + "one exact event/generation/attempt audit outcome is committed" + ); +} + +async fn assert_no_audit_outcome( + database: &TestDatabase, + profile: &AuditProfile, + event: &CapturedEvent, + generation: i64, + attempt: i64, + phase: &str, +) { + assert!( + audit_outcomes(database, profile, event, generation, attempt, phase,) + .await + .is_empty(), + "the refused terminal audit and transition are both absent" + ); +} + +async fn audit_outcomes( + database: &TestDatabase, + profile: &AuditProfile, + event: &CapturedEvent, + generation: i64, + attempt: i64, + phase: &str, +) -> Vec { + let event_reference = profile + .key_hasher() + .audit_reference_hash( + "registry-server-webhook-event-v1", + PACKAGE_REVISION, + &event.event_id.to_string(), + ) + .expect("test can derive the keyed event reference"); + database + .admin + .query( + "SELECT envelope + FROM registry_internal.registry_audit + ORDER BY created_at, envelope_id", + &[], + ) + .await + .expect("administrator can inspect minimized audit envelopes") + .into_iter() + .filter_map(|row| serde_json::from_slice::(&row.get::<_, Vec>(0)).ok()) + .filter_map(|envelope| envelope.get("record").cloned()) + .filter(|record| { + record.get("schema").and_then(Value::as_str) == Some("registry-server-webhook-audit/v1") + && record.get("eventReference").and_then(Value::as_str) + == Some(event_reference.as_str()) + && record.get("generation").and_then(Value::as_i64) == Some(generation) + && record.get("attempt").and_then(Value::as_i64) == Some(attempt) + && record.get("phase").and_then(Value::as_str) == Some(phase) + }) + .filter_map(|record| { + record + .get("outcome") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .collect() +} + +async fn assert_webhook_audits_are_closed_and_value_free(database: &TestDatabase) { + let audits = database + .admin + .query( + "SELECT convert_from(envelope, 'UTF8') FROM registry_internal.registry_audit", + &[], + ) + .await + .expect("administrator can inspect minimized audit envelopes") + .into_iter() + .map(|row| row.get::<_, String>(0)) + .filter(|envelope| envelope.contains("registry-server-webhook-audit/v1")) + .collect::>(); + assert!(!audits.is_empty()); + let joined = audits.join("\n"); + for forbidden in [ + DESTINATION_ID, + "https://localhost", + DELIVERY_PATH, + RECORD_VALUE_CANARY, + KEY_REF_CANARY, + CA_REF_CANARY, + std::str::from_utf8(HMAC_KEY).expect("test HMAC key is UTF-8"), + "x-registry-signature", + "upstream", + ] { + assert!( + !joined.contains(forbidden), + "webhook audit remains value-free" + ); + } +} + +fn header<'a>(request: &'a ReceivedRequest, name: &str) -> &'a str { + request + .headers + .get(name) + .map(String::as_str) + .expect("closed webhook header is present") +} + +fn header_time(request: &ReceivedRequest, name: &str) -> SystemTime { + SystemTime::from( + OffsetDateTime::parse(header(request, name), &Rfc3339) + .expect("webhook timestamp is strict RFC3339"), + ) +} + +async fn assert_exact_request(request: &ReceivedRequest, event: &CapturedEvent) { + assert_eq!(request.method, "POST"); + assert_eq!(request.target, DELIVERY_PATH); + assert!( + request.body == event.payload, + "request body is the exact captured canonical bytes" + ); + assert_eq!( + header(request, "x-registry-event-id"), + event.event_id.to_string() + ); + assert_eq!(header(request, "x-registry-event-type"), "case-created"); + assert_eq!(header(request, "x-registry-event-generation"), "1"); + assert_eq!(header(request, "content-type"), "application/json"); + let signature = independent_signature( + header(request, "x-registry-event-id"), + header(request, "x-registry-event-type"), + header(request, "x-registry-event-generation"), + header(request, "x-registry-delivery-attempt"), + header(request, "x-registry-event-timestamp"), + header(request, "idempotency-key"), + &request.body, + ); + assert_eq!(header(request, "x-registry-signature"), signature); +} + +fn independent_signature( + event_id: &str, + event_type: &str, + generation: &str, + attempt: &str, + timestamp: &str, + idempotency_key: &str, + body: &[u8], +) -> String { + let mut input = SIGNATURE_DOMAIN.to_vec(); + for value in [ + event_id.as_bytes(), + event_type.as_bytes(), + generation.as_bytes(), + attempt.as_bytes(), + timestamp.as_bytes(), + idempotency_key.as_bytes(), + body, + ] { + input.extend_from_slice(&(value.len() as u64).to_be_bytes()); + input.extend_from_slice(value); + } + let mut mac = HmacSha256::new_from_slice(HMAC_KEY).expect("test HMAC key is valid"); + mac.update(&input); + format!("v1={}", URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())) +} + +fn compiled_registry() -> registry_server::CompiledRegistry { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"webhook-delivery-registry","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"case","route":"cases","mutationMode":"create_only","classification":"restricted", + "fields":[ + {"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"label","type":"string","maxLength":64,"required":true,"classification":"internal"}, + {"id":"restricted_note","type":"string","maxLength":64,"required":true,"classification":"restricted"} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "operations":["create","get","list"], + "readableFields":["jurisdiction","label","restricted_note"], + "writableFields":["jurisdiction","label","restricted_note"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }], + "events":[{ + "id":"case-created","trigger":"created","projection":["label","restricted_note"], + "webhook":{ + "destinationId":"case-operations", + "classificationCeiling":"restricted", + "authenticationProfile":"hmac_sha256_v1", + "delivery":{ + "attemptTimeoutMs":100, + "initialBackoffMs":100, + "maximumBackoffMs":100, + "maximumAttempts":2, + "deadLetter":"required", + "operatorReplay":true + } + } + }] + }] + }"#, + ) + .expect("webhook delivery fixture parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("webhook delivery fixture compiles") +} + +fn expected_identity() -> ExpectedRegistryIdentity { + ExpectedRegistryIdentity { + package_id: "webhook-delivery-registry".to_owned(), + environment: "local".to_owned(), + instance_id: "webhook-delivery-instance".to_owned(), + database_id: "webhook-delivery-database".to_owned(), + package_revision: PACKAGE_REVISION.to_owned(), + schema_fingerprint: SCHEMA_FINGERPRINT.to_owned(), + package_sequence: 1, + } +} + +async fn initialize_registry_state( + migration: &tokio_postgres::Client, + identity: &ExpectedRegistryIdentity, +) { + let changed = migration + .execute( + "INSERT INTO registry_internal.registry_state + (singleton, package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status) + VALUES (true, $1, $2, $3, $4, $5, $6, $7, 'ready')", + &[ + &identity.package_id, + &identity.environment, + &identity.instance_id, + &identity.database_id, + &identity.package_revision, + &identity.schema_fingerprint, + &identity.package_sequence, + ], + ) + .await + .expect("migration initializes the exact active package binding"); + assert_eq!(changed, 1); +} + +fn mutation_claims(registry: ®istry_server::CompiledRegistry) -> ClaimContext { + ClaimContext::for_compiled( + registry, + "case", + Some("operator-principal".to_owned()), + "operator", + Some("case-management".to_owned()), + vec![RowBoundaryContext::Equals { + field: "jurisdiction".to_owned(), + value: "zone-a".to_owned(), + }], + ) + .expect("compiled authority context is valid") +} + +struct DestinationFixture { + root: PathBuf, + secret_root: PathBuf, + package_root: PathBuf, + trust_anchor: PathBuf, + receiver_port: u16, +} + +impl DestinationFixture { + fn new(receiver: &HttpsReceiver) -> Self { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock is after epoch") + .as_nanos(); + let root = std::env::temp_dir() + .canonicalize() + .expect("temporary parent canonicalizes") + .join(format!( + "registry-server-webhook-delivery-{suffix}-{}", + std::process::id() + )); + let secret_root = root.join("secrets"); + let package_root = root.join("package"); + fs::create_dir_all(&secret_root).expect("secret root creates"); + fs::create_dir(&package_root).expect("package root creates"); + let trust_anchor = root.join("trust-anchor.json"); + fs::write(&trust_anchor, "{}").expect("trust anchor placeholder writes"); + write_secret(&secret_root.join(KEY_REF_CANARY), HMAC_KEY); + write_secret( + &secret_root.join(CA_REF_CANARY), + receiver.certificate_pem.as_bytes(), + ); + Self { + root, + secret_root, + package_root, + trust_anchor, + receiver_port: receiver.address.port(), + } + } + + fn activate( + &self, + compiled: ®istry_server::CompiledRegistry, + ) -> ActivatedEventDestinationRegistry { + let raw = format!( + r#" +listener: + bind: 127.0.0.1:8080 + trustedProxy: direct +identity: + environment: local + instanceId: webhook-delivery-instance + databaseId: webhook-delivery-database + databaseInitializationEnvironment: local +secretProviders: + file: + root: {} +database: + runtimeUrlRef: secret:file/database-url + migrationUrlRef: secret:file/migration-database-url + pool: + maxSize: 12 + waitTimeoutMilliseconds: 1000 + createTimeoutMilliseconds: 1000 + recycleTimeoutMilliseconds: 1000 + roles: + migration: registry_migration + runtime: registry_runtime +package: + root: {} + trustAnchorPath: {} + compilerSourceRevision: source-revision-1 + activeRevision: {} + activeSequence: 1 +authentication: + oidc: + issuer: https://issuer.example + audience: urn:registry-server:webhook-delivery + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + allowedClients: [registry-client] + deniedKids: [denied-kid] + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 600 + negativeCacheTtlSeconds: 60 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 5000 + outageToleranceSeconds: 900 + authorityClaims: + principal: registry_principal + purpose: registry_purpose + rowBoundaryClaims: + - {{name: jurisdiction, type: directString}} +audit: + hashKeyRef: secret:file/audit-key +cursor: + secretRef: secret:file/cursor-key + maxAgeSeconds: 300 +eventDestinations: + {DESTINATION_ID}: + origin: https://localhost:{}/ + path: {DELIVERY_PATH} + networkProfile: pinnedLoopbackHttpsTest + dnsFamily: ipv4Only + allowedPrivateCidrs: [] + hmacSha256KeyRef: secret:file/{KEY_REF_CANARY} + tls: + caBundleRef: secret:file/{CA_REF_CANARY} + deliveryCeilings: + attemptTimeoutMilliseconds: 100 + maximumAttempts: 2 +operationalTimeouts: + httpRequestMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + recordLockMilliseconds: 5000 + migrationLockMilliseconds: 30000 + migrationStatementMilliseconds: 60000 +"#, + self.secret_root.display(), + self.package_root.display(), + self.trust_anchor.display(), + PACKAGE_REVISION, + self.receiver_port, + ); + parse_runtime_config(&raw) + .expect("strict pinned-loopback HTTPS config parses") + .activate_event_destinations(compiled) + .expect("exact destination inventory and TLS material activate") + } +} + +impl Drop for DestinationFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn write_secret(path: &std::path::Path, value: &[u8]) { + fs::write(path, value).expect("test secret writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("test secret permissions set"); + } +} + +#[derive(Clone)] +enum ResponsePlan { + Status(u16), + Delay(Duration, u16), + Break, +} + +#[derive(Clone)] +struct ReceivedRequest { + method: String, + target: String, + headers: BTreeMap, + body: Vec, +} + +struct HttpsReceiver { + address: std::net::SocketAddr, + certificate_pem: String, + plans: Arc>>, + requests: Arc>>, + notify: Arc, + shutdown: Option>, + task: tokio::task::JoinHandle<()>, +} + +impl HttpsReceiver { + async fn start() -> Self { + let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default(); + let CertifiedKey { cert, key_pair } = + generate_simple_self_signed(vec!["localhost".to_owned()]) + .expect("loopback TLS certificate generates"); + let certificate_der = cert.der().clone(); + let certificate_pem = pem("CERTIFICATE", certificate_der.as_ref()); + let private_key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); + let server_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certificate_der], private_key) + .expect("loopback TLS server configuration builds"); + let acceptor = TlsAcceptor::from(Arc::new(server_config)); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback TLS receiver binds"); + let address = listener + .local_addr() + .expect("receiver address is available"); + let plans = Arc::new(Mutex::new(VecDeque::new())); + let requests = Arc::new(Mutex::new(Vec::new())); + let notify = Arc::new(Notify::new()); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let task_plans = Arc::clone(&plans); + let task_requests = Arc::clone(&requests); + let task_notify = Arc::clone(¬ify); + let task = tokio::spawn(async move { + loop { + let accepted = tokio::select! { + _ = &mut shutdown_rx => return, + accepted = listener.accept() => accepted, + }; + let Ok((stream, _)) = accepted else { + return; + }; + let acceptor = acceptor.clone(); + let plans = Arc::clone(&task_plans); + let requests = Arc::clone(&task_requests); + let notify = Arc::clone(&task_notify); + tokio::spawn(async move { + let Ok(mut stream) = acceptor.accept(stream).await else { + return; + }; + let Ok(request) = read_request(&mut stream).await else { + return; + }; + requests.lock().await.push(request); + notify.notify_waiters(); + let plan = plans + .lock() + .await + .pop_front() + .unwrap_or(ResponsePlan::Status(204)); + let (delay, status) = match plan { + ResponsePlan::Status(status) => (Duration::ZERO, status), + ResponsePlan::Delay(delay, status) => (delay, status), + ResponsePlan::Break => { + let _ = stream.shutdown().await; + return; + } + }; + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + let reason = if status == 204 { + "No Content" + } else { + "Server Error" + }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + }); + } + }); + Self { + address, + certificate_pem, + plans, + requests, + notify, + shutdown: Some(shutdown_tx), + task, + } + } + + async fn enqueue(&self, plan: ResponsePlan) { + self.plans.lock().await.push_back(plan); + } + + async fn count(&self) -> usize { + self.requests.lock().await.len() + } + + async fn request(&self, index: usize) -> ReceivedRequest { + self.requests + .lock() + .await + .get(index) + .cloned() + .expect("requested receiver observation exists") + } + + async fn wait_for_count(&self, expected: usize) { + tokio::time::timeout(Duration::from_secs(3), async { + while self.count().await < expected { + self.notify.notified().await; + } + }) + .await + .expect("confined receiver observes the expected request count"); + } + + async fn stop(mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + let _ = self.task.await; + } +} + +async fn read_request(stream: &mut S) -> Result +where + S: AsyncReadExt + Unpin, +{ + let mut bytes = Vec::with_capacity(2_048); + let header_end = loop { + let mut chunk = [0_u8; 1_024]; + let read = stream.read(&mut chunk).await.map_err(|_| ())?; + if read == 0 || bytes.len() + read > 2_097_152 { + return Err(()); + } + bytes.extend_from_slice(&chunk[..read]); + if let Some(position) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + break position + 4; + } + }; + let header_text = std::str::from_utf8(&bytes[..header_end]).map_err(|_| ())?; + let mut lines = header_text.split("\r\n"); + let mut request_line = lines.next().ok_or(())?.split_whitespace(); + let method = request_line.next().ok_or(())?.to_owned(); + let target = request_line.next().ok_or(())?.to_owned(); + let mut headers = BTreeMap::new(); + for line in lines.filter(|line| !line.is_empty()) { + let (name, value) = line.split_once(':').ok_or(())?; + headers.insert(name.to_ascii_lowercase(), value.trim().to_owned()); + } + let content_length = headers + .get("content-length") + .ok_or(())? + .parse::() + .map_err(|_| ())?; + while bytes.len() - header_end < content_length { + let mut chunk = [0_u8; 1_024]; + let read = stream.read(&mut chunk).await.map_err(|_| ())?; + if read == 0 || bytes.len() + read > 2_097_152 { + return Err(()); + } + bytes.extend_from_slice(&chunk[..read]); + } + Ok(ReceivedRequest { + method, + target, + headers, + body: bytes[header_end..header_end + content_length].to_vec(), + }) +} + +fn pem(label: &str, der: &[u8]) -> String { + let encoded = 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") +} diff --git a/crates/registry-server/tests/postgres_webhook_outbox.rs b/crates/registry-server/tests/postgres_webhook_outbox.rs new file mode 100644 index 0000000000..820f9de106 --- /dev/null +++ b/crates/registry-server/tests/postgres_webhook_outbox.rs @@ -0,0 +1,888 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use postgres_harness::TestDatabase; +use registry_platform_audit::AuditProfile; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::event_destination::ActivatedEventDestinationRegistry; +use registry_server::model::CompiledEventDelivery; +use registry_server::mutation::{ + MutationBody, MutationCoordinator, MutationError, MutationFaultPoint, MutationPlan, + MutationRequest, +}; +use registry_server::postgres::{ + install_compiled_schema, managed_schema_fingerprint, ClaimContext, ExpectedManagedCatalog, + ExpectedRegistryIdentity, RegistryLockKey, RowBoundaryContext, +}; +use registry_server::runtime_config::parse_runtime_config; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use tokio_postgres::Row; +use uuid::Uuid; + +const PACKAGE_REVISION: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SCHEMA_FINGERPRINT: &str = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const DESTINATION_ID: &str = "case-operations"; +const ORIGIN_CANARY: &str = "webhook-url-canary.example"; +const PATH_CANARY: &str = "/webhook-path-canary"; +const SECRET_REF_CANARY: &str = "webhook-key-ref-canary"; +const SECRET_KEY_CANARY: &[u8] = b"webhook-key-material-canary-0123456789abcdef"; +const RESTRICTED_CANARY: &str = "restricted-projection-canary"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_deterministically_identified( +) { + let database = TestDatabase::create(10).await; + let (migration, migration_task) = database.connect_migration().await; + let compiled = compiled_registry(); + let compiled_delivery = compiled.event_deliveries().deliveries[0].clone(); + let mut mismatched_value = + serde_json::to_value(&compiled).expect("compiled registry serializes"); + mismatched_value["eventDeliveryInventory"]["deliveries"][0]["projectionFields"] = + json!(["label"]); + let mismatched = + serde_json::from_value(mismatched_value).expect("strict mismatch deserializes"); + assert_eq!( + MutationPlan::from_compiled(&mismatched, "records.case.create").err(), + Some(MutationError::InvalidRequest), + "a source/inventory mismatch is refused before mutation I/O" + ); + + install_compiled_schema(&migration, &compiled, &database.runtime_role) + .await + .expect("migration installs the compiled data and capture schemas"); + let expected_catalog = ExpectedManagedCatalog::compiled(&compiled); + let authoring_search_path: String = migration + .query_one("SELECT current_setting('search_path')", &[]) + .await + .expect("authoring search path reads") + .get(0); + let authoring_fingerprint = + managed_schema_fingerprint(&migration, &database.runtime_role, &expected_catalog) + .await + .expect("authoring-path fingerprint computes"); + assert_eq!( + migration + .query_one("SELECT current_setting('search_path')", &[]) + .await + .expect("authoring search path rereads") + .get::<_, String>(0), + authoring_search_path, + "fingerprinting restores the caller search path" + ); + migration + .batch_execute("SET search_path TO pg_catalog, registry_internal, registry_data, pg_temp") + .await + .expect("apply-style search path installs"); + let apply_search_path: String = migration + .query_one("SELECT current_setting('search_path')", &[]) + .await + .expect("apply search path reads") + .get(0); + let apply_fingerprint = + managed_schema_fingerprint(&migration, &database.runtime_role, &expected_catalog) + .await + .expect("apply-path fingerprint computes"); + assert_eq!(authoring_fingerprint, apply_fingerprint); + assert_eq!( + migration + .query_one("SELECT current_setting('search_path')", &[]) + .await + .expect("apply search path rereads") + .get::<_, String>(0), + apply_search_path, + "fingerprinting restores the pinned apply search path" + ); + let identity = expected_identity(); + initialize_registry_state(&migration, &identity).await; + migration_task.abort(); + + let fixture = DestinationFixture::new(); + let destinations = Arc::new(fixture.activate(&compiled)); + let binding_digest = destinations + .lookup(DESTINATION_ID) + .expect("compiled logical destination is activated") + .binding_digest() + .to_owned(); + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let audit_profile = AuditProfile::production_from_secret_bytes(vec![0x5a; 32].into()) + .expect("test owns a strongly keyed audit profile"); + let plan = MutationPlan::from_compiled(&compiled, "records.case.create") + .expect("create plan retains the exact compiler delivery"); + let claims = mutation_claims(&compiled); + let table = &compiled.entities()["case"].physical_table; + let lock_key = + RegistryLockKey::derive("webhook-outbox-registry").expect("test lock identity is bounded"); + + let without_activation = MutationCoordinator::new( + lock_key, + Duration::from_secs(2), + identity.clone(), + audit_profile.clone(), + ); + let before_missing = durable_counts(&database, table).await; + let mut client = pool + .get_for_test() + .await + .expect("runtime connection is available"); + assert_eq!( + without_activation + .execute( + &mut client, + create_request(&plan, &claims, "missing-activation", "missing"), + ) + .await, + Err(MutationError::Unavailable) + ); + assert_eq!(durable_counts(&database, table).await, before_missing); + + let coordinator = MutationCoordinator::new_with_event_destinations( + lock_key, + Duration::from_secs(2), + identity.clone(), + audit_profile.clone(), + Some(Arc::clone(&destinations)), + ); + database + .admin + .batch_execute(&format!( + "REVOKE INSERT ON registry_internal.registry_webhook_deliveries FROM \"{}\";", + database.runtime_role.as_str() + )) + .await + .expect("administrator can inject a capture privilege failure"); + let before_revoke = durable_counts(&database, table).await; + assert_eq!( + coordinator + .execute( + &mut client, + create_request(&plan, &claims, "revoked-delivery-insert", "revoked"), + ) + .await, + Err(MutationError::Unavailable) + ); + assert_eq!(durable_counts(&database, table).await, before_revoke); + database + .admin + .batch_execute(&format!( + "GRANT INSERT ON registry_internal.registry_webhook_deliveries TO \"{}\";", + database.runtime_role.as_str() + )) + .await + .expect("administrator restores the capture-only grant"); + + for (index, fault) in [ + MutationFaultPoint::BeforeTerminalAudit, + MutationFaultPoint::BeforeIdempotency, + MutationFaultPoint::BeforeCommit, + ] + .into_iter() + .enumerate() + { + let faulted = MutationCoordinator::new_with_event_destinations( + lock_key, + Duration::from_secs(2), + identity.clone(), + audit_profile.clone(), + Some(Arc::clone(&destinations)), + ); + let before = durable_counts(&database, table).await; + assert_eq!( + faulted + .execute_with_fault( + &mut client, + create_request( + &plan, + &claims, + &format!("terminal-fault-{index}"), + &format!("fault-{index}"), + ), + fault, + ) + .await, + Err(MutationError::Unavailable) + ); + assert_eq!(durable_counts(&database, table).await, before); + } + + let first = coordinator + .execute( + &mut client, + create_request(&plan, &claims, "successful-create", "first"), + ) + .await + .expect("configured webhook capture commits with the record"); + assert!(!first.replayed()); + let first_response: Value = serde_json::from_slice(first.response().body()) + .expect("held create response is strict JSON"); + let raw_record_id = first_response["id"] + .as_str() + .expect("create response contains a record id"); + let first_capture = capture(&database, 0).await; + assert_capture_matches( + &first_capture, + &compiled_delivery, + &binding_digest, + &identity, + ); + assert!(first_capture.payload == expected_payload()); + assert!(first_capture.payload.len() <= compiled_delivery.maximum_payload_bytes as usize); + assert_delivery_is_transport_and_value_free(&database, first_capture.event_id, raw_record_id) + .await; + assert_initial_delivery_state(&database, &first_capture).await; + assert_capture_acl_is_insert_and_select_only(&database).await; + + let after_first = durable_counts(&database, table).await; + let replay = coordinator + .execute( + &mut client, + create_request(&plan, &claims, "successful-create", "first"), + ) + .await + .expect("exact replay returns the held result"); + assert!(replay.replayed()); + assert!(replay.response() == first.response()); + assert_eq!(durable_counts(&database, table).await, after_first); + + let mut replay_client_a = pool + .get_for_test() + .await + .expect("first concurrent replay connection is available"); + let mut replay_client_b = pool + .get_for_test() + .await + .expect("second concurrent replay connection is available"); + let (replay_a, replay_b) = tokio::join!( + coordinator.execute( + &mut replay_client_a, + create_request(&plan, &claims, "successful-create", "first"), + ), + coordinator.execute( + &mut replay_client_b, + create_request(&plan, &claims, "successful-create", "first"), + ) + ); + for replay in [replay_a, replay_b] { + let replay = replay.expect("concurrent exact replay returns the held result"); + assert!(replay.replayed()); + assert!(replay.response() == first.response()); + } + assert_eq!(durable_counts(&database, table).await, after_first); + assert_eq!(capture(&database, 0).await.event_id, first_capture.event_id); + + let second = coordinator + .execute( + &mut client, + create_request(&plan, &claims, "second-create", "second"), + ) + .await + .expect("a distinct mutation captures a distinct event"); + assert!(!second.replayed()); + let second_capture = capture(&database, 1).await; + assert_ne!(second_capture.event_id, first_capture.event_id); + assert_eq!( + second_capture.compiled_delivery_id, + first_capture.compiled_delivery_id + ); + assert_eq!(durable_counts(&database, table).await.delivery, 2); + + drop(replay_client_a); + drop(replay_client_b); + drop(client); + drop(pool); + database.cleanup().await; +} + +fn compiled_registry() -> registry_server::CompiledRegistry { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"webhook-outbox-registry","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"case","route":"cases","mutationMode":"create_only","classification":"restricted", + "fields":[ + {"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}, + {"id":"label","type":"string","maxLength":64,"required":true,"classification":"internal"}, + {"id":"restricted_note","type":"string","maxLength":64,"required":true,"classification":"restricted"} + ], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "operations":["create","get","list"], + "readableFields":["jurisdiction","label","restricted_note"], + "writableFields":["jurisdiction","label","restricted_note"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }], + "events":[{ + "id":"case-created","trigger":"created","projection":["label","restricted_note"], + "webhook":{ + "destinationId":"case-operations", + "classificationCeiling":"restricted", + "authenticationProfile":"hmac_sha256_v1", + "delivery":{ + "attemptTimeoutMs":5000, + "initialBackoffMs":250, + "maximumBackoffMs":2000, + "maximumAttempts":5, + "deadLetter":"required", + "operatorReplay":true + } + } + }] + }] + }"#, + ) + .expect("webhook capture fixture parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("webhook capture fixture compiles") +} + +fn expected_identity() -> ExpectedRegistryIdentity { + ExpectedRegistryIdentity { + package_id: "webhook-outbox-registry".to_owned(), + environment: "local".to_owned(), + instance_id: "webhook-outbox-instance".to_owned(), + database_id: "webhook-outbox-database".to_owned(), + package_revision: PACKAGE_REVISION.to_owned(), + schema_fingerprint: SCHEMA_FINGERPRINT.to_owned(), + package_sequence: 1, + } +} + +async fn initialize_registry_state( + migration: &tokio_postgres::Client, + identity: &ExpectedRegistryIdentity, +) { + let changed = migration + .execute( + "INSERT INTO registry_internal.registry_state + (singleton, package_id, environment, instance_id, database_id, + active_package_revision, schema_fingerprint, package_sequence, + maintenance_status) + VALUES (true, $1, $2, $3, $4, $5, $6, $7, 'ready')", + &[ + &identity.package_id, + &identity.environment, + &identity.instance_id, + &identity.database_id, + &identity.package_revision, + &identity.schema_fingerprint, + &identity.package_sequence, + ], + ) + .await + .expect("migration initializes the exact active package binding"); + assert_eq!(changed, 1); +} + +fn mutation_claims(registry: ®istry_server::CompiledRegistry) -> ClaimContext { + ClaimContext::for_compiled( + registry, + "case", + Some("operator-principal".to_owned()), + "operator", + Some("case-management".to_owned()), + vec![RowBoundaryContext::Equals { + field: "jurisdiction".to_owned(), + value: "zone-a".to_owned(), + }], + ) + .expect("compiled authority context is valid") +} + +fn create_request<'a>( + plan: &'a MutationPlan, + claims: &'a ClaimContext, + idempotency_key: &'a str, + label: &'a str, +) -> MutationRequest<'a> { + MutationRequest { + plan, + idempotency_key, + claims, + record_id: None, + expected_etag: None, + body: MutationBody::Create(Map::from_iter([ + ("jurisdiction".to_owned(), json!("zone-a")), + ("label".to_owned(), json!(label)), + ("restricted_note".to_owned(), json!(RESTRICTED_CANARY)), + ])), + response_fields: BTreeSet::from([ + "jurisdiction".to_owned(), + "label".to_owned(), + "restricted_note".to_owned(), + ]), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct DurableCounts { + current: i64, + revisions: i64, + outbox: i64, + delivery: i64, + delivery_state: i64, + idempotency: i64, +} + +async fn durable_counts(database: &TestDatabase, table: &str) -> DurableCounts { + let row = database + .admin + .query_one( + &format!( + "SELECT (SELECT count(*) FROM registry_data.\"{table}\"), + (SELECT count(*) FROM registry_internal.registry_revisions), + (SELECT count(*) FROM registry_internal.registry_outbox), + (SELECT count(*) FROM registry_internal.registry_webhook_deliveries), + (SELECT count(*) FROM registry_internal.registry_webhook_delivery_state), + (SELECT count(*) FROM registry_internal.registry_idempotency)" + ), + &[], + ) + .await + .expect("administrator can inspect minimized durable mutation state"); + DurableCounts { + current: row.get(0), + revisions: row.get(1), + outbox: row.get(2), + delivery: row.get(3), + delivery_state: row.get(4), + idempotency: row.get(5), + } +} + +struct CapturedDelivery { + event_id: Uuid, + payload: Vec, + compiled_delivery_id: String, + logical_destination_id: String, + destination_binding_digest: String, + package_revision: String, + schema_fingerprint: String, + classification_ceiling: String, + authentication_profile: String, + delivery_mode: String, + attempt_timeout_ms: i64, + initial_backoff_ms: i64, + maximum_backoff_ms: i64, + exponential_backoff_multiplier: i16, + maximum_attempts: i16, + retry_delays_ms: Vec, + maximum_payload_bytes: i64, + payload_digest: Vec, + deployed_attempt_timeout_ms: i64, + deployed_maximum_attempts: i16, + dead_letter: String, + operator_replay: bool, +} + +impl From for CapturedDelivery { + fn from(row: Row) -> Self { + Self { + event_id: row.get(0), + payload: row.get(1), + compiled_delivery_id: row.get(2), + logical_destination_id: row.get(3), + destination_binding_digest: row.get(4), + package_revision: row.get(5), + schema_fingerprint: row.get(6), + classification_ceiling: row.get(7), + authentication_profile: row.get(8), + delivery_mode: row.get(9), + attempt_timeout_ms: row.get(10), + initial_backoff_ms: row.get(11), + maximum_backoff_ms: row.get(12), + exponential_backoff_multiplier: row.get(13), + maximum_attempts: row.get(14), + retry_delays_ms: row.get(15), + maximum_payload_bytes: row.get(16), + payload_digest: row.get(17), + deployed_attempt_timeout_ms: row.get(18), + deployed_maximum_attempts: row.get(19), + dead_letter: row.get(20), + operator_replay: row.get(21), + } + } +} + +async fn capture(database: &TestDatabase, offset: i64) -> CapturedDelivery { + database + .admin + .query_one( + "SELECT outbox.event_id, outbox.payload, + delivery.compiled_delivery_id, delivery.logical_destination_id, + delivery.destination_binding_digest, delivery.package_revision, + delivery.schema_fingerprint, delivery.classification_ceiling, + delivery.authentication_profile, delivery.delivery_mode, + delivery.attempt_timeout_ms, delivery.initial_backoff_ms, + delivery.maximum_backoff_ms, delivery.exponential_backoff_multiplier, + delivery.maximum_attempts, delivery.retry_delays_ms, + delivery.maximum_payload_bytes, delivery.payload_digest, + delivery.deployed_attempt_timeout_ms, + delivery.deployed_maximum_attempts, delivery.dead_letter, + delivery.operator_replay + FROM registry_internal.registry_outbox AS outbox + JOIN registry_internal.registry_webhook_deliveries AS delivery + ON delivery.event_id = outbox.event_id + ORDER BY outbox.outbox_id + OFFSET $1 LIMIT 1", + &[&offset], + ) + .await + .expect("one outbox event has exactly one package-bound delivery") + .into() +} + +fn assert_capture_matches( + actual: &CapturedDelivery, + compiled: &CompiledEventDelivery, + binding_digest: &str, + identity: &ExpectedRegistryIdentity, +) { + assert_eq!(actual.compiled_delivery_id, compiled.id); + assert_eq!(actual.logical_destination_id, compiled.destination_id); + assert_eq!(actual.destination_binding_digest, binding_digest); + assert_eq!(actual.package_revision, identity.package_revision); + assert_eq!(actual.schema_fingerprint, identity.schema_fingerprint); + assert_eq!(actual.classification_ceiling, "restricted"); + assert_eq!(actual.authentication_profile, "hmac_sha256_v1"); + assert_eq!(actual.delivery_mode, "after_commit"); + assert_eq!( + actual.attempt_timeout_ms, + i64::from(compiled.attempt_timeout_ms) + ); + assert_eq!( + actual.initial_backoff_ms, + i64::from(compiled.initial_backoff_ms) + ); + assert_eq!( + actual.maximum_backoff_ms, + i64::from(compiled.maximum_backoff_ms) + ); + assert_eq!( + actual.exponential_backoff_multiplier, + i16::from(compiled.exponential_backoff_multiplier) + ); + assert_eq!( + actual.maximum_attempts, + i16::from(compiled.maximum_attempts) + ); + assert_eq!( + actual.retry_delays_ms, + compiled + .retry_delays_ms + .iter() + .copied() + .map(i64::from) + .collect::>() + ); + assert_eq!( + actual.maximum_payload_bytes, + i64::from(compiled.maximum_payload_bytes) + ); + assert_eq!( + actual.payload_digest, + Sha256::digest(&actual.payload).to_vec() + ); + assert_eq!(actual.deployed_attempt_timeout_ms, 4000); + assert_eq!(actual.deployed_maximum_attempts, 4); + assert_eq!(actual.dead_letter, "required"); + assert_eq!(actual.operator_replay, compiled.operator_replay); +} + +fn expected_payload() -> Vec { + format!(r#"{{"label":"first","restricted_note":"{RESTRICTED_CANARY}"}}"#).into_bytes() +} + +async fn assert_delivery_is_transport_and_value_free( + database: &TestDatabase, + event_id: Uuid, + raw_record_id: &str, +) { + let row_text: String = database + .admin + .query_one( + "SELECT row_to_json(delivery)::text + FROM registry_internal.registry_webhook_deliveries AS delivery + WHERE event_id = $1", + &[&event_id], + ) + .await + .expect("administrator can inspect the immutable delivery metadata") + .get(0); + for forbidden in [ + RESTRICTED_CANARY, + ORIGIN_CANARY, + PATH_CANARY, + SECRET_REF_CANARY, + std::str::from_utf8(SECRET_KEY_CANARY).expect("test key canary is UTF-8"), + raw_record_id, + ] { + assert!(!row_text.contains(forbidden)); + } + let columns = database + .admin + .query( + "SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'registry_internal' + AND table_name = 'registry_webhook_deliveries' + ORDER BY ordinal_position", + &[], + ) + .await + .expect("administrator can inspect the fixed delivery schema") + .into_iter() + .map(|row| row.get::<_, String>(0)) + .collect::>(); + assert_eq!( + columns, + [ + "event_id", + "compiled_delivery_id", + "logical_destination_id", + "destination_binding_digest", + "package_revision", + "schema_fingerprint", + "classification_ceiling", + "authentication_profile", + "delivery_mode", + "attempt_timeout_ms", + "initial_backoff_ms", + "maximum_backoff_ms", + "exponential_backoff_multiplier", + "maximum_attempts", + "retry_delays_ms", + "maximum_payload_bytes", + "payload_digest", + "deployed_attempt_timeout_ms", + "deployed_maximum_attempts", + "dead_letter", + "operator_replay", + "created_at", + ] + ); +} + +async fn assert_capture_acl_is_insert_and_select_only(database: &TestDatabase) { + let privileges = database + .admin + .query( + "SELECT privilege_type + FROM information_schema.role_table_grants + WHERE table_schema = 'registry_internal' + AND table_name = 'registry_webhook_deliveries' + AND grantee = $1 + ORDER BY privilege_type", + &[&database.runtime_role.as_str()], + ) + .await + .expect("administrator can inspect capture ACL") + .into_iter() + .map(|row| row.get::<_, String>(0)) + .collect::>(); + assert_eq!(privileges, ["INSERT", "SELECT"]); + + let state_privileges = database + .admin + .query( + "SELECT privilege_type + FROM information_schema.role_table_grants + WHERE table_schema = 'registry_internal' + AND table_name = 'registry_webhook_delivery_state' + AND grantee = $1 + ORDER BY privilege_type", + &[&database.runtime_role.as_str()], + ) + .await + .expect("administrator can inspect state ACL") + .into_iter() + .map(|row| row.get::<_, String>(0)) + .collect::>(); + assert_eq!(state_privileges, ["INSERT", "SELECT", "UPDATE"]); +} + +async fn assert_initial_delivery_state(database: &TestDatabase, capture: &CapturedDelivery) { + let row = database + .admin + .query_one( + "SELECT generation, state, attempt, + next_attempt_at IS NOT NULL, + attempt_started_at IS NULL, + lease_expires_at IS NULL, + lease_token IS NULL, + delivered_at IS NULL, + dead_lettered_at IS NULL + FROM registry_internal.registry_webhook_delivery_state + WHERE event_id = $1 AND compiled_delivery_id = $2", + &[&capture.event_id, &capture.compiled_delivery_id], + ) + .await + .expect("capture atomically seeds one exact pending delivery state"); + assert_eq!(row.get::<_, i64>(0), 1); + assert_eq!(row.get::<_, String>(1), "pending"); + assert_eq!(row.get::<_, i16>(2), 0); + for index in 3..9 { + assert!(row.get::<_, bool>(index)); + } +} + +struct DestinationFixture { + root: PathBuf, + secret_root: PathBuf, + package_root: PathBuf, + trust_anchor: PathBuf, +} + +impl DestinationFixture { + fn new() -> Self { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock is after epoch") + .as_nanos(); + let root = std::env::temp_dir() + .canonicalize() + .expect("temporary parent canonicalizes") + .join(format!( + "registry-server-webhook-outbox-{suffix}-{}", + std::process::id() + )); + fs::create_dir(&root).expect("fixture root creates"); + let secret_root = root.join("secrets"); + let package_root = root.join("package"); + fs::create_dir(&secret_root).expect("secret root creates"); + fs::create_dir(&package_root).expect("package root creates"); + let trust_anchor = root.join("trust-anchor.json"); + fs::write(&trust_anchor, "{}").expect("trust anchor placeholder writes"); + let key_path = secret_root.join(SECRET_REF_CANARY); + fs::write(&key_path, SECRET_KEY_CANARY).expect("destination key writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(key_path, fs::Permissions::from_mode(0o600)) + .expect("destination key permissions set"); + } + Self { + root, + secret_root, + package_root, + trust_anchor, + } + } + + fn activate( + &self, + compiled: ®istry_server::CompiledRegistry, + ) -> ActivatedEventDestinationRegistry { + let raw = format!( + r#" +listener: + bind: 127.0.0.1:8080 + trustedProxy: direct +identity: + environment: local + instanceId: webhook-outbox-instance + databaseId: webhook-outbox-database + databaseInitializationEnvironment: local +secretProviders: + file: + root: {} +database: + runtimeUrlRef: secret:file/database-url + migrationUrlRef: secret:file/migration-database-url + pool: + maxSize: 4 + waitTimeoutMilliseconds: 1000 + createTimeoutMilliseconds: 1000 + recycleTimeoutMilliseconds: 1000 + roles: + migration: registry_migration + runtime: registry_runtime +package: + root: {} + trustAnchorPath: {} + compilerSourceRevision: source-revision-1 + activeRevision: {} + activeSequence: 1 +authentication: + oidc: + issuer: https://issuer.example + audience: urn:registry-server:webhook-outbox + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + allowedClients: [registry-client] + deniedKids: [denied-kid] + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 600 + negativeCacheTtlSeconds: 60 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 5000 + outageToleranceSeconds: 900 + authorityClaims: + principal: registry_principal + purpose: registry_purpose + rowBoundaryClaims: + - {{name: jurisdiction, type: directString}} +audit: + hashKeyRef: secret:file/audit-key +cursor: + secretRef: secret:file/cursor-key + maxAgeSeconds: 300 +eventDestinations: + {DESTINATION_ID}: + origin: https://{ORIGIN_CANARY}/ + path: {PATH_CANARY} + networkProfile: productionHttps + dnsFamily: dualStackStrict + allowedPrivateCidrs: [] + hmacSha256KeyRef: secret:file/{SECRET_REF_CANARY} + deliveryCeilings: + attemptTimeoutMilliseconds: 4000 + maximumAttempts: 4 +operationalTimeouts: + httpRequestMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + recordLockMilliseconds: 5000 + migrationLockMilliseconds: 30000 + migrationStatementMilliseconds: 60000 +"#, + self.secret_root.display(), + self.package_root.display(), + self.trust_anchor.display(), + PACKAGE_REVISION, + ); + parse_runtime_config(&raw) + .expect("strict destination configuration parses") + .activate_event_destinations(compiled) + .expect("exact destination inventory activates") + } +} + +impl Drop for DestinationFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} diff --git a/crates/registry-server/tests/runtime_config.rs b/crates/registry-server/tests/runtime_config.rs new file mode 100644 index 0000000000..66aa43533a --- /dev/null +++ b/crates/registry-server/tests/runtime_config.rs @@ -0,0 +1,1821 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "runtime")] + +use std::{ + fs, + path::{Path, PathBuf}, + sync::atomic::{AtomicUsize, Ordering}, + sync::{Mutex, OnceLock}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use registry_platform_crypto::PrivateJwk; +use registry_platform_httputil::destination::{ + DestinationDnsFamily, DestinationSendError, EventDeliveryHeaders, +}; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::event_destination::EventDestinationActivationError; +use registry_server::runtime_config::{ + load_runtime_config, load_runtime_config_with_env, parse_runtime_config_with_env, + RuntimeConfigError, TrustedProxyPosture, +}; +use serde_json::{json, Value}; + +const DATABASE_URL_CANARY: &str = + "postgresql://registry_runtime:database-url-canary@db.example/registry"; +const MIGRATION_DATABASE_URL_CANARY: &str = + "postgresql://registry_migration:migration-database-url-canary@db.example/registry"; +const AUDIT_KEY_CANARY: &str = "audit-key-canary-012345678901234567890123456789"; +const EXPANDED_CANARY: &str = "runtime-expanded-canary"; +const STATIC_JWKS_ENV: &str = "REGISTRY_SERVER_RUNTIME_CONFIG_STATIC_JWKS"; + +fn valid_runtime(secret_root: &Path, package_root: &Path, trust_anchor: &Path) -> String { + format!( + r#" +listener: + bind: 127.0.0.1:8080 + trustedProxy: direct +identity: + environment: production + instanceId: registry-primary + databaseId: registry-db + databaseInitializationEnvironment: production +secretProviders: + environment: {{}} + file: + root: {} +database: + runtimeUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL + migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL + pool: + maxSize: 4 + waitTimeoutMilliseconds: 1000 + createTimeoutMilliseconds: 1000 + recycleTimeoutMilliseconds: 1000 + roles: + migration: registry_migration + runtime: registry_runtime +package: + root: {} + trustAnchorPath: {} + compilerSourceRevision: source-revision-1 + activeRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + activeSequence: 1 +authentication: + oidc: + issuer: https://issuer.example + audience: urn:registry-server:test + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + allowedClients: [registry-client] + deniedKids: [denied-kid] + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 600 + negativeCacheTtlSeconds: 60 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 5000 + outageToleranceSeconds: 900 + authorityClaims: + principal: registry_principal + purpose: registry_purpose + rowBoundaryClaims: + - {{name: jurisdiction, type: directStringSet}} + - {{name: tenant, type: directString}} +audit: + hashKeyRef: secret:file/audit-key +cursor: + secretRef: secret:file/cursor-key + maxAgeSeconds: 300 +eventDestinations: {{}} +operationalTimeouts: + httpRequestMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + recordLockMilliseconds: 5000 + migrationLockMilliseconds: 30000 + migrationStatementMilliseconds: 60000 +"#, + secret_root.display(), + package_root.display(), + trust_anchor.display() + ) +} + +fn runtime_with_event_destinations(fixture: &RuntimeFixture, bindings: &str) -> String { + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace( + "eventDestinations: {}\n", + &format!("eventDestinations:\n{bindings}"), + ) +} + +fn event_destination_binding( + logical_id: &str, + origin: &str, + path: &str, + key_ref: &str, + timeout_ms: u32, + maximum_attempts: u8, +) -> String { + format!( + r#" {logical_id}: + origin: {origin} + path: {path} + networkProfile: productionHttps + dnsFamily: dualStackStrict + allowedPrivateCidrs: [] + hmacSha256KeyRef: {key_ref} + deliveryCeilings: + attemptTimeoutMilliseconds: {timeout_ms} + maximumAttempts: {maximum_attempts} +"# + ) +} + +fn compiled_webhooks(destinations: &[(&str, u32, u8)]) -> registry_server::CompiledRegistry { + let events = destinations + .iter() + .enumerate() + .map(|(index, (destination_id, timeout_ms, maximum_attempts))| { + json!({ + "id": format!("case-event-{index}"), + "trigger": if index == 0 { "created" } else { "patched" }, + "projection": ["label"], + "webhook": { + "destinationId": destination_id, + "classificationCeiling": "internal", + "authenticationProfile": "hmac_sha256_v1", + "delivery": { + "attemptTimeoutMs": timeout_ms, + "initialBackoffMs": 250, + "maximumBackoffMs": 2000, + "maximumAttempts": maximum_attempts, + "deadLetter": "required", + "operatorReplay": false + } + } + }) + }) + .collect::>(); + let project = json!({ + "apiVersion": "registry.registrystack.org/v1alpha1", + "kind": "RegistryProject", + "registry": {"id": "runtime-event-destinations", "version": "1", "defaultLanguage": "en"}, + "entities": [{ + "id": "case", + "route": "cases", + "mutationMode": "mutable", + "tombstone": true, + "classification": "internal", + "fields": [ + {"id": "label", "type": "string", "maxLength": 64, "classification": "internal"} + ], + "events": events + }] + }); + let parsed = parse_project_json(&serde_json::to_vec(&project).expect("project serializes")) + .expect("project parses"); + compile_project(&parsed, &[], CompileProfile::Authoring).expect("webhooks compile") +} + +fn event_headers() -> EventDeliveryHeaders<'static> { + EventDeliveryHeaders { + event_id: b"event-id", + event_type: b"case.created", + generation: b"1", + attempt: b"1", + timestamp: b"2026-08-30T00:00:00Z", + idempotency_key: b"delivery-key", + signature: b"v1=signature", + } +} + +#[test] +fn strict_runtime_file_loads_and_constructs_existing_runtime_inputs() { + let _guard = environment_lock(); + let fixture = RuntimeFixture::new(); + let config_path = fixture.path("runtime.yaml"); + fs::write( + &config_path, + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ), + ) + .expect("runtime config writes"); + std::env::set_var( + "REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL", + DATABASE_URL_CANARY, + ); + std::env::remove_var("REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL"); + + let config = load_runtime_config(&config_path).expect("runtime config loads"); + + assert_eq!(config.listener().bind().to_string(), "127.0.0.1:8080"); + assert_eq!( + config.listener().trusted_proxy(), + TrustedProxyPosture::Direct + ); + assert_eq!(config.identity().environment(), "production"); + assert_eq!( + config.database().pool_bounds().wait_timeout, + Duration::from_secs(1) + ); + assert_eq!( + config.database().roles().migration().as_str(), + "registry_migration" + ); + assert_eq!(config.package().active_sequence(), 1); + assert_eq!( + config.package().compiler_source_revision(), + "source-revision-1" + ); + + let package = config.package_load_context(); + assert_eq!(package.environment, "production"); + assert_eq!(package.database_id, "registry-db"); + assert!(package.trust_anchor.is_some()); + + let verifier = config.authentication().oidc().token_verifier_config(); + assert_eq!(verifier.issuer, "https://issuer.example"); + assert_eq!(verifier.audiences, vec!["urn:registry-server:test"]); + assert_eq!( + verifier.allowed_algorithms, + vec![jsonwebtoken::Algorithm::EdDSA] + ); + assert_eq!(verifier.allowed_clients, vec!["registry-client"]); + assert!(verifier.denied_kids.contains("denied-kid")); + + let discovery = config.authentication().oidc().discovery_config(); + assert!(discovery.jwks_uri_override.is_none()); + assert_eq!( + config + .authentication() + .oidc() + .jwks_fetcher_config() + .request_timeout, + Duration::from_secs(5) + ); + let _claims = config.authentication().authority_claim_config(); + let database_result = config.runtime_database_connection_config(); + std::env::remove_var("REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL"); + if let Err(error) = database_result { + let rendered = format!("{error:?} {error}"); + assert!(!rendered.contains(DATABASE_URL_CANARY)); + } + let _audit = config + .audit_profile() + .expect("keyed audit profile builds from protected file secret"); + let _cursor = config + .cursor_codec() + .expect("cursor codec builds from protected file secret"); +} + +#[test] +fn local_runtime_does_not_require_or_supply_package_trust_authority() { + let fixture = RuntimeFixture::new(); + let missing_anchor = fixture.path("unused-local-trust-anchor.json"); + let config_path = fixture.path("runtime-local.yaml"); + let raw = valid_runtime(&fixture.secret_root, &fixture.package_root, &missing_anchor) + .replace("environment: production", "environment: local") + .replace( + "databaseInitializationEnvironment: production", + "databaseInitializationEnvironment: local", + ); + fs::write(&config_path, raw).expect("local runtime config writes"); + + let config = load_runtime_config(&config_path) + .expect("local runtime does not require a production trust anchor file"); + assert!(config.package_trust_anchor().is_none()); + assert!(config.package_load_context().trust_anchor.is_none()); +} + +#[test] +fn governed_unknown_keys_are_refused_before_they_become_runtime() { + let fixture = RuntimeFixture::new(); + let mut raw = valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ); + raw.push_str("entities: []\n"); + assert_eq!( + parse_runtime_config_with_env(&raw, env_lookup).expect_err("governed key refused"), + RuntimeConfigError::GovernedMember + ); +} + +#[test] +fn raw_database_urls_inline_secrets_and_plaintext_posture_are_refused() { + let fixture = RuntimeFixture::new(); + for replacement in [ + format!( + "runtimeUrlRef: {}\n migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL\n pool:", + "postgresql://registry_runtime:raw-secret@db.example/registry" + ), + "runtimeUrlRef: secret:env/lowercase\n migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL\n pool:".to_owned(), + "runtimeUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL\n migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL\n plaintext: true\n pool:".to_owned(), + "runtimeUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL\n migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL\n password: inline-secret\n pool:".to_owned(), + ] { + let raw = valid_runtime(&fixture.secret_root, &fixture.package_root, &fixture.trust_anchor) + .replace( + "runtimeUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL\n migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL\n pool:", + &replacement, + ); + assert_eq!( + parse_runtime_config_with_env(&raw, env_lookup) + .expect_err("unsafe database material refused"), + RuntimeConfigError::InvalidDatabase + ); + } +} + +#[test] +fn old_single_database_url_ref_is_refused_by_strict_schema() { + let fixture = RuntimeFixture::new(); + let raw = valid_runtime(&fixture.secret_root, &fixture.package_root, &fixture.trust_anchor) + .replace( + "runtimeUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL\n migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL\n", + "urlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL\n", + ); + + assert_eq!( + parse_runtime_config_with_env(&raw, env_lookup) + .expect_err("legacy single database URL ref is refused"), + RuntimeConfigError::Document + ); +} + +#[test] +fn database_role_specific_resolvers_refuse_wrong_role_without_leaking_values() { + let _guard = environment_lock(); + let fixture = RuntimeFixture::new(); + let config = parse_runtime_config_with_env( + &valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ), + env_lookup, + ) + .expect("runtime parses"); + + std::env::set_var( + "REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL", + MIGRATION_DATABASE_URL_CANARY, + ); + let runtime_error = config + .runtime_database_connection_config() + .expect_err("migration URL cannot satisfy runtime connection"); + assert_eq!(runtime_error, RuntimeConfigError::InvalidDatabase); + let rendered = format!("{runtime_error:?} {runtime_error}"); + assert!(!rendered.contains(MIGRATION_DATABASE_URL_CANARY)); + assert!(!rendered.contains("registry_migration")); + + std::env::remove_var("REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL"); + std::env::set_var( + "REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL", + DATABASE_URL_CANARY, + ); + let migration_error = config + .migration_database_connection_config() + .expect_err("runtime URL cannot satisfy migration connection"); + assert_eq!(migration_error, RuntimeConfigError::InvalidDatabase); + let rendered = format!("{migration_error:?} {migration_error}"); + assert!(!rendered.contains(DATABASE_URL_CANARY)); + assert!(!rendered.contains("registry_runtime")); + std::env::remove_var("REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL"); +} + +#[test] +fn migration_database_resolver_uses_only_the_migration_reference() { + let _guard = environment_lock(); + let fixture = RuntimeFixture::new(); + let config = parse_runtime_config_with_env( + &valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ), + env_lookup, + ) + .expect("runtime parses"); + + std::env::remove_var("REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL"); + std::env::set_var( + "REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL", + MIGRATION_DATABASE_URL_CANARY, + ); + let migration_result = config.migration_database_connection_config(); + std::env::remove_var("REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL"); + if let Err(error) = migration_result { + let rendered = format!("{error:?} {error}"); + assert_ne!(error, RuntimeConfigError::Secret); + assert!(!rendered.contains(MIGRATION_DATABASE_URL_CANARY)); + } +} + +#[test] +fn database_references_must_be_structurally_distinct() { + let fixture = RuntimeFixture::new(); + let raw = valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace( + "migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL", + "migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL", + ); + + let error = parse_runtime_config_with_env(&raw, env_lookup) + .expect_err("same database reference is refused"); + assert_eq!(error, RuntimeConfigError::InvalidDatabase); + let rendered = format!("{error:?} {error}"); + assert!(!rendered.contains("REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL")); +} + +#[test] +fn invalid_bounds_roles_paths_and_oidc_inputs_are_refused() { + let fixture = RuntimeFixture::new(); + for (raw, expected) in [ + ( + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace("maxSize: 4", "maxSize: 129"), + RuntimeConfigError::InvalidBounds, + ), + ( + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace( + "migration: registry_migration", + "migration: RegistryMigration", + ), + RuntimeConfigError::InvalidDatabase, + ), + ( + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace("runtime: registry_runtime", "runtime: registry_migration"), + RuntimeConfigError::InvalidDatabase, + ), + ( + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace( + &fixture.package_root.display().to_string(), + "relative/package", + ), + RuntimeConfigError::InvalidPackage, + ), + ( + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace("principal: registry_principal", "principal: sub"), + RuntimeConfigError::InvalidOidc, + ), + ] { + assert_eq!( + parse_runtime_config_with_env(&raw, env_lookup).expect_err("invalid runtime refused"), + expected + ); + } +} + +#[test] +fn authored_jwks_uri_override_is_refused() { + let fixture = RuntimeFixture::new(); + let raw = valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace( + " audience: urn:registry-server:test\n", + " audience: urn:registry-server:test\n jwksUri: https://attacker.example/jwks.json\n", + ); + assert_eq!( + parse_runtime_config_with_env(&raw, env_lookup).expect_err("JWKS override refused"), + RuntimeConfigError::Document + ); +} + +#[test] +fn jwks_source_is_a_strict_tagged_oidc_member() { + let fixture = RuntimeFixture::new(); + let raw = valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ); + parse_runtime_config_with_env(&raw, env_lookup).expect("omitted source keeps discovery"); + parse_runtime_config_with_env(&runtime_with_discovery_source(&fixture), env_lookup) + .expect("explicit discovery source parses"); + parse_runtime_config_with_env( + &runtime_with_static_jwks_ref(&fixture, "secret:file/oidc-jwks"), + env_lookup, + ) + .expect("static file source parses without resolving the secret"); + parse_runtime_config_with_env( + &runtime_with_static_jwks_ref(&fixture, "secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_JWKS"), + env_lookup, + ) + .expect("static env source parses without resolving the secret"); + + for raw in [ + runtime_with_discovery_source(&fixture).replace( + " kind: discovery\n", + " kind: discovery\n documentRef: secret:file/oidc-jwks\n", + ), + runtime_with_static_jwks_ref(&fixture, "secret:file/oidc-jwks") + .replace(" documentRef:", " keys: []\n documentRef:"), + runtime_with_static_jwks_ref(&fixture, "secret:file/oidc-jwks") + .replace("secret:file/oidc-jwks", "/direct/path/jwks.json"), + runtime_with_static_jwks_ref(&fixture, "secret:file/oidc-jwks") + .replace("secret:file/oidc-jwks", "secret:file/../jwks"), + runtime_with_static_jwks_ref(&fixture, "secret:file/oidc-jwks") + .replace("kind: static", "kind: remote"), + ] { + assert!( + parse_runtime_config_with_env(&raw, env_lookup).is_err(), + "invalid JWKS source shape refused" + ); + } +} + +#[tokio::test] +async fn static_jwks_file_and_env_refs_build_a_ready_key_source() { + let fixture = RuntimeFixture::new(); + let kid = "static-ed25519"; + let jwks = static_jwks_document(&[static_ed25519_jwk(kid)]); + fixture.write_secret("oidc-jwks", jwks.as_bytes()); + let file_config = parse_runtime_config_with_env( + &runtime_with_static_jwks_ref(&fixture, "secret:file/oidc-jwks"), + env_lookup, + ) + .expect("file-pinned runtime parses"); + let file_source = file_config + .oidc_key_source() + .await + .expect("file-pinned static JWKS constructs"); + file_source + .ensure_key_set() + .await + .expect("static file key set is ready"); + file_source + .key_for_kid(kid) + .await + .expect("static file key is selectable by kid"); + + let _guard = async_environment_lock().await; + std::env::set_var(STATIC_JWKS_ENV, &jwks); + let env_config = parse_runtime_config_with_env( + &runtime_with_static_jwks_ref(&fixture, &format!("secret:env/{STATIC_JWKS_ENV}")), + env_lookup, + ) + .expect("env-pinned runtime parses"); + let env_source = env_config + .oidc_key_source() + .await + .expect("env-pinned static JWKS constructs"); + std::env::remove_var(STATIC_JWKS_ENV); + env_source + .ensure_key_set() + .await + .expect("resolved env key set remains ready"); + env_source + .key_for_kid(kid) + .await + .expect("resolved env key is selectable by kid"); +} + +#[tokio::test] +async fn static_jwks_validation_refuses_unsafe_documents_value_free() { + let fixture = RuntimeFixture::new(); + let denied = "denied-kid"; + let valid = static_ed25519_jwk("static-ed25519"); + let duplicate = static_jwks_document(&[ + static_ed25519_jwk("static-ed25519"), + static_ed25519_jwk("static-ed25519"), + ]); + let too_many = { + let keys = (0..=128) + .map(|index| static_ed25519_jwk(&format!("static-ed25519-{index}"))) + .collect::>(); + static_jwks_document(&keys) + }; + let cases = [ + ("top-level-missing", "{}".to_owned(), "EdDSA"), + ( + "top-level-unknown", + serde_json::to_string(&json!({"keys":[valid.clone()],"issuer":"issuer-canary"})) + .expect("JWKS serializes"), + "EdDSA", + ), + ("empty-keys", r#"{"keys":[]}"#.to_owned(), "EdDSA"), + ( + "keys-not-array", + r#"{"keys":{"kty":"OKP"}}"#.to_owned(), + "EdDSA", + ), + ( + "private-member", + static_jwks_document(&[with_member(valid.clone(), "d", json!("private-canary"))]), + "EdDSA", + ), + ( + "unknown-member", + static_jwks_document(&[with_member(valid.clone(), "x5c", json!(["cert-canary"]))]), + "EdDSA", + ), + ( + "duplicate-json-field", + format!( + r#"{{"keys":[{{"kty":"OKP","kid":"duplicate-canary","kid":"duplicate-canary-2","alg":"EdDSA","crv":"Ed25519","x":"{}"}}]}}"#, + valid["x"].as_str().expect("x is present") + ), + "EdDSA", + ), + ( + "oct-key", + r#"{"keys":[{"kty":"oct","kid":"oct-canary","alg":"EdDSA","k":"AA"}]}"#.to_owned(), + "EdDSA", + ), + ( + "missing-kid", + static_jwks_document(&[without_member(valid.clone(), "kid")]), + "EdDSA", + ), + ("duplicate-kid", duplicate, "EdDSA"), + ( + "denied-kid", + static_jwks_document(&[static_ed25519_jwk(denied)]), + "EdDSA", + ), + ( + "empty-kid", + static_jwks_document(&[with_member(valid.clone(), "kid", json!(""))]), + "EdDSA", + ), + ( + "oversized-kid", + static_jwks_document(&[with_member(valid.clone(), "kid", json!("k".repeat(513)))]), + "EdDSA", + ), + ("too-many-keys", too_many, "EdDSA"), + ( + "wrong-alg", + static_jwks_document(&[with_member(valid.clone(), "alg", json!("ES256"))]), + "EdDSA", + ), + ( + "wrong-type", + static_jwks_document(&[with_member(valid.clone(), "kty", json!("EC"))]), + "EdDSA", + ), + ( + "wrong-curve", + static_jwks_document(&[with_member(valid.clone(), "crv", json!("P-256"))]), + "EdDSA", + ), + ( + "bad-use", + static_jwks_document(&[with_member(valid.clone(), "use", json!("enc"))]), + "EdDSA", + ), + ( + "bad-key-ops", + static_jwks_document(&[with_member( + valid.clone(), + "key_ops", + json!(["verify", "verify"]), + )]), + "EdDSA", + ), + ( + "bad-base64", + static_jwks_document(&[with_member(valid.clone(), "x", json!("not base64"))]), + "EdDSA", + ), + ( + "bad-point", + static_jwks_document(&[es256_jwk_with_point( + "bad-point-canary", + vec![0; 32], + vec![0; 32], + )]), + "ES256", + ), + ( + "wrong-ec-curve", + static_jwks_document(&[with_member( + es256_jwk_with_point("wrong-curve-canary", vec![1; 32], vec![2; 32]), + "crv", + json!("P-384"), + )]), + "ES256", + ), + ( + "weak-rsa", + static_jwks_document(&[rsa_jwk("weak-rsa-canary", 255, "AQAB")]), + "RS256", + ), + ( + "oversized-rsa", + static_jwks_document(&[rsa_jwk("oversized-rsa-canary", 1025, "AQAB")]), + "RS256", + ), + ( + "bad-exponent", + static_jwks_document(&[rsa_jwk("bad-exponent-canary", 256, "Ag")]), + "RS256", + ), + ]; + + for (name, jwks, algorithm) in cases { + fixture.write_secret("oidc-jwks", jwks.as_bytes()); + let raw = runtime_with_static_jwks_ref(&fixture, "secret:file/oidc-jwks").replace( + "allowedAlgorithm: EdDSA", + &format!("allowedAlgorithm: {algorithm}"), + ); + let config = parse_runtime_config_with_env(&raw, env_lookup).expect("runtime shell parses"); + let error = config + .oidc_key_source() + .await + .expect_err("invalid static JWKS refused"); + assert_eq!(error, RuntimeConfigError::InvalidOidc, "{name}"); + let rendered = format!("{error:?} {error} {config:?}"); + for canary in [ + "oidc-jwks", + "issuer-canary", + "private-canary", + "cert-canary", + "duplicate-canary", + "oct-canary", + "bad-point-canary", + "wrong-curve-canary", + "weak-rsa-canary", + "oversized-rsa-canary", + "bad-exponent-canary", + ] { + assert!(!rendered.contains(canary), "{name} leaked {canary}"); + } + } +} + +#[tokio::test] +async fn static_jwks_resolves_once_and_rotates_only_on_reconstruction() { + let fixture = RuntimeFixture::new(); + let first_kid = "static-ed25519-first"; + let second_kid = "static-ed25519-second"; + fixture.write_secret( + "oidc-jwks", + static_jwks_document(&[static_ed25519_jwk(first_kid)]).as_bytes(), + ); + let config = parse_runtime_config_with_env( + &runtime_with_static_jwks_ref(&fixture, "secret:file/oidc-jwks"), + env_lookup, + ) + .expect("static runtime parses"); + let first_source = config + .oidc_key_source() + .await + .expect("first static source constructs"); + first_source + .ensure_key_set() + .await + .expect("first source is ready"); + fixture.write_secret( + "oidc-jwks", + static_jwks_document(&[static_ed25519_jwk(second_kid)]).as_bytes(), + ); + + first_source + .key_for_kid(first_kid) + .await + .expect("already constructed source keeps the original key"); + assert!( + first_source.key_for_kid(second_kid).await.is_err(), + "already constructed source does not reload the rotated file" + ); + let second_source = config + .oidc_key_source() + .await + .expect("reconstructed source reads the rotated document"); + second_source + .ensure_key_set() + .await + .expect("second source is ready"); + second_source + .key_for_kid(second_kid) + .await + .expect("reconstructed source sees the rotated key"); + assert!( + second_source.key_for_kid(first_kid).await.is_err(), + "reconstructed source does not retain stale keys" + ); +} + +#[test] +fn debug_and_errors_do_not_render_secret_or_expanded_canaries() { + let fixture = RuntimeFixture::new(); + let raw = valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace("production", "${ENVIRONMENT_CANARY}"); + let config = parse_runtime_config_with_env(&raw, |name| match name { + "ENVIRONMENT_CANARY" => Some(EXPANDED_CANARY.to_owned()), + _ => env_lookup(name), + }) + .expect("runtime config with env expansion parses"); + + let debug = format!("{config:?}"); + for canary in [ + EXPANDED_CANARY, + DATABASE_URL_CANARY, + MIGRATION_DATABASE_URL_CANARY, + AUDIT_KEY_CANARY, + "REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL", + "REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL", + "audit-key", + ] { + assert!(!debug.contains(canary), "debug leaked {canary}"); + } + + let unsafe_raw = raw.replace("registry_purpose", "purpose canary\nextra: true"); + let error = parse_runtime_config_with_env(&unsafe_raw, |name| match name { + "ENVIRONMENT_CANARY" => Some(EXPANDED_CANARY.to_owned()), + _ => env_lookup(name), + }) + .expect_err("invalid document refused"); + let rendered = format!("{error:?} {error}"); + assert!(!rendered.contains(EXPANDED_CANARY)); + assert!(!rendered.contains("purpose canary")); +} + +#[test] +fn unsafe_embedded_env_expansion_is_refused_without_echoing_value() { + let fixture = RuntimeFixture::new(); + let raw = valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace("https://issuer.example", "https://${OIDC_HOST}"); + let error = parse_runtime_config_with_env(&raw, |name| match name { + "OIDC_HOST" => Some("issuer.example\nentities: []".to_owned()), + _ => env_lookup(name), + }) + .expect_err("unsafe embedded expansion refused"); + assert_eq!(error, RuntimeConfigError::EnvExpansion); + let rendered = format!("{error:?} {error}"); + assert!(!rendered.contains("issuer.example")); + assert!(!rendered.contains("entities")); +} + +#[test] +fn expanded_runtime_document_is_bounded_before_yaml_parsing() { + let raw = "listener: ${OVERSIZED_RUNTIME_VALUE}\n: malformed\n"; + let error = parse_runtime_config_with_env(raw, |name| match name { + "OVERSIZED_RUNTIME_VALUE" => Some("runtime-bound-canary".repeat(4096)), + _ => env_lookup(name), + }) + .expect_err("oversized expansion refused before parsing"); + + assert_eq!(error, RuntimeConfigError::Bounds); + let rendered = format!("{error:?} {error}"); + assert!(!rendered.contains("runtime-bound-canary")); +} + +#[cfg(unix)] +#[test] +fn runtime_config_file_must_not_be_a_symlink() { + use std::os::unix::fs::symlink; + + let fixture = RuntimeFixture::new(); + let target = fixture.path("runtime-target.yaml"); + fs::write( + &target, + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ), + ) + .expect("runtime config target writes"); + let link = fixture.path("runtime-link.yaml"); + symlink(&target, &link).expect("runtime config symlink creates"); + + assert_eq!( + load_runtime_config_with_env(&link, env_lookup).expect_err("symlinked runtime refused"), + RuntimeConfigError::UnsafeFile + ); +} + +#[cfg(unix)] +#[test] +fn runtime_config_path_components_must_not_be_symlinks() { + use std::os::unix::fs::symlink; + + let fixture = RuntimeFixture::new(); + let real_dir = fixture.path("real-config-dir"); + fs::create_dir(&real_dir).expect("real config dir creates"); + let config_path = real_dir.join("runtime.yaml"); + fs::write( + &config_path, + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ), + ) + .expect("runtime config writes"); + let linked_dir = fixture.path("linked-config-dir"); + symlink(&real_dir, &linked_dir).expect("runtime config ancestor symlink creates"); + let linked_config = linked_dir.join("runtime.yaml"); + + assert_eq!( + load_runtime_config_with_env(&linked_config, env_lookup) + .expect_err("symlinked runtime ancestor refused"), + RuntimeConfigError::UnsafeFile + ); +} + +#[cfg(unix)] +#[test] +fn loaded_paths_must_not_be_symlinks() { + use std::os::unix::fs::symlink; + + let fixture = RuntimeFixture::new(); + let linked_root = fixture.path("linked-package"); + symlink(&fixture.package_root, &linked_root).expect("package symlink creates"); + let raw = valid_runtime(&fixture.secret_root, &linked_root, &fixture.trust_anchor); + let config_path = fixture.path("runtime-symlink.yaml"); + fs::write(&config_path, raw).expect("runtime config writes"); + + assert_eq!( + load_runtime_config(&config_path).expect_err("symlink path refused"), + RuntimeConfigError::InvalidPackage + ); +} + +#[test] +fn event_destination_shape_is_strict_and_governed_webhooks_remain_refused() { + let fixture = RuntimeFixture::new(); + let binding = event_destination_binding( + "case-operations", + "https://events.example/", + "/hooks/registry", + "secret:file/event-hmac-key", + 4_000, + 4, + ); + let valid = runtime_with_event_destinations(&fixture, &binding); + parse_runtime_config_with_env(&valid, env_lookup).expect("strict event binding parses"); + parse_runtime_config_with_env( + &valid.replace(" case-operations:\n", " events:\n"), + env_lookup, + ) + .expect("a compiler-valid logical id is not mistaken for a governed member"); + + for raw in [ + valid.replace( + " path: /hooks/registry\n", + " path: /hooks/registry\n headers: {}\n", + ), + valid.replace( + " maximumAttempts: 4\n", + " maximumAttempts: 4\n retryPolicy: caller-controlled\n", + ), + valid.replace( + " deliveryCeilings:\n", + " tls:\n caBundleRef: secret:file/event-ca\n privateKey: inline-canary\n deliveryCeilings:\n", + ), + ] { + assert_eq!( + parse_runtime_config_with_env(&raw, env_lookup) + .expect_err("unknown event destination key refused"), + RuntimeConfigError::Document + ); + } + + let mut governed = valid; + governed.push_str("webhooks: []\n"); + assert_eq!( + parse_runtime_config_with_env(&governed, env_lookup) + .expect_err("governed webhooks refused"), + RuntimeConfigError::GovernedMember + ); +} + +#[test] +fn invalid_event_destination_ids_origins_paths_cidrs_refs_and_ceilings_are_refused() { + let fixture = RuntimeFixture::new(); + let valid = runtime_with_event_destinations( + &fixture, + &event_destination_binding( + "case-operations", + "https://events.example/", + "/hooks/registry", + "secret:file/event-hmac-key", + 4_000, + 4, + ), + ); + let invalid_bindings = [ + valid.replace(" case-operations:\n", " Case-operations:\n"), + valid.replace("https://events.example/", "not-a-url"), + valid.replace("https://events.example/", "http://events.example/"), + valid.replace("https://events.example/", "https://events.example/path"), + valid.replace("/hooks/registry", "//authority-smuggling"), + valid.replace("/hooks/registry", "/hooks?query=denied"), + valid.replace( + " allowedPrivateCidrs: []\n", + " allowedPrivateCidrs: [10.1.2.3/8]\n", + ), + valid.replace( + " allowedPrivateCidrs: []\n", + " allowedPrivateCidrs: [203.0.113.0/24]\n", + ), + valid.replace( + " allowedPrivateCidrs: []\n", + " allowedPrivateCidrs: [192.168.0.0/16, 10.0.0.0/8]\n", + ), + valid.replace( + "secret:file/event-hmac-key", + "secret:file/../event-hmac-key", + ), + valid.replace( + "attemptTimeoutMilliseconds: 4000", + "attemptTimeoutMilliseconds: 99", + ), + valid.replace( + "attemptTimeoutMilliseconds: 4000", + "attemptTimeoutMilliseconds: 10001", + ), + valid.replace("maximumAttempts: 4", "maximumAttempts: 0"), + valid.replace("maximumAttempts: 4", "maximumAttempts: 21"), + valid.replace( + " deliveryCeilings:\n", + " tls: {}\n deliveryCeilings:\n", + ), + ]; + for raw in invalid_bindings { + assert_eq!( + parse_runtime_config_with_env(&raw, env_lookup) + .expect_err("invalid event binding refused"), + RuntimeConfigError::InvalidEventDestination + ); + } + + for raw in [ + valid.replace("productionHttps", "privateServiceHttp"), + valid.replace("dualStackStrict", "resolverDefault"), + ] { + assert_eq!( + parse_runtime_config_with_env(&raw, env_lookup) + .expect_err("non-closed event profile refused"), + RuntimeConfigError::Document + ); + } +} + +#[cfg(not(feature = "postgres-test"))] +#[test] +fn pinned_loopback_https_event_profile_is_absent_without_postgres_test() { + let fixture = RuntimeFixture::new(); + let raw = runtime_with_event_destinations( + &fixture, + &event_destination_binding( + "case-operations", + "https://127.0.0.1:1/", + "/hooks/registry", + "secret:file/event-hmac-key", + 2_500, + 3, + ) + .replace("productionHttps", "pinnedLoopbackHttpsTest"), + ); + + assert_eq!( + parse_runtime_config_with_env(&raw, env_lookup) + .expect_err("test-only network profile is absent from production parsing"), + RuntimeConfigError::Document + ); +} + +#[cfg(feature = "postgres-test")] +#[tokio::test] +async fn pinned_loopback_https_event_profile_activates_with_exact_test_tls_and_ceilings() { + const DESTINATION: &str = "case-operations"; + let fixture = RuntimeFixture::new(); + fixture.write_secret("event-hmac-key", &[0x41; 32]); + let binding = event_destination_binding( + DESTINATION, + "https://127.0.0.1:1/", + "/hooks/registry", + "secret:file/event-hmac-key", + 2_500, + 3, + ) + .replace("productionHttps", "pinnedLoopbackHttpsTest"); + let raw = runtime_with_event_destinations(&fixture, &binding); + let compiled = compiled_webhooks(&[(DESTINATION, 5_000, 5)]); + + let activate = |runtime: &str| { + parse_runtime_config_with_env(runtime, env_lookup) + .expect("test-only loopback HTTPS binding parses") + .activate_event_destinations(&compiled) + .expect("test-only loopback HTTPS binding activates") + }; + let first = activate(&raw); + let second = activate(&raw); + assert_eq!(first.binding_digest(), second.binding_digest()); + let destination = first + .lookup(DESTINATION) + .expect("compiled logical destination is activated"); + assert_eq!( + destination.policy().dns_family(), + DestinationDnsFamily::DualStackStrict + ); + assert_eq!(destination.attempt_timeout(), Duration::from_millis(2_500)); + assert_eq!(destination.maximum_attempts(), 3); + assert_eq!( + destination.binding_digest(), + second + .lookup(DESTINATION) + .expect("second activation has the exact destination") + .binding_digest() + ); + + let production = activate(&raw.replace("pinnedLoopbackHttpsTest", "productionHttps")); + assert_ne!(first.binding_digest(), production.binding_digest()); + assert_ne!( + destination.binding_digest(), + production + .lookup(DESTINATION) + .expect("production comparison binding activates") + .binding_digest(), + "the test-only TLS confinement profile is digest-bound" + ); + + let request = destination + .request_template() + .render_event(event_headers(), br#"{"label":"value"}"#.to_vec()) + .expect("closed event request renders"); + assert_eq!( + destination + .policy() + .send(request, Duration::from_secs(1)) + .await + .expect_err("closed loopback port has no TLS listener"), + DestinationSendError::TransportFailed, + "the pinned profile permits only the HTTPS transport attempt to loopback" + ); + + assert_eq!( + parse_runtime_config_with_env( + &raw.replace("https://127.0.0.1:1/", "http://127.0.0.1:1/"), + env_lookup + ) + .expect_err("the test profile never weakens HTTPS"), + RuntimeConfigError::InvalidEventDestination + ); +} + +#[test] +fn activation_constructs_the_exact_platform_policy_template_and_signing_material() { + const ORIGIN_CANARY: &str = "event-origin-canary.example"; + const PATH_CANARY: &str = "/event-path-canary"; + const DESTINATION_CANARY: &str = "case-operations"; + const REF_CANARY: &str = "event-hmac-key"; + const KEY_CANARY: &[u8] = b"event-key-canary-012345678901234567890123456789"; + + let fixture = RuntimeFixture::new(); + fixture.write_secret(REF_CANARY, KEY_CANARY); + let raw = runtime_with_event_destinations( + &fixture, + &event_destination_binding( + DESTINATION_CANARY, + &format!("https://{ORIGIN_CANARY}/"), + PATH_CANARY, + &format!("secret:file/{REF_CANARY}"), + 4_000, + 4, + ), + ); + let config = parse_runtime_config_with_env(&raw, env_lookup).expect("runtime parses"); + let compiled = compiled_webhooks(&[(DESTINATION_CANARY, 5_000, 5)]); + let activated = config + .activate_event_destinations(&compiled) + .expect("exact event binding activates"); + + assert!(activated.binding_digest().starts_with("sha256:")); + assert_eq!(activated.binding_digest().len(), 71); + assert!(activated.lookup("substituted-destination").is_none()); + let destination = activated + .lookup(DESTINATION_CANARY) + .expect("compiled logical destination is active"); + assert_eq!(destination.policy().origin_id(), DESTINATION_CANARY); + assert_eq!( + destination.policy().dns_family(), + DestinationDnsFamily::DualStackStrict + ); + assert_eq!(destination.attempt_timeout(), Duration::from_secs(4)); + assert_eq!(destination.maximum_attempts(), 4); + assert!(destination.binding_digest().starts_with("sha256:")); + assert_eq!(destination.binding_digest().len(), 71); + let destination_binding_digest = destination.binding_digest().to_owned(); + destination.with_hmac_sha256_key(|key| assert_eq!(key, KEY_CANARY)); + let request = destination + .request_template() + .render_event(event_headers(), br#"{"label":"value"}"#.to_vec()) + .expect("closed event request renders"); + + let diagnostic = format!("{config:?} {activated:?} {destination:?} {request:?}"); + for canary in [ + ORIGIN_CANARY, + PATH_CANARY, + DESTINATION_CANARY, + REF_CANARY, + std::str::from_utf8(KEY_CANARY).expect("key canary is text"), + "label\":\"value", + ] { + assert!(!diagnostic.contains(canary), "debug leaked {canary}"); + } + + fixture.write_secret(REF_CANARY, &[0x5a; 32]); + let same_references = config + .activate_event_destinations(&compiled) + .expect("rotated key activates under the same reference"); + assert_eq!( + destination_binding_digest, + same_references + .lookup(DESTINATION_CANARY) + .expect("rotated destination remains active") + .binding_digest(), + "binding identity must never digest secret bytes" + ); +} + +#[test] +fn activation_requires_exact_compiled_and_runtime_destination_sets() { + let fixture = RuntimeFixture::new(); + let compiled = compiled_webhooks(&[("case-operations", 5_000, 5)]); + + let missing = parse_runtime_config_with_env( + &valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ), + env_lookup, + ) + .expect("empty runtime parses"); + assert_eq!( + missing + .activate_event_destinations(&compiled) + .expect_err("missing binding refused"), + EventDestinationActivationError::InventoryMismatch + ); + + for bindings in [ + event_destination_binding( + "substituted-destination", + "https://events.example/", + "/hooks/registry", + "secret:file/missing-key", + 4_000, + 4, + ), + format!( + "{}{}", + event_destination_binding( + "case-operations", + "https://events.example/", + "/hooks/registry", + "secret:file/missing-key", + 4_000, + 4, + ), + event_destination_binding( + "extra-destination", + "https://extra.example/", + "/hooks/extra", + "secret:file/missing-key", + 4_000, + 4, + ) + ), + ] { + let config = parse_runtime_config_with_env( + &runtime_with_event_destinations(&fixture, &bindings), + env_lookup, + ) + .expect("binding set parses"); + assert_eq!( + config + .activate_event_destinations(&compiled) + .expect_err("non-exact binding set refused before secret lookup"), + EventDestinationActivationError::InventoryMismatch + ); + } +} + +#[test] +fn runtime_ceilings_narrow_every_subscription_sharing_a_destination() { + let fixture = RuntimeFixture::new(); + fixture.write_secret("event-hmac-key", &[0x41; 32]); + let compiled = compiled_webhooks(&[ + ("shared-destination", 5_000, 5), + ("shared-destination", 3_000, 3), + ]); + + let compatible = parse_runtime_config_with_env( + &runtime_with_event_destinations( + &fixture, + &event_destination_binding( + "shared-destination", + "https://events.example/", + "/hooks/shared", + "secret:file/event-hmac-key", + 2_500, + 3, + ), + ), + env_lookup, + ) + .expect("compatible shared binding parses"); + let activated = compatible + .activate_event_destinations(&compiled) + .expect("one narrowing ceiling is compatible with every subscription"); + assert_eq!( + activated + .lookup("shared-destination") + .expect("shared destination is active") + .attempt_timeout(), + Duration::from_millis(2_500) + ); + + for (timeout_ms, maximum_attempts) in [(3_001, 3), (2_500, 4)] { + let widening = parse_runtime_config_with_env( + &runtime_with_event_destinations( + &fixture, + &event_destination_binding( + "shared-destination", + "https://events.example/", + "/hooks/shared", + "secret:file/event-hmac-key", + timeout_ms, + maximum_attempts, + ), + ), + env_lookup, + ) + .expect("bounded but widening binding parses"); + assert_eq!( + widening + .activate_event_destinations(&compiled) + .expect_err("runtime cannot widen one shared subscription"), + EventDestinationActivationError::DeliveryCeilingWidening + ); + } +} + +#[test] +fn event_destination_digest_is_deterministic_across_yaml_map_order() { + let fixture = RuntimeFixture::new(); + fixture.write_secret("alpha-key", &[0x41; 32]); + fixture.write_secret("alpha-key-rotated-reference", &[0x41; 32]); + fixture.write_secret("bravo-key", &[0x42; 32]); + fixture.write_secret("bravo-key-rotated-reference", &[0x42; 32]); + let alpha = event_destination_binding( + "alpha-destination", + "https://alpha.example/", + "/hooks/alpha", + "secret:file/alpha-key", + 3_000, + 3, + ); + let bravo = event_destination_binding( + "bravo-destination", + "https://bravo.example/", + "/hooks/bravo", + "secret:file/bravo-key", + 4_000, + 4, + ); + let compiled = compiled_webhooks(&[ + ("alpha-destination", 5_000, 5), + ("bravo-destination", 5_000, 5), + ]); + + let activate = |bindings: String| { + parse_runtime_config_with_env( + &runtime_with_event_destinations(&fixture, &bindings), + env_lookup, + ) + .expect("ordered runtime parses") + .activate_event_destinations(&compiled) + .expect("ordered runtime activates") + }; + let first = activate(format!("{alpha}{bravo}")); + let second = activate(format!("{bravo}{alpha}")); + assert_eq!(first.binding_digest(), second.binding_digest()); + assert!(first.lookup("alpha-destination").is_some()); + assert!(first.lookup("bravo-destination").is_some()); + assert!(first.lookup("charlie-destination").is_none()); + + let changed_alpha_reference = activate(format!("{alpha}{bravo}").replace( + "secret:file/alpha-key\n", + "secret:file/alpha-key-rotated-reference\n", + )); + assert_ne!( + first.binding_digest(), + changed_alpha_reference.binding_digest() + ); + assert_ne!( + first + .lookup("alpha-destination") + .expect("alpha is active") + .binding_digest(), + changed_alpha_reference + .lookup("alpha-destination") + .expect("changed alpha is active") + .binding_digest() + ); + + let changed_bravo_reference = activate(format!("{alpha}{bravo}").replace( + "secret:file/bravo-key\n", + "secret:file/bravo-key-rotated-reference\n", + )); + assert_ne!( + first.binding_digest(), + changed_bravo_reference.binding_digest() + ); + assert_eq!( + first + .lookup("alpha-destination") + .expect("alpha is active") + .binding_digest(), + changed_bravo_reference + .lookup("alpha-destination") + .expect("unchanged alpha is active") + .binding_digest(), + "an unrelated destination must not invalidate alpha" + ); +} + +#[test] +fn event_destination_missing_oversized_and_unsafe_secrets_fail_value_free() { + const SECRET_CANARY: &str = "event-secret-reference-canary"; + + let fixture = RuntimeFixture::new(); + let compiled = compiled_webhooks(&[("case-operations", 5_000, 5)]); + for (key_ref, tls) in [ + (format!("secret:file/{SECRET_CANARY}"), "".to_owned()), + ( + "secret:file/event-hmac-key".to_owned(), + format!(" tls:\n caBundleRef: secret:file/{SECRET_CANARY}\n"), + ), + ] { + if !tls.is_empty() { + fixture.write_secret("event-hmac-key", &[0x41; 32]); + } + let binding = event_destination_binding( + "case-operations", + "https://events.example/", + "/hooks/registry", + &key_ref, + 4_000, + 4, + ) + .replace( + " deliveryCeilings:\n", + &format!("{tls} deliveryCeilings:\n"), + ); + let config = parse_runtime_config_with_env( + &runtime_with_event_destinations(&fixture, &binding), + env_lookup, + ) + .expect("secret-ref runtime parses"); + let error = config + .activate_event_destinations(&compiled) + .expect_err("missing secret refused"); + assert_eq!(error, EventDestinationActivationError::Secret); + assert!(!format!("{error:?} {error}").contains(SECRET_CANARY)); + } + + let oversized_key = vec![0x51; 64 * 1024 + 1]; + fixture.write_secret("oversized-event-key", &oversized_key); + let oversized = parse_runtime_config_with_env( + &runtime_with_event_destinations( + &fixture, + &event_destination_binding( + "case-operations", + "https://events.example/", + "/hooks/registry", + "secret:file/oversized-event-key", + 4_000, + 4, + ), + ), + env_lookup, + ) + .expect("oversized secret ref parses"); + assert_eq!( + oversized + .activate_event_destinations(&compiled) + .expect_err("oversized secret refused by the shared loader"), + EventDestinationActivationError::Secret + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + fixture.write_secret("unsafe-event-key", &[0x52; 32]); + fs::set_permissions( + fixture.secret_root.join("unsafe-event-key"), + fs::Permissions::from_mode(0o644), + ) + .expect("unsafe permissions set"); + let unsafe_file = parse_runtime_config_with_env( + &runtime_with_event_destinations( + &fixture, + &event_destination_binding( + "case-operations", + "https://events.example/", + "/hooks/registry", + "secret:file/unsafe-event-key", + 4_000, + 4, + ), + ), + env_lookup, + ) + .expect("unsafe secret ref parses"); + assert_eq!( + unsafe_file + .activate_event_destinations(&compiled) + .expect_err("unsafe secret file refused by the shared loader"), + EventDestinationActivationError::Secret + ); + } +} + +#[tokio::test] +async fn production_event_policy_refuses_literal_metadata_and_unallowed_private_destinations() { + let fixture = RuntimeFixture::new(); + fixture.write_secret("event-hmac-key", &[0x41; 32]); + let compiled = compiled_webhooks(&[("case-operations", 5_000, 5)]); + + for (origin, expected) in [ + ( + "https://169.254.169.254/", + DestinationSendError::CloudMetadataDenied, + ), + ( + "https://10.20.30.40/", + DestinationSendError::PrivateAddressNotAllowed, + ), + ] { + let config = parse_runtime_config_with_env( + &runtime_with_event_destinations( + &fixture, + &event_destination_binding( + "case-operations", + origin, + "/hooks/registry", + "secret:file/event-hmac-key", + 4_000, + 4, + ), + ), + env_lookup, + ) + .expect("literal HTTPS binding parses"); + let activated = config + .activate_event_destinations(&compiled) + .expect("literal HTTPS binding activates under platform authority"); + let destination = activated + .lookup("case-operations") + .expect("compiled destination is active"); + let request = destination + .request_template() + .render_event(event_headers(), br#"{"label":"value"}"#.to_vec()) + .expect("event request renders"); + let error = destination + .policy() + .send(request, Duration::from_secs(1)) + .await + .expect_err("platform policy refuses the literal address"); + assert_eq!(error, expected); + let rendered = format!("{error:?} {error} {activated:?}"); + assert!(!rendered.contains(origin)); + } +} + +fn env_lookup(name: &str) -> Option { + match name { + "REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL" => Some(DATABASE_URL_CANARY.to_owned()), + "REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL" => { + Some(MIGRATION_DATABASE_URL_CANARY.to_owned()) + } + _ => None, + } +} + +fn runtime_with_discovery_source(fixture: &RuntimeFixture) -> String { + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace( + " jwksCache:\n", + " jwksSource:\n kind: discovery\n jwksCache:\n", + ) +} + +fn runtime_with_static_jwks_ref(fixture: &RuntimeFixture, document_ref: &str) -> String { + valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ) + .replace( + " jwksCache:\n", + &format!(" jwksSource:\n kind: static\n documentRef: {document_ref}\n jwksCache:\n"), + ) +} + +fn static_jwks_document(keys: &[Value]) -> String { + let document = json!({ "keys": keys }); + serde_json::to_string(&document).expect("test JWKS serializes") +} + +fn static_ed25519_jwk(kid: &str) -> Value { + let mut public = PrivateJwk::parse(registry_platform_testing::fixtures::ED25519_PRIVATE_JWK) + .expect("fixture private JWK parses") + .public(); + public.kid = Some(kid.to_owned()); + serde_json::to_value(public).expect("public JWK serializes") +} + +fn es256_jwk_with_point(kid: &str, x: Vec, y: Vec) -> Value { + json!({ + "kty": "EC", + "kid": kid, + "alg": "ES256", + "crv": "P-256", + "x": URL_SAFE_NO_PAD.encode(x), + "y": URL_SAFE_NO_PAD.encode(y), + }) +} + +fn rsa_jwk(kid: &str, modulus_bytes: usize, exponent: &str) -> Value { + json!({ + "kty": "RSA", + "kid": kid, + "alg": "RS256", + "n": URL_SAFE_NO_PAD.encode(vec![0xff; modulus_bytes]), + "e": exponent, + }) +} + +fn with_member(mut value: Value, member: &str, replacement: Value) -> Value { + value + .as_object_mut() + .expect("test JWK is an object") + .insert(member.to_owned(), replacement); + value +} + +fn without_member(mut value: Value, member: &str) -> Value { + value + .as_object_mut() + .expect("test JWK is an object") + .remove(member); + value +} + +fn environment_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +async fn async_environment_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await +} + +struct RuntimeFixture { + root: PathBuf, + secret_root: PathBuf, + package_root: PathBuf, + trust_anchor: PathBuf, +} + +impl RuntimeFixture { + fn new() -> Self { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock is after epoch") + .as_nanos(); + let counter = COUNTER.fetch_add(1, Ordering::SeqCst); + let root = std::env::temp_dir() + .canonicalize() + .expect("temporary parent canonicalizes") + .join(format!( + "registry-server-runtime-config-test-{suffix}-{}-{counter}", + std::process::id() + )); + fs::create_dir(&root).expect("temporary fixture root creates"); + let secret_root = root.join("secrets"); + let package_root = root.join("package"); + fs::create_dir(&secret_root).expect("secret root creates"); + fs::create_dir(&package_root).expect("package root creates"); + let trust_anchor = root.join("trust-anchor.json"); + fs::write(&trust_anchor, "{}").expect("trust anchor placeholder writes"); + fs::write(secret_root.join("audit-key"), AUDIT_KEY_CANARY).expect("audit secret writes"); + fs::write(secret_root.join("cursor-key"), [0x52_u8; 32]).expect("cursor secret writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions( + secret_root.join("audit-key"), + fs::Permissions::from_mode(0o600), + ) + .expect("audit secret permissions set"); + fs::set_permissions( + secret_root.join("cursor-key"), + fs::Permissions::from_mode(0o600), + ) + .expect("cursor secret permissions set"); + } + Self { + root, + secret_root, + package_root, + trust_anchor, + } + } + + fn path(&self, name: &str) -> PathBuf { + self.root.join(name) + } + + fn write_secret(&self, name: &str, bytes: &[u8]) { + let path = self.secret_root.join(name); + fs::write(&path, bytes).expect("event secret writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("event secret permissions set"); + } + } +} + +impl Drop for RuntimeFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} diff --git a/crates/registry-server/tests/schema_fingerprint_rehearsal.rs b/crates/registry-server/tests/schema_fingerprint_rehearsal.rs new file mode 100644 index 0000000000..054e3fab4b --- /dev/null +++ b/crates/registry-server/tests/schema_fingerprint_rehearsal.rs @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "postgres-test")] + +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; + +use postgres_harness::TestDatabase; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::parse_project_json; +use registry_server::package::{ + prepare_package, PackageBuildRequest, PackageMigrationPlanInput, PackageSourceFile, + PreparedPackage, SignaturePolicy, +}; +use registry_server::runtime_config::{parse_runtime_config, RuntimeConfig}; +use registry_server::startup::{ + prepare_schema_test_database_with_connection_configs_for_test, rehearse_schema_fingerprint, + rehearse_schema_fingerprint_with_connection_config_for_test, StartupError, +}; +use registry_server::CompiledRegistry; + +const ENVIRONMENT: &str = "production"; +const INSTANCE: &str = "rehearsal-instance"; +const DATABASE: &str = "rehearsal-database"; +const SOURCE_REVISION: &str = "rehearsal-source-revision"; +const FIXTURE_JOURNEYS: &[u8] = br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: [] +"#; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rehearsal_rolls_back_and_matches_committed_schema_test_preparation() { + let registry = compiled_registry(ENVIRONMENT, INSTANCE, SOURCE_REVISION); + let database = TestDatabase::create(1).await; + let config = runtime_config(&database, ENVIRONMENT, INSTANCE, SOURCE_REVISION); + + let fingerprint = rehearse_schema_fingerprint_with_connection_config_for_test( + &config, + ®istry, + &database.migration_config, + ) + .await + .expect("schema fingerprint rehearsal succeeds on an empty disposable database"); + + assert!( + managed_schemas_empty_by_restrict(&database).await, + "successful rehearsal rolls back every managed schema dependency" + ); + assert!( + registry_state_table(&database).await.is_none(), + "successful rehearsal never leaves active package state behind" + ); + + let package = prepared_package(&fingerprint); + prepare_schema_test_database_with_connection_configs_for_test( + &config, + &package, + &database.migration_config, + &database.runtime_config, + ) + .await + .expect("schema-test preparation can commit into the same database after rehearsal"); + assert_eq!( + active_schema_fingerprint(&database).await, + fingerprint, + "committed schema-test state carries the rehearsed fingerprint" + ); + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rehearsal_and_schema_test_prepare_refuse_dirty_text_search_configuration() { + let registry = compiled_registry(ENVIRONMENT, INSTANCE, SOURCE_REVISION); + let database = TestDatabase::create(1).await; + database + .admin + .batch_execute( + "CREATE TEXT SEARCH CONFIGURATION registry_data.dirty_canary + ( COPY = pg_catalog.simple )", + ) + .await + .expect("test can create a dirty managed text search configuration"); + let config = runtime_config(&database, ENVIRONMENT, INSTANCE, SOURCE_REVISION); + + assert_eq!( + rehearse_schema_fingerprint_with_connection_config_for_test( + &config, + ®istry, + &database.migration_config, + ) + .await + .err(), + Some(StartupError::DatabaseUnready) + ); + assert!( + registry_state_table(&database).await.is_none(), + "dirty-database refusal happens before the installer creates managed state" + ); + assert!( + text_search_configuration_exists(&database).await, + "rehearsal refusal preserves the dirty managed text search configuration" + ); + assert!( + !managed_schemas_empty_by_restrict(&database).await, + "dirty-database refusal does not erase the polluted managed schema" + ); + + let package = + prepared_package("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + assert_eq!( + prepare_schema_test_database_with_connection_configs_for_test( + &config, + &package, + &database.migration_config, + &database.runtime_config, + ) + .await + .err(), + Some(StartupError::DatabaseUnready) + ); + assert!( + text_search_configuration_exists(&database).await, + "schema-test preparation also preserves the dirty managed text search configuration" + ); + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rehearsal_refuses_migration_connection_using_the_runtime_role() { + let registry = compiled_registry(ENVIRONMENT, INSTANCE, SOURCE_REVISION); + let database = TestDatabase::create(1).await; + let config = runtime_config(&database, ENVIRONMENT, INSTANCE, SOURCE_REVISION); + + assert_eq!( + rehearse_schema_fingerprint_with_connection_config_for_test( + &config, + ®istry, + &database.runtime_config, + ) + .await + .err(), + Some(StartupError::DatabaseUnready) + ); + assert!( + managed_schemas_empty_by_restrict(&database).await, + "wrong migration role is refused before any managed schema dependency is installed" + ); + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn injected_rehearsal_seam_does_not_read_runtime_database_secret() { + let registry = compiled_registry(ENVIRONMENT, INSTANCE, SOURCE_REVISION); + let database = TestDatabase::create(1).await; + let config = runtime_config(&database, ENVIRONMENT, INSTANCE, SOURCE_REVISION); + + rehearse_schema_fingerprint_with_connection_config_for_test( + &config, + ®istry, + &database.migration_config, + ) + .await + .expect("injected migration rehearsal does not require the runtime database secret"); + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rehearsal_binding_is_refused_before_database_secret_resolution() { + let registry = compiled_registry(ENVIRONMENT, INSTANCE, SOURCE_REVISION); + let config = runtime_config_with_roles( + "wrong-environment", + INSTANCE, + SOURCE_REVISION, + "registry_migration", + "registry_runtime", + ); + + assert_eq!( + rehearse_schema_fingerprint(&config, ®istry).await.err(), + Some(StartupError::PackageRefused), + "identity binding is checked before missing database secrets can be resolved" + ); +} + +async fn active_schema_fingerprint(database: &TestDatabase) -> String { + database + .admin + .query_one( + "SELECT schema_fingerprint FROM registry_internal.registry_state WHERE singleton", + &[], + ) + .await + .expect("active schema fingerprint can be inspected") + .get(0) +} + +async fn registry_state_table(database: &TestDatabase) -> Option { + database + .admin + .query_one( + "SELECT to_regclass('registry_internal.registry_state')::text", + &[], + ) + .await + .expect("registry_state table presence can be inspected") + .get(0) +} + +async fn text_search_configuration_exists(database: &TestDatabase) -> bool { + database + .admin + .query_one( + "SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_ts_config c + JOIN pg_catalog.pg_namespace n ON n.oid = c.cfgnamespace + WHERE n.nspname = 'registry_data' + AND c.cfgname = 'dirty_canary' + )", + &[], + ) + .await + .expect("managed text search configuration can be inspected") + .get(0) +} + +async fn managed_schemas_empty_by_restrict(database: &TestDatabase) -> bool { + database + .admin + .batch_execute("BEGIN") + .await + .expect("empty-schema probe transaction starts"); + let empty = database + .admin + .batch_execute("DROP SCHEMA registry_internal RESTRICT; DROP SCHEMA registry_data RESTRICT") + .await + .is_ok(); + database + .admin + .batch_execute("ROLLBACK") + .await + .expect("empty-schema probe transaction rolls back"); + empty +} + +fn runtime_config( + database: &TestDatabase, + environment: &str, + instance_id: &str, + source_revision: &str, +) -> RuntimeConfig { + runtime_config_with_roles( + environment, + instance_id, + source_revision, + database.migration_role.as_str(), + database.runtime_role.as_str(), + ) +} + +fn runtime_config_with_roles( + environment: &str, + instance_id: &str, + source_revision: &str, + migration_role: &str, + runtime_role: &str, +) -> RuntimeConfig { + parse_runtime_config(&format!( + r#" +listener: + bind: 127.0.0.1:8080 + trustedProxy: direct +identity: + environment: {environment} + instanceId: {instance_id} + databaseId: {DATABASE} + databaseInitializationEnvironment: {environment} +secretProviders: + environment: {{}} +database: + runtimeUrlRef: secret:env/REGISTRY_SERVER_REHEARSAL_RUNTIME_URL + migrationUrlRef: secret:env/REGISTRY_SERVER_REHEARSAL_MIGRATION_URL + pool: + maxSize: 2 + waitTimeoutMilliseconds: 1000 + createTimeoutMilliseconds: 1000 + recycleTimeoutMilliseconds: 1000 + roles: + migration: {migration_role} + runtime: {runtime_role} +package: + root: /tmp/registry-server-rehearsal-package + trustAnchorPath: /tmp/registry-server-rehearsal-trust-anchor.json + compilerSourceRevision: {source_revision} + activeRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + activeSequence: 1 +authentication: + oidc: + issuer: https://issuer.example + audience: urn:registry-server:rehearsal + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + allowedClients: [registry-client] + deniedKids: [] + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 600 + negativeCacheTtlSeconds: 60 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 5000 + outageToleranceSeconds: 900 + authorityClaims: + principal: registry_principal + purpose: registry_purpose + rowBoundaryClaims: [] +audit: + hashKeyRef: secret:env/REGISTRY_SERVER_REHEARSAL_AUDIT_KEY +cursor: + secretRef: secret:env/REGISTRY_SERVER_REHEARSAL_CURSOR_KEY + maxAgeSeconds: 300 +eventDestinations: {{}} +operationalTimeouts: + httpRequestMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + recordLockMilliseconds: 5000 + migrationLockMilliseconds: 30000 + migrationStatementMilliseconds: 60000 +"# + )) + .expect("test runtime config parses") +} + +fn prepared_package(schema_fingerprint: &str) -> PreparedPackage { + prepare_package(PackageBuildRequest { + environment: ENVIRONMENT.to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + sequence: 1, + prior_revision: None, + compiler_source_revision: SOURCE_REVISION.to_owned(), + schema_fingerprint: schema_fingerprint.to_owned(), + signature_policy: SignaturePolicy { + threshold: 1, + key_ids: vec!["rehearsal-key".to_owned()], + }, + project: PackageSourceFile { + path: "source/registry.json".to_owned(), + bytes: project_bytes(ENVIRONMENT, INSTANCE, SOURCE_REVISION), + }, + modules: vec![], + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: FIXTURE_JOURNEYS.to_vec(), + }, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + }) + .expect("prepared package builds around rehearsed fingerprint") +} + +fn compiled_registry( + environment: &str, + instance_id: &str, + source_revision: &str, +) -> CompiledRegistry { + let project = project_bytes(environment, instance_id, source_revision); + let parsed = parse_project_json(&project).expect("production project parses"); + compile_project(&parsed, &[], CompileProfile::Production).expect("production project compiles") +} + +fn project_bytes(environment: &str, instance_id: &str, source_revision: &str) -> Vec { + let project = format!( + r#"{{ + "apiVersion": "registry.registrystack.org/v1alpha1", + "kind": "RegistryProject", + "registry": {{"id": "rehearsal-registry", "version": "1", "defaultLanguage": "en"}}, + "package": {{ + "environment": "{environment}", + "instanceId": "{instance_id}", + "sequence": 1, + "sourceRevision": "{source_revision}" + }}, + "manifestProjection": {{ + "accessProfile": "reader", + "classificationCeiling": "internal", + "catalog": {{ + "baseUrl": "https://rehearsal.example.test", + "title": "Rehearsal Registry", + "publisher": {{"name": "Rehearsal Publisher"}} + }}, + "dataset": {{ + "title": "Rehearsal Dataset", + "owner": "Rehearsal Publisher", + "status": "active" + }} + }}, + "entities": [{{ + "id": "case", + "route": "cases", + "mutationMode": "create_only", + "fields": [{{ + "id": "code", + "type": "string", + "maxLength": 32, + "classification": "internal" + }}], + "accessProfiles": [{{ + "id": "reader", + "principalClaim": "principal", + "operations": ["get", "list"], + "readableFields": ["code"] + }}] + }}] +}}"# + ); + project.into_bytes() +} diff --git a/crates/registry-server/tests/startup_http.rs b/crates/registry-server/tests/startup_http.rs new file mode 100644 index 0000000000..9180054564 --- /dev/null +++ b/crates/registry-server/tests/startup_http.rs @@ -0,0 +1,892 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "runtime")] + +use std::collections::BTreeSet; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::body::{to_bytes, Body}; +use axum::http::{HeaderName, HeaderValue, Request, StatusCode}; +use jsonwebtoken::jwk::JwkSet; +use jsonwebtoken::Algorithm; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; +use registry_server::api::{ + authenticated_router, router, HeldReadResponse, HttpService, ReadRuntimeIdentity, + ReadServiceError, ReadinessProbe, RecordReadRequest, RecordReadService, ServiceFuture, +}; +use registry_server::auth::{AuthorityClaimConfig, RegistryAuthenticator}; +use registry_server::cursor::CursorCodec; +use registry_server::runtime_config::{parse_runtime_config_with_env, RuntimeConfigError}; +use registry_server::startup::{ + operational_log_level, with_request_timeout_for_test, OperationalEvent, OperationalLogLevel, + StartupError, WebhookStateTransitionCode, +}; +use registry_server::{compile_project, parse_project_yaml, CompileProfile, CompiledRegistry}; +use serde_json::{json, Value}; +use tower::ServiceExt as _; +use zeroize::Zeroizing; + +const RAW_PRINCIPAL_CANARY: &str = "rs-v1-25-raw-principal-canary"; +const RECORD_ID_CANARY: &str = "aaaaaaaa-aaaa-4aaa-8aaa-rsv125canary"; +const QUERY_VALUE_CANARY: &str = "rs-v1-25-query-value-canary"; +const REQUEST_VALUE_CANARY: &str = "rs-v1-25-request-value-canary"; +const RESPONSE_VALUE_CANARY: &str = "rs-v1-25-response-value-canary"; +const SQL_CANARY: &str = "SELECT rs_v1_25_sql_canary FROM private_records"; +const TOKEN_CANARY: &str = "rs-v1-25-token-canary"; +const FILESYSTEM_PATH_CANARY: &str = "rs-v1-25-filesystem-path-canary"; +const WEBHOOK_URL_CANARY: &str = "https://rs-v1-25-webhook.invalid/private"; +const WEBHOOK_SECRET_CANARY: &str = "rs-v1-25-webhook-secret-canary"; +const WEBHOOK_PAYLOAD_CANARY: &str = "rs-v1-25-webhook-payload-canary"; +const UPSTREAM_DETAIL_CANARY: &str = "rs-v1-25-upstream-detail-canary"; +const TRACESTATE_CANARY: &str = "registry=rs-v1-25-tracestate-canary"; + +const PROJECT: &str = r#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: startup-http + version: 1 + defaultLanguage: en +entities: + - id: public-record + route: public-records + mutationMode: create_only + tombstone: false + classification: public + fields: + - {id: label, type: string, required: true, maxLength: 80, classification: public} + accessProfiles: + - id: public + default: true + anonymous: true + operations: [list] + readableFields: [label] +"#; + +struct NoopRecords; + +impl RecordReadService for NoopRecords { + fn get( + &self, + _request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(async { Ok(None) }) + } + + fn list( + &self, + _request: RecordReadRequest, + ) -> ServiceFuture<'_, Result> { + Box::pin(async { + HeldReadResponse::from_json(&json!({"items": []})) + .map_err(|_| ReadServiceError::Unavailable) + }) + } +} + +struct SlowReadiness; + +impl ReadinessProbe for SlowReadiness { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async { + tokio::time::sleep(Duration::from_millis(200)).await; + true + }) + } +} + +#[tokio::test] +async fn request_timeout_returns_value_free_problem() { + let service = Arc::new(HttpService::new( + compiled_registry(), + ReadRuntimeIdentity { + package_revision: "package-startup-http".to_owned(), + schema_fingerprint: "schema-startup-http".to_owned(), + }, + Arc::new(NoopRecords), + Arc::new(SlowReadiness), + Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x45; 32]), Duration::from_secs(300)) + .expect("test cursor key is valid"), + ), + )); + let app = with_request_timeout_for_test(router(service), Duration::from_millis(10)); + + let response = app + .oneshot( + Request::builder() + .uri("/ready") + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("router responds"); + + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("timeout body reads"); + let text = std::str::from_utf8(&body).expect("timeout body is utf-8"); + assert!(text.contains("request.timeout")); + assert!(!text.contains("startup-http")); +} + +#[test] +fn operational_log_level_is_a_closed_vocabulary() { + assert_eq!( + operational_log_level(None).expect("default log level"), + tracing_subscriber::filter::LevelFilter::INFO + ); + assert!(operational_log_level(Some("info")).is_ok()); + assert!(operational_log_level(Some("warn")).is_ok()); + assert!(operational_log_level(Some("error")).is_ok()); + assert!(operational_log_level(Some("debug")).is_err()); + assert!(operational_log_level(Some("registry_server=trace")).is_err()); +} + +#[derive(Clone, Default)] +struct CapturedOperationalLogs(Arc>>); + +impl CapturedOperationalLogs { + fn text(&self) -> String { + String::from_utf8(self.0.lock().expect("operational log buffer").clone()) + .expect("operational logs are UTF-8") + } +} + +impl io::Write for CapturedOperationalLogs { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0 + .lock() + .map_err(|_| io::Error::other("operational log buffer poisoned"))? + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturedOperationalLogs { + type Writer = Self; + + fn make_writer(&'writer self) -> Self::Writer { + self.clone() + } +} + +fn startup_errors() -> [StartupError; 12] { + [ + StartupError::RuntimeConfig, + StartupError::PackageRefused, + StartupError::DatabaseConnection, + StartupError::DatabaseUnready, + StartupError::Audit, + StartupError::Cursor, + StartupError::Oidc, + StartupError::Authentication, + StartupError::EventDestinations, + StartupError::Listener, + StartupError::Shutdown, + StartupError::Logging, + ] +} + +fn expected_operational_event( + event: OperationalEvent, +) -> ( + OperationalLogLevel, + &'static str, + &'static str, + Option<&'static str>, + Option<&'static str>, +) { + match event { + OperationalEvent::StartupBegan => ( + OperationalLogLevel::Info, + "registry_server::startup", + "Registry Server startup began", + None, + None, + ), + OperationalEvent::Listening => ( + OperationalLogLevel::Info, + "registry_server::startup", + "Registry Server is listening", + None, + None, + ), + OperationalEvent::Stopped => ( + OperationalLogLevel::Error, + "registry_server::startup", + "Registry Server stopped", + None, + None, + ), + OperationalEvent::StoppedWithError(error) => ( + OperationalLogLevel::Error, + "registry_server::startup", + "Registry Server stopped", + Some(expected_startup_error(error)), + None, + ), + OperationalEvent::WebhookWorkerIterationFailed => ( + OperationalLogLevel::Warn, + "registry_server::webhook", + "webhook worker iteration failed", + None, + Some("webhook.worker.iteration_failed"), + ), + OperationalEvent::WebhookStateTransitionFailed(code) => ( + OperationalLogLevel::Warn, + "registry_server::webhook", + "webhook state transition failed", + None, + Some(expected_webhook_state_transition_code(code)), + ), + } +} + +fn expected_startup_error(error: StartupError) -> &'static str { + match error { + StartupError::RuntimeConfig => "the Registry runtime configuration was refused", + StartupError::PackageRefused => "the Registry package was refused", + StartupError::DatabaseConnection => "the Registry database connection was refused", + StartupError::DatabaseUnready => "the Registry database is not ready for this package", + StartupError::Audit => "the Registry audit profile was refused", + StartupError::Cursor => "the Registry cursor profile was refused", + StartupError::Oidc => "the Registry OIDC key source was refused", + StartupError::Authentication => "the Registry authentication profile was refused", + StartupError::EventDestinations => "the Registry event destination bindings were refused", + StartupError::Listener => "the Registry listener could not be started", + StartupError::Shutdown => "the Registry shutdown signal failed", + StartupError::Logging => "the Registry operational log level was refused", + } +} + +fn expected_webhook_state_transition_code(code: WebhookStateTransitionCode) -> &'static str { + match code { + WebhookStateTransitionCode::ClaimIdentityRefused => "webhook.claim.identity_refused", + WebhookStateTransitionCode::ClaimRecoveryFailed => "webhook.claim.recovery_failed", + WebhookStateTransitionCode::ClaimSelectFailed => "webhook.claim.select_failed", + WebhookStateTransitionCode::ClaimPolicyRefused => "webhook.claim.policy_refused", + WebhookStateTransitionCode::ClaimUpdateFailed => "webhook.claim.update_failed", + WebhookStateTransitionCode::ClaimAuditFailed => "webhook.claim.audit_failed", + WebhookStateTransitionCode::ClaimCommitFailed => "webhook.claim.commit_failed", + } +} + +fn operational_level_name(level: OperationalLogLevel) -> &'static str { + match level { + OperationalLogLevel::Info => "INFO", + OperationalLogLevel::Warn => "WARN", + OperationalLogLevel::Error => "ERROR", + } +} + +#[test] +fn every_operational_event_renders_exact_closed_value_free_json_fields() { + let mut events = vec![ + OperationalEvent::StartupBegan, + OperationalEvent::Listening, + OperationalEvent::Stopped, + ]; + events.extend( + startup_errors() + .into_iter() + .map(OperationalEvent::StoppedWithError), + ); + events.push(OperationalEvent::WebhookWorkerIterationFailed); + events.extend( + WebhookStateTransitionCode::ALL + .into_iter() + .map(OperationalEvent::WebhookStateTransitionFailed), + ); + + let writer = CapturedOperationalLogs::default(); + let subscriber = tracing_subscriber::fmt() + .json() + .with_target(false) + .with_current_span(false) + .with_span_list(false) + .with_writer(writer.clone()) + .finish(); + tracing::subscriber::with_default(subscriber, || { + for event in &events { + event.emit(); + } + }); + + let output = writer.text(); + assert_forbidden_values_absent(&output); + let rendered = output + .lines() + .map(|line| serde_json::from_str::(line).expect("operational log is JSON")) + .collect::>(); + assert_eq!(rendered.len(), events.len()); + for (event, rendered) in events.into_iter().zip(rendered) { + let expected = event.record(); + let (level, target, message, error, code) = expected_operational_event(event); + assert_eq!(expected.level(), level); + assert_eq!(expected.target(), target); + assert_eq!(expected.message(), message); + assert_eq!(expected.error(), error); + assert_eq!(expected.code(), code); + let object = rendered + .as_object() + .expect("operational log record is an object"); + assert_eq!( + object.keys().map(String::as_str).collect::>(), + BTreeSet::from(["fields", "level", "timestamp"]) + ); + assert_eq!(rendered["level"], operational_level_name(expected.level())); + let fields = rendered["fields"] + .as_object() + .expect("operational fields are an object"); + let mut expected_field_names = BTreeSet::from(["message"]); + if expected.error().is_some() { + expected_field_names.insert("error"); + } + if expected.code().is_some() { + expected_field_names.insert("code"); + } + assert_eq!( + fields.keys().map(String::as_str).collect::>(), + expected_field_names + ); + assert_eq!(fields["message"], expected.message()); + assert_eq!( + fields.get("error").and_then(Value::as_str), + expected.error() + ); + assert_eq!(fields.get("code").and_then(Value::as_str), expected.code()); + assert!(matches!( + expected.target(), + "registry_server::startup" | "registry_server::webhook" + )); + } +} + +#[tokio::test] +async fn provenance_operational_logs_metrics_and_traces_are_separate_closed_and_value_free() { + let directory = TestDirectory::create(); + let registry = compiled_registry(); + let registry_revision = registry.revision().to_owned(); + let service = Arc::new(HttpService::new( + Arc::clone(®istry), + ReadRuntimeIdentity { + package_revision: "package-startup-http".to_owned(), + schema_fingerprint: "schema-startup-http".to_owned(), + }, + Arc::new(NoopRecords), + Arc::new(SlowReadiness), + Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x45; 32]), Duration::from_secs(300)) + .expect("test cursor key is valid"), + ), + )); + let authenticator = Arc::new( + RegistryAuthenticator::new( + ®istry, + TokenVerifierConfig::access_token_profile( + "https://issuer.example", + vec!["urn:registry-server:test".to_owned()], + vec![Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + Arc::new(JwksFetcher::new_static( + JwkSet { keys: Vec::new() }, + JwksFetcherConfig::defaults(), + )), + AuthorityClaimConfig::new("registry_principal", None, Vec::new()), + ) + .expect("anonymous Registry has a valid production authenticator"), + ); + let app = with_request_timeout_for_test( + authenticated_router(service, authenticator), + Duration::from_secs(10), + ); + + let provenance = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/registry") + .body(Body::empty()) + .expect("provenance request builds"), + ) + .await + .expect("provenance response"); + assert_eq!(provenance.status(), StatusCode::OK); + let provenance: Value = serde_json::from_slice( + &to_bytes(provenance.into_body(), 1024 * 1024) + .await + .expect("provenance body reads"), + ) + .expect("provenance body is JSON"); + assert_eq!(provenance["id"], "startup-http"); + assert_eq!(provenance["version"], "1"); + assert_eq!(provenance["revision"], registry_revision); + + let mut request = Request::builder() + .uri(format!( + "/v1/records/public-records?filter=label:equals:{QUERY_VALUE_CANARY}&requestValue={REQUEST_VALUE_CANARY}" + )) + .body(Body::from(REQUEST_VALUE_CANARY)) + .expect("canary request builds"); + for (name, value) in [ + ("authorization", format!("Bearer {TOKEN_CANARY}")), + ("tracestate", TRACESTATE_CANARY.to_owned()), + ( + "traceparent", + "00-11111111111111111111111111111111-2222222222222222-01".to_owned(), + ), + ("x-raw-principal", RAW_PRINCIPAL_CANARY.to_owned()), + ("x-record-id", RECORD_ID_CANARY.to_owned()), + ("x-response-value", RESPONSE_VALUE_CANARY.to_owned()), + ("x-sql", SQL_CANARY.to_owned()), + ("x-webhook-url", WEBHOOK_URL_CANARY.to_owned()), + ("x-webhook-secret", WEBHOOK_SECRET_CANARY.to_owned()), + ("x-webhook-payload", WEBHOOK_PAYLOAD_CANARY.to_owned()), + ("x-upstream-detail", UPSTREAM_DETAIL_CANARY.to_owned()), + ] { + request.headers_mut().insert( + HeaderName::from_bytes(name.as_bytes()).expect("header name is valid"), + HeaderValue::from_str(&value).expect("header value is valid"), + ); + } + let response = app + .clone() + .oneshot(request) + .await + .expect("canary request responds"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(response.headers().get("traceparent").is_none()); + assert!(response.headers().get("tracestate").is_none()); + let mut rendered_response = response + .headers() + .iter() + .map(|(name, value)| format!("{}:{}\n", name, value.to_str().unwrap_or(""))) + .collect::(); + rendered_response.push_str( + std::str::from_utf8( + &to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("canary response reads"), + ) + .expect("canary response is UTF-8"), + ); + assert_forbidden_values_absent(&rendered_response); + + for uri in ["/metrics", "/v1/metrics"] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("metrics request builds"), + ) + .await + .expect("metrics request responds"); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("metrics refusal reads"); + let body = std::str::from_utf8(&body).expect("metrics refusal is UTF-8"); + assert_forbidden_values_absent(body); + } + + let valid_runtime = runtime_without_telemetry(directory.path()); + parse_runtime_config_with_env(&valid_runtime, |_| None) + .expect("runtime without telemetry parses"); + for (member, expected) in [ + ( + format!("metrics:\n labels:\n principal: {RAW_PRINCIPAL_CANARY}\n"), + RuntimeConfigError::Document, + ), + ( + format!("telemetry:\n tracestate: {TRACESTATE_CANARY}\n"), + RuntimeConfigError::GovernedMember, + ), + ] { + let error = parse_runtime_config_with_env(&(valid_runtime.clone() + &member), |_| None) + .expect_err("runtime telemetry authority is absent"); + assert_eq!(error, expected); + assert_forbidden_values_absent(&format!("{error:?} {error}")); + } + + let config_path = directory.path().join(FILESYSTEM_PATH_CANARY); + fs::write(&config_path, canary_runtime_document()).expect("canary runtime config writes"); + let output = Command::new(env!("CARGO_BIN_EXE_registry-server")) + .args([ + "--config", + config_path.to_str().expect("config path is UTF-8"), + ]) + .env("REGISTRY_SERVER_LOG", "info") + .output() + .expect("registry-server process runs"); + assert_eq!(output.status.code(), Some(1)); + let stdout = std::str::from_utf8(&output.stdout).expect("operational stdout is UTF-8"); + let stderr = std::str::from_utf8(&output.stderr).expect("operational stderr is UTF-8"); + let logs = format!("{stdout}{stderr}"); + assert_forbidden_values_absent(&logs); + assert!(!logs.contains("startup-http")); + assert!(!logs.contains(®istry_revision)); + let records = logs + .lines() + .map(|line| serde_json::from_str::(line).expect("operational log is JSON")) + .collect::>(); + assert_eq!(records.len(), 2); + for record in &records { + assert_eq!( + record + .as_object() + .expect("log record is an object") + .keys() + .map(String::as_str) + .collect::>(), + BTreeSet::from(["fields", "level", "timestamp"]) + ); + let fields = record["fields"].as_object().expect("log fields are closed"); + assert!(fields + .keys() + .all(|field| matches!(field.as_str(), "message" | "error"))); + assert!(matches!( + fields["message"].as_str(), + Some("Registry Server startup began" | "Registry Server stopped") + )); + if let Some(error) = fields.get("error") { + assert_eq!(error, "the Registry runtime configuration was refused"); + } + } + + let invalid_level = Command::new(env!("CARGO_BIN_EXE_registry-server")) + .args([ + "--config", + config_path.to_str().expect("config path is UTF-8"), + ]) + .env("REGISTRY_SERVER_LOG", "debug") + .output() + .expect("invalid log level is rendered through the production logger"); + assert_eq!(invalid_level.status.code(), Some(2)); + let invalid_logs = format!( + "{}{}", + std::str::from_utf8(&invalid_level.stdout).expect("operational stdout is UTF-8"), + std::str::from_utf8(&invalid_level.stderr).expect("operational stderr is UTF-8") + ); + assert_forbidden_values_absent(&invalid_logs); + let invalid_records = invalid_logs + .lines() + .map(|line| serde_json::from_str::(line).expect("operational log is JSON")) + .collect::>(); + assert_eq!(invalid_records.len(), 1); + let invalid_record = &invalid_records[0]; + assert_eq!(invalid_record["level"], "ERROR"); + assert_eq!( + invalid_record["fields"]["message"], + "Registry Server stopped" + ); + assert_eq!( + invalid_record["fields"]["error"], + "the Registry operational log level was refused" + ); + assert_eq!( + invalid_record["fields"] + .as_object() + .expect("invalid-level fields are closed") + .keys() + .map(String::as_str) + .collect::>(), + BTreeSet::from(["error", "message"]) + ); +} + +struct TestDirectory { + directory: PathBuf, +} + +impl TestDirectory { + fn create() -> Self { + let directory = std::env::temp_dir().join(format!( + "registry-server-startup-http-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir(&directory).expect("temporary directory is created"); + Self { directory } + } + + fn path(&self) -> &Path { + &self.directory + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + fs::remove_dir_all(&self.directory).expect("temporary directory is removed"); + } +} + +fn forbidden_values() -> [&'static str; 13] { + [ + RAW_PRINCIPAL_CANARY, + RECORD_ID_CANARY, + QUERY_VALUE_CANARY, + REQUEST_VALUE_CANARY, + RESPONSE_VALUE_CANARY, + SQL_CANARY, + TOKEN_CANARY, + FILESYSTEM_PATH_CANARY, + WEBHOOK_URL_CANARY, + WEBHOOK_SECRET_CANARY, + WEBHOOK_PAYLOAD_CANARY, + UPSTREAM_DETAIL_CANARY, + TRACESTATE_CANARY, + ] +} + +fn assert_forbidden_values_absent(text: &str) { + for forbidden in forbidden_values() { + assert!(!text.contains(forbidden), "forbidden value was disclosed"); + } +} + +fn canary_runtime_document() -> String { + format!( + r#"telemetry: + rawPrincipal: {RAW_PRINCIPAL_CANARY} + recordId: {RECORD_ID_CANARY} + queryValue: {QUERY_VALUE_CANARY} + requestValue: {REQUEST_VALUE_CANARY} + responseValue: {RESPONSE_VALUE_CANARY} + sql: "{SQL_CANARY}" + token: {TOKEN_CANARY} + webhookUrl: {WEBHOOK_URL_CANARY} + webhookSecret: {WEBHOOK_SECRET_CANARY} + webhookPayload: {WEBHOOK_PAYLOAD_CANARY} + upstreamDetail: {UPSTREAM_DETAIL_CANARY} + tracestate: {TRACESTATE_CANARY} +"# + ) +} + +fn runtime_without_telemetry(root: &Path) -> String { + format!( + r#"listener: + bind: 127.0.0.1:8080 + trustedProxy: direct +identity: + environment: production + instanceId: registry-primary + databaseId: registry-db + databaseInitializationEnvironment: production +secretProviders: + environment: {{}} + file: + root: {} +database: + runtimeUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL + migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL + pool: + maxSize: 4 + waitTimeoutMilliseconds: 1000 + createTimeoutMilliseconds: 1000 + recycleTimeoutMilliseconds: 1000 + roles: + migration: registry_migration + runtime: registry_runtime +package: + root: {} + trustAnchorPath: {} + compilerSourceRevision: source-revision-1 + activeRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + activeSequence: 1 +authentication: + oidc: + issuer: https://issuer.example + audience: urn:registry-server:test + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + allowedClients: [registry-client] + deniedKids: [denied-kid] + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 600 + negativeCacheTtlSeconds: 60 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 5000 + outageToleranceSeconds: 900 + authorityClaims: + principal: registry_principal + purpose: registry_purpose + rowBoundaryClaims: + - {{name: jurisdiction, type: directStringSet}} + - {{name: tenant, type: directString}} +audit: + hashKeyRef: secret:file/audit-key +cursor: + secretRef: secret:file/cursor-key + maxAgeSeconds: 300 +eventDestinations: {{}} +operationalTimeouts: + httpRequestMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + recordLockMilliseconds: 5000 + migrationLockMilliseconds: 30000 + migrationStatementMilliseconds: 60000 +"#, + root.display(), + root.display(), + root.join("trust-anchor.json").display() + ) +} + +#[cfg(feature = "postgres-test")] +#[tokio::test] +async fn serve_returns_after_graceful_shutdown_signal() { + use axum::routing::get; + use registry_server::startup::{serve_until_shutdown, PreparedServer}; + use registry_server::webhook::WebhookWorkerLifecycleProbe; + + let app = axum::Router::new().route("/healthz", get(|| async { "ok" })); + let probe = WebhookWorkerLifecycleProbe::new(false); + let prepared = PreparedServer::from_parts_with_webhook_worker_for_test( + "127.0.0.1:0".parse().expect("ephemeral bind parses"), + app, + Duration::from_secs(2), + probe.worker(), + ); + + let result = tokio::time::timeout( + Duration::from_secs(2), + serve_until_shutdown(prepared, async { Ok(()) }), + ) + .await + .expect("server exits within test timeout"); + assert_eq!(result, Ok(())); + assert!(probe.started()); + assert!(probe.stopped()); + assert!(!probe.running()); +} + +#[cfg(feature = "postgres-test")] +#[tokio::test] +async fn shutdown_signal_failure_still_stops_and_joins_the_bound_server() { + use axum::routing::get; + use registry_server::startup::{serve_until_shutdown, PreparedServer, StartupError}; + use registry_server::webhook::WebhookWorkerLifecycleProbe; + use tokio::net::TcpListener; + + let reservation = TcpListener::bind("127.0.0.1:0") + .await + .expect("ephemeral listener reservation binds"); + let bind = reservation + .local_addr() + .expect("ephemeral listener address reads"); + drop(reservation); + let app = axum::Router::new().route("/healthz", get(|| async { "ok" })); + let probe = WebhookWorkerLifecycleProbe::new(false); + let prepared = PreparedServer::from_parts_with_webhook_worker_for_test( + bind, + app, + Duration::from_secs(2), + probe.worker(), + ); + + let result = tokio::time::timeout( + Duration::from_secs(2), + serve_until_shutdown(prepared, async { Err(StartupError::Shutdown) }), + ) + .await + .expect("signal failure still completes bounded cleanup"); + assert_eq!(result, Err(StartupError::Shutdown)); + assert!(probe.started()); + assert!(probe.stopped()); + assert!(!probe.running()); + let rebound = TcpListener::bind(bind) + .await + .expect("server task no longer owns the listener after error cleanup"); + drop(rebound); +} + +#[cfg(feature = "postgres-test")] +#[tokio::test] +async fn listener_bind_failure_never_starts_or_detaches_the_webhook_worker() { + use axum::routing::get; + use registry_server::startup::{serve_until_shutdown, PreparedServer, StartupError}; + use registry_server::webhook::WebhookWorkerLifecycleProbe; + use tokio::net::TcpListener; + + let occupied = TcpListener::bind("127.0.0.1:0") + .await + .expect("occupied listener binds"); + let bind = occupied.local_addr().expect("occupied address reads"); + let app = axum::Router::new().route("/healthz", get(|| async { "ok" })); + let probe = WebhookWorkerLifecycleProbe::new(false); + let prepared = PreparedServer::from_parts_with_webhook_worker_for_test( + bind, + app, + Duration::from_secs(1), + probe.worker(), + ); + + assert_eq!( + serve_until_shutdown(prepared, async { Ok(()) }).await, + Err(StartupError::Listener) + ); + assert!(!probe.started()); + assert!(!probe.running()); + drop(occupied); +} + +#[cfg(feature = "postgres-test")] +#[tokio::test] +async fn shutdown_timeout_aborts_and_joins_the_webhook_worker_before_returning() { + use axum::routing::get; + use registry_server::startup::{serve_until_shutdown, PreparedServer, StartupError}; + use registry_server::webhook::WebhookWorkerLifecycleProbe; + use tokio::net::TcpListener; + + let reservation = TcpListener::bind("127.0.0.1:0") + .await + .expect("ephemeral listener reservation binds"); + let bind = reservation + .local_addr() + .expect("ephemeral listener address reads"); + drop(reservation); + let app = axum::Router::new().route("/healthz", get(|| async { "ok" })); + let probe = WebhookWorkerLifecycleProbe::new(true); + let prepared = PreparedServer::from_parts_with_webhook_worker_for_test( + bind, + app, + Duration::from_millis(25), + probe.worker(), + ); + + assert_eq!( + serve_until_shutdown(prepared, async { Ok(()) }).await, + Err(StartupError::Shutdown) + ); + assert!(probe.started()); + assert!(probe.stopped()); + assert!(!probe.running()); + let rebound = TcpListener::bind(bind) + .await + .expect("timeout cleanup joined the server before returning"); + drop(rebound); +} + +fn compiled_registry() -> Arc { + let project = parse_project_yaml(PROJECT.as_bytes()).expect("project parses"); + Arc::new(compile_project(&project, &[], CompileProfile::Authoring).expect("project compiles")) +} diff --git a/crates/registry-server/tests/startup_ordering.rs b/crates/registry-server/tests/startup_ordering.rs new file mode 100644 index 0000000000..efff37bd20 --- /dev/null +++ b/crates/registry-server/tests/startup_ordering.rs @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "runtime")] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_crypto::{generate_private_jwk, sign, GeneratedKeyAlgorithm}; +use registry_server::compiler::{module_digest, CompileProfile}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::package::{ + prepare_package, PackageBuildRequest, PackageFileRole, PackageMigrationPlanInput, + PackageModuleSource, PackageSignature, PackageSourceFile, PackageTrustAnchor, SignaturePolicy, + TrustAnchorKey, TRUST_ANCHOR_API_VERSION, +}; +use registry_server::startup::{prepare, StartupError}; +use serde::Serialize; + +const INSTANCE: &str = "instance-under-test"; +const DATABASE: &str = "database-under-test"; +const SOURCE_REVISION: &str = "compiler-source-revision"; +const FIXTURE_JOURNEYS: &[u8] = br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: neutral-record-list + steps: + - id: list-neutral-records + entity: neutral-record + accessProfile: reader + claims: {principal: package-reader} + request: {operation: list} + expect: {outcome: success, status: 200, count: 0} +"#; +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[tokio::test] +async fn tampered_package_refuses_before_database_audit_oidc_or_listener_access() { + let fixture = StartupFixture::new(); + let package = PackageFixture::build(&fixture.root); + fs::write( + first_generated_path(&package.root), + b"tampered-before-startup", + ) + .expect("test tampers package artifact"); + let config_path = fixture.write_config(&package); + + let error = match prepare(&config_path).await { + Ok(_) => panic!("tampered package prepared"), + Err(error) => error, + }; + + assert_eq!(error, StartupError::PackageRefused); +} + +struct StartupFixture { + root: PathBuf, + secret_root: PathBuf, +} + +impl StartupFixture { + fn new() -> Self { + let parent = std::env::temp_dir() + .canonicalize() + .expect("temporary parent canonicalizes"); + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock follows epoch") + .as_nanos(); + let ordinal = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let root = parent.join(format!( + "registry-server-startup-ordering-{}-{suffix}-{ordinal}", + std::process::id(), + )); + fs::create_dir(&root).expect("fixture root creates"); + let secret_root = root.join("secrets"); + fs::create_dir(&secret_root).expect("secret root creates"); + Self { root, secret_root } + } + + fn write_config(&self, package: &PackageFixture) -> PathBuf { + let path = self.root.join("runtime.yaml"); + fs::write( + &path, + format!( + r#" +listener: + bind: 127.0.0.1:9 + trustedProxy: direct +identity: + environment: production + instanceId: {INSTANCE} + databaseId: {DATABASE} + databaseInitializationEnvironment: production +secretProviders: + environment: {{}} + file: + root: {} +database: + runtimeUrlRef: secret:env/REGISTRY_SERVER_STARTUP_TEST_DATABASE_URL + migrationUrlRef: secret:env/REGISTRY_SERVER_STARTUP_TEST_MIGRATION_DATABASE_URL + pool: + maxSize: 1 + waitTimeoutMilliseconds: 1000 + createTimeoutMilliseconds: 1000 + recycleTimeoutMilliseconds: 1000 + roles: + migration: registry_migration + runtime: registry_runtime +package: + root: {} + trustAnchorPath: {} + compilerSourceRevision: {SOURCE_REVISION} + activeRevision: {} + activeSequence: 1 +authentication: + oidc: + issuer: http://127.0.0.1:9 + audience: urn:registry-server:test + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 600 + negativeCacheTtlSeconds: 60 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 10 + outageToleranceSeconds: 0 + authorityClaims: + principal: principal +audit: + hashKeyRef: secret:file/missing-audit-key +cursor: + secretRef: secret:file/missing-cursor-key + maxAgeSeconds: 300 +operationalTimeouts: + httpRequestMilliseconds: 1000 + shutdownGraceMilliseconds: 1000 + recordLockMilliseconds: 1000 + migrationLockMilliseconds: 1000 + migrationStatementMilliseconds: 1000 +"#, + self.secret_root.display(), + package.root.display(), + package.anchor.display(), + package.revision + ), + ) + .expect("runtime config writes"); + path + } +} + +impl Drop for StartupFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +struct PackageFixture { + root: PathBuf, + anchor: PathBuf, + revision: String, +} + +impl PackageFixture { + fn build(parent: &Path) -> Self { + let root = parent.join("package"); + let signing = generate_private_jwk(GeneratedKeyAlgorithm::Es384) + .expect("fixture signing key generates"); + let module_bytes = module_bytes(); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); + let project_bytes = project_bytes(&module_digest(&module)); + let project = parse_project_yaml(&project_bytes).expect("fixture project parses"); + registry_server::compiler::compile_project( + &project, + std::slice::from_ref(&module), + CompileProfile::Production, + ) + .expect("fixture project compiles in production"); + let key_id = signing.public().kid.expect("generated key has kid"); + let prepared = prepare_package(PackageBuildRequest { + environment: "production".to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + sequence: 1, + prior_revision: None, + compiler_source_revision: SOURCE_REVISION.to_owned(), + schema_fingerprint: fingerprint(1), + signature_policy: SignaturePolicy { + threshold: 1, + key_ids: vec![key_id.clone()], + }, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: project_bytes, + }, + modules: vec![PackageModuleSource { + id: "core".to_owned(), + path: "source/modules/core/module.yaml".to_owned(), + bytes: module_bytes, + }], + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: FIXTURE_JOURNEYS.to_vec(), + }, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + }) + .expect("fixture package prepares"); + let signature = + sign(prepared.canonical_signed_bytes(), &signing).expect("fixture package signs"); + prepared + .publish_to_directory( + &root, + vec![PackageSignature { + key_id: key_id.clone(), + signature_hex: hex(&signature), + }], + ) + .expect("fixture package publishes"); + let anchor = parent.join("trust-anchor.json"); + write_json( + &anchor, + &PackageTrustAnchor { + api_version: TRUST_ANCHOR_API_VERSION.to_owned(), + environment: "production".to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + threshold: 1, + keys: vec![TrustAnchorKey { + key_id, + jwk: serde_json::to_value(signing.public()).expect("public JWK serializes"), + }], + }, + ); + Self { + root, + anchor, + revision: prepared.package_revision().to_owned(), + } + } +} + +fn project_bytes(module_digest: &str) -> Vec { + format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"production","instanceId":"{INSTANCE}","sequence":1,"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"name":"Package Test Publisher"}}}},"dataset":{{"title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}}},"modules":[{{"id":"core","version":"1","digest":"{module_digest}"}}]}}"# + ) + .into_bytes() +} + +fn module_bytes() -> Vec { + br#"{"id":"core","version":"1","entities":[{"id":"neutral-record","route":"neutral-records","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get","list"],"readableFields":["code"]}]}]}"#.to_vec() +} + +fn first_generated_path(root: &Path) -> PathBuf { + let envelope: registry_server::package::PackageEnvelope = + serde_json::from_slice(&fs::read(root.join("package.json")).expect("manifest reads")) + .expect("manifest parses"); + root.join( + &envelope + .signed + .files + .iter() + .find(|entry| entry.role == PackageFileRole::GeneratedOpenapi) + .expect("generated entry exists") + .path, + ) +} + +fn write_json(path: &Path, value: &impl Serialize) { + let bytes = canonicalize_json(&serde_json::to_value(value).expect("value serializes")) + .expect("value canonicalizes"); + fs::write(path, bytes).expect("fixture JSON writes"); +} + +fn fingerprint(byte: u8) -> String { + format!("sha256:{}", format!("{byte:02x}").repeat(32)) +} + +fn hex(bytes: &[u8]) -> String { + let mut result = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(&mut result, "{byte:02x}").expect("writing to String succeeds"); + } + result +} diff --git a/crates/registry-server/tests/support/pilot_acceptance_harness.rs b/crates/registry-server/tests/support/pilot_acceptance_harness.rs new file mode 100644 index 0000000000..1992e6cbb9 --- /dev/null +++ b/crates/registry-server/tests/support/pilot_acceptance_harness.rs @@ -0,0 +1,702 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::body::{to_bytes, Body}; +use axum::http::header::AUTHORIZATION; +use axum::http::{HeaderName, HeaderValue, Method, Request, Response}; +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_crypto::{generate_private_jwk, sign, GeneratedKeyAlgorithm, PrivateJwk}; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; +use registry_platform_testing::MockIdp; +use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::contract::{ + parse_module_yaml, parse_project_yaml, BoundaryOperator, Operation, RegistryProject, +}; +use registry_server::package::{ + load_package, PackageBuildRequest, PackageIntent, PackageLoadContext, + PackageMigrationPlanInput, PackageModuleSource, PackageSignature, PackageSourceFile, + PackageTrustAnchor, SignaturePolicy, TrustAnchorKey, TRUST_ANCHOR_API_VERSION, +}; +use registry_server::postgres::{ + initialize_registry_state_for_catalog_test, install_compiled_schema, + managed_schema_fingerprint, ExpectedManagedCatalog, RegistryStateTestIdentity, +}; +use registry_server::startup::{prepare_with_connection_and_key_source_for_test, PreparedServer}; +use registry_server::CompiledRegistry; +use serde::Serialize; +use serde_json::{json, Value}; +use tower::ServiceExt as _; + +use super::postgres_harness::TestDatabase; + +const AUDIENCE: &str = "urn:registry-server:pilot-acceptance"; +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub struct PilotHarness { + pub database: TestDatabase, + pub registry: Arc, + prepared: PreparedServer, + idp: MockIdp, + scratch: ScratchDirectory, +} + +impl PilotHarness { + pub async fn start(fixture_name: &str) -> Self { + let sources = FixtureSources::load(fixture_name); + let identity = sources + .project + .package + .as_ref() + .expect("Production pilot fixture declares package identity") + .clone(); + let database_id = format!("{}-acceptance-db", sources.project.registry.id); + let database = TestDatabase::create(8).await; + database + .admin + .batch_execute(&format!( + "ALTER ROLE \"{}\" SET timezone TO 'Asia/Bangkok';", + database.runtime_role.as_str() + )) + .await + .expect("pilot runtime role explicitly uses a non-UTC session timezone"); + let timezone_probe_pool = database + .runtime_config + .build_pool() + .expect("timezone probe pool builds from the exact runtime role"); + let timezone_probe = timezone_probe_pool + .get_for_test() + .await + .expect("timezone probe opens a runtime-role session"); + let timezone: String = timezone_probe + .query_one("SHOW timezone", &[]) + .await + .expect("runtime session exposes its configured timezone") + .get(0); + assert_eq!(timezone, "Asia/Bangkok"); + drop(timezone_probe); + drop(timezone_probe_pool); + if sources.compiled.ddl().requires_btree_gist { + database + .admin + .execute("CREATE EXTENSION IF NOT EXISTS btree_gist", &[]) + .await + .expect("administrator installs the generic temporal exclusion dependency"); + } + let (migration, migration_task) = database.connect_migration().await; + let scratch = ScratchDirectory::new(fixture_name); + let signing = generate_private_jwk(GeneratedKeyAlgorithm::Es384) + .expect("pilot package signing key generates"); + + let provisional = PublishedPackage::build( + scratch.path(), + "provisional", + &sources, + &database_id, + fingerprint(1), + &signing, + ); + let provisional_context = + provisional.context(&identity, &database_id, PackageIntent::InitialActivation); + let verified_provisional = load_package(&provisional.root, &provisional_context) + .expect("exact committed pilot sources prepare a closed Production package"); + assert_eq!(verified_provisional.registry(), &sources.compiled); + install_compiled_schema( + &migration, + verified_provisional.registry(), + &database.runtime_role, + ) + .await + .expect("pilot Production schema installs without in-memory repair"); + let expected_catalog = ExpectedManagedCatalog::compiled(verified_provisional.registry()); + let schema_fingerprint = + managed_schema_fingerprint(&migration, &database.runtime_role, &expected_catalog) + .await + .expect("installed pilot schema has an exact managed fingerprint"); + drop(verified_provisional); + + let package = PublishedPackage::build( + scratch.path(), + "active", + &sources, + &database_id, + schema_fingerprint, + &signing, + ); + let package_context = + package.context(&identity, &database_id, PackageIntent::InitialActivation); + let verified = load_package(&package.root, &package_context) + .expect("signed pilot package verifies with its exact committed sources"); + assert_eq!(verified.registry(), &sources.compiled); + initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &ExpectedManagedCatalog::compiled(verified.registry()), + RegistryStateTestIdentity { + package_id: &verified.manifest().package_id, + environment: &verified.manifest().environment, + instance_id: &verified.manifest().instance_id, + database_id: &verified.manifest().database_id, + package_revision: &verified.manifest().package_revision, + package_sequence: i64::try_from(verified.manifest().sequence) + .expect("pilot package sequence fits PostgreSQL"), + }, + ) + .await + .expect("database initializes from the exact signed pilot identity"); + drop(verified); + drop(migration); + migration_task.abort(); + + let idp = MockIdp::start().await; + let config_path = write_runtime_config( + scratch.path(), + &package, + &identity, + &database_id, + &database, + &idp, + &sources.compiled, + ); + let key_source = Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig { + cache_ttl: Duration::from_secs(60), + negative_cache_ttl: Duration::from_secs(1), + refresh_cooldown: Duration::from_secs(1), + max_doc_bytes: 64 * 1024, + request_timeout: Duration::from_secs(5), + outage_tolerance: Duration::ZERO, + }, + FetchUrlPolicy::dev(), + )); + let prepared = prepare_with_connection_and_key_source_for_test( + &config_path, + database.runtime_config.clone(), + key_source, + ) + .await + .expect("existing startup seam accepts the exact package, database, audit, and MockIdp"); + + Self { + database, + registry: Arc::new(sources.compiled), + prepared, + idp, + scratch, + } + } + + pub fn token(&self, purpose: &str, row_boundary_claims: &[(&str, Value)]) -> String { + let mut claims = json!({ + "aud": AUDIENCE, + "registry_principal": "pilot-operator", + "purpose": purpose, + }); + for (name, value) in row_boundary_claims { + claims[*name] = value.clone(); + } + self.idp.mint_token(claims) + } + + pub async fn send( + &self, + method: Method, + uri: &str, + token: Option<&str>, + headers: &[(&str, &str)], + body: Vec, + ) -> Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .body(Body::from(body)) + .expect("pilot HTTP request builds"); + if let Some(token) = token { + request.headers_mut().insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")) + .expect("MockIdp bearer value is valid"), + ); + } + for (name, value) in headers { + request.headers_mut().append( + HeaderName::from_bytes(name.as_bytes()).expect("pilot header name is valid"), + HeaderValue::from_str(value).expect("pilot header value is valid"), + ); + } + self.prepared + .app() + .oneshot(request) + .await + .expect("PreparedServer router responds") + } + + pub async fn send_json( + &self, + method: Method, + uri: &str, + token: Option<&str>, + idempotency_key: Option<&str>, + body: Value, + ) -> Response { + let mut headers = vec![("content-type", "application/json")]; + if let Some(key) = idempotency_key { + headers.push(("idempotency-key", key)); + } + self.send( + method, + uri, + token, + &headers, + serde_json::to_vec(&body).expect("pilot request JSON serializes"), + ) + .await + } + + pub async fn finish(self) { + let Self { + database, + registry, + prepared, + idp, + scratch, + } = self; + drop(prepared); + drop(registry); + idp.stop().await; + database.cleanup().await; + drop(scratch); + } +} + +pub async fn response_bytes(response: Response) -> Vec { + to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .expect("bounded pilot response body reads") + .to_vec() +} + +pub async fn response_json(response: Response) -> Value { + serde_json::from_slice(&response_bytes(response).await).expect("pilot response is strict JSON") +} + +struct FixtureSources { + project: RegistryProject, + project_bytes: Vec, + modules: Vec<(String, Vec)>, + compiled: CompiledRegistry, +} + +impl FixtureSources { + fn load(name: &str) -> Self { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/registry-server/acceptance") + .join(name); + let project_bytes = fs::read(root.join("registry.yaml")) + .expect("committed pilot registry source is readable"); + let project = parse_project_yaml(&project_bytes) + .expect("committed pilot registry follows the strict authoring contract"); + let modules = project + .modules + .iter() + .map(|locked| { + let bytes = fs::read(root.join("modules").join(&locked.id).join("module.yaml")) + .expect("every exact locked module source is committed and readable"); + (locked.id.clone(), bytes) + }) + .collect::>(); + let parsed_modules = modules + .iter() + .map(|(_, bytes)| { + parse_module_yaml(bytes) + .expect("committed pilot module follows the strict contract") + }) + .collect::>(); + let compiled = compile_project(&project, &parsed_modules, CompileProfile::Production) + .expect("pilot fixture closes under the Production compiler without repair"); + Self { + project, + project_bytes, + modules, + compiled, + } + } +} + +struct PublishedPackage { + root: PathBuf, + anchor: PathBuf, + revision: String, +} + +impl PublishedPackage { + fn build( + parent: &Path, + label: &str, + sources: &FixtureSources, + database_id: &str, + schema_fingerprint: String, + signing: &PrivateJwk, + ) -> Self { + let identity = sources + .project + .package + .as_ref() + .expect("Production pilot identity exists"); + let key_id = signing.public().kid.expect("generated signing key has kid"); + let prepared = registry_server::package::prepare_package(PackageBuildRequest { + environment: identity.environment.clone(), + instance_id: identity.instance_id.clone(), + database_id: database_id.to_owned(), + sequence: identity.sequence, + prior_revision: None, + compiler_source_revision: identity.source_revision.clone(), + schema_fingerprint, + signature_policy: SignaturePolicy { + threshold: 1, + key_ids: vec![key_id.clone()], + }, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: sources.project_bytes.clone(), + }, + modules: sources + .modules + .iter() + .map(|(id, bytes)| PackageModuleSource { + id: id.clone(), + path: format!("source/modules/{id}/module.yaml"), + bytes: bytes.clone(), + }) + .collect(), + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: fixture_journey_bytes(&sources.compiled), + }, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + }) + .expect("exact pilot sources prepare as a Production package"); + let signature = sign(prepared.canonical_signed_bytes(), signing) + .expect("pilot package signature succeeds"); + let root = parent.join(format!("package-{label}")); + prepared + .publish_to_directory( + &root, + vec![PackageSignature { + key_id: key_id.clone(), + signature_hex: hex(&signature), + }], + ) + .expect("signed pilot package publishes to a closed directory"); + let anchor = parent.join(format!("trust-anchor-{label}.json")); + write_json( + &anchor, + &PackageTrustAnchor { + api_version: TRUST_ANCHOR_API_VERSION.to_owned(), + environment: identity.environment.clone(), + instance_id: identity.instance_id.clone(), + database_id: database_id.to_owned(), + threshold: 1, + keys: vec![TrustAnchorKey { + key_id, + jwk: serde_json::to_value(signing.public()) + .expect("pilot public JWK serializes"), + }], + }, + ); + Self { + root, + anchor, + revision: prepared.package_revision().to_owned(), + } + } + + fn context<'a>( + &'a self, + identity: &'a registry_server::contract::PackageIdentitySource, + database_id: &'a str, + intent: PackageIntent<'a>, + ) -> PackageLoadContext<'a> { + PackageLoadContext { + environment: &identity.environment, + instance_id: &identity.instance_id, + database_id, + database_initialization_environment: &identity.environment, + compiler_source_revision: &identity.source_revision, + trust_anchor: Some(&self.anchor), + intent, + } + } +} + +fn fixture_journey_bytes(registry: &CompiledRegistry) -> Vec { + let (entity_id, profile_id, profile) = registry + .entities() + .iter() + .flat_map(|(entity_id, entity)| { + entity + .access_profiles + .iter() + .map(move |(profile_id, profile)| (entity_id, profile_id, profile)) + }) + .find(|(_, _, profile)| profile.operations.contains(&Operation::List)) + .expect("every pilot fixture exposes one configured list journey"); + let claims = if profile.anonymous { + json!({}) + } else { + let direct_claims = profile + .row_boundaries + .iter() + .map(|boundary| { + ( + boundary.claim.clone(), + Value::String("fixture-boundary".to_owned()), + ) + }) + .collect::>(); + let mut claims = serde_json::Map::new(); + claims.insert( + "principal".to_owned(), + Value::String("fixture-operator".to_owned()), + ); + if !profile.required_scopes.is_empty() { + claims.insert( + "scopes".to_owned(), + Value::Array( + profile + .required_scopes + .iter() + .cloned() + .map(Value::String) + .collect(), + ), + ); + } + if let Some(purpose) = profile.required_purposes.iter().next() { + claims.insert("purpose".to_owned(), Value::String(purpose.clone())); + } + if !direct_claims.is_empty() { + claims.insert("directClaims".to_owned(), Value::Object(direct_claims)); + } + Value::Object(claims) + }; + serde_norway::to_string(&json!({ + "apiVersion": "registry.registrystack.org/server-journeys/v1", + "journeys": [{ + "id": "pilot-package-list", + "steps": [{ + "id": "list-configured-records", + "entity": entity_id, + "accessProfile": profile_id, + "claims": claims, + "request": {"operation": "list"}, + "expect": {"outcome": "success", "status": 200, "count": 0} + }] + }] + })) + .expect("generated pilot fixture journey serializes") + .into_bytes() +} + +fn write_runtime_config( + root: &Path, + package: &PublishedPackage, + identity: ®istry_server::contract::PackageIdentitySource, + database_id: &str, + database: &TestDatabase, + idp: &MockIdp, + registry: &CompiledRegistry, +) -> PathBuf { + let secrets = root.join("secrets"); + fs::create_dir(&secrets).expect("pilot secret root creates"); + write_secret( + &secrets.join("database-url"), + b"unused-by-test-startup-seam", + ); + write_secret(&secrets.join("audit-key"), &[0x6b; 32]); + write_secret(&secrets.join("cursor-key"), &[0x43; 32]); + let row_boundary_claims = runtime_row_boundary_claims(registry); + let path = root.join("runtime.yaml"); + fs::write( + &path, + format!( + r#"listener: + bind: 127.0.0.1:9 + trustedProxy: direct +identity: + environment: {} + instanceId: {} + databaseId: {database_id} + databaseInitializationEnvironment: {} +secretProviders: + file: + root: {} +database: + runtimeUrlRef: secret:file/database-url + migrationUrlRef: secret:file/migration-database-url + pool: + maxSize: 8 + waitTimeoutMilliseconds: 2000 + createTimeoutMilliseconds: 2000 + recycleTimeoutMilliseconds: 2000 + roles: + migration: {} + runtime: {} +package: + root: {} + trustAnchorPath: {} + compilerSourceRevision: {} + activeRevision: {} + activeSequence: {} +authentication: + oidc: + issuer: {} + audience: {AUDIENCE} + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + maxTokenLifetimeSeconds: 3600 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 60 + negativeCacheTtlSeconds: 1 + refreshCooldownSeconds: 1 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 5000 + outageToleranceSeconds: 0 + authorityClaims: + principal: registry_principal + purpose: purpose{row_boundary_claims} +audit: + hashKeyRef: secret:file/audit-key +cursor: + secretRef: secret:file/cursor-key + maxAgeSeconds: 300 +operationalTimeouts: + httpRequestMilliseconds: 5000 + shutdownGraceMilliseconds: 1000 + recordLockMilliseconds: 2000 + migrationLockMilliseconds: 2000 + migrationStatementMilliseconds: 5000 +"#, + identity.environment, + identity.instance_id, + identity.environment, + secrets.display(), + database.migration_role.as_str(), + database.runtime_role.as_str(), + package.root.display(), + package.anchor.display(), + identity.source_revision, + package.revision, + identity.sequence, + idp.issuer(), + ), + ) + .expect("strict pilot runtime configuration writes"); + set_private_permissions(&path); + path +} + +fn runtime_row_boundary_claims(registry: &CompiledRegistry) -> String { + let mut claims = BTreeMap::new(); + for entity in registry.entities().values() { + for profile in entity.access_profiles.values() { + for boundary in &profile.row_boundaries { + let value_type = match boundary.operator { + BoundaryOperator::Equals => "directString", + BoundaryOperator::In => "directStringSet", + }; + if let Some(previous) = claims.insert(boundary.claim.as_str(), value_type) { + assert_eq!( + previous, value_type, + "one verified authority claim cannot have conflicting compiled types" + ); + } + } + } + } + if claims.is_empty() { + return String::new(); + } + let mut yaml = String::from("\n rowBoundaryClaims:"); + for (name, value_type) in claims { + yaml.push_str(&format!( + "\n - name: {name}\n type: {value_type}" + )); + } + yaml +} + +fn write_secret(path: &Path, bytes: &[u8]) { + fs::write(path, bytes).expect("pilot secret writes"); + set_private_permissions(path); +} + +fn write_json(path: &Path, value: &impl Serialize) { + let bytes = canonicalize_json(&serde_json::to_value(value).expect("value serializes")) + .expect("value canonicalizes"); + fs::write(path, bytes).expect("pilot trust anchor writes"); + set_private_permissions(path); +} + +fn set_private_permissions(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("pilot private file permissions set"); + } +} + +struct ScratchDirectory(PathBuf); + +impl ScratchDirectory { + fn new(label: &str) -> Self { + let parent = std::env::temp_dir() + .canonicalize() + .expect("temporary parent canonicalizes"); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock follows epoch") + .as_nanos(); + let ordinal = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!( + "registry-server-pilot-{}-{label}-{nanos}-{ordinal}", + std::process::id() + )); + fs::create_dir(&path).expect("pilot scratch directory creates"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for ScratchDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn fingerprint(byte: u8) -> String { + format!("sha256:{}", format!("{byte:02x}").repeat(32)) +} + +fn hex(bytes: &[u8]) -> String { + let mut result = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(&mut result, "{byte:02x}").expect("writing to String succeeds"); + } + result +} diff --git a/crates/registry-server/tests/support/postgres_harness.rs b/crates/registry-server/tests/support/postgres_harness.rs new file mode 100644 index 0000000000..c4b623d94b --- /dev/null +++ b/crates/registry-server/tests/support/postgres_harness.rs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::{ + env, + str::FromStr, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use registry_server::postgres::{ + provision_managed_schemas, ConnectionConfig, PoolBounds, SqlIdentifier, +}; +use tokio::task::JoinHandle; +use tokio_postgres::{Client, Config, NoTls}; + +pub struct TestDatabase { + admin_root: Config, + pub admin: Client, + admin_task: JoinHandle<()>, + pub migration_config: ConnectionConfig, + pub runtime_config: ConnectionConfig, + pub tls_runtime_config: ConnectionConfig, + pub migration_role: SqlIdentifier, + pub runtime_role: SqlIdentifier, + pub intruder_role: SqlIdentifier, + database: SqlIdentifier, + migration_raw: Config, +} + +impl TestDatabase { + pub async fn create(pool_size: usize) -> Self { + let url = env::var("REGISTRY_SERVER_TEST_DATABASE_URL").expect( + "REGISTRY_SERVER_TEST_DATABASE_URL is required for the real PostgreSQL kernel test", + ); + let admin_root = Config::from_str(&url) + .expect("REGISTRY_SERVER_TEST_DATABASE_URL must be a valid PostgreSQL URL"); + let suffix = unique_suffix(); + let database = SqlIdentifier::parse(&format!("rs_test_{suffix}")) + .expect("generated database identifier is valid"); + let migration_role = SqlIdentifier::parse(&format!("rs_migration_{suffix}")) + .expect("generated migration role identifier is valid"); + let runtime_role = SqlIdentifier::parse(&format!("rs_runtime_{suffix}")) + .expect("generated runtime role identifier is valid"); + let intruder_role = SqlIdentifier::parse(&format!("rs_intruder_{suffix}")) + .expect("generated intruder role identifier is valid"); + let password = format!("rs{suffix}password"); + + let (root, root_task) = connect(admin_root.clone()).await; + root.batch_execute(&format!( + "CREATE ROLE \"{}\" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{}';\n\ + CREATE ROLE \"{}\" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{}';\n\ + CREATE ROLE \"{}\" NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;", + migration_role.as_str(), + password, + runtime_role.as_str(), + password, + intruder_role.as_str(), + )) + .await + .expect("test administrator can create isolated roles"); + root.batch_execute(&format!("CREATE DATABASE \"{}\";", database.as_str())) + .await + .expect("test administrator can create an isolated database"); + root_task.abort(); + + let mut database_admin_config = admin_root.clone(); + database_admin_config.dbname(database.as_str()); + let (admin, admin_task) = connect(database_admin_config).await; + admin + .batch_execute(&format!( + "REVOKE ALL ON DATABASE \"{}\" FROM PUBLIC;\n\ + GRANT CONNECT ON DATABASE \"{}\" TO \"{}\", \"{}\";", + database.as_str(), + database.as_str(), + migration_role.as_str(), + runtime_role.as_str(), + )) + .await + .expect("test administrator can constrain database privileges"); + provision_managed_schemas(&admin, &migration_role) + .await + .expect("test administrator can provision managed schemas"); + + let bounds = PoolBounds::new( + pool_size, + Duration::from_secs(2), + Duration::from_secs(2), + Duration::from_secs(2), + ) + .expect("test pool bounds are valid"); + let migration_raw = role_config(&admin_root, &database, &migration_role, &password); + let migration_config = ConnectionConfig::from_test_config(migration_raw.clone(), bounds) + .expect("migration test configuration is valid"); + let runtime_raw = role_config(&admin_root, &database, &runtime_role, &password); + let runtime_config = ConnectionConfig::from_test_config(runtime_raw.clone(), bounds) + .expect("runtime test configuration is valid"); + let tls_runtime_config = ConnectionConfig::require_tls_config(runtime_raw, bounds) + .expect("TLS runtime test configuration is valid"); + Self { + admin_root, + admin, + admin_task, + migration_config, + runtime_config, + tls_runtime_config, + migration_role, + runtime_role, + intruder_role, + database, + migration_raw, + } + } + + pub async fn connect_migration(&self) -> (Client, JoinHandle<()>) { + connect(self.migration_raw.clone()).await + } + + pub async fn cleanup(self) { + self.admin_task.abort(); + let (root, root_task) = connect(self.admin_root).await; + root.batch_execute(&format!( + "DROP DATABASE \"{}\" WITH (FORCE);", + self.database.as_str(), + )) + .await + .expect("isolated PostgreSQL test database can be removed"); + root.batch_execute(&format!( + "DROP ROLE \"{}\"; DROP ROLE \"{}\"; DROP ROLE \"{}\";", + self.intruder_role.as_str(), + self.runtime_role.as_str(), + self.migration_role.as_str(), + )) + .await + .expect("isolated PostgreSQL test roles can be removed"); + root_task.abort(); + } +} + +fn role_config( + admin: &Config, + database: &SqlIdentifier, + role: &SqlIdentifier, + password: &str, +) -> Config { + let mut config = admin.clone(); + config.dbname(database.as_str()); + config.user(role.as_str()); + config.password(password); + config +} + +async fn connect(config: Config) -> (Client, JoinHandle<()>) { + let (client, connection) = config + .connect(NoTls) + .await + .expect("real PostgreSQL test connection succeeds"); + let task = tokio::spawn(async move { + let _ = connection.await; + }); + (client, task) +} + +fn unique_suffix() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is after the Unix epoch") + .as_nanos(); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{}_{nanos}_{counter}", std::process::id()) +} diff --git a/crates/registry-serverctl/Cargo.toml b/crates/registry-serverctl/Cargo.toml new file mode 100644 index 0000000000..724662e236 --- /dev/null +++ b/crates/registry-serverctl/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "registry-serverctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Deterministic authoring and operator tooling for Registry Server." +repository.workspace = true +publish = false +readme = "README.md" + +[[bin]] +name = "registry-serverctl" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +clap.workspace = true +registry-platform-canonical-json.workspace = true +registry-platform-httputil.workspace = true +registry-platform-buildinfo.workspace = true +registry-server = { workspace = true, features = ["runtime", "tooling"] } +reqwest.workspace = true +rustix.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +sha2.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["rt"] } +zeroize.workspace = true + +[dev-dependencies] +registry-platform-crypto.workspace = true diff --git a/crates/registry-serverctl/README.md b/crates/registry-serverctl/README.md new file mode 100644 index 0000000000..de17f80ea5 --- /dev/null +++ b/crates/registry-serverctl/README.md @@ -0,0 +1,50 @@ +# Registry Server CLI + +`registry-serverctl` provides deterministic authoring and operator workflows +for Registry Server. It delegates model compilation, artifact generation, +package verification, and migration behavior to the `registry-server` library +rather than defining parallel semantics. + +AI-assisted tools may invoke this CLI, but receive no separate authority to +sign or apply production changes. + +`registry-serverctl package PROJECT --database-id ID --schema-fingerprint +SHA256 --output BUILD` always recompiles with the production profile. It writes +the exact canonical `BUILD/signing-input.json`. +For a non-local environment it reports `awaiting_signatures` and creates no +package until a later invocation supplies an external signature document. It +has no signing command and never receives private key material. The required +schema fingerprint is the exact digest from the separately reviewed +PostgreSQL rehearsal, not a compiler approximation. + +`registry-serverctl apply --runtime-config ACTIVE_RUNTIME --package TARGET` +loads the package selected by the runtime configuration as the verified +current state, verifies the separate target with activation intent, resolves +the configured database secret, and delegates the closed plan to the server +library. `--initial` is explicit and also requires the runtime package binding +to name the sequence-one target. There is no maintenance-clear, arbitrary SQL, +down-migration, role grant, or signing path in the CLI. + +`registry-serverctl diff PROJECT` compiles the candidate in authoring mode and +compares it with a closed, rederived package baseline. Exactly one baseline is +required: `--runtime-config ABSOLUTE_FILE` verifies configured deployment and +trust bindings without opening runtime dependencies, while `--package +DIRECTORY` performs integrity-only inspection and grants no activation +authority. + +`registry-serverctl explain events PROJECT [--production]` renders only the +compiler's deterministic event-delivery inventory. It contains logical +destination identifiers and governed delivery policy, never deployed URLs, +secret references, or secret values. + +`registry-serverctl doctor --runtime-config ABSOLUTE_FILE` verifies the startup +dependencies opened by the current preparation path without binding a +listener. It does not claim listener activation, webhook worker readiness, or +webhook delivery readiness. + +JSON failures expose only CLI-owned diagnostics with the stable keys +`severity`, `code`, `artifact`, `path`, `message`, and `suggestedAction`. +`artifact` is a logical source or operation identifier, never a filesystem +path. `suggestedAction` is a closed snake-case identifier and never contains +authored or deployed values. Human diagnostics retain the existing +`severity/code/path/message` rendering. diff --git a/crates/registry-serverctl/src/apply_lifecycle.rs b/crates/registry-serverctl/src/apply_lifecycle.rs new file mode 100644 index 0000000000..3262a9c6ee --- /dev/null +++ b/crates/registry-serverctl/src/apply_lifecycle.rs @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Authority-preserving Registry package activation. + +use std::path::{Path, PathBuf}; + +use registry_server::migration::{ + apply_verified_package, ApplyPrecondition, ApplyRoles, ApplyTimeouts, + ApplyVerifiedPackageRequest, DestructiveBackupEvidence, MigrationError, +}; +use registry_server::package::{ + load_package, PackageError, PackageIntent, PackageLoadContext, VerifiedPackage, +}; +use registry_server::postgres::ExpectedRegistryIdentity; +use registry_server::runtime_config::{load_runtime_config, RuntimeConfigError}; + +#[derive(Debug)] +pub(crate) enum ApplyLifecycleError { + RuntimeConfigPath, + RuntimeConfig, + TargetPackagePath, + CurrentPackage(PackageError), + TargetPackage(PackageError), + DatabaseConfiguration, + TimeoutConfiguration, + BackupArgument, + Runtime, + Apply(MigrationError), +} + +pub(crate) struct ApplyLifecycleRequest<'a> { + pub runtime_config: &'a Path, + pub package: &'a Path, + pub initial: bool, + pub backups: &'a [String], +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ApplyLifecycleOutcome { + pub package_revision: String, + pub schema_fingerprint: String, + pub package_sequence: i64, + pub initial: bool, +} + +pub(crate) fn run( + request: ApplyLifecycleRequest<'_>, +) -> Result { + if !request.runtime_config.is_absolute() { + return Err(ApplyLifecycleError::RuntimeConfigPath); + } + if !request.package.is_absolute() { + return Err(ApplyLifecycleError::TargetPackagePath); + } + let backup_arguments = parse_backup_arguments(request.backups)?; + let config = load_runtime_config(request.runtime_config) + .map_err(|_error: RuntimeConfigError| ApplyLifecycleError::RuntimeConfig)?; + + let current_package = if request.initial { + None + } else { + Some( + load_package(config.package().root(), &config.package_load_context()) + .map_err(ApplyLifecycleError::CurrentPackage)?, + ) + }; + let current_identity = current_package + .as_ref() + .map(expected_identity) + .transpose()?; + let target_intent = match current_identity.as_ref() { + Some(current) => PackageIntent::Activation { + active_revision: ¤t.package_revision, + active_sequence: u64::try_from(current.package_sequence) + .map_err(|_| ApplyLifecycleError::TargetPackage(PackageError::Binding))?, + }, + None => PackageIntent::InitialActivation, + }; + let target = load_package( + request.package, + &PackageLoadContext { + environment: config.identity().environment(), + instance_id: config.identity().instance_id(), + database_id: config.identity().database_id(), + database_initialization_environment: config + .identity() + .database_initialization_environment(), + compiler_source_revision: config.package().compiler_source_revision(), + trust_anchor: config.package_trust_anchor(), + intent: target_intent, + }, + ) + .map_err(ApplyLifecycleError::TargetPackage)?; + if request.initial + && (target.manifest().package_revision != config.package().active_revision() + || target.manifest().sequence != config.package().active_sequence() + || target.manifest().sequence != 1) + { + return Err(ApplyLifecycleError::TargetPackage(PackageError::Binding)); + } + + let connection = config + .migration_database_connection_config() + .map_err(|_| ApplyLifecycleError::DatabaseConfiguration)?; + let timeouts = ApplyTimeouts::new( + config.operational_timeouts().migration_lock, + config.operational_timeouts().migration_statement, + ) + .map_err(|_| ApplyLifecycleError::TimeoutConfiguration)?; + let backup_evidence = backup_arguments + .iter() + .map(|backup| { + DestructiveBackupEvidence::new(backup.binding_path.as_str(), &backup.local_path) + }) + .collect::>(); + let precondition = current_identity + .as_ref() + .map_or(ApplyPrecondition::InitialActivation, |current| { + ApplyPrecondition::Successor { current } + }); + let apply = ApplyVerifiedPackageRequest::new( + &connection, + &target, + precondition, + ApplyRoles::new( + config.database().roles().migration(), + config.database().roles().runtime(), + ), + timeouts, + ) + .with_destructive_backup_evidence(&backup_evidence); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| ApplyLifecycleError::Runtime)?; + let activated = runtime + .block_on(apply_verified_package(apply)) + .map_err(ApplyLifecycleError::Apply)?; + Ok(ApplyLifecycleOutcome { + package_revision: activated.package_revision, + schema_fingerprint: activated.schema_fingerprint, + package_sequence: activated.package_sequence, + initial: request.initial, + }) +} + +fn expected_identity( + package: &VerifiedPackage, +) -> Result { + let manifest = package.manifest(); + Ok(ExpectedRegistryIdentity { + package_id: manifest.package_id.clone(), + environment: manifest.environment.clone(), + instance_id: manifest.instance_id.clone(), + database_id: manifest.database_id.clone(), + package_revision: manifest.package_revision.clone(), + schema_fingerprint: manifest.schema_fingerprint.clone(), + package_sequence: i64::try_from(manifest.sequence) + .map_err(|_| ApplyLifecycleError::CurrentPackage(PackageError::Binding))?, + }) +} + +struct BackupArgument { + binding_path: String, + local_path: PathBuf, +} + +fn parse_backup_arguments(values: &[String]) -> Result, ApplyLifecycleError> { + values + .iter() + .map(|value| { + let (binding_path, local_path) = value + .split_once('=') + .ok_or(ApplyLifecycleError::BackupArgument)?; + let local_path = PathBuf::from(local_path); + if binding_path.is_empty() + || binding_path.starts_with('/') + || binding_path.contains("..") + || !local_path.is_absolute() + { + return Err(ApplyLifecycleError::BackupArgument); + } + Ok(BackupArgument { + binding_path: binding_path.to_owned(), + local_path, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backup_arguments_are_closed_and_require_an_absolute_local_file() { + let parsed = parse_backup_arguments(&["migrations/backup.json=/tmp/backup.bin".to_owned()]) + .expect("one closed backup binding parses"); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].binding_path, "migrations/backup.json"); + assert_eq!(parsed[0].local_path, Path::new("/tmp/backup.bin")); + + for refused in [ + "migrations/backup.json", + "../backup.json=/tmp/backup.bin", + "/backup.json=/tmp/backup.bin", + "migrations/backup.json=relative.bin", + ] { + assert!(parse_backup_arguments(&[refused.to_owned()]).is_err()); + } + } +} diff --git a/crates/registry-serverctl/src/data_lifecycle.rs b/crates/registry-serverctl/src/data_lifecycle.rs new file mode 100644 index 0000000000..f97378c5dd --- /dev/null +++ b/crates/registry-serverctl/src/data_lifecycle.rs @@ -0,0 +1,1487 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Authenticated HTTP data workflows for Registry Server. +//! +//! This module owns only ctl-side package inspection, file checkpoints, and +//! HTTP dispatch. Data shape, chunking, idempotency, and response validation +//! remain in `registry_server::data`. + +use std::fs; +use std::future::Future; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use registry_platform_httputil::client::{ + build_client, transport_protects_the_credential, OutboundOptions, DEFAULT_CONNECT_TIMEOUT, + DEFAULT_REQUEST_TIMEOUT, +}; +use registry_platform_httputil::{read_bounded, validate_response_headers}; +use registry_server::data::{ + execute_export_page, execute_import_chunk, DataError, DataExportCheckpoint, DataExportPlan, + DataHttpMethod, DataHttpRequest, DataHttpResponse, DataImportCheckpoint, DataImportOperation, + DataImportPlan, MAX_DATA_HTTP_RESPONSE_BYTES, MAX_DATA_IMPORT_INPUT_BYTES, +}; +use registry_server::package::{inspect_package_integrity, PackageEnvelope, PackageError}; +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; +use reqwest::{Client, Method, Url}; +use serde::{Deserialize, Serialize}; + +const MAX_TOKEN_BYTES: u64 = 64 * 1024; +const MAX_CHECKPOINT_BYTES: u64 = 1024 * 1024; +const DATA_HTTP_USER_AGENT: &str = "registry-serverctl-data"; +const DATA_STATE_API_VERSION: &str = "registry.registrystack.org/serverctl-data/v1"; +const IMPORT_STATE_KIND: &str = "RegistryServerctlDataImportState"; +const MAX_ATOMIC_WRITE_TEMP_ATTEMPTS: usize = 16; + +static DATA_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug)] +pub(crate) enum DataLifecycleError { + PackagePath, + Package(PackageError), + PackageManifest, + Input, + Output, + Checkpoint, + ServerUrl, + Token, + Runtime, + Transport, + Data(DataError), +} + +pub(crate) struct DataValidateRequest<'a> { + pub package: &'a Path, + pub entity: &'a str, + pub operation: DataImportOperation, + pub profile: &'a str, + pub input: &'a Path, +} + +pub(crate) struct DataImportRequest<'a> { + pub package: &'a Path, + pub server_url: &'a str, + pub access_token_file: &'a Path, + pub entity: &'a str, + pub operation: DataImportOperation, + pub profile: &'a str, + pub input: &'a Path, + pub checkpoint: &'a Path, + pub max_chunks: Option, +} + +pub(crate) struct DataExportRequest<'a> { + pub package: &'a Path, + pub server_url: &'a str, + pub access_token_file: &'a Path, + pub entity: &'a str, + pub profile: &'a str, + pub fields: &'a [String], + pub output: &'a Path, + pub checkpoint: &'a Path, + pub max_pages: Option, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct DataValidateOutcome { + pub package_revision: String, + pub schema_fingerprint: String, + pub entity_id: String, + pub profile_id: String, + pub operation: DataImportOperation, + pub input_length: u64, + pub item_count: u64, + pub chunk_count: usize, + pub maximum_items: u16, + pub maximum_bytes: u32, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct DataImportOutcome { + pub package_revision: String, + pub schema_fingerprint: String, + pub entity_id: String, + pub profile_id: String, + pub operation: DataImportOperation, + pub input_length: u64, + pub item_count: u64, + pub completed_chunk_count: u64, + pub committed_items: u64, + pub complete: bool, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct DataExportOutcome { + pub package_revision: String, + pub schema_fingerprint: String, + pub entity_id: String, + pub profile_id: String, + pub requested_fields: Vec, + pub completed_page_count: u64, + pub record_count: u64, + pub output_length: u64, + pub complete: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ImportState { + api_version: String, + kind: String, + package_revision: String, + schema_fingerprint: String, + entity_id: String, + operation: DataImportOperation, + profile_id: String, + input_digest: String, + import_id: String, +} + +pub(crate) fn validate_import( + request: DataValidateRequest<'_>, +) -> Result { + let inspected = inspect_data_package(request.package)?; + let input = read_bounded_regular(request.input, MAX_DATA_IMPORT_INPUT_BYTES as u64) + .map_err(|_| DataLifecycleError::Input)?; + let plan = DataImportPlan::from_jsonl( + inspected.registry(), + request.entity, + request.operation, + request.profile, + &input, + ) + .map_err(DataLifecycleError::Data)?; + Ok(DataValidateOutcome { + package_revision: inspected.package_revision, + schema_fingerprint: inspected.schema_fingerprint, + entity_id: plan.entity_id().to_owned(), + profile_id: plan.profile_id().to_owned(), + operation: plan.operation(), + input_length: plan.input_length(), + item_count: plan.item_count(), + chunk_count: plan.chunks().len(), + maximum_items: plan.maximum_items(), + maximum_bytes: plan.maximum_bytes(), + }) +} + +pub(crate) fn run_import( + request: DataImportRequest<'_>, +) -> Result { + let inspected = inspect_data_package(request.package)?; + let input = read_bounded_regular(request.input, MAX_DATA_IMPORT_INPUT_BYTES as u64) + .map_err(|_| DataLifecycleError::Input)?; + let plan = DataImportPlan::from_jsonl( + inspected.registry(), + request.entity, + request.operation, + request.profile, + &input, + ) + .map_err(DataLifecycleError::Data)?; + if request.max_chunks == Some(0) { + return Err(DataLifecycleError::Data(DataError::InvalidBinding)); + } + let server_url = parse_server_url(request.server_url)?; + let token = read_access_token(request.access_token_file)?; + let state_path = import_state_path(request.checkpoint); + let (mut checkpoint, import_id) = + load_or_start_import(&plan, &inspected, request.checkpoint, &state_path)?; + let client = build_data_http_client()?; + let (_committed_chunks, committed_items) = run_import_chunks( + &plan, + &mut checkpoint, + ImportExecutionBinding { + package_revision: &inspected.package_revision, + schema_fingerprint: &inspected.schema_fingerprint, + import_id: &import_id, + }, + request.max_chunks, + |checkpoint| { + write_atomic( + request.checkpoint, + &checkpoint + .canonical_json() + .map_err(DataLifecycleError::Data)?, + ) + }, + |data_request| dispatch_http(&client, &server_url, &token, data_request), + )?; + Ok(DataImportOutcome { + package_revision: inspected.package_revision, + schema_fingerprint: inspected.schema_fingerprint, + entity_id: plan.entity_id().to_owned(), + profile_id: plan.profile_id().to_owned(), + operation: plan.operation(), + input_length: plan.input_length(), + item_count: plan.item_count(), + completed_chunk_count: checkpoint.completed_chunk_count(), + committed_items, + complete: checkpoint.is_complete(), + }) +} + +struct ImportExecutionBinding<'a> { + package_revision: &'a str, + schema_fingerprint: &'a str, + import_id: &'a str, +} + +fn run_import_chunks( + plan: &DataImportPlan, + checkpoint: &mut DataImportCheckpoint, + binding: ImportExecutionBinding<'_>, + max_chunks: Option, + mut after_chunk: AfterChunk, + mut dispatch: Dispatch, +) -> Result<(u64, u64), DataLifecycleError> +where + Dispatch: FnMut(DataHttpRequest) -> DispatchFuture, + DispatchFuture: Future>, + AfterChunk: FnMut(&DataImportCheckpoint) -> Result<(), DataLifecycleError>, +{ + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| DataLifecycleError::Runtime)?; + let mut committed_items = 0u64; + let mut committed_chunks = 0u64; + let max_chunks = max_chunks.unwrap_or(u64::MAX); + while !checkpoint.is_complete() && committed_chunks < max_chunks { + let progress = runtime + .block_on(execute_import_chunk( + plan, + checkpoint, + binding.package_revision, + binding.schema_fingerprint, + binding.import_id, + &mut dispatch, + )) + .map_err(map_data_or_transport)?; + let Some(progress) = progress else { + break; + }; + committed_items = committed_items + .checked_add(progress.committed_items()) + .ok_or(DataLifecycleError::Checkpoint)?; + committed_chunks = committed_chunks + .checked_add(1) + .ok_or(DataLifecycleError::Checkpoint)?; + after_chunk(checkpoint)?; + } + Ok((committed_chunks, committed_items)) +} + +pub(crate) fn run_export( + request: DataExportRequest<'_>, +) -> Result { + let inspected = inspect_data_package(request.package)?; + if request.max_pages == Some(0) { + return Err(DataLifecycleError::Data(DataError::InvalidBinding)); + } + let plan = DataExportPlan::from_compiled( + inspected.registry(), + request.entity, + request.profile, + request.fields.iter().cloned(), + ) + .map_err(DataLifecycleError::Data)?; + ensure_new_file(request.output)?; + ensure_new_file(request.checkpoint)?; + let server_url = parse_server_url(request.server_url)?; + let token = read_access_token(request.access_token_file)?; + let (mut checkpoint, mut resume_state) = DataExportCheckpoint::start( + &plan, + &inspected.package_revision, + &inspected.schema_fingerprint, + ) + .map_err(DataLifecycleError::Data)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| DataLifecycleError::Runtime)?; + let client = build_data_http_client()?; + let mut output_prefix = Vec::new(); + let mut pages = 0u64; + let max_pages = request.max_pages.unwrap_or(u64::MAX); + while !checkpoint.is_complete() && pages < max_pages { + let progress = runtime + .block_on(execute_export_page( + &plan, + &mut checkpoint, + &inspected.package_revision, + &inspected.schema_fingerprint, + &output_prefix, + &resume_state, + |data_request| dispatch_http(&client, &server_url, &token, data_request), + )) + .map_err(map_data_or_transport)?; + let Some(progress) = progress else { + break; + }; + let parts = progress.into_parts(); + output_prefix = parts.0; + resume_state = parts.1; + pages = pages.checked_add(1).ok_or(DataLifecycleError::Checkpoint)?; + write_atomic(request.output, &output_prefix)?; + write_atomic( + request.checkpoint, + &checkpoint + .canonical_json() + .map_err(DataLifecycleError::Data)?, + )?; + } + Ok(DataExportOutcome { + package_revision: inspected.package_revision, + schema_fingerprint: inspected.schema_fingerprint, + entity_id: plan.entity_id().to_owned(), + profile_id: plan.profile_id().to_owned(), + requested_fields: plan.requested_fields().to_vec(), + completed_page_count: pages, + record_count: checkpoint.record_count(), + output_length: checkpoint.output_length(), + complete: checkpoint.is_complete(), + }) +} + +async fn dispatch_http( + client: &Client, + base: &Url, + token: &str, + request: DataHttpRequest, +) -> Result { + let url = base.join(request.path_and_query()).map_err(|_| ())?; + if url.origin() != base.origin() { + return Err(()); + } + let method = match request.method() { + DataHttpMethod::Get => Method::GET, + DataHttpMethod::Post => Method::POST, + }; + let mut builder = client + .request(method, url) + .header(AUTHORIZATION, format!("Bearer {token}")); + if let Some(content_type) = request.content_type() { + builder = builder.header(CONTENT_TYPE, content_type); + } + if let Some(idempotency_key) = request.idempotency_key() { + builder = builder.header("idempotency-key", idempotency_key); + } + let response = builder + .body(request.body().to_vec()) + .send() + .await + .map_err(|_| ())?; + let status = response.status().as_u16(); + validate_response_headers(response.headers()).map_err(|_| ())?; + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let body = read_data_http_body(response).await.map_err(|_| ())?; + DataHttpResponse::new(status, content_type, body).map_err(|_| ()) +} + +fn build_data_http_client() -> Result { + build_data_http_client_with_timeouts(DEFAULT_REQUEST_TIMEOUT, DEFAULT_CONNECT_TIMEOUT) +} + +fn build_data_http_client_with_timeouts( + request_timeout: Duration, + connect_timeout: Duration, +) -> Result { + build_client(OutboundOptions { + request_timeout, + connect_timeout, + user_agent: Some(DATA_HTTP_USER_AGENT), + trusted_root_certificates: None, + }) + .map_err(|_| DataLifecycleError::Transport) +} + +async fn read_data_http_body( + response: reqwest::Response, +) -> Result, registry_platform_httputil::BoundedReadError> { + read_bounded(response, MAX_DATA_HTTP_RESPONSE_BYTES as u64).await +} + +fn map_data_or_transport(error: DataError) -> DataLifecycleError { + match error { + DataError::TransportUnavailable => DataLifecycleError::Transport, + other => DataLifecycleError::Data(other), + } +} + +struct InspectedDataPackage { + package_revision: String, + schema_fingerprint: String, + registry: registry_server::CompiledRegistry, +} + +impl InspectedDataPackage { + fn registry(&self) -> ®istry_server::CompiledRegistry { + &self.registry + } +} + +fn inspect_data_package(package: &Path) -> Result { + if !package.is_absolute() { + return Err(DataLifecycleError::PackagePath); + } + let inspected = inspect_package_integrity(package).map_err(DataLifecycleError::Package)?; + let manifest_bytes = read_bounded_regular(&package.join("package.json"), MAX_CHECKPOINT_BYTES) + .map_err(|_| DataLifecycleError::PackageManifest)?; + let envelope: PackageEnvelope = serde_json::from_value( + parse_json_strict(&manifest_bytes).map_err(|_| DataLifecycleError::PackageManifest)?, + ) + .map_err(|_| DataLifecycleError::PackageManifest)?; + if envelope.signed.package_revision != inspected.package_revision() { + return Err(DataLifecycleError::PackageManifest); + } + Ok(InspectedDataPackage { + package_revision: envelope.signed.package_revision, + schema_fingerprint: envelope.signed.schema_fingerprint, + registry: inspected.registry().clone(), + }) +} + +fn load_or_start_import( + plan: &DataImportPlan, + inspected: &InspectedDataPackage, + checkpoint_path: &Path, + state_path: &Path, +) -> Result<(DataImportCheckpoint, String), DataLifecycleError> { + let checkpoint_exists = checkpoint_path + .try_exists() + .map_err(|_| DataLifecycleError::Checkpoint)?; + let state_exists = state_path + .try_exists() + .map_err(|_| DataLifecycleError::Checkpoint)?; + match (checkpoint_exists, state_exists) { + (false, false) => start_new_import(plan, inspected, checkpoint_path, state_path), + (false, true) => recover_state_only_import(plan, inspected, checkpoint_path, state_path), + (true, true) => load_existing_import(plan, inspected, checkpoint_path, state_path), + (true, false) => Err(DataLifecycleError::Checkpoint), + } +} + +fn start_new_import( + plan: &DataImportPlan, + inspected: &InspectedDataPackage, + checkpoint_path: &Path, + state_path: &Path, +) -> Result<(DataImportCheckpoint, String), DataLifecycleError> { + let checkpoint = DataImportCheckpoint::start( + plan, + &inspected.package_revision, + &inspected.schema_fingerprint, + ) + .map_err(DataLifecycleError::Data)?; + let state = import_state_for_checkpoint(plan, inspected, &checkpoint); + let state_bytes = canonical_import_state(&state)?; + let checkpoint_bytes = checkpoint + .canonical_json() + .map_err(DataLifecycleError::Data)?; + write_atomic_create_new(state_path, &state_bytes) + .map_err(|_| DataLifecycleError::Checkpoint)?; + if write_atomic_create_new(checkpoint_path, &checkpoint_bytes).is_err() { + return load_existing_import(plan, inspected, checkpoint_path, state_path) + .map_err(|_| DataLifecycleError::Checkpoint); + } + Ok((checkpoint, state.import_id)) +} + +fn recover_state_only_import( + plan: &DataImportPlan, + inspected: &InspectedDataPackage, + checkpoint_path: &Path, + state_path: &Path, +) -> Result<(DataImportCheckpoint, String), DataLifecycleError> { + let state = read_import_state(state_path, plan, inspected)?; + let checkpoint = start_checkpoint_from_state(plan, inspected, &state)?; + let checkpoint_bytes = checkpoint + .canonical_json() + .map_err(DataLifecycleError::Data)?; + match write_atomic_create_new(checkpoint_path, &checkpoint_bytes) { + Ok(_) => Ok((checkpoint, state.import_id)), + Err(_) => load_existing_import(plan, inspected, checkpoint_path, state_path) + .map_err(|_| DataLifecycleError::Checkpoint), + } +} + +fn load_existing_import( + plan: &DataImportPlan, + inspected: &InspectedDataPackage, + checkpoint_path: &Path, + state_path: &Path, +) -> Result<(DataImportCheckpoint, String), DataLifecycleError> { + let state = read_import_state(state_path, plan, inspected)?; + let checkpoint_bytes = read_bounded_regular(checkpoint_path, MAX_CHECKPOINT_BYTES) + .map_err(|_| DataLifecycleError::Checkpoint)?; + let checkpoint = DataImportCheckpoint::from_json( + &checkpoint_bytes, + plan, + &inspected.package_revision, + &inspected.schema_fingerprint, + &state.import_id, + ) + .map_err(DataLifecycleError::Data)?; + Ok((checkpoint, state.import_id)) +} + +fn import_state_for_checkpoint( + plan: &DataImportPlan, + inspected: &InspectedDataPackage, + checkpoint: &DataImportCheckpoint, +) -> ImportState { + ImportState { + api_version: DATA_STATE_API_VERSION.to_owned(), + kind: IMPORT_STATE_KIND.to_owned(), + package_revision: inspected.package_revision.clone(), + schema_fingerprint: inspected.schema_fingerprint.clone(), + entity_id: plan.entity_id().to_owned(), + operation: plan.operation(), + profile_id: plan.profile_id().to_owned(), + input_digest: plan.input_digest().to_owned(), + import_id: checkpoint.import_id().to_owned(), + } +} + +fn canonical_import_state(state: &ImportState) -> Result, DataLifecycleError> { + canonicalize_json(&serde_json::to_value(state).map_err(|_| DataLifecycleError::Checkpoint)?) + .map_err(|_| DataLifecycleError::Checkpoint) +} + +fn start_checkpoint_from_state( + plan: &DataImportPlan, + inspected: &InspectedDataPackage, + state: &ImportState, +) -> Result { + let checkpoint = DataImportCheckpoint::start( + plan, + &inspected.package_revision, + &inspected.schema_fingerprint, + ) + .map_err(DataLifecycleError::Data)?; + let mut value = + serde_json::to_value(&checkpoint).map_err(|_| DataLifecycleError::Checkpoint)?; + value["importId"] = serde_json::Value::String(state.import_id.clone()); + let bytes = canonicalize_json(&value).map_err(|_| DataLifecycleError::Checkpoint)?; + DataImportCheckpoint::from_json( + &bytes, + plan, + &inspected.package_revision, + &inspected.schema_fingerprint, + &state.import_id, + ) + .map_err(DataLifecycleError::Data) +} + +fn read_import_state( + path: &Path, + plan: &DataImportPlan, + inspected: &InspectedDataPackage, +) -> Result { + let bytes = read_bounded_regular(path, MAX_CHECKPOINT_BYTES) + .map_err(|_| DataLifecycleError::Checkpoint)?; + let state: ImportState = serde_json::from_value( + parse_json_strict(&bytes).map_err(|_| DataLifecycleError::Checkpoint)?, + ) + .map_err(|_| DataLifecycleError::Checkpoint)?; + if state.api_version != DATA_STATE_API_VERSION + || state.kind != IMPORT_STATE_KIND + || state.package_revision != inspected.package_revision + || state.schema_fingerprint != inspected.schema_fingerprint + || state.entity_id != plan.entity_id() + || state.operation != plan.operation() + || state.profile_id != plan.profile_id() + || state.input_digest != plan.input_digest() + { + return Err(DataLifecycleError::Checkpoint); + } + Ok(state) +} + +fn import_state_path(checkpoint_path: &Path) -> PathBuf { + let mut state = checkpoint_path.as_os_str().to_owned(); + state.push(".state"); + PathBuf::from(state) +} + +fn read_access_token(path: &Path) -> Result { + if !path.is_absolute() { + return Err(DataLifecycleError::Token); + } + let bytes = + read_bounded_regular(path, MAX_TOKEN_BYTES).map_err(|_| DataLifecycleError::Token)?; + let token = std::str::from_utf8(&bytes).map_err(|_| DataLifecycleError::Token)?; + let token = token.trim_end_matches(['\r', '\n']); + if token.is_empty() + || token.len() > MAX_TOKEN_BYTES as usize + || token.bytes().any(|byte| !(0x21..=0x7e).contains(&byte)) + { + return Err(DataLifecycleError::Token); + } + Ok(token.to_owned()) +} + +fn parse_server_url(value: &str) -> Result { + let mut url = Url::parse(value).map_err(|_| DataLifecycleError::ServerUrl)?; + if url.username() != "" + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.host_str().is_none() + { + return Err(DataLifecycleError::ServerUrl); + } + if !transport_protects_the_credential(&url) { + return Err(DataLifecycleError::ServerUrl); + } + url.set_path("/"); + Ok(url) +} + +fn read_bounded_regular(path: &Path, max_bytes: u64) -> Result, io::Error> { + super::ensure_no_symlink_components(path, "data.input.invalid", "data") + .map_err(|_| io::Error::other("unsafe path"))?; + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() == 0 + || metadata.len() > max_bytes + { + return Err(io::Error::other("invalid file")); + } + fs::read(path) +} + +fn ensure_new_file(path: &Path) -> Result<(), DataLifecycleError> { + if path.as_os_str().is_empty() || super::has_parent_component(path) { + return Err(DataLifecycleError::Output); + } + if let Some(parent) = path.parent() { + super::ensure_no_symlink_components(parent, "data.output.invalid", "output") + .map_err(|_| DataLifecycleError::Output)?; + } + match fs::symlink_metadata(path) { + Ok(_) => Err(DataLifecycleError::Output), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(DataLifecycleError::Output), + } +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), DataLifecycleError> { + let parent = prepare_atomic_write_path(path, true)?; + let temporary = write_atomic_temporary(parent, bytes)?; + fs::rename(&temporary, path).map_err(|_| { + let _ = fs::remove_file(&temporary); + DataLifecycleError::Output + }) +} + +fn write_atomic_create_new(path: &Path, bytes: &[u8]) -> Result<(), DataLifecycleError> { + let parent = prepare_atomic_write_path(path, false)?; + let temporary = write_atomic_temporary(parent, bytes)?; + match fs::hard_link(&temporary, path) { + Ok(()) => { + let _ = fs::remove_file(&temporary); + Ok(()) + } + Err(_) => { + let _ = fs::remove_file(&temporary); + Err(DataLifecycleError::Output) + } + } +} + +fn prepare_atomic_write_path( + path: &Path, + allow_existing_regular_file: bool, +) -> Result<&Path, DataLifecycleError> { + if path.as_os_str().is_empty() || super::has_parent_component(path) { + return Err(DataLifecycleError::Output); + } + let parent = path.parent().ok_or(DataLifecycleError::Output)?; + super::ensure_no_symlink_components(parent, "data.output.invalid", "output") + .map_err(|_| DataLifecycleError::Output)?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(DataLifecycleError::Output); + } + Ok(_) if !allow_existing_regular_file => return Err(DataLifecycleError::Output), + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => return Err(DataLifecycleError::Output), + } + Ok(parent) +} + +fn write_atomic_temporary(parent: &Path, bytes: &[u8]) -> Result { + for _ in 0..MAX_ATOMIC_WRITE_TEMP_ATTEMPTS { + let temporary = + atomic_write_temporary_path(parent, DATA_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed)); + let mut file = match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(_) => return Err(DataLifecycleError::Output), + }; + if file + .write_all(bytes) + .and_then(|()| file.sync_all()) + .is_err() + { + drop(file); + let _ = fs::remove_file(&temporary); + return Err(DataLifecycleError::Output); + } + drop(file); + return Ok(temporary); + } + Err(DataLifecycleError::Output) +} + +fn atomic_write_temporary_path(parent: &Path, sequence: u64) -> PathBuf { + parent.join(format!( + ".registry-serverctl-data-{}-{sequence}.tmp", + std::process::id() + )) +} + +#[cfg(test)] +mod tests { + use std::io::{Read, Write}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::process::Command; + use std::sync::{ + atomic::{AtomicUsize, Ordering as AtomicOrdering}, + Arc, Mutex, + }; + use std::thread; + use std::time::{Duration as StdDuration, Instant}; + + use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; + use registry_server::compiler::{compile_project, CompileProfile}; + use registry_server::contract::parse_project_json; + use serde_json::{json, Value}; + + use super::*; + + const ENTITY: &str = "record"; + const PROFILE: &str = "operator"; + const PACKAGE: &str = "package-revision"; + const SCHEMA: &str = "schema-fingerprint"; + + fn test_runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + fn read_http_request(stream: &mut TcpStream) -> Vec { + stream + .set_read_timeout(Some(StdDuration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + let mut header_end = None; + let mut buffer = [0_u8; 1024]; + while header_end.is_none() { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + return request; + } + request.extend_from_slice(&buffer[..read]); + header_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4); + } + let header_end = header_end.unwrap(); + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + while request.len() < header_end.saturating_add(content_length) { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + request + } + + fn spawn_one_response_server(response: Vec) -> (SocketAddr, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + stream.write_all(&response).unwrap(); + stream.flush().unwrap(); + request + }); + (address, handle) + } + + fn test_directory(label: &str) -> PathBuf { + let directory = std::env::current_dir().unwrap().join(format!( + ".registry-serverctl-data-test-{}-{}-{}", + std::process::id(), + label, + DATA_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&directory); + fs::create_dir(&directory).unwrap(); + directory + } + + fn compiled() -> registry_server::CompiledRegistry { + let source = json!({ + "apiVersion": "registry.registrystack.org/v1alpha1", + "kind": "RegistryProject", + "registry": {"id": "ctl-data", "version": "1", "defaultLanguage": "en"}, + "entities": [{ + "id": ENTITY, + "route": "records", + "mutationMode": "create_only", + "batch": {"maximumItems": 2, "maximumBytes": 400}, + "fields": [ + {"id": "code", "type": "string", "minLength": 2, "maxLength": 16, + "required": true, "classification": "internal"} + ], + "accessProfiles": [{ + "id": PROFILE, + "principalClaim": "principal", + "operations": ["create", "batch", "list"], + "readableFields": ["code"], + "writableFields": ["code"], + "allowDataExport": true + }] + }] + }); + let project = parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(); + compile_project(&project, &[], CompileProfile::Authoring).unwrap() + } + + fn import_plan_and_inspected() -> (DataImportPlan, InspectedDataPackage) { + let registry = compiled(); + let input = br#"{"operation":"create","data":{"code":"AA"}} +"#; + let plan = DataImportPlan::from_jsonl( + ®istry, + ENTITY, + DataImportOperation::Create, + PROFILE, + input, + ) + .unwrap(); + let inspected = InspectedDataPackage { + package_revision: PACKAGE.to_owned(), + schema_fingerprint: SCHEMA.to_owned(), + registry, + }; + (plan, inspected) + } + + #[test] + fn authenticated_import_transport_uses_the_compiled_batch_request_shape() { + let input = br#"{"operation":"create","data":{"code":"AA"}} +"#; + let plan = DataImportPlan::from_jsonl( + &compiled(), + ENTITY, + DataImportOperation::Create, + PROFILE, + input, + ) + .unwrap(); + let mut checkpoint = DataImportCheckpoint::start(&plan, PACKAGE, SCHEMA).unwrap(); + let import_id = checkpoint.import_id().to_owned(); + let captured = Arc::new(Mutex::new(Vec::new())); + let captured_request = Arc::clone(&captured); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let progress = runtime + .block_on(execute_import_chunk( + &plan, + &mut checkpoint, + PACKAGE, + SCHEMA, + &import_id, + move |request| { + let captured_request = Arc::clone(&captured_request); + async move { + captured_request.lock().unwrap().push(( + request.method(), + request.path_and_query().to_owned(), + request.content_type(), + request.idempotency_key().map(str::to_owned), + request.body().to_vec(), + )); + let body = canonicalize_json(&json!({ + "results": [{ + "operation": "create", + "id": "018f06d6-0248-4c7f-8a7e-df9dfbd83d2c", + "revision": 1, + "etag": "\"rs-revision\"", + "data": {"code": "AA"} + }] + })) + .unwrap(); + DataHttpResponse::new(200, Some("application/json".to_owned()), body) + } + }, + )) + .unwrap() + .expect("one chunk commits"); + + assert!(progress.is_complete()); + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), 1); + let (method, path, content_type, idempotency_key, body) = &captured[0]; + assert_eq!(*method, DataHttpMethod::Post); + assert_eq!(path, "/v1/records/records:batch?accessProfile=operator"); + assert_eq!(*content_type, Some("application/json")); + assert!(idempotency_key + .as_deref() + .is_some_and(|key| key.starts_with("rs-data-v1-"))); + let body = parse_json_strict(body).unwrap(); + assert_eq!(body["items"].as_array().unwrap().len(), 1); + assert_eq!(body["items"][0]["data"]["code"], "AA"); + } + + #[cfg(unix)] + #[test] + fn write_atomic_skips_temp_symlink_collision_and_refuses_final_symlink() { + let directory = test_directory("atomic-symlink"); + let canary = directory.join("canary.txt"); + let destination = directory.join("checkpoint.json"); + let final_symlink = directory.join("final-symlink.json"); + fs::write(&canary, b"unchanged").unwrap(); + + let collided_sequence = DATA_WRITE_COUNTER.load(Ordering::Relaxed); + let collided_temporary = atomic_write_temporary_path(&directory, collided_sequence); + std::os::unix::fs::symlink(&canary, &collided_temporary).unwrap(); + + write_atomic(&destination, b"checkpoint").unwrap(); + + assert_eq!(fs::read(&destination).unwrap(), b"checkpoint"); + assert_eq!(fs::read(&canary).unwrap(), b"unchanged"); + assert!(fs::symlink_metadata(&collided_temporary) + .unwrap() + .file_type() + .is_symlink()); + + std::os::unix::fs::symlink(&canary, &final_symlink).unwrap(); + assert!(matches!( + write_atomic(&final_symlink, b"must-not-follow"), + Err(DataLifecycleError::Output) + )); + assert_eq!(fs::read(&canary).unwrap(), b"unchanged"); + + fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(unix)] + #[test] + fn first_import_creation_does_not_clobber_staged_state_or_checkpoint_collisions() { + let (plan, inspected) = import_plan_and_inspected(); + let directory = test_directory("initial-collisions"); + let checkpoint_path = directory.join("import.checkpoint.json"); + let state_path = import_state_path(&checkpoint_path); + + fs::write(&state_path, b"existing-state").unwrap(); + assert!(matches!( + start_new_import(&plan, &inspected, &checkpoint_path, &state_path), + Err(DataLifecycleError::Checkpoint) + )); + assert_eq!(fs::read(&state_path).unwrap(), b"existing-state"); + assert!(!checkpoint_path.try_exists().unwrap()); + fs::remove_file(&state_path).unwrap(); + + fs::write(&checkpoint_path, b"existing-checkpoint").unwrap(); + assert!(matches!( + start_new_import(&plan, &inspected, &checkpoint_path, &state_path), + Err(DataLifecycleError::Checkpoint) + )); + assert_eq!(fs::read(&checkpoint_path).unwrap(), b"existing-checkpoint"); + read_import_state(&state_path, &plan, &inspected).unwrap(); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn first_import_loser_keeps_state_after_concurrent_state_only_repair() { + let (plan, inspected) = import_plan_and_inspected(); + let directory = test_directory("state-repair-race"); + let checkpoint_path = directory.join("import.checkpoint.json"); + let state_path = import_state_path(&checkpoint_path); + let checkpoint = DataImportCheckpoint::start( + &plan, + &inspected.package_revision, + &inspected.schema_fingerprint, + ) + .unwrap(); + let state = import_state_for_checkpoint(&plan, &inspected, &checkpoint); + let state_bytes = canonical_import_state(&state).unwrap(); + let checkpoint_bytes = checkpoint.canonical_json().unwrap(); + + write_atomic_create_new(&state_path, &state_bytes).unwrap(); + + let (repaired, repaired_import_id) = + recover_state_only_import(&plan, &inspected, &checkpoint_path, &state_path).unwrap(); + + assert_eq!(repaired_import_id, checkpoint.import_id()); + assert_eq!(repaired.import_id(), checkpoint.import_id()); + + assert!(matches!( + write_atomic_create_new(&checkpoint_path, &checkpoint_bytes), + Err(DataLifecycleError::Output) + )); + assert_eq!(fs::read(&state_path).unwrap(), state_bytes); + + let (loaded, loaded_import_id) = + load_existing_import(&plan, &inspected, &checkpoint_path, &state_path).unwrap(); + assert_eq!(loaded_import_id, checkpoint.import_id()); + assert_eq!(loaded.import_id(), checkpoint.import_id()); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn state_only_half_start_is_repaired_but_checkpoint_only_is_not_authority() { + let (plan, inspected) = import_plan_and_inspected(); + let directory = test_directory("partial-start"); + let checkpoint_path = directory.join("import.checkpoint.json"); + let state_path = import_state_path(&checkpoint_path); + let checkpoint = DataImportCheckpoint::start( + &plan, + &inspected.package_revision, + &inspected.schema_fingerprint, + ) + .unwrap(); + let state = import_state_for_checkpoint(&plan, &inspected, &checkpoint); + let state_bytes = canonical_import_state(&state).unwrap(); + fs::write(&state_path, &state_bytes).unwrap(); + + let (repaired, import_id) = + load_or_start_import(&plan, &inspected, &checkpoint_path, &state_path).unwrap(); + + assert_eq!(import_id, checkpoint.import_id()); + assert_eq!(repaired.import_id(), checkpoint.import_id()); + assert_eq!(fs::read(&state_path).unwrap(), state_bytes); + let repaired_bytes = fs::read(&checkpoint_path).unwrap(); + let reloaded = DataImportCheckpoint::from_json( + &repaired_bytes, + &plan, + &inspected.package_revision, + &inspected.schema_fingerprint, + checkpoint.import_id(), + ) + .unwrap(); + assert_eq!(reloaded.import_id(), checkpoint.import_id()); + fs::remove_dir_all(&directory).unwrap(); + + let directory = test_directory("checkpoint-only"); + let checkpoint_path = directory.join("import.checkpoint.json"); + let state_path = import_state_path(&checkpoint_path); + let checkpoint = DataImportCheckpoint::start( + &plan, + &inspected.package_revision, + &inspected.schema_fingerprint, + ) + .unwrap(); + fs::write(&checkpoint_path, checkpoint.canonical_json().unwrap()).unwrap(); + assert!(matches!( + load_or_start_import(&plan, &inspected, &checkpoint_path, &state_path), + Err(DataLifecycleError::Checkpoint) + )); + assert!(!state_path.try_exists().unwrap()); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn data_http_client_does_not_follow_redirects() { + let attempts = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let attempts_for_server = Arc::clone(&attempts); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + attempts_for_server.fetch_add(1, AtomicOrdering::SeqCst); + let request = read_http_request(&mut stream); + assert!(String::from_utf8_lossy(&request) + .starts_with("POST /v1/records/records:batch?accessProfile=operator ")); + stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: /redirect-target\r\nContent-Length: 0\r\n\r\n", + ) + .unwrap(); + stream.flush().unwrap(); + + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + StdDuration::from_millis(300); + while Instant::now() < deadline { + match listener.accept() { + Ok((mut redirected, _)) => { + attempts_for_server.fetch_add(1, AtomicOrdering::SeqCst); + let _ = read_http_request(&mut redirected); + let _ = redirected + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\nfollowed"); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(StdDuration::from_millis(10)); + } + Err(error) => panic!("redirect listener failed: {error}"), + } + } + }); + + let input = br#"{"operation":"create","data":{"code":"AA"}} +"#; + let plan = DataImportPlan::from_jsonl( + &compiled(), + ENTITY, + DataImportOperation::Create, + PROFILE, + input, + ) + .unwrap(); + let mut checkpoint = DataImportCheckpoint::start(&plan, PACKAGE, SCHEMA).unwrap(); + let import_id = checkpoint.import_id().to_owned(); + let runtime = test_runtime(); + let client = build_data_http_client_with_timeouts( + StdDuration::from_secs(2), + StdDuration::from_secs(1), + ) + .unwrap(); + let base = parse_server_url(&format!("http://{address}")).unwrap(); + + let error = runtime + .block_on(execute_import_chunk( + &plan, + &mut checkpoint, + PACKAGE, + SCHEMA, + &import_id, + |request| dispatch_http(&client, &base, "TEST-TOKEN", request), + )) + .unwrap_err(); + + assert_eq!(error, DataError::OperationRefused); + handle.join().unwrap(); + assert_eq!(attempts.load(AtomicOrdering::SeqCst), 1); + } + + #[test] + fn data_http_client_ignores_ambient_proxy_variables_in_an_isolated_process() { + if std::env::var_os("REGISTRY_SERVERCTL_DATA_PROXY_CHILD").is_some() { + return; + } + let status = Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg("data_lifecycle::tests::data_http_client_ignores_ambient_proxy_variables_child") + .arg("--nocapture") + .env("REGISTRY_SERVERCTL_DATA_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", "") + .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() + .unwrap(); + assert!(status.success()); + } + + #[test] + fn data_http_client_ignores_ambient_proxy_variables_child() { + if std::env::var_os("REGISTRY_SERVERCTL_DATA_PROXY_CHILD").is_none() { + return; + } + let (address, handle) = spawn_one_response_server( + b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\ndirect".to_vec(), + ); + let client = build_data_http_client_with_timeouts( + StdDuration::from_secs(2), + StdDuration::from_secs(1), + ) + .unwrap(); + let runtime = test_runtime(); + let response = runtime + .block_on(async { client.get(format!("http://{address}/direct")).send().await }) + .expect("the hardened data client connects directly"); + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + runtime.block_on(read_data_http_body(response)).unwrap(), + b"direct" + ); + let request = handle.join().unwrap(); + assert!(String::from_utf8_lossy(&request).starts_with("GET /direct ")); + } + + #[test] + fn data_http_client_applies_the_request_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut stream); + thread::sleep(StdDuration::from_millis(300)); + let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); + }); + let client = build_data_http_client_with_timeouts( + StdDuration::from_millis(50), + StdDuration::from_millis(50), + ) + .unwrap(); + let runtime = test_runtime(); + let started = Instant::now(); + let error = runtime + .block_on(async { client.get(format!("http://{address}/slow")).send().await }) + .expect_err("the configured request timeout elapses"); + assert!(error.is_timeout()); + assert!(started.elapsed() < StdDuration::from_secs(1)); + handle.join().unwrap(); + } + + #[test] + fn data_http_client_does_not_retry_failed_exchanges() { + let attempts = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let attempts_for_server = Arc::clone(&attempts); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + attempts_for_server.fetch_add(1, AtomicOrdering::SeqCst); + let _ = read_http_request(&mut stream); + drop(stream); + + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + StdDuration::from_millis(300); + while Instant::now() < deadline { + match listener.accept() { + Ok((mut retry, _)) => { + attempts_for_server.fetch_add(1, AtomicOrdering::SeqCst); + let _ = read_http_request(&mut retry); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(StdDuration::from_millis(10)); + } + Err(error) => panic!("retry listener failed: {error}"), + } + } + }); + let client = build_data_http_client_with_timeouts( + StdDuration::from_secs(2), + StdDuration::from_secs(1), + ) + .unwrap(); + let runtime = test_runtime(); + let error = runtime + .block_on(async { client.get(format!("http://{address}/drop")).send().await }) + .expect_err("the failed exchange is not retried"); + assert!(!error.is_timeout()); + handle.join().unwrap(); + assert_eq!(attempts.load(AtomicOrdering::SeqCst), 1); + } + + #[test] + fn data_http_body_reader_rejects_oversized_content_length_before_body_read() { + let response_bound = MAX_DATA_HTTP_RESPONSE_BYTES as u64; + let advertised = response_bound + 1; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {advertised}\r\n\r\n" + ); + let (address, handle) = spawn_one_response_server(response.into_bytes()); + let client = build_data_http_client_with_timeouts( + StdDuration::from_secs(2), + StdDuration::from_secs(1), + ) + .unwrap(); + let runtime = test_runtime(); + let response = runtime + .block_on(async { + client + .get(format!("http://{address}/oversized")) + .send() + .await + }) + .unwrap(); + let error = runtime + .block_on(read_data_http_body(response)) + .expect_err("oversized content-length is rejected"); + + assert!(matches!( + error, + registry_platform_httputil::BoundedReadError::ContentLengthExceeded { + content_length, + max_bytes + } if content_length == advertised && max_bytes == response_bound + )); + let request = handle.join().unwrap(); + assert!(String::from_utf8_lossy(&request).starts_with("GET /oversized ")); + } + + #[test] + fn max_chunks_counts_committed_chunks_not_committed_items() { + let input = br#"{"operation":"create","data":{"code":"AA"}} +{"operation":"create","data":{"code":"BB"}} +{"operation":"create","data":{"code":"CC"}} +"#; + let plan = DataImportPlan::from_jsonl( + &compiled(), + ENTITY, + DataImportOperation::Create, + PROFILE, + input, + ) + .unwrap(); + assert_eq!(plan.chunks().len(), 2); + let mut checkpoint = DataImportCheckpoint::start(&plan, PACKAGE, SCHEMA).unwrap(); + let import_id = checkpoint.import_id().to_owned(); + let captured = Arc::new(Mutex::new(Vec::new())); + let captured_request = Arc::clone(&captured); + + let (committed_chunks, committed_items) = run_import_chunks( + &plan, + &mut checkpoint, + ImportExecutionBinding { + package_revision: PACKAGE, + schema_fingerprint: SCHEMA, + import_id: &import_id, + }, + Some(2), + |_| Ok(()), + move |request| { + let captured_request = Arc::clone(&captured_request); + async move { + let body = parse_json_strict(request.body()).unwrap(); + let submitted = body["items"].as_array().unwrap(); + captured_request.lock().unwrap().push(( + request.path_and_query().to_owned(), + request.idempotency_key().map(str::to_owned), + submitted.len(), + )); + let results = submitted + .iter() + .map(|item| { + json!({ + "operation": item["operation"], + "id": "018f06d6-0248-4c7f-8a7e-df9dfbd83d2c", + "revision": 1, + "etag": "\"rs-revision\"", + "data": item["data"] + }) + }) + .collect::>(); + let body = canonicalize_json(&json!({"results": results})).unwrap(); + DataHttpResponse::new(200, Some("application/json".to_owned()), body) + } + }, + ) + .unwrap(); + + assert_eq!(committed_chunks, 2); + assert_eq!(committed_items, 3); + assert!(checkpoint.is_complete()); + let captured = captured.lock().unwrap(); + assert_eq!(captured.len(), 2); + assert_eq!(captured[0].2, 2); + assert_eq!(captured[1].2, 1); + assert_ne!(captured[0].1, captured[1].1); + assert!(captured + .iter() + .all(|(path, _, _)| path == "/v1/records/records:batch?accessProfile=operator")); + } + + #[test] + fn server_urls_are_closed_and_remote_http_is_refused() { + assert!(parse_server_url("https://registry.example.test").is_ok()); + assert!(parse_server_url("http://127.0.0.1:8080").is_ok()); + assert!(parse_server_url("http://localhost:8080").is_ok()); + + for refused in [ + "http://registry.example.test", + "https://user:pass@registry.example.test", + "https://registry.example.test?x=1", + "https://registry.example.test/#fragment", + "file:///tmp/registry", + ] { + assert!(parse_server_url(refused).is_err(), "{refused}"); + } + } + + #[test] + fn import_state_rejects_unknown_or_changed_binding_without_rendering_values() { + let input = br#"{"operation":"create","data":{"code":"AA"}} +"#; + let plan = DataImportPlan::from_jsonl( + &compiled(), + ENTITY, + DataImportOperation::Create, + PROFILE, + input, + ) + .unwrap(); + let inspected = InspectedDataPackage { + package_revision: PACKAGE.to_owned(), + schema_fingerprint: SCHEMA.to_owned(), + registry: compiled(), + }; + let directory = std::env::current_dir().unwrap().join(format!( + ".registry-serverctl-data-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&directory); + fs::create_dir(&directory).unwrap(); + let state_path = directory.join("import.state"); + let state = ImportState { + api_version: DATA_STATE_API_VERSION.to_owned(), + kind: IMPORT_STATE_KIND.to_owned(), + package_revision: PACKAGE.to_owned(), + schema_fingerprint: SCHEMA.to_owned(), + entity_id: ENTITY.to_owned(), + operation: DataImportOperation::Create, + profile_id: PROFILE.to_owned(), + input_digest: plan.input_digest().to_owned(), + import_id: "018f06d6-0248-4c7f-8a7e-df9dfbd83d2c".to_owned(), + }; + let mut value = serde_json::to_value(&state).unwrap(); + value["unknownCredential"] = json!("SECRET-CANARY"); + fs::write(&state_path, canonicalize_json(&value).unwrap()).unwrap(); + + let error = read_import_state(&state_path, &plan, &inspected).unwrap_err(); + let rendered = format!("{error:?}"); + assert!(!rendered.contains("SECRET-CANARY")); + assert!(!rendered.contains(PACKAGE)); + assert!(!rendered.contains(PROFILE)); + + let mut changed: Value = serde_json::to_value(&state).unwrap(); + changed["profileId"] = json!("other-profile-canary"); + fs::write(&state_path, canonicalize_json(&changed).unwrap()).unwrap(); + assert!(read_import_state(&state_path, &plan, &inspected).is_err()); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/crates/registry-serverctl/src/doctor.rs b/crates/registry-serverctl/src/doctor.rs new file mode 100644 index 0000000000..9e1fdc7be0 --- /dev/null +++ b/crates/registry-serverctl/src/doctor.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Registry Server configured startup dependency verification. + +use std::path::Path; + +use registry_server::startup::{prepare, StartupError}; +use registry_server::{Diagnostic, DiagnosticSeverity}; + +/// Run startup preparation without binding a listener and discard its unbound +/// prepared state. This verifies only the dependencies preparation currently +/// opens and intentionally owns no parallel readiness logic. +pub(crate) fn run(runtime_config: &Path) -> Result<(), Diagnostic> { + if !runtime_config.is_absolute() { + return Err(diagnostic( + "startup.runtime_config.path_invalid", + "runtimeConfig", + "the runtime configuration path must be absolute", + )); + } + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| { + diagnostic( + "startup.runtime.unavailable", + "runtime", + "the startup preparation runtime is unavailable", + ) + })?; + runtime + .block_on(prepare(runtime_config)) + .map(drop) + .map_err(startup_diagnostic) +} + +fn startup_diagnostic(error: StartupError) -> Diagnostic { + let (code, path, message) = match error { + StartupError::RuntimeConfig => ( + "startup.runtime_config.refused", + "runtimeConfig", + "the runtime configuration was refused", + ), + StartupError::PackageRefused => ( + "startup.package.refused", + "package", + "the runtime package was refused", + ), + StartupError::DatabaseConnection => ( + "startup.database.connection_refused", + "database", + "the database connection was refused", + ), + StartupError::DatabaseUnready => ( + "startup.database.unready", + "database", + "the database is not ready for the runtime package", + ), + StartupError::Audit => ( + "startup.audit.refused", + "audit", + "the audit profile was refused", + ), + StartupError::Cursor => ( + "startup.cursor.refused", + "cursor", + "the cursor profile was refused", + ), + StartupError::Oidc => ( + "startup.oidc.refused", + "authentication", + "the OIDC key source was refused", + ), + StartupError::Authentication => ( + "startup.authentication.refused", + "authentication", + "the authentication profile was refused", + ), + StartupError::EventDestinations => ( + "startup.event_destinations.refused", + "eventDestinations", + "the event destination bindings were refused", + ), + StartupError::Listener => ( + "startup.listener.refused", + "listener", + "the listener configuration was refused", + ), + StartupError::Shutdown => ( + "startup.shutdown.refused", + "shutdown", + "the shutdown configuration was refused", + ), + StartupError::Logging => ( + "startup.logging.refused", + "logging", + "the operational logging configuration was refused", + ), + }; + diagnostic(code, path, message) +} + +fn diagnostic(code: &str, path: &str, message: &str) -> Diagnostic { + Diagnostic { + severity: DiagnosticSeverity::Error, + code: code.to_owned(), + path: path.to_owned(), + message: message.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn startup_value_disclosure_threat_is_enforced_by_a_closed_negative_class_mapping() { + let cases = [ + ( + StartupError::RuntimeConfig, + "startup.runtime_config.refused", + "runtimeConfig", + ), + ( + StartupError::PackageRefused, + "startup.package.refused", + "package", + ), + ( + StartupError::DatabaseConnection, + "startup.database.connection_refused", + "database", + ), + ( + StartupError::DatabaseUnready, + "startup.database.unready", + "database", + ), + (StartupError::Audit, "startup.audit.refused", "audit"), + (StartupError::Cursor, "startup.cursor.refused", "cursor"), + (StartupError::Oidc, "startup.oidc.refused", "authentication"), + ( + StartupError::Authentication, + "startup.authentication.refused", + "authentication", + ), + ( + StartupError::Listener, + "startup.listener.refused", + "listener", + ), + ( + StartupError::Shutdown, + "startup.shutdown.refused", + "shutdown", + ), + (StartupError::Logging, "startup.logging.refused", "logging"), + ]; + + for (error, expected_code, expected_path) in cases { + let rendered_dependency_error = error.to_string(); + let diagnostic = startup_diagnostic(error); + assert_eq!(diagnostic.code, expected_code); + assert_eq!(diagnostic.path, expected_path); + assert!(!diagnostic.message.contains(&rendered_dependency_error)); + } + } +} diff --git a/crates/registry-serverctl/src/lib.rs b/crates/registry-serverctl/src/lib.rs new file mode 100644 index 0000000000..5d0c461fc5 --- /dev/null +++ b/crates/registry-serverctl/src/lib.rs @@ -0,0 +1,3619 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Deterministic Registry Server project checking and artifact generation. +//! +//! This crate owns filesystem orchestration and report rendering only. Model +//! parsing, validation, compilation, and artifact generation remain in +//! `registry-server`. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::ExitCode; +use std::sync::atomic::{AtomicU64, Ordering}; + +use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use registry_server::migration_plan::ReviewedMigrationRecovery; +use registry_server::package::{ + inspect_package_integrity, CompiledRegistryChangeClass, MigrationInspectionPlanKind, + MigrationInspectionSummary, PackageBuildRequest, PackageError, PackageMigrationPlanInput, + PackageModuleSource, PackageSourceFile, PreparedPackage, SignaturePolicy, + FIXTURE_JOURNEYS_PATH, MAX_PACKAGE_SOURCE_FILE_BYTES, +}; +use registry_server::runtime_config::RuntimeConfigError; +use registry_server::tooling::{classify_registry_diff, CompiledRegistryDiff, DiffClassification}; +use registry_server::{ + compile_project, parse_module_yaml, parse_project_yaml, CompileFailure, CompileProfile, + CompiledRegistry, Diagnostic, DiagnosticSeverity, GeneratedArtifact, GeneratedArtifacts, + RegistryModule, RegistryProject, +}; +use serde::Serialize; +use serde_json::{json, Value}; + +mod apply_lifecycle; +mod data_lifecycle; +mod doctor; +mod package_inspection; +mod package_lifecycle; +mod test_lifecycle; + +use apply_lifecycle::{ApplyLifecycleError, ApplyLifecycleRequest}; +use data_lifecycle::{ + DataExportRequest, DataImportRequest, DataLifecycleError, DataValidateRequest, +}; +use package_inspection::{inspect_runtime_package, RuntimePackageInspectionError}; +use package_lifecycle::{PackageLifecycleError, PackageLifecycleState}; +use registry_server::data::DataError; +use test_lifecycle::{TestLifecycleError, TestLifecycleRequest}; + +const DOMAIN_REFUSAL_EXIT: u8 = 1; +const USAGE_EXIT: u8 = 2; +const OPERATIONAL_FAILURE_EXIT: u8 = 3; +// Keep ctl-authored project and module source capture aligned with the +// schema-test package rederivation ceiling so source-size refusals occur +// before runtime secret resolution or database rehearsal. Broader package-file +// limits still apply to fixture journeys and generated package artifacts. +const AUTHORED_SOURCE_REDERIVATION_MAX_BYTES: u64 = 1024 * 1024; +static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Parser)] +#[command( + name = "registry-serverctl", + version = registry_platform_buildinfo::DISPLAY_VERSION, + about = "Registry Server project checking and deterministic generation" +)] +struct Cli { + /// Emit the selected command's report in this format. + #[arg(long, value_enum, global = true, default_value_t)] + format: OutputFormat, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Create a minimal domain-neutral authoring project in a new directory. + Init(InitArgs), + /// Validate a Registry Server authoring project without opening a database. + Check(CheckArgs), + /// Write selected compiler artifacts to a new directory. + Generate(GenerateArgs), + /// Explain compiled model, access, route, or event inventories. + Explain(ExplainArgs), + /// Compare an authoring candidate with a rederived closed package. + Diff(DiffArgs), + /// Build a deterministic production-profile signing input or publish its externally signed package. + Package(PackageArgs), + /// Execute the production schema-test journey suite for one unsigned package candidate. + Test(TestArgs), + /// Apply one already signed package using the configured migration authority. + Apply(ApplyArgs), + /// Verify configured startup dependencies without binding a listener. + Doctor(DoctorArgs), + /// Verify one configured package without opening runtime dependencies. + Verify(VerifyArgs), + /// Inspect configured migration lifecycle metadata. + Migration(MigrationArgs), + /// Validate, import, or export data through authenticated Registry HTTP APIs. + Data(DataArgs), +} + +#[derive(Debug, Args)] +struct InitArgs { + /// New directory that will receive the minimal project closure. + #[arg(value_name = "DESTINATION")] + destination: PathBuf, +} + +#[derive(Debug, Args)] +struct CheckArgs { + /// Registry Server project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, + + /// Enforce production-only package closure requirements. + #[arg(long)] + production: bool, +} + +#[derive(Debug, Args)] +struct GenerateArgs { + /// Artifact family to write. + #[arg(value_name = "ARTIFACT", value_enum)] + artifact: ArtifactSelector, + + /// Registry Server project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, + + /// Enforce production-only package closure requirements. + #[arg(long)] + production: bool, + + /// New directory that will receive exactly the generated artifact inventory. + #[arg(long, value_name = "DIRECTORY")] + output: PathBuf, +} + +#[derive(Debug, Args)] +struct ExplainArgs { + /// Compiled inventory to explain. + #[arg(value_name = "SUBJECT", value_enum)] + subject: ExplainSubject, + + /// Registry Server project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, + + /// Enforce production-only package closure requirements. + #[arg(long)] + production: bool, +} + +#[derive(Debug, Args)] +struct DoctorArgs { + /// Absolute Registry Server runtime configuration file. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, +} + +#[derive(Debug, Args)] +struct PackageCandidateArgs { + /// Registry Server project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, + + /// Stable deployment database identity recorded in the package. + #[arg(long, value_name = "ID")] + database_id: String, + + /// Runtime configuration selecting the verified active baseline for a successor. + #[arg(long, value_name = "ABSOLUTE_FILE")] + baseline_runtime_config: Option, + + /// Production signature threshold. Local packages require zero. + #[arg(long, default_value_t = 0, value_name = "COUNT")] + signature_threshold: u16, + + /// Allowed package-signing key id. Repeat once per trust-anchor key. + #[arg( + long = "signature-key-id", + value_name = "KEY_ID", + allow_hyphen_values = true + )] + signature_key_ids: Vec, +} + +#[derive(Debug, Args)] +struct PackageArgs { + #[command(flatten)] + candidate: PackageCandidateArgs, + + /// Exact managed-catalog SHA-256 produced by the reviewed PostgreSQL rehearsal. + #[arg(long, value_name = "SHA256")] + schema_fingerprint: String, + + /// Canonical receipt from a successful schema test of this exact candidate. + #[arg(long, value_name = "ABSOLUTE_FILE")] + test_receipt: PathBuf, + + /// JSON document containing externally produced package signatures. + #[arg(long, value_name = "FILE")] + signatures: Option, + + /// New build directory containing signing-input.json and, once approved, package/. + #[arg(long, value_name = "DIRECTORY")] + output: PathBuf, +} + +#[derive(Debug, Args)] +struct TestArgs { + #[command(flatten)] + candidate: PackageCandidateArgs, + + /// Absolute runtime configuration for test database access and secret resolution. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Absolute schema-test credential binding document. + #[arg(long, value_name = "ABSOLUTE_FILE")] + credentials: PathBuf, + + /// New canonical schema-test receipt file. + #[arg(long, value_name = "ABSOLUTE_FILE")] + output: PathBuf, +} + +#[derive(Debug, Args)] +struct ApplyArgs { + /// Absolute runtime configuration for deployment identity, trust, roles, and database access. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Absolute target package directory. + #[arg(long, value_name = "ABSOLUTE_DIRECTORY")] + package: PathBuf, + + /// Activate sequence one in an uninitialized Registry database. + #[arg(long)] + initial: bool, + + /// Reviewed backup binding and absolute local artifact as BINDING_PATH=ABSOLUTE_FILE. + #[arg(long = "backup", value_name = "BINDING_PATH=ABSOLUTE_FILE")] + backups: Vec, +} + +#[derive(Debug, Args)] +struct VerifyArgs { + /// Absolute Registry Server runtime configuration file. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, +} + +#[derive(Debug, Args)] +struct MigrationArgs { + #[command(subcommand)] + command: MigrationCommand, +} + +#[derive(Debug, Args)] +struct DataArgs { + #[command(subcommand)] + command: DataCommand, +} + +#[derive(Debug, Subcommand)] +enum DataCommand { + /// Validate a JSONL import file against one closed package plan. + Validate(DataValidateArgs), + /// Import JSONL records through the ordinary authenticated batch API. + Import(DataImportArgs), + /// Export records through the ordinary authenticated list API. + Export(DataExportArgs), +} + +#[derive(Debug, Args)] +struct DataValidateArgs { + /// Absolute closed package directory used only for deterministic planning. + #[arg(long, value_name = "ABSOLUTE_DIRECTORY")] + package: PathBuf, + + /// Compiled entity identifier. + #[arg(long, value_name = "ID")] + entity: String, + + /// Compiled non-anonymous access profile identifier. + #[arg(long, value_name = "ID")] + profile: String, + + /// Import item operation. + #[arg(long, value_enum)] + operation: DataOperationArg, + + /// JSON Lines import file. + #[arg(long, value_name = "FILE")] + input: PathBuf, +} + +#[derive(Debug, Args)] +struct DataImportArgs { + /// Absolute closed package directory used only for deterministic planning. + #[arg(long, value_name = "ABSOLUTE_DIRECTORY")] + package: PathBuf, + + /// Registry Server base URL. HTTP is accepted only for loopback hosts. + #[arg(long, value_name = "URL")] + server_url: String, + + /// File containing one bearer access token and no other credential material. + #[arg(long, value_name = "ABSOLUTE_FILE")] + access_token_file: PathBuf, + + /// Compiled entity identifier. + #[arg(long, value_name = "ID")] + entity: String, + + /// Compiled non-anonymous access profile identifier. + #[arg(long, value_name = "ID")] + profile: String, + + /// Import item operation. + #[arg(long, value_enum)] + operation: DataOperationArg, + + /// JSON Lines import file. + #[arg(long, value_name = "FILE")] + input: PathBuf, + + /// Import checkpoint file. A ctl-held .state sidecar is created beside it. + #[arg(long, value_name = "FILE")] + checkpoint: PathBuf, + + /// Stop after this many committed chunks, for resumable operator runs. + #[arg(long, value_name = "COUNT")] + max_chunks: Option, +} + +#[derive(Debug, Args)] +struct DataExportArgs { + /// Absolute closed package directory used only for deterministic planning. + #[arg(long, value_name = "ABSOLUTE_DIRECTORY")] + package: PathBuf, + + /// Registry Server base URL. HTTP is accepted only for loopback hosts. + #[arg(long, value_name = "URL")] + server_url: String, + + /// File containing one bearer access token and no other credential material. + #[arg(long, value_name = "ABSOLUTE_FILE")] + access_token_file: PathBuf, + + /// Compiled entity identifier. + #[arg(long, value_name = "ID")] + entity: String, + + /// Compiled non-anonymous export-enabled access profile identifier. + #[arg(long, value_name = "ID")] + profile: String, + + /// Requested readable field. Repeat for every exported field. + #[arg(long = "field", value_name = "ID", required = true)] + fields: Vec, + + /// New JSON Lines output file. + #[arg(long, value_name = "FILE")] + output: PathBuf, + + /// New export checkpoint file written after every page. + #[arg(long, value_name = "FILE")] + checkpoint: PathBuf, + + /// Stop after this many pages, for bounded operator runs. + #[arg(long, value_name = "COUNT")] + max_pages: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +enum DataOperationArg { + Create, + Patch, +} + +impl From for registry_server::data::DataImportOperation { + fn from(value: DataOperationArg) -> Self { + match value { + DataOperationArg::Create => Self::Create, + DataOperationArg::Patch => Self::Patch, + } + } +} + +#[derive(Debug, Subcommand)] +enum MigrationCommand { + /// Explain the verified package's closed migration plan without executing it. + Explain(MigrationExplainArgs), +} + +#[derive(Debug, Args)] +struct MigrationExplainArgs { + /// Absolute Registry Server runtime configuration file. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, +} + +#[derive(Debug, Args)] +#[command(group( + ArgGroup::new("baseline") + .required(true) + .multiple(false) + .args(["runtime_config", "package"]) +))] +struct DiffArgs { + /// Registry Server authoring project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, + + /// Absolute runtime configuration whose package bindings and trust apply. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: Option, + + /// Closed package inspected for integrity only, without activation authority. + #[arg(long, value_name = "DIRECTORY")] + package: Option, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +enum OutputFormat { + #[default] + Human, + Json, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +enum ArtifactSelector { + Openapi, + Schemas, + Manifest, + Metadata, + Sql, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +enum ExplainSubject { + Model, + Access, + Routes, + Events, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ProfileArg { + #[default] + Authoring, + Production, +} + +impl From for CompileProfile { + fn from(value: ProfileArg) -> Self { + match value { + ProfileArg::Authoring => Self::Authoring, + ProfileArg::Production => Self::Production, + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ArtifactReport { + path: String, + media_type: String, + sha256: String, + byte_length: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SuccessReport { + ok: bool, + command: &'static str, + profile: ProfileArg, + revision: String, + findings: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + artifacts: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + explanation: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FailureReport { + ok: bool, + command: &'static str, + diagnostics: Vec, +} + +/// CLI-owned diagnostic envelope. Shared compiler diagnostics are converted at +/// the command boundary so machine consumers receive one stable shape without +/// widening the compiler's public diagnostic contract. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ToolDiagnostic { + severity: DiagnosticSeverity, + code: String, + artifact: DiagnosticArtifact, + path: String, + message: String, + suggested_action: SuggestedAction, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DiagnosticArtifact { + CommandArguments, + RegistryProject, + ProjectInitialization, + GeneratedArtifacts, + CompiledInventory, + RuntimeConfiguration, + BaselinePackage, + CompiledDiff, + PackageBuild, + PackageSigningInput, + SchemaTestReceipt, + SchemaTestCandidate, + FixtureJourneys, + SchemaTestCredentials, + SchemaTestDatabase, + SchemaTestExecution, + SchemaTestOutput, + PackageSignatures, + PackageActivation, + DatabaseMigration, + StartupDependencies, + VerifiedPackage, + DataOperation, + DataCheckpoint, + DataTransport, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum SuggestedAction { + CorrectCommandUsage, + CorrectAuthoringSource, + ReviewAuthoringFinding, + ChooseSafeOutputDirectory, + SelectAvailableArtifact, + RetryArtifactGeneration, + RetryInventoryExplanation, + CorrectRuntimeConfiguration, + VerifyPackagePath, + VerifyPackagePermissions, + VerifyPackageTrust, + VerifyPackageBinding, + VerifyPackageIntegrity, + ReviewCompiledDiff, + CorrectPackageBuild, + ReviewSigningInput, + SupplySchemaTestReceipt, + CorrectSchemaTestCandidate, + CorrectFixtureJourneys, + SupplySchemaTestCredentials, + PrepareSchemaTestDatabase, + RecreateDisposableDatabase, + ChooseSchemaTestOutput, + SupplyExternalSignatures, + VerifyMigrationAuthority, + ReconcileFailedMigration, + VerifyStartupDependencies, + CorrectDataBinding, + CorrectDataInput, + VerifyDataCheckpoint, + VerifyDataTransport, +} + +#[derive(Serialize)] +struct DoctorSuccessReport { + ok: bool, + command: &'static str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct VerifySuccessReport { + ok: bool, + command: &'static str, + assurance: BaselineAssurance, + package_revision: String, + registry: VerifiedRegistryReport, + inventory: VerifiedInventoryReport, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MigrationExplainSuccessReport { + ok: bool, + command: &'static str, + assurance: BaselineAssurance, + package_revision: String, + plan: MigrationInspectionSummary, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PackageSuccessReport { + ok: bool, + command: &'static str, + profile: ProfileArg, + state: PackageReportState, + package_revision: String, + signature_threshold: u16, + provided_signatures: usize, + package_files: usize, + signing_input: ArtifactReport, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SchemaTestSuccessReport { + ok: bool, + command: &'static str, + profile: ProfileArg, + package_revision: String, + schema_fingerprint: String, + signing_input_sha256: String, + successful_journey_ids: Vec, + receipt: ArtifactReport, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum PackageReportState { + AwaitingSignatures, + Published, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ApplySuccessReport { + ok: bool, + command: &'static str, + activation: ApplyActivation, + package_revision: String, + schema_fingerprint: String, + package_sequence: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DataValidateSuccessReport { + ok: bool, + command: &'static str, + package_revision: String, + schema_fingerprint: String, + entity_id: String, + profile_id: String, + operation: DataOperationArg, + input_length: u64, + item_count: u64, + chunk_count: usize, + maximum_items: u16, + maximum_bytes: u32, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DataImportSuccessReport { + ok: bool, + command: &'static str, + package_revision: String, + schema_fingerprint: String, + entity_id: String, + profile_id: String, + operation: DataOperationArg, + input_length: u64, + item_count: u64, + completed_chunk_count: u64, + committed_items: u64, + complete: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DataExportSuccessReport { + ok: bool, + command: &'static str, + package_revision: String, + schema_fingerprint: String, + entity_id: String, + profile_id: String, + requested_fields: Vec, + completed_page_count: u64, + record_count: u64, + output_length: u64, + complete: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ApplyActivation { + Initial, + Successor, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct VerifiedRegistryReport { + id: String, + version: String, + revision: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct VerifiedInventoryReport { + modules: usize, + entities: usize, + routes: usize, + access_entries: usize, + queries: usize, + event_deliveries: usize, + ddl_statements: usize, + generated_artifacts: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum BaselineAssurance { + RuntimeBound, + IntegrityOnly, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DiffSuccessReport { + ok: bool, + command: &'static str, + profile: ProfileArg, + baseline_assurance: BaselineAssurance, + findings: Vec, + #[serde(flatten)] + diff: CompiledRegistryDiff, +} + +#[derive(Debug)] +struct CapturedProjectSource { + project: RegistryProject, + project_bytes: Vec, + modules: Vec, +} + +#[derive(Debug)] +struct CapturedModuleSource { + id: String, + module: RegistryModule, + bytes: Vec, +} + +#[derive(Clone, Debug)] +struct CapturedPackageCandidate { + compiled: CompiledRegistry, + environment: String, + instance_id: String, + database_id: String, + sequence: u64, + compiler_source_revision: String, + prior_revision: Option, + signature_policy: SignaturePolicy, + project: PackageSourceFile, + modules: Vec, + fixture_journeys: PackageSourceFile, + migration_plan: PackageMigrationPlanInput, +} + +impl CapturedPackageCandidate { + fn registry(&self) -> &CompiledRegistry { + &self.compiled + } + + fn fixture_journeys(&self) -> &[u8] { + &self.fixture_journeys.bytes + } + + fn validate_runtime_binding( + &self, + config: ®istry_server::runtime_config::RuntimeConfig, + ) -> Result<(), TestLifecycleError> { + if config.identity().environment() != self.environment + || config.identity().instance_id() != self.instance_id + || config.identity().database_id() != self.database_id + || config.package().compiler_source_revision() != self.compiler_source_revision + { + return Err(TestLifecycleError::Candidate); + } + Ok(()) + } + + fn prevalidate(&self) -> Result<(), PackageError> { + const PLACEHOLDER_SCHEMA_FINGERPRINT: &str = + "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + self.clone() + .prepare(PLACEHOLDER_SCHEMA_FINGERPRINT.to_owned()) + .map(|_| ()) + } + + fn prepare(self, schema_fingerprint: String) -> Result { + registry_server::package::prepare_package(PackageBuildRequest { + environment: self.environment, + instance_id: self.instance_id, + database_id: self.database_id, + sequence: self.sequence, + prior_revision: self.prior_revision, + compiler_source_revision: self.compiler_source_revision, + schema_fingerprint, + signature_policy: self.signature_policy, + project: self.project, + modules: self.modules, + fixture_journeys: self.fixture_journeys, + migration_plan: self.migration_plan, + }) + } +} + +/// Return the public command tree without running a project operation. +pub fn command() -> clap::Command { + let mut command = Cli::command(); + command.build(); + command +} + +/// Parse the current process arguments and execute the selected operation. +pub fn main_entry() -> ExitCode { + run_from(std::env::args_os(), &mut io::stdout(), &mut io::stderr()) +} + +/// Run from explicit arguments. This is public so process-level tests can use +/// the exact command parser while keeping filesystem behavior in one place. +pub fn run_from(arguments: I, stdout: &mut dyn Write, stderr: &mut dyn Write) -> ExitCode +where + I: IntoIterator, + T: Into + Clone, +{ + let arguments: Vec = arguments.into_iter().map(Into::into).collect(); + let machine_mode = requested_json(&arguments); + let cli = match Cli::try_parse_from(&arguments) { + Ok(cli) => cli, + Err(error) => { + if matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) { + let _ = write!(stdout, "{error}"); + return ExitCode::SUCCESS; + } + let report = FailureReport { + ok: false, + command: "usage", + diagnostics: vec![tool_diagnostic( + diagnostic( + "usage.invalid", + "arguments", + "the command arguments are invalid", + ), + DiagnosticArtifact::CommandArguments, + SuggestedAction::CorrectCommandUsage, + )], + }; + let _ = write_failure( + &report, + if machine_mode { + OutputFormat::Json + } else { + OutputFormat::Human + }, + stdout, + stderr, + ); + return ExitCode::from(USAGE_EXIT); + } + }; + + let format = cli.format; + let result = match cli.command { + Command::Init(args) => init(&args.destination), + Command::Check(args) => check(&args.project, profile(args.production)), + Command::Generate(args) => generate( + args.artifact, + &args.project, + profile(args.production), + &args.output, + ), + Command::Explain(args) => explain(args.subject, &args.project, profile(args.production)), + Command::Diff(args) => { + return match diff(&args) { + Ok(report) => write_diff_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }; + } + Command::Package(args) => { + return match package(&args) { + Ok(report) => write_package_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }; + } + Command::Test(args) => { + return match test(&args) { + Ok(report) => write_schema_test_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }; + } + Command::Apply(args) => { + return match apply(&args) { + Ok(report) => write_apply_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }; + } + Command::Doctor(args) => { + return match doctor::run(&args.runtime_config) { + Ok(()) => write_doctor_success(format, stdout, stderr), + Err(diagnostic) => { + let (artifact, action) = + if diagnostic.code.starts_with("startup.runtime_config") { + ( + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ) + } else { + ( + DiagnosticArtifact::StartupDependencies, + SuggestedAction::VerifyStartupDependencies, + ) + }; + write_failure( + &FailureReport { + ok: false, + command: "doctor", + diagnostics: vec![tool_diagnostic(diagnostic, artifact, action)], + }, + format, + stdout, + stderr, + ) + } + }; + } + Command::Verify(args) => { + return match verify(&args) { + Ok(report) => write_verify_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }; + } + Command::Migration(args) => match args.command { + MigrationCommand::Explain(args) => { + return match migration_explain(&args) { + Ok(report) => write_migration_explain_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }; + } + }, + Command::Data(args) => match args.command { + DataCommand::Validate(args) => { + return match data_validate(&args) { + Ok(report) => write_data_validate_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }; + } + DataCommand::Import(args) => { + return match data_import(&args) { + Ok(report) => write_data_import_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }; + } + DataCommand::Export(args) => { + return match data_export(&args) { + Ok(report) => write_data_export_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }; + } + }, + }; + + match result { + Ok(report) => write_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + } +} + +fn data_validate(args: &DataValidateArgs) -> Result { + let outcome = data_lifecycle::validate_import(DataValidateRequest { + package: &args.package, + entity: &args.entity, + operation: args.operation.into(), + profile: &args.profile, + input: &args.input, + }) + .map_err(|error| data_lifecycle_failure("data validate", "data.validate", error))?; + Ok(DataValidateSuccessReport { + ok: true, + command: "data validate", + package_revision: outcome.package_revision, + schema_fingerprint: outcome.schema_fingerprint, + entity_id: outcome.entity_id, + profile_id: outcome.profile_id, + operation: operation_arg(outcome.operation), + input_length: outcome.input_length, + item_count: outcome.item_count, + chunk_count: outcome.chunk_count, + maximum_items: outcome.maximum_items, + maximum_bytes: outcome.maximum_bytes, + }) +} + +fn data_import(args: &DataImportArgs) -> Result { + let outcome = data_lifecycle::run_import(DataImportRequest { + package: &args.package, + server_url: &args.server_url, + access_token_file: &args.access_token_file, + entity: &args.entity, + operation: args.operation.into(), + profile: &args.profile, + input: &args.input, + checkpoint: &args.checkpoint, + max_chunks: args.max_chunks, + }) + .map_err(|error| data_lifecycle_failure("data import", "data.import", error))?; + Ok(DataImportSuccessReport { + ok: true, + command: "data import", + package_revision: outcome.package_revision, + schema_fingerprint: outcome.schema_fingerprint, + entity_id: outcome.entity_id, + profile_id: outcome.profile_id, + operation: operation_arg(outcome.operation), + input_length: outcome.input_length, + item_count: outcome.item_count, + completed_chunk_count: outcome.completed_chunk_count, + committed_items: outcome.committed_items, + complete: outcome.complete, + }) +} + +fn data_export(args: &DataExportArgs) -> Result { + let outcome = data_lifecycle::run_export(DataExportRequest { + package: &args.package, + server_url: &args.server_url, + access_token_file: &args.access_token_file, + entity: &args.entity, + profile: &args.profile, + fields: &args.fields, + output: &args.output, + checkpoint: &args.checkpoint, + max_pages: args.max_pages, + }) + .map_err(|error| data_lifecycle_failure("data export", "data.export", error))?; + Ok(DataExportSuccessReport { + ok: true, + command: "data export", + package_revision: outcome.package_revision, + schema_fingerprint: outcome.schema_fingerprint, + entity_id: outcome.entity_id, + profile_id: outcome.profile_id, + requested_fields: outcome.requested_fields, + completed_page_count: outcome.completed_page_count, + record_count: outcome.record_count, + output_length: outcome.output_length, + complete: outcome.complete, + }) +} + +fn operation_arg(operation: registry_server::data::DataImportOperation) -> DataOperationArg { + match operation { + registry_server::data::DataImportOperation::Create => DataOperationArg::Create, + registry_server::data::DataImportOperation::Patch => DataOperationArg::Patch, + } +} + +fn data_lifecycle_failure( + command: &'static str, + prefix: &'static str, + error: DataLifecycleError, +) -> FailureReport { + let (code, path, message, artifact, action) = match error { + DataLifecycleError::PackagePath => ( + format!("{prefix}.package.path_invalid"), + "package", + "the package path must be absolute", + DiagnosticArtifact::VerifiedPackage, + SuggestedAction::VerifyPackagePath, + ), + DataLifecycleError::Package(error) => { + let action = match error { + PackageError::UnsafePath => SuggestedAction::VerifyPackagePath, + PackageError::Permissions => SuggestedAction::VerifyPackagePermissions, + PackageError::Signature => SuggestedAction::VerifyPackageTrust, + PackageError::Binding => SuggestedAction::VerifyPackageBinding, + _ => SuggestedAction::VerifyPackageIntegrity, + }; + ( + format!("{prefix}.package.refused"), + "package", + "the data package was refused", + DiagnosticArtifact::VerifiedPackage, + action, + ) + } + DataLifecycleError::PackageManifest => ( + format!("{prefix}.package.refused"), + "package", + "the data package was refused", + DiagnosticArtifact::VerifiedPackage, + SuggestedAction::VerifyPackageIntegrity, + ), + DataLifecycleError::Input | DataLifecycleError::Data(DataError::InvalidInput) => ( + format!("{prefix}.input.refused"), + "input", + "the data input was refused", + DiagnosticArtifact::DataOperation, + SuggestedAction::CorrectDataInput, + ), + DataLifecycleError::Data(DataError::InvalidItem) + | DataLifecycleError::Data(DataError::ItemTooLarge) => ( + format!("{prefix}.item.refused"), + "input", + "a data item was refused", + DiagnosticArtifact::DataOperation, + SuggestedAction::CorrectDataInput, + ), + DataLifecycleError::Data(DataError::InvalidBinding) => ( + format!("{prefix}.binding.refused"), + "data", + "the data operation binding was refused", + DiagnosticArtifact::DataOperation, + SuggestedAction::CorrectDataBinding, + ), + DataLifecycleError::Checkpoint + | DataLifecycleError::Data(DataError::CheckpointMismatch) => ( + format!("{prefix}.checkpoint.refused"), + "checkpoint", + "the data checkpoint was refused", + DiagnosticArtifact::DataCheckpoint, + SuggestedAction::VerifyDataCheckpoint, + ), + DataLifecycleError::Output => ( + format!("{prefix}.output.refused"), + "output", + "the data output was refused", + DiagnosticArtifact::DataOperation, + SuggestedAction::CorrectDataInput, + ), + DataLifecycleError::ServerUrl => ( + format!("{prefix}.server_url.refused"), + "serverUrl", + "the Registry Server URL was refused", + DiagnosticArtifact::DataTransport, + SuggestedAction::VerifyDataTransport, + ), + DataLifecycleError::Token => ( + format!("{prefix}.access_token.refused"), + "accessToken", + "the access token file was refused", + DiagnosticArtifact::DataTransport, + SuggestedAction::VerifyDataTransport, + ), + DataLifecycleError::Runtime | DataLifecycleError::Transport => ( + format!("{prefix}.transport.unavailable"), + "transport", + "the Registry data transport is unavailable", + DiagnosticArtifact::DataTransport, + SuggestedAction::VerifyDataTransport, + ), + DataLifecycleError::Data(DataError::OperationRefused) => ( + format!("{prefix}.operation.refused"), + "data", + "the Registry data operation was refused", + DiagnosticArtifact::DataOperation, + SuggestedAction::CorrectDataBinding, + ), + DataLifecycleError::Data(DataError::InvalidResponse) => ( + format!("{prefix}.response.refused"), + "data", + "the Registry data response was refused", + DiagnosticArtifact::DataTransport, + SuggestedAction::VerifyDataTransport, + ), + DataLifecycleError::Data(DataError::TransportUnavailable) => ( + format!("{prefix}.transport.unavailable"), + "transport", + "the Registry data transport is unavailable", + DiagnosticArtifact::DataTransport, + SuggestedAction::VerifyDataTransport, + ), + }; + FailureReport { + ok: false, + command, + diagnostics: vec![tool_diagnostic( + diagnostic(&code, path, message), + artifact, + action, + )], + } +} + +fn diff(args: &DiffArgs) -> Result { + let candidate = compile(&args.project, ProfileArg::Authoring, "diff")?; + let (baseline, baseline_assurance) = match (&args.runtime_config, &args.package) { + (Some(runtime_path), None) => { + let inspected = inspect_runtime_package(runtime_path).map_err(|error| match error { + RuntimePackageInspectionError::RuntimeConfigPath => diff_failure( + "diff.runtime_config.path_invalid", + "runtimeConfig", + "the runtime configuration path must be absolute", + ), + RuntimePackageInspectionError::RuntimeConfig(error) => { + runtime_config_diff_failure(error) + } + RuntimePackageInspectionError::Package(error) => package_diff_failure(error), + })?; + (inspected, BaselineAssurance::RuntimeBound) + } + (None, Some(package_root)) => ( + inspect_package_integrity(package_root).map_err(package_diff_failure)?, + BaselineAssurance::IntegrityOnly, + ), + _ => unreachable!("clap enforces exactly one diff baseline selector"), + }; + let compiled_diff = + classify_registry_diff(baseline.registry(), &candidate, baseline.package_revision()); + let mut compiler_findings = candidate.findings().to_vec(); + compiler_findings.extend(unsupported_diff_findings(&compiled_diff)); + compiler_findings.sort(); + compiler_findings.dedup(); + let findings = compiler_findings + .into_iter() + .map(|diagnostic| { + let (artifact, action) = if diagnostic.code == "diff.classification.unsupported" { + ( + DiagnosticArtifact::CompiledDiff, + SuggestedAction::ReviewCompiledDiff, + ) + } else { + ( + DiagnosticArtifact::RegistryProject, + SuggestedAction::ReviewAuthoringFinding, + ) + }; + tool_diagnostic(diagnostic, artifact, action) + }) + .collect(); + Ok(DiffSuccessReport { + ok: true, + command: "diff", + profile: ProfileArg::Authoring, + baseline_assurance, + findings, + diff: compiled_diff, + }) +} + +fn package(args: &PackageArgs) -> Result { + let prepared = prepare_candidate(&args.candidate, args.schema_fingerprint.clone(), "package")?; + let receipt = package_lifecycle::validate_test_receipt(&args.test_receipt, &prepared) + .map_err(package_lifecycle_failure)?; + let outcome = + package_lifecycle::run(prepared, receipt, &args.output, args.signatures.as_deref()) + .map_err(package_lifecycle_failure)?; + Ok(PackageSuccessReport { + ok: true, + command: "package", + profile: ProfileArg::Production, + state: match outcome.state { + PackageLifecycleState::AwaitingSignatures => PackageReportState::AwaitingSignatures, + PackageLifecycleState::Published => PackageReportState::Published, + }, + package_revision: outcome.package_revision, + signature_threshold: outcome.signature_threshold, + provided_signatures: outcome.provided_signatures, + package_files: outcome.package_files, + signing_input: ArtifactReport { + path: "signing-input.json".to_owned(), + media_type: "application/json".to_owned(), + sha256: outcome.signing_input_sha256, + byte_length: outcome.signing_input_bytes, + }, + }) +} + +fn test(args: &TestArgs) -> Result { + let output = test_lifecycle::preflight_output(&args.output).map_err(test_lifecycle_failure)?; + let candidate = capture_candidate(&args.candidate, "test")?; + let outcome = test_lifecycle::run(TestLifecycleRequest { + candidate, + runtime_config: &args.runtime_config, + credentials: &args.credentials, + output, + }) + .map_err(test_lifecycle_failure)?; + Ok(SchemaTestSuccessReport { + ok: true, + command: "test", + profile: ProfileArg::Production, + package_revision: outcome.package_revision, + schema_fingerprint: outcome.schema_fingerprint, + signing_input_sha256: outcome.signing_input_sha256, + successful_journey_ids: outcome.successful_journey_ids, + receipt: ArtifactReport { + path: test_lifecycle::receipt_artifact_path().to_owned(), + media_type: "application/json".to_owned(), + sha256: outcome.receipt_sha256, + byte_length: outcome.receipt_bytes, + }, + }) +} + +fn prepare_candidate( + args: &PackageCandidateArgs, + schema_fingerprint: String, + command: &'static str, +) -> Result { + capture_candidate(args, command)? + .prepare(schema_fingerprint) + .map_err(|error| candidate_package_error(command, error)) +} + +fn capture_candidate( + args: &PackageCandidateArgs, + command: &'static str, +) -> Result { + let source = capture_project_source(&args.project).map_err(|diagnostic| { + source_failure( + command, + diagnostic, + DiagnosticArtifact::RegistryProject, + SuggestedAction::CorrectAuthoringSource, + ) + })?; + let compiled = compile_captured_project(&source, ProfileArg::Production, command)?; + let identity = compiled.package().ok_or_else(|| { + candidate_failure( + command, + "package.identity.refused", + "package", + "the production package identity was refused", + candidate_artifact(command), + SuggestedAction::CorrectPackageBuild, + ) + })?; + let environment = identity.environment.clone(); + let instance_id = identity.instance_id.clone(); + let sequence = identity.sequence; + let compiler_source_revision = identity.source_revision.clone(); + let project_bytes = source.project_bytes; + let modules = source + .modules + .into_iter() + .map(|module| PackageModuleSource { + path: format!("source/modules/{}/module.yaml", module.id), + id: module.id, + bytes: module.bytes, + }) + .collect(); + let fixture_journey_bytes = read_bounded_regular_file( + &args.project.join(FIXTURE_JOURNEYS_PATH), + "source.fixture_journeys.missing", + MAX_PACKAGE_SOURCE_FILE_BYTES, + ) + .map_err(|diagnostic| FailureReport { + ok: false, + command, + diagnostics: vec![tool_diagnostic( + diagnostic, + DiagnosticArtifact::RegistryProject, + SuggestedAction::CorrectAuthoringSource, + )], + })?; + let (prior_revision, migration_plan) = match args.baseline_runtime_config.as_deref() { + Some(runtime_config) => { + let baseline = inspect_runtime_package(runtime_config) + .map_err(|error| inspection_failure(command, "package.baseline", error))?; + ( + Some(baseline.package_revision().to_owned()), + PackageMigrationPlanInput::Successor { + prior_registry: Box::new(baseline.registry().clone()), + }, + ) + } + None => (None, PackageMigrationPlanInput::InitialCompiledDdl), + }; + let mut signature_key_ids = args.signature_key_ids.clone(); + signature_key_ids.sort(); + if signature_key_ids.windows(2).any(|ids| ids[0] == ids[1]) { + return Err(candidate_failure( + command, + "package.signature_policy.refused", + "signaturePolicy", + "the package signature policy was refused", + candidate_artifact(command), + SuggestedAction::CorrectPackageBuild, + )); + } + Ok(CapturedPackageCandidate { + compiled, + environment, + instance_id, + database_id: args.database_id.clone(), + sequence, + prior_revision, + compiler_source_revision, + signature_policy: SignaturePolicy { + threshold: args.signature_threshold, + key_ids: signature_key_ids, + }, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: project_bytes, + }, + modules, + fixture_journeys: PackageSourceFile { + path: FIXTURE_JOURNEYS_PATH.to_owned(), + bytes: fixture_journey_bytes, + }, + migration_plan, + }) +} + +fn apply(args: &ApplyArgs) -> Result { + let outcome = apply_lifecycle::run(ApplyLifecycleRequest { + runtime_config: &args.runtime_config, + package: &args.package, + initial: args.initial, + backups: &args.backups, + }) + .map_err(apply_lifecycle_failure)?; + Ok(ApplySuccessReport { + ok: true, + command: "apply", + activation: if outcome.initial { + ApplyActivation::Initial + } else { + ApplyActivation::Successor + }, + package_revision: outcome.package_revision, + schema_fingerprint: outcome.schema_fingerprint, + package_sequence: outcome.package_sequence, + }) +} + +fn package_lifecycle_failure(error: PackageLifecycleError) -> FailureReport { + match error { + PackageLifecycleError::Package(error) => { + let (code, action) = match error { + PackageError::Signature => ( + "package.signatures.refused", + SuggestedAction::SupplyExternalSignatures, + ), + PackageError::UnsafePath | PackageError::Permissions => ( + "package.output.refused", + SuggestedAction::CorrectPackageBuild, + ), + _ => ( + "package.build.refused", + SuggestedAction::CorrectPackageBuild, + ), + }; + package_failure( + code, + "package", + "the package build was refused", + DiagnosticArtifact::PackageBuild, + action, + ) + } + PackageLifecycleError::Output => package_failure( + "package.output.refused", + "output", + "the package output was refused", + DiagnosticArtifact::PackageSigningInput, + SuggestedAction::ReviewSigningInput, + ), + PackageLifecycleError::SignatureDocument => package_failure( + "package.signatures.refused", + "signatures", + "the external package signatures were refused", + DiagnosticArtifact::PackageSignatures, + SuggestedAction::SupplyExternalSignatures, + ), + PackageLifecycleError::TestReceiptMissing => package_failure( + "package.test_receipt.missing", + "testReceipt", + "the schema-test receipt is required", + DiagnosticArtifact::SchemaTestReceipt, + SuggestedAction::SupplySchemaTestReceipt, + ), + PackageLifecycleError::TestReceiptRefused | PackageLifecycleError::TestReceiptEvidence => { + package_failure( + "package.test_receipt.refused", + "testReceipt", + "the schema-test receipt was refused", + DiagnosticArtifact::SchemaTestReceipt, + SuggestedAction::SupplySchemaTestReceipt, + ) + } + } +} + +fn test_lifecycle_failure(error: TestLifecycleError) -> FailureReport { + let (code, path, message, artifact, action) = match error { + TestLifecycleError::RuntimeConfigPath => ( + "test.runtime_config.path_invalid", + "runtimeConfig", + "the runtime configuration path must be absolute", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + TestLifecycleError::RuntimeConfig(RuntimeConfigError::UnsafeFile) => ( + "test.runtime_config.path_invalid", + "runtimeConfig", + "the runtime configuration path is unsafe", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + TestLifecycleError::RuntimeConfig(_) => ( + "test.runtime_config.refused", + "runtimeConfig", + "the runtime configuration was refused", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + TestLifecycleError::Candidate => ( + "test.candidate.refused", + "candidate", + "the schema-test package candidate was refused", + DiagnosticArtifact::SchemaTestCandidate, + SuggestedAction::CorrectSchemaTestCandidate, + ), + TestLifecycleError::Journeys => ( + "test.journeys.refused", + "journeys", + "the packaged schema-test journey suite was refused", + DiagnosticArtifact::FixtureJourneys, + SuggestedAction::CorrectFixtureJourneys, + ), + TestLifecycleError::Credentials => ( + "test.credentials.refused", + "credentials", + "the schema-test credential bindings were refused", + DiagnosticArtifact::SchemaTestCredentials, + SuggestedAction::SupplySchemaTestCredentials, + ), + TestLifecycleError::Database => ( + "test.database.unavailable", + "database", + "the schema-test database is unavailable; recreate the disposable database before retrying", + DiagnosticArtifact::SchemaTestDatabase, + SuggestedAction::RecreateDisposableDatabase, + ), + TestLifecycleError::Execution => ( + "test.execution.refused", + "execution", + "the schema-test execution was refused; recreate the disposable database before retrying", + DiagnosticArtifact::SchemaTestExecution, + SuggestedAction::RecreateDisposableDatabase, + ), + TestLifecycleError::OutputPreflight => ( + "test.output.refused", + "output", + "the schema-test receipt output was refused", + DiagnosticArtifact::SchemaTestOutput, + SuggestedAction::ChooseSchemaTestOutput, + ), + TestLifecycleError::OutputCommit => ( + "test.output.failed", + "output", + "the schema-test receipt could not be published; recreate the disposable database before retrying", + DiagnosticArtifact::SchemaTestOutput, + SuggestedAction::RecreateDisposableDatabase, + ), + TestLifecycleError::Runtime => ( + "test.runtime.unavailable", + "runtime", + "the schema-test runtime is unavailable", + DiagnosticArtifact::SchemaTestExecution, + SuggestedAction::PrepareSchemaTestDatabase, + ), + }; + FailureReport { + ok: false, + command: "test", + diagnostics: vec![tool_diagnostic( + diagnostic(code, path, message), + artifact, + action, + )], + } +} + +fn apply_lifecycle_failure(error: ApplyLifecycleError) -> FailureReport { + let (code, path, message, artifact, action) = match error { + ApplyLifecycleError::RuntimeConfigPath => ( + "apply.runtime_config.path_invalid", + "runtimeConfig", + "the runtime configuration path must be absolute", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + ApplyLifecycleError::RuntimeConfig => ( + "apply.runtime_config.refused", + "runtimeConfig", + "the runtime configuration was refused", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + ApplyLifecycleError::TargetPackagePath => ( + "apply.package.path_invalid", + "package", + "the target package path must be absolute", + DiagnosticArtifact::VerifiedPackage, + SuggestedAction::VerifyPackagePath, + ), + ApplyLifecycleError::CurrentPackage(error) | ApplyLifecycleError::TargetPackage(error) => { + let action = match error { + PackageError::UnsafePath => SuggestedAction::VerifyPackagePath, + PackageError::Permissions => SuggestedAction::VerifyPackagePermissions, + PackageError::Signature => SuggestedAction::VerifyPackageTrust, + PackageError::Binding => SuggestedAction::VerifyPackageBinding, + _ => SuggestedAction::VerifyPackageIntegrity, + }; + ( + "apply.package.refused", + "package", + "the activation package was refused", + DiagnosticArtifact::VerifiedPackage, + action, + ) + } + ApplyLifecycleError::DatabaseConfiguration | ApplyLifecycleError::TimeoutConfiguration => ( + "apply.database_configuration.refused", + "database", + "the migration database configuration was refused", + DiagnosticArtifact::DatabaseMigration, + SuggestedAction::VerifyMigrationAuthority, + ), + ApplyLifecycleError::BackupArgument => ( + "apply.backup_evidence.refused", + "backup", + "the destructive backup evidence argument was refused", + DiagnosticArtifact::PackageActivation, + SuggestedAction::CorrectPackageBuild, + ), + ApplyLifecycleError::Runtime => ( + "apply.runtime.unavailable", + "runtime", + "the package apply runtime is unavailable", + DiagnosticArtifact::PackageActivation, + SuggestedAction::VerifyMigrationAuthority, + ), + ApplyLifecycleError::Apply(error) => match error { + registry_server::migration::MigrationError::PackageBinding + | registry_server::migration::MigrationError::EmptyPlan => ( + "apply.package.refused", + "package", + "the activation package was refused", + DiagnosticArtifact::VerifiedPackage, + SuggestedAction::VerifyPackageBinding, + ), + registry_server::migration::MigrationError::BackupEvidence => ( + "apply.backup_evidence.refused", + "backup", + "the destructive backup evidence was refused", + DiagnosticArtifact::PackageActivation, + SuggestedAction::CorrectPackageBuild, + ), + registry_server::migration::MigrationError::ApplyFailed => ( + "apply.migration.failed", + "database", + "the Registry package apply failed and requires exact-target reconciliation", + DiagnosticArtifact::DatabaseMigration, + SuggestedAction::ReconcileFailedMigration, + ), + }, + }; + FailureReport { + ok: false, + command: "apply", + diagnostics: vec![tool_diagnostic( + diagnostic(code, path, message), + artifact, + action, + )], + } +} + +fn package_failure( + code: &str, + path: &str, + message: &str, + artifact: DiagnosticArtifact, + action: SuggestedAction, +) -> FailureReport { + FailureReport { + ok: false, + command: "package", + diagnostics: vec![tool_diagnostic( + diagnostic(code, path, message), + artifact, + action, + )], + } +} + +fn candidate_package_error(command: &'static str, error: PackageError) -> FailureReport { + if command == "package" { + return package_lifecycle_failure(PackageLifecycleError::Package(error)); + } + candidate_failure( + command, + "test.candidate.refused", + "candidate", + "the schema-test package candidate was refused", + DiagnosticArtifact::SchemaTestCandidate, + match error { + PackageError::UnsafePath => SuggestedAction::VerifyPackagePath, + PackageError::Permissions => SuggestedAction::VerifyPackagePermissions, + PackageError::Signature => SuggestedAction::VerifyPackageTrust, + PackageError::Binding => SuggestedAction::VerifyPackageBinding, + _ => SuggestedAction::CorrectSchemaTestCandidate, + }, + ) +} + +fn candidate_failure( + command: &'static str, + code: &str, + path: &str, + message: &str, + artifact: DiagnosticArtifact, + action: SuggestedAction, +) -> FailureReport { + FailureReport { + ok: false, + command, + diagnostics: vec![tool_diagnostic( + diagnostic(code, path, message), + artifact, + action, + )], + } +} + +fn candidate_artifact(command: &'static str) -> DiagnosticArtifact { + if command == "test" { + DiagnosticArtifact::SchemaTestCandidate + } else { + DiagnosticArtifact::PackageBuild + } +} + +fn verify(args: &VerifyArgs) -> Result { + let inspected = inspect_runtime_package(&args.runtime_config) + .map_err(|error| inspection_failure("verify", "verify", error))?; + let registry = inspected.registry(); + Ok(VerifySuccessReport { + ok: true, + command: "verify", + assurance: BaselineAssurance::RuntimeBound, + package_revision: inspected.package_revision().to_owned(), + registry: VerifiedRegistryReport { + id: registry.registry_id().to_owned(), + version: registry.version().to_owned(), + revision: registry.revision().to_owned(), + }, + inventory: VerifiedInventoryReport { + modules: registry.module_closure().len(), + entities: registry.entities().len(), + routes: registry.routes().routes.len(), + access_entries: registry.access().entries.len(), + queries: registry.queries().operations.len(), + event_deliveries: registry.event_deliveries().deliveries.len(), + ddl_statements: registry.ddl().statements.len(), + generated_artifacts: registry.artifacts().entries().len(), + }, + }) +} + +fn migration_explain( + args: &MigrationExplainArgs, +) -> Result { + let inspected = inspect_runtime_package(&args.runtime_config) + .map_err(|error| inspection_failure("migration explain", "migration.explain", error))?; + Ok(MigrationExplainSuccessReport { + ok: true, + command: "migration explain", + assurance: BaselineAssurance::RuntimeBound, + package_revision: inspected.package_revision().to_owned(), + plan: inspected.migration_summary().clone(), + }) +} + +fn inspection_failure( + command: &'static str, + prefix: &'static str, + error: RuntimePackageInspectionError, +) -> FailureReport { + let (code, path, message, artifact, action) = match error { + RuntimePackageInspectionError::RuntimeConfigPath => ( + format!("{prefix}.runtime_config.path_invalid"), + "runtimeConfig", + "the runtime configuration path must be absolute", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + RuntimePackageInspectionError::RuntimeConfig(RuntimeConfigError::UnsafeFile) => ( + format!("{prefix}.runtime_config.path_invalid"), + "runtimeConfig", + "the runtime configuration path is unsafe", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + RuntimePackageInspectionError::RuntimeConfig(_) => ( + format!("{prefix}.runtime_config.refused"), + "runtimeConfig", + "the runtime configuration was refused", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + RuntimePackageInspectionError::Package(error) => { + let (suffix, action) = match error { + PackageError::UnsafePath => ("path_refused", SuggestedAction::VerifyPackagePath), + PackageError::Permissions => ( + "permissions_refused", + SuggestedAction::VerifyPackagePermissions, + ), + PackageError::Signature => { + ("signature_refused", SuggestedAction::VerifyPackageTrust) + } + PackageError::Binding => ("binding_refused", SuggestedAction::VerifyPackageBinding), + PackageError::Closure + | PackageError::Integrity + | PackageError::CanonicalJson + | PackageError::Derivation + | PackageError::MigrationPlan => { + ("integrity_refused", SuggestedAction::VerifyPackageIntegrity) + } + PackageError::Bounds | PackageError::Read => { + ("package_refused", SuggestedAction::VerifyPackageIntegrity) + } + }; + ( + format!("{prefix}.package.{suffix}"), + "package", + "the configured package was refused", + DiagnosticArtifact::VerifiedPackage, + action, + ) + } + }; + FailureReport { + ok: false, + command, + diagnostics: vec![tool_diagnostic( + diagnostic(&code, path, message), + artifact, + action, + )], + } +} + +fn runtime_config_diff_failure(error: RuntimeConfigError) -> FailureReport { + match error { + RuntimeConfigError::UnsafeFile => diff_failure( + "diff.runtime_config.path_invalid", + "runtimeConfig", + "the runtime configuration path is unsafe", + ), + _ => diff_failure( + "diff.runtime_config.refused", + "runtimeConfig", + "the runtime configuration was refused", + ), + } +} + +fn package_diff_failure(error: PackageError) -> FailureReport { + let (code, action) = match error { + PackageError::UnsafePath => ( + "diff.baseline.path_refused", + SuggestedAction::VerifyPackagePath, + ), + PackageError::Permissions => ( + "diff.baseline.permissions_refused", + SuggestedAction::VerifyPackagePermissions, + ), + PackageError::Signature => ( + "diff.baseline.signature_refused", + SuggestedAction::VerifyPackageTrust, + ), + PackageError::Binding => ( + "diff.baseline.binding_refused", + SuggestedAction::VerifyPackageBinding, + ), + PackageError::Closure + | PackageError::Integrity + | PackageError::CanonicalJson + | PackageError::Derivation + | PackageError::MigrationPlan => ( + "diff.baseline.integrity_refused", + SuggestedAction::VerifyPackageIntegrity, + ), + PackageError::Bounds | PackageError::Read => ( + "diff.baseline.package_refused", + SuggestedAction::VerifyPackageIntegrity, + ), + }; + diff_failure_with_action( + code, + "baseline", + "the baseline package was refused", + DiagnosticArtifact::BaselinePackage, + action, + ) +} + +fn diff_failure(code: &str, path: &str, message: &str) -> FailureReport { + diff_failure_with_action( + code, + path, + message, + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ) +} + +fn diff_failure_with_action( + code: &str, + path: &str, + message: &str, + artifact: DiagnosticArtifact, + action: SuggestedAction, +) -> FailureReport { + FailureReport { + ok: false, + command: "diff", + diagnostics: vec![tool_diagnostic( + diagnostic(code, path, message), + artifact, + action, + )], + } +} + +fn unsupported_diff_findings(diff: &CompiledRegistryDiff) -> Vec { + diff.changes + .iter() + .filter(|change| change.classification == DiffClassification::Unsupported) + .map(|change| Diagnostic { + severity: DiagnosticSeverity::Finding, + code: "diff.classification.unsupported".to_owned(), + path: diff_change_path(&change.change), + message: "the compiled change cannot be classified more precisely".to_owned(), + }) + .collect() +} + +fn diff_change_path(change: ®istry_server::package::CompiledRegistryChange) -> String { + match ( + change.target.entity_id.as_deref(), + change.target.member_id.as_deref(), + ) { + (Some(entity), Some(member)) => format!("changes.{entity}.{member}"), + (Some(entity), None) => format!("changes.{entity}"), + (None, _) => "changes.registry".to_owned(), + } +} + +fn requested_json(arguments: &[OsString]) -> bool { + arguments.iter().enumerate().any(|(index, argument)| { + argument == "--format=json" + || (argument == "--format" + && arguments.get(index + 1).is_some_and(|next| next == "json")) + }) +} + +fn profile(production: bool) -> ProfileArg { + if production { + ProfileArg::Production + } else { + ProfileArg::Authoring + } +} + +fn init(destination: &Path) -> Result { + let files = init_files(); + write_source_files(destination, &files).map_err(|diagnostic| FailureReport { + ok: false, + command: "init", + diagnostics: vec![tool_diagnostic( + diagnostic, + DiagnosticArtifact::ProjectInitialization, + SuggestedAction::ChooseSafeOutputDirectory, + )], + })?; + let compiled = compile(destination, ProfileArg::Authoring, "init")?; + Ok(SuccessReport { + ok: true, + command: "init", + profile: ProfileArg::Authoring, + revision: compiled.revision().to_owned(), + findings: compiler_findings(&compiled), + artifacts: files + .iter() + .map(|(path, bytes)| artifact_report(path, "text/yaml", bytes)) + .collect(), + explanation: None, + }) +} + +fn check(project_path: &Path, profile: ProfileArg) -> Result { + let compiled = compile(project_path, profile, "check")?; + Ok(SuccessReport { + ok: true, + command: "check", + profile, + revision: compiled.revision().to_owned(), + findings: compiler_findings(&compiled), + artifacts: Vec::new(), + explanation: None, + }) +} + +fn generate( + selector: ArtifactSelector, + project_path: &Path, + profile: ProfileArg, + output: &Path, +) -> Result { + let compiled = compile(project_path, profile, "generate")?; + let selected = + selected_artifacts(compiled.artifacts(), selector).map_err(|diagnostic| FailureReport { + ok: false, + command: "generate", + diagnostics: vec![tool_diagnostic( + diagnostic, + DiagnosticArtifact::GeneratedArtifacts, + SuggestedAction::SelectAvailableArtifact, + )], + })?; + write_artifacts(output, &selected).map_err(|diagnostic| FailureReport { + ok: false, + command: "generate", + diagnostics: vec![tool_diagnostic( + diagnostic, + DiagnosticArtifact::GeneratedArtifacts, + SuggestedAction::RetryArtifactGeneration, + )], + })?; + let artifacts = selected + .iter() + .map(|artifact| artifact_report(&artifact.path, &artifact.media_type, &artifact.bytes)) + .collect(); + Ok(SuccessReport { + ok: true, + command: "generate", + profile, + revision: compiled.revision().to_owned(), + findings: compiler_findings(&compiled), + artifacts, + explanation: None, + }) +} + +fn explain( + subject: ExplainSubject, + project_path: &Path, + profile: ProfileArg, +) -> Result { + let compiled = compile(project_path, profile, "explain")?; + let explanation = match subject { + ExplainSubject::Model => explain_model(&compiled), + ExplainSubject::Access => serde_json::to_value(compiled.access()), + ExplainSubject::Routes => serde_json::to_value(compiled.routes()), + ExplainSubject::Events => serde_json::to_value(compiled.event_deliveries()), + } + .map_err(|_| FailureReport { + ok: false, + command: "explain", + diagnostics: vec![tool_diagnostic( + diagnostic( + "explain.render.failed", + "explain", + "the compiled inventory could not be rendered", + ), + DiagnosticArtifact::CompiledInventory, + SuggestedAction::RetryInventoryExplanation, + )], + })?; + Ok(SuccessReport { + ok: true, + command: "explain", + profile, + revision: compiled.revision().to_owned(), + findings: compiler_findings(&compiled), + artifacts: Vec::new(), + explanation: Some(explanation), + }) +} + +fn compile( + project_path: &Path, + profile: ProfileArg, + command: &'static str, +) -> Result { + let source = capture_project_source(project_path).map_err(|diagnostic| { + source_failure( + command, + diagnostic, + DiagnosticArtifact::RegistryProject, + SuggestedAction::CorrectAuthoringSource, + ) + })?; + compile_captured_project(&source, profile, command) +} + +fn compile_captured_project( + source: &CapturedProjectSource, + profile: ProfileArg, + command: &'static str, +) -> Result { + let modules = source + .modules + .iter() + .map(|module| module.module.clone()) + .collect::>(); + compile_project(&source.project, &modules, profile.into()).map_err(|failure| FailureReport { + ok: false, + command, + diagnostics: failure + .diagnostics() + .iter() + .cloned() + .map(|diagnostic| { + tool_diagnostic( + diagnostic, + DiagnosticArtifact::RegistryProject, + SuggestedAction::CorrectAuthoringSource, + ) + }) + .collect(), + }) +} + +fn compiler_findings(compiled: &CompiledRegistry) -> Vec { + compiled + .findings() + .iter() + .cloned() + .map(|diagnostic| { + tool_diagnostic( + diagnostic, + DiagnosticArtifact::RegistryProject, + SuggestedAction::ReviewAuthoringFinding, + ) + }) + .collect() +} + +fn source_failure( + command: &'static str, + diagnostic: Diagnostic, + artifact: DiagnosticArtifact, + action: SuggestedAction, +) -> FailureReport { + FailureReport { + ok: false, + command, + diagnostics: vec![tool_diagnostic(diagnostic, artifact, action)], + } +} + +fn capture_project_source(project_path: &Path) -> Result { + validate_project_directory(project_path)?; + let project_bytes = read_bounded_regular_file( + &project_path.join("registry.yaml"), + "source.project.missing", + AUTHORED_SOURCE_REDERIVATION_MAX_BYTES, + )?; + let project = parse_project_yaml(&project_bytes).map_err(first_diagnostic)?; + let modules = load_module_files(project_path, &project)? + .into_iter() + .map(|(id, bytes)| { + let module = parse_module_yaml(&bytes).map_err(first_diagnostic)?; + Ok(CapturedModuleSource { id, module, bytes }) + }) + .collect::, Diagnostic>>()?; + Ok(CapturedProjectSource { + project, + project_bytes, + modules, + }) +} + +fn load_module_files( + project_path: &Path, + project: &RegistryProject, +) -> Result)>, Diagnostic> { + let modules_directory = project_path.join("modules"); + match fs::symlink_metadata(&modules_directory) { + Ok(_) => validate_directory(&modules_directory, "source.modules.invalid")?, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(_) => { + return Err(diagnostic( + "source.modules.unreadable", + "modules", + "module sources cannot be read", + )); + } + } + let locked: std::collections::BTreeSet<&str> = project + .modules + .iter() + .map(|module| module.id.as_str()) + .collect(); + let mut module_paths = Vec::new(); + for entry in fs::read_dir(&modules_directory).map_err(|_| { + diagnostic( + "source.modules.unreadable", + "modules", + "module sources cannot be read", + ) + })? { + let entry = entry.map_err(|_| { + diagnostic( + "source.modules.unreadable", + "modules", + "module sources cannot be read", + ) + })?; + let file_type = entry.file_type().map_err(|_| { + diagnostic( + "source.modules.unreadable", + "modules", + "module sources cannot be read", + ) + })?; + if file_type.is_symlink() || !file_type.is_dir() { + return Err(diagnostic( + "source.modules.invalid", + "modules", + "module sources must be directories and must not be symbolic links", + )); + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + return Err(diagnostic( + "source.modules.invalid", + "modules", + "module source names must be valid UTF-8 identifiers", + )); + }; + if !locked.contains(name) { + return Err(diagnostic( + "source.modules.unlocked", + "modules", + "every authored module directory must be declared by the project module lock", + )); + } + module_paths.push((name.to_owned(), entry.path().join("module.yaml"))); + } + module_paths.sort_by(|left, right| left.0.cmp(&right.0)); + module_paths + .into_iter() + .map(|(id, path)| { + let bytes = read_bounded_regular_file( + &path, + "source.module.missing", + AUTHORED_SOURCE_REDERIVATION_MAX_BYTES, + )?; + Ok((id, bytes)) + }) + .collect() +} + +fn init_files() -> BTreeMap> { + BTreeMap::from([ + ( + "registry.yaml".to_owned(), + br#"apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: generic-registry + version: 0.1.0 + defaultLanguage: en +manifestProjection: + accessProfile: operator + classificationCeiling: internal + catalog: + baseUrl: https://registry.example.test + title: Generic Registry Catalog + publisher: + name: Registry Operator + dataset: + title: Generic Registry Dataset + owner: Registry Operator + status: active +modules: + - id: core + version: 0.1.0 +entities: + - id: record + route: records + mutationMode: mutable + fields: + - id: code + type: string + required: true + maxLength: 64 + classification: internal + - id: label + type: string + required: true + maxLength: 200 + classification: internal + constraints: + - kind: unique + fields: [code] +accessProfiles: + - id: operator + principalClaim: registry_principal + purposes: [registry-operations] + grants: + - entity: record + actions: [create, get, list, patch] + readableFields: [code, label] + writableFields: [code, label] +"# + .to_vec(), + ), + ( + "modules/core/module.yaml".to_owned(), + br#"id: core +version: 0.1.0 +"# + .to_vec(), + ), + ( + FIXTURE_JOURNEYS_PATH.to_owned(), + br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: record-lifecycle + steps: + - id: create-record + entity: record + accessProfile: operator + claims: &operator_claims + principal: fixture-operator + purpose: registry-operations + request: + operation: create + data: {code: example, label: Example record} + expect: + outcome: success + status: 201 + fields: {code: example, label: Example record} + capture: example-record + - id: get-record + entity: record + accessProfile: operator + claims: *operator_claims + request: {operation: get, recordRef: example-record} + expect: + outcome: success + status: 200 + fields: {code: example, label: Example record} + - id: list-records + entity: record + accessProfile: operator + claims: *operator_claims + request: {operation: list} + expect: {outcome: success, status: 200, count: 1} +"# + .to_vec(), + ), + ]) +} + +fn selected_artifacts( + artifacts: &GeneratedArtifacts, + selector: ArtifactSelector, +) -> Result, Diagnostic> { + let selected: Vec<_> = artifacts + .entries() + .values() + .filter(|artifact| match selector { + ArtifactSelector::Openapi => artifact.path == "generated/openapi.json", + ArtifactSelector::Schemas => artifact.path.starts_with("generated/schemas/"), + ArtifactSelector::Manifest => { + artifact.path == "generated/manifest/registry-manifest.json" + } + ArtifactSelector::Metadata => artifact.path == "generated/metadata/registry.json", + ArtifactSelector::Sql => artifact.path == "generated/postgres/schema.sql", + }) + .cloned() + .collect(); + if selected.is_empty() { + return Err(diagnostic( + "artifact.selection.empty", + "artifacts", + "the selected artifact is unavailable for this compiled project", + )); + } + Ok(selected) +} + +fn artifact_report(path: &str, media_type: &str, bytes: &[u8]) -> ArtifactReport { + use sha2::{Digest, Sha256}; + + ArtifactReport { + path: path.to_owned(), + media_type: media_type.to_owned(), + sha256: hex_lower(&Sha256::digest(bytes)), + byte_length: bytes.len(), + } +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(HEX[usize::from(byte >> 4)] as char); + encoded.push(HEX[usize::from(byte & 0x0f)] as char); + } + encoded +} + +fn explain_model(compiled: &CompiledRegistry) -> serde_json::Result { + serde_json::to_value(json!({ + "registryId": compiled.registry_id(), + "version": compiled.version(), + "moduleOrder": compiled.module_order(), + "moduleClosure": compiled.module_closure(), + "entities": compiled.entities(), + "physicalNames": compiled.physical_names(), + "package": compiled.package(), + "manifestProjection": compiled.manifest_projection(), + })) +} + +fn validate_project_directory(project_path: &Path) -> Result<(), Diagnostic> { + if project_path.as_os_str().is_empty() || has_parent_component(project_path) { + return Err(diagnostic( + "source.project.path_unsafe", + "project", + "the project path must not contain parent-directory components", + )); + } + validate_directory(project_path, "source.project.invalid") +} + +fn validate_directory(path: &Path, code: &str) -> Result<(), Diagnostic> { + validate_directory_for( + path, + code, + "project", + "the project directory is not available", + "the project directory must be a directory and must not be a symbolic link", + ) +} + +fn validate_directory_for( + path: &Path, + code: &str, + report_path: &str, + unavailable_message: &str, + invalid_message: &str, +) -> Result<(), Diagnostic> { + let metadata = fs::symlink_metadata(path) + .map_err(|_| diagnostic(code, report_path, unavailable_message))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(diagnostic(code, report_path, invalid_message)); + } + ensure_no_symlink_components(path, code, report_path) +} + +fn read_bounded_regular_file( + path: &Path, + missing_code: &str, + bound: u64, +) -> Result, Diagnostic> { + let metadata = fs::symlink_metadata(path).map_err(|_| { + diagnostic( + missing_code, + "project", + "the required authoring source is not available", + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(diagnostic( + "source.file.invalid", + "project", + "authoring sources must be regular files and must not be symbolic links", + )); + } + ensure_no_symlink_components(path, "source.file.invalid", "project")?; + if metadata.len() > bound { + return Err(diagnostic( + "source.file.bounds", + "project", + "an authoring source exceeds its fixed size bound", + )); + } + let file = File::open(path).map_err(|_| { + diagnostic( + "source.file.unreadable", + "project", + "an authoring source cannot be read", + ) + })?; + let opened = file.metadata().map_err(|_| { + diagnostic( + "source.file.unreadable", + "project", + "an authoring source cannot be read", + ) + })?; + let after = fs::symlink_metadata(path).map_err(|_| { + diagnostic( + "source.file.invalid", + "project", + "authoring sources must be regular files and must not be symbolic links", + ) + })?; + if after.file_type().is_symlink() + || !opened.is_file() + || !same_file_metadata(&metadata, &opened) + || !same_file_metadata(&opened, &after) + { + return Err(diagnostic( + "source.file.invalid", + "project", + "authoring sources must be regular files and must not be symbolic links", + )); + } + if opened.len() > bound { + return Err(diagnostic( + "source.file.bounds", + "project", + "an authoring source exceeds its fixed size bound", + )); + } + let capacity = usize::try_from(opened.len()).map_err(|_| { + diagnostic( + "source.file.bounds", + "project", + "an authoring source exceeds its fixed size bound", + ) + })?; + let mut bytes = Vec::with_capacity(capacity); + file.take(bound.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| { + diagnostic( + "source.file.unreadable", + "project", + "an authoring source cannot be read", + ) + })?; + if bytes.len() as u64 > bound || bytes.len() as u64 != opened.len() { + return Err(diagnostic( + "source.file.bounds", + "project", + "an authoring source exceeds its fixed size bound", + )); + } + Ok(bytes) +} + +#[cfg(unix)] +fn same_file_metadata(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(not(unix))] +fn same_file_metadata(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.len() == right.len() + && left.modified().ok() == right.modified().ok() + && left.created().ok() == right.created().ok() +} + +fn write_source_files(output: &Path, files: &BTreeMap>) -> Result<(), Diagnostic> { + write_files_with_before_publish(output, files, |_| Ok(())) +} + +fn write_artifacts(output: &Path, artifacts: &[GeneratedArtifact]) -> Result<(), Diagnostic> { + let files = artifacts + .iter() + .map(|artifact| (artifact.path.clone(), artifact.bytes.clone())) + .collect(); + write_files_with_before_publish(output, &files, |_| Ok(())) +} + +#[cfg(test)] +fn write_artifacts_with_before_publish( + output: &Path, + artifacts: &GeneratedArtifacts, + before_publish: impl FnOnce(&Path) -> Result<(), Diagnostic>, +) -> Result<(), Diagnostic> { + let files = artifacts + .entries() + .values() + .map(|artifact| (artifact.path.clone(), artifact.bytes.clone())) + .collect(); + write_files_with_before_publish(output, &files, before_publish) +} + +fn write_files_with_before_publish( + output: &Path, + files: &BTreeMap>, + before_publish: impl FnOnce(&Path) -> Result<(), Diagnostic>, +) -> Result<(), Diagnostic> { + if output.as_os_str().is_empty() + || has_parent_component(output) + || output.file_name().is_none() + || output.exists() + { + return Err(diagnostic( + "output.destination.invalid", + "output", + "the output directory must be a new path without parent-directory components", + )); + } + let parent = output.parent().unwrap_or_else(|| Path::new(".")); + validate_directory_for( + parent, + "output.parent.invalid", + "output.parent", + "the output parent directory is not available", + "the output parent must be a directory and must not be a symbolic link", + )?; + ensure_no_symlink_components(output, "output.destination.invalid", "output")?; + + let staged = create_staging_directory(parent)?; + let result = (|| { + for (relative_path, bytes) in files { + let path = safe_artifact_path(&staged, relative_path)?; + if let Some(directory) = path.parent() { + fs::create_dir_all(directory).map_err(|_| { + diagnostic( + "output.write.failed", + "output", + "a generated artifact could not be written", + ) + })?; + } + let mut file = File::options() + .write(true) + .create_new(true) + .open(&path) + .map_err(|_| { + diagnostic( + "output.write.failed", + "output", + "a generated artifact could not be written", + ) + })?; + file.write_all(bytes).map_err(|_| { + diagnostic( + "output.write.failed", + "output", + "a generated artifact could not be written", + ) + })?; + file.sync_all().map_err(|_| { + diagnostic( + "output.write.failed", + "output", + "a generated artifact could not be written", + ) + })?; + } + before_publish(output)?; + publish_staged_directory(&staged, output) + })(); + if result.is_err() && staged.exists() { + let _ = fs::remove_dir_all(&staged); + } + result +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn publish_staged_directory(staged: &Path, output: &Path) -> Result<(), Diagnostic> { + use rustix::fs::{renameat_with, RenameFlags, CWD}; + + renameat_with(CWD, staged, CWD, output, RenameFlags::NOREPLACE).map_err(|_| { + diagnostic( + "output.publish.failed", + "output", + "the generated artifact directory could not be published", + ) + }) +} + +#[cfg(target_os = "windows")] +fn publish_staged_directory(staged: &Path, output: &Path) -> Result<(), Diagnostic> { + fs::rename(staged, output).map_err(|_| { + diagnostic( + "output.publish.failed", + "output", + "the generated artifact directory could not be published", + ) + }) +} + +#[cfg(not(any(target_os = "linux", target_vendor = "apple", target_os = "windows")))] +fn publish_staged_directory(_staged: &Path, _output: &Path) -> Result<(), Diagnostic> { + Err(diagnostic( + "output.publish.unsupported", + "output", + "atomic no-replace directory publication is unavailable on this platform", + )) +} + +fn create_staging_directory(parent: &Path) -> Result { + for _ in 0..64 { + let counter = STAGING_COUNTER.fetch_add(1, Ordering::Relaxed); + let staged = parent.join(format!( + ".registry-serverctl-stage-{}-{counter}", + std::process::id() + )); + match fs::create_dir(&staged) { + Ok(()) => return Ok(staged), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(_) => { + return Err(diagnostic( + "output.stage.failed", + "output", + "a staged output directory could not be created", + )); + } + } + } + Err(diagnostic( + "output.stage.failed", + "output", + "a staged output directory could not be created", + )) +} + +fn safe_artifact_path(root: &Path, artifact_path: &str) -> Result { + let path = Path::new(artifact_path); + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(diagnostic( + "artifact.path.invalid", + "artifacts", + "the compiler returned an unsafe artifact path", + )); + } + Ok(root.join(path)) +} + +fn has_parent_component(path: &Path) -> bool { + path.components() + .any(|component| matches!(component, Component::ParentDir)) +} + +fn ensure_no_symlink_components( + path: &Path, + code: &str, + report_path: &str, +) -> Result<(), Diagnostic> { + let mut checked = if path.is_absolute() { + PathBuf::from(std::path::MAIN_SEPARATOR_STR) + } else { + PathBuf::new() + }; + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir | Component::CurDir => continue, + Component::ParentDir => { + return Err(diagnostic( + code, + report_path, + "paths must not contain parent-directory components", + )); + } + Component::Normal(part) => checked.push(part), + } + match fs::symlink_metadata(&checked) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(diagnostic( + code, + report_path, + "paths must not traverse symbolic links", + )); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => { + return Err(diagnostic( + code, + report_path, + "paths cannot be inspected safely", + )); + } + } + } + Ok(()) +} + +fn first_diagnostic(failure: CompileFailure) -> Diagnostic { + failure.diagnostics().first().cloned().unwrap_or_else(|| { + diagnostic( + "source.invalid", + "project", + "the authoring source is invalid", + ) + }) +} + +fn tool_diagnostic( + diagnostic: Diagnostic, + artifact: DiagnosticArtifact, + suggested_action: SuggestedAction, +) -> ToolDiagnostic { + ToolDiagnostic { + severity: diagnostic.severity, + code: diagnostic.code, + artifact, + path: diagnostic.path, + message: diagnostic.message, + suggested_action, + } +} + +fn diagnostic(code: &str, path: &str, message: &str) -> Diagnostic { + Diagnostic { + severity: DiagnosticSeverity::Error, + code: code.to_owned(), + path: path.to_owned(), + message: message.to_owned(), + } +} + +fn write_success( + report: &SuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "{} succeeded", report.command).and_then(|()| { + writeln!(stdout, "revision: {}", report.revision)?; + for finding in &report.findings { + writeln!( + stdout, + "finding {} at {}: {}", + finding.code, finding.path, finding.message + )?; + } + if !report.artifacts.is_empty() { + writeln!(stdout, "artifacts: {}", report.artifacts.len())?; + } + if let Some(explanation) = &report.explanation { + let rendered = + serde_json::to_string_pretty(explanation).map_err(io::Error::other)?; + writeln!(stdout, "{rendered}")?; + } + Ok(()) + }) + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(_) => { + let _ = writeln!(stderr, "registry-serverctl: output could not be written"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + +fn write_doctor_success( + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let report = DoctorSuccessReport { + ok: true, + command: "doctor", + }; + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, &report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "doctor succeeded") + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(_) => { + let _ = writeln!(stderr, "registry-serverctl: output could not be written"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + +fn write_verify_success( + report: &VerifySuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "{} succeeded", report.command).and_then(|()| { + writeln!(stdout, "assurance: runtime_bound")?; + writeln!(stdout, "package revision: {}", report.package_revision)?; + writeln!(stdout, "registry id: {}", report.registry.id)?; + writeln!(stdout, "registry version: {}", report.registry.version)?; + writeln!(stdout, "registry revision: {}", report.registry.revision)?; + writeln!(stdout, "modules: {}", report.inventory.modules)?; + writeln!(stdout, "entities: {}", report.inventory.entities)?; + writeln!(stdout, "routes: {}", report.inventory.routes)?; + writeln!( + stdout, + "access entries: {}", + report.inventory.access_entries + )?; + writeln!(stdout, "queries: {}", report.inventory.queries)?; + writeln!( + stdout, + "event deliveries: {}", + report.inventory.event_deliveries + )?; + writeln!( + stdout, + "DDL statements: {}", + report.inventory.ddl_statements + )?; + writeln!( + stdout, + "generated artifacts: {}", + report.inventory.generated_artifacts + ) + }) + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(_) => { + let _ = writeln!(stderr, "registry-serverctl: output could not be written"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + +fn write_package_success( + report: &PackageSuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "package succeeded").and_then(|()| { + writeln!(stdout, "profile: production")?; + writeln!( + stdout, + "state: {}", + match report.state { + PackageReportState::AwaitingSignatures => "awaiting_signatures", + PackageReportState::Published => "published", + } + )?; + writeln!(stdout, "package revision: {}", report.package_revision)?; + writeln!( + stdout, + "signature threshold: {}", + report.signature_threshold + )?; + writeln!( + stdout, + "provided signatures: {}", + report.provided_signatures + )?; + writeln!(stdout, "package files: {}", report.package_files)?; + writeln!( + stdout, + "signing input sha256: {}", + report.signing_input.sha256 + )?; + writeln!( + stdout, + "signing input bytes: {}", + report.signing_input.byte_length + ) + }) + }; + write_result(result, stderr) +} + +fn write_schema_test_success( + report: &SchemaTestSuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "test succeeded").and_then(|()| { + writeln!(stdout, "profile: production")?; + writeln!(stdout, "package revision: {}", report.package_revision)?; + writeln!(stdout, "schema fingerprint: {}", report.schema_fingerprint)?; + writeln!( + stdout, + "signing input sha256: {}", + report.signing_input_sha256 + )?; + writeln!( + stdout, + "successful journeys: {}", + report.successful_journey_ids.join(",") + )?; + writeln!(stdout, "receipt sha256: {}", report.receipt.sha256)?; + writeln!(stdout, "receipt bytes: {}", report.receipt.byte_length) + }) + }; + write_result(result, stderr) +} + +fn write_apply_success( + report: &ApplySuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "apply succeeded").and_then(|()| { + writeln!( + stdout, + "activation: {}", + match report.activation { + ApplyActivation::Initial => "initial", + ApplyActivation::Successor => "successor", + } + )?; + writeln!(stdout, "package revision: {}", report.package_revision)?; + writeln!(stdout, "schema fingerprint: {}", report.schema_fingerprint)?; + writeln!(stdout, "package sequence: {}", report.package_sequence) + }) + }; + write_result(result, stderr) +} + +fn write_result(result: io::Result<()>, stderr: &mut dyn Write) -> ExitCode { + match result { + Ok(()) => ExitCode::SUCCESS, + Err(_) => { + let _ = writeln!(stderr, "registry-serverctl: output could not be written"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + +fn write_migration_explain_success( + report: &MigrationExplainSuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + write_migration_explain_human(report, stdout) + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(_) => { + let _ = writeln!(stderr, "registry-serverctl: output could not be written"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + +fn write_data_validate_success( + report: &DataValidateSuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "data validate succeeded").and_then(|()| { + writeln!(stdout, "package revision: {}", report.package_revision)?; + writeln!(stdout, "schema fingerprint: {}", report.schema_fingerprint)?; + writeln!(stdout, "entity: {}", report.entity_id)?; + writeln!(stdout, "profile: {}", report.profile_id)?; + writeln!( + stdout, + "operation: {}", + data_operation_name(report.operation) + )?; + writeln!(stdout, "input bytes: {}", report.input_length)?; + writeln!(stdout, "items: {}", report.item_count)?; + writeln!(stdout, "chunks: {}", report.chunk_count)?; + writeln!(stdout, "maximum items: {}", report.maximum_items)?; + writeln!(stdout, "maximum bytes: {}", report.maximum_bytes) + }) + }; + write_result(result, stderr) +} + +fn write_data_import_success( + report: &DataImportSuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "data import succeeded").and_then(|()| { + writeln!(stdout, "package revision: {}", report.package_revision)?; + writeln!(stdout, "schema fingerprint: {}", report.schema_fingerprint)?; + writeln!(stdout, "entity: {}", report.entity_id)?; + writeln!(stdout, "profile: {}", report.profile_id)?; + writeln!( + stdout, + "operation: {}", + data_operation_name(report.operation) + )?; + writeln!(stdout, "input bytes: {}", report.input_length)?; + writeln!(stdout, "items: {}", report.item_count)?; + writeln!(stdout, "completed chunks: {}", report.completed_chunk_count)?; + writeln!(stdout, "committed items: {}", report.committed_items)?; + writeln!(stdout, "complete: {}", report.complete) + }) + }; + write_result(result, stderr) +} + +fn write_data_export_success( + report: &DataExportSuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "data export succeeded").and_then(|()| { + writeln!(stdout, "package revision: {}", report.package_revision)?; + writeln!(stdout, "schema fingerprint: {}", report.schema_fingerprint)?; + writeln!(stdout, "entity: {}", report.entity_id)?; + writeln!(stdout, "profile: {}", report.profile_id)?; + writeln!(stdout, "fields: {}", report.requested_fields.join(","))?; + writeln!(stdout, "completed pages: {}", report.completed_page_count)?; + writeln!(stdout, "records: {}", report.record_count)?; + writeln!(stdout, "output bytes: {}", report.output_length)?; + writeln!(stdout, "complete: {}", report.complete) + }) + }; + write_result(result, stderr) +} + +fn data_operation_name(operation: DataOperationArg) -> &'static str { + match operation { + DataOperationArg::Create => "create", + DataOperationArg::Patch => "patch", + } +} + +fn write_migration_explain_human( + report: &MigrationExplainSuccessReport, + stdout: &mut dyn Write, +) -> io::Result<()> { + let plan = &report.plan; + let counts = plan.change_counts(); + writeln!(stdout, "migration explain succeeded")?; + writeln!(stdout, "assurance: runtime_bound")?; + writeln!(stdout, "package revision: {}", report.package_revision)?; + writeln!(stdout, "plan kind: {}", plan_kind_name(plan.plan_kind()))?; + writeln!(stdout, "has prior revision: {}", plan.has_prior_revision())?; + writeln!(stdout, "has prior baseline: {}", plan.has_prior_baseline())?; + writeln!(stdout, "change count: {}", plan.change_count())?; + writeln!( + stdout, + "compatible additive changes: {}", + counts.compatible_additive() + )?; + writeln!( + stdout, + "data backfill required changes: {}", + counts.data_backfill_required() + )?; + writeln!( + stdout, + "access or disclosure changes: {}", + counts.access_or_disclosure_change() + )?; + writeln!( + stdout, + "destructive or irreversible changes: {}", + counts.destructive_or_irreversible() + )?; + writeln!(stdout, "unsupported changes: {}", counts.unsupported())?; + writeln!( + stdout, + "generated statement count: {}", + plan.generated_statement_count() + )?; + writeln!( + stdout, + "reviewed migration count: {}", + plan.reviewed_migrations().len() + )?; + for (index, migration) in plan.reviewed_migrations().iter().enumerate() { + let number = index + 1; + writeln!( + stdout, + "reviewed migration {number} change class: {}", + change_class_name(migration.change_class()) + )?; + writeln!( + stdout, + "reviewed migration {number} recovery: {}", + recovery_name(migration.recovery()) + )?; + writeln!( + stdout, + "reviewed migration {number} lock timeout ms: {}", + migration.lock_timeout_ms() + )?; + writeln!( + stdout, + "reviewed migration {number} statement timeout ms: {}", + migration.statement_timeout_ms() + )?; + writeln!( + stdout, + "reviewed migration {number} transactional step count: {}", + migration.transactional_step_count() + )?; + writeln!( + stdout, + "reviewed migration {number} chunked step count: {}", + migration.chunked_step_count() + )?; + writeln!( + stdout, + "reviewed migration {number} pre-assertion count: {}", + migration.pre_assertion_count() + )?; + writeln!( + stdout, + "reviewed migration {number} post-assertion count: {}", + migration.post_assertion_count() + )?; + writeln!( + stdout, + "reviewed migration {number} backup required: {}", + migration.backup_required() + )?; + if let Some(bounds) = migration.chunked_step_bounds() { + writeln!( + stdout, + "reviewed migration {number} minimum chunk size: {}", + bounds.minimum_chunk_size() + )?; + writeln!( + stdout, + "reviewed migration {number} maximum chunk size: {}", + bounds.maximum_chunk_size() + )?; + writeln!( + stdout, + "reviewed migration {number} maximum total rows: {}", + bounds.maximum_total_rows() + )?; + } + } + Ok(()) +} + +fn plan_kind_name(kind: MigrationInspectionPlanKind) -> &'static str { + match kind { + MigrationInspectionPlanKind::Initial => "initial", + MigrationInspectionPlanKind::CompatibleAdditive => "compatible_additive", + MigrationInspectionPlanKind::Reviewed => "reviewed", + } +} + +fn change_class_name(class: CompiledRegistryChangeClass) -> &'static str { + match class { + CompiledRegistryChangeClass::CompatibleAdditive => "compatible_additive", + CompiledRegistryChangeClass::DataBackfillRequired => "data_backfill_required", + CompiledRegistryChangeClass::AccessOrDisclosureChange => "access_or_disclosure_change", + CompiledRegistryChangeClass::DestructiveOrIrreversible => "destructive_or_irreversible", + CompiledRegistryChangeClass::Unsupported => "unsupported", + } +} + +fn recovery_name(recovery: ReviewedMigrationRecovery) -> &'static str { + match recovery { + ReviewedMigrationRecovery::ExactTargetResume => "exact_target_resume", + } +} + +fn write_diff_success( + report: &DiffSuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "diff succeeded").and_then(|()| { + writeln!(stdout, "profile: authoring")?; + writeln!( + stdout, + "baseline assurance: {}", + match report.baseline_assurance { + BaselineAssurance::RuntimeBound => "runtime_bound", + BaselineAssurance::IntegrityOnly => "integrity_only", + } + )?; + writeln!( + stdout, + "baseline package revision: {}", + report.diff.baseline_package_revision + )?; + writeln!( + stdout, + "baseline registry revision: {}", + report.diff.baseline_registry_revision + )?; + writeln!( + stdout, + "candidate registry revision: {}", + report.diff.candidate_registry_revision + )?; + writeln!(stdout, "changes: {}", report.diff.changes.len())?; + for change in &report.diff.changes { + let rendered = serde_json::to_string(change).map_err(io::Error::other)?; + writeln!(stdout, "change: {rendered}")?; + } + for finding in &report.findings { + writeln!( + stdout, + "finding {} at {}: {}", + finding.code, finding.path, finding.message + )?; + } + Ok(()) + }) + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(_) => { + let _ = writeln!(stderr, "registry-serverctl: output could not be written"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + +fn write_failure( + report: &FailureReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + report.diagnostics.iter().try_for_each(|diagnostic| { + writeln!( + stderr, + "error {} at {}: {}", + diagnostic.code, diagnostic.path, diagnostic.message + ) + }) + }; + if result.is_err() { + let _ = writeln!(stderr, "registry-serverctl: output could not be written"); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + ExitCode::from(DOMAIN_REFUSAL_EXIT) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestDirectory { + path: PathBuf, + } + + impl TestDirectory { + fn create() -> Self { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!( + ".registry-serverctl-unit-test-{}-{}", + std::process::id(), + STAGING_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).expect("test directory is created"); + Self { path } + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + if self.path.exists() { + fs::remove_dir_all(&self.path).expect("test directory is removed"); + } + } + } + + #[test] + fn public_command_surface_is_explicit() { + let command = command(); + let names: Vec<_> = command + .get_subcommands() + .filter(|command| !command.is_hide_set() && command.get_name() != "help") + .map(clap::Command::get_name) + .collect(); + assert_eq!( + names, + [ + "init", + "check", + "generate", + "explain", + "diff", + "package", + "test", + "apply", + "doctor", + "verify", + "migration", + "data" + ] + ); + } + + #[test] + fn global_format_is_accepted_before_or_after_the_subcommand() { + for arguments in [ + vec!["registry-serverctl", "--format", "json", "check", "project"], + vec!["registry-serverctl", "check", "project", "--format", "json"], + ] { + assert!(Cli::try_parse_from(arguments).is_ok()); + } + } + + #[test] + fn doctor_success_output_is_stable_in_human_and_machine_formats() { + for (format, expected) in [ + (OutputFormat::Human, "doctor succeeded\n"), + ( + OutputFormat::Json, + "{\n \"ok\": true,\n \"command\": \"doctor\"\n}\n", + ), + ] { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + assert_eq!( + write_doctor_success(format, &mut stdout, &mut stderr), + ExitCode::SUCCESS + ); + assert_eq!( + String::from_utf8(stdout).expect("output is UTF-8"), + expected + ); + assert!(stderr.is_empty()); + } + } + + #[test] + fn legacy_json_profile_and_full_generate_forms_are_not_accepted() { + for arguments in [ + vec!["registry-serverctl", "--json", "check", "project"], + vec![ + "registry-serverctl", + "check", + "project", + "--profile", + "production", + ], + vec![ + "registry-serverctl", + "generate", + "project", + "--output", + "out", + ], + ] { + assert!(Cli::try_parse_from(arguments).is_err()); + } + } + + #[test] + fn publication_refuses_a_destination_created_after_staging() { + let project = parse_project_yaml( + br#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: example-registry + version: 0.1.0 + defaultLanguage: en +entities: + - id: record + route: records + mutationMode: mutable + fields: + - id: code + type: string + required: true + maxLength: 64 + classification: internal +accessProfiles: + - id: operator + principalClaim: registry_principal + purposes: [operations] + grants: + - entity: record + actions: [create, get, list, patch] + readableFields: [code] + writableFields: [code] +"#, + ) + .expect("domain-neutral test project parses"); + let compiled = compile_project(&project, &[], CompileProfile::Authoring) + .expect("domain-neutral test project compiles"); + let directory = TestDirectory::create(); + let destination = directory.path.join("output"); + + let failure = write_artifacts_with_before_publish( + &destination, + compiled.artifacts(), + |destination| { + fs::create_dir(destination).map_err(|_| { + diagnostic( + "test.setup.failed", + "test", + "the test destination could not be created", + ) + })?; + fs::write(destination.join("preserved.txt"), b"preserved").map_err(|_| { + diagnostic( + "test.setup.failed", + "test", + "the test destination could not be written", + ) + }) + }, + ) + .expect_err("publication must not replace a destination created after staging"); + + assert_eq!(failure.code, "output.publish.failed"); + assert_eq!( + fs::read(destination.join("preserved.txt")).expect("existing destination is intact"), + b"preserved" + ); + assert!(fs::read_dir(&directory.path) + .expect("test directory is readable") + .all(|entry| { + !entry + .expect("test directory entry is readable") + .file_name() + .to_string_lossy() + .starts_with(".registry-serverctl-stage-") + })); + } +} diff --git a/crates/registry-serverctl/src/main.rs b/crates/registry-serverctl/src/main.rs new file mode 100644 index 0000000000..761edd46ba --- /dev/null +++ b/crates/registry-serverctl/src/main.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +fn main() -> ExitCode { + registry_serverctl::main_entry() +} diff --git a/crates/registry-serverctl/src/package_inspection.rs b/crates/registry-serverctl/src/package_inspection.rs new file mode 100644 index 0000000000..787f806942 --- /dev/null +++ b/crates/registry-serverctl/src/package_inspection.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Runtime-bound, read-only package inspection shared by CLI operations. + +use std::path::Path; + +use registry_server::package::{ + inspect_package_with_context, IntegrityInspectedPackage, PackageError, PackageInspectionContext, +}; +use registry_server::runtime_config::{load_runtime_config, RuntimeConfigError}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RuntimePackageInspectionError { + RuntimeConfigPath, + RuntimeConfig(RuntimeConfigError), + Package(PackageError), +} + +/// Inspect exactly the package selected and bound by one strict runtime +/// configuration. This opens no database, OIDC source, or listener. +pub(crate) fn inspect_runtime_package( + runtime_config: &Path, +) -> Result { + if !runtime_config.is_absolute() { + return Err(RuntimePackageInspectionError::RuntimeConfigPath); + } + let config = load_runtime_config(runtime_config) + .map_err(RuntimePackageInspectionError::RuntimeConfig)?; + let context = PackageInspectionContext { + environment: config.identity().environment(), + instance_id: config.identity().instance_id(), + database_id: config.identity().database_id(), + database_initialization_environment: config + .identity() + .database_initialization_environment(), + compiler_source_revision: config.package().compiler_source_revision(), + trust_anchor: config.package_trust_anchor(), + expected_package_revision: config.package().active_revision(), + expected_sequence: config.package().active_sequence(), + }; + inspect_package_with_context(config.package().root(), &context) + .map_err(RuntimePackageInspectionError::Package) +} diff --git a/crates/registry-serverctl/src/package_lifecycle.rs b/crates/registry-serverctl/src/package_lifecycle.rs new file mode 100644 index 0000000000..51fdb7fa0d --- /dev/null +++ b/crates/registry-serverctl/src/package_lifecycle.rs @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Deterministic package signing-input and publication orchestration. + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::Read; +use std::path::Path; + +use registry_platform_canonical_json::parse_json_strict; +use registry_server::fixtures::{ + validate_fixture_journeys, validate_schema_test_receipt_for_package, +}; +use registry_server::package::{ + PackageError, PackageSignature, PreparedPackage, FIXTURE_JOURNEYS_PATH, +}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +const SIGNING_INPUT_PATH: &str = "signing-input.json"; +const TEST_RECEIPT_PATH: &str = "schema-test-receipt.json"; +const PACKAGE_DIRECTORY: &str = "package"; +const MAX_SIGNATURE_DOCUMENT_BYTES: u64 = 1024 * 1024; +const MAX_TEST_RECEIPT_BYTES: u64 = 64 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PackageLifecycleState { + AwaitingSignatures, + Published, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PackageLifecycleOutcome { + pub state: PackageLifecycleState, + pub package_revision: String, + pub signing_input_sha256: String, + pub signing_input_bytes: usize, + pub signature_threshold: u16, + pub provided_signatures: usize, + pub package_files: usize, +} + +/// Canonical receipt bytes that have been rederived against one exact +/// in-memory candidate. No unchecked constructor is exposed. +pub(crate) struct ValidatedTestReceipt { + bytes: Vec, +} + +#[derive(Debug)] +pub(crate) enum PackageLifecycleError { + Package(PackageError), + Output, + SignatureDocument, + TestReceiptMissing, + TestReceiptRefused, + TestReceiptEvidence, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SignatureDocument { + signatures: Vec, +} + +pub(crate) fn run( + prepared: PreparedPackage, + test_receipt: ValidatedTestReceipt, + build_directory: &Path, + signature_document: Option<&Path>, +) -> Result { + let signed_bytes = prepared.canonical_signed_bytes(); + ensure_reviewer_evidence(build_directory, signed_bytes, &test_receipt.bytes)?; + + let signatures = signature_document + .map(read_signatures) + .transpose()? + .unwrap_or_default(); + let threshold = prepared.manifest().signature_policy.threshold; + let requires_external_signatures = prepared.manifest().environment != "local"; + if requires_external_signatures && signature_document.is_none() { + return Ok(outcome( + &prepared, + PackageLifecycleState::AwaitingSignatures, + 0, + )); + } + + prepared + .publish_to_directory(&build_directory.join(PACKAGE_DIRECTORY), signatures.clone()) + .map_err(PackageLifecycleError::Package)?; + Ok(PackageLifecycleOutcome { + state: PackageLifecycleState::Published, + package_revision: prepared.package_revision().to_owned(), + signing_input_sha256: sha256(signed_bytes), + signing_input_bytes: signed_bytes.len(), + signature_threshold: threshold, + provided_signatures: signatures.len(), + package_files: prepared.file_bytes().len() + 1, + }) +} + +pub(crate) fn validate_test_receipt( + path: &Path, + prepared: &PreparedPackage, +) -> Result { + let bytes = read_test_receipt(path)?; + let journeys = prepared + .file_bytes() + .get(FIXTURE_JOURNEYS_PATH) + .ok_or(PackageLifecycleError::TestReceiptRefused)?; + let suite = validate_fixture_journeys(journeys, prepared.registry()) + .map_err(|_| PackageLifecycleError::TestReceiptRefused)?; + let receipt = validate_schema_test_receipt_for_package(&bytes, prepared, &suite) + .map_err(|_| PackageLifecycleError::TestReceiptRefused)?; + let canonical = receipt + .canonical_bytes() + .map_err(|_| PackageLifecycleError::TestReceiptRefused)?; + if canonical != bytes { + return Err(PackageLifecycleError::TestReceiptRefused); + } + Ok(ValidatedTestReceipt { bytes }) +} + +fn outcome( + prepared: ®istry_server::package::PreparedPackage, + state: PackageLifecycleState, + provided_signatures: usize, +) -> PackageLifecycleOutcome { + let signed_bytes = prepared.canonical_signed_bytes(); + PackageLifecycleOutcome { + state, + package_revision: prepared.package_revision().to_owned(), + signing_input_sha256: sha256(signed_bytes), + signing_input_bytes: signed_bytes.len(), + signature_threshold: prepared.manifest().signature_policy.threshold, + provided_signatures, + package_files: prepared.file_bytes().len() + 1, + } +} + +fn ensure_reviewer_evidence( + build_directory: &Path, + expected_signing_input: &[u8], + expected_test_receipt: &[u8], +) -> Result<(), PackageLifecycleError> { + if build_directory.exists() { + super::validate_directory_for( + build_directory, + "package.output.invalid", + "output", + "the package build directory is unavailable", + "the package build path must be a directory and must not be a symbolic link", + ) + .map_err(|_| PackageLifecycleError::Output)?; + let existing_signing_input = + read_bounded_regular(&build_directory.join(SIGNING_INPUT_PATH))?; + let existing_test_receipt = read_bounded_regular_with_bound( + &build_directory.join(TEST_RECEIPT_PATH), + MAX_TEST_RECEIPT_BYTES, + ) + .map_err(|_| PackageLifecycleError::TestReceiptEvidence)?; + if existing_test_receipt != expected_test_receipt { + return Err(PackageLifecycleError::TestReceiptEvidence); + } + if existing_signing_input != expected_signing_input + || build_directory.join(PACKAGE_DIRECTORY).exists() + { + return Err(PackageLifecycleError::Output); + } + return Ok(()); + } + let files = BTreeMap::from([ + ( + SIGNING_INPUT_PATH.to_owned(), + expected_signing_input.to_vec(), + ), + (TEST_RECEIPT_PATH.to_owned(), expected_test_receipt.to_vec()), + ]); + super::write_source_files(build_directory, &files).map_err(|_| PackageLifecycleError::Output) +} + +fn read_signatures(path: &Path) -> Result, PackageLifecycleError> { + let bytes = read_bounded_regular(path)?; + let value = parse_json_strict(&bytes).map_err(|_| PackageLifecycleError::SignatureDocument)?; + let document: SignatureDocument = + serde_json::from_value(value).map_err(|_| PackageLifecycleError::SignatureDocument)?; + if document.signatures.is_empty() || document.signatures.len() > 128 { + return Err(PackageLifecycleError::SignatureDocument); + } + Ok(document.signatures) +} + +fn read_bounded_regular(path: &Path) -> Result, PackageLifecycleError> { + read_bounded_regular_with_bound(path, MAX_SIGNATURE_DOCUMENT_BYTES) +} + +fn read_bounded_regular_with_bound( + path: &Path, + bound: u64, +) -> Result, PackageLifecycleError> { + if path.as_os_str().is_empty() || super::has_parent_component(path) { + return Err(PackageLifecycleError::Output); + } + super::ensure_no_symlink_components(path, "package.input.invalid", "package") + .map_err(|_| PackageLifecycleError::Output)?; + let metadata = fs::symlink_metadata(path).map_err(|_| PackageLifecycleError::Output)?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() == 0 + || metadata.len() > bound + { + return Err(PackageLifecycleError::Output); + } + fs::read(path).map_err(|_| PackageLifecycleError::Output) +} + +fn read_test_receipt(path: &Path) -> Result, PackageLifecycleError> { + if !path.is_absolute() || super::has_parent_component(path) { + return Err(PackageLifecycleError::TestReceiptRefused); + } + super::ensure_no_symlink_components(path, "package.test_receipt.refused", "testReceipt") + .map_err(|_| PackageLifecycleError::TestReceiptRefused)?; + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(PackageLifecycleError::TestReceiptMissing) + } + Err(_) => return Err(PackageLifecycleError::TestReceiptRefused), + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() == 0 + || metadata.len() > MAX_TEST_RECEIPT_BYTES + { + return Err(PackageLifecycleError::TestReceiptRefused); + } + let file = File::open(path).map_err(|_| PackageLifecycleError::TestReceiptRefused)?; + let opened = file + .metadata() + .map_err(|_| PackageLifecycleError::TestReceiptRefused)?; + let after = + fs::symlink_metadata(path).map_err(|_| PackageLifecycleError::TestReceiptRefused)?; + if after.file_type().is_symlink() + || !opened.is_file() + || !super::same_file_metadata(&metadata, &opened) + || !super::same_file_metadata(&opened, &after) + || opened.len() > MAX_TEST_RECEIPT_BYTES + { + return Err(PackageLifecycleError::TestReceiptRefused); + } + let capacity = + usize::try_from(opened.len()).map_err(|_| PackageLifecycleError::TestReceiptRefused)?; + let mut bytes = Vec::with_capacity(capacity); + file.take(MAX_TEST_RECEIPT_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| PackageLifecycleError::TestReceiptRefused)?; + if bytes.len() as u64 != opened.len() || bytes.len() as u64 > MAX_TEST_RECEIPT_BYTES { + return Err(PackageLifecycleError::TestReceiptRefused); + } + Ok(bytes) +} + +fn sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut encoded = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + write!(encoded, "{byte:02x}").expect("writing to a String cannot fail"); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signature_document_is_closed_and_never_accepts_an_empty_approval_set() { + for refused in [ + br#"{"signatures":[]}"#.as_slice(), + br#"{"signatures":[],"privateKey":"canary"}"#.as_slice(), + br#"[{"keyId":"operator","signatureHex":"00"}]"#.as_slice(), + ] { + let parsed = serde_json::from_slice::(refused); + assert!(parsed.is_err() || parsed.is_ok_and(|document| document.signatures.is_empty())); + } + } +} diff --git a/crates/registry-serverctl/src/test_lifecycle.rs b/crates/registry-serverctl/src/test_lifecycle.rs new file mode 100644 index 0000000000..d79aa31d68 --- /dev/null +++ b/crates/registry-serverctl/src/test_lifecycle.rs @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Production schema-test orchestration for unsigned package candidates. + +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use registry_server::fixtures::{ + execute_schema_test, validate_fixture_journeys, FixtureError, SchemaTestCredentialBinding, + SchemaTestCredentialBindings, +}; +use registry_server::runtime_config::{load_runtime_config, RuntimeConfig, RuntimeConfigError}; +use registry_server::startup; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::CapturedPackageCandidate; + +const CREDENTIALS_API_VERSION: &str = + "registry.registrystack.org/server-schema-test-credentials/v1"; +const CREDENTIALS_KIND: &str = "SchemaTestCredentials"; +const MAX_CREDENTIAL_DOCUMENT_BYTES: u64 = 64 * 1024; +const RECEIPT_ARTIFACT_PATH: &str = "schema-test-receipt.json"; + +static TEST_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug)] +pub(crate) struct TestLifecycleRequest<'a> { + pub candidate: CapturedPackageCandidate, + pub runtime_config: &'a Path, + pub credentials: &'a Path, + pub output: OutputTarget, +} + +#[derive(Debug)] +pub(crate) struct TestLifecycleOutcome { + pub package_revision: String, + pub schema_fingerprint: String, + pub signing_input_sha256: String, + pub successful_journey_ids: Vec, + pub receipt_sha256: String, + pub receipt_bytes: usize, +} + +#[derive(Debug)] +pub(crate) struct OutputTarget { + path: PathBuf, + parent: PathBuf, +} + +#[derive(Debug)] +pub(crate) enum TestLifecycleError { + RuntimeConfigPath, + RuntimeConfig(RuntimeConfigError), + Candidate, + Journeys, + Credentials, + Database, + Execution, + OutputPreflight, + OutputCommit, + Runtime, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CredentialDocument { + api_version: String, + kind: String, + bindings: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CredentialBindingDocument { + journey_id: String, + step_id: String, + credential: CredentialDocumentMode, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "type")] +enum CredentialDocumentMode { + Anonymous, + Bearer { + #[serde(rename = "tokenRef")] + token_ref: String, + }, +} + +pub(crate) fn preflight_output(path: &Path) -> Result { + if !path.is_absolute() + || path.as_os_str().is_empty() + || super::has_parent_component(path) + || path.file_name().is_none() + || path.exists() + { + return Err(TestLifecycleError::OutputPreflight); + } + let parent = path.parent().ok_or(TestLifecycleError::OutputPreflight)?; + super::validate_directory_for( + parent, + "test.output.parent_invalid", + "output.parent", + "the schema-test receipt output parent is not available", + "the schema-test receipt output parent was refused", + ) + .map_err(|_| TestLifecycleError::OutputPreflight)?; + super::ensure_no_symlink_components(path, "test.output.path_invalid", "output") + .map_err(|_| TestLifecycleError::OutputPreflight)?; + + let file = File::options() + .write(true) + .create_new(true) + .open(path) + .map_err(|_| TestLifecycleError::OutputPreflight)?; + let opened = match file.metadata() { + Ok(metadata) => metadata, + Err(_) => { + drop(file); + let _ = fs::remove_file(path); + return Err(TestLifecycleError::OutputPreflight); + } + }; + let result = (|| { + file.sync_all() + .map_err(|_| TestLifecycleError::OutputPreflight)?; + let after = fs::symlink_metadata(path).map_err(|_| TestLifecycleError::OutputPreflight)?; + if after.file_type().is_symlink() + || !after.is_file() + || !super::same_file_metadata(&opened, &after) + { + return Err(TestLifecycleError::OutputPreflight); + } + Ok(()) + })(); + drop(file); + if result.is_err() { + cleanup_exact_file(path, &opened); + return Err(TestLifecycleError::OutputPreflight); + } + remove_exact_file(path, &opened).map_err(|_| TestLifecycleError::OutputPreflight)?; + sync_parent(parent).map_err(|_| TestLifecycleError::OutputPreflight)?; + Ok(OutputTarget { + path: path.to_path_buf(), + parent: parent.to_path_buf(), + }) +} + +pub(crate) fn run( + request: TestLifecycleRequest<'_>, +) -> Result { + let config = load_test_runtime_config(request.runtime_config)?; + request.candidate.validate_runtime_binding(&config)?; + request + .candidate + .prevalidate() + .map_err(|_| TestLifecycleError::Candidate)?; + let suite = validate_fixture_journeys( + request.candidate.fixture_journeys(), + request.candidate.registry(), + ) + .map_err(|_| TestLifecycleError::Journeys)?; + let credentials = load_credentials(request.credentials, &config, &suite)?; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| TestLifecycleError::Runtime)?; + let schema_fingerprint = runtime.block_on(async { + startup::rehearse_schema_fingerprint(&config, request.candidate.registry()) + .await + .map_err(|_| TestLifecycleError::Database) + })?; + let prepared = request + .candidate + .prepare(schema_fingerprint.clone()) + .map_err(|_| TestLifecycleError::Candidate)?; + let signing_input_sha256 = sha256(prepared.canonical_signed_bytes()); + let package_revision = prepared.package_revision().to_owned(); + let receipt = runtime.block_on(async { + let database = startup::prepare_schema_test_database(&config, &prepared) + .await + .map_err(|_| TestLifecycleError::Database)?; + execute_schema_test(database, &config, &prepared, &suite, credentials) + .await + .map_err(execution_error) + })?; + let successful_journey_ids = receipt.successful_journey_ids().to_vec(); + let receipt_bytes = receipt + .canonical_bytes() + .map_err(|_| TestLifecycleError::Execution)?; + publish_receipt(&request.output, &receipt_bytes)?; + Ok(TestLifecycleOutcome { + package_revision, + schema_fingerprint, + signing_input_sha256, + successful_journey_ids, + receipt_sha256: sha256(&receipt_bytes), + receipt_bytes: receipt_bytes.len(), + }) +} + +fn load_test_runtime_config(path: &Path) -> Result { + if !path.is_absolute() || super::has_parent_component(path) { + return Err(TestLifecycleError::RuntimeConfigPath); + } + load_runtime_config(path).map_err(TestLifecycleError::RuntimeConfig) +} + +fn load_credentials( + path: &Path, + config: &RuntimeConfig, + suite: ®istry_server::fixtures::ValidatedFixtureJourneys, +) -> Result { + let bytes = read_credentials(path)?; + let raw = std::str::from_utf8(&bytes).map_err(|_| TestLifecycleError::Credentials)?; + let document: CredentialDocument = + serde_norway::from_str(raw).map_err(|_| TestLifecycleError::Credentials)?; + if document.api_version != CREDENTIALS_API_VERSION || document.kind != CREDENTIALS_KIND { + return Err(TestLifecycleError::Credentials); + } + let resolver = config + .secret_resolver() + .map_err(|_| TestLifecycleError::Credentials)?; + let mut bindings = Vec::with_capacity(document.bindings.len()); + for binding in document.bindings { + let binding = match binding.credential { + CredentialDocumentMode::Anonymous => { + SchemaTestCredentialBinding::anonymous(binding.journey_id, binding.step_id) + } + CredentialDocumentMode::Bearer { token_ref } => { + if !is_protected_secret_reference(&token_ref) { + return Err(TestLifecycleError::Credentials); + } + let secret = resolver + .resolve(&token_ref) + .map_err(|_| TestLifecycleError::Credentials)?; + let token = std::str::from_utf8(secret.expose_secret()) + .map_err(|_| TestLifecycleError::Credentials)? + .to_owned(); + SchemaTestCredentialBinding::bearer( + binding.journey_id, + binding.step_id, + Zeroizing::new(token), + ) + } + }; + bindings.push(binding); + } + SchemaTestCredentialBindings::new(suite, bindings).map_err(|_| TestLifecycleError::Credentials) +} + +fn read_credentials(path: &Path) -> Result, TestLifecycleError> { + if !path.is_absolute() || super::has_parent_component(path) { + return Err(TestLifecycleError::Credentials); + } + super::ensure_no_symlink_components(path, "test.credentials.path_invalid", "credentials") + .map_err(|_| TestLifecycleError::Credentials)?; + let metadata = fs::symlink_metadata(path).map_err(|_| TestLifecycleError::Credentials)?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() == 0 + || metadata.len() > MAX_CREDENTIAL_DOCUMENT_BYTES + { + return Err(TestLifecycleError::Credentials); + } + let file = File::open(path).map_err(|_| TestLifecycleError::Credentials)?; + let opened = file + .metadata() + .map_err(|_| TestLifecycleError::Credentials)?; + let after = fs::symlink_metadata(path).map_err(|_| TestLifecycleError::Credentials)?; + if after.file_type().is_symlink() + || !opened.is_file() + || !super::same_file_metadata(&metadata, &opened) + || !super::same_file_metadata(&opened, &after) + || opened.len() > MAX_CREDENTIAL_DOCUMENT_BYTES + { + return Err(TestLifecycleError::Credentials); + } + let capacity = usize::try_from(opened.len()).map_err(|_| TestLifecycleError::Credentials)?; + let mut bytes = Vec::with_capacity(capacity); + file.take(MAX_CREDENTIAL_DOCUMENT_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| TestLifecycleError::Credentials)?; + if bytes.len() as u64 != opened.len() || bytes.len() as u64 > MAX_CREDENTIAL_DOCUMENT_BYTES { + return Err(TestLifecycleError::Credentials); + } + Ok(bytes) +} + +fn execution_error(error: FixtureError) -> TestLifecycleError { + match error { + FixtureError::CandidateBindingRefused => TestLifecycleError::Database, + _ => TestLifecycleError::Execution, + } +} + +fn publish_receipt(target: &OutputTarget, bytes: &[u8]) -> Result<(), TestLifecycleError> { + let (temporary, mut file) = create_temporary_file(&target.parent)?; + let result = (|| { + file.write_all(bytes) + .map_err(|_| TestLifecycleError::OutputCommit)?; + file.sync_all() + .map_err(|_| TestLifecycleError::OutputCommit)?; + let metadata = file + .metadata() + .map_err(|_| TestLifecycleError::OutputCommit)?; + drop(file); + publish_temporary_file(&temporary, &target.path)?; + sync_parent(&target.parent).map_err(|_| TestLifecycleError::OutputCommit)?; + if fs::symlink_metadata(&target.path) + .map(|after| { + after.file_type().is_symlink() || !super::same_file_metadata(&metadata, &after) + }) + .unwrap_or(true) + { + return Err(TestLifecycleError::OutputCommit); + } + Ok(()) + })(); + if result.is_err() { + cleanup_temporary_file(&temporary); + } + result +} + +fn create_temporary_file(parent: &Path) -> Result<(PathBuf, File), TestLifecycleError> { + for _ in 0..64 { + let counter = TEST_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed); + let temporary = parent.join(format!( + ".registry-serverctl-test-receipt-{}-{counter}.tmp", + std::process::id() + )); + match File::options() + .write(true) + .create_new(true) + .open(&temporary) + { + Ok(file) => return Ok((temporary, file)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(_) => return Err(TestLifecycleError::OutputCommit), + } + } + Err(TestLifecycleError::OutputCommit) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn publish_temporary_file(temporary: &Path, output: &Path) -> Result<(), TestLifecycleError> { + use rustix::fs::{renameat_with, RenameFlags, CWD}; + + renameat_with(CWD, temporary, CWD, output, RenameFlags::NOREPLACE) + .map_err(|_| TestLifecycleError::OutputCommit) +} + +#[cfg(target_os = "windows")] +fn publish_temporary_file(temporary: &Path, output: &Path) -> Result<(), TestLifecycleError> { + fs::rename(temporary, output).map_err(|_| TestLifecycleError::OutputCommit) +} + +#[cfg(not(any(target_os = "linux", target_vendor = "apple", target_os = "windows")))] +fn publish_temporary_file(_temporary: &Path, _output: &Path) -> Result<(), TestLifecycleError> { + Err(TestLifecycleError::OutputCommit) +} + +fn cleanup_temporary_file(path: &Path) { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return; + }; + if name.starts_with(".registry-serverctl-test-receipt-") && name.ends_with(".tmp") { + let _ = fs::remove_file(path); + } +} + +fn remove_exact_file(path: &Path, expected: &fs::Metadata) -> std::io::Result<()> { + let actual = fs::symlink_metadata(path)?; + if actual.file_type().is_symlink() + || !actual.is_file() + || !super::same_file_metadata(expected, &actual) + { + return Err(std::io::Error::other("output identity changed")); + } + fs::remove_file(path) +} + +fn cleanup_exact_file(path: &Path, expected: &fs::Metadata) { + let _ = remove_exact_file(path, expected); +} + +fn sync_parent(parent: &Path) -> std::io::Result<()> { + File::open(parent)?.sync_all() +} + +pub(crate) fn receipt_artifact_path() -> &'static str { + RECEIPT_ARTIFACT_PATH +} + +fn sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut encoded = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + write!(encoded, "{byte:02x}").expect("writing to a String cannot fail"); + } + encoded +} + +fn is_protected_secret_reference(value: &str) -> bool { + let Some(name) = value.strip_prefix("secret:env/") else { + return is_file_secret_reference(value); + }; + 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 is_file_secret_reference(value: &str) -> bool { + let Some(name) = value.strip_prefix("secret:file/") else { + return false; + }; + 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'-') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory { + path: PathBuf, + } + + impl TestDirectory { + fn create() -> Self { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!( + ".registry-serverctl-schema-test-unit-{}-{}", + std::process::id(), + TEST_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).expect("unit test directory creates"); + Self { path } + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + if self.path.exists() { + fs::remove_dir_all(&self.path).expect("unit test directory removes"); + } + } + } + + #[test] + fn output_publish_refuses_a_target_created_after_preflight_without_replacement() { + let directory = TestDirectory::create(); + let output = directory.path.join("receipt.json"); + let target = preflight_output(&output).expect("output preflights"); + assert!(!output.exists()); + + fs::write(&output, b"operator-owned").expect("racing output writes"); + let error = publish_receipt(&target, br#"{"ok":true}"#).expect_err("race is refused"); + assert!(matches!(error, TestLifecycleError::OutputCommit)); + assert_eq!( + fs::read(&output).expect("racing output remains"), + b"operator-owned" + ); + assert!( + fs::read_dir(&directory.path) + .expect("directory reads") + .all(|entry| !entry + .expect("entry reads") + .file_name() + .to_string_lossy() + .starts_with(".registry-serverctl-test-receipt-")), + "temporary receipt files are cleaned up" + ); + } +} diff --git a/crates/registry-serverctl/tests/cli.rs b/crates/registry-serverctl/tests/cli.rs new file mode 100644 index 0000000000..2e74c2df1b --- /dev/null +++ b/crates/registry-serverctl/tests/cli.rs @@ -0,0 +1,2971 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeMap; +use std::fs; +use std::net::{SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_crypto::{generate_private_jwk, sign, GeneratedKeyAlgorithm, PrivateJwk}; +use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::contract::{parse_module_json, parse_module_yaml, parse_project_yaml}; +use registry_server::fixtures::{ + validate_fixture_journeys, validate_schema_test_receipt_for_package, +}; +use registry_server::package::{ + prepare_package, PackageBuildRequest, PackageMigrationPlanInput, PackageModuleSource, + PackageSignature, PackageSourceFile, PackageTrustAnchor, PreparedPackage, SignaturePolicy, + TrustAnchorKey, FIXTURE_JOURNEYS_PATH, MAX_PACKAGE_SOURCE_FILE_BYTES, TRUST_ANCHOR_API_VERSION, +}; +use serde::Serialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0); +const PACKAGE_INSTANCE: &str = "verify-instance"; +const PACKAGE_DATABASE: &str = "verify-database"; +const PACKAGE_SOURCE_REVISION: &str = "verify-compiler-source"; +const PACKAGE_VALUE_CANARY: &str = "verify-path-trust-secret-sql-canary"; +const SCHEMA_TEST_AUTHORED_SOURCE_CEILING_BYTES: usize = 1024 * 1024; +const PACKAGE_FIXTURE_JOURNEYS: &[u8] = + br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: package-record-list + steps: + - id: list-records + entity: record + accessProfile: reader + claims: {principal: package-reader} + request: {operation: list} + expect: {outcome: success, status: 200, count: 0} +"#; +const DATA_FIXTURE_JOURNEYS: &[u8] = br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: data-record-list + steps: + - id: list-records + entity: record + accessProfile: operator + claims: {principal: data-operator} + request: {operation: list} + expect: {outcome: success, status: 200, count: 0} +"#; + +struct TestProject { + root: PathBuf, +} + +impl TestProject { + fn asset_fixture() -> Self { + Self::from_registry_source(asset_fixture()) + } + + fn from_registry_source(source: &[u8]) -> Self { + let temporary_parent = std::env::current_dir().expect("current directory is available"); + let root = temporary_parent.join(format!( + "registry-serverctl-test-{}-{}", + std::process::id(), + TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).expect("test directory is created"); + fs::write(root.join("registry.yaml"), source).expect("fixture is copied"); + Self { root } + } + + fn path(&self) -> &Path { + &self.root + } +} + +impl Drop for TestProject { + fn drop(&mut self) { + if self.root.exists() { + fs::remove_dir_all(&self.root).expect("test directory is removed"); + } + } +} + +struct RuntimePackageFixture { + directory: TestProject, + package: PathBuf, + anchor: PathBuf, + runtime_config: PathBuf, + package_revision: String, +} + +impl RuntimePackageFixture { + fn production(bind: SocketAddr) -> Self { + let directory = TestProject::from_registry_source(authoring_fixture()); + let signing = generate_private_jwk(GeneratedKeyAlgorithm::Es384) + .expect("production package signing key generates"); + let module_bytes = package_module_bytes(); + let module = parse_module_json(&module_bytes).expect("package module parses"); + let project_bytes = package_project_bytes(&module_digest(&module)); + let key_id = signing.public().kid.expect("generated key has an id"); + let prepared = + prepare_package(PackageBuildRequest { + environment: "production".to_owned(), + instance_id: PACKAGE_INSTANCE.to_owned(), + database_id: PACKAGE_DATABASE.to_owned(), + sequence: 1, + prior_revision: None, + compiler_source_revision: PACKAGE_SOURCE_REVISION.to_owned(), + schema_fingerprint: + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + .to_owned(), + signature_policy: SignaturePolicy { + threshold: 1, + key_ids: vec![key_id.clone()], + }, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: project_bytes, + }, + modules: vec![PackageModuleSource { + id: "core".to_owned(), + path: "source/modules/core/module.yaml".to_owned(), + bytes: module_bytes, + }], + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: PACKAGE_FIXTURE_JOURNEYS.to_vec(), + }, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + }) + .expect("package prepares"); + validate_fixture_journeys(PACKAGE_FIXTURE_JOURNEYS, prepared.registry()) + .expect("package fixture journeys resolve against the packaged registry"); + let signature = sign(prepared.canonical_signed_bytes(), &signing) + .expect("package canonical bytes sign"); + let package = directory.path().join("package"); + let package_revision = prepared.package_revision().to_owned(); + prepared + .publish_to_directory( + &package, + vec![PackageSignature { + key_id: key_id.clone(), + signature_hex: hex(&signature), + }], + ) + .expect("package publishes"); + let anchor = directory.path().join("trust.json"); + write_anchor(&anchor, &signing); + let runtime_config = + write_runtime_config(directory.path(), &package, &anchor, &package_revision, bind); + Self { + directory, + package, + anchor, + runtime_config, + package_revision, + } + } + + fn variant(&self, name: &str, from: &str, to: &str) -> PathBuf { + let target = self.directory.path().join(format!("{name}.yaml")); + let source = fs::read_to_string(&self.runtime_config).expect("runtime config reads"); + assert!(source.contains(from), "runtime replacement is exact"); + fs::write(&target, source.replacen(from, to, 1)).expect("runtime variant writes"); + target + } +} + +fn asset_fixture() -> &'static [u8] { + include_bytes!( + "../../../products/registry-server/acceptance/asset-site-placement/registry.yaml" + ) +} + +fn authoring_fixture() -> &'static [u8] { + br#"apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: cli-authoring-fixture + version: 1 + defaultLanguage: en +entities: + - id: record + route: records + mutationMode: create_only + fields: + - id: code + type: string + maxLength: 64 + classification: internal +"# +} + +fn packaging_project() -> (TestProject, PrivateJwk, String) { + let signing = generate_private_jwk(GeneratedKeyAlgorithm::Es384) + .expect("production package signing key generates"); + let key_id = signing + .public() + .kid + .clone() + .expect("generated key has an id"); + let module_bytes = package_module_bytes(); + let module = parse_module_json(&module_bytes).expect("package module parses"); + let project = + TestProject::from_registry_source(&package_project_bytes(&module_digest(&module))); + let module_directory = project.path().join("modules/core"); + fs::create_dir_all(&module_directory).expect("package module directory creates"); + fs::write(module_directory.join("module.yaml"), module_bytes).expect("package module writes"); + let tests_directory = project.path().join("tests"); + fs::create_dir(&tests_directory).expect("package tests directory creates"); + fs::write( + tests_directory.join("journeys.yaml"), + PACKAGE_FIXTURE_JOURNEYS, + ) + .expect("package fixture journeys write"); + (project, signing, key_id) +} + +fn prepare_packaging_candidate( + project: &TestProject, + database_id: &str, + schema_fingerprint: &str, + signature_threshold: u16, + signature_key_ids: Vec, +) -> PreparedPackage { + let project_bytes = + fs::read(project.path().join("registry.yaml")).expect("package project reads"); + let project_source = parse_project_yaml(&project_bytes).expect("package project parses"); + let identity = project_source + .package + .as_ref() + .expect("package identity exists"); + let module_bytes = + fs::read(project.path().join("modules/core/module.yaml")).expect("package module reads"); + let journey_bytes = + fs::read(project.path().join(FIXTURE_JOURNEYS_PATH)).expect("package journey reads"); + prepare_package(PackageBuildRequest { + environment: identity.environment.clone(), + instance_id: identity.instance_id.clone(), + database_id: database_id.to_owned(), + sequence: identity.sequence, + prior_revision: None, + compiler_source_revision: identity.source_revision.clone(), + schema_fingerprint: schema_fingerprint.to_owned(), + signature_policy: SignaturePolicy { + threshold: signature_threshold, + key_ids: signature_key_ids, + }, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: project_bytes, + }, + modules: vec![PackageModuleSource { + id: "core".to_owned(), + path: "source/modules/core/module.yaml".to_owned(), + bytes: module_bytes, + }], + fixture_journeys: PackageSourceFile { + path: FIXTURE_JOURNEYS_PATH.to_owned(), + bytes: journey_bytes, + }, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + }) + .expect("package candidate prepares") +} + +/// Test-only raw construction of the receipt shape emitted by a successful +/// rehearsal. Public production code only validates receipts and deliberately +/// exposes no unchecked constructor. +fn schema_test_receipt_bytes(prepared: &PreparedPackage, journey_ids: &[&str]) -> Vec { + let manifest = prepared.manifest(); + let files = prepared.file_bytes(); + let project_bytes = files + .get(&manifest.sources.project) + .expect("captured project exists"); + let project = parse_project_yaml(project_bytes).expect("captured project parses"); + let project_identity = project.package.expect("captured project identity exists"); + let journeys = files + .get(FIXTURE_JOURNEYS_PATH) + .expect("captured journeys exist"); + let migration_plan = files + .get("database/migration-plan.json") + .expect("captured migration plan exists"); + let mut source_closure = Sha256::new(); + source_closure.update(b"registry-server-schema-test-source-closure-v2\0"); + digest_part( + &mut source_closure, + manifest.sources.project.as_bytes(), + project_bytes, + ); + for module in &manifest.sources.modules { + let bytes = files.get(&module.path).expect("captured module exists"); + digest_part( + &mut source_closure, + module.id.as_bytes(), + module.path.as_bytes(), + ); + digest_part(&mut source_closure, module.path.as_bytes(), bytes); + } + digest_part( + &mut source_closure, + FIXTURE_JOURNEYS_PATH.as_bytes(), + journeys, + ); + let mut receipt = json!({ + "apiVersion": "registry.registrystack.org/server-schema-test-receipt/v1", + "kind": "SchemaTestReceipt", + "registryRevision": prepared.registry().revision(), + "projectSourceRevision": project_identity.source_revision, + "compilerSourceRevision": manifest.compiler.source_revision, + "environment": manifest.environment, + "instanceId": manifest.instance_id, + "databaseId": manifest.database_id, + "sequence": manifest.sequence, + "candidatePackageRevision": manifest.package_revision, + "sourceClosureSha256": prefixed_digest(source_closure.finalize().as_slice()), + "migrationPlanSha256": sha256_prefixed(migration_plan), + "signingInputSha256": sha256_prefixed(prepared.canonical_signed_bytes()), + "postgresMajor": 16, + "targetManagedSchemaFingerprint": manifest.schema_fingerprint, + "successfulJourneyIds": journey_ids, + "journeyFileSha256": sha256_prefixed(journeys), + }); + if let Some(prior) = &manifest.prior_revision { + receipt + .as_object_mut() + .expect("receipt is an object") + .insert("priorPackageRevision".to_owned(), json!(prior)); + } + let bytes = canonicalize_json(&receipt).expect("test receipt canonicalizes"); + let suite = validate_fixture_journeys(journeys, prepared.registry()) + .expect("packaged journeys validate"); + validate_schema_test_receipt_for_package(&bytes, prepared, &suite) + .expect("test receipt binds to candidate"); + bytes +} + +fn digest_part(digest: &mut Sha256, name: &[u8], bytes: &[u8]) { + digest.update((name.len() as u64).to_be_bytes()); + digest.update(name); + digest.update((bytes.len() as u64).to_be_bytes()); + digest.update(bytes); +} + +fn sha256_prefixed(bytes: &[u8]) -> String { + prefixed_digest(Sha256::digest(bytes).as_slice()) +} + +fn prefixed_digest(bytes: &[u8]) -> String { + format!("sha256:{}", hex(bytes)) +} + +fn registry_serverctl(arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_registry-serverctl")) + .args(arguments) + .output() + .expect("registry-serverctl starts") +} + +fn package_candidate_command( + project: &TestProject, + database_id: &str, + schema_fingerprint: &str, + signature_threshold: u16, + signature_key_ids: &[String], + receipt: &Path, + output: &Path, +) -> Output { + let mut arguments = vec![ + "--format".to_owned(), + "json".to_owned(), + "package".to_owned(), + path(project.path()).to_owned(), + "--database-id".to_owned(), + database_id.to_owned(), + "--schema-fingerprint".to_owned(), + schema_fingerprint.to_owned(), + "--signature-threshold".to_owned(), + signature_threshold.to_string(), + ]; + for key_id in signature_key_ids { + arguments.push(format!("--signature-key-id={key_id}")); + } + arguments.extend([ + "--test-receipt".to_owned(), + path(receipt).to_owned(), + "--output".to_owned(), + path(output).to_owned(), + ]); + let arguments = arguments.iter().map(String::as_str).collect::>(); + registry_serverctl(&arguments) +} + +fn test_candidate_command( + project: &TestProject, + signature_threshold: u16, + signature_key_ids: &[String], + runtime_config: &Path, + credentials: &Path, + output: &Path, +) -> Output { + test_candidate_command_for_database( + project, + PACKAGE_DATABASE, + signature_threshold, + signature_key_ids, + runtime_config, + credentials, + output, + ) +} + +fn test_candidate_command_for_database( + project: &TestProject, + database_id: &str, + signature_threshold: u16, + signature_key_ids: &[String], + runtime_config: &Path, + credentials: &Path, + output: &Path, +) -> Output { + let mut arguments = vec![ + "--format".to_owned(), + "json".to_owned(), + "test".to_owned(), + path(project.path()).to_owned(), + "--database-id".to_owned(), + database_id.to_owned(), + "--signature-threshold".to_owned(), + signature_threshold.to_string(), + ]; + for key_id in signature_key_ids { + arguments.push(format!("--signature-key-id={key_id}")); + } + arguments.extend([ + "--runtime-config".to_owned(), + path(runtime_config).to_owned(), + "--credentials".to_owned(), + path(credentials).to_owned(), + "--output".to_owned(), + path(output).to_owned(), + ]); + let arguments = arguments.iter().map(String::as_str).collect::>(); + registry_serverctl(&arguments) +} + +fn json_stdout(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).expect("command stdout is JSON") +} + +fn assert_tool_diagnostic(diagnostic: &Value, artifact: &str, suggested_action: &str) { + let keys = diagnostic + .as_object() + .expect("diagnostic is an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "artifact", + "code", + "message", + "path", + "severity", + "suggestedAction", + ]) + ); + assert_eq!(diagnostic["artifact"], artifact); + assert_eq!(diagnostic["suggestedAction"], suggested_action); +} + +#[test] +fn authored_project_findings_use_the_tool_diagnostic_schema() { + let project = TestProject::from_registry_source(authoring_fixture()); + let output = registry_serverctl(&[ + "--format", + "json", + "check", + project.path().to_str().expect("path is UTF-8"), + ]); + + assert!(output.status.success(), "{output:?}"); + assert!(output.stderr.is_empty()); + let report = json_stdout(&output); + assert_eq!(report["ok"], true); + assert_eq!(report["command"], "check"); + assert_eq!(report["profile"], "authoring"); + assert!(report["findings"] + .as_array() + .expect("findings is an array") + .iter() + .any(|finding| finding["code"] == "package.identity.missing")); + for finding in report["findings"].as_array().expect("findings is an array") { + assert_tool_diagnostic(finding, "registry_project", "review_authoring_finding"); + } +} + +#[test] +fn production_profile_refuses_missing_package_closure() { + let project = TestProject::from_registry_source(authoring_fixture()); + let output = registry_serverctl(&[ + "--format", + "json", + "check", + project.path().to_str().expect("path is UTF-8"), + "--production", + ]); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stderr.is_empty()); + let report = json_stdout(&output); + assert_eq!(report["ok"], false); + let codes: Vec<_> = report["diagnostics"] + .as_array() + .expect("diagnostics is an array") + .iter() + .filter_map(|diagnostic| diagnostic["code"].as_str()) + .collect(); + assert!(codes.contains(&"package.identity.required")); + for diagnostic in report["diagnostics"] + .as_array() + .expect("diagnostics is an array") + { + assert_tool_diagnostic(diagnostic, "registry_project", "correct_authoring_source"); + } +} + +#[test] +fn generation_is_byte_stable_and_reports_the_exact_artifact_inventory() { + let project = TestProject::asset_fixture(); + let first = project.path().join("first-output"); + let second = project.path().join("second-output"); + let first_output = registry_serverctl(&[ + "--format", + "json", + "generate", + "schemas", + project.path().to_str().expect("path is UTF-8"), + "--output", + first.to_str().expect("path is UTF-8"), + ]); + let second_output = registry_serverctl(&[ + "--format", + "json", + "generate", + "schemas", + project.path().to_str().expect("path is UTF-8"), + "--output", + second.to_str().expect("path is UTF-8"), + ]); + + assert!(first_output.status.success(), "{first_output:?}"); + assert!(second_output.status.success(), "{second_output:?}"); + let first_tree = tree(&first); + assert_eq!(first_tree, tree(&second)); + + let report = json_stdout(&first_output); + let paths: Vec<_> = report["artifacts"] + .as_array() + .expect("artifacts is an array") + .iter() + .filter_map(|artifact| artifact["path"].as_str()) + .collect(); + assert_eq!( + paths, + first_tree.keys().map(String::as_str).collect::>() + ); +} + +#[test] +fn init_creates_a_domain_neutral_project_that_checks_immediately() { + let project = TestProject::asset_fixture(); + let destination = project.path().join("initialized"); + + let output = registry_serverctl(&[ + "--format", + "json", + "init", + destination.to_str().expect("path is UTF-8"), + ]); + + assert!(output.status.success(), "{output:?}"); + assert!(destination.join("registry.yaml").is_file()); + assert!(destination.join("modules/core/module.yaml").is_file()); + assert!(destination.join("tests/journeys.yaml").is_file()); + let journeys = fs::read_to_string(destination.join("tests/journeys.yaml")) + .expect("initialized fixture journeys read"); + assert!(journeys.contains("entity: record")); + assert!(journeys.contains("accessProfile: operator")); + assert!(journeys.contains("purpose: registry-operations")); + assert!(!journeys.contains("token")); + let initialized_project = parse_project_yaml( + &fs::read(destination.join("registry.yaml")).expect("initialized project reads"), + ) + .expect("initialized project parses"); + let initialized_module = parse_module_yaml( + &fs::read(destination.join("modules/core/module.yaml")).expect("initialized module reads"), + ) + .expect("initialized module parses"); + let compiled = compile_project( + &initialized_project, + &[initialized_module], + CompileProfile::Authoring, + ) + .expect("initialized project compiles"); + validate_fixture_journeys(journeys.as_bytes(), &compiled) + .expect("initialized fixture journeys resolve against the compiled project"); + let report = json_stdout(&output); + assert_eq!(report["ok"], true); + assert_eq!(report["command"], "init"); + assert!(report["findings"] + .as_array() + .expect("findings is an array") + .iter() + .any(|finding| finding["code"] == "package.identity.missing")); + + let check = registry_serverctl(&[ + "--format", + "json", + "check", + destination.to_str().expect("path is UTF-8"), + ]); + assert!(check.status.success(), "{check:?}"); + assert_eq!(json_stdout(&check)["ok"], true); +} + +#[test] +fn init_and_generate_missing_output_parents_have_exact_logical_diagnostics() { + const PATH_CANARY: &str = "registry-serverctl-missing-parent-canary"; + + let project = TestProject::asset_fixture(); + let missing_parent = project.path().join(PATH_CANARY); + let init_destination = missing_parent.join("initialized"); + let init_output = registry_serverctl(&[ + "--format", + "json", + "init", + init_destination.to_str().expect("path is UTF-8"), + ]); + assert_eq!(init_output.status.code(), Some(1)); + assert!(init_output.stderr.is_empty()); + assert_eq!( + json_stdout(&init_output), + json!({ + "ok": false, + "command": "init", + "diagnostics": [{ + "severity": "error", + "code": "output.parent.invalid", + "artifact": "project_initialization", + "path": "output.parent", + "message": "the output parent directory is not available", + "suggestedAction": "choose_safe_output_directory" + }] + }) + ); + assert!(!String::from_utf8_lossy(&init_output.stdout).contains(PATH_CANARY)); + + let generate_destination = missing_parent.join("generated"); + let generate_output = registry_serverctl(&[ + "--format", + "json", + "generate", + "openapi", + project.path().to_str().expect("path is UTF-8"), + "--output", + generate_destination.to_str().expect("path is UTF-8"), + ]); + assert_eq!(generate_output.status.code(), Some(1)); + assert!(generate_output.stderr.is_empty()); + assert_eq!( + json_stdout(&generate_output), + json!({ + "ok": false, + "command": "generate", + "diagnostics": [{ + "severity": "error", + "code": "output.parent.invalid", + "artifact": "generated_artifacts", + "path": "output.parent", + "message": "the output parent directory is not available", + "suggestedAction": "retry_artifact_generation" + }] + }) + ); + assert!(!String::from_utf8_lossy(&generate_output.stdout).contains(PATH_CANARY)); +} + +#[test] +fn generate_selectors_publish_only_selected_artifacts() { + let project = TestProject::asset_fixture(); + let output_root = project.path().join("selected-output"); + + let output = registry_serverctl(&[ + "--format", + "json", + "generate", + "openapi", + project.path().to_str().expect("path is UTF-8"), + "--output", + output_root.to_str().expect("path is UTF-8"), + ]); + + assert!(output.status.success(), "{output:?}"); + assert!(output_root.join("generated/openapi.json").is_file()); + assert!(!output_root.join("generated/postgres/schema.sql").exists()); + assert_eq!( + tree(&output_root).keys().cloned().collect::>(), + vec!["generated/openapi.json"] + ); + let report = json_stdout(&output); + let artifacts = report["artifacts"] + .as_array() + .expect("artifacts is an array"); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["path"], "generated/openapi.json"); +} + +#[test] +fn manifest_selector_requires_the_compiled_manifest_projection() { + let project = TestProject::asset_fixture(); + let output_root = project.path().join("manifest-output"); + + let output = registry_serverctl(&[ + "--format", + "json", + "generate", + "manifest", + project.path().to_str().expect("path is UTF-8"), + "--output", + output_root.to_str().expect("path is UTF-8"), + ]); + + assert!(output.status.success(), "{output:?}"); + assert!(output_root + .join("generated/manifest/registry-manifest.json") + .is_file()); + let report = json_stdout(&output); + assert_eq!( + report["artifacts"][0]["path"], + "generated/manifest/registry-manifest.json" + ); +} + +#[test] +fn metadata_selector_publishes_only_registry_metadata() { + let project = TestProject::asset_fixture(); + let output_root = project.path().join("metadata-output"); + + let output = registry_serverctl(&[ + "--format", + "json", + "generate", + "metadata", + project.path().to_str().expect("path is UTF-8"), + "--output", + output_root.to_str().expect("path is UTF-8"), + ]); + + assert!(output.status.success(), "{output:?}"); + assert!(output_root + .join("generated/metadata/registry.json") + .is_file()); + assert!(!output_root.join("generated/openapi.json").exists()); + assert!(!output_root.join("generated/postgres/schema.sql").exists()); + assert_eq!( + tree(&output_root).keys().cloned().collect::>(), + vec!["generated/metadata/registry.json"] + ); + let report = json_stdout(&output); + let artifacts = report["artifacts"] + .as_array() + .expect("artifacts is an array"); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["path"], "generated/metadata/registry.json"); +} + +#[test] +fn explain_reports_are_derived_from_compiled_inventories() { + let project = TestProject::asset_fixture(); + + let routes = registry_serverctl(&[ + "--format", + "json", + "explain", + "routes", + project.path().to_str().expect("path is UTF-8"), + ]); + let access = registry_serverctl(&[ + "--format", + "json", + "explain", + "access", + project.path().to_str().expect("path is UTF-8"), + ]); + let model = registry_serverctl(&[ + "--format", + "json", + "explain", + "model", + project.path().to_str().expect("path is UTF-8"), + ]); + + assert!(routes.status.success(), "{routes:?}"); + assert!(access.status.success(), "{access:?}"); + assert!(model.status.success(), "{model:?}"); + assert_eq!( + json_stdout(&routes)["explanation"]["routes"][0]["entityId"], + "asset-item" + ); + assert_eq!( + json_stdout(&access)["explanation"]["entries"][0]["entityId"], + "asset-item" + ); + assert_eq!( + json_stdout(&model)["explanation"]["registryId"], + "asset-site-placement" + ); +} + +#[test] +fn explain_events_is_empty_for_outbox_only_and_deterministic_for_webhooks() { + let no_events = TestProject::asset_fixture(); + let empty = registry_serverctl(&[ + "--format", + "json", + "explain", + "events", + no_events.path().to_str().expect("path is UTF-8"), + ]); + assert!(empty.status.success(), "{empty:?}"); + assert_eq!(json_stdout(&empty)["explanation"]["deliveries"], json!([])); + + let outbox_only = TestProject::from_registry_source( + br#"apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: event-explain-outbox + version: 1 + defaultLanguage: en +entities: + - id: case + route: cases + mutationMode: mutable + fields: + - id: label + type: string + maxLength: 64 + classification: public + events: + - id: case-created + trigger: created + projection: [label] +"#, + ); + let outbox = registry_serverctl(&[ + "--format", + "json", + "explain", + "events", + outbox_only.path().to_str().expect("path is UTF-8"), + ]); + assert!(outbox.status.success(), "{outbox:?}"); + assert_eq!(json_stdout(&outbox)["explanation"]["deliveries"], json!([])); + + let webhook = TestProject::from_registry_source( + br#"apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: event-explain-webhook + version: 1 + defaultLanguage: en +entities: + - id: case + route: cases + mutationMode: mutable + fields: + - id: label + type: string + maxLength: 64 + classification: public + events: + - id: case-created + trigger: created + projection: [label] + webhook: + destinationId: case-operations + classificationCeiling: public + authenticationProfile: hmac_sha256_v1 + delivery: + attemptTimeoutMs: 5000 + initialBackoffMs: 250 + maximumBackoffMs: 2000 + maximumAttempts: 5 + deadLetter: required + operatorReplay: false +"#, + ); + let arguments = [ + "--format", + "json", + "explain", + "events", + webhook.path().to_str().expect("path is UTF-8"), + ]; + let first = registry_serverctl(&arguments); + let second = registry_serverctl(&arguments); + assert!(first.status.success(), "{first:?}"); + assert_eq!( + first.stdout, second.stdout, + "event explanation is byte stable" + ); + let report = json_stdout(&first); + assert_eq!( + report["explanation"]["deliveries"][0]["id"], + "events.case.case-created.webhook" + ); + assert_eq!( + report["explanation"]["deliveries"][0]["destinationId"], + "case-operations" + ); + let delivery_keys = report["explanation"]["deliveries"][0] + .as_object() + .expect("delivery is an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + delivery_keys, + std::collections::BTreeSet::from([ + "attemptTimeoutMs", + "authenticationProfile", + "classificationCeiling", + "deadLetter", + "deliveryMode", + "destinationId", + "entityId", + "eventId", + "exponentialBackoffMultiplier", + "id", + "initialBackoffMs", + "maximumAttempts", + "maximumBackoffMs", + "maximumPayloadBytes", + "operatorReplay", + "projectionFields", + "retryDelaysMs", + "trigger", + ]) + ); + let rendered = String::from_utf8(first.stdout).expect("report is UTF-8"); + for forbidden in [ + "http://", + "https://", + "destinationUrl", + "secretRef", + "secretValue", + "tlsCertificate", + ] { + assert!(!rendered.contains(forbidden)); + } +} + +#[test] +fn production_package_emits_exact_signing_input_and_publishes_only_external_signatures() { + let (project, signing, key_id) = packaging_project(); + let build = project.path().join("build"); + let fingerprint = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + let expected = prepare_packaging_candidate( + &project, + PACKAGE_DATABASE, + fingerprint, + 1, + vec![key_id.clone()], + ); + let receipt = project.path().join("schema-test-receipt.json"); + let receipt_bytes = schema_test_receipt_bytes(&expected, &["package-record-list"]); + fs::write(&receipt, &receipt_bytes).expect("schema-test receipt writes"); + let common = vec![ + "--format".to_owned(), + "json".to_owned(), + "package".to_owned(), + path(project.path()).to_owned(), + "--database-id".to_owned(), + PACKAGE_DATABASE.to_owned(), + "--schema-fingerprint".to_owned(), + fingerprint.to_owned(), + "--signature-threshold".to_owned(), + "1".to_owned(), + format!("--signature-key-id={key_id}"), + "--test-receipt".to_owned(), + path(&receipt).to_owned(), + "--output".to_owned(), + path(&build).to_owned(), + ]; + let common_args = common.iter().map(String::as_str).collect::>(); + + let prepared = registry_serverctl(&common_args); + assert!(prepared.status.success(), "{prepared:?}"); + assert!(prepared.stderr.is_empty()); + let report = json_stdout(&prepared); + assert_eq!(report["command"], "package"); + assert_eq!(report["profile"], "production"); + assert_eq!(report["state"], "awaiting_signatures"); + assert_eq!(report["signatureThreshold"], 1); + assert_eq!(report["providedSignatures"], 0); + assert!(build.join("signing-input.json").is_file()); + assert_eq!( + fs::read(build.join("schema-test-receipt.json")).expect("reviewer receipt reads"), + receipt_bytes + ); + assert!(!build.join("package").exists()); + + let signing_input = + fs::read(build.join("signing-input.json")).expect("canonical signing input reads"); + let signature = sign(&signing_input, &signing).expect("external signer signs exact bytes"); + let signatures = project.path().join("signatures.json"); + write_canonical( + &signatures, + &json!({ + "signatures": [{"keyId": key_id, "signatureHex": hex(&signature)}] + }), + ); + let mut final_arguments = common.clone(); + final_arguments.extend(["--signatures".to_owned(), path(&signatures).to_owned()]); + let final_arguments = final_arguments + .iter() + .map(String::as_str) + .collect::>(); + let published = registry_serverctl(&final_arguments); + assert!(published.status.success(), "{published:?}"); + assert!(published.stderr.is_empty()); + let published_report = json_stdout(&published); + assert_eq!(published_report["state"], "published"); + assert_eq!( + published_report["packageRevision"], + report["packageRevision"] + ); + assert_eq!(published_report["signingInput"], report["signingInput"]); + assert_eq!(published_report["providedSignatures"], 1); + assert!(build.join("package/package.json").is_file()); + assert!(!tree(&build.join("package")) + .keys() + .any(|entry| entry.contains("schema-test-receipt"))); + let envelope: Value = serde_json::from_slice( + &fs::read(build.join("package/package.json")).expect("package envelope reads"), + ) + .expect("package envelope parses"); + assert!(!envelope["signed"]["files"] + .as_array() + .expect("package files are an array") + .iter() + .any(|entry| entry["path"] == "schema-test-receipt.json")); + + let anchor = project.path().join("trust.json"); + write_anchor(&anchor, &signing); + let runtime = write_runtime_config( + project.path(), + &build.join("package"), + &anchor, + published_report["packageRevision"].as_str().unwrap(), + "127.0.0.1:1".parse().unwrap(), + ); + let verified = registry_serverctl(&[ + "--format", + "json", + "verify", + "--runtime-config", + path(&runtime), + ]); + assert!(verified.status.success(), "{verified:?}"); + assert_eq!( + json_stdout(&verified)["packageRevision"], + published_report["packageRevision"] + ); + + let rendered = String::from_utf8(published.stdout).expect("package report is UTF-8"); + for forbidden in [ + path(project.path()), + path(&signatures), + &hex(&signature), + PACKAGE_VALUE_CANARY, + ] { + assert!(!rendered.contains(forbidden)); + } +} + +#[test] +fn package_refuses_missing_noncanonical_and_stale_receipts_before_output() { + let (project, _signing, key_id) = packaging_project(); + let fingerprint = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + let prepared = prepare_packaging_candidate( + &project, + PACKAGE_DATABASE, + fingerprint, + 1, + vec![key_id.clone()], + ); + let valid_receipt = schema_test_receipt_bytes(&prepared, &["package-record-list"]); + let receipt = project.path().join("candidate-receipt.json"); + let key_ids = vec![key_id.clone()]; + + let missing_build = project.path().join("missing-receipt-build"); + let missing = package_candidate_command( + &project, + PACKAGE_DATABASE, + fingerprint, + 1, + &key_ids, + &receipt, + &missing_build, + ); + assert_eq!(missing.status.code(), Some(1), "{missing:?}"); + let missing_report = json_stdout(&missing); + assert_eq!( + missing_report["diagnostics"][0]["code"], + "package.test_receipt.missing" + ); + assert_tool_diagnostic( + &missing_report["diagnostics"][0], + "schema_test_receipt", + "supply_schema_test_receipt", + ); + assert!(!missing_build.exists()); + + fs::write(&receipt, [&valid_receipt[..], b"\n"].concat()).expect("noncanonical receipt writes"); + let refused_build = project.path().join("noncanonical-receipt-build"); + let refused = package_candidate_command( + &project, + PACKAGE_DATABASE, + fingerprint, + 1, + &key_ids, + &receipt, + &refused_build, + ); + assert_eq!(refused.status.code(), Some(1), "{refused:?}"); + let refused_report = json_stdout(&refused); + assert_eq!( + refused_report["diagnostics"][0]["code"], + "package.test_receipt.refused" + ); + assert_tool_diagnostic( + &refused_report["diagnostics"][0], + "schema_test_receipt", + "supply_schema_test_receipt", + ); + assert!(!refused_build.exists()); + + for rendered in [ + String::from_utf8(missing.stdout).expect("missing diagnostic is UTF-8"), + String::from_utf8(refused.stdout).expect("refused diagnostic is UTF-8"), + ] { + assert!(!rendered.contains(path(project.path()))); + assert!(!rendered.contains("candidate-receipt")); + assert!(!rendered.contains(PACKAGE_DATABASE)); + } +} + +#[test] +fn package_receipt_is_stale_for_every_candidate_binding_change() { + let (project, _signing, key_id) = packaging_project(); + let fingerprint = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + let prepared = prepare_packaging_candidate( + &project, + PACKAGE_DATABASE, + fingerprint, + 1, + vec![key_id.clone()], + ); + let receipt = project.path().join("exact-receipt.json"); + fs::write( + &receipt, + schema_test_receipt_bytes(&prepared, &["package-record-list"]), + ) + .expect("schema-test receipt writes"); + let original_project = + fs::read(project.path().join("registry.yaml")).expect("original package project reads"); + let original_module = fs::read(project.path().join("modules/core/module.yaml")) + .expect("original package module reads"); + let original_journeys = fs::read(project.path().join(FIXTURE_JOURNEYS_PATH)) + .expect("original package journeys read"); + let original_project_text = + String::from_utf8(original_project.clone()).expect("project is UTF-8"); + let original_module_model = + parse_module_json(&original_module).expect("original package module parses"); + let original_module_digest = module_digest(&original_module_model); + let alternate_signing = generate_private_jwk(GeneratedKeyAlgorithm::Es384) + .expect("alternate signing key generates"); + let alternate_key_id = alternate_signing + .public() + .kid + .expect("alternate signing key has an id"); + + let altered_module = String::from_utf8(original_module.clone()) + .expect("module is UTF-8") + .replace("\"maxLength\":16", "\"maxLength\":17") + .into_bytes(); + let altered_module_model = + parse_module_json(&altered_module).expect("altered package module parses"); + let altered_module_digest = module_digest(&altered_module_model); + let cases = [ + ( + "database", + original_project.clone(), + original_module.clone(), + original_journeys.clone(), + "alternate-database".to_owned(), + fingerprint.to_owned(), + vec![key_id.clone()], + ), + ( + "fingerprint", + original_project.clone(), + original_module.clone(), + original_journeys.clone(), + PACKAGE_DATABASE.to_owned(), + "sha256:4444444444444444444444444444444444444444444444444444444444444444".to_owned(), + vec![key_id.clone()], + ), + ( + "signature-policy", + original_project.clone(), + original_module.clone(), + original_journeys.clone(), + PACKAGE_DATABASE.to_owned(), + fingerprint.to_owned(), + vec![alternate_key_id], + ), + ( + "project", + original_project_text + .replace(PACKAGE_SOURCE_REVISION, "alternate-source") + .into_bytes(), + original_module.clone(), + original_journeys.clone(), + PACKAGE_DATABASE.to_owned(), + fingerprint.to_owned(), + vec![key_id.clone()], + ), + ( + "module", + original_project_text + .replace(&original_module_digest, &altered_module_digest) + .into_bytes(), + altered_module, + original_journeys.clone(), + PACKAGE_DATABASE.to_owned(), + fingerprint.to_owned(), + vec![key_id.clone()], + ), + ( + "environment", + original_project_text + .replace( + "\"environment\":\"production\"", + "\"environment\":\"pilot\"", + ) + .into_bytes(), + original_module.clone(), + original_journeys.clone(), + PACKAGE_DATABASE.to_owned(), + fingerprint.to_owned(), + vec![key_id.clone()], + ), + ( + "instance", + original_project_text + .replace(PACKAGE_INSTANCE, "alternate-instance") + .into_bytes(), + original_module.clone(), + original_journeys.clone(), + PACKAGE_DATABASE.to_owned(), + fingerprint.to_owned(), + vec![key_id.clone()], + ), + ( + "journey", + original_project.clone(), + original_module.clone(), + String::from_utf8(original_journeys.clone()) + .expect("journeys are UTF-8") + .replace("package-record-list", "package-record-list-alternate") + .into_bytes(), + PACKAGE_DATABASE.to_owned(), + fingerprint.to_owned(), + vec![key_id.clone()], + ), + ]; + + for (name, project_bytes, module_bytes, journeys, database, fingerprint, keys) in cases { + fs::write(project.path().join("registry.yaml"), project_bytes) + .expect("altered project writes"); + fs::write( + project.path().join("modules/core/module.yaml"), + module_bytes, + ) + .expect("altered module writes"); + fs::write(project.path().join(FIXTURE_JOURNEYS_PATH), journeys) + .expect("altered journeys write"); + let build = project.path().join(format!("stale-{name}-build")); + let output = package_candidate_command( + &project, + &database, + &fingerprint, + 1, + &keys, + &receipt, + &build, + ); + assert_eq!(output.status.code(), Some(1), "{name}: {output:?}"); + let report = json_stdout(&output); + assert_eq!( + report["diagnostics"][0]["code"], "package.test_receipt.refused", + "{name}" + ); + assert!(!build.exists(), "{name}"); + let rendered = String::from_utf8(output.stdout).expect("diagnostic is UTF-8"); + assert!(!rendered.contains(path(project.path())), "{name}"); + } + + fs::write( + project.path().join("registry.yaml"), + original_project_text + .replace("\"sequence\":1", "\"sequence\":2") + .into_bytes(), + ) + .expect("successor project writes"); + fs::write( + project.path().join("modules/core/module.yaml"), + &original_module, + ) + .expect("original module restores"); + fs::write( + project.path().join(FIXTURE_JOURNEYS_PATH), + &original_journeys, + ) + .expect("original journeys restore"); + let baseline = RuntimePackageFixture::production("127.0.0.1:1".parse().unwrap()); + let sequence_build = project.path().join("stale-sequence-build"); + let signature_key_arg = format!("--signature-key-id={key_id}"); + let sequence = registry_serverctl(&[ + "--format", + "json", + "package", + path(project.path()), + "--database-id", + PACKAGE_DATABASE, + "--schema-fingerprint", + fingerprint, + "--signature-threshold", + "1", + &signature_key_arg, + "--baseline-runtime-config", + path(&baseline.runtime_config), + "--test-receipt", + path(&receipt), + "--output", + path(&sequence_build), + ]); + assert_eq!(sequence.status.code(), Some(1), "{sequence:?}"); + assert_eq!( + json_stdout(&sequence)["diagnostics"][0]["code"], + "package.test_receipt.refused" + ); + assert!(!sequence_build.exists()); +} + +#[test] +fn package_resume_requires_the_exact_receipt_evidence() { + let (project, _signing, key_id) = packaging_project(); + let fingerprint = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + let prepared = prepare_packaging_candidate( + &project, + PACKAGE_DATABASE, + fingerprint, + 1, + vec![key_id.clone()], + ); + let receipt = project.path().join("resume-receipt.json"); + let receipt_bytes = schema_test_receipt_bytes(&prepared, &["package-record-list"]); + fs::write(&receipt, &receipt_bytes).expect("schema-test receipt writes"); + let build = project.path().join("resume-build"); + let key_ids = vec![key_id]; + + let first = package_candidate_command( + &project, + PACKAGE_DATABASE, + fingerprint, + 1, + &key_ids, + &receipt, + &build, + ); + assert!(first.status.success(), "{first:?}"); + assert!(!build.join("package").exists()); + + fs::remove_file(build.join("schema-test-receipt.json")) + .expect("build receipt evidence removes"); + let missing = package_candidate_command( + &project, + PACKAGE_DATABASE, + fingerprint, + 1, + &key_ids, + &receipt, + &build, + ); + assert_eq!(missing.status.code(), Some(1), "{missing:?}"); + assert_eq!( + json_stdout(&missing)["diagnostics"][0]["code"], + "package.test_receipt.refused" + ); + assert!(!build.join("package").exists()); + + fs::write(build.join("schema-test-receipt.json"), b"substituted") + .expect("substituted receipt evidence writes"); + let substituted = package_candidate_command( + &project, + PACKAGE_DATABASE, + fingerprint, + 1, + &key_ids, + &receipt, + &build, + ); + assert_eq!(substituted.status.code(), Some(1), "{substituted:?}"); + assert_eq!( + json_stdout(&substituted)["diagnostics"][0]["code"], + "package.test_receipt.refused" + ); + assert!(!build.join("package").exists()); + + fs::write(build.join("schema-test-receipt.json"), receipt_bytes) + .expect("exact receipt evidence restores"); + let resumed = package_candidate_command( + &project, + PACKAGE_DATABASE, + fingerprint, + 1, + &key_ids, + &receipt, + &build, + ); + assert!(resumed.status.success(), "{resumed:?}"); + assert_eq!(json_stdout(&resumed)["state"], "awaiting_signatures"); +} + +#[test] +fn local_package_requires_a_receipt_and_publishes_without_external_signatures() { + let (project, _signing, _key_id) = packaging_project(); + let project_path = project.path().join("registry.yaml"); + let local_source = String::from_utf8(fs::read(&project_path).expect("project reads")) + .expect("project is UTF-8") + .replace( + "\"environment\":\"production\"", + "\"environment\":\"local\"", + ); + fs::write(&project_path, local_source).expect("local project writes"); + let fingerprint = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + let prepared = prepare_packaging_candidate(&project, PACKAGE_DATABASE, fingerprint, 0, vec![]); + let receipt = project.path().join("local-receipt.json"); + let receipt_bytes = schema_test_receipt_bytes(&prepared, &["package-record-list"]); + fs::write(&receipt, &receipt_bytes).expect("local receipt writes"); + let build = project.path().join("local-build"); + + let output = package_candidate_command( + &project, + PACKAGE_DATABASE, + fingerprint, + 0, + &[], + &receipt, + &build, + ); + assert!(output.status.success(), "{output:?}"); + assert_eq!(json_stdout(&output)["state"], "published"); + assert_eq!( + fs::read(build.join("schema-test-receipt.json")).expect("reviewer receipt reads"), + receipt_bytes + ); + assert!(build.join("package/package.json").is_file()); + assert!(!tree(&build.join("package")) + .keys() + .any(|entry| entry.contains("schema-test-receipt"))); +} + +#[test] +fn test_help_requires_test_inputs_and_exposes_no_package_or_apply_authority() { + let help = registry_serverctl(&["test", "--help"]); + assert!(help.status.success(), "{help:?}"); + let rendered = String::from_utf8(help.stdout).expect("help is UTF-8"); + for required in [ + "--runtime-config", + "--credentials", + "--output", + "--database-id", + ] { + assert!(rendered.contains(required), "help omits {required}"); + } + for forbidden in [ + "--test-receipt", + "--signatures", + "--schema-fingerprint", + "--package", + "--initial", + "--backup", + ] { + assert!(!rendered.contains(forbidden), "help exposes {forbidden}"); + } + + let missing = registry_serverctl(&["--format", "json", "test"]); + assert_eq!(missing.status.code(), Some(2), "{missing:?}"); + assert_eq!( + json_stdout(&missing)["diagnostics"][0]["code"], + "usage.invalid" + ); + + let project = TestProject::from_registry_source(authoring_fixture()); + let rejected_schema_fingerprint = registry_serverctl(&[ + "--format", + "json", + "test", + path(project.path()), + "--database-id", + PACKAGE_DATABASE, + "--schema-fingerprint", + "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "--runtime-config", + path(&project.path().join("runtime.yaml")), + "--credentials", + path(&project.path().join("credentials.yaml")), + "--output", + path(&project.path().join("receipt.json")), + ]); + assert_eq!(rejected_schema_fingerprint.status.code(), Some(2)); + assert_eq!( + json_stdout(&rejected_schema_fingerprint)["diagnostics"][0]["code"], + "usage.invalid" + ); + + let package_requires_schema_fingerprint = registry_serverctl(&[ + "--format", + "json", + "package", + path(project.path()), + "--database-id", + PACKAGE_DATABASE, + "--test-receipt", + path(&project.path().join("receipt.json")), + "--output", + path(&project.path().join("build")), + ]); + assert_eq!(package_requires_schema_fingerprint.status.code(), Some(2)); + assert_eq!( + json_stdout(&package_requires_schema_fingerprint)["diagnostics"][0]["code"], + "usage.invalid" + ); +} + +#[test] +fn test_credentials_are_strict_secret_refs_and_preflight_before_database() { + let (project, _signing, key_id) = packaging_project(); + let runtime = test_runtime_config(&project); + write_test_secret(&project, "operator-token", b"aaa.bbb.ccc"); + let key_ids = vec![key_id.clone()]; + + let cases = [ + ( + "duplicate-field", + r#"apiVersion: registry.registrystack.org/server-schema-test-credentials/v1 +apiVersion: registry.registrystack.org/server-schema-test-credentials/v1 +kind: SchemaTestCredentials +bindings: [] +"# + .to_owned(), + ), + ( + "unknown-field", + r#"apiVersion: registry.registrystack.org/server-schema-test-credentials/v1 +kind: SchemaTestCredentials +bindings: [] +literal: aaa.bbb.ccc +"# + .to_owned(), + ), + ( + "literal-token", + credential_source("type: bearer\n token: aaa.bbb.ccc\n"), + ), + ("missing-token-ref", credential_source("type: bearer\n")), + ( + "extra-token-ref", + credential_source("type: anonymous\n tokenRef: secret:file/operator-token\n"), + ), + ( + "wrong-discriminator", + credential_source("mode: bearer\n tokenRef: secret:file/operator-token\n"), + ), + ( + "literal-ref", + credential_source("type: bearer\n tokenRef: aaa.bbb.ccc\n"), + ), + ( + "unknown-provider", + credential_source("type: bearer\n tokenRef: secret:literal/operator-token\n"), + ), + ( + "missing-coverage", + r#"apiVersion: registry.registrystack.org/server-schema-test-credentials/v1 +kind: SchemaTestCredentials +bindings: [] +"# + .to_owned(), + ), + ( + "duplicate-binding", + r#"apiVersion: registry.registrystack.org/server-schema-test-credentials/v1 +kind: SchemaTestCredentials +bindings: + - journeyId: package-record-list + stepId: list-records + credential: + type: bearer + tokenRef: secret:file/operator-token + - journeyId: package-record-list + stepId: list-records + credential: + type: bearer + tokenRef: secret:file/operator-token +"# + .to_owned(), + ), + ]; + + for (name, source) in cases { + let credentials = project.path().join(format!("credentials-{name}.yaml")); + fs::write(&credentials, source).expect("credential fixture writes"); + let output = project.path().join(format!("receipt-{name}.json")); + let result = test_candidate_command(&project, 1, &key_ids, &runtime, &credentials, &output); + assert_schema_test_refusal( + result, + "test.credentials.refused", + "schema_test_credentials", + "supply_schema_test_credentials", + &output, + &[ + path(&credentials), + "aaa.bbb.ccc", + "secret:file/operator-token", + ], + ); + } +} + +#[test] +fn test_credentials_secret_value_failures_are_preflight_and_value_free() { + let (project, _signing, key_id) = packaging_project(); + let runtime = test_runtime_config(&project); + let key_ids = vec![key_id.clone()]; + let cases: Vec<(&str, Vec)> = vec![ + ("utf8", vec![0xff, b'.', b'a', b'.', b'b']), + ("empty", Vec::new()), + ("oversized", vec![b'a'; 65 * 1024]), + ("malformed-token", b"not.a-token!".to_vec()), + ]; + + for (name, secret) in cases { + let secret_name = format!("operator-token-{name}"); + write_test_secret(&project, &secret_name, &secret); + let credentials = project + .path() + .join(format!("secret-credentials-{name}.yaml")); + fs::write( + &credentials, + credential_source(&format!( + "type: bearer\n tokenRef: secret:file/{secret_name}\n" + )), + ) + .expect("credential fixture writes"); + let output = project.path().join(format!("secret-receipt-{name}.json")); + let result = test_candidate_command(&project, 1, &key_ids, &runtime, &credentials, &output); + assert_schema_test_refusal( + result, + "test.credentials.refused", + "schema_test_credentials", + "supply_schema_test_credentials", + &output, + &[path(&credentials), &secret_name, "not.a-token!"], + ); + } +} + +#[test] +fn test_valid_credentials_reach_database_and_never_publish_partial_receipts() { + let (project, _signing, key_id) = packaging_project(); + let runtime = test_runtime_config(&project); + write_test_secret(&project, "operator-token", b"aaa.bbb.ccc"); + let credentials = project.path().join("valid-credentials.yaml"); + fs::write( + &credentials, + credential_source("type: bearer\n tokenRef: secret:file/operator-token\n"), + ) + .expect("credential fixture writes"); + let output = project.path().join("schema-test-receipt.json"); + let result = test_candidate_command(&project, 1, &[key_id], &runtime, &credentials, &output); + + assert_schema_test_refusal( + result, + "test.database.unavailable", + "schema_test_database", + "recreate_disposable_database", + &output, + &[ + path(project.path()), + path(&runtime), + path(&credentials), + "aaa.bbb.ccc", + "secret:file/operator-token", + PACKAGE_VALUE_CANARY, + ], + ); + assert!(!project.path().join("signing-input.json").exists()); + assert!(!project.path().join("package").exists()); + assert!(!project.path().join("apply").exists()); + assert!( + fs::read_dir(project.path()) + .expect("project directory reads") + .all(|entry| !entry + .expect("entry reads") + .file_name() + .to_string_lossy() + .starts_with(".registry-serverctl-test-receipt-")), + "temporary receipt files are cleaned up" + ); +} + +#[test] +fn test_runtime_database_id_mismatch_is_candidate_refused_before_rehearsal() { + let (project, _signing, key_id) = packaging_project(); + let runtime = test_runtime_config(&project); + write_test_secret(&project, "operator-token", b"aaa.bbb.ccc"); + let credentials = project.path().join("database-mismatch-credentials.yaml"); + fs::write( + &credentials, + credential_source("type: bearer\n tokenRef: secret:file/operator-token\n"), + ) + .expect("credential fixture writes"); + let output = project.path().join("database-mismatch-receipt.json"); + let result = test_candidate_command_for_database( + &project, + "wrong-database", + 1, + &[key_id], + &runtime, + &credentials, + &output, + ); + + assert_schema_test_refusal( + result, + "test.candidate.refused", + "schema_test_candidate", + "correct_schema_test_candidate", + &output, + &[ + path(&runtime), + path(&credentials), + "wrong-database", + PACKAGE_DATABASE, + "aaa.bbb.ccc", + "secret:file/operator-token", + PACKAGE_VALUE_CANARY, + "VERIFY_MIGRATION_DATABASE_SECRET_IS_NOT_OPENED", + ], + ); +} + +#[test] +fn test_deterministic_candidate_errors_are_refused_before_rehearsal() { + let (project, _signing, key_id) = packaging_project(); + let runtime = test_runtime_config(&project); + write_test_secret(&project, "operator-token", b"aaa.bbb.ccc"); + let credentials = project.path().join("invalid-policy-credentials.yaml"); + fs::write( + &credentials, + credential_source("type: bearer\n tokenRef: secret:file/operator-token\n"), + ) + .expect("credential fixture writes"); + let output = project.path().join("invalid-policy-receipt.json"); + let result = test_candidate_command(&project, 2, &[key_id], &runtime, &credentials, &output); + + assert_schema_test_refusal( + result, + "test.candidate.refused", + "schema_test_candidate", + "correct_schema_test_candidate", + &output, + &[ + path(&runtime), + path(&credentials), + "aaa.bbb.ccc", + "secret:file/operator-token", + PACKAGE_VALUE_CANARY, + "VERIFY_MIGRATION_DATABASE_SECRET_IS_NOT_OPENED", + ], + ); +} + +#[test] +fn authoring_and_test_candidate_sources_are_read_once_and_bounded() { + let oversized_yaml_comment = vec![b'#'; SCHEMA_TEST_AUTHORED_SOURCE_CEILING_BYTES + 1]; + + let (project, _signing, key_id) = packaging_project(); + fs::write( + project.path().join("registry.yaml"), + &oversized_yaml_comment, + ) + .expect("oversized project source writes"); + let check_project = registry_serverctl(&["--format", "json", "check", path(project.path())]); + assert_eq!(check_project.status.code(), Some(1), "{check_project:?}"); + assert_eq!( + json_stdout(&check_project)["diagnostics"][0]["code"], + "source.file.bounds" + ); + let test_project = test_candidate_command( + &project, + 1, + &[key_id], + &project.path().join("unused-runtime.yaml"), + &project.path().join("unused-credentials.yaml"), + &project.path().join("oversized-project-receipt.json"), + ); + assert_schema_test_refusal( + test_project, + "source.file.bounds", + "registry_project", + "correct_authoring_source", + &project.path().join("oversized-project-receipt.json"), + &[path(project.path()), "unused-runtime", "unused-credentials"], + ); + + let (project, _signing, key_id) = packaging_project(); + fs::write( + project.path().join("modules/core/module.yaml"), + oversized_yaml_comment, + ) + .expect("oversized module source writes"); + let check_module = registry_serverctl(&["--format", "json", "check", path(project.path())]); + assert_eq!(check_module.status.code(), Some(1), "{check_module:?}"); + assert_eq!( + json_stdout(&check_module)["diagnostics"][0]["code"], + "source.file.bounds" + ); + let test_module = test_candidate_command( + &project, + 1, + &[key_id], + &project.path().join("unused-runtime.yaml"), + &project.path().join("unused-credentials.yaml"), + &project.path().join("oversized-module-receipt.json"), + ); + assert_schema_test_refusal( + test_module, + "source.file.bounds", + "registry_project", + "correct_authoring_source", + &project.path().join("oversized-module-receipt.json"), + &[path(project.path()), "unused-runtime", "unused-credentials"], + ); +} + +#[test] +fn test_output_target_is_absolute_new_and_under_existing_non_symlink_parent() { + let project = TestProject::from_registry_source(authoring_fixture()); + let missing_parent = project.path().join("missing").join("receipt.json"); + let existing = project.path().join("existing-receipt.json"); + fs::write(&existing, b"operator-owned").expect("existing output writes"); + let credentials = project.path().join("unused-credentials.yaml"); + let runtime = project.path().join("unused-runtime.yaml"); + + for output in [ + Path::new("relative-receipt.json"), + missing_parent.as_path(), + &existing, + ] { + let result = registry_serverctl(&[ + "--format", + "json", + "test", + path(project.path()), + "--database-id", + PACKAGE_DATABASE, + "--runtime-config", + path(&runtime), + "--credentials", + path(&credentials), + "--output", + path(output), + ]); + assert_eq!(result.status.code(), Some(1), "{result:?}"); + assert_eq!( + json_stdout(&result)["diagnostics"][0]["code"], + "test.output.refused" + ); + } + assert_eq!( + fs::read(&existing).expect("existing output remains"), + b"operator-owned" + ); +} + +#[cfg(unix)] +#[test] +fn test_output_symlink_parent_is_refused_before_candidate_or_database_work() { + use std::os::unix::fs::symlink; + + let project = TestProject::from_registry_source(authoring_fixture()); + let real = project.path().join("real-parent"); + let linked = project.path().join("linked-parent"); + fs::create_dir(&real).expect("real parent creates"); + symlink(&real, &linked).expect("symlink parent creates"); + let output = linked.join("receipt.json"); + let result = registry_serverctl(&[ + "--format", + "json", + "test", + path(project.path()), + "--database-id", + PACKAGE_DATABASE, + "--runtime-config", + path(&project.path().join("runtime.yaml")), + "--credentials", + path(&project.path().join("credentials.yaml")), + "--output", + path(&output), + ]); + + assert_eq!(result.status.code(), Some(1), "{result:?}"); + assert_eq!( + json_stdout(&result)["diagnostics"][0]["code"], + "test.output.refused" + ); + assert!(!real.join("receipt.json").exists()); +} + +#[cfg(unix)] +#[test] +fn package_fixture_journey_source_is_required_regular_bounded_and_value_free() { + use std::os::unix::fs::symlink; + + let (project, _signing, _key_id) = packaging_project(); + let journey_path = project.path().join("tests/journeys.yaml"); + let build = project.path().join("fixture-source-build"); + let missing_receipt = project.path().join("missing-receipt.json"); + let arguments = [ + "--format", + "json", + "package", + path(project.path()), + "--database-id", + PACKAGE_DATABASE, + "--schema-fingerprint", + "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "--test-receipt", + path(&missing_receipt), + "--output", + path(&build), + ]; + + fs::remove_file(&journey_path).expect("fixture journeys remove"); + let missing = registry_serverctl(&arguments); + assert_eq!(missing.status.code(), Some(1), "{missing:?}"); + assert_eq!( + json_stdout(&missing)["diagnostics"][0]["code"], + "source.fixture_journeys.missing" + ); + + let target = project.path().join("journey-source-canary.yaml"); + fs::write(&target, b"journey-source-value-canary").expect("symlink target writes"); + symlink(&target, &journey_path).expect("fixture journey symlink creates"); + let linked = registry_serverctl(&arguments); + assert_eq!(linked.status.code(), Some(1), "{linked:?}"); + assert_eq!( + json_stdout(&linked)["diagnostics"][0]["code"], + "source.file.invalid" + ); + fs::remove_file(&journey_path).expect("fixture journey symlink removes"); + + fs::write( + &journey_path, + vec![b'x'; usize::try_from(MAX_PACKAGE_SOURCE_FILE_BYTES).unwrap() + 1], + ) + .expect("oversized fixture journey writes"); + let oversized = registry_serverctl(&arguments); + assert_eq!(oversized.status.code(), Some(1), "{oversized:?}"); + assert_eq!( + json_stdout(&oversized)["diagnostics"][0]["code"], + "source.file.bounds" + ); + + for output in [missing, linked, oversized] { + let rendered = String::from_utf8(output.stdout).expect("diagnostic is UTF-8"); + assert!(!rendered.contains(path(project.path()))); + assert!(!rendered.contains("journey-source-value-canary")); + } + assert!(!build.exists()); +} + +#[test] +fn package_always_uses_production_compilation_and_never_offers_a_signing_command() { + let project = TestProject::from_registry_source(authoring_fixture()); + let build = project.path().join("build"); + let missing_receipt = project.path().join("missing-receipt.json"); + let output = registry_serverctl(&[ + "--format", + "json", + "package", + path(project.path()), + "--database-id", + PACKAGE_DATABASE, + "--schema-fingerprint", + "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "--test-receipt", + path(&missing_receipt), + "--output", + path(&build), + ]); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + let report = json_stdout(&output); + assert!(report["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|diagnostic| diagnostic["code"] == "package.identity.required")); + assert!(!build.exists()); + + let help = registry_serverctl(&["--help"]); + let help = String::from_utf8(help.stdout).expect("help is UTF-8"); + assert!(!help + .lines() + .any(|line| line.trim_start().starts_with("sign"))); +} + +#[test] +fn apply_verifies_package_intent_before_database_authority_and_stays_value_free() { + let relative = registry_serverctl(&[ + "--format", + "json", + "apply", + "--runtime-config", + PACKAGE_VALUE_CANARY, + "--package", + PACKAGE_VALUE_CANARY, + ]); + assert_eq!(relative.status.code(), Some(1), "{relative:?}"); + assert_eq!( + json_stdout(&relative)["diagnostics"][0]["code"], + "apply.runtime_config.path_invalid" + ); + assert!(!String::from_utf8_lossy(&relative.stdout).contains(PACKAGE_VALUE_CANARY)); + + let fixture = RuntimePackageFixture::production("127.0.0.1:1".parse().unwrap()); + let malformed_backup = registry_serverctl(&[ + "--format", + "json", + "apply", + "--runtime-config", + path(&fixture.runtime_config), + "--package", + path(&fixture.package), + "--initial", + "--backup", + PACKAGE_VALUE_CANARY, + ]); + assert_eq!( + malformed_backup.status.code(), + Some(1), + "{malformed_backup:?}" + ); + assert_eq!( + json_stdout(&malformed_backup)["diagnostics"][0]["code"], + "apply.backup_evidence.refused" + ); + assert!(!String::from_utf8_lossy(&malformed_backup.stdout).contains(PACKAGE_VALUE_CANARY)); + + let output = registry_serverctl(&[ + "--format", + "json", + "apply", + "--runtime-config", + path(&fixture.runtime_config), + "--package", + path(&fixture.package), + ]); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(output.stderr.is_empty()); + let report = json_stdout(&output); + assert_eq!(report["diagnostics"][0]["code"], "apply.package.refused"); + assert_tool_diagnostic( + &report["diagnostics"][0], + "verified_package", + "verify_package_binding", + ); + let rendered = String::from_utf8(output.stdout).expect("apply refusal is UTF-8"); + for forbidden in [ + path(&fixture.runtime_config), + path(&fixture.package), + path(&fixture.anchor), + PACKAGE_VALUE_CANARY, + "VERIFY_DATABASE_SECRET_IS_NOT_OPENED", + ] { + assert!(!rendered.contains(forbidden)); + } + + let database_refusal = registry_serverctl(&[ + "--format", + "json", + "apply", + "--runtime-config", + path(&fixture.runtime_config), + "--package", + path(&fixture.package), + "--initial", + ]); + assert_eq!( + database_refusal.status.code(), + Some(1), + "{database_refusal:?}" + ); + assert_eq!( + json_stdout(&database_refusal)["diagnostics"][0]["code"], + "apply.database_configuration.refused" + ); + assert!(!String::from_utf8_lossy(&database_refusal.stdout) + .contains("VERIFY_DATABASE_SECRET_IS_NOT_OPENED")); +} + +#[test] +fn verify_is_runtime_bound_deterministic_and_listener_free() { + let occupied = TcpListener::bind("127.0.0.1:0").expect("listener proof binds one local port"); + let fixture = RuntimePackageFixture::production( + occupied + .local_addr() + .expect("listener proof address is available"), + ); + let arguments = [ + "--format", + "json", + "verify", + "--runtime-config", + path(&fixture.runtime_config), + ]; + let first = registry_serverctl(&arguments); + let second = registry_serverctl(&arguments); + + assert!(first.status.success(), "{first:?}"); + assert!(first.stderr.is_empty()); + assert_eq!(first.stdout, second.stdout, "verify output is byte stable"); + let report = json_stdout(&first); + assert_eq!( + report, + json!({ + "ok": true, + "command": "verify", + "assurance": "runtime_bound", + "packageRevision": fixture.package_revision, + "registry": { + "id": "verify-registry", + "version": "1", + "revision": report["registry"]["revision"], + }, + "inventory": { + "modules": 1, + "entities": 1, + "routes": 2, + "accessEntries": 2, + "queries": 1, + "eventDeliveries": 0, + "ddlStatements": report["inventory"]["ddlStatements"], + "generatedArtifacts": report["inventory"]["generatedArtifacts"], + } + }) + ); + assert!(report["inventory"]["ddlStatements"].as_u64().unwrap() > 0); + assert!(report["inventory"]["generatedArtifacts"].as_u64().unwrap() > 0); + let rendered = String::from_utf8(first.stdout).expect("verify JSON is UTF-8"); + for forbidden in [ + PACKAGE_VALUE_CANARY, + path(&fixture.runtime_config), + path(&fixture.package), + path(&fixture.anchor), + "oidc-is-not-opened.invalid", + "VERIFY_DATABASE_SECRET_IS_NOT_OPENED", + ] { + assert!(!rendered.contains(forbidden)); + } + + let human = registry_serverctl(&["verify", "--runtime-config", path(&fixture.runtime_config)]); + assert!(human.status.success(), "{human:?}"); + assert!(human.stderr.is_empty()); + let human = String::from_utf8(human.stdout).expect("verify human report is UTF-8"); + assert!(human.starts_with("verify succeeded\nassurance: runtime_bound\n")); + assert!(human.contains(&format!("package revision: {}\n", fixture.package_revision))); + assert!(human.contains("registry id: verify-registry\n")); + assert!(!human.contains(path(&fixture.runtime_config))); +} + +#[test] +fn migration_explain_is_runtime_bound_deterministic_and_listener_free() { + let occupied = TcpListener::bind("127.0.0.1:0").expect("listener proof binds one local port"); + let fixture = RuntimePackageFixture::production( + occupied + .local_addr() + .expect("listener proof address is available"), + ); + let arguments = [ + "--format", + "json", + "migration", + "explain", + "--runtime-config", + path(&fixture.runtime_config), + ]; + let first = registry_serverctl(&arguments); + let second = registry_serverctl(&arguments); + + assert!(first.status.success(), "{first:?}"); + assert!(first.stderr.is_empty()); + assert_eq!(first.stdout, second.stdout, "report output is byte stable"); + let report = json_stdout(&first); + assert!(report["plan"]["generatedStatementCount"] + .as_u64() + .is_some_and(|count| count > 0)); + assert_eq!( + report, + json!({ + "ok": true, + "command": "migration explain", + "assurance": "runtime_bound", + "packageRevision": fixture.package_revision, + "plan": { + "planKind": "initial", + "hasPriorRevision": false, + "hasPriorBaseline": false, + "changeCount": 0, + "changeCounts": { + "compatibleAdditive": 0, + "dataBackfillRequired": 0, + "accessOrDisclosureChange": 0, + "destructiveOrIrreversible": 0, + "unsupported": 0, + }, + "generatedStatementCount": report["plan"]["generatedStatementCount"], + "reviewedMigrations": [], + } + }) + ); + let rendered = String::from_utf8(first.stdout).expect("migration JSON is UTF-8"); + for forbidden in [ + PACKAGE_VALUE_CANARY, + path(&fixture.runtime_config), + path(&fixture.package), + path(&fixture.anchor), + "CREATE TABLE", + "signature", + ] { + assert!(!rendered.contains(forbidden)); + } + + let human = registry_serverctl(&[ + "migration", + "explain", + "--runtime-config", + path(&fixture.runtime_config), + ]); + assert!(human.status.success(), "{human:?}"); + assert!(human.stderr.is_empty()); + let human = String::from_utf8(human.stdout).expect("migration report is UTF-8"); + assert!(human.starts_with("migration explain succeeded\nassurance: runtime_bound\n")); + assert!(human.contains("plan kind: initial\n")); + assert!(human.contains("change count: 0\n")); + assert!(human.contains("reviewed migration count: 0\n")); + assert!(!human.contains(path(&fixture.runtime_config))); +} + +#[test] +fn lifecycle_parser_surfaces_are_exact_and_value_free() { + let help = registry_serverctl(&["--help"]); + assert!(help.status.success()); + let rendered = String::from_utf8(help.stdout).expect("top-level help is UTF-8"); + for available in ["package", "apply", "verify", "migration", "data"] { + assert!(rendered + .lines() + .any(|line| line.trim_start().starts_with(available))); + } + assert!(!rendered + .lines() + .any(|line| line.trim_start().starts_with("webhook"))); + + for arguments in [ + vec!["verify", "--help"], + vec!["migration", "explain", "--help"], + vec!["data", "validate", "--help"], + vec!["data", "import", "--help"], + vec!["data", "export", "--help"], + ] { + let output = registry_serverctl(&arguments); + assert!(output.status.success(), "{output:?}"); + let help = String::from_utf8(output.stdout).expect("command help is UTF-8"); + if arguments.first() == Some(&"data") { + assert!(help.contains("--package ")); + assert!(!help.contains("runtime-config")); + assert!(!help.contains("database")); + } else { + assert!(help.contains("--runtime-config ")); + } + } + let migration_help = registry_serverctl(&["migration", "--help"]); + let migration_help = String::from_utf8(migration_help.stdout).expect("migration help is UTF-8"); + assert!(migration_help + .lines() + .any(|line| line.trim_start().starts_with("explain"))); + let package_help = registry_serverctl(&["package", "--help"]); + let package_help = String::from_utf8(package_help.stdout).expect("package help is UTF-8"); + for required in [ + "--database-id ", + "--schema-fingerprint ", + "--test-receipt ", + "--output ", + "--signatures ", + ] { + assert!(package_help.contains(required)); + } + let apply_help = registry_serverctl(&["apply", "--help"]); + let apply_help = String::from_utf8(apply_help.stdout).expect("apply help is UTF-8"); + for required in [ + "--runtime-config ", + "--package ", + "--initial", + "--backup ", + ] { + assert!(apply_help.contains(required)); + } + + for arguments in [ + vec!["--format", "json", "package", PACKAGE_VALUE_CANARY], + vec![ + "--format", + "json", + "apply", + "--runtime-config", + PACKAGE_VALUE_CANARY, + ], + vec!["--format", "json", "verify"], + vec![ + "--format", + "json", + "verify", + "--runtime-config", + PACKAGE_VALUE_CANARY, + "--package", + PACKAGE_VALUE_CANARY, + ], + vec!["--format", "json", "migration"], + vec![ + "--format", + "json", + "migration", + "explain", + "--runtime-config", + PACKAGE_VALUE_CANARY, + "--package", + PACKAGE_VALUE_CANARY, + ], + vec!["--format", "json", "data"], + vec![ + "--format", + "json", + "data", + "validate", + "--runtime-config", + PACKAGE_VALUE_CANARY, + ], + ] { + let output = registry_serverctl(&arguments); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let rendered = String::from_utf8(output.stdout).expect("usage refusal is UTF-8"); + assert!(!rendered.contains(PACKAGE_VALUE_CANARY)); + assert_eq!( + serde_json::from_str::(&rendered).unwrap()["diagnostics"][0]["code"], + "usage.invalid" + ); + } +} + +#[test] +fn runtime_bound_package_refusals_are_exact_and_value_free_for_both_commands() { + let fixture = RuntimePackageFixture::production("127.0.0.1:1".parse().unwrap()); + + for (prefix, command) in [ + ("verify", vec!["verify"]), + ("migration.explain", vec!["migration", "explain"]), + ] { + let mut arguments = vec!["--format", "json"]; + arguments.extend(command); + arguments.extend(["--runtime-config", PACKAGE_VALUE_CANARY]); + assert_inspection_refusal( + &arguments, + &format!("{prefix}.runtime_config.path_invalid"), + "runtime_configuration", + "correct_runtime_configuration", + &[PACKAGE_VALUE_CANARY], + ); + } + + let wrong = + generate_private_jwk(GeneratedKeyAlgorithm::Es384).expect("wrong trust key generates"); + let wrong_anchor = fixture + .directory + .path() + .join(format!("{PACKAGE_VALUE_CANARY}.json")); + write_anchor(&wrong_anchor, &wrong); + let wrong_trust = fixture.variant("wrong-trust", path(&fixture.anchor), path(&wrong_anchor)); + let wrong_binding = fixture.variant( + "wrong-binding", + &format!("activeRevision: {}", fixture.package_revision), + &format!("activeRevision: {PACKAGE_VALUE_CANARY}"), + ); + + for (runtime, suffix, action) in [ + (&wrong_trust, "signature_refused", "verify_package_trust"), + (&wrong_binding, "binding_refused", "verify_package_binding"), + ] { + for (prefix, command) in [ + ("verify", vec!["verify"]), + ("migration.explain", vec!["migration", "explain"]), + ] { + let mut arguments = vec!["--format", "json"]; + arguments.extend(command); + arguments.extend(["--runtime-config", path(runtime)]); + assert_inspection_refusal( + &arguments, + &format!("{prefix}.package.{suffix}"), + "verified_package", + action, + &[ + PACKAGE_VALUE_CANARY, + path(runtime), + path(&fixture.package), + path(&wrong_anchor), + ], + ); + } + } +} + +#[test] +fn canonical_package_tampering_is_refused_without_rendering_package_values() { + let fixture = RuntimePackageFixture::production("127.0.0.1:1".parse().unwrap()); + let manifest = fixture.package.join("package.json"); + let mut bytes = fs::read(&manifest).expect("package manifest reads"); + bytes.push(b'\n'); + set_owner_writable(&manifest); + fs::write(&manifest, bytes).expect("package manifest tampers"); + set_owner_read_only(&manifest); + + for (prefix, command) in [ + ("verify", vec!["verify"]), + ("migration.explain", vec!["migration", "explain"]), + ] { + let mut arguments = vec!["--format", "json"]; + arguments.extend(command); + arguments.extend(["--runtime-config", path(&fixture.runtime_config)]); + assert_inspection_refusal( + &arguments, + &format!("{prefix}.package.integrity_refused"), + "verified_package", + "verify_package_integrity", + &[ + PACKAGE_VALUE_CANARY, + path(&fixture.runtime_config), + path(&fixture.package), + ], + ); + } +} + +#[cfg(unix)] +#[test] +fn unsafe_package_permissions_are_refused_without_rendering_paths() { + let fixture = RuntimePackageFixture::production("127.0.0.1:1".parse().unwrap()); + let manifest = fixture.package.join("package.json"); + set_group_writable(&manifest); + for (prefix, command) in [ + ("verify", vec!["verify"]), + ("migration.explain", vec!["migration", "explain"]), + ] { + let mut arguments = vec!["--format", "json"]; + arguments.extend(command); + arguments.extend(["--runtime-config", path(&fixture.runtime_config)]); + assert_inspection_refusal( + &arguments, + &format!("{prefix}.package.permissions_refused"), + "verified_package", + "verify_package_permissions", + &[path(&fixture.runtime_config), path(&fixture.package)], + ); + } +} + +#[test] +fn unknown_source_is_refused_without_echoing_source_values() { + const SOURCE_VALUE_CANARY: &str = "registry-serverctl-source-value-canary"; + + let project = TestProject::asset_fixture(); + let mut source = String::from_utf8(asset_fixture().to_vec()).expect("fixture is UTF-8"); + source.push_str(&format!("\nunexpectedSetting: {SOURCE_VALUE_CANARY}\n")); + fs::write(project.path().join("registry.yaml"), source).expect("unknown source is written"); + + let output = registry_serverctl(&[ + "--format", + "json", + "check", + project.path().to_str().expect("path is UTF-8"), + ]); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stderr.is_empty()); + assert!(!String::from_utf8_lossy(&output.stdout).contains(SOURCE_VALUE_CANARY)); + let report = json_stdout(&output); + assert_eq!(report["diagnostics"][0]["code"], "source.yaml.invalid"); + assert_tool_diagnostic( + &report["diagnostics"][0], + "registry_project", + "correct_authoring_source", + ); +} + +#[test] +fn json_usage_errors_are_machine_readable_and_value_free() { + const ARGUMENT_CANARY: &str = "registry-serverctl-argument-canary"; + + let output = registry_serverctl(&["--format", "json", "check", ARGUMENT_CANARY, "--unknown"]); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + assert!(!String::from_utf8_lossy(&output.stdout).contains(ARGUMENT_CANARY)); + let report = json_stdout(&output); + assert_eq!(report["command"], "usage"); + assert_eq!(report["diagnostics"][0]["code"], "usage.invalid"); + assert_tool_diagnostic( + &report["diagnostics"][0], + "command_arguments", + "correct_command_usage", + ); +} + +#[test] +fn data_validate_uses_a_closed_package_plan_and_value_free_usage() { + const DATA_CANARY: &str = "registry-serverctl-data-value-canary"; + + let (directory, package) = data_package_fixture(); + let input = directory.path().join("input.jsonl"); + fs::write( + &input, + r#"{"operation":"create","data":{"code":"AA"}}"#.to_owned() + "\n", + ) + .expect("data input writes"); + + let output = registry_serverctl(&[ + "--format", + "json", + "data", + "validate", + "--package", + path(&package), + "--entity", + "record", + "--profile", + "operator", + "--operation", + "create", + "--input", + path(&input), + ]); + + assert!(output.status.success(), "{output:?}"); + assert!(output.stderr.is_empty()); + assert!(!String::from_utf8_lossy(&output.stdout).contains("AA")); + let report = json_stdout(&output); + assert_eq!(report["ok"], true); + assert_eq!(report["command"], "data validate"); + assert_eq!(report["entityId"], "record"); + assert_eq!(report["profileId"], "operator"); + assert_eq!(report["operation"], "create"); + assert_eq!(report["itemCount"], 1); + assert_eq!(report["chunkCount"], 1); + + let refused = registry_serverctl(&[ + "--format", + "json", + "data", + "validate", + "--runtime-config", + DATA_CANARY, + ]); + assert_eq!(refused.status.code(), Some(2)); + assert!(refused.stderr.is_empty()); + let rendered = String::from_utf8(refused.stdout).expect("usage response is UTF-8"); + assert!(!rendered.contains(DATA_CANARY)); + assert_eq!( + serde_json::from_str::(&rendered).unwrap()["diagnostics"][0]["code"], + "usage.invalid" + ); +} + +#[cfg(unix)] +#[test] +fn generation_refuses_a_broken_symlink_destination_without_publishing_output() { + use std::os::unix::fs::symlink; + + let project = TestProject::asset_fixture(); + let destination = project.path().join("linked-output"); + symlink("not-present", &destination).expect("broken output symlink is created"); + let output = registry_serverctl(&[ + "--format", + "json", + "generate", + "openapi", + project.path().to_str().expect("path is UTF-8"), + "--output", + destination.to_str().expect("path is UTF-8"), + ]); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stderr.is_empty()); + let report = json_stdout(&output); + assert_eq!( + report["diagnostics"][0]["code"], + "output.destination.invalid" + ); + assert_tool_diagnostic( + &report["diagnostics"][0], + "generated_artifacts", + "retry_artifact_generation", + ); + assert!(fs::symlink_metadata(destination) + .expect("symlink remains intact") + .file_type() + .is_symlink()); +} + +fn package_project_bytes(module_digest: &str) -> Vec { + format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"verify-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"production","instanceId":"{PACKAGE_INSTANCE}","sequence":1,"sourceRevision":"{PACKAGE_SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"restricted","catalog":{{"baseUrl":"https://package.example.test","title":"Verify Registry Catalog","publisher":{{"name":"Verify Publisher"}}}},"dataset":{{"title":"Verify Registry Dataset","owner":"Verify Publisher","status":"active"}}}},"modules":[{{"id":"core","version":"1","digest":"{module_digest}"}}]}}"# + ) + .into_bytes() +} + +fn package_module_bytes() -> Vec { + br#"{"id":"core","version":"1","entities":[{"id":"record","route":"records","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":16,"classification":"internal"}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get","list"],"readableFields":["code"]}]}]}"# + .to_vec() +} + +fn data_package_fixture() -> (TestProject, PathBuf) { + let module_bytes = br#"{"id":"core","version":"1","entities":[{"id":"record","route":"records","mutationMode":"create_only","batch":{"maximumItems":2,"maximumBytes":400},"fields":[{"id":"code","type":"string","minLength":2,"maxLength":16,"required":true,"classification":"internal"}],"accessProfiles":[{"id":"operator","principalClaim":"principal","operations":["create","batch","list"],"readableFields":["code"],"writableFields":["code"],"allowDataExport":true}]}]}"#.to_vec(); + let module = parse_module_json(&module_bytes).expect("data module parses"); + let module_digest = module_digest(&module); + let project = TestProject::from_registry_source( + format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"data-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"local","instanceId":"data-instance","sequence":1,"sourceRevision":"data-source"}},"manifestProjection":{{"accessProfile":"operator","classificationCeiling":"restricted","catalog":{{"baseUrl":"https://data.example.test","title":"Data Registry Catalog","publisher":{{"name":"Data Publisher"}}}},"dataset":{{"title":"Data Registry Dataset","owner":"Data Publisher","status":"active"}}}},"modules":[{{"id":"core","version":"1","digest":"{module_digest}"}}]}}"# + ) + .as_bytes(), + ); + let package = project.path().join("data-package"); + let prepared = prepare_package(PackageBuildRequest { + environment: "local".to_owned(), + instance_id: "data-instance".to_owned(), + database_id: "data-database".to_owned(), + sequence: 1, + prior_revision: None, + compiler_source_revision: "data-source".to_owned(), + schema_fingerprint: + "sha256:3333333333333333333333333333333333333333333333333333333333333333".to_owned(), + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: vec![], + }, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: fs::read(project.path().join("registry.yaml")).expect("data project reads"), + }, + modules: vec![PackageModuleSource { + id: "core".to_owned(), + path: "source/modules/core/module.yaml".to_owned(), + bytes: module_bytes, + }], + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: DATA_FIXTURE_JOURNEYS.to_vec(), + }, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + }) + .expect("data package prepares"); + validate_fixture_journeys(DATA_FIXTURE_JOURNEYS, prepared.registry()) + .expect("data fixture journeys resolve against the packaged registry"); + prepared + .publish_to_directory(&package, vec![]) + .expect("data package publishes"); + (project, package) +} + +fn write_anchor(path: &Path, key: &PrivateJwk) { + let public = key.public(); + write_canonical( + path, + &PackageTrustAnchor { + api_version: TRUST_ANCHOR_API_VERSION.to_owned(), + environment: "production".to_owned(), + instance_id: PACKAGE_INSTANCE.to_owned(), + database_id: PACKAGE_DATABASE.to_owned(), + threshold: 1, + keys: vec![TrustAnchorKey { + key_id: public.kid.clone().expect("generated key has an id"), + jwk: serde_json::to_value(public).expect("public key serializes"), + }], + }, + ); +} + +fn write_canonical(path: &Path, value: &impl Serialize) { + let value = serde_json::to_value(value).expect("value serializes"); + let bytes = canonicalize_json(&value).expect("value canonicalizes"); + fs::write(path, bytes).expect("canonical file writes"); +} + +fn write_runtime_config( + parent: &Path, + package: &Path, + trust_anchor: &Path, + revision: &str, + bind: SocketAddr, +) -> PathBuf { + let secret_root = parent.join("secrets"); + fs::create_dir_all(&secret_root).expect("secret root creates"); + let path = parent.join("runtime.yaml"); + fs::write( + &path, + format!( + r#"listener: + bind: {bind} + trustedProxy: direct +identity: + environment: production + instanceId: {PACKAGE_INSTANCE} + databaseId: {PACKAGE_DATABASE} + databaseInitializationEnvironment: production +secretProviders: + environment: {{}} + file: + root: {secret_root} +database: + runtimeUrlRef: secret:env/VERIFY_RUNTIME_DATABASE_SECRET_IS_NOT_OPENED + migrationUrlRef: secret:env/VERIFY_MIGRATION_DATABASE_SECRET_IS_NOT_OPENED + pool: + maxSize: 1 + waitTimeoutMilliseconds: 1000 + createTimeoutMilliseconds: 1000 + recycleTimeoutMilliseconds: 1000 + roles: + migration: registry_migration + runtime: registry_runtime +package: + root: {package} + trustAnchorPath: {trust_anchor} + compilerSourceRevision: {PACKAGE_SOURCE_REVISION} + activeRevision: {revision} + activeSequence: 1 +authentication: + oidc: + issuer: https://oidc-is-not-opened.invalid + audience: urn:registry-server:verify + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + allowedClients: [verify-client] + deniedKids: [] + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 600 + negativeCacheTtlSeconds: 60 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 1 + outageToleranceSeconds: 900 + authorityClaims: + principal: registry_principal + purpose: registry_purpose + rowBoundaryClaims: [] +audit: + hashKeyRef: secret:file/{PACKAGE_VALUE_CANARY} +cursor: + secretRef: secret:file/{PACKAGE_VALUE_CANARY} + maxAgeSeconds: 300 +operationalTimeouts: + httpRequestMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + recordLockMilliseconds: 5000 + migrationLockMilliseconds: 30000 + migrationStatementMilliseconds: 60000 +"#, + secret_root = secret_root.display(), + package = package.display(), + trust_anchor = trust_anchor.display(), + ), + ) + .expect("runtime configuration writes"); + path +} + +fn test_runtime_config(project: &TestProject) -> PathBuf { + let package_root = project.path().join("runtime-package-root"); + fs::create_dir(&package_root).expect("runtime package root creates"); + let trust_anchor = project.path().join("runtime-trust-anchor.json"); + fs::write(&trust_anchor, b"{}").expect("runtime trust anchor writes"); + write_runtime_config( + project.path(), + &package_root, + &trust_anchor, + "schema-test-active-revision", + "127.0.0.1:1".parse().expect("loopback address parses"), + ) +} + +fn credential_source(credential: &str) -> String { + format!( + r#"apiVersion: registry.registrystack.org/server-schema-test-credentials/v1 +kind: SchemaTestCredentials +bindings: + - journeyId: package-record-list + stepId: list-records + credential: + {credential}"# + ) +} + +fn write_test_secret(project: &TestProject, name: &str, bytes: &[u8]) { + let path = project.path().join("secrets").join(name); + fs::write(&path, bytes).expect("test secret writes"); + set_owner_read_only(&path); +} + +fn assert_schema_test_refusal( + output: Output, + expected_code: &str, + artifact: &str, + action: &str, + receipt: &Path, + forbidden: &[&str], +) { + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(output.stderr.is_empty()); + assert!(!receipt.exists(), "receipt was not published on refusal"); + let rendered = String::from_utf8(output.stdout).expect("refusal JSON is UTF-8"); + for canary in forbidden { + assert!(!rendered.contains(canary), "refusal leaked {canary}"); + } + let report: Value = serde_json::from_str(&rendered).expect("refusal JSON parses"); + assert_eq!(report["command"], "test"); + assert_eq!(report["diagnostics"][0]["code"], expected_code); + assert_tool_diagnostic(&report["diagnostics"][0], artifact, action); +} + +fn assert_inspection_refusal( + arguments: &[&str], + expected_code: &str, + artifact: &str, + action: &str, + forbidden: &[&str], +) { + let output = registry_serverctl(arguments); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(output.stderr.is_empty()); + let rendered = String::from_utf8(output.stdout).expect("refusal JSON is UTF-8"); + for canary in forbidden { + assert!(!rendered.contains(canary), "refusal leaked {canary}"); + } + let report: Value = serde_json::from_str(&rendered).expect("refusal JSON parses"); + assert_eq!(report["diagnostics"][0]["code"], expected_code); + assert_tool_diagnostic(&report["diagnostics"][0], artifact, action); +} + +fn path(path: &Path) -> &str { + path.to_str().expect("test path is UTF-8") +} + +#[cfg(unix)] +fn set_owner_writable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("test package file becomes owner-writable"); +} + +#[cfg(not(unix))] +fn set_owner_writable(_path: &Path) {} + +#[cfg(unix)] +fn set_owner_read_only(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o400)) + .expect("test package file becomes read-only"); +} + +#[cfg(not(unix))] +fn set_owner_read_only(_path: &Path) {} + +#[cfg(unix)] +fn set_group_writable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o660)) + .expect("test package file becomes group-writable"); +} + +fn hex(bytes: &[u8]) -> String { + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + + write!(&mut encoded, "{byte:02x}").expect("hex writes to String"); + } + encoded +} + +fn tree(root: &Path) -> BTreeMap> { + let mut files = BTreeMap::new(); + collect_tree(root, root, &mut files); + files +} + +fn collect_tree(root: &Path, directory: &Path, files: &mut BTreeMap>) { + for entry in fs::read_dir(directory).expect("directory is readable") { + let entry = entry.expect("directory entry is readable"); + let path = entry.path(); + if path.is_dir() { + collect_tree(root, &path, files); + } else { + files.insert( + path.strip_prefix(root) + .expect("generated path is under root") + .to_str() + .expect("generated path is UTF-8") + .replace(std::path::MAIN_SEPARATOR, "/"), + fs::read(&path).expect("generated artifact is readable"), + ); + } + } +} diff --git a/crates/registry-serverctl/tests/diff.rs b/crates/registry-serverctl/tests/diff.rs new file mode 100644 index 0000000000..312a1dba98 --- /dev/null +++ b/crates/registry-serverctl/tests/diff.rs @@ -0,0 +1,748 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_crypto::{generate_private_jwk, sign, GeneratedKeyAlgorithm, PrivateJwk}; +use registry_server::compiler::module_digest; +use registry_server::contract::parse_module_json; +use registry_server::fixtures::validate_fixture_journeys; +use registry_server::package::{ + prepare_package, PackageBuildRequest, PackageMigrationPlanInput, PackageModuleSource, + PackageSignature, PackageSourceFile, PackageTrustAnchor, SignaturePolicy, TrustAnchorKey, + TRUST_ANCHOR_API_VERSION, +}; +use serde::Serialize; +use serde_json::Value; + +const INSTANCE: &str = "instance-under-test"; +const DATABASE: &str = "database-under-test"; +const SOURCE_REVISION: &str = "compiler-source-revision"; +const VALUE_CANARY: &str = "diff-source-path-record-sql-canary"; +const FIXTURE_JOURNEYS: &[u8] = br#"apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: diff-record-list + steps: + - id: list-records + entity: record + accessProfile: reader + claims: {principal: diff-reader} + request: {operation: list} + expect: {outcome: success, status: 200, count: 0} +"#; +static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0); + +struct TestDirectory { + path: PathBuf, +} + +impl TestDirectory { + fn create() -> Self { + let path = std::env::current_dir() + .expect("current directory is available") + .join(format!( + "registry-serverctl-diff-test-{}-{}", + std::process::id(), + TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).expect("test directory is created"); + Self { path } + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + if self + .path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("registry-serverctl-diff-test-")) + && self.path.exists() + { + fs::remove_dir_all(&self.path).expect("test directory is removed"); + } + } +} + +#[test] +fn diff_inventory_is_deterministic_and_classification_direction_is_exact() { + let directory = TestDirectory::create(); + let baseline = publish_package(&directory.path, "baseline", "local", "internal", None); + let widening = write_project(&directory.path, "widening", "local", "public"); + + let first = run(&[ + "--format", + "json", + "diff", + path(&widening), + "--package", + path(&baseline.package), + ]); + let second = run(&[ + "--format", + "json", + "diff", + path(&widening), + "--package", + path(&baseline.package), + ]); + assert!(first.status.success(), "{first:?}"); + assert_eq!( + first.stdout, second.stdout, + "JSON diff is byte deterministic" + ); + assert!(first.stderr.is_empty()); + let report = json_stdout(&first); + assert_eq!(report["profile"], "authoring"); + assert_eq!(report["baselineAssurance"], "integrity_only"); + assert!(report["changes"] + .as_array() + .expect("changes array") + .iter() + .any(|change| change["classification"] == "disclosure_widening" + && change["change"]["code"] == "field_classification_changed")); + + let public_baseline = + publish_package(&directory.path, "public-baseline", "local", "public", None); + let narrowing = write_project(&directory.path, "narrowing", "local", "internal"); + let reverse = run(&[ + "--format", + "json", + "diff", + path(&narrowing), + "--package", + path(&public_baseline.package), + ]); + assert!(reverse.status.success(), "{reverse:?}"); + assert!(json_stdout(&reverse)["changes"] + .as_array() + .expect("changes array") + .iter() + .any(|change| change["classification"] == "disclosure_narrowing")); + + let human = run(&[ + "diff", + path(&widening), + "--package", + path(&baseline.package), + ]); + assert!(human.status.success(), "{human:?}"); + assert!(human.stderr.is_empty()); + assert!(String::from_utf8_lossy(&human.stdout).contains("diff succeeded")); + + let unsupported = write_project(&directory.path, "unsupported", "local", "internal"); + let project_path = unsupported.join("registry.yaml"); + let source = fs::read_to_string(&project_path) + .expect("unsupported candidate reads") + .replacen(r#""version":"1""#, r#""version":"2""#, 1); + fs::write(project_path, source).expect("unsupported candidate writes"); + let unsupported_output = run(&[ + "--format", + "json", + "diff", + path(&unsupported), + "--package", + path(&baseline.package), + ]); + assert!( + unsupported_output.status.success(), + "{unsupported_output:?}" + ); + let report = json_stdout(&unsupported_output); + assert!(report["changes"] + .as_array() + .expect("changes array") + .iter() + .any(|change| change["classification"] == "unsupported")); + assert!(report["findings"] + .as_array() + .expect("findings array") + .iter() + .any(|finding| finding["code"] == "diff.classification.unsupported")); +} + +#[test] +fn package_closure_and_path_disclosure_threats_are_enforced_by_value_free_negatives() { + let directory = TestDirectory::create(); + let baseline = publish_package(&directory.path, "baseline", "local", "internal", None); + let candidate = write_project(&directory.path, "candidate", "local", "public"); + let module_path = baseline.package.join("source/modules/core/module.yaml"); + fs::write(&module_path, VALUE_CANARY).expect("package closure is tampered"); + + let tampered = run(&[ + "--format", + "json", + "diff", + path(&candidate), + "--package", + path(&baseline.package), + ]); + assert_eq!(tampered.status.code(), Some(1)); + assert!(tampered.stderr.is_empty()); + let rendered = String::from_utf8_lossy(&tampered.stdout); + assert!(!rendered.contains(VALUE_CANARY)); + assert!(!rendered.contains(path(&baseline.package))); + assert_eq!( + json_stdout(&tampered)["diagnostics"][0]["code"], + "diff.baseline.integrity_refused" + ); + assert_tool_diagnostic( + &json_stdout(&tampered)["diagnostics"][0], + "baseline_package", + "verify_package_integrity", + ); + let human_refusal = run(&[ + "diff", + path(&candidate), + "--package", + path(&baseline.package), + ]); + assert_eq!(human_refusal.status.code(), Some(1)); + assert!(human_refusal.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&human_refusal.stderr).contains("diff.baseline.integrity_refused") + ); + + #[cfg(unix)] + { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let safe = publish_package(&directory.path, "safe", "local", "internal", None); + fs::set_permissions(&safe.package, fs::Permissions::from_mode(0o777)) + .expect("unsafe permissions are installed"); + let unsafe_permissions = run(&[ + "--format", + "json", + "diff", + path(&candidate), + "--package", + path(&safe.package), + ]); + assert_eq!(unsafe_permissions.status.code(), Some(1)); + assert_eq!( + json_stdout(&unsafe_permissions)["diagnostics"][0]["code"], + "diff.baseline.permissions_refused" + ); + assert_tool_diagnostic( + &json_stdout(&unsafe_permissions)["diagnostics"][0], + "baseline_package", + "verify_package_permissions", + ); + + let linked = directory.path.join(VALUE_CANARY); + symlink(&safe.package, &linked).expect("package symlink is created"); + let symlinked = run(&[ + "--format", + "json", + "diff", + path(&candidate), + "--package", + path(&linked), + ]); + assert_eq!(symlinked.status.code(), Some(1)); + assert!(!String::from_utf8_lossy(&symlinked.stdout).contains(VALUE_CANARY)); + assert_eq!( + json_stdout(&symlinked)["diagnostics"][0]["code"], + "diff.baseline.path_refused" + ); + assert_tool_diagnostic( + &json_stdout(&symlinked)["diagnostics"][0], + "baseline_package", + "verify_package_path", + ); + } +} + +#[test] +fn production_trust_is_verified_without_opening_runtime_dependencies() { + let directory = TestDirectory::create(); + let signing = generate_private_jwk(GeneratedKeyAlgorithm::Es384) + .expect("production package signing key generates"); + let baseline = publish_package( + &directory.path, + "production-baseline", + "production", + "internal", + Some(&signing), + ); + let candidate = write_project( + &directory.path, + "production-candidate", + "production", + "public", + ); + let runtime = write_runtime_config( + &directory.path, + &baseline, + baseline.anchor.as_ref().expect("anchor exists"), + ); + + let accepted = run(&[ + "--format", + "json", + "diff", + path(&candidate), + "--runtime-config", + path(&runtime), + ]); + assert!(accepted.status.success(), "{accepted:?}"); + assert!(accepted.stderr.is_empty()); + assert!(!String::from_utf8_lossy(&accepted.stdout).contains(VALUE_CANARY)); + assert_eq!(json_stdout(&accepted)["baselineAssurance"], "runtime_bound"); + + let wrong = + generate_private_jwk(GeneratedKeyAlgorithm::Es384).expect("wrong trust key generates"); + let wrong_anchor = directory.path.join(format!("{VALUE_CANARY}.json")); + write_anchor(&wrong_anchor, &wrong); + let wrong_runtime = write_runtime_config(&directory.path, &baseline, &wrong_anchor); + let refused = run(&[ + "--format", + "json", + "diff", + path(&candidate), + "--runtime-config", + path(&wrong_runtime), + ]); + assert_eq!(refused.status.code(), Some(1)); + assert!(refused.stderr.is_empty()); + assert!(!String::from_utf8_lossy(&refused.stdout).contains(VALUE_CANARY)); + assert_eq!( + json_stdout(&refused)["diagnostics"][0]["code"], + "diff.baseline.signature_refused" + ); + assert_tool_diagnostic( + &json_stdout(&refused)["diagnostics"][0], + "baseline_package", + "verify_package_trust", + ); + + let wrong_revision_runtime = rewrite_runtime_config( + &directory.path, + &runtime, + "wrong-active-revision", + &format!("activeRevision: {}", baseline.revision), + &format!("activeRevision: {VALUE_CANARY}"), + ); + assert_runtime_package_binding_refusal(&candidate, &wrong_revision_runtime); + + let wrong_sequence_runtime = rewrite_runtime_config( + &directory.path, + &runtime, + &format!("wrong-active-sequence-{VALUE_CANARY}"), + "activeSequence: 1", + "activeSequence: 2", + ); + assert_runtime_package_binding_refusal(&candidate, &wrong_sequence_runtime); +} + +#[test] +fn diff_help_and_selector_usage_preserve_the_closed_command_inventory_and_exit_codes() { + let directory = TestDirectory::create(); + let candidate = write_project(&directory.path, "candidate", "local", "internal"); + let help = run(&["--help"]); + assert!(help.status.success()); + assert!(help.stderr.is_empty()); + let rendered = String::from_utf8_lossy(&help.stdout); + assert!(rendered.contains("diff")); + assert!(rendered + .lines() + .any(|line| line.trim_start().starts_with("data"))); + assert!(rendered + .lines() + .any(|line| line.trim_start().starts_with("verify"))); + assert!(rendered + .lines() + .any(|line| line.trim_start().starts_with("migration"))); + + let diff_help = run(&["diff", "--help"]); + let rendered = String::from_utf8_lossy(&diff_help.stdout); + assert!(rendered.contains("--runtime-config ")); + assert!(rendered.contains("--package ")); + + let neither = run(&["--format", "json", "diff", VALUE_CANARY]); + assert_eq!(neither.status.code(), Some(2)); + assert!(neither.stderr.is_empty()); + assert!(!String::from_utf8_lossy(&neither.stdout).contains(VALUE_CANARY)); + assert_eq!( + json_stdout(&neither)["diagnostics"][0]["code"], + "usage.invalid" + ); + assert_tool_diagnostic( + &json_stdout(&neither)["diagnostics"][0], + "command_arguments", + "correct_command_usage", + ); + + let both = run(&[ + "--format", + "json", + "diff", + VALUE_CANARY, + "--runtime-config", + VALUE_CANARY, + "--package", + VALUE_CANARY, + ]); + assert_eq!(both.status.code(), Some(2)); + assert!(both.stderr.is_empty()); + assert!(!String::from_utf8_lossy(&both.stdout).contains(VALUE_CANARY)); + + let relative_runtime = run(&[ + "--format", + "json", + "diff", + path(&candidate), + "--runtime-config", + VALUE_CANARY, + ]); + assert_eq!(relative_runtime.status.code(), Some(1)); + assert!(relative_runtime.stderr.is_empty()); + assert!(!String::from_utf8_lossy(&relative_runtime.stdout).contains(VALUE_CANARY)); + assert_eq!( + json_stdout(&relative_runtime)["diagnostics"][0]["code"], + "diff.runtime_config.path_invalid" + ); + assert_tool_diagnostic( + &json_stdout(&relative_runtime)["diagnostics"][0], + "runtime_configuration", + "correct_runtime_configuration", + ); + + let malformed_runtime = directory.path.join(format!("{VALUE_CANARY}.yaml")); + fs::write( + &malformed_runtime, + format!("unexpectedSetting: {VALUE_CANARY}\n"), + ) + .expect("malformed runtime configuration is written"); + let refused_runtime = run(&[ + "--format", + "json", + "diff", + path(&candidate), + "--runtime-config", + path(&malformed_runtime), + ]); + assert_eq!(refused_runtime.status.code(), Some(1)); + assert!(refused_runtime.stderr.is_empty()); + let rendered = String::from_utf8_lossy(&refused_runtime.stdout); + assert!(!rendered.contains(VALUE_CANARY)); + assert!(!rendered.contains(path(&malformed_runtime))); + let report = json_stdout(&refused_runtime); + assert_eq!( + report["diagnostics"][0]["code"], + "diff.runtime_config.refused" + ); + assert_tool_diagnostic( + &report["diagnostics"][0], + "runtime_configuration", + "correct_runtime_configuration", + ); +} + +struct PublishedPackage { + package: PathBuf, + anchor: Option, + revision: String, +} + +fn publish_package( + parent: &Path, + name: &str, + environment: &str, + classification: &str, + signing: Option<&PrivateJwk>, +) -> PublishedPackage { + let module_bytes = module_bytes(classification); + let module = parse_module_json(&module_bytes).expect("package module parses"); + let project_bytes = project_bytes(environment, &module_digest(&module)); + let signature_policy = signing + .map(|key| SignaturePolicy { + threshold: 1, + key_ids: vec![key.public().kid.expect("generated key has an id")], + }) + .unwrap_or(SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }); + let prepared = prepare_package(PackageBuildRequest { + environment: environment.to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + sequence: 1, + prior_revision: None, + compiler_source_revision: SOURCE_REVISION.to_owned(), + schema_fingerprint: + "sha256:2222222222222222222222222222222222222222222222222222222222222222".to_owned(), + signature_policy, + project: PackageSourceFile { + path: "source/registry.yaml".to_owned(), + bytes: project_bytes, + }, + modules: vec![PackageModuleSource { + id: "core".to_owned(), + path: "source/modules/core/module.yaml".to_owned(), + bytes: module_bytes, + }], + fixture_journeys: PackageSourceFile { + path: "tests/journeys.yaml".to_owned(), + bytes: FIXTURE_JOURNEYS.to_vec(), + }, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + }) + .expect("package prepares"); + validate_fixture_journeys(FIXTURE_JOURNEYS, prepared.registry()) + .expect("diff fixture journeys resolve against the packaged registry"); + let signatures = signing + .map(|key| { + vec![PackageSignature { + key_id: key.public().kid.expect("generated key has an id"), + signature_hex: hex( + &sign(prepared.canonical_signed_bytes(), key).expect("package signs") + ), + }] + }) + .unwrap_or_default(); + let package = parent.join(name); + let revision = prepared.package_revision().to_owned(); + prepared + .publish_to_directory(&package, signatures) + .expect("package publishes"); + let anchor = signing.map(|key| { + let path = parent.join(format!("{name}-trust.json")); + write_anchor(&path, key); + path + }); + PublishedPackage { + package, + anchor, + revision, + } +} + +fn write_project(parent: &Path, name: &str, environment: &str, classification: &str) -> PathBuf { + let root = parent.join(name); + let module = module_bytes(classification); + let parsed = parse_module_json(&module).expect("candidate module parses"); + fs::create_dir_all(root.join("modules/core")).expect("candidate directories create"); + fs::write( + root.join("registry.yaml"), + project_bytes(environment, &module_digest(&parsed)), + ) + .expect("candidate project writes"); + fs::write(root.join("modules/core/module.yaml"), module).expect("candidate module writes"); + root +} + +fn project_bytes(environment: &str, module_digest: &str) -> Vec { + format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"{environment}","instanceId":"{INSTANCE}","sequence":1,"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"restricted","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"name":"Package Test Publisher"}}}},"dataset":{{"title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}}},"modules":[{{"id":"core","version":"1","digest":"{module_digest}"}}]}}"# + ) + .into_bytes() +} + +fn module_bytes(classification: &str) -> Vec { + format!( + r#"{{"id":"core","version":"1","entities":[{{"id":"record","route":"records","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":16,"classification":"{classification}"}}],"accessProfiles":[{{"id":"reader","principalClaim":"principal","operations":["get","list"],"readableFields":["code"]}}]}}]}}"# + ) + .into_bytes() +} + +fn write_anchor(path: &Path, key: &PrivateJwk) { + let public = key.public(); + write_canonical( + path, + &PackageTrustAnchor { + api_version: TRUST_ANCHOR_API_VERSION.to_owned(), + environment: "production".to_owned(), + instance_id: INSTANCE.to_owned(), + database_id: DATABASE.to_owned(), + threshold: 1, + keys: vec![TrustAnchorKey { + key_id: public.kid.clone().expect("generated key has an id"), + jwk: serde_json::to_value(public).expect("public key serializes"), + }], + }, + ); +} + +fn write_canonical(path: &Path, value: &impl Serialize) { + let value = serde_json::to_value(value).expect("value serializes"); + let bytes = canonicalize_json(&value).expect("value canonicalizes"); + fs::write(path, bytes).expect("canonical file writes"); +} + +fn write_runtime_config(parent: &Path, package: &PublishedPackage, trust_anchor: &Path) -> PathBuf { + let secret_root = parent.join("secrets"); + fs::create_dir_all(&secret_root).expect("secret root creates"); + let path = parent.join(format!( + "runtime-{}.yaml", + TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::write( + &path, + format!( + r#"listener: + bind: 127.0.0.1:1 + trustedProxy: direct +identity: + environment: production + instanceId: {INSTANCE} + databaseId: {DATABASE} + databaseInitializationEnvironment: production +secretProviders: + environment: {{}} + file: + root: {secret_root} +database: + runtimeUrlRef: secret:env/DIFF_RUNTIME_DATABASE_SECRET_IS_NOT_OPENED + migrationUrlRef: secret:env/DIFF_MIGRATION_DATABASE_SECRET_IS_NOT_OPENED + pool: + maxSize: 1 + waitTimeoutMilliseconds: 1000 + createTimeoutMilliseconds: 1000 + recycleTimeoutMilliseconds: 1000 + roles: + migration: registry_migration + runtime: registry_runtime +package: + root: {package_root} + trustAnchorPath: {trust_anchor} + compilerSourceRevision: {SOURCE_REVISION} + activeRevision: {revision} + activeSequence: 1 +authentication: + oidc: + issuer: https://oidc-is-not-opened.invalid + audience: urn:registry-server:diff + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + allowedClients: [diff-client] + deniedKids: [] + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 600 + negativeCacheTtlSeconds: 60 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 1 + outageToleranceSeconds: 900 + authorityClaims: + principal: registry_principal + purpose: registry_purpose + rowBoundaryClaims: [] +audit: + hashKeyRef: secret:file/{VALUE_CANARY} +cursor: + secretRef: secret:file/{VALUE_CANARY} + maxAgeSeconds: 300 +operationalTimeouts: + httpRequestMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + recordLockMilliseconds: 5000 + migrationLockMilliseconds: 30000 + migrationStatementMilliseconds: 60000 +"#, + secret_root = secret_root.display(), + package_root = package.package.display(), + trust_anchor = trust_anchor.display(), + revision = package.revision, + ), + ) + .expect("runtime config writes"); + path +} + +fn rewrite_runtime_config( + parent: &Path, + source: &Path, + name: &str, + from: &str, + to: &str, +) -> PathBuf { + let target = parent.join(format!("{name}.yaml")); + let original = fs::read_to_string(source).expect("runtime config reads"); + assert!( + original.contains(from), + "runtime fixture replacement is exact" + ); + fs::write(&target, original.replacen(from, to, 1)).expect("runtime config variant writes"); + target +} + +fn assert_runtime_package_binding_refusal(candidate: &Path, runtime_config: &Path) { + let refused = run(&[ + "--format", + "json", + "diff", + path(candidate), + "--runtime-config", + path(runtime_config), + ]); + assert_eq!(refused.status.code(), Some(1)); + assert!(refused.stderr.is_empty()); + let rendered = String::from_utf8_lossy(&refused.stdout); + assert!(!rendered.contains(VALUE_CANARY)); + assert!(!rendered.contains(path(runtime_config))); + assert_eq!( + json_stdout(&refused)["diagnostics"][0]["code"], + "diff.baseline.binding_refused" + ); + assert_tool_diagnostic( + &json_stdout(&refused)["diagnostics"][0], + "baseline_package", + "verify_package_binding", + ); +} + +fn run(arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_registry-serverctl")) + .args(arguments) + .output() + .expect("registry-serverctl starts") +} + +fn json_stdout(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).expect("stdout is JSON") +} + +fn assert_tool_diagnostic(diagnostic: &Value, artifact: &str, suggested_action: &str) { + let keys = diagnostic + .as_object() + .expect("diagnostic is an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "artifact", + "code", + "message", + "path", + "severity", + "suggestedAction", + ]) + ); + assert_eq!(diagnostic["artifact"], artifact); + assert_eq!(diagnostic["suggestedAction"], suggested_action); +} + +fn path(path: &Path) -> &str { + path.to_str().expect("test path is UTF-8") +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut result = String::with_capacity(bytes.len() * 2); + for byte in bytes { + result.push(HEX[usize::from(byte >> 4)] as char); + result.push(HEX[usize::from(byte & 0x0f)] as char); + } + result +} diff --git a/crates/registry-serverctl/tests/doctor.rs b/crates/registry-serverctl/tests/doctor.rs new file mode 100644 index 0000000000..54e0d8eecd --- /dev/null +++ b/crates/registry-serverctl/tests/doctor.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::ffi::OsStr; +use std::fs; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::Value; + +const CONFIG_VALUE_CANARY: &str = "registry-server-doctor-config-value-canary"; +const PATH_VALUE_CANARY: &str = "registry-server-doctor-path-value-canary"; +static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0); + +struct TestDirectory { + path: PathBuf, +} + +impl TestDirectory { + fn create() -> Self { + let path = std::env::current_dir() + .expect("current directory is available") + .join(format!( + "registry-serverctl-doctor-test-{}-{}", + std::process::id(), + TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).expect("test directory is created"); + assert!(path.is_absolute()); + Self { path } + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + if self.path.exists() { + fs::remove_dir_all(&self.path).expect("test directory is removed"); + } + } +} + +fn run(runtime_config: &Path) -> (u8, String, String) { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let status = registry_serverctl::run_from( + [ + OsStr::new("registry-serverctl"), + OsStr::new("--format"), + OsStr::new("json"), + OsStr::new("doctor"), + OsStr::new("--runtime-config"), + runtime_config.as_os_str(), + ], + &mut stdout, + &mut stderr, + ); + ( + if status == std::process::ExitCode::SUCCESS { + 0 + } else { + 1 + }, + String::from_utf8(stdout).expect("stdout is UTF-8"), + String::from_utf8(stderr).expect("stderr is UTF-8"), + ) +} + +#[test] +fn doctor_help_names_the_absolute_runtime_configuration_contract() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let status = registry_serverctl::run_from( + ["registry-serverctl", "doctor", "--help"], + &mut stdout, + &mut stderr, + ); + + assert_eq!(status, std::process::ExitCode::SUCCESS); + assert!(stderr.is_empty()); + let help = String::from_utf8(stdout).expect("help is UTF-8"); + assert!(help.contains("--runtime-config ")); + assert!(help.contains("configured startup dependencies")); + assert!(help.contains("without binding a listener")); + assert!(!help.contains("complete startup readiness")); +} + +#[test] +fn path_disclosure_threat_is_enforced_by_refusing_a_relative_runtime_config_negative() { + let relative = Path::new(PATH_VALUE_CANARY); + let (status, stdout, stderr) = run(relative); + + assert_eq!(status, 1); + assert!(stderr.is_empty()); + assert!(!stdout.contains(PATH_VALUE_CANARY)); + let report: Value = serde_json::from_str(&stdout).expect("failure is JSON"); + assert_eq!(report["command"], "doctor"); + assert_eq!( + report["diagnostics"][0]["code"], + "startup.runtime_config.path_invalid" + ); + assert_tool_diagnostic( + &report["diagnostics"][0], + "runtime_configuration", + "correct_runtime_configuration", + ); +} + +#[test] +fn startup_value_disclosure_and_listener_activation_threats_are_enforced_by_prepare_negative() { + let directory = TestDirectory::create(); + let runtime_config = directory.path.join("runtime.yaml"); + let probe = TcpListener::bind("127.0.0.1:0").expect("probe listener binds"); + let address = probe.local_addr().expect("probe address is available"); + drop(probe); + fs::write( + &runtime_config, + format!( + "listener:\n bind: {address}\n trustedProxy: direct\nunexpected: {CONFIG_VALUE_CANARY}\n" + ), + ) + .expect("invalid runtime configuration is written"); + + let (status, stdout, stderr) = run(&runtime_config); + + assert_eq!(status, 1); + assert!(stderr.is_empty()); + assert!(!stdout.contains(CONFIG_VALUE_CANARY)); + assert!(!stdout.contains(runtime_config.to_str().expect("path is UTF-8"))); + let report: Value = serde_json::from_str(&stdout).expect("failure is JSON"); + assert_eq!( + report["diagnostics"][0]["code"], + "startup.runtime_config.refused" + ); + assert_tool_diagnostic( + &report["diagnostics"][0], + "runtime_configuration", + "correct_runtime_configuration", + ); + + let listener = TcpListener::bind(address) + .expect("startup preparation refuses without binding the configured listener"); + drop(listener); +} + +fn assert_tool_diagnostic(diagnostic: &Value, artifact: &str, suggested_action: &str) { + let keys = diagnostic + .as_object() + .expect("diagnostic is an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "artifact", + "code", + "message", + "path", + "severity", + "suggestedAction", + ]) + ); + assert_eq!(diagnostic["artifact"], artifact); + assert_eq!(diagnostic["suggestedAction"], suggested_action); +} diff --git a/products/registry-server/ACCEPTANCE-JOURNEYS.md b/products/registry-server/ACCEPTANCE-JOURNEYS.md new file mode 100644 index 0000000000..764939631b --- /dev/null +++ b/products/registry-server/ACCEPTANCE-JOURNEYS.md @@ -0,0 +1,25 @@ +# Acceptance journeys + +Registry Server must prove the same binary and compiler profile can serve five +configuration-defined domains: a non-person asset registry, household, +disability, farmer, and business registries. Their contract identifiers and +delivery state are in `contracts/acceptance-scenario-matrix.yaml`. + +All five configuration projects pass the Production compiler and the same +real-PostgreSQL pilot test. That test loads their locked module digests and +executes configured behavior without domain-specific runtime concepts. + +The non-person asset project also passes the public-binary adopter workflow. +That workflow builds the two executables once, tests a candidate in an isolated +database, packages it for external signing, proves that an author without the +migration secret cannot initialize production state, applies it with the +operator role, and performs authenticated data access through a static JWKS. +It then adds one optional restricted field by configuration, tests and signs +the successor in a second isolated database, records a deliberately blocked +migration as durable failed maintenance, applies the exact fix-forward package, +and restarts the byte-identical server binary. The original data survives and +the restricted field remains outside the selected response projection. + +The machine-readable matrix binds each journey to its exact executable proof. +Compiler-only evidence is not used to claim the unchanged-binary upgrade or +the author-versus-production authority boundary. diff --git a/products/registry-server/DECISIONS.md b/products/registry-server/DECISIONS.md new file mode 100644 index 0000000000..0651be257d --- /dev/null +++ b/products/registry-server/DECISIONS.md @@ -0,0 +1,34 @@ +# Product decisions + +- Version 1 supports PostgreSQL only. SQLite is not a deployment compatibility + promise. +- Extension points begin with transactionally created outbox events and + authenticated webhooks. Arbitrary synchronous code hooks are not part of the + server. +- Packages contain governed model and generated artifacts. Runtime + configuration binds deployment-specific values and secrets and is not part of + the signed model. +- Registry Server accepts one PostgreSQL client path: `tokio-postgres` 0.7.18, + `deadpool-postgres` 0.14.2, and `tokio-postgres-rustls` 0.14.0. The real + PostgreSQL kernel proves dynamic result handling, transactions, cancellation, + pool recovery, role separation, RLS, advisory locks, and activation + interlocking. There is no second production client behind an abstraction. +- Runtime connections use strict native-root or custom-CA TLS. Custom-CA mode + accepts one DER root for the current connection scope and verifies both the + chain and server hostname. Plaintext PostgreSQL is confined to the test + harness; a required-TLS runtime does not downgrade against it. +- Generated RLS policies defend against application mistakes and pooled-context + leakage. They do not constrain a party holding the runtime database + credential, which can set the same custom transaction context; credential + posture and rotation remain operator controls. +- OIDC verification can use issuer discovery or an operator-pinned static JWKS + held behind a secret reference. Static documents accept only a bounded, + duplicate-free set of public keys exactly compatible with the configured + algorithm and key policy. They are loaded once per verifier construction and + rotate only by configuration change and restart. +- `registry-serverctl test` may use a dedicated schema-test database, but it + grants no package-signing or production-migration authority. Package signing + remains external to the CLI, and production activation requires the separate + migration database credential. The clean adopter proof uses distinct schema + test databases for the initial and successor candidates plus a third + production database. diff --git a/products/registry-server/DEFINITION-OF-DONE.md b/products/registry-server/DEFINITION-OF-DONE.md new file mode 100644 index 0000000000..fcd3351b76 --- /dev/null +++ b/products/registry-server/DEFINITION-OF-DONE.md @@ -0,0 +1,29 @@ +# Definition of Done + +Registry Server is not complete because one entity can be stored or because a +generated OpenAPI document exists. Completion requires the machine-readable +requirements in `contracts/definition-of-done.yaml` to pass on the same +revision using real PostgreSQL. + +The delivery states are deliberately separate: + +- **Architecture proof** proves the compiler, real router, PostgreSQL role and + RLS boundary, atomic record transaction, audit ordering, and activation + interlock. It is not a shippable partial writable API. +- **Pilot** adds the approved governed model, package and migration recovery, + bounded REST and operational tooling, and all five coequal domain fixtures. +- **Release** uses the normal Registry Stack release process after the pilot + surface is complete. + +No required row may be satisfied by a mock database, disabled test, placeholder +artifact, or manual action claimed to be automated. A planned security row is +not implementation evidence. At the exit for its wave, it must be enforced and +have exactly one resolving negative executable test. + +The pilot catalog is now enforced. Its closing proof combines the +real-PostgreSQL five-domain acceptance test with a clean public-binary lifecycle +that exercises production checking, isolated schema tests, external signing, +operator apply, authenticated serving, compatible additive upgrade, durable +failed maintenance, exact fix-forward recovery, and restart with unchanged +server bytes. This is a pilot exit claim, not a claim that a release has been +published. diff --git a/products/registry-server/IMPLEMENTATION.md b/products/registry-server/IMPLEMENTATION.md new file mode 100644 index 0000000000..0f1dc38661 --- /dev/null +++ b/products/registry-server/IMPLEMENTATION.md @@ -0,0 +1,20 @@ +# Implementation schedule + +The product is delivered through small vertical slices. The canonical schedule +is `contracts/implementation-schedule.yaml`. + +1. **W0:** publish validated contracts, a non-person authored fixture, the two + crate boundaries, and one CI ownership path. +2. **W1:** compile a strict, deterministic effective model and all Slice 0 + inventories from one source of truth. +3. **W2:** prove the PostgreSQL role, RLS, advisory-lock, and connection-pool + design against a real PostgreSQL instance. +4. **W3:** add the real REST router, request authorization, revisions, + idempotency, audit ordering, and transactional outbox as one path. +5. **W4:** add verified packages, migrations, activation, and recovery. +6. **W5:** complete the pilot tooling, bounded data operations, webhooks, and + five coequal adopter journeys. + +No wave creates a generic storage framework, package framework, plugin runtime, +workflow engine, or second database client abstraction merely in anticipation +of future needs. diff --git a/products/registry-server/README.md b/products/registry-server/README.md new file mode 100644 index 0000000000..2d6339d163 --- /dev/null +++ b/products/registry-server/README.md @@ -0,0 +1,131 @@ +# Registry Server + +Registry Server is a small PostgreSQL system of record whose data model and +REST surface are compiled from governed configuration. It is intended for +institutional registries that need reliable writes, history, access control, +and a safe way to evolve their schema without building a bespoke service. + +The runtime has no built-in person, household, farmer, disability, business, +programme, or asset model. An entity, relationship, route, field, access +profile, and event exists only when an active Registry package declares it. +For example, a household membership is an ordinary configured relationship +entity, not a server feature. + +The product starts with two executables: + +- `registry-server` loads one verified package and serves its configured REST + API against PostgreSQL. +- `registry-serverctl` is deterministic tooling for authoring, checking, + packaging, applying, and verifying Registry configuration. + +AI-assisted authoring remains outside the production authority boundary. It +may propose configuration and run deterministic checks, but it cannot bypass +package review, signature policy, or the separate migration database role. + +## Pilot operator lifecycle + +The pilot lifecycle uses the published `registry-serverctl` and +`registry-server` executables. It does not require a Rust change for a new +configured domain or a compatible additive schema change. + +1. An author runs `registry-serverctl check --production`, generates + review artifacts as needed, and uses `diff` against the active runtime + configuration for a successor. +2. `registry-serverctl test` executes the declared journeys against a separate + schema-test database. Its result binds the candidate source, database + identity, exact catalog fingerprint, signature policy, and test receipt. +3. `registry-serverctl package` reproduces that tested candidate and stops at + `awaiting_signatures`. An external signer reviews and signs the exact + `signing-input.json`; rerunning `package` with the detached signature + document publishes the verified package. The CLI accepts no private signing + key. +4. An operator with the migration database credential runs + `registry-serverctl apply --runtime-config --package ` and + then `registry-serverctl verify --runtime-config `. Initial activation + also requires `--initial`. +5. `registry-server --config ` serves the active package. Authorized + bulk operations use `registry-serverctl data validate`, `data import`, and + `data export`, which reuse the packaged plans and normal authenticated API + paths. + +For a compatible successor, repeat test, package, external signing, and apply +with the active runtime configuration as the baseline, then restart the same +server executable on the successor package. A migration failure after +maintenance begins leaves the database durably in maintenance and readiness +fails until an operator resolves the cause and applies the exact successor +again or completes the reviewed restore path. + +Authoring, signing, and migration authority are deliberately separate. An +author or coding agent can edit configuration, inspect a diff, and run checks. +Those commands cannot mint a package signature or obtain the production +migration credential. A runtime configuration without that credential is +refused before initial production control-plane state or DDL is created. + +OIDC key resolution is deployment configuration, not governed package content. +If `authentication.oidc.jwksSource` is omitted, discovery is used. An operator +can instead pin a static document through a protected secret reference: + +```yaml +authentication: + oidc: + jwksSource: + kind: static + documentRef: secret:file/oidc-jwks +``` + +The static document must be a bounded, duplicate-free set of public keys that +matches the configured algorithm, signature use, verification operation, key +identifier policy, and key shape. It is resolved once when the verifier is +constructed, so rotation requires a reviewed configuration change and process +restart. + +## Scope + +Registry Server owns typed configured storage, generated REST contracts, +authorization, record revisions, audit ordering, idempotency, outbox creation, +and governed migrations. It does not provide a UI, GraphQL, workflow, +eligibility, payment, identity matching, SQLite support, a multi-registry +control plane, or runtime code plugins. + +PostgreSQL is the sole Version 1 database. The administrator installs +`btree_gist`; neither the runtime nor migration role installs extensions. + +## Product contracts + +The files in `contracts/` are the authoritative machine-readable delivery +catalog. They deliberately distinguish a `planned` invariant from an +`enforced` one. A planned row records a concrete threat and future refusal but +does not pretend that a test exists. The implementation change that enforces +it must add one resolving negative executable test in the same patch. + +The five projects under `acceptance/` are authored configuration inputs for the +same compiler and binary. They cover asset/site placement, PublicSchema-shaped +household membership, disability, farmer, and business registries. They are not +generated output or implicit runtime models. The real-PostgreSQL pilot test +executes all five, while the public-binary adopter workflow proves signed +activation, authenticated data access, an additive upgrade, failure recovery, +and unchanged server bytes for the non-person project. + +Run the current deterministic contract checks with: + +```bash +products/registry-server/scripts/check-contracts.sh +``` + +For an interactive local household example backed by disposable PostgreSQL, +Registry Mint, a real local package, and deterministic relational data, run: + +```bash +products/registry-server/demo/run.sh +``` + +The launcher retains every key and token in an ignored owner-only directory +and prints a separate query helper rather than printing bearer credentials. + +## Relationship to Registry Stack + +Registry Server is a writable source-of-truth product. Registry Relay remains +the separately deployed read-only publication product; Evidence remains the +minimum-disclosure assertion product; Manifest receives a safe one-way +metadata projection; Mint may issue configured OIDC tokens; and PublicSchema +is an authoring input rather than a runtime dependency. diff --git a/products/registry-server/acceptance/asset-site-placement/modules/asset-site-placement-core/module.yaml b/products/registry-server/acceptance/asset-site-placement/modules/asset-site-placement-core/module.yaml new file mode 100644 index 0000000000..a177c0ed6a --- /dev/null +++ b/products/registry-server/acceptance/asset-site-placement/modules/asset-site-placement-core/module.yaml @@ -0,0 +1,2 @@ +id: asset-site-placement-core +version: 0.1.0 diff --git a/products/registry-server/acceptance/asset-site-placement/registry.yaml b/products/registry-server/acceptance/asset-site-placement/registry.yaml new file mode 100644 index 0000000000..e84620e1b9 --- /dev/null +++ b/products/registry-server/acceptance/asset-site-placement/registry.yaml @@ -0,0 +1,92 @@ +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: asset-site-placement + version: 0.1.0 + defaultLanguage: en +package: + environment: acceptance + instanceId: asset-site-placement-acceptance + sequence: 1 + sourceRevision: asset-site-placement-acceptance-0.1.0 +manifestProjection: + accessProfile: asset-operator + classificationCeiling: internal + catalog: + baseUrl: https://asset-site-placement.example.gov + title: Asset Site Placement Catalog + description: Portable metadata for the asset site placement registry. + publisher: + name: Asset Site Placement Authority + iri: https://asset-site-placement.example.gov/authority + dataset: + title: Asset Site Placement Registry + description: Asset placement, site, item, and inspection metadata. + owner: Asset Site Placement Authority + status: active +modules: + - id: asset-site-placement-core + version: 0.1.0 + digest: sha256:6bfb23a7780219187d3f30fab0a37d51e739bb0a26669e4c162b59c1f502ebf3 +entities: + - id: asset-item + route: assets + mutationMode: mutable + batch: {maximumItems: 4, maximumBytes: 16384} + fields: + - {id: asset-code, type: string, required: true, maxLength: 64, classification: internal} + - {id: label, type: string, required: true, maxLength: 200, classification: internal} + - {id: asset-class, type: vocabulary-code, vocabulary: asset-classification, required: true, classification: internal} + constraints: + - {kind: unique, fields: [asset-code]} + - id: asset-site + route: sites + mutationMode: mutable + fields: + - {id: site-code, type: string, required: true, maxLength: 64, classification: internal} + - {id: label, type: string, required: true, maxLength: 200, classification: internal} + constraints: + - {kind: unique, fields: [site-code]} + - id: asset-placement + route: placements + mutationMode: mutable + fields: + - {id: asset, type: reference, target: asset-item, required: true, classification: internal} + - {id: site, type: reference, target: asset-site, required: true, classification: internal} + - {id: valid-from, type: date, required: true, classification: internal} + - {id: valid-to, type: date, required: false, classification: internal} + temporal: + startField: valid-from + endField: valid-to + scopeFields: [asset] + constraints: + - {kind: temporal-non-overlap, scopeFields: [asset], startField: valid-from, endField: valid-to} + - id: inspection-event + route: inspections + mutationMode: create_only + fields: + - {id: asset, type: reference, target: asset-item, required: true, classification: internal} + - {id: observed-at, type: timestamp, required: true, classification: internal} + - {id: result, type: vocabulary-code, vocabulary: inspection-result, required: true, classification: internal} + events: + - {id: inspection-created, trigger: created, projection: [asset, observed-at, result]} +accessProfiles: + - id: asset-operator + default: true + principalClaim: registry_principal + purposes: [asset-management] + grants: + - {entity: asset-item, actions: [create, get, list, patch, batch], readableFields: [asset-code, label, asset-class], writableFields: [asset-code, label, asset-class]} + - {entity: asset-site, actions: [create, get, list, patch], readableFields: [site-code, label], writableFields: [site-code, label]} + - {entity: asset-placement, actions: [create, get, list, patch], readableFields: [asset, site, valid-from, valid-to], writableFields: [asset, site, valid-from, valid-to]} + - {entity: inspection-event, actions: [create, get, list], readableFields: [asset, observed-at, result], writableFields: [asset, observed-at, result]} + - id: site-planner + principalClaim: registry_principal + purposes: [site-planning] + grants: + - {entity: asset-item, actions: [get, list], readableFields: [asset-code, label], filterableFields: [asset-code]} + - {entity: asset-site, actions: [get, list], readableFields: [site-code, label], filterableFields: [site-code]} + - {entity: asset-placement, actions: [create, get, list, patch], readableFields: [asset, site, valid-from, valid-to], writableFields: [asset, site, valid-from, valid-to], filterableFields: [asset, site, valid-from]} +vocabularies: + - {id: asset-classification, values: [equipment, vehicle, furniture]} + - {id: inspection-result, values: [passed, failed]} diff --git a/products/registry-server/acceptance/asset-site-placement/tests/journeys.yaml b/products/registry-server/acceptance/asset-site-placement/tests/journeys.yaml new file mode 100644 index 0000000000..887295e331 --- /dev/null +++ b/products/registry-server/acceptance/asset-site-placement/tests/journeys.yaml @@ -0,0 +1,96 @@ +apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: asset-and-site-caller-surfaces + steps: + - id: create-asset + entity: asset-item + accessProfile: asset-operator + claims: &asset_operator_claims + principal: synthetic-asset-operator + purpose: asset-management + request: + operation: create + data: + asset-code: ASSET-SYNTH-001 + label: Synthetic water pump + asset-class: equipment + expect: + outcome: success + status: 201 + fields: + asset-code: ASSET-SYNTH-001 + label: Synthetic water pump + asset-class: equipment + capture: first-asset + - id: planner-gets-asset + entity: asset-item + accessProfile: site-planner + claims: &site_planner_claims + principal: synthetic-site-planner + purpose: site-planning + request: {operation: get, recordRef: first-asset} + expect: + outcome: success + status: 200 + fields: {asset-code: ASSET-SYNTH-001, label: Synthetic water pump} + - id: planner-lists-assets + entity: asset-item + accessProfile: site-planner + claims: *site_planner_claims + request: {operation: list} + expect: {outcome: success, status: 200, count: 1} + - id: operator-renames-asset + entity: asset-item + accessProfile: asset-operator + claims: *asset_operator_claims + request: + operation: patch + recordRef: first-asset + etagRef: first-asset + changes: + - {field: label, value: Synthetic irrigation pump} + expect: + outcome: success + status: 200 + fields: + asset-code: ASSET-SYNTH-001 + label: Synthetic irrigation pump + asset-class: equipment + capture: renamed-asset + - id: create-site + entity: asset-site + accessProfile: asset-operator + claims: *asset_operator_claims + request: + operation: create + data: {site-code: SITE-SYNTH-001, label: Synthetic northern depot} + expect: + outcome: success + status: 201 + fields: {site-code: SITE-SYNTH-001, label: Synthetic northern depot} + capture: first-site + - id: planner-gets-site + entity: asset-site + accessProfile: site-planner + claims: *site_planner_claims + request: {operation: get, recordRef: first-site} + expect: + outcome: success + status: 200 + fields: {site-code: SITE-SYNTH-001, label: Synthetic northern depot} + - id: planner-lists-sites + entity: asset-site + accessProfile: site-planner + claims: *site_planner_claims + request: {operation: list} + expect: {outcome: success, status: 200, count: 1} + - id: planner-without-purpose-is-concealed + entity: asset-item + accessProfile: site-planner + claims: + principal: synthetic-site-planner + request: {operation: get, recordRef: renamed-asset} + expect: + outcome: refusal + status: 404 + problemCode: resource.not_found diff --git a/products/registry-server/acceptance/business/modules/business-core/module.yaml b/products/registry-server/acceptance/business/modules/business-core/module.yaml new file mode 100644 index 0000000000..837fec5844 --- /dev/null +++ b/products/registry-server/acceptance/business/modules/business-core/module.yaml @@ -0,0 +1,2 @@ +id: business-core +version: 0.1.0 diff --git a/products/registry-server/acceptance/business/registry.yaml b/products/registry-server/acceptance/business/registry.yaml new file mode 100644 index 0000000000..ebec1c4c8d --- /dev/null +++ b/products/registry-server/acceptance/business/registry.yaml @@ -0,0 +1,113 @@ +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: business + version: 0.1.0 + defaultLanguage: en +package: + environment: acceptance + instanceId: business-acceptance + sequence: 1 + sourceRevision: business-acceptance-0.1.0 +manifestProjection: + accessProfile: public-register + classificationCeiling: public + catalog: + baseUrl: https://business-register.example.gov + title: Business Register Catalog + description: Portable metadata for the public business register slice. + publisher: + name: Business Register Authority + iri: https://business-register.example.gov/authority + dataset: + title: Public Business Register + description: Public legal entity and officer appointment metadata. + owner: Business Register Authority + status: active +modules: + - id: business-core + version: 0.1.0 + digest: sha256:fe95523a7c5c061af80f3b85f361ffa0913f6532c1834e1ae0e67e017dd50c2f +entities: + - id: legal-entity + route: legal-entities + mutationMode: mutable + classification: public + fields: + - {id: jurisdiction-code, type: string, required: true, maxLength: 16, classification: public} + - {id: registration-number, type: string, required: true, maxLength: 64, classification: public} + - {id: legal-name, type: string, required: true, maxLength: 240, classification: public} + - {id: entity-status, type: vocabulary-code, vocabulary: entity-status, required: true, classification: public} + - {id: public-service-address, type: string, required: false, maxLength: 240, classification: public} + - {id: protected-contact, type: string, required: false, maxLength: 240, classification: restricted} + - {id: protected-ownership-reference, type: string, required: false, maxLength: 120, classification: restricted} + - {id: internal-case-note, type: text, required: false, maxLength: 4000, classification: restricted} + constraints: + - {kind: unique, fields: [jurisdiction-code, registration-number]} + accessProfiles: + - id: public-register + default: true + anonymous: true + operations: [get, list] + readableFields: [jurisdiction-code, registration-number, legal-name, entity-status, public-service-address] + filterableFields: [jurisdiction-code, entity-status] + sortableFields: [legal-name] + - id: filing + route: filings + mutationMode: create_only + fields: + - {id: legal-entity, type: reference, target: legal-entity, required: true, classification: internal} + - {id: filing-number, type: string, required: true, maxLength: 64, classification: internal} + - {id: filing-type, type: vocabulary-code, vocabulary: filing-type, required: true, classification: internal} + - {id: filed-date, type: date, required: true, classification: internal} + - {id: source-system, type: string, required: true, maxLength: 80, classification: internal} + - {id: source-record-id, type: string, required: true, maxLength: 120, classification: internal} + - {id: correction-of, type: reference, target: filing, required: false, classification: internal} + - {id: provenance-note, type: text, required: false, maxLength: 4000, classification: restricted} + constraints: + - {kind: unique, fields: [legal-entity, filing-number]} + - {kind: unique, fields: [source-system, source-record-id]} + - id: officer-appointment + route: officer-appointments + mutationMode: mutable + classification: public + fields: + - {id: legal-entity, type: reference, target: legal-entity, required: true, classification: public} + - {id: officer-code, type: string, required: true, maxLength: 64, classification: public} + - {id: officer-name, type: string, required: true, maxLength: 240, classification: public} + - {id: officer-role, type: vocabulary-code, vocabulary: officer-role, required: true, classification: public} + - {id: effective-from, type: date, required: true, classification: public} + - {id: effective-to, type: date, required: false, classification: public} + - {id: protected-officer-id, type: string, required: false, maxLength: 120, classification: restricted} + temporal: + startField: effective-from + endField: effective-to + scopeFields: [legal-entity, officer-code] + constraints: + - {kind: unique, fields: [legal-entity, officer-code, effective-from]} + - kind: unique + fields: [legal-entity, officer-role] + when: + - {kind: field_is_null, field: effective-to} + - {kind: active_lifecycle} + - {kind: temporal-non-overlap, scopeFields: [legal-entity, officer-code], startField: effective-from, endField: effective-to} + accessProfiles: + - id: public-register + default: true + anonymous: true + operations: [get, list] + readableFields: [legal-entity, officer-name, officer-role, effective-from, effective-to] + filterableFields: [legal-entity, officer-role, effective-from] + sortableFields: [effective-from] +accessProfiles: + - id: business-registrar + principalClaim: registry_principal + purposes: [business-registry] + grants: + - {entity: legal-entity, actions: [create, get, list, patch], readableFields: [jurisdiction-code, registration-number, legal-name, entity-status, public-service-address, protected-contact, protected-ownership-reference, internal-case-note], writableFields: [jurisdiction-code, registration-number, legal-name, entity-status, public-service-address, protected-contact, protected-ownership-reference, internal-case-note], filterableFields: [jurisdiction-code, registration-number, entity-status]} + - {entity: filing, actions: [create, get, list], readableFields: [legal-entity, filing-number, filing-type, filed-date, source-system, source-record-id, correction-of, provenance-note], writableFields: [legal-entity, filing-number, filing-type, filed-date, source-system, source-record-id, correction-of, provenance-note], filterableFields: [legal-entity, filing-type, filed-date, source-system]} + - {entity: officer-appointment, actions: [create, get, list, patch], readableFields: [legal-entity, officer-code, officer-name, officer-role, effective-from, effective-to, protected-officer-id], writableFields: [legal-entity, officer-code, officer-name, officer-role, effective-from, effective-to, protected-officer-id], filterableFields: [legal-entity, officer-code, officer-role, effective-from]} +vocabularies: + - {id: entity-status, values: [active, dissolved, suspended]} + - {id: filing-type, values: [incorporation, annual-return, correction]} + - {id: officer-role, values: [director, secretary, partner]} diff --git a/products/registry-server/acceptance/business/tests/journeys.yaml b/products/registry-server/acceptance/business/tests/journeys.yaml new file mode 100644 index 0000000000..bfb3b88f67 --- /dev/null +++ b/products/registry-server/acceptance/business/tests/journeys.yaml @@ -0,0 +1,88 @@ +apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: registrar-and-public-register + steps: + - id: create-legal-entity + entity: legal-entity + accessProfile: business-registrar + claims: &business_registrar_claims + principal: synthetic-business-registrar + purpose: business-registry + request: + operation: create + data: + jurisdiction-code: ZZ + registration-number: BUSINESS-SYNTH-001 + legal-name: Synthetic Example Cooperative + entity-status: active + public-service-address: Synthetic service address + protected-contact: synthetic-contact@example.invalid + protected-ownership-reference: OWNERSHIP-SYNTH-001 + internal-case-note: Synthetic test record only + expect: + outcome: success + status: 201 + fields: + jurisdiction-code: ZZ + registration-number: BUSINESS-SYNTH-001 + legal-name: Synthetic Example Cooperative + entity-status: active + protected-ownership-reference: OWNERSHIP-SYNTH-001 + capture: first-business + - id: public-register-gets-business + entity: legal-entity + accessProfile: public-register + request: {operation: get, recordRef: first-business} + expect: + outcome: success + status: 200 + fields: + jurisdiction-code: ZZ + registration-number: BUSINESS-SYNTH-001 + legal-name: Synthetic Example Cooperative + entity-status: active + public-service-address: Synthetic service address + - id: public-register-lists-businesses + entity: legal-entity + accessProfile: public-register + request: {operation: list} + expect: {outcome: success, status: 200, count: 1} + - id: registrar-suspends-business + entity: legal-entity + accessProfile: business-registrar + claims: *business_registrar_claims + request: + operation: patch + recordRef: first-business + etagRef: first-business + changes: + - {field: entity-status, value: suspended} + expect: + outcome: success + status: 200 + fields: + registration-number: BUSINESS-SYNTH-001 + entity-status: suspended + protected-ownership-reference: OWNERSHIP-SYNTH-001 + capture: suspended-business + - id: public-register-sees-new-status + entity: legal-entity + accessProfile: public-register + request: {operation: get, recordRef: suspended-business} + expect: + outcome: success + status: 200 + fields: + registration-number: BUSINESS-SYNTH-001 + legal-name: Synthetic Example Cooperative + entity-status: suspended + - id: registrar-without-purpose-is-concealed + entity: legal-entity + accessProfile: business-registrar + claims: + principal: synthetic-business-registrar + request: {operation: get, recordRef: suspended-business} + expect: + outcome: refusal + status: 404 + problemCode: resource.not_found diff --git a/products/registry-server/acceptance/disability/modules/disability-core/module.yaml b/products/registry-server/acceptance/disability/modules/disability-core/module.yaml new file mode 100644 index 0000000000..a4fb41a219 --- /dev/null +++ b/products/registry-server/acceptance/disability/modules/disability-core/module.yaml @@ -0,0 +1,2 @@ +id: disability-core +version: 0.1.0 diff --git a/products/registry-server/acceptance/disability/registry.yaml b/products/registry-server/acceptance/disability/registry.yaml new file mode 100644 index 0000000000..6dcd2a8f5c --- /dev/null +++ b/products/registry-server/acceptance/disability/registry.yaml @@ -0,0 +1,108 @@ +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: disability + version: 0.1.0 + defaultLanguage: en +package: + environment: acceptance + instanceId: disability-acceptance + sequence: 1 + sourceRevision: disability-acceptance-0.1.0 +manifestProjection: + accessProfile: disability-caseworker + classificationCeiling: restricted + catalog: + baseUrl: https://disability-registry.example.gov + title: Disability Assessment Registry Catalog + description: Portable metadata for disability assessment and certification records. + publisher: + name: Disability Assessment Authority + iri: https://disability-registry.example.gov/authority + dataset: + title: Disability Assessment Registry + description: Assessment episode, functioning observation, and certification metadata. + owner: Disability Assessment Authority + status: active +modules: + - id: disability-core + version: 0.1.0 + digest: sha256:420b18a2b8db11fb96675c0a720788ea3a127d69e50fdf90143b795c71f9b56b +entities: + - id: assessment-episode + route: assessment-episodes + mutationMode: mutable + classification: restricted + fields: + - {id: episode-code, type: string, required: true, maxLength: 64, classification: restricted} + - {id: subject-code, type: string, required: true, maxLength: 64, classification: restricted} + - {id: opened-on, type: date, required: true, classification: restricted} + - {id: closed-on, type: date, required: false, classification: restricted} + - {id: assessment-source, type: string, required: true, maxLength: 80, classification: restricted} + constraints: + - {kind: unique, fields: [episode-code]} + - id: functioning-observation + route: functioning-observations + mutationMode: create_only + classification: restricted + fields: + - {id: assessment-episode, type: reference, target: assessment-episode, required: true, classification: restricted} + - {id: observed-at, type: timestamp, required: true, classification: restricted} + - {id: functioning-domain, type: vocabulary-code, vocabulary: functioning-domain, required: true, classification: restricted} + - {id: severity-score, type: int64, required: true, classification: restricted} + - id: observation-schema-metadata + type: structured + required: true + maxBytes: 2048 + classification: restricted + schema: + type: object + additionalProperties: false + required: [schemaVersion, vocabularyRelease, scoringScale] + properties: + schemaVersion: + type: string + minLength: 1 + maxLength: 32 + vocabularyRelease: + type: string + minLength: 1 + maxLength: 32 + scoringScale: + type: string + enum: [zero-to-four] + - {id: observation-note, type: text, required: false, maxLength: 4000, classification: restricted} + constraints: + - {kind: int_range, field: severity-score, minimum: 0, maximum: 4} + - id: certification + route: certifications + mutationMode: create_only + classification: restricted + fields: + - {id: certification-code, type: string, required: true, maxLength: 64, classification: restricted} + - {id: assessment-episode, type: reference, target: assessment-episode, required: true, classification: restricted} + - {id: certification-status, type: vocabulary-code, vocabulary: certification-status, required: true, classification: restricted} + - {id: valid-from, type: date, required: true, classification: restricted} + - {id: valid-to, type: date, required: false, classification: restricted} + - {id: corrected-certification, type: reference, target: certification, required: false, classification: restricted} + - {id: correction-reason, type: text, required: false, maxLength: 2000, classification: restricted} + - {id: validity-source, type: string, required: true, maxLength: 120, classification: restricted} + - {id: provenance-note, type: text, required: false, maxLength: 4000, classification: restricted} + temporal: + startField: valid-from + endField: valid-to + scopeFields: [assessment-episode] + constraints: + - {kind: unique, fields: [certification-code]} + - {kind: temporal-non-overlap, scopeFields: [assessment-episode], startField: valid-from, endField: valid-to} +accessProfiles: + - id: disability-caseworker + principalClaim: registry_principal + purposes: [disability-assessment] + grants: + - {entity: assessment-episode, actions: [create, get, list, patch], readableFields: [episode-code, subject-code, opened-on, closed-on, assessment-source], writableFields: [episode-code, subject-code, opened-on, closed-on, assessment-source], filterableFields: [episode-code, subject-code]} + - {entity: functioning-observation, actions: [create, get, list], readableFields: [assessment-episode, observed-at, functioning-domain, severity-score, observation-schema-metadata, observation-note], writableFields: [assessment-episode, observed-at, functioning-domain, severity-score, observation-schema-metadata, observation-note], filterableFields: [assessment-episode, functioning-domain]} + - {entity: certification, actions: [create, get, list], readableFields: [certification-code, assessment-episode, certification-status, valid-from, valid-to, corrected-certification, correction-reason, validity-source, provenance-note], writableFields: [certification-code, assessment-episode, certification-status, valid-from, valid-to, corrected-certification, correction-reason, validity-source, provenance-note], filterableFields: [assessment-episode, certification-status, valid-from]} +vocabularies: + - {id: functioning-domain, values: [mobility, cognition, self-care, communication]} + - {id: certification-status, values: [draft, active, corrected, withdrawn]} diff --git a/products/registry-server/acceptance/disability/tests/journeys.yaml b/products/registry-server/acceptance/disability/tests/journeys.yaml new file mode 100644 index 0000000000..5fffdaf2c0 --- /dev/null +++ b/products/registry-server/acceptance/disability/tests/journeys.yaml @@ -0,0 +1,89 @@ +apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: assessment-and-schema-validation + steps: + - id: create-assessment-episode + entity: assessment-episode + accessProfile: disability-caseworker + claims: &disability_caseworker_claims + principal: synthetic-disability-caseworker + purpose: disability-assessment + request: + operation: create + data: + episode-code: EPISODE-SYNTH-001 + subject-code: SUBJECT-SYNTH-001 + opened-on: 2026-01-10 + assessment-source: synthetic-case-management + expect: + outcome: success + status: 201 + fields: + episode-code: EPISODE-SYNTH-001 + subject-code: SUBJECT-SYNTH-001 + opened-on: 2026-01-10 + assessment-source: synthetic-case-management + capture: first-assessment + - id: close-assessment-episode + entity: assessment-episode + accessProfile: disability-caseworker + claims: *disability_caseworker_claims + request: + operation: patch + recordRef: first-assessment + etagRef: first-assessment + changes: + - {field: closed-on, value: 2026-01-20} + expect: + outcome: success + status: 200 + fields: + episode-code: EPISODE-SYNTH-001 + opened-on: 2026-01-10 + closed-on: 2026-01-20 + capture: closed-assessment + - id: get-closed-assessment + entity: assessment-episode + accessProfile: disability-caseworker + claims: *disability_caseworker_claims + request: {operation: get, recordRef: closed-assessment} + expect: + outcome: success + status: 200 + fields: {episode-code: EPISODE-SYNTH-001, closed-on: 2026-01-20} + - id: list-assessments + entity: assessment-episode + accessProfile: disability-caseworker + claims: *disability_caseworker_claims + request: {operation: list} + expect: {outcome: success, status: 200, count: 1} + - id: refuse-undeclared-observation-metadata + entity: functioning-observation + accessProfile: disability-caseworker + claims: *disability_caseworker_claims + request: + operation: create + data: + assessment-episode: 00000000-0000-0000-0000-000000000001 + observed-at: 2026-01-11T09:00:00Z + functioning-domain: mobility + severity-score: 3 + observation-schema-metadata: + schemaVersion: "1" + vocabularyRelease: 2026-01 + scoringScale: zero-to-four + undeclared: refused + expect: + outcome: refusal + status: 400 + problemCode: request.invalid + - id: caseworker-without-purpose-is-concealed + entity: assessment-episode + accessProfile: disability-caseworker + claims: + principal: synthetic-disability-caseworker + request: {operation: get, recordRef: closed-assessment} + expect: + outcome: refusal + status: 404 + problemCode: resource.not_found diff --git a/products/registry-server/acceptance/farmer/modules/farmer-core/module.yaml b/products/registry-server/acceptance/farmer/modules/farmer-core/module.yaml new file mode 100644 index 0000000000..7f54629466 --- /dev/null +++ b/products/registry-server/acceptance/farmer/modules/farmer-core/module.yaml @@ -0,0 +1,2 @@ +id: farmer-core +version: 0.1.0 diff --git a/products/registry-server/acceptance/farmer/registry.yaml b/products/registry-server/acceptance/farmer/registry.yaml new file mode 100644 index 0000000000..cf62be2af3 --- /dev/null +++ b/products/registry-server/acceptance/farmer/registry.yaml @@ -0,0 +1,108 @@ +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: farmer + version: 0.1.0 + defaultLanguage: en +package: + environment: acceptance + instanceId: farmer-acceptance + sequence: 1 + sourceRevision: farmer-acceptance-0.1.0 +manifestProjection: + accessProfile: farmer-operator + classificationCeiling: restricted + catalog: + baseUrl: https://farmer-registry.example.gov + title: Farmer Registry Catalog + description: Portable metadata for farmer, holding, plot, and seasonal activity records. + publisher: + name: Farmer Registry Authority + iri: https://farmer-registry.example.gov/authority + dataset: + title: Farmer Registry + description: Farmer registration, holdings, plots, and seasonal activity metadata. + owner: Farmer Registry Authority + status: active +modules: + - id: farmer-core + version: 0.1.0 + digest: sha256:abcdafff8a686fb17ae9d4c3aa8d4fed13a94c5c6bfdff9172131a2c90d8a159 +entities: + - id: farmer + route: farmers + mutationMode: mutable + fields: + - {id: farmer-code, type: string, required: true, maxLength: 64, classification: restricted} + - {id: display-name, type: string, required: true, maxLength: 160, classification: restricted} + - {id: administrative-boundary, type: vocabulary-code, vocabulary: administrative-boundary, required: true, classification: internal} + constraints: + - {kind: unique, fields: [farmer-code]} + - id: holding + route: holdings + mutationMode: mutable + fields: + - {id: holding-code, type: string, required: true, maxLength: 64, classification: internal} + - {id: farmer, type: reference, target: farmer, required: true, classification: restricted} + - {id: tenure-type, type: vocabulary-code, vocabulary: tenure-type, required: true, classification: internal} + - {id: tenure-start, type: date, required: true, classification: internal} + - {id: tenure-end, type: date, required: false, classification: internal} + - {id: administrative-boundary, type: vocabulary-code, vocabulary: administrative-boundary, required: true, classification: internal} + - {id: import-source, type: string, required: true, maxLength: 80, classification: internal} + - {id: source-record-id, type: string, required: true, maxLength: 120, classification: internal} + temporal: + startField: tenure-start + endField: tenure-end + scopeFields: [holding-code] + constraints: + - {kind: unique, fields: [holding-code, tenure-start]} + - {kind: unique, fields: [import-source, source-record-id]} + - {kind: temporal-non-overlap, scopeFields: [holding-code], startField: tenure-start, endField: tenure-end} + - id: plot + route: plots + mutationMode: mutable + batch: {maximumItems: 4, maximumBytes: 16384} + fields: + - {id: plot-code, type: string, required: true, maxLength: 64, classification: internal} + - {id: holding, type: reference, target: holding, required: true, classification: restricted} + - {id: administrative-boundary, type: vocabulary-code, vocabulary: administrative-boundary, required: true, classification: internal} + - {id: centroid, type: crs84-point, precision: 7, bbox: {west: "30.0000000", south: "-10.0000000", east: "31.0000000", north: "-9.0000000"}, required: true, classification: internal} + - {id: area-value, type: decimal, precision: 12, scale: 4, minimum: "0.0000", required: true, classification: internal} + - {id: area-unit, type: vocabulary-code, vocabulary: area-unit, required: true, classification: internal} + - {id: import-source, type: string, required: true, maxLength: 80, classification: internal} + - {id: source-record-id, type: string, required: true, maxLength: 120, classification: internal} + constraints: + - {kind: unique, fields: [plot-code]} + - {kind: unique, fields: [import-source, source-record-id]} + - id: seasonal-activity + route: seasonal-activities + mutationMode: mutable + fields: + - {id: plot, type: reference, target: plot, required: true, classification: restricted} + - {id: administrative-boundary, type: vocabulary-code, vocabulary: administrative-boundary, required: true, classification: internal} + - {id: activity-type, type: vocabulary-code, vocabulary: activity-type, required: true, classification: internal} + - {id: season-start, type: date, required: true, classification: internal} + - {id: season-end, type: date, required: false, classification: internal} + - {id: quantity-value, type: decimal, precision: 12, scale: 3, minimum: "0.000", required: false, classification: internal} + - {id: quantity-unit, type: vocabulary-code, vocabulary: quantity-unit, required: false, classification: internal} + temporal: + startField: season-start + endField: season-end + scopeFields: [plot, activity-type] + constraints: + - {kind: temporal-non-overlap, scopeFields: [plot, activity-type], startField: season-start, endField: season-end} +accessProfiles: + - id: farmer-operator + principalClaim: registry_principal + purposes: [farmer-registry] + grants: + - {entity: farmer, actions: [create, get, list, patch], readableFields: [farmer-code, display-name, administrative-boundary], writableFields: [farmer-code, display-name, administrative-boundary], filterableFields: [farmer-code, administrative-boundary], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} + - {entity: holding, actions: [create, get, list, patch], readableFields: [holding-code, farmer, tenure-type, tenure-start, tenure-end, administrative-boundary, import-source, source-record-id], writableFields: [holding-code, farmer, tenure-type, tenure-start, tenure-end, administrative-boundary, import-source, source-record-id], filterableFields: [farmer, administrative-boundary, import-source, tenure-start], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} + - {entity: plot, actions: [create, get, list, patch, batch], readableFields: [plot-code, holding, administrative-boundary, centroid, area-value, area-unit, import-source, source-record-id], writableFields: [plot-code, holding, administrative-boundary, centroid, area-value, area-unit, import-source, source-record-id], filterableFields: [holding, administrative-boundary, import-source], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} + - {entity: seasonal-activity, actions: [create, get, list, patch], readableFields: [plot, administrative-boundary, activity-type, season-start, season-end, quantity-value, quantity-unit], writableFields: [plot, administrative-boundary, activity-type, season-start, season-end, quantity-value, quantity-unit], filterableFields: [plot, administrative-boundary, activity-type, season-start], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} +vocabularies: + - {id: administrative-boundary, values: [north-district, south-district, central-district]} + - {id: tenure-type, values: [owned, leased, communal]} + - {id: area-unit, values: [hectare, square-meter]} + - {id: activity-type, values: [planting, harvest, irrigation]} + - {id: quantity-unit, values: [kilogram, litre, headcount]} diff --git a/products/registry-server/acceptance/farmer/tests/journeys.yaml b/products/registry-server/acceptance/farmer/tests/journeys.yaml new file mode 100644 index 0000000000..2b1f60d292 --- /dev/null +++ b/products/registry-server/acceptance/farmer/tests/journeys.yaml @@ -0,0 +1,91 @@ +apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: bounded-farmer-and-batch-validation + steps: + - id: create-north-district-farmer + entity: farmer + accessProfile: farmer-operator + claims: &north_operator_claims + principal: synthetic-farmer-operator + purpose: farmer-registry + directClaims: {administrative_boundaries: north-district} + request: + operation: create + data: + farmer-code: FARMER-SYNTH-001 + display-name: Synthetic North District Farmer + administrative-boundary: north-district + expect: + outcome: success + status: 201 + fields: + farmer-code: FARMER-SYNTH-001 + display-name: Synthetic North District Farmer + administrative-boundary: north-district + capture: north-farmer + - id: get-north-district-farmer + entity: farmer + accessProfile: farmer-operator + claims: *north_operator_claims + request: {operation: get, recordRef: north-farmer} + expect: + outcome: success + status: 200 + fields: {farmer-code: FARMER-SYNTH-001, administrative-boundary: north-district} + - id: rename-north-district-farmer + entity: farmer + accessProfile: farmer-operator + claims: *north_operator_claims + request: + operation: patch + recordRef: north-farmer + etagRef: north-farmer + changes: + - {field: display-name, value: Synthetic North District Producer} + expect: + outcome: success + status: 200 + fields: + farmer-code: FARMER-SYNTH-001 + display-name: Synthetic North District Producer + administrative-boundary: north-district + capture: updated-north-farmer + - id: list-north-district-farmers + entity: farmer + accessProfile: farmer-operator + claims: *north_operator_claims + request: {operation: list} + expect: {outcome: success, status: 200, count: 1} + - id: south-district-claim-cannot-see-north-record + entity: farmer + accessProfile: farmer-operator + claims: + principal: synthetic-farmer-operator + purpose: farmer-registry + directClaims: {administrative_boundaries: south-district} + request: {operation: get, recordRef: updated-north-farmer} + expect: + outcome: refusal + status: 404 + problemCode: resource.not_found + - id: batch-refuses-out-of-bounds-plot + entity: plot + accessProfile: farmer-operator + claims: *north_operator_claims + request: + operation: batch + items: + - operation: create + data: + plot-code: PLOT-SYNTH-INVALID-001 + holding: 00000000-0000-0000-0000-000000000001 + administrative-boundary: north-district + centroid: {type: Point, coordinates: [200.0, -9.5]} + area-value: "1.2500" + area-unit: hectare + import-source: synthetic-survey + source-record-id: plot-synth-invalid-001 + expect: + outcome: refusal + status: 400 + problemCode: request.invalid diff --git a/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-core/module.yaml b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-core/module.yaml new file mode 100644 index 0000000000..2cd03586dd --- /dev/null +++ b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-core/module.yaml @@ -0,0 +1,42 @@ +id: publicschema-household-core +version: 0.1.0 +entities: + - id: person + route: persons + mutationMode: mutable + classification: restricted + fields: + - {id: person-code, type: string, required: true, maxLength: 64, classification: restricted} + - {id: legal-name, type: string, required: true, maxLength: 160, classification: restricted} + - {id: family-name, type: string, required: false, maxLength: 120, classification: restricted} + - {id: date-of-birth, type: date, required: false, classification: restricted} + constraints: + - {kind: unique, fields: [person-code]} + - id: household + route: households + mutationMode: mutable + classification: restricted + fields: + - {id: household-code, type: string, required: true, maxLength: 64, classification: restricted} + - {id: household-name, type: string, required: true, maxLength: 160, classification: restricted} + - {id: administrative-area, type: string, required: true, maxLength: 80, classification: restricted} + - {id: household-type, type: vocabulary-code, vocabulary: household-type, required: true, classification: restricted} + constraints: + - {kind: unique, fields: [household-code]} + - id: group-membership + route: group-memberships + mutationMode: mutable + classification: restricted + fields: + - {id: person, type: reference, target: person, required: true, classification: restricted} + - {id: household, type: reference, target: household, required: true, classification: restricted} + - {id: relationship, type: vocabulary-code, vocabulary: household-relationship, required: true, classification: restricted} + - {id: valid-from, type: date, required: true, classification: restricted} + - {id: valid-to, type: date, required: false, classification: restricted} + temporal: + startField: valid-from + endField: valid-to + scopeFields: [person] + constraints: + - {kind: unique, fields: [person, household, valid-from]} + - {kind: temporal-non-overlap, scopeFields: [person], startField: valid-from, endField: valid-to} diff --git a/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/module.yaml b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/module.yaml new file mode 100644 index 0000000000..05bc3e3697 --- /dev/null +++ b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/module.yaml @@ -0,0 +1,8 @@ +id: publicschema-household-demographics +version: 0.1.0 +dependencies: [publicschema-household-core] +extendEntities: + - entity: person + fields: + - {id: residency-status, type: vocabulary-code, vocabulary: residency-status, required: true, classification: restricted} + - {id: preferred-language, type: vocabulary-code, vocabulary: preferred-language, required: false, classification: restricted} diff --git a/products/registry-server/acceptance/publicschema-household/registry.yaml b/products/registry-server/acceptance/publicschema-household/registry.yaml new file mode 100644 index 0000000000..d86cc87cb4 --- /dev/null +++ b/products/registry-server/acceptance/publicschema-household/registry.yaml @@ -0,0 +1,47 @@ +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: publicschema-household + version: 0.1.0 + defaultLanguage: en +package: + environment: acceptance + instanceId: publicschema-household-acceptance + sequence: 1 + sourceRevision: publicschema-household-acceptance-0.1.0 +manifestProjection: + accessProfile: household-operator + classificationCeiling: restricted + catalog: + baseUrl: https://publicschema-household.example.gov + title: PublicSchema Household Registry Catalog + description: Portable metadata for household, person, and group membership records. + publisher: + name: PublicSchema Household Authority + iri: https://publicschema-household.example.gov/authority + dataset: + title: PublicSchema Household Registry + description: Household, person, and time-bounded group membership metadata. + owner: PublicSchema Household Authority + status: active +modules: + - id: publicschema-household-core + version: 0.1.0 + digest: sha256:9e681a1d27a3677aa8cd319df83ab70d794263a93a560dd65672319e1d89b231 + - id: publicschema-household-demographics + version: 0.1.0 + digest: sha256:aba9eeebcbf420de79306fb8f499dc52cce8ad838953d5b1fcaf1a878e5f046c +accessProfiles: + - id: household-operator + principalClaim: registry_principal + requiredScopes: [registry:household:operate] + purposes: [household-administration] + grants: + - {entity: person, actions: [create, get, list, patch], readableFields: [person-code, legal-name, family-name, date-of-birth, residency-status, preferred-language], writableFields: [person-code, legal-name, family-name, date-of-birth, residency-status, preferred-language], filterableFields: [person-code, residency-status]} + - {entity: household, actions: [create, get, list, patch], readableFields: [household-code, household-name, administrative-area, household-type], writableFields: [household-code, household-name, administrative-area, household-type], filterableFields: [household-code, administrative-area, household-type]} + - {entity: group-membership, actions: [create, get, list, patch], readableFields: [person, household, relationship, valid-from, valid-to], writableFields: [person, household, relationship, valid-from, valid-to], filterableFields: [person, household, valid-from]} +vocabularies: + - {id: household-relationship, values: [head, spouse, child, dependent, other]} + - {id: household-type, values: [private, collective, institutional]} + - {id: residency-status, values: [usual-resident, temporary-resident, departed]} + - {id: preferred-language, values: [en, es, fr]} diff --git a/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml b/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml new file mode 100644 index 0000000000..d65b78501f --- /dev/null +++ b/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml @@ -0,0 +1,105 @@ +apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: household-person-lifecycle + steps: + - id: create-person + entity: person + accessProfile: household-operator + claims: &household_operator_claims + principal: synthetic-household-operator + scopes: [registry:household:operate] + purpose: household-administration + request: + operation: create + data: + person-code: PERSON-SYNTH-001 + legal-name: Synthetic Person One + family-name: Example + date-of-birth: 1990-01-15 + residency-status: usual-resident + preferred-language: en + expect: + outcome: success + status: 201 + fields: + person-code: PERSON-SYNTH-001 + legal-name: Synthetic Person One + family-name: Example + date-of-birth: 1990-01-15 + residency-status: usual-resident + preferred-language: en + capture: first-person + - id: get-person + entity: person + accessProfile: household-operator + claims: *household_operator_claims + request: {operation: get, recordRef: first-person} + expect: + outcome: success + status: 200 + fields: {person-code: PERSON-SYNTH-001, residency-status: usual-resident} + - id: update-person-residency + entity: person + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: patch + recordRef: first-person + etagRef: first-person + changes: + - {field: residency-status, value: temporary-resident} + expect: + outcome: success + status: 200 + fields: + person-code: PERSON-SYNTH-001 + residency-status: temporary-resident + capture: updated-person + - id: create-household + entity: household + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: create + data: + household-code: HOUSEHOLD-SYNTH-001 + household-name: Synthetic Example Household + administrative-area: demonstration-area + household-type: private + expect: + outcome: success + status: 201 + fields: + household-code: HOUSEHOLD-SYNTH-001 + household-name: Synthetic Example Household + administrative-area: demonstration-area + household-type: private + capture: first-household + - id: list-people + entity: person + accessProfile: household-operator + claims: *household_operator_claims + request: {operation: list} + expect: {outcome: success, status: 200, count: 1} + - id: refuse-incomplete-membership + entity: group-membership + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: create + data: {relationship: head, valid-from: 2026-01-01} + expect: + outcome: refusal + status: 400 + problemCode: request.invalid + - id: operator-without-purpose-is-concealed + entity: person + accessProfile: household-operator + claims: + principal: synthetic-household-operator + scopes: [registry:household:operate] + request: {operation: get, recordRef: updated-person} + expect: + outcome: refusal + status: 404 + problemCode: resource.not_found diff --git a/products/registry-server/contracts/acceptance-scenario-matrix.yaml b/products/registry-server/contracts/acceptance-scenario-matrix.yaml new file mode 100644 index 0000000000..db4a2eb734 --- /dev/null +++ b/products/registry-server/contracts/acceptance-scenario-matrix.yaml @@ -0,0 +1,83 @@ +apiVersion: registry.registrystack.org/product-contract/v1 +product: registry-server +scenarios: + - id: RS-J01 + state: enforced + domain: asset-site-placement + fixture: acceptance/asset-site-placement + doneWhen: "The non-person project proves valid-time placement, current and as-of reads, two access profiles, and generated contracts." + evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}] + - id: RS-J02 + state: enforced + domain: asset-site-placement + fixture: acceptance/asset-site-placement + doneWhen: "An additive classified field produces an exact diff and signed apply without rebuilding the server." + evidence: [{path: crates/registry-server/tests/package_change_plan.rs, name: new_optional_scalar_field_emits_only_closed_add_column}, {path: crates/registry-serverctl/tests/diff.rs, name: diff_inventory_is_deterministic_and_classification_direction_is_exact}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}] + - id: RS-J03 + state: enforced + domain: household + fixture: acceptance/publicschema-household + doneWhen: "Household, two persons, and current and historical membership run without privileged runtime concepts." + evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}] + - id: RS-J04 + state: enforced + domain: disability + fixture: acceptance/disability + doneWhen: "Assessment, observation, certification, protected metadata, and correction provenance run as configured records." + evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}] + - id: RS-J05 + state: enforced + domain: farmer + fixture: acceptance/farmer + doneWhen: "Farmer records prove point, unit, temporal, resumable import, and administrative-boundary behavior." + evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: crates/registry-server/tests/postgres_data_farmer.rs, name: real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race_safe}] + - id: RS-J06 + state: enforced + domain: business + fixture: acceptance/business + doneWhen: "Business records prove filings, appointments, identifiers, constraints, effective time, and public/protected processing." + evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}] + - id: RS-J07 + state: enforced + doneWhen: "Runtime inventories contain no acceptance-fixture dependency." + evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: products/registry-server/scripts/check-source-neutrality.sh, name: check-source-neutrality.sh}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_public_kernel_contract_canary_is_rejected}] + - id: RS-J08 + state: enforced + doneWhen: "Hidden processing, unauthorized writes, wrong purpose, stale ETags, changed idempotency, and malformed tokens refuse without leakage." + evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: anonymous_public_profile_cannot_filter_a_non_public_field}, {path: crates/registry-server/tests/compiler_contract.rs, name: anonymous_public_surface_rejects_every_non_public_constraint_field}, {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}, {path: crates/registry-server/tests/http_auth.rs, name: issuer_audience_algorithm_token_type_and_signature_are_all_verified}] + - id: RS-J09 + state: enforced + doneWhen: "Package, profile, purpose, row boundary, projection, and query changes invalidate replay after fresh authorization." + evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}, {path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}] + - id: RS-J10 + state: enforced + doneWhen: "Fault injection proves atomic record, revision, audit, idempotency, and configured outbox state." + evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}] + - id: RS-J11 + state: enforced + doneWhen: "Pool reuse across success and every failure path leaks no authority." + evidence: [{path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}] + - id: RS-J12 + state: enforced + doneWhen: "Terminal audit failure releases no protected read or mutation response bytes." + evidence: [{path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_read_is_authorized_bounded_minimized_and_audit_gated}, {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}] + - id: RS-J13 + state: enforced + doneWhen: "Live apply drains prior work, blocks new work, verifies schema, activates once, and leaves the old process unready." + evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: real_postgres_package_startup_apply_failure_and_old_process_are_closed}, {path: crates/registry-server/tests/postgres_startup.rs, name: live_old_server_drains_apply_and_exact_successor_restart_becomes_ready}] + - id: RS-J14 + state: enforced + doneWhen: "Package, artifact, checksum, signature, sequence, permission, path, and schema tampering fail before serving or activation." + evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: local_unsigned_package_rederives_every_artifact_and_refuses_filesystem_tampering}, {path: crates/registry-server/tests/postgres_package.rs, name: package_manifest_refuses_ddl_checksum_path_and_canonical_json_tampering}, {path: crates/registry-server/tests/postgres_package.rs, name: package_refuses_symlinks_and_production_writable_permissions}, {path: crates/registry-server/tests/postgres_package.rs, name: package_binding_refuses_wrong_environment_instance_database_sequence_and_prior}, {path: crates/registry-server/tests/postgres_package.rs, name: production_package_requires_exact_trust_anchor_threshold_and_signature}, {path: crates/registry-server/tests/postgres_package.rs, name: signed_schema_fingerprint_mismatch_is_durably_failed_and_never_ready}] + - id: RS-J15 + state: enforced + doneWhen: "Risky backfill and destructive rehearsal prove bounds, backup binding, and failed-maintenance recovery." + evidence: [{path: crates/registry-server/tests/migration_plan.rs, name: reviewed_migration_plan_closes_ast_sql_and_bound_evidence}, {path: crates/registry-server/tests/migration_plan.rs, name: reviewed_migration_plan_rejects_uncovered_changes_forbidden_sql_and_unbound_evidence}, {path: crates/registry-server/tests/postgres_migration.rs, name: real_postgres_backfill_and_destructive_recovery_are_bounded_resumable_and_activation_closed}] + - id: RS-J16 + state: enforced + doneWhen: "Webhook delivery, retry, dead letter, replay, projection, and classification confinement pass together." + evidence: [{path: crates/registry-server/tests/compiler_webhook.rs, name: governed_webhook_compiles_to_deterministic_destination_neutral_inventory}, {path: crates/registry-server/tests/runtime_config.rs, name: activation_constructs_the_exact_platform_policy_template_and_signing_material}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_deterministically_identified}, {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}] + - id: RS-J17 + state: enforced + doneWhen: "An external coding agent can author and check but cannot satisfy signature or migration-role authority." + evidence: [{path: crates/registry-serverctl/tests/cli.rs, name: package_always_uses_production_compilation_and_never_offers_a_signing_command}, {path: crates/registry-serverctl/tests/diff.rs, name: production_trust_is_verified_without_opening_runtime_dependencies}, {path: crates/registry-server/tests/postgres_package.rs, name: wrong_migration_role_is_refused_before_initial_control_plane_or_ddl}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}] diff --git a/products/registry-server/contracts/artifact-inventory.yaml b/products/registry-server/contracts/artifact-inventory.yaml new file mode 100644 index 0000000000..15e57cf620 --- /dev/null +++ b/products/registry-server/contracts/artifact-inventory.yaml @@ -0,0 +1,27 @@ +apiVersion: registry.registrystack.org/product-contract/v1 +product: registry-server +artifacts: + - {path: README.md, kind: product-boundary, state: authored} + - {path: DEFINITION-OF-DONE.md, kind: completion-contract, state: authored} + - {path: IMPLEMENTATION.md, kind: implementation-guide, state: authored} + - {path: ACCEPTANCE-JOURNEYS.md, kind: acceptance-guide, state: authored} + - {path: contracts/definition-of-done.yaml, kind: machine-readable-dod, state: authored} + - {path: contracts/implementation-schedule.yaml, kind: delivery-schedule, state: authored} + - {path: contracts/acceptance-scenario-matrix.yaml, kind: acceptance-matrix, state: authored} + - {path: contracts/artifact-inventory.yaml, kind: artifact-inventory, state: authored} + - {path: contracts/package-layout.yaml, kind: package-layout, state: authored} + - {path: contracts/security-invariant-matrix.yaml, kind: security-lifecycle, state: authored} + - {path: contracts/security-test-traceability.yaml, kind: security-traceability, state: authored} + - {path: acceptance/asset-site-placement/registry.yaml, kind: authored-fixture, state: authored} + - {path: acceptance/publicschema-household, kind: authored-fixture, state: authored} + - {path: acceptance/disability, kind: authored-fixture, state: authored} + - {path: acceptance/farmer, kind: authored-fixture, state: authored} + - {path: acceptance/business, kind: authored-fixture, state: authored} + - {path: demo, kind: local-mint-server-demo, state: authored} + - {path: generated/authoring/registry-project.schema.json, kind: generated-authoring-schema, state: authored} + - {path: generated/asset-site-placement, kind: generated-baseline, state: authored} + - {path: scripts/check-generated.sh, kind: generated-artifact-gate, state: authored} + - {path: scripts/compare-generated-tree.py, kind: generated-tree-comparator, state: authored} + - {path: scripts/test-postgres.sh, kind: real-postgresql-entrypoint, state: authored} + - {path: scripts/test-postgres-tls.sh, kind: real-postgresql-tls-entrypoint, state: authored} + - {path: scripts/test-adopter-workflow.sh, kind: adopter-workflow-entrypoint, state: authored} diff --git a/products/registry-server/contracts/definition-of-done.yaml b/products/registry-server/contracts/definition-of-done.yaml new file mode 100644 index 0000000000..65020c21a6 --- /dev/null +++ b/products/registry-server/contracts/definition-of-done.yaml @@ -0,0 +1,68 @@ +apiVersion: registry.registrystack.org/product-contract/v1 +product: registry-server +requirements: + - {id: RS-W0-CONTRACTS, phase: W0, state: enforced, doneWhen: "Product contracts and source-neutrality checks are deterministic and self-tested.", journeys: [RS-J07], evidence: [{path: products/registry-server/scripts/test_validate_product.py, name: test_tracked_product_catalog_is_internally_complete}]} + - {id: RS-W0-CRATES, phase: W0, state: enforced, doneWhen: "Exactly two product crates keep runtime I/O opt-in.", journeys: [RS-J17], evidence: [{path: products/registry-server/scripts/test_validate_product.py, name: test_w0_crate_boundary_is_two_crates_with_opt_in_runtime}]} + - {id: RS-W0-CI, phase: W0, state: enforced, doneWhen: "Owning paths select the Registry Server gates.", journeys: [RS-J07], evidence: [{path: .github/scripts/test_ci_changes.py, name: test_registry_server_paths_select_its_shard_and_product_gate}]} + - {id: RS-AP-02, phase: W1, state: enforced, doneWhen: "A strict non-person project compiles deterministic model, DDL, route, access, schema, and OpenAPI inventories.", journeys: [RS-J01], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: public_asset_fixture_compiles_to_coherent_deterministic_inventories}]} + - {id: RS-AP-03, phase: W3, state: enforced, doneWhen: "The real router serves mutable operations and omits unsupported create-only mutations.", journeys: [RS-J01], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}]} + - {id: RS-AP-04, phase: W2, state: enforced, doneWhen: "Separated roles operate through generated forced RLS policies.", journeys: [RS-J08], evidence: [{path: crates/registry-server/tests/postgres_compiled_schema.rs, name: compiled_postgres_schema_enforces_context_rls_and_exact_catalog}]} + - {id: RS-AP-05, phase: W2, state: enforced, doneWhen: "Record transactions take the shared lock and install transaction-local authority.", journeys: [RS-J11], evidence: [{path: crates/registry-server/tests/postgres_compiled_schema.rs, name: compiled_postgres_schema_enforces_context_rls_and_exact_catalog}]} + - {id: RS-AP-06, phase: W3, state: enforced, doneWhen: "A mutation atomically commits record, revision, outbox, audit, idempotency, and held response.", journeys: [RS-J10], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}]} + - {id: RS-AP-07, phase: W3, state: enforced, doneWhen: "Fault injection leaves no partial mutation and replay returns exact stored response bytes.", journeys: [RS-J10], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}]} + - {id: RS-AP-08, phase: W3, state: enforced, doneWhen: "Attempt and terminal audit gates bracket protected I/O and release.", journeys: [RS-J12], evidence: [{path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_read_is_authorized_bounded_minimized_and_audit_gated}]} + - {id: RS-AP-09, phase: W2, state: enforced, doneWhen: "A one-connection pool leaks no transaction-local authority.", journeys: [RS-J11], evidence: [{path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}]} + - {id: RS-AP-10, phase: W2, state: enforced, doneWhen: "The apply connection retains its exclusive lock across maintenance transitions.", journeys: [RS-J13], evidence: [{path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}]} + - {id: RS-AP-11, phase: W4, state: enforced, doneWhen: "Package loading rederives artifacts and rejects tampering before listener binding.", journeys: [RS-J14], evidence: [{path: crates/registry-server/tests/startup_ordering.rs, name: tampered_package_refuses_before_database_audit_oidc_or_listener_access}]} + - {id: RS-AP-12, phase: W2, state: enforced, doneWhen: "The PostgreSQL client passes pooling, cancellation, TLS, and crash feasibility proof.", journeys: [RS-J11], evidence: [{path: crates/registry-server/tests/postgres_tls.rs, name: custom_ca_requires_a_trusted_tls_server}]} + - {id: RS-AP-13, phase: W2, state: enforced, doneWhen: "Temporal DDL refuses a missing administrator-installed btree_gist prerequisite.", journeys: [RS-J01], evidence: [{path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}]} + - {id: RS-AP-14, phase: W4, state: enforced, doneWhen: "Every pre-pilot security invariant has one resolving executable negative.", journeys: [RS-J08], evidence: [{path: products/registry-server/scripts/test_validate_product.py, name: test_every_pre_w5_security_invariant_is_enforced_with_an_executable_negative}]} + - {id: RS-AP-15, phase: W1, state: enforced, doneWhen: "Product, generated, PostgreSQL, TLS, and classifier gates are executable.", journeys: [RS-J07], evidence: [{path: products/registry-server/scripts/test_validate_product.py, name: test_tracked_product_catalog_is_internally_complete}]} + - {id: RS-AP-16, phase: W3, state: enforced, doneWhen: "Revision routes return bounded authorized history through audit release gates.", journeys: [RS-J12], evidence: [{path: crates/registry-server/tests/postgres_revision_http.rs, name: real_postgres_revision_http_is_bounded_authorized_atomic_and_audit_gated}]} + - {id: RS-AP-17, phase: W5, state: enforced, doneWhen: "Batch routes are bounded, atomic, authorized, audited, and exactly replayable.", journeys: [RS-J10], evidence: [{path: crates/registry-server/tests/postgres_batch.rs, name: real_postgres_batch_is_bounded_authorized_atomic_and_exactly_replayable}]} + - {id: RS-V1-PILOT, phase: W5, state: enforced, doneWhen: "All pilot requirements and journeys pass on one revision against real PostgreSQL.", journeys: [RS-J01, RS-J03, RS-J04, RS-J05, RS-J06], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}]} + + - {id: RS-V1-01, phase: W1, state: enforced, doneWhen: "Strict configuration closes stable governed identities and rejects unknown or duplicate members.", journeys: [RS-J01, RS-J02], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: public_asset_fixture_compiles_to_coherent_deterministic_inventories}, {path: crates/registry-server/tests/compiler_contract.rs, name: strict_parse_refuses_unknown_and_duplicate_members_without_echoing_values}, {path: crates/registry-server/tests/compiler_contract.rs, name: verified_module_digest_changes_compiled_closure_artifact_and_revision}]} + - {id: RS-V1-02, phase: W1, state: enforced, doneWhen: "The scalar grammar is exactly the approved typed set and excludes unvalidated JSON and reference lists.", journeys: [RS-J04, RS-J05], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: generic_decimal_crs84_point_and_structured_fields_compile_to_deterministic_ddl_and_schema}, {path: crates/registry-server/tests/compiler_contract.rs, name: scalar_field_sources_reject_incompatible_type_options_during_strict_parse}, {path: crates/registry-server/tests/compiler_contract.rs, name: scalar_grammar_is_exactly_the_typed_allowlist_and_rejects_json_or_reference_lists}]} + - {id: RS-V1-03, phase: W1, state: enforced, doneWhen: "Mutation modes and tombstones remain separate from ordinary domain vocabularies.", journeys: [RS-J01, RS-J04, RS-J06], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: create_only_operation_conflict_fails_before_artifact_generation}, {path: crates/registry-server/tests/postgres_mutation.rs, name: compiled_fixture_exposes_create_patch_and_configured_tombstone_plans}, {path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} + - {id: RS-V1-04, phase: W1, state: enforced, doneWhen: "Valid time supports ordered, open-ended current and as-of reads with scoped non-overlap.", journeys: [RS-J01, RS-J03, RS-J06], evidence: [{path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}, {path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: crates/registry-server/src/postgres/read.rs, name: temporal_query_instant_uses_utc_calendar_dates_without_session_timezone_dependence}]} + - {id: RS-V1-05, phase: W1, state: enforced, doneWhen: "A closed typed constraint grammar leaves concurrent uniqueness and references authoritative in PostgreSQL.", journeys: [RS-J06, RS-J08], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: closed_constraint_grammar_compiles_typed_checks_and_refuses_expression_escape_hatches}, {path: crates/registry-server/tests/compiler_contract.rs, name: temporal_non_overlap_refuses_structured_and_crs84_point_scope_fields}, {path: crates/registry-server/tests/compiler_contract.rs, name: temporal_non_overlap_accepts_every_btree_gist_equality_scalar_scope_type}, {path: crates/registry-server/tests/postgres_partial_unique.rs, name: real_postgres_partial_unique_index_enforces_only_the_closed_predicate}, {path: crates/registry-server/tests/postgres_constraint_races.rs, name: real_postgres_reference_and_temporal_races_leave_no_dangling_or_overlapping_records}]} + - {id: RS-V1-06, phase: W1, state: enforced, doneWhen: "Additive modules merge deterministically and incompatible changes require migration treatment.", journeys: [RS-J02], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: independent_additive_modules_are_order_independent}, {path: crates/registry-server/tests/package_change_plan.rs, name: complete_extension_surface_modules_are_order_independent}, {path: crates/registry-server/tests/package_change_plan.rs, name: non_additive_changes_are_classified_and_cannot_create_applicable_plans}, {path: crates/registry-server/tests/package_change_plan.rs, name: metadata_only_reviewed_migration_covers_non_sql_surface_without_dummy_sql}, {path: crates/registry-server/tests/package_change_plan.rs, name: reference_target_change_can_be_reviewed_through_compiler_owned_fk_constraint}, {path: crates/registry-server/tests/compiler_webhook.rs, name: additive_modules_add_nonconflicting_subscriptions_deterministically_and_refuse_conflicts}]} + - {id: RS-V1-07, phase: W1, state: enforced, doneWhen: "One compiler emits the complete governed, database, API, metadata, package, and Manifest artifact set.", journeys: [RS-J01, RS-J02], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: public_asset_fixture_compiles_to_coherent_deterministic_inventories}, {path: crates/registry-server/tests/compiler_contract.rs, name: compiled_metadata_inventory_is_bijective_canonical_schema_bound_and_deterministic}, {path: crates/registry-server/tests/http_read_only.rs, name: caller_filtered_discovery_conceals_counts_vocabularies_events_queries_and_every_metadata_surface}, {path: crates/registry-server/tests/postgres_package.rs, name: package_layout_contract_required_manifest_projection_is_in_prepared_closure}, {path: crates/registry-server/tests/postgres_package.rs, name: package_rederivation_refuses_rehashed_substituted_caller_safe_metadata}, {path: crates/registry-serverctl/tests/cli.rs, name: generation_is_byte_stable_and_reports_the_exact_artifact_inventory}, {path: products/registry-server/scripts/check-generated.sh, name: check-generated.sh}]} + - {id: RS-V1-08, phase: W1, state: enforced, doneWhen: "Router, OpenAPI, metadata, authorization, and migration planning consume shared inventories.", journeys: [RS-J01, RS-J02], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: generated_openapi_routes_and_physical_names_share_one_compiled_inventory}, {path: crates/registry-serverctl/tests/cli.rs, name: explain_reports_are_derived_from_compiled_inventories}, {path: crates/registry-server/tests/package_change_plan.rs, name: new_entity_plan_uses_complete_candidate_ddl_in_dependency_order}]} + - {id: RS-V1-09, phase: W1, state: enforced, doneWhen: "Canonical inputs generate byte-identical artifacts and committed drift is rejected.", journeys: [RS-J01, RS-J02], evidence: [{path: crates/registry-serverctl/tests/cli.rs, name: generation_is_byte_stable_and_reports_the_exact_artifact_inventory}, {path: products/registry-server/scripts/check-generated.sh, name: check-generated.sh}, {path: products/registry-server/scripts/test_generated_gates.py, name: test_comparator_rejects_a_missing_committed_artifact}]} + - {id: RS-V1-10, phase: W1, state: enforced, doneWhen: "The Manifest adapter receives only a classified one-way lossy projection.", journeys: [RS-J01], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: manifest_projection_filters_by_selected_profile_and_classification_ceiling}, {path: crates/registry-server/tests/compiler_contract.rs, name: manifest_projection_omits_physical_runtime_and_security_terms}, {path: crates/registry-serverctl/tests/cli.rs, name: manifest_selector_requires_the_compiled_manifest_projection}]} + - {id: RS-V1-11, phase: W2, state: enforced, doneWhen: "Typed tables use compiler-owned identifiers while runtime values remain parameters.", journeys: [RS-J08], evidence: [{path: crates/registry-server/tests/postgres_compiled_schema.rs, name: compiled_postgres_schema_enforces_context_rls_and_exact_catalog}, {path: crates/registry-server/src/postgres/roles.rs, name: governed_identifier_grammar_refuses_sql_syntax_and_case_folding}, {path: crates/registry-server/src/mutation.rs, name: mutation_scalar_validation_refuses_invalid_lexical_values_before_sql}]} + - {id: RS-V1-12, phase: W2, state: enforced, doneWhen: "Entity data and internal package, revision, audit, idempotency, and outbox state use the approved schema split.", journeys: [RS-J10], evidence: [{path: crates/registry-server/tests/postgres_compiled_schema.rs, name: compiled_postgres_schema_enforces_context_rls_and_exact_catalog}]} + - {id: RS-V1-13, phase: W2, state: enforced, doneWhen: "Revisions retain package, actor, request, import, correction, and supersession provenance under access control.", journeys: [RS-J04, RS-J12], evidence: [{path: crates/registry-server/tests/postgres_revision_http.rs, name: real_postgres_revision_http_is_bounded_authorized_atomic_and_audit_gated}, {path: crates/registry-server/tests/postgres_tombstone_revision.rs, name: tombstone_revisions_survive_package_upgrade_and_replay_exactly}, {path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} + - {id: RS-V1-14, phase: W2, state: enforced, doneWhen: "Concurrent constraints, optimistic writes, and idempotency races produce no duplicate effect or disclosure.", journeys: [RS-J08, RS-J10], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}, {path: crates/registry-server/tests/postgres_tombstone_revision.rs, name: tombstone_refusals_faults_and_concurrency_have_no_duplicate_effects}, {path: crates/registry-server/tests/postgres_partial_unique.rs, name: real_postgres_partial_unique_index_enforces_only_the_closed_predicate}, {path: crates/registry-server/tests/postgres_constraint_races.rs, name: real_postgres_reference_and_temporal_races_leave_no_dangling_or_overlapping_records}, {path: crates/registry-server/tests/postgres_data_farmer.rs, name: real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race_safe}]} + - {id: RS-V1-15, phase: W2, state: enforced, doneWhen: "RLS assurance and the operator credential boundary avoid overstating compromise protection.", journeys: [RS-J11, RS-J17], evidence: [{path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}, {path: crates/registry-server/tests/postgres_compiled_schema.rs, name: compiled_postgres_schema_enforces_context_rls_and_exact_catalog}, {path: crates/registry-server/src/postgres/config.rs, name: connection_debug_never_contains_database_secrets}, {path: products/registry-server/scripts/test_validate_product.py, name: test_rls_assurance_and_operator_credential_posture_are_exact}]} + - {id: RS-V1-16, phase: W3, state: enforced, doneWhen: "Configured REST exposes only permitted mutations, reads, Batch, revisions, schemas, metadata, and queries.", journeys: [RS-J01, RS-J10], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}, {path: crates/registry-server/tests/postgres_batch.rs, name: real_postgres_batch_is_bounded_authorized_atomic_and_exactly_replayable}, {path: crates/registry-server/tests/postgres_revision_http.rs, name: real_postgres_revision_http_is_bounded_authorized_atomic_and_audit_gated}, {path: crates/registry-server/tests/http_read_only.rs, name: discovery_surfaces_share_caller_filtered_routes_and_fields}]} + - {id: RS-V1-17, phase: W3, state: enforced, doneWhen: "Conditional mutations and idempotency use bound headers and value-free stable problems.", journeys: [RS-J08, RS-J09], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}, {path: crates/registry-server/tests/postgres_mutation.rs, name: mutation_error_vocabulary_is_closed_and_value_free}]} + - {id: RS-V1-18, phase: W3, state: enforced, doneWhen: "Queries close operators, ordering, pages, projections, and fresh authenticated cursors.", journeys: [RS-J09], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: compiled_query_inventory_is_profile_scoped_bounded_and_temporal}, {path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}, {path: crates/registry-server/src/cursor.rs, name: cursor_codec_conceals_payload_and_uses_fresh_nonces}]} + - {id: RS-V1-19, phase: W3, state: enforced, doneWhen: "Cursor, ETag, and idempotency replay bind active authorization and request context.", journeys: [RS-J09], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}, {path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}, {path: crates/registry-server/src/cursor.rs, name: cursor_binding_mismatch_cases_are_separate_and_value_free}]} + - {id: RS-V1-20, phase: W3, state: enforced, doneWhen: "Caller-filtered discovery exposes only the selected profile and conceals unauthorized surfaces.", journeys: [RS-J01, RS-J08], evidence: [{path: crates/registry-server/tests/http_read_only.rs, name: discovery_surfaces_share_caller_filtered_routes_and_fields}, {path: crates/registry-server/tests/http_read_only.rs, name: profile_and_resource_concealment_complete_before_record_io}, {path: crates/registry-server/tests/http_read_only.rs, name: caller_filtered_discovery_conceals_counts_vocabularies_events_queries_and_every_metadata_surface}]} + - {id: RS-V1-21, phase: W3, state: enforced, doneWhen: "OIDC validates its complete verifier profile and uses only the configured direct principal claim.", journeys: [RS-J08], evidence: [{path: crates/registry-server/tests/http_auth.rs, name: issuer_audience_algorithm_token_type_and_signature_are_all_verified}, {path: crates/registry-server/tests/http_auth.rs, name: missing_malformed_or_fallback_only_principal_is_refused_before_record_io}, {path: crates/registry-server/tests/http_auth.rs, name: constructor_requires_one_exact_bounded_verifier_profile}]} + - {id: RS-V1-22, phase: W3, state: enforced, doneWhen: "Finite profile selection and verified claim authority complete before record I/O.", journeys: [RS-J08], evidence: [{path: crates/registry-server/tests/http_read_only.rs, name: profile_and_resource_concealment_complete_before_record_io}, {path: crates/registry-server/tests/http_auth.rs, name: verified_direct_authority_reaches_the_protected_record_service}, {path: crates/registry-server/tests/http_auth.rs, name: malformed_purpose_and_row_boundary_shapes_are_refused_before_record_io}]} + - {id: RS-V1-23, phase: W3, state: enforced, doneWhen: "Anonymous and public profiles process no non-public input through hidden operations.", journeys: [RS-J06, RS-J08], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: public_profile_cannot_process_an_internal_field}, {path: crates/registry-server/tests/compiler_contract.rs, name: anonymous_public_profile_cannot_filter_a_non_public_field}, {path: crates/registry-server/tests/compiler_contract.rs, name: anonymous_profiles_cannot_inherit_partial_unique_processing_over_non_public_fields}, {path: crates/registry-server/tests/compiler_contract.rs, name: anonymous_public_surface_rejects_every_non_public_constraint_field}, {path: crates/registry-server/tests/compiler_contract.rs, name: deferred_query_features_are_strictly_unknown_key_rejected}]} + - {id: RS-V1-24, phase: W3, state: enforced, doneWhen: "Application authorization and RLS agree for positive, negative, malformed, and pooled authority.", journeys: [RS-J05, RS-J11], evidence: [{path: crates/registry-server/tests/postgres_compiled_schema.rs, name: compiled_postgres_schema_enforces_context_rls_and_exact_catalog}, {path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}, {path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} + - {id: RS-V1-25, phase: W3, state: enforced, doneWhen: "Provenance stays distinct from minimized value-free audit, logs, metrics, and traces.", journeys: [RS-J12], evidence: [{path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_read_is_authorized_bounded_minimized_and_audit_gated}, {path: crates/registry-server/tests/postgres_tombstone_revision.rs, name: tombstone_revisions_survive_package_upgrade_and_replay_exactly}, {path: crates/registry-server/tests/startup_http.rs, name: operational_log_level_is_a_closed_vocabulary}, {path: crates/registry-server/tests/startup_http.rs, name: every_operational_event_renders_exact_closed_value_free_json_fields}, {path: crates/registry-server/tests/startup_http.rs, name: provenance_operational_logs_metrics_and_traces_are_separate_closed_and_value_free}]} + - {id: RS-V1-26, phase: W3, state: enforced, doneWhen: "Protected responses release only after successful attempt and terminal audit gates.", journeys: [RS-J12], evidence: [{path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_read_is_authorized_bounded_minimized_and_audit_gated}, {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}]} + - {id: RS-V1-27, phase: W3, state: enforced, doneWhen: "Transactions use platform audit envelopes and atomically update the PostgreSQL chain head.", journeys: [RS-J10, RS-J12], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}, {path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_read_is_authorized_bounded_minimized_and_audit_gated}]} + - {id: RS-V1-28, phase: W4, state: enforced, doneWhen: "Production packages capture the governed closure and sign exact canonical bytes with monotonic identity.", journeys: [RS-J14], evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: package_builder_is_deterministic_and_local_publication_loads}, {path: crates/registry-server/tests/postgres_package.rs, name: production_package_requires_exact_trust_anchor_threshold_and_signature}, {path: crates/registry-server/tests/postgres_package.rs, name: package_layout_contract_required_manifest_projection_is_in_prepared_closure}]} + - {id: RS-V1-29, phase: W4, state: enforced, doneWhen: "Activation verifies trust, identity, inventory, filesystem safety, artifacts, and schema before readiness.", journeys: [RS-J14], evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: package_binding_refuses_wrong_environment_instance_database_sequence_and_prior}, {path: crates/registry-server/tests/postgres_package.rs, name: package_refuses_symlinks_and_production_writable_permissions}, {path: crates/registry-server/tests/postgres_package.rs, name: package_manifest_refuses_ddl_checksum_path_and_canonical_json_tampering}, {path: crates/registry-server/tests/postgres_package.rs, name: signed_schema_fingerprint_mismatch_is_durably_failed_and_never_ready}]} + - {id: RS-V1-30, phase: W4, state: enforced, doneWhen: "Apply retains the lock through migrations, catalog verification, activation, and maintenance clearing.", journeys: [RS-J13, RS-J15], evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: real_postgres_package_startup_apply_failure_and_old_process_are_closed}, {path: crates/registry-server/tests/postgres_migration.rs, name: real_postgres_backfill_and_destructive_recovery_are_bounded_resumable_and_activation_closed}]} + - {id: RS-V1-31, phase: W4, state: enforced, doneWhen: "Failed apply stays unavailable until exact fix-forward or restore reconciliation.", journeys: [RS-J13, RS-J15], evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: real_postgres_package_startup_apply_failure_and_old_process_are_closed}, {path: crates/registry-server/tests/postgres_package.rs, name: signed_schema_fingerprint_mismatch_is_durably_failed_and_never_ready}, {path: crates/registry-server/tests/postgres_migration.rs, name: real_postgres_backfill_and_destructive_recovery_are_bounded_resumable_and_activation_closed}]} + - {id: RS-V1-32, phase: W4, state: enforced, doneWhen: "Risky migrations require resumable bounds, rehearsal, backup binding, and tested recovery.", journeys: [RS-J15], evidence: [{path: crates/registry-server/tests/migration_plan.rs, name: reviewed_migration_plan_closes_ast_sql_and_bound_evidence}, {path: crates/registry-server/tests/migration_plan.rs, name: reviewed_migration_plan_rejects_uncovered_changes_forbidden_sql_and_unbound_evidence}, {path: crates/registry-server/tests/postgres_migration.rs, name: real_postgres_backfill_and_destructive_recovery_are_bounded_resumable_and_activation_closed}]} + - {id: RS-V1-33, phase: W4, state: enforced, doneWhen: "Recovery covers crashes, cancellation, network interruption, old processes, liveness, and readiness.", journeys: [RS-J13, RS-J15], evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: real_postgres_package_startup_apply_failure_and_old_process_are_closed}, {path: crates/registry-server/tests/postgres_startup.rs, name: live_old_server_drains_apply_and_exact_successor_restart_becomes_ready}, {path: crates/registry-server/tests/http_read_only.rs, name: health_and_readiness_are_operational_and_independent}]} + - {id: RS-V1-34, phase: W5, state: enforced, doneWhen: "registry-serverctl exposes the complete authoring, package, apply, verification, doctor, and data command set.", journeys: [RS-J02, RS-J17], evidence: [{path: crates/registry-serverctl/src/lib.rs, name: public_command_surface_is_explicit}, {path: crates/registry-serverctl/tests/cli.rs, name: lifecycle_parser_surfaces_are_exact_and_value_free}, {path: crates/registry-serverctl/tests/doctor.rs, name: startup_value_disclosure_and_listener_activation_threats_are_enforced_by_prepare_negative}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}]} + - {id: RS-V1-35, phase: W5, state: enforced, doneWhen: "Production tooling is distinct and callers cannot acquire signature or migration authority.", journeys: [RS-J02, RS-J17], evidence: [{path: crates/registry-serverctl/tests/cli.rs, name: test_help_requires_test_inputs_and_exposes_no_package_or_apply_authority}, {path: crates/registry-serverctl/tests/cli.rs, name: package_always_uses_production_compilation_and_never_offers_a_signing_command}, {path: crates/registry-serverctl/tests/cli.rs, name: apply_verifies_package_intent_before_database_authority_and_stays_value_free}, {path: crates/registry-serverctl/tests/diff.rs, name: production_trust_is_verified_without_opening_runtime_dependencies}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}]} + - {id: RS-V1-36, phase: W5, state: enforced, doneWhen: "Resumable import and authorized export use normal mutation, audit, revision, idempotency, and outbox paths.", journeys: [RS-J05], evidence: [{path: crates/registry-server/tests/data_operations.rs, name: data_export_requires_explicit_nonanonymous_profile_permission}, {path: crates/registry-server/tests/data_operations.rs, name: data_validate_and_chunk_plan_reuse_runtime_rules_and_compiled_batch_bounds}, {path: crates/registry-server/tests/data_operations.rs, name: data_import_checkpoint_and_idempotency_are_exact_and_value_free}, {path: crates/registry-server/tests/data_operations.rs, name: data_export_checkpoint_refuses_package_profile_projection_or_prefix_substitution}, {path: crates/registry-server/tests/postgres_data_farmer.rs, name: real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race_safe}, {path: crates/registry-server/tests/postgres_data_export.rs, name: real_postgres_export_is_authenticated_projected_audited_and_resumable}]} + - {id: RS-V1-37, phase: W5, state: enforced, doneWhen: "Webhook delivery is confined, authenticated, bounded, audited, retryable, dead-lettered, and replayable.", journeys: [RS-J16], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}, {path: crates/registry-server/tests/compiler_webhook.rs, name: governed_webhook_compiles_to_deterministic_destination_neutral_inventory}, {path: crates/registry-server/tests/runtime_config.rs, name: activation_constructs_the_exact_platform_policy_template_and_signing_material}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_deterministically_identified}, {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}]} + - {id: RS-V1-38, phase: W5, state: enforced, doneWhen: "The non-person asset, site, and placement project proves the kernel without person-related concepts.", journeys: [RS-J01, RS-J07], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} + - {id: RS-V1-39, phase: W5, state: enforced, doneWhen: "The household project has no domain-specific route, query, Rust type, feature, migration, metric, or error.", journeys: [RS-J03, RS-J07], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: products/registry-server/scripts/check-source-neutrality.sh, name: check-source-neutrality.sh}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_every_domain_fixture_family_has_a_rejected_route_canary}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_bare_domain_rust_type_identifier_is_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_cargo_feature_is_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_migration_and_resource_inputs_are_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_metric_and_error_identifiers_are_rejected}]} + - {id: RS-V1-40, phase: W5, state: enforced, doneWhen: "The disability project proves protected observations, certification, validity, and correction provenance.", journeys: [RS-J04], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} + - {id: RS-V1-41, phase: W5, state: enforced, doneWhen: "The farmer project proves bounded CRS84, units, temporal tenure or activity, resumable import, and finite boundaries without PostGIS or domain runtime code.", journeys: [RS-J05], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: crates/registry-server/tests/postgres_data_farmer.rs, name: real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race_safe}]} + - {id: RS-V1-42, phase: W5, state: enforced, doneWhen: "The business project proves identifiers, filings, appointments, temporal constraints, and public/protected processing.", journeys: [RS-J06], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} + - {id: RS-V1-43, phase: W5, state: enforced, doneWhen: "Five projects use the same binaries and Production compiler while absent fixtures remove routes and planted identifiers fail.", journeys: [RS-J07], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: products/registry-server/scripts/check-source-neutrality.sh, name: check-source-neutrality.sh}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_bare_domain_rust_type_identifier_is_rejected}]} + - {id: RS-V1-44, phase: W5, state: enforced, doneWhen: "A clean adopter checks, diffs, packages, applies, serves, upgrades, and recovers without Rust edits.", journeys: [RS-J02, RS-J17], evidence: [{path: crates/registry-serverctl/tests/cli.rs, name: init_creates_a_domain_neutral_project_that_checks_immediately}, {path: crates/registry-serverctl/tests/diff.rs, name: diff_inventory_is_deterministic_and_classification_direction_is_exact}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}]} diff --git a/products/registry-server/contracts/implementation-schedule.yaml b/products/registry-server/contracts/implementation-schedule.yaml new file mode 100644 index 0000000000..51b625dd32 --- /dev/null +++ b/products/registry-server/contracts/implementation-schedule.yaml @@ -0,0 +1,22 @@ +apiVersion: registry.registrystack.org/product-contract/v1 +product: registry-server +currentWave: W5 +waves: + - id: W0 + outcome: "Machine-checked product boundary, authored non-person input, crate skeleton, and CI ownership." + exitCriteria: [RS-W0-CONTRACTS, RS-W0-CRATES, RS-W0-CI] + - id: W1 + outcome: "Strict deterministic compiler and one compiled inventory consumed by all generated surfaces." + exitCriteria: [RS-AP-02, RS-AP-15] + - id: W2 + outcome: "Real PostgreSQL feasibility kernel with roles, RLS, locks, and pool isolation." + exitCriteria: [RS-AP-04, RS-AP-05, RS-AP-09, RS-AP-10, RS-AP-12, RS-AP-13] + - id: W3 + outcome: "Real REST record path with atomic revisions, audit, idempotency, and outbox." + exitCriteria: [RS-AP-03, RS-AP-06, RS-AP-07, RS-AP-08, RS-AP-16] + - id: W4 + outcome: "Verified package activation, migration recovery, and startup integrity." + exitCriteria: [RS-AP-11, RS-AP-14] + - id: W5 + outcome: "Pilot tooling, bounded operations and webhook delivery, proven by five coequal fixtures." + exitCriteria: [RS-AP-17, RS-V1-PILOT] diff --git a/products/registry-server/contracts/package-layout.yaml b/products/registry-server/contracts/package-layout.yaml new file mode 100644 index 0000000000..20ff44affc --- /dev/null +++ b/products/registry-server/contracts/package-layout.yaml @@ -0,0 +1,23 @@ +apiVersion: registry.registrystack.org/product-contract/v1 +product: registry-server +packageVersion: v1 +entries: + - {path: package.json, role: identity, required: true} + - {path: effective-model.json, role: governed-model, required: true} + - {path: inventories/physical-names.json, role: physical-name-inventory, required: true} + - {path: inventories/routes.json, role: route-inventory, required: true} + - {path: inventories/access.json, role: access-inventory, required: true} + - {path: inventories/queries.json, role: query-inventory, required: true} + - {path: inventories/events.json, role: event-inventory, required: true} + - {path: metadata/registry.json, role: caller-safe-metadata, required: true} + - {path: database/ddl.sql, role: generated-ddl, required: true} + - {path: database/migration-plan.json, role: migration-plan, required: true} + - {path: openapi/openapi.json, role: generated-openapi, required: true} + - {path: schemas, role: entity-json-schemas, required: true} + - {path: manifest/registry-manifest.json, role: lossy-manifest-projection, required: true} + - {path: tests/journeys.yaml, role: fixture-journeys, required: true} + - {path: signatures, role: package-signatures, required: false} + +# A deployment supplies its trust anchor and runtime bindings separately. They +# must not be embedded in a Registry package or runtime image. +forbiddenEmbeddedRoles: [deployment-trust-anchor, runtime-secret, migration-credential, signing-key] diff --git a/products/registry-server/contracts/security-invariant-matrix.yaml b/products/registry-server/contracts/security-invariant-matrix.yaml new file mode 100644 index 0000000000..59cdcd3adc --- /dev/null +++ b/products/registry-server/contracts/security-invariant-matrix.yaml @@ -0,0 +1,22 @@ +apiVersion: registry.registrystack.org/product-contract/v1 +product: registry-server +invariants: + - {id: RS-SEC-01, state: enforced, targetWave: W4, threat: Deployment bindings alter governed behavior after package review., enforcementPoint: package loader and runtime binding parser, refusal: Reject a package or runtime binding that mixes governed model bytes with deployment-only values., negativeId: RS-NEG-01, negativeTest: {path: crates/registry-server/tests/postgres_package.rs, name: package_binding_refuses_wrong_environment_instance_database_sequence_and_prior}} + - {id: RS-SEC-02, state: enforced, targetWave: W4, threat: "A tampered, wrong-environment, stale, or wrong-database package becomes active.", enforcementPoint: package verification and activation ledger, refusal: "Refuse activation before readiness when identity, signature, environment, sequence, or database binding is invalid.", negativeId: RS-NEG-02, negativeTest: {path: crates/registry-server/tests/postgres_package.rs, name: real_postgres_package_startup_apply_failure_and_old_process_are_closed}} + - {id: RS-SEC-03, state: enforced, targetWave: W2, threat: Records change while migrations or activation are incomplete., enforcementPoint: advisory locks and durable maintenance state, refusal: Refuse record operations while maintenance is active or the active package differs., negativeId: RS-NEG-03, negativeTest: {path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}} + - {id: RS-SEC-04, state: enforced, targetWave: W2, threat: The runtime can alter schema or bypass row-security administration., enforcementPoint: PostgreSQL role bootstrap and startup verification, refusal: "Refuse startup when runtime or migration roles violate ownership, privilege, or extension boundaries.", negativeId: RS-NEG-04, negativeTest: {path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}} + - {id: RS-SEC-05, state: enforced, targetWave: W3, threat: A caller selects an ungranted profile or learns protected resource existence., enforcementPoint: compiled route and access inventory, refusal: Treat unknown or unauthorized profile and resource combinations as the same value-free absence outcome before record I/O., negativeId: RS-NEG-05, negativeTest: {path: crates/registry-server/tests/http_read_only.rs, name: profile_and_resource_concealment_complete_before_record_io}} + - {id: RS-SEC-06, state: enforced, targetWave: W3, threat: Fallback claims or mismatched row rules grant authority., enforcementPoint: verified-claim mapper and generated RLS policy, refusal: Deny absent or malformed direct authority claims before record I/O and deny rows outside the matching RLS boundary., negativeId: RS-NEG-06, negativeTest: {path: crates/registry-server/tests/http_auth.rs, name: missing_malformed_or_fallback_only_principal_is_refused_before_record_io}} + - {id: RS-SEC-07, state: enforced, targetWave: W2, threat: Transaction-local claim context leaks through a reused database connection., enforcementPoint: pool checkout cleanup and transaction-local context installation, refusal: Abort the request and discard an unsafe connection rather than reuse stale context., negativeId: RS-NEG-07, negativeTest: {path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}} + - {id: RS-SEC-08, state: enforced, targetWave: W3, threat: Classified metadata or non-public fields become public inputs or outputs., enforcementPoint: compiler classification and profile projection checks, refusal: Reject an unsafe package and refuse an operation that crosses the public-processing floor., negativeId: RS-NEG-08, negativeTest: {path: crates/registry-server/tests/compiler_contract.rs, name: public_profile_cannot_process_an_internal_field}} + - {id: RS-SEC-09, state: enforced, targetWave: W3, threat: Constraint failures disclose hidden rows or values., enforcementPoint: mutation problem mapping and database diagnostic filter, refusal: Return a stable value-free conflict or absence problem without physical names or values., negativeId: RS-NEG-09, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}} + - {id: RS-SEC-10, state: enforced, targetWave: W3, threat: "A partial mutation commits record state without its revision, audit, idempotency, or event history.", enforcementPoint: single record transaction coordinator, refusal: Roll back every mutation component and release no success response when any terminal component fails., negativeId: RS-NEG-10, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}} + - {id: RS-SEC-11, state: enforced, targetWave: W3, threat: Protected data is released before an accountable attempt and terminal audit exist., enforcementPoint: protected-read and mutation response-release gate, refusal: Persist the value-free attempt first and return service unavailable if terminal audit cannot commit., negativeId: RS-NEG-11, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}} + - {id: RS-SEC-12, state: enforced, targetWave: W3, threat: "An ETag or idempotency result is replayed under a different access context.", enforcementPoint: authenticated ETag and idempotency binding, refusal: "Reject a replay whose package, profile, principal, purpose, row boundary, projection, or canonical request context differs.", negativeId: RS-NEG-12, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}} + - {id: RS-SEC-13, state: enforced, targetWave: W3, threat: "Problems, logs, metrics, traces, or database diagnostics disclose data, credentials, or physical structure.", enforcementPoint: closed diagnostic vocabulary and telemetry boundary, refusal: Replace unsafe detail with a stable value-free problem and suppress unsafe telemetry fields., negativeId: RS-NEG-13, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}} + - {id: RS-SEC-14, state: enforced, targetWave: W1, threat: Different generated surfaces or physical names diverge from the reviewed configuration., enforcementPoint: canonical compiler and artifact inventory comparison, refusal: Reject non-deterministic or inconsistent compilation before package creation., negativeId: RS-NEG-14, negativeTest: {path: crates/registry-server/tests/compiler_contract.rs, name: duplicate_routes_fail_before_artifact_generation}} + - {id: RS-SEC-15, state: enforced, targetWave: W5, threat: A webhook leaks data to an arbitrary destination or retries with a widened projection., enforcementPoint: logical destination resolver and durable delivery worker, refusal: "Refuse a destination, TLS, egress, signature, projection, or replay that is outside the compiled subscription.", negativeId: RS-NEG-15, negativeTest: {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}} + - {id: RS-SEC-16, state: enforced, targetWave: W5, threat: A domain fixture gains hidden production behavior through a hard-coded runtime concept., enforcementPoint: self-tested source-neutrality gate over production source and public kernel contracts, refusal: Reject any acceptance-fixture identifier planted in the runtime source or public kernel contract., negativeId: RS-NEG-16, negativeTest: {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_fixture_identifier_in_production_source_is_rejected}} + - {id: RS-SEC-17, state: enforced, targetWave: W3, threat: An encrypted cursor is replayed under a different authorized query context., enforcementPoint: fresh HTTP authorization plus authenticated cursor opening and PostgreSQL ReadPlan binding recomputation, refusal: "Reject before SQL when package, route, operation, profile, principal, purpose, row boundary, projection, filter, sort, temporal instant, page size, or expiry differs.", negativeId: RS-NEG-17, negativeTest: {path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}} + - {id: RS-SEC-18, state: enforced, targetWave: W5, threat: A bulk request bypasses per-item authority or commits a valid prefix after a later item fails., enforcementPoint: configured Batch route and single-transaction mutation coordinator, refusal: "Refuse the complete request before record I/O when its bounds, operation, profile, or mutation mode is invalid; otherwise roll back every item and release nothing when any item or terminal component fails.", negativeId: RS-NEG-18, negativeTest: {path: crates/registry-server/tests/postgres_batch.rs, name: real_postgres_batch_is_bounded_authorized_atomic_and_exactly_replayable}} + - {id: RS-SEC-19, state: enforced, targetWave: W5, threat: "A malformed, private, ambiguous, denied, or wrong-algorithm operator-pinned JWKS selects unintended verification material or leaks key metadata.", enforcementPoint: static JWKS validation before verifier construction in production startup and schema-test execution, refusal: "Reject the complete bounded document unless it is a strict duplicate-free set of valid public keys exactly bound to the configured algorithm, signature use, verification operation, and kid policy.", negativeId: RS-NEG-19, negativeTest: {path: crates/registry-server/tests/runtime_config.rs, name: static_jwks_validation_refuses_unsafe_documents_value_free}} diff --git a/products/registry-server/contracts/security-test-traceability.yaml b/products/registry-server/contracts/security-test-traceability.yaml new file mode 100644 index 0000000000..8850fa82ae --- /dev/null +++ b/products/registry-server/contracts/security-test-traceability.yaml @@ -0,0 +1,22 @@ +apiVersion: registry.registrystack.org/product-contract/v1 +product: registry-server +traceability: + - {id: RS-SEC-01, state: enforced, negativeId: RS-NEG-01, negativeTest: {path: crates/registry-server/tests/postgres_package.rs, name: package_binding_refuses_wrong_environment_instance_database_sequence_and_prior}} + - {id: RS-SEC-02, state: enforced, negativeId: RS-NEG-02, negativeTest: {path: crates/registry-server/tests/postgres_package.rs, name: real_postgres_package_startup_apply_failure_and_old_process_are_closed}} + - {id: RS-SEC-03, state: enforced, negativeId: RS-NEG-03, negativeTest: {path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}} + - {id: RS-SEC-04, state: enforced, negativeId: RS-NEG-04, negativeTest: {path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}} + - {id: RS-SEC-05, state: enforced, negativeId: RS-NEG-05, negativeTest: {path: crates/registry-server/tests/http_read_only.rs, name: profile_and_resource_concealment_complete_before_record_io}} + - {id: RS-SEC-06, state: enforced, negativeId: RS-NEG-06, negativeTest: {path: crates/registry-server/tests/http_auth.rs, name: missing_malformed_or_fallback_only_principal_is_refused_before_record_io}} + - {id: RS-SEC-07, state: enforced, negativeId: RS-NEG-07, negativeTest: {path: crates/registry-server/tests/postgres_kernel.rs, name: real_postgres_kernel_proves_roles_rls_interlock_and_pool_isolation}} + - {id: RS-SEC-08, state: enforced, negativeId: RS-NEG-08, negativeTest: {path: crates/registry-server/tests/compiler_contract.rs, name: public_profile_cannot_process_an_internal_field}} + - {id: RS-SEC-09, state: enforced, negativeId: RS-NEG-09, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}} + - {id: RS-SEC-10, state: enforced, negativeId: RS-NEG-10, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}} + - {id: RS-SEC-11, state: enforced, negativeId: RS-NEG-11, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}} + - {id: RS-SEC-12, state: enforced, negativeId: RS-NEG-12, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}} + - {id: RS-SEC-13, state: enforced, negativeId: RS-NEG-13, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}} + - {id: RS-SEC-14, state: enforced, negativeId: RS-NEG-14, negativeTest: {path: crates/registry-server/tests/compiler_contract.rs, name: duplicate_routes_fail_before_artifact_generation}} + - {id: RS-SEC-15, state: enforced, negativeId: RS-NEG-15, negativeTest: {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}} + - {id: RS-SEC-16, state: enforced, negativeId: RS-NEG-16, negativeTest: {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_fixture_identifier_in_production_source_is_rejected}} + - {id: RS-SEC-17, state: enforced, negativeId: RS-NEG-17, negativeTest: {path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}} + - {id: RS-SEC-18, state: enforced, negativeId: RS-NEG-18, negativeTest: {path: crates/registry-server/tests/postgres_batch.rs, name: real_postgres_batch_is_bounded_authorized_atomic_and_exactly_replayable}} + - {id: RS-SEC-19, state: enforced, negativeId: RS-NEG-19, negativeTest: {path: crates/registry-server/tests/runtime_config.rs, name: static_jwks_validation_refuses_unsafe_documents_value_free}} diff --git a/products/registry-server/demo/.gitignore b/products/registry-server/demo/.gitignore new file mode 100644 index 0000000000..1b6128f158 --- /dev/null +++ b/products/registry-server/demo/.gitignore @@ -0,0 +1 @@ +.run/ diff --git a/products/registry-server/demo/README.md b/products/registry-server/demo/README.md new file mode 100644 index 0000000000..24319f3069 --- /dev/null +++ b/products/registry-server/demo/README.md @@ -0,0 +1,67 @@ +# Registry Server household demo + +This local demo starts four real components: + +- PostgreSQL 17 with TLS, separate migration and runtime roles, and disposable + databases; +- Registry Mint as the OIDC token issuer; +- Registry Server configured from the PublicSchema-shaped household project; +- `registry-serverctl` for schema testing, packaging, activation, and + verification. + +It then asks Mint for short-lived operator and negative-test tokens and creates +five synthetic people, two households, and five effective-dated memberships +through Registry Server's ordinary authenticated REST API. + +## Run it + +Prerequisites are Docker, Cargo, OpenSSL, Python 3, and `uv`. Run: + +```bash +products/registry-server/demo/run.sh +``` + +The first run builds the four required Registry Stack binaries and may pull the +pinned PostgreSQL image. When the demo is ready, leave that terminal running. +In a second terminal, execute the sample reads: + +```bash +products/registry-server/demo/query.sh +``` + +The query helper reads the bearer token from its owner-only file without +placing the token in a command-line argument or printing it. Press Ctrl-C in +the first terminal to stop Mint and Registry Server and remove the PostgreSQL +container. + +Use `--smoke` to run the full setup, seed and query assertions, then stop +without waiting: + +```bash +products/registry-server/demo/run.sh --smoke +``` + +## Disposable state + +All generated configuration, keys, tokens, logs, package artifacts, and +database connection material live under `demo/.run/`, which is ignored by Git. +The directory and its secret subdirectories are owner-only. A new run replaces +the previous disposable directory after verifying that it is the demo-owned +path and not a symbolic link. + +The demo deliberately uses Registry Mint's supervised local-development +profile and a local unsigned Registry package. Production deployments require +their normal issuer, signer custody, package signatures, and operated +PostgreSQL service. + +## Demo data + +The data is a small curated relational fixture rather than random names. This +makes the household memberships and expected query results stable and easy to +understand. The existing Evidence source-mock generator is not reused here +because it generates isolated HTTP responses from OpenAPI; it does not create +referentially coherent Registry records. + +The seed still follows the real application boundary: Mint owns the authority +claims, Registry Server validates every write, and memberships use the server +UUIDs returned for their person and household records. diff --git a/products/registry-server/demo/query.sh b/products/registry-server/demo/query.sh new file mode 100755 index 0000000000..7f244943af --- /dev/null +++ b/products/registry-server/demo/query.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +demo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +run_dir="$demo_dir/.run" + +if [[ ! -d "$run_dir" || -L "$run_dir" ]]; then + printf '%s\n' 'Registry Server demo is not running. Start demo/run.sh first.' >&2 + exit 2 +fi + +python3 "$demo_dir/support/demo.py" query --root "$run_dir" diff --git a/products/registry-server/demo/run.sh b/products/registry-server/demo/run.sh new file mode 100755 index 0000000000..501da8af08 --- /dev/null +++ b/products/registry-server/demo/run.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +set -euo pipefail + +demo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +product_dir=$(cd -- "$demo_dir/.." && pwd) +repository_root=$(cd -- "$product_dir/../.." && pwd) +support="$demo_dir/support/demo.py" +fixture="$product_dir/acceptance/publicschema-household" +run_dir="$demo_dir/.run" +mint_key_material="$repository_root/crates/registry-mint/demo/support/key_material.py" +postgres_image='postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675' +mode=serve + +if [[ "${1:-}" == "--smoke" ]]; then + mode=smoke +elif [[ $# -ne 0 ]]; then + printf '%s\n' 'usage: products/registry-server/demo/run.sh [--smoke]' >&2 + exit 2 +fi + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf '%s\n' "$1 is required for the Registry Server demo." >&2 + exit 2 + fi +} + +for command in cargo docker openssl python3 uv; do + require_command "$command" +done + +case "$run_dir" in + "$demo_dir/.run") ;; + *) + printf '%s\n' 'demo run directory escaped its owned location.' >&2 + exit 2 + ;; +esac +if [[ -L "$run_dir" ]]; then + printf '%s\n' 'demo run directory must not be a symbolic link.' >&2 + exit 2 +fi +if [[ -d "$run_dir" ]]; then + rm -rf -- "$run_dir" +elif [[ -e "$run_dir" ]]; then + printf '%s\n' 'demo run path exists and is not a directory.' >&2 + exit 2 +fi +umask 077 +mkdir -m 700 "$run_dir" "$run_dir/secrets" "$run_dir/keys" "$run_dir/logs" "$run_dir/tls" + +mint_pid="" +server_pid="" +postgres_container="registry-server-demo-${PPID}-$$" +cleanup() { + if [[ -n "${server_pid:-}" ]]; then + kill "$server_pid" >/dev/null 2>&1 || true + wait "$server_pid" >/dev/null 2>&1 || true + fi + if [[ -n "${mint_pid:-}" ]]; then + kill "$mint_pid" >/dev/null 2>&1 || true + wait "$mint_pid" >/dev/null 2>&1 || true + fi + docker rm -f "$postgres_container" >/dev/null 2>&1 || true +} +trap cleanup EXIT HUP INT TERM + +ports=$(python3 "$support" ports) +read -r database_port mint_port server_port </dev/null + +registry_server="$repository_root/target/debug/registry-server" +registry_serverctl="$repository_root/target/debug/registry-serverctl" +mint="$repository_root/target/debug/mint" + +printf '%s\n' '== Generating disposable keys and configuration' +uv run --quiet "$mint_key_material" p256 \ + --private-out "$run_dir/keys/mint/signing-p256-private-jwk" \ + --public-out "$run_dir/keys/mint-public.jwk.json" +uv run --quiet "$mint_key_material" p256 \ + --private-out "$run_dir/keys/operator/signing-p256-private-jwk" \ + --public-out "$run_dir/keys/operator-public.jwk.json" +uv run --quiet "$mint_key_material" p256 \ + --private-out "$run_dir/keys/no-purpose/signing-p256-private-jwk" \ + --public-out "$run_dir/keys/no-purpose-public.jwk.json" +uv run --quiet "$mint_key_material" secret-hex \ + --out "$run_dir/keys/mint/audit-hmac-key" +uv run --quiet "$mint_key_material" secret-hex \ + --out "$run_dir/secrets/audit-key" +uv run --quiet "$mint_key_material" secret-hex \ + --out "$run_dir/secrets/cursor-key" +openssl rand -hex 24 >"$run_dir/secrets/database-password" +chmod 600 "$run_dir/secrets/database-password" + +python3 "$support" prepare \ + --root "$run_dir" \ + --fixture "$fixture" \ + --database-port "$database_port" \ + --mint-port "$mint_port" \ + --server-port "$server_port" + +openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 2 \ + -subj '/CN=Registry Server local demo CA' \ + -keyout "$run_dir/tls/ca.key" -out "$run_dir/tls/ca.pem" >/dev/null 2>&1 +openssl req -new -nodes -newkey rsa:2048 \ + -subj '/CN=localhost' \ + -keyout "$run_dir/tls/server.key" -out "$run_dir/tls/server.csr" >/dev/null 2>&1 +printf '%s\n' 'subjectAltName=DNS:localhost' >"$run_dir/tls/server.ext" +openssl x509 -req -sha256 -days 2 \ + -in "$run_dir/tls/server.csr" \ + -CA "$run_dir/tls/ca.pem" \ + -CAkey "$run_dir/tls/ca.key" \ + -CAcreateserial \ + -extfile "$run_dir/tls/server.ext" \ + -out "$run_dir/tls/server.crt" >/dev/null 2>&1 +chmod 600 "$run_dir/tls/ca.key" "$run_dir/tls/server.key" +chmod 644 "$run_dir/tls/ca.pem" "$run_dir/tls/server.crt" + +printf '%s\n' '== Starting disposable PostgreSQL 17 with TLS' +docker run --detach --name "$postgres_container" \ + --env-file "$run_dir/database/postgres.env" \ + --publish "127.0.0.1:${database_port}:5432" \ + "$postgres_image" >"$run_dir/postgres-container-id" + +for attempt in $(seq 1 60); do + if docker exec "$postgres_container" pg_isready -q -U postgres; then + break + fi + if [[ "$attempt" -eq 60 ]]; then + printf '%s\n' "PostgreSQL did not become ready; see $run_dir/logs." >&2 + exit 1 + fi + sleep 0.25 +done + +postgres_data_directory=$(docker exec "$postgres_container" sh -c 'printf %s "$PGDATA"') +case "$postgres_data_directory" in + /var/lib/postgresql/*) ;; + *) + printf '%s\n' 'PostgreSQL reported an unsafe data directory.' >&2 + exit 1 + ;; +esac +if [[ "$postgres_data_directory" == *..* ]]; then + printf '%s\n' 'PostgreSQL data directory contains parent traversal.' >&2 + exit 1 +fi +docker cp "$run_dir/tls/server.crt" "$postgres_container:$postgres_data_directory/server.crt" +docker cp "$run_dir/tls/server.key" "$postgres_container:$postgres_data_directory/server.key" +docker exec --user root "$postgres_container" sh -eu -c ' + chown postgres:postgres "$1/server.crt" "$1/server.key" + chmod 644 "$1/server.crt" + chmod 600 "$1/server.key" + printf "\nssl = on\nssl_cert_file = '\''server.crt'\''\nssl_key_file = '\''server.key'\''\n" >> "$1/postgresql.conf" + sed -i "s/^host /hostssl /" "$1/pg_hba.conf" +' sh "$postgres_data_directory" +docker exec --user postgres "$postgres_container" \ + pg_ctl -D "$postgres_data_directory" reload >/dev/null + +docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d postgres \ + <"$run_dir/database/bootstrap.sql" +docker exec "$postgres_container" createdb -U postgres registry_demo_test +docker exec "$postgres_container" createdb -U postgres registry_demo +docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d registry_demo_test \ + <"$run_dir/database/initialize.sql" +docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d registry_demo \ + <"$run_dir/database/initialize-runtime.sql" + +printf '%s\n' '== Starting Registry Mint and obtaining short-lived tokens' +"$mint" serve --config "$run_dir/mint/mint.yaml" >"$run_dir/logs/mint.log" 2>&1 & +mint_pid=$! +python3 "$support" wait-http --url "http://127.0.0.1:${mint_port}/ready" --timeout 30 + +"$mint" token \ + --url "http://127.0.0.1:${mint_port}/token" \ + --client-id household-demo \ + --key "$run_dir/keys/operator/signing-p256-private-jwk" | + python3 "$support" store-token --out "$run_dir/secrets/operator-token" +"$mint" token \ + --url "http://127.0.0.1:${mint_port}/token" \ + --client-id household-demo-no-purpose \ + --key "$run_dir/keys/no-purpose/signing-p256-private-jwk" | + python3 "$support" store-token --out "$run_dir/secrets/no-purpose-token" + +printf '%s\n' '== Testing, packaging, and activating the household Registry' +export SSL_CERT_FILE="$run_dir/tls/ca.pem" +"$registry_serverctl" --format json test "$run_dir/project" \ + --runtime-config "$run_dir/runtime-test.yaml" \ + --credentials "$run_dir/schema-test-credentials.yaml" \ + --database-id publicschema-household-demo \ + --output "$run_dir/schema-test-receipt.json" \ + >"$run_dir/test-report.json" +schema_fingerprint=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["schemaFingerprint"])' "$run_dir/test-report.json") + +"$registry_serverctl" --format json package "$run_dir/project" \ + --database-id publicschema-household-demo \ + --schema-fingerprint "$schema_fingerprint" \ + --test-receipt "$run_dir/schema-test-receipt.json" \ + --output "$run_dir/build" \ + >"$run_dir/package-report.json" +package_revision=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["packageRevision"])' "$run_dir/package-report.json") +python3 "$support" render-runtime --root "$run_dir" --revision "$package_revision" + +"$registry_serverctl" apply \ + --runtime-config "$run_dir/runtime.yaml" \ + --package "$run_dir/build/package" \ + --initial >/dev/null +"$registry_serverctl" verify --runtime-config "$run_dir/runtime.yaml" >/dev/null + +printf '%s\n' '== Starting Registry Server and creating deterministic demo records' +REGISTRY_SERVER_LOG=error "$registry_server" --config "$run_dir/runtime.yaml" \ + >"$run_dir/logs/registry-server.log" 2>&1 & +server_pid=$! +python3 "$support" wait-http --url "http://127.0.0.1:${server_port}/ready" --timeout 30 +python3 "$support" seed --root "$run_dir" +"$demo_dir/query.sh" >/dev/null + +printf '\n%s\n' 'Registry Server household demo is ready.' +printf ' Registry Server: http://127.0.0.1:%s\n' "$server_port" +printf ' Registry Mint: http://127.0.0.1:%s\n' "$mint_port" +printf ' Token file: %s\n' "$run_dir/secrets/operator-token" +printf ' Sample queries: %s\n' "$demo_dir/query.sh" +printf ' Logs: %s\n' "$run_dir/logs" + +if [[ "$mode" == smoke ]]; then + printf '%s\n' 'Registry Server household demo smoke passed.' + exit 0 +fi + +printf '\n%s\n' 'Leave this terminal running. Press Ctrl-C to stop the services.' +while kill -0 "$mint_pid" >/dev/null 2>&1 && kill -0 "$server_pid" >/dev/null 2>&1; do + sleep 1 +done +printf '%s\n' "A demo service stopped unexpectedly; inspect $run_dir/logs." >&2 +exit 1 diff --git a/products/registry-server/demo/support/demo.py b/products/registry-server/demo/support/demo.py new file mode 100755 index 0000000000..bd6b6c0cb0 --- /dev/null +++ b/products/registry-server/demo/support/demo.py @@ -0,0 +1,571 @@ +#!/usr/bin/env python3 +"""Provision and exercise the disposable Registry Server household demo.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import socket +import stat +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +DATABASE_ID = "publicschema-household-demo" +INSTANCE_ID = "publicschema-household-local" +SOURCE_REVISION = "publicschema-household-local-0.1.0" +AUDIENCE = "urn:registry-server:household-demo" +OPERATOR_CLIENT = "household-demo" +NO_PURPOSE_CLIENT = "household-demo-no-purpose" +MIGRATION_ROLE = "registry_demo_migration" +RUNTIME_ROLE = "registry_demo_runtime" +TEST_DATABASE = "registry_demo_test" +RUNTIME_DATABASE = "registry_demo" +EXPECTED_PROJECT_REPLACEMENTS = { + " environment: acceptance": " environment: local", + " instanceId: publicschema-household-acceptance": f" instanceId: {INSTANCE_ID}", + " sourceRevision: publicschema-household-acceptance-0.1.0": f" sourceRevision: {SOURCE_REVISION}", +} + + +class DemoError(RuntimeError): + pass + + +def _write_new(path: Path, content: str, mode: int = 0o644) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(content) + path.chmod(mode) + + +def _write_json(path: Path, value: Any, mode: int = 0o644) -> None: + _write_new(path, json.dumps(value, sort_keys=True, separators=(",", ":")), mode) + + +def _read_json_object(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise DemoError(f"{path.name} must contain one JSON object") + return value + + +def _require_root(root: Path) -> Path: + if root.is_symlink(): + raise DemoError("demo root must not be a symbolic link") + root = root.resolve() + if not root.is_dir(): + raise DemoError("demo root must be an existing ordinary directory") + return root + + +def reserve_ports() -> tuple[int, int, int]: + listeners: list[socket.socket] = [] + try: + for _ in range(3): + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listeners.append(listener) + return tuple(listener.getsockname()[1] for listener in listeners) # type: ignore[return-value] + finally: + for listener in listeners: + listener.close() + + +def _local_project(root: Path, fixture: Path) -> None: + target = root / "project" + shutil.copytree(fixture, target, ignore=shutil.ignore_patterns(".DS_Store")) + project_path = target / "registry.yaml" + source = project_path.read_text(encoding="utf-8") + for expected, replacement in EXPECTED_PROJECT_REPLACEMENTS.items(): + if source.count(expected) != 1: + raise DemoError(f"household fixture no longer has the expected package line: {expected.strip()}") + source = source.replace(expected, replacement, 1) + project_path.write_text(source, encoding="utf-8") + + +def _mint_client(client_id: str, principal: str, public_key: dict[str, Any], purpose: str | None) -> str: + claims = f" registry_principal: {principal}\n" + if purpose is not None: + claims += f" registry_purpose: {purpose}\n" + return ( + f"clientId: {client_id}\n" + f"principal: urn:registry-server:demo:{client_id}\n" + "authorization:\n" + " scopes: [registry:household:operate]\n" + " claims:\n" + f"{claims}" + f"keys: [{json.dumps(public_key, sort_keys=True, separators=(',', ':'))}]\n" + ) + + +def _runtime_config(root: Path, package_root: Path, revision: str, bind: str) -> str: + secrets = root / "secrets" + return f"""listener: + bind: {bind} + trustedProxy: direct +identity: + environment: local + instanceId: {INSTANCE_ID} + databaseId: {DATABASE_ID} + databaseInitializationEnvironment: local +secretProviders: + file: + root: {secrets} +database: + runtimeUrlRef: secret:file/runtime-database-url + migrationUrlRef: secret:file/migration-database-url + pool: + maxSize: 4 + waitTimeoutMilliseconds: 2000 + createTimeoutMilliseconds: 2000 + recycleTimeoutMilliseconds: 2000 + roles: + migration: {MIGRATION_ROLE} + runtime: {RUNTIME_ROLE} +package: + root: {package_root} + trustAnchorPath: {root / 'trust-anchor.json'} + compilerSourceRevision: {SOURCE_REVISION} + activeRevision: {revision} + activeSequence: 1 +authentication: + oidc: + issuer: {root.joinpath('mint-origin').read_text(encoding='ascii').strip()} + audience: {AUDIENCE} + allowedAlgorithm: ES256 + accessTokenType: at+jwt + scopeClaim: scope + scopeSeparator: " " + allowedClients: [{OPERATOR_CLIENT}, {NO_PURPOSE_CLIENT}] + deniedKids: [] + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 30000 + jwksCache: + cacheTtlSeconds: 300 + negativeCacheTtlSeconds: 30 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 2000 + outageToleranceSeconds: 0 + jwksSource: + kind: static + documentRef: secret:file/mint-jwks + authorityClaims: + principal: registry_principal + purpose: registry_purpose +audit: + hashKeyRef: secret:file/audit-key +cursor: + secretRef: secret:file/cursor-key + maxAgeSeconds: 300 +eventDestinations: {{}} +operationalTimeouts: + httpRequestMilliseconds: 10000 + shutdownGraceMilliseconds: 5000 + recordLockMilliseconds: 5000 + migrationLockMilliseconds: 5000 + migrationStatementMilliseconds: 60000 +""" + + +def prepare(root: Path, fixture: Path, database_port: int, mint_port: int, server_port: int) -> None: + root = _require_root(root) + fixture = fixture.resolve() + if not (fixture / "registry.yaml").is_file(): + raise DemoError("household fixture is missing registry.yaml") + password_path = root / "secrets/database-password" + password = password_path.read_text(encoding="ascii").strip() + if not password or any(character not in "0123456789abcdef" for character in password): + raise DemoError("database password must be non-empty lowercase hexadecimal") + + _local_project(root, fixture) + mint_public = _read_json_object(root / "keys/mint-public.jwk.json") + operator_public = _read_json_object(root / "keys/operator-public.jwk.json") + no_purpose_public = _read_json_object(root / "keys/no-purpose-public.jwk.json") + kid = mint_public.get("kid") + if not isinstance(kid, str) or not kid: + raise DemoError("Mint public JWK must carry a key identifier") + + mint_origin = f"http://127.0.0.1:{mint_port}" + server_origin = f"http://127.0.0.1:{server_port}" + _write_new(root / "mint-origin", mint_origin + "\n") + _write_new(root / "server-origin", server_origin + "\n") + _write_json(root / "secrets/mint-jwks", {"keys": [mint_public]}, 0o600) + _write_json(root / f"mint/public-keys/{kid}.jwk.json", mint_public) + _write_new( + root / f"mint/clients/{OPERATOR_CLIENT}.yaml", + _mint_client( + OPERATOR_CLIENT, + "synthetic-household-operator", + operator_public, + "household-administration", + ), + ) + _write_new( + root / f"mint/clients/{NO_PURPOSE_CLIENT}.yaml", + _mint_client( + NO_PURPOSE_CLIENT, + "synthetic-household-operator", + no_purpose_public, + None, + ), + ) + _write_new( + root / "mint/mint.yaml", + f"""version: 1 +validationMode: supervised-local-development +issuer: {mint_origin} +listener: {{address: 127.0.0.1, port: {mint_port}}} +signing: + algorithm: ES256 + activePublicJwkFile: public-keys/{kid}.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: local-jwk + privateKeyRef: secret:file/signing-p256-private-jwk +secretProviders: + file: {{root: {root / 'keys/mint'}}} +audit: + path: audit/mint.jsonl + maximumFileBytes: 10485760 + hashKeyRef: secret:file/audit-hmac-key + hashKeyVersion: 1 +accessTokens: + audiences: [{AUDIENCE}] + lifetimeSeconds: 300 +clientAssertion: + audience: {mint_origin}/token + maximumLifetimeSeconds: 120 + algorithms: [ES256] +clients: + directory: clients +""", + ) + + encoded_password = urllib.parse.quote(password, safe="") + base = f"localhost:{database_port}" + _write_new( + root / "secrets/test-runtime-database-url", + f"postgresql://{RUNTIME_ROLE}:{encoded_password}@{base}/{TEST_DATABASE}", + 0o600, + ) + _write_new( + root / "secrets/test-migration-database-url", + f"postgresql://{MIGRATION_ROLE}:{encoded_password}@{base}/{TEST_DATABASE}", + 0o600, + ) + _write_new( + root / "secrets/runtime-database-url", + f"postgresql://{RUNTIME_ROLE}:{encoded_password}@{base}/{RUNTIME_DATABASE}", + 0o600, + ) + _write_new( + root / "secrets/migration-database-url", + f"postgresql://{MIGRATION_ROLE}:{encoded_password}@{base}/{RUNTIME_DATABASE}", + 0o600, + ) + _write_new( + root / "database/postgres.env", + f"POSTGRES_USER=postgres\nPOSTGRES_PASSWORD={password}\nPOSTGRES_DB=postgres\n", + 0o600, + ) + _write_new( + root / "database/bootstrap.sql", + f"""CREATE ROLE {MIGRATION_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}'; +CREATE ROLE {RUNTIME_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}'; +""", + 0o600, + ) + _write_new( + root / "database/initialize.sql", + f"""CREATE EXTENSION IF NOT EXISTS btree_gist; +REVOKE ALL ON DATABASE {TEST_DATABASE} FROM PUBLIC; +GRANT CONNECT ON DATABASE {TEST_DATABASE} TO {MIGRATION_ROLE}, {RUNTIME_ROLE}; +CREATE SCHEMA registry_internal AUTHORIZATION {MIGRATION_ROLE}; +CREATE SCHEMA registry_data AUTHORIZATION {MIGRATION_ROLE}; +REVOKE ALL ON SCHEMA registry_internal, registry_data FROM PUBLIC; +""", + ) + _write_new( + root / "database/initialize-runtime.sql", + (root / "database/initialize.sql") + .read_text(encoding="utf-8") + .replace(TEST_DATABASE, RUNTIME_DATABASE), + ) + _write_new(root / "trust-anchor.json", "{}") + (root / "empty-package").mkdir(mode=0o755) + dummy_revision = "sha256:" + "1" * 64 + test_runtime = _runtime_config(root, root / "empty-package", dummy_revision, "127.0.0.1:0") + test_runtime = test_runtime.replace( + "secret:file/runtime-database-url", "secret:file/test-runtime-database-url" + ).replace( + "secret:file/migration-database-url", "secret:file/test-migration-database-url" + ) + _write_new(root / "runtime-test.yaml", test_runtime) + _write_new( + root / "schema-test-credentials.yaml", + f"""apiVersion: registry.registrystack.org/server-schema-test-credentials/v1 +kind: SchemaTestCredentials +bindings: + - {{journeyId: household-person-lifecycle, stepId: create-person, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: get-person, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: update-person-residency, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: list-people, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: refuse-incomplete-membership, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: operator-without-purpose-is-concealed, credential: {{type: bearer, tokenRef: secret:file/no-purpose-token}}}} +""", + ) + + +def render_runtime(root: Path, revision: str) -> None: + root = _require_root(root) + if not revision.startswith("sha256:") or len(revision) != 71: + raise DemoError("package revision must be one SHA-256 identifier") + bind = urllib.parse.urlparse((root / "server-origin").read_text(encoding="ascii").strip()).netloc + _write_new(root / "runtime.yaml", _runtime_config(root, root / "build/package", revision, bind)) + + +def _token(root: Path, name: str) -> str: + path = root / f"secrets/{name}" + if not path.is_file() or path.is_symlink() or stat.S_IMODE(path.stat().st_mode) & 0o077: + raise DemoError(f"{name} must be an owner-only regular file") + value = path.read_text(encoding="ascii").strip() + if value.count(".") != 2: + raise DemoError(f"{name} does not contain one compact JWT") + return value + + +def store_token(path: Path, source: bytes) -> None: + if len(source) > 64 * 1024: + raise DemoError("Mint returned an oversized token") + try: + value = source.decode("ascii").rstrip("\r\n") + except UnicodeDecodeError as error: + raise DemoError("Mint returned a non-ASCII token") from error + if value.count(".") != 2 or any(character.isspace() for character in value): + raise DemoError("Mint did not return one compact JWT") + _write_new(path, value, 0o600) + + +def _request( + root: Path, + method: str, + path: str, + token_name: str, + body: dict[str, Any] | None = None, + idempotency_key: str | None = None, + expected: int = 200, +) -> tuple[dict[str, Any], dict[str, str]]: + origin = (root / "server-origin").read_text(encoding="ascii").strip() + headers = {"Accept": "application/json", "Authorization": f"Bearer {_token(root, token_name)}"} + data = None + if body is not None: + data = json.dumps(body, sort_keys=True, separators=(",", ":")).encode() + headers["Content-Type"] = "application/json" + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + request = urllib.request.Request(origin + path, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=10) as response: + response_bytes = response.read() + status = response.status + response_headers = {name.lower(): value for name, value in response.headers.items()} + except urllib.error.HTTPError as error: + response_bytes = error.read() + status = error.code + response_headers = {name.lower(): value for name, value in error.headers.items()} + if status != expected: + raise DemoError(f"{method} {path} returned {status}, expected {expected}") + document = json.loads(response_bytes) if response_bytes else {} + if not isinstance(document, dict): + raise DemoError(f"{method} {path} returned a non-object JSON response") + return document, response_headers + + +def _create(root: Path, route: str, logical_key: str, data: dict[str, Any]) -> str: + response, _ = _request( + root, + "POST", + route + "?accessProfile=household-operator", + "operator-token", + {"data": data}, + f"demo-{logical_key}", + 201, + ) + identifier = response.get("id") + if not isinstance(identifier, str): + raise DemoError(f"created {logical_key} has no record id") + return identifier + + +def seed_spec() -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + people = [ + {"person-code": "PERSON-DEMO-001", "legal-name": "Amina Example", "family-name": "Example", "date-of-birth": "1988-02-22", "residency-status": "usual-resident", "preferred-language": "en"}, + {"person-code": "PERSON-DEMO-002", "legal-name": "Karim Example", "family-name": "Example", "date-of-birth": "2014-06-17", "residency-status": "usual-resident", "preferred-language": "fr"}, + {"person-code": "PERSON-DEMO-003", "legal-name": "Elena Sample", "family-name": "Sample", "date-of-birth": "1992-11-02", "residency-status": "usual-resident", "preferred-language": "es"}, + {"person-code": "PERSON-DEMO-004", "legal-name": "Mateo Sample", "family-name": "Sample", "date-of-birth": "2022-03-14", "residency-status": "usual-resident", "preferred-language": "es"}, + {"person-code": "PERSON-DEMO-005", "legal-name": "Luis Sample", "family-name": "Sample", "date-of-birth": "1989-08-20", "residency-status": "temporary-resident", "preferred-language": "en"}, + ] + households = [ + {"household-code": "HOUSEHOLD-DEMO-001", "household-name": "Northern Demo Household", "administrative-area": "north-demo", "household-type": "private"}, + {"household-code": "HOUSEHOLD-DEMO-002", "household-name": "Central Demo Household", "administrative-area": "central-demo", "household-type": "private"}, + ] + memberships = [ + {"person-code": "PERSON-DEMO-001", "household-code": "HOUSEHOLD-DEMO-001", "relationship": "head", "valid-from": "2026-01-01"}, + {"person-code": "PERSON-DEMO-002", "household-code": "HOUSEHOLD-DEMO-001", "relationship": "child", "valid-from": "2026-01-01"}, + {"person-code": "PERSON-DEMO-005", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "head", "valid-from": "2026-01-01"}, + {"person-code": "PERSON-DEMO-003", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "spouse", "valid-from": "2026-01-01"}, + {"person-code": "PERSON-DEMO-004", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "child", "valid-from": "2026-01-01"}, + ] + return people, households, memberships + + +def seed(root: Path) -> None: + root = _require_root(root) + people, households, memberships = seed_spec() + person_ids = { + person["person-code"]: _create(root, "/v1/records/persons", person["person-code"].lower(), person) + for person in people + } + household_ids = { + household["household-code"]: _create( + root, "/v1/records/households", household["household-code"].lower(), household + ) + for household in households + } + for index, membership in enumerate(memberships, start=1): + _create( + root, + "/v1/records/group-memberships", + f"membership-{index}", + { + "person": person_ids[membership["person-code"]], + "household": household_ids[membership["household-code"]], + "relationship": membership["relationship"], + "valid-from": membership["valid-from"], + }, + ) + people_response, _ = _request( + root, + "GET", + "/v1/records/persons?accessProfile=household-operator&pageSize=20", + "operator-token", + ) + household_response, _ = _request( + root, + "GET", + "/v1/records/households?accessProfile=household-operator&pageSize=20", + "operator-token", + ) + membership_response, _ = _request( + root, + "GET", + "/v1/records/group-memberships:current?accessProfile=household-operator&pageSize=20", + "operator-token", + ) + if [len(response.get("items", [])) for response in (people_response, household_response, membership_response)] != [5, 2, 5]: + raise DemoError("seeded list counts did not match the expected 5 people, 2 households, and 5 memberships") + _request( + root, + "GET", + f"/v1/records/persons/{person_ids['PERSON-DEMO-001']}?accessProfile=household-operator", + "no-purpose-token", + expected=404, + ) + print("Seeded 5 synthetic people, 2 households, and 5 current memberships.") + + +def query(root: Path) -> None: + root = _require_root(root) + queries = [ + ("Usual residents", "/v1/records/persons?accessProfile=household-operator&fields=person-code,legal-name,residency-status&filter=residency-status:equals:usual-resident&pageSize=20"), + ("Households", "/v1/records/households?accessProfile=household-operator&fields=household-code,household-name,administrative-area&pageSize=20"), + ("Current memberships", "/v1/records/group-memberships:current?accessProfile=household-operator&pageSize=20"), + ] + for label, path in queries: + response, _ = _request(root, "GET", path, "operator-token") + print(f"\n{label}\n{'=' * len(label)}") + print(json.dumps(response, indent=2, sort_keys=True)) + + +def wait_http(url: str, timeout_seconds: float) -> None: + deadline = time.monotonic() + timeout_seconds + last_status: int | None = None + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(url, timeout=1) as response: + last_status = response.status + if response.status == 200: + return + except urllib.error.HTTPError as error: + last_status = error.code + except OSError: + pass + time.sleep(0.1) + suffix = f" (last HTTP status {last_status})" if last_status is not None else "" + raise DemoError(f"{url} did not become ready within {timeout_seconds:g} seconds{suffix}") + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser() + commands = result.add_subparsers(dest="command", required=True) + commands.add_parser("ports") + prepare_parser = commands.add_parser("prepare") + prepare_parser.add_argument("--root", required=True, type=Path) + prepare_parser.add_argument("--fixture", required=True, type=Path) + prepare_parser.add_argument("--database-port", required=True, type=int) + prepare_parser.add_argument("--mint-port", required=True, type=int) + prepare_parser.add_argument("--server-port", required=True, type=int) + runtime_parser = commands.add_parser("render-runtime") + runtime_parser.add_argument("--root", required=True, type=Path) + runtime_parser.add_argument("--revision", required=True) + seed_parser = commands.add_parser("seed") + seed_parser.add_argument("--root", required=True, type=Path) + query_parser = commands.add_parser("query") + query_parser.add_argument("--root", required=True, type=Path) + wait_parser = commands.add_parser("wait-http") + wait_parser.add_argument("--url", required=True) + wait_parser.add_argument("--timeout", type=float, default=30.0) + token_parser = commands.add_parser("store-token") + token_parser.add_argument("--out", required=True, type=Path) + return result + + +def main() -> int: + args = parser().parse_args() + try: + if args.command == "ports": + print(*reserve_ports()) + elif args.command == "prepare": + prepare(args.root, args.fixture, args.database_port, args.mint_port, args.server_port) + elif args.command == "render-runtime": + render_runtime(args.root, args.revision) + elif args.command == "seed": + seed(args.root) + elif args.command == "query": + query(args.root) + elif args.command == "wait-http": + wait_http(args.url, args.timeout) + elif args.command == "store-token": + store_token(args.out, sys.stdin.buffer.read(64 * 1024 + 1)) + else: # pragma: no cover + raise AssertionError(args.command) + except (DemoError, OSError, ValueError, json.JSONDecodeError) as error: + print(f"Registry Server demo failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/registry-server/demo/support/test_demo.py b/products/registry-server/demo/support/test_demo.py new file mode 100755 index 0000000000..2d0aabcae7 --- /dev/null +++ b/products/registry-server/demo/support/test_demo.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("demo.py") +SPEC = importlib.util.spec_from_file_location("registry_server_demo", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +DEMO = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = DEMO +SPEC.loader.exec_module(DEMO) + + +def public_jwk(kid: str) -> dict[str, str]: + return { + "alg": "ES256", + "crv": "P-256", + "kid": kid, + "kty": "EC", + "x": "A" * 43, + "y": "B" * 43, + } + + +class DemoProvisioningTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) / "run" + self.root.mkdir(mode=0o700) + (self.root / "secrets").mkdir(mode=0o700) + password = self.root / "secrets/database-password" + password.write_text("a" * 48, encoding="ascii") + password.chmod(0o600) + (self.root / "keys").mkdir() + for name in ("mint", "operator", "no-purpose"): + (self.root / f"keys/{name}-public.jwk.json").write_text( + json.dumps(public_jwk(f"{name}-key")), encoding="utf-8" + ) + self.fixture = MODULE_PATH.parents[2] / "acceptance/publicschema-household" + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_prepare_binds_mint_authority_static_jwks_and_secret_database_urls(self) -> None: + DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) + + project = (self.root / "project/registry.yaml").read_text(encoding="utf-8") + self.assertIn("environment: local", project) + self.assertIn(f"instanceId: {DEMO.INSTANCE_ID}", project) + self.assertNotIn("publicschema-household-acceptance", project) + self.assertFalse(any((self.root / "project").rglob(".DS_Store"))) + + mint = (self.root / "mint/mint.yaml").read_text(encoding="utf-8") + self.assertIn("validationMode: supervised-local-development", mint) + self.assertIn("audiences: [urn:registry-server:household-demo]", mint) + self.assertIn("algorithms: [ES256]", mint) + self.assertNotIn("database-password", mint) + operator = (self.root / "mint/clients/household-demo.yaml").read_text(encoding="utf-8") + self.assertIn("registry_principal: synthetic-household-operator", operator) + self.assertIn("registry_purpose: household-administration", operator) + no_purpose = (self.root / "mint/clients/household-demo-no-purpose.yaml").read_text(encoding="utf-8") + self.assertIn("registry_principal: synthetic-household-operator", no_purpose) + self.assertNotIn("registry_purpose", no_purpose) + + runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") + self.assertIn("accessTokenType: at+jwt", runtime) + self.assertIn("kind: static", runtime) + self.assertIn("documentRef: secret:file/mint-jwks", runtime) + self.assertIn("principal: registry_principal", runtime) + self.assertIn("purpose: registry_purpose", runtime) + self.assertNotIn("a" * 48, runtime) + self.assertEqual( + json.loads((self.root / "secrets/mint-jwks").read_text(encoding="utf-8"))["keys"][0]["kid"], + "mint-key", + ) + for name in ( + "test-runtime-database-url", + "test-migration-database-url", + "runtime-database-url", + "migration-database-url", + "mint-jwks", + ): + self.assertEqual(os.stat(self.root / f"secrets/{name}").st_mode & 0o077, 0) + + def test_render_runtime_selects_exact_package_and_listener(self) -> None: + DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) + revision = "sha256:" + "2" * 64 + DEMO.render_runtime(self.root, revision) + runtime = (self.root / "runtime.yaml").read_text(encoding="utf-8") + self.assertIn(f"activeRevision: {revision}", runtime) + self.assertIn(f"root: {self.root.resolve() / 'build/package'}", runtime) + self.assertIn("bind: 127.0.0.1:18080", runtime) + + def test_seed_is_referentially_closed_and_stable(self) -> None: + people, households, memberships = DEMO.seed_spec() + person_codes = {person["person-code"] for person in people} + household_codes = {household["household-code"] for household in households} + self.assertEqual((len(people), len(households), len(memberships)), (5, 2, 5)) + self.assertEqual(len(person_codes), len(people)) + self.assertEqual(len(household_codes), len(households)) + self.assertTrue(all(row["person-code"] in person_codes for row in memberships)) + self.assertTrue(all(row["household-code"] in household_codes for row in memberships)) + self.assertEqual( + sum(person["residency-status"] == "usual-resident" for person in people), + 4, + ) + + def test_prepare_refuses_a_fixture_without_the_expected_localization_boundary(self) -> None: + bad_fixture = Path(self.temporary.name) / "bad-fixture" + bad_fixture.mkdir() + (bad_fixture / "registry.yaml").write_text("apiVersion: wrong\n", encoding="utf-8") + with self.assertRaisesRegex(DEMO.DemoError, "expected package line"): + DEMO.prepare(self.root, bad_fixture, 15432, 18081, 18080) + + def test_demo_root_must_not_be_a_symbolic_link(self) -> None: + linked_root = Path(self.temporary.name) / "linked-run" + linked_root.symlink_to(self.root, target_is_directory=True) + with self.assertRaisesRegex(DEMO.DemoError, "must not be a symbolic link"): + DEMO.prepare(linked_root, self.fixture, 15432, 18081, 18080) + + def test_token_capture_removes_transport_newline_and_uses_owner_only_mode(self) -> None: + output = self.root / "secrets/token" + DEMO.store_token(output, b"aaa.bbb.ccc\n") + + self.assertEqual("aaa.bbb.ccc", output.read_text(encoding="ascii")) + self.assertEqual(0, output.stat().st_mode & 0o077) + with self.assertRaises(FileExistsError): + DEMO.store_token(output, b"ddd.eee.fff\n") + + def test_token_capture_refuses_non_compact_output(self) -> None: + for value in (b"not a token\n", b" aaa.bbb.ccc\n"): + with self.subTest(value=value): + with self.assertRaisesRegex(DEMO.DemoError, "compact JWT"): + DEMO.store_token(self.root / "secrets/token", value) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/registry-server/generated/asset-site-placement/generated/manifest/registry-manifest.json b/products/registry-server/generated/asset-site-placement/generated/manifest/registry-manifest.json new file mode 100644 index 0000000000..96b371e420 --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/manifest/registry-manifest.json @@ -0,0 +1 @@ +{"authorities":[],"catalog":{"application_profiles":[],"base_url":"https://asset-site-placement.example.gov","conforms_to":[],"description":"Portable metadata for the asset site placement registry.","id":"asset-site-placement","publisher":{"iri":"https://asset-site-placement.example.gov/authority","name":"Asset Site Placement Authority"},"standards":{},"title":"Asset Site Placement Catalog"},"codelists":[],"data_services":[],"datasets":[{"access_rights":"restricted","applicable_legislation":[],"conforms_to":[],"description":"Asset placement, site, item, and inspection metadata.","entities":[{"fields":[{"concepts":[],"constraints":{"in":["equipment","vehicle","furniture"]},"name":"asset-class","required":true,"type":"code"},{"concepts":[],"constraints":{"in":[],"max_length":64,"min_length":0},"name":"asset-code","required":true,"type":"string"},{"concepts":[],"constraints":{"in":[],"max_length":200,"min_length":0},"name":"label","required":true,"type":"string"}],"identifiers":[],"name":"asset-item","relationships":[]},{"fields":[{"concepts":[],"constraints":{"in":[]},"name":"valid-from","required":true,"type":"date"},{"concepts":[],"constraints":{"in":[]},"name":"valid-to","required":false,"type":"date"}],"identifiers":[],"name":"asset-placement","relationships":[{"cardinality":"one","name":"asset","target_entity":"asset-item"},{"cardinality":"one","name":"site","target_entity":"asset-site"}]},{"fields":[{"concepts":[],"constraints":{"in":[],"max_length":200,"min_length":0},"name":"label","required":true,"type":"string"},{"concepts":[],"constraints":{"in":[],"max_length":64,"min_length":0},"name":"site-code","required":true,"type":"string"}],"identifiers":[],"name":"asset-site","relationships":[]},{"fields":[{"concepts":[],"constraints":{"in":[]},"name":"observed-at","required":true,"type":"timestamp"},{"concepts":[],"constraints":{"in":["passed","failed"]},"name":"result","required":true,"type":"code"}],"identifiers":[],"name":"inspection-event","relationships":[{"cardinality":"one","name":"asset","target_entity":"asset-item"}]}],"evidence_offerings":[],"id":"asset-site-placement","owner":"Asset Site Placement Authority","public_services":[],"sensitivity":"internal","status":"active","title":"Asset Site Placement Registry","update_frequency":"unknown"}],"ecosystem_bindings":[],"evaluation_profiles":[],"evidence_types":[],"forms":[],"profiles":[],"public_services":[],"requirements":[],"schema_version":"registry-manifest/v1","vocabularies":{}} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/metadata/registry.json b/products/registry-server/generated/asset-site-placement/generated/metadata/registry.json new file mode 100644 index 0000000000..d6324975e4 --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/metadata/registry.json @@ -0,0 +1 @@ +{"entities":[{"entries":[{"accessProfile":"asset-operator","operation":"batch","readableFields":["asset-class","asset-code","label"],"routeId":"records.asset-item.batch"},{"accessProfile":"asset-operator","operation":"create","readableFields":["asset-class","asset-code","label"],"routeId":"records.asset-item.create"},{"accessProfile":"asset-operator","operation":"get","readableFields":["asset-class","asset-code","label"],"routeId":"records.asset-item.get"},{"accessProfile":"site-planner","operation":"get","readableFields":["asset-code","label"],"routeId":"records.asset-item.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset-class","asset-code","label"],"routeId":"records.asset-item.list"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset-code","label"],"routeId":"records.asset-item.list"},{"accessProfile":"asset-operator","operation":"patch","readableFields":["asset-class","asset-code","label"],"routeId":"records.asset-item.patch"}],"id":"asset-item","route":"assets","schemaPath":"/v1/schemas/asset-item"},{"entries":[{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.as-of"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.as-of"},{"accessProfile":"asset-operator","operation":"create","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.create"},{"accessProfile":"site-planner","operation":"create","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.create"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.current"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.current"},{"accessProfile":"asset-operator","operation":"get","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.get"},{"accessProfile":"site-planner","operation":"get","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.list"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.list"},{"accessProfile":"asset-operator","operation":"patch","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.patch"},{"accessProfile":"site-planner","operation":"patch","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.patch"}],"id":"asset-placement","route":"placements","schemaPath":"/v1/schemas/asset-placement"},{"entries":[{"accessProfile":"asset-operator","operation":"create","readableFields":["label","site-code"],"routeId":"records.asset-site.create"},{"accessProfile":"asset-operator","operation":"get","readableFields":["label","site-code"],"routeId":"records.asset-site.get"},{"accessProfile":"site-planner","operation":"get","readableFields":["label","site-code"],"routeId":"records.asset-site.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["label","site-code"],"routeId":"records.asset-site.list"},{"accessProfile":"site-planner","operation":"list","readableFields":["label","site-code"],"routeId":"records.asset-site.list"},{"accessProfile":"asset-operator","operation":"patch","readableFields":["label","site-code"],"routeId":"records.asset-site.patch"}],"id":"asset-site","route":"sites","schemaPath":"/v1/schemas/asset-site"},{"entries":[{"accessProfile":"asset-operator","operation":"create","readableFields":["asset","observed-at","result"],"routeId":"records.inspection-event.create"},{"accessProfile":"asset-operator","operation":"get","readableFields":["asset","observed-at","result"],"routeId":"records.inspection-event.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","observed-at","result"],"routeId":"records.inspection-event.list"}],"id":"inspection-event","route":"inspections","schemaPath":"/v1/schemas/inspection-event"}],"registryId":"asset-site-placement","version":"0.1.0"} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/openapi.json b/products/registry-server/generated/asset-site-placement/generated/openapi.json new file mode 100644 index 0000000000..935f0d7f00 --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/openapi.json @@ -0,0 +1 @@ +{"components":{"schemas":{"asset-item":{"$id":"urn:registry-server:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset-class":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"asset-code":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["asset-class","asset-code","label"],"type":"object","x-registry-mutationMode":"mutable"},"asset-placement":{"$id":"urn:registry-server:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["asset","site","valid-from"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site":{"$id":"urn:registry-server:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"site-code":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label","site-code"],"type":"object","x-registry-mutationMode":"mutable"},"inspection-event":{"$id":"urn:registry-server:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observed-at":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observed-at","result"],"type":"object","x-registry-mutationMode":"create_only"}}},"info":{"title":"asset-site-placement","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/assets":{"get":{"operationId":"records.asset-item.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-item.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"create"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset-item.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"get"},"patch":{"operationId":"records.asset-item.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"patch"}},"/v1/records/assets:batch":{"post":{"operationId":"records.asset-item.batch","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"items":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"operation":{"const":"create"}},"required":["operation","data"],"type":"object"},{"additionalProperties":false,"properties":{"ifMatch":{"type":"string"},"operation":{"const":"patch"},"patch":{"maxItems":128,"minItems":1,"type":"array"},"recordId":{"format":"uuid","type":"string"}},"required":["operation","recordId","ifMatch","patch"],"type":"object"}]},"maxItems":4,"minItems":1,"type":"array"}},"required":["items"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"results":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"etag":{"type":"string"},"id":{"format":"uuid","type":"string"},"operation":{"enum":["create","patch"]},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["operation","id","revision","etag","data"],"type":"object"},"maxItems":4,"minItems":1,"type":"array"}},"required":["results"],"type":"object"}}},"description":"Atomic batch committed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-maximumBytes":16384,"x-registry-maximumItems":4,"x-registry-operation":"batch"}},"/v1/records/inspections":{"get":{"operationId":"records.inspection-event.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.inspection-event.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"create"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.inspection-event.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"get"}},"/v1/records/placements":{"get":{"operationId":"records.asset-placement.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-placement.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"create"}},"/v1/records/placements/{record_id}":{"get":{"operationId":"records.asset-placement.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"get"},"patch":{"operationId":"records.asset-placement.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"patch"}},"/v1/records/placements:as-of":{"get":{"operationId":"records.asset-placement.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/placements:current":{"get":{"operationId":"records.asset-placement.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/sites":{"get":{"operationId":"records.asset-site.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-site.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"create"}},"/v1/records/sites/{record_id}":{"get":{"operationId":"records.asset-site.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"get"},"patch":{"operationId":"records.asset-site.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"patch"}}}} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql b/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql new file mode 100644 index 0000000000..991c134399 --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql @@ -0,0 +1,36 @@ +CREATE SCHEMA IF NOT EXISTS registry_data; +CREATE TABLE registry_data."rs_e_asset_item_847d26c3e6e68a51" (record_id uuid NOT NULL, record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0), record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned')), created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> ''), PRIMARY KEY (record_id), "rs_f_asset_item_asset_class_d600d2cfe0601df0" text NOT NULL CHECK ("rs_f_asset_item_asset_class_d600d2cfe0601df0" IN ('equipment', 'vehicle', 'furniture')), "rs_f_asset_item_asset_code_3dcfb11c8485c27d" varchar(64) NOT NULL, "rs_f_asset_item_label_07f15f9906c86214" varchar(200) NOT NULL); +CREATE TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" (record_id uuid NOT NULL, record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0), record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned')), created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> ''), PRIMARY KEY (record_id), "rs_f_asset_placement_asset_c9ca09383d36692d" uuid NOT NULL, "rs_f_asset_placement_site_1f363a0accf66d99" uuid NOT NULL, "rs_f_asset_placement_valid_from_26d05bd0c44857c7" date NOT NULL, "rs_f_asset_placement_valid_to_f90aaf0250c93a70" date); +CREATE TABLE registry_data."rs_e_asset_site_db7008b8eaed2382" (record_id uuid NOT NULL, record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0), record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned')), created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> ''), PRIMARY KEY (record_id), "rs_f_asset_site_label_12f77c179e4d46c0" varchar(200) NOT NULL, "rs_f_asset_site_site_code_078b8d51a606a531" varchar(64) NOT NULL); +CREATE TABLE registry_data."rs_e_inspection_event_8d78d2871d86ffa3" (record_id uuid NOT NULL, record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0), record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned')), created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> ''), PRIMARY KEY (record_id), "rs_f_inspection_event_asset_3a5ad890cb504dce" uuid NOT NULL, "rs_f_inspection_event_observed_at_5ae9cec8794e85a2" timestamptz NOT NULL, "rs_f_inspection_event_result_f0d09fc75deb558a" text NOT NULL CHECK ("rs_f_inspection_event_result_f0d09fc75deb558a" IN ('passed', 'failed'))); +ALTER TABLE registry_data."rs_e_asset_item_847d26c3e6e68a51" ADD CONSTRAINT "rs_c_asset_item_unique_f7bbe96ad6f0b7c3_a73136daf6f863a2" UNIQUE ("rs_f_asset_item_asset_code_3dcfb11c8485c27d"); +ALTER TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" ADD CONSTRAINT "registry_temporal_order_d42bf0d1ebe39f1cb7b668e6" CHECK ("rs_f_asset_placement_valid_to_f90aaf0250c93a70" IS NULL OR "rs_f_asset_placement_valid_from_26d05bd0c44857c7" < "rs_f_asset_placement_valid_to_f90aaf0250c93a70"); +ALTER TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" ADD CONSTRAINT "rs_r_asset_placement_asset_ee97de502541e560" FOREIGN KEY ("rs_f_asset_placement_asset_c9ca09383d36692d") REFERENCES registry_data."rs_e_asset_item_847d26c3e6e68a51" (record_id) ON DELETE RESTRICT; +ALTER TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" ADD CONSTRAINT "rs_r_asset_placement_site_a7380bb274655233" FOREIGN KEY ("rs_f_asset_placement_site_1f363a0accf66d99") REFERENCES registry_data."rs_e_asset_site_db7008b8eaed2382" (record_id) ON DELETE RESTRICT; +ALTER TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" ADD CONSTRAINT "rs_c_asset_placement_temporal_non_overlap_e109_f3757222e5baf646" EXCLUDE USING gist ("rs_f_asset_placement_asset_c9ca09383d36692d" WITH =, daterange("rs_f_asset_placement_valid_from_26d05bd0c44857c7", "rs_f_asset_placement_valid_to_f90aaf0250c93a70", '[)') WITH &&); +ALTER TABLE registry_data."rs_e_asset_site_db7008b8eaed2382" ADD CONSTRAINT "rs_c_asset_site_unique_cb6773812408ef56_0b32a06790296753" UNIQUE ("rs_f_asset_site_site_code_078b8d51a606a531"); +ALTER TABLE registry_data."rs_e_inspection_event_8d78d2871d86ffa3" ADD CONSTRAINT "rs_r_inspection_event_asset_22f7a61e3add7045" FOREIGN KEY ("rs_f_inspection_event_asset_3a5ad890cb504dce") REFERENCES registry_data."rs_e_asset_item_847d26c3e6e68a51" (record_id) ON DELETE RESTRICT; +ALTER TABLE registry_data."rs_e_asset_item_847d26c3e6e68a51" ENABLE ROW LEVEL SECURITY; +ALTER TABLE registry_data."rs_e_asset_item_847d26c3e6e68a51" FORCE ROW LEVEL SECURITY; +CREATE POLICY "registry_rls_select_ae0796eafa1e9eac571bb87c" ON registry_data."rs_e_asset_item_847d26c3e6e68a51" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_insert_d025d90a72995a769e8a6173" ON registry_data."rs_e_asset_item_847d26c3e6e68a51" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_update_705b1fb4f79ed895a0e92256" ON registry_data."rs_e_asset_item_847d26c3e6e68a51" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_select_ef9fd8aeff50702410afaaaa" ON registry_data."rs_e_asset_item_847d26c3e6e68a51" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +ALTER TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" ENABLE ROW LEVEL SECURITY; +ALTER TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" FORCE ROW LEVEL SECURITY; +CREATE POLICY "registry_rls_select_607646a772003fc998702c5d" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_insert_8b9e6b3a9153d3ffef4c6780" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_update_3954a4ba2983e7cdfcde8af4" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_select_975f856168c7c15a912dda52" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_insert_bc00fa6b634ab2ca59bb7efd" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_update_240cb40a7ff28a3eda3e35fa" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +ALTER TABLE registry_data."rs_e_asset_site_db7008b8eaed2382" ENABLE ROW LEVEL SECURITY; +ALTER TABLE registry_data."rs_e_asset_site_db7008b8eaed2382" FORCE ROW LEVEL SECURITY; +CREATE POLICY "registry_rls_select_34fa8a622e702a16f5b0b398" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_insert_e160cd033236bc16b7069084" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_update_50997e5bd81b338659ce5217" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_select_1077759afe589ed883cbf7e8" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +ALTER TABLE registry_data."rs_e_inspection_event_8d78d2871d86ffa3" ENABLE ROW LEVEL SECURITY; +ALTER TABLE registry_data."rs_e_inspection_event_8d78d2871d86ffa3" FORCE ROW LEVEL SECURITY; +CREATE POLICY "registry_rls_select_160ed0ec696ef3506f21244c" ON registry_data."rs_e_inspection_event_8d78d2871d86ffa3" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_insert_e57571ab1a9e5b1bd4f66b59" ON registry_data."rs_e_inspection_event_8d78d2871d86ffa3" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); diff --git a/products/registry-server/generated/asset-site-placement/generated/schemas/asset-item.schema.json b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-item.schema.json new file mode 100644 index 0000000000..c55206a5ca --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-item.schema.json @@ -0,0 +1 @@ +{"$id":"urn:registry-server:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset-class":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"asset-code":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["asset-class","asset-code","label"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/schemas/asset-placement.schema.json b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-placement.schema.json new file mode 100644 index 0000000000..3f74cf3b14 --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-placement.schema.json @@ -0,0 +1 @@ +{"$id":"urn:registry-server:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["asset","site","valid-from"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/schemas/asset-site.schema.json b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-site.schema.json new file mode 100644 index 0000000000..e5e2e55c75 --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-site.schema.json @@ -0,0 +1 @@ +{"$id":"urn:registry-server:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"site-code":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label","site-code"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/schemas/inspection-event.schema.json b/products/registry-server/generated/asset-site-placement/generated/schemas/inspection-event.schema.json new file mode 100644 index 0000000000..1264cd3525 --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/schemas/inspection-event.schema.json @@ -0,0 +1 @@ +{"$id":"urn:registry-server:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observed-at":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observed-at","result"],"type":"object","x-registry-mutationMode":"create_only"} \ No newline at end of file diff --git a/products/registry-server/generated/authoring/registry-project.schema.json b/products/registry-server/generated/authoring/registry-project.schema.json new file mode 100644 index 0000000000..821d14108f --- /dev/null +++ b/products/registry-server/generated/authoring/registry-project.schema.json @@ -0,0 +1,1747 @@ +{ + "$defs": { + "AccessGrantSource": { + "additionalProperties": false, + "properties": { + "actions": { + "items": { + "$ref": "#/$defs/Operation" + }, + "type": "array", + "uniqueItems": true + }, + "allowDataExport": { + "default": false, + "type": "boolean" + }, + "entity": { + "type": "string" + }, + "filterableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "readableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "revisionAccess": { + "default": false, + "type": "boolean" + }, + "rowBoundaries": { + "default": [], + "items": { + "$ref": "#/$defs/RowBoundarySource" + }, + "type": "array" + }, + "sortableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "writableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "entity", + "actions" + ], + "type": "object" + }, + "AccessProfileSource": { + "additionalProperties": false, + "properties": { + "allowDataExport": { + "default": false, + "type": "boolean" + }, + "anonymous": { + "default": false, + "type": "boolean" + }, + "default": { + "default": false, + "type": "boolean" + }, + "filterableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "id": { + "type": "string" + }, + "operations": { + "items": { + "$ref": "#/$defs/Operation" + }, + "type": "array", + "uniqueItems": true + }, + "principalClaim": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "readableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "requiredPurposes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "requiredScopes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "revisionAccess": { + "default": false, + "type": "boolean" + }, + "rowBoundaries": { + "default": [], + "items": { + "$ref": "#/$defs/RowBoundarySource" + }, + "type": "array" + }, + "sortableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "writableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "id", + "operations" + ], + "type": "object" + }, + "BatchSource": { + "additionalProperties": false, + "properties": { + "maximumBytes": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "maximumItems": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "maximumItems", + "maximumBytes" + ], + "type": "object" + }, + "BooleanFieldKindSchema": { + "enum": [ + "boolean" + ], + "type": "string" + }, + "BooleanFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/BooleanFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification" + ], + "type": "object" + }, + "BoundaryOperator": { + "enum": [ + "equals", + "in" + ], + "type": "string" + }, + "Classification": { + "enum": [ + "public", + "internal", + "restricted" + ], + "type": "string" + }, + "ComparisonOperator": { + "enum": [ + "less_than", + "less_than_or_equal", + "greater_than", + "greater_than_or_equal" + ], + "type": "string" + }, + "ConstraintSource": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "unique", + "type": "string" + }, + "when": { + "items": { + "$ref": "#/$defs/UniqueWhenPredicate" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "kind", + "fields" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "compare", + "type": "string" + }, + "left": { + "type": "string" + }, + "operator": { + "$ref": "#/$defs/ComparisonOperator" + }, + "right": { + "type": "string" + } + }, + "required": [ + "kind", + "left", + "operator", + "right" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "int_range", + "type": "string" + }, + "maximum": { + "default": null, + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "minimum": { + "default": null, + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "kind", + "field" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "vocabulary", + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "kind", + "field", + "values" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "endField": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "temporal-non-overlap", + "type": "string" + }, + "scopeFields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "startField": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "scopeFields" + ], + "type": "object" + } + ] + }, + "Crs84BboxSource": { + "additionalProperties": false, + "properties": { + "east": { + "type": "string" + }, + "north": { + "type": "string" + }, + "south": { + "type": "string" + }, + "west": { + "type": "string" + } + }, + "required": [ + "west", + "south", + "east", + "north" + ], + "type": "object" + }, + "Crs84PointFieldKindSchema": { + "enum": [ + "crs84-point" + ], + "type": "string" + }, + "Crs84PointFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "bbox": { + "anyOf": [ + { + "$ref": "#/$defs/Crs84BboxSource" + }, + { + "type": "null" + } + ], + "default": null + }, + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "precision": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/Crs84PointFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification", + "precision" + ], + "type": "object" + }, + "DateFieldKindSchema": { + "enum": [ + "date" + ], + "type": "string" + }, + "DateFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/DateFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification" + ], + "type": "object" + }, + "DecimalFieldKindSchema": { + "enum": [ + "decimal" + ], + "type": "string" + }, + "DecimalFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "maximum": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "minimum": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "precision": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "required": { + "default": false, + "type": "boolean" + }, + "scale": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "type": { + "$ref": "#/$defs/DecimalFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification", + "precision", + "scale" + ], + "type": "object" + }, + "EntitySource": { + "additionalProperties": false, + "properties": { + "accessProfiles": { + "default": [], + "items": { + "$ref": "#/$defs/AccessProfileSource" + }, + "type": "array" + }, + "batch": { + "anyOf": [ + { + "$ref": "#/$defs/BatchSource" + }, + { + "type": "null" + } + ], + "default": null + }, + "classification": { + "$ref": "#/$defs/Classification", + "default": "internal" + }, + "constraints": { + "default": [], + "items": { + "$ref": "#/$defs/ConstraintSource" + }, + "type": "array" + }, + "events": { + "default": [], + "items": { + "$ref": "#/$defs/EventSource" + }, + "type": "array" + }, + "fields": { + "default": [], + "items": { + "$ref": "#/$defs/FieldSource" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "indexes": { + "default": [], + "items": { + "$ref": "#/$defs/IndexSource" + }, + "type": "array" + }, + "mutationMode": { + "$ref": "#/$defs/MutationMode" + }, + "route": { + "type": "string" + }, + "temporal": { + "anyOf": [ + { + "$ref": "#/$defs/TemporalSource" + }, + { + "type": "null" + } + ], + "default": null + }, + "tombstone": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "id", + "route", + "mutationMode" + ], + "type": "object" + }, + "EventSource": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "projection": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "trigger": { + "$ref": "#/$defs/EventTrigger" + }, + "webhook": { + "anyOf": [ + { + "$ref": "#/$defs/WebhookSource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "trigger", + "projection" + ], + "type": "object" + }, + "EventTrigger": { + "enum": [ + "created", + "patched", + "tombstoned" + ], + "type": "string" + }, + "FieldSource": { + "anyOf": [ + { + "$ref": "#/$defs/BooleanFieldSourceSchema" + }, + { + "$ref": "#/$defs/StringFieldSourceSchema" + }, + { + "$ref": "#/$defs/TextFieldSourceSchema" + }, + { + "$ref": "#/$defs/Int64FieldSourceSchema" + }, + { + "$ref": "#/$defs/DecimalFieldSourceSchema" + }, + { + "$ref": "#/$defs/DateFieldSourceSchema" + }, + { + "$ref": "#/$defs/TimestampFieldSourceSchema" + }, + { + "$ref": "#/$defs/UuidFieldSourceSchema" + }, + { + "$ref": "#/$defs/VocabularyCodeFieldSourceSchema" + }, + { + "$ref": "#/$defs/ReferenceFieldSourceSchema" + }, + { + "$ref": "#/$defs/Crs84PointFieldSourceSchema" + }, + { + "$ref": "#/$defs/StructuredFieldSourceSchema" + } + ] + }, + "IndexSource": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "fields" + ], + "type": "object" + }, + "Int64FieldKindSchema": { + "enum": [ + "int64" + ], + "type": "string" + }, + "Int64FieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/Int64FieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification" + ], + "type": "object" + }, + "ManifestProjectionCatalogSource": { + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "participantId": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "$ref": "#/$defs/ManifestProjectionPublisherSource" + }, + "title": { + "type": "string" + } + }, + "required": [ + "baseUrl", + "title", + "publisher" + ], + "type": "object" + }, + "ManifestProjectionDatasetSource": { + "additionalProperties": false, + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "owner": { + "type": [ + "string", + "null" + ] + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionDatasetStatus" + }, + { + "type": "null" + } + ] + }, + "title": { + "type": "string" + } + }, + "required": [ + "title" + ], + "type": "object" + }, + "ManifestProjectionDatasetStatus": { + "enum": [ + "under_development", + "active", + "completed", + "deprecated", + "withdrawn" + ], + "type": "string" + }, + "ManifestProjectionPublisherSource": { + "additionalProperties": false, + "properties": { + "authorityType": { + "type": [ + "string", + "null" + ] + }, + "iri": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "ManifestProjectionSource": { + "additionalProperties": false, + "properties": { + "accessProfile": { + "type": "string" + }, + "catalog": { + "$ref": "#/$defs/ManifestProjectionCatalogSource" + }, + "classificationCeiling": { + "$ref": "#/$defs/Classification" + }, + "dataset": { + "$ref": "#/$defs/ManifestProjectionDatasetSource" + } + }, + "required": [ + "accessProfile", + "classificationCeiling", + "catalog", + "dataset" + ], + "type": "object" + }, + "ModuleLockSource": { + "additionalProperties": false, + "properties": { + "digest": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, + "MutationMode": { + "enum": [ + "mutable", + "create_only" + ], + "type": "string" + }, + "Operation": { + "enum": [ + "create", + "get", + "list", + "patch", + "tombstone", + "batch", + "revisions" + ], + "type": "string" + }, + "PackageIdentitySource": { + "additionalProperties": false, + "properties": { + "environment": { + "type": "string" + }, + "instanceId": { + "type": "string" + }, + "sequence": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "sourceRevision": { + "type": "string" + } + }, + "required": [ + "environment", + "instanceId", + "sequence", + "sourceRevision" + ], + "type": "object" + }, + "ProjectAccessProfileSource": { + "additionalProperties": false, + "properties": { + "default": { + "default": false, + "type": "boolean" + }, + "grants": { + "default": [], + "items": { + "$ref": "#/$defs/AccessGrantSource" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "principalClaim": { + "type": "string" + }, + "purposes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "requiredScopes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "id", + "principalClaim" + ], + "type": "object" + }, + "ReferenceDelete": { + "enum": [ + "restrict" + ], + "type": "string" + }, + "ReferenceFieldKindSchema": { + "enum": [ + "reference" + ], + "type": "string" + }, + "ReferenceFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "onDelete": { + "$ref": "#/$defs/ReferenceDelete", + "default": "restrict" + }, + "required": { + "default": false, + "type": "boolean" + }, + "target": { + "type": "string" + }, + "type": { + "$ref": "#/$defs/ReferenceFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification", + "target" + ], + "type": "object" + }, + "RegistryIdentitySource": { + "additionalProperties": false, + "properties": { + "defaultLanguage": { + "type": "string" + }, + "id": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version", + "defaultLanguage" + ], + "type": "object" + }, + "RowBoundarySource": { + "additionalProperties": false, + "properties": { + "claim": { + "type": "string" + }, + "field": { + "type": "string" + }, + "operator": { + "$ref": "#/$defs/BoundaryOperator" + } + }, + "required": [ + "field", + "claim", + "operator" + ], + "type": "object" + }, + "StringFieldKindSchema": { + "enum": [ + "string" + ], + "type": "string" + }, + "StringFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "maxLength": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "minLength": { + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/StringFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification", + "maxLength" + ], + "type": "object" + }, + "StructuredFieldKindSchema": { + "enum": [ + "structured" + ], + "type": "string" + }, + "StructuredFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "maxBytes": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "required": { + "default": false, + "type": "boolean" + }, + "schema": true, + "type": { + "$ref": "#/$defs/StructuredFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification", + "maxBytes", + "schema" + ], + "type": "object" + }, + "TemporalSource": { + "additionalProperties": false, + "properties": { + "endField": { + "type": "string" + }, + "scopeFields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "startField": { + "type": "string" + } + }, + "required": [ + "startField", + "endField", + "scopeFields" + ], + "type": "object" + }, + "TextFieldKindSchema": { + "enum": [ + "text" + ], + "type": "string" + }, + "TextFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "maxLength": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/TextFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification", + "maxLength" + ], + "type": "object" + }, + "TimestampFieldKindSchema": { + "enum": [ + "timestamp" + ], + "type": "string" + }, + "TimestampFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/TimestampFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification" + ], + "type": "object" + }, + "UniqueWhenPredicate": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "kind": { + "const": "field_equals", + "type": "string" + }, + "value": true + }, + "required": [ + "kind", + "field", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "kind": { + "const": "field_is_null", + "type": "string" + } + }, + "required": [ + "kind", + "field" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "kind": { + "const": "field_is_not_null", + "type": "string" + } + }, + "required": [ + "kind", + "field" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "active_lifecycle", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "UuidFieldKindSchema": { + "enum": [ + "uuid" + ], + "type": "string" + }, + "UuidFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/UuidFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "type", + "classification" + ], + "type": "object" + }, + "ValidTimeRole": { + "enum": [ + "valid_from", + "valid_to" + ], + "type": "string" + }, + "VocabularyCodeFieldKindSchema": { + "enum": [ + "vocabulary-code" + ], + "type": "string" + }, + "VocabularyCodeFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/VocabularyCodeFieldKindSchema" + }, + "validTimeRole": { + "anyOf": [ + { + "$ref": "#/$defs/ValidTimeRole" + }, + { + "type": "null" + } + ], + "default": null + }, + "values": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "vocabulary": { + "type": "string" + } + }, + "required": [ + "id", + "type", + "classification", + "vocabulary" + ], + "type": "object" + }, + "VocabularySource": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "values" + ], + "type": "object" + }, + "WebhookAuthenticationProfile": { + "enum": [ + "hmac_sha256_v1" + ], + "type": "string" + }, + "WebhookDeadLetterMode": { + "enum": [ + "required" + ], + "type": "string" + }, + "WebhookDeliverySource": { + "additionalProperties": false, + "properties": { + "attemptTimeoutMs": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "deadLetter": { + "anyOf": [ + { + "$ref": "#/$defs/WebhookDeadLetterMode" + }, + { + "type": "null" + } + ], + "default": null + }, + "initialBackoffMs": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "maximumAttempts": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "maximumBackoffMs": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "operatorReplay": { + "type": "boolean" + } + }, + "required": [ + "attemptTimeoutMs", + "initialBackoffMs", + "maximumBackoffMs", + "maximumAttempts", + "operatorReplay" + ], + "type": "object" + }, + "WebhookSource": { + "additionalProperties": false, + "description": "Governed, destination-neutral webhook subscription.\n\nDeployment configuration may bind `destination_id` to transport details\nand tighten these bounds, but cannot supply or widen this authority.", + "properties": { + "authenticationProfile": { + "$ref": "#/$defs/WebhookAuthenticationProfile" + }, + "classificationCeiling": { + "$ref": "#/$defs/Classification" + }, + "delivery": { + "$ref": "#/$defs/WebhookDeliverySource" + }, + "destinationId": { + "type": "string" + } + }, + "required": [ + "destinationId", + "classificationCeiling", + "authenticationProfile", + "delivery" + ], + "type": "object" + } + }, + "$id": "https://id.registrystack.org/schemas/registry-server/authoring/registry-project.v1alpha1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "accessProfiles": { + "default": [], + "items": { + "$ref": "#/$defs/ProjectAccessProfileSource" + }, + "type": "array" + }, + "apiVersion": { + "type": "string" + }, + "entities": { + "default": [], + "items": { + "$ref": "#/$defs/EntitySource" + }, + "type": "array" + }, + "kind": { + "type": "string" + }, + "manifestProjection": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionSource" + }, + { + "type": "null" + } + ], + "default": null + }, + "modules": { + "default": [], + "items": { + "$ref": "#/$defs/ModuleLockSource" + }, + "type": "array" + }, + "package": { + "anyOf": [ + { + "$ref": "#/$defs/PackageIdentitySource" + }, + { + "type": "null" + } + ], + "default": null + }, + "registry": { + "$ref": "#/$defs/RegistryIdentitySource" + }, + "vocabularies": { + "default": [], + "items": { + "$ref": "#/$defs/VocabularySource" + }, + "type": "array" + } + }, + "required": [ + "apiVersion", + "kind", + "registry" + ], + "title": "Registry Server authored project", + "type": "object" +} diff --git a/products/registry-server/scripts/check-contracts.sh b/products/registry-server/scripts/check-contracts.sh new file mode 100755 index 0000000000..cf4369182a --- /dev/null +++ b/products/registry-server/scripts/check-contracts.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +export PYTHONDONTWRITEBYTECODE=1 + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +python3 "$script_dir/validate_product.py" +python3 "$script_dir/check_source_neutrality.py" +"$script_dir/check-generated.sh" +python3 -m unittest \ + "$script_dir/test_validate_product.py" \ + "$script_dir/test_check_source_neutrality.py" \ + "$script_dir/test_generated_gates.py" \ + "$script_dir/../demo/support/test_demo.py" + +echo "Registry Server product contracts passed" diff --git a/products/registry-server/scripts/check-generated.sh b/products/registry-server/scripts/check-generated.sh new file mode 100755 index 0000000000..5d09cc5685 --- /dev/null +++ b/products/registry-server/scripts/check-generated.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repository_root=$(cd -- "$script_dir/../../.." && pwd) +fixture="$repository_root/products/registry-server/acceptance/asset-site-placement" +baseline="$repository_root/products/registry-server/generated/asset-site-placement" +authoring_baseline="$repository_root/products/registry-server/generated/authoring" +temporary_root="" + +cleanup() { + case "$temporary_root" in + "$repository_root"/.registry-server-generated.*) + if [[ -d "$temporary_root" && ! -L "$temporary_root" ]]; then + rm -rf -- "$temporary_root" + fi + ;; + "") ;; + *) + printf '%s\n' 'generated-artifact temporary directory did not match its validated location' >&2 + return 1 + ;; + esac +} +trap cleanup EXIT HUP INT TERM + +temporary_root=$(mktemp -d "$repository_root/.registry-server-generated.XXXXXX") +export CARGO_INCREMENTAL=0 +export CARGO_PROFILE_DEV_DEBUG=0 +export CARGO_PROFILE_TEST_DEBUG=0 +export RUSTC_WRAPPER="${RUSTC_WRAPPER-}" + +candidate="$temporary_root/generated" +mkdir "$candidate" +authoring_candidate="$temporary_root/authoring" +mkdir "$authoring_candidate" +( + cd "$temporary_root" + cargo run --manifest-path "$repository_root/Cargo.toml" --locked --quiet \ + -p registry-server --features schema --example authoring-schema -- \ + --output "$authoring_candidate" + for selector in openapi schemas manifest metadata sql; do + cargo run --manifest-path "$repository_root/Cargo.toml" --locked -p registry-serverctl -- \ + generate "$selector" "$fixture" --output "./$selector" + cp -R "./$selector/." "$candidate" + done +) + +if ! diff -ru "$authoring_baseline" "$authoring_candidate"; then + printf '%s\n' 'Registry Server authoring schema differs from the committed artifact.' >&2 + printf '%s\n' 'Regenerate it, then review the complete diff:' >&2 + printf '%s\n' ' cargo run -p registry-server --features schema --example authoring-schema -- --output products/registry-server/generated/authoring' >&2 + exit 1 +fi + +python3 "$script_dir/compare-generated-tree.py" "$baseline" "$candidate" diff --git a/products/registry-server/scripts/check-source-neutrality.sh b/products/registry-server/scripts/check-source-neutrality.sh new file mode 100755 index 0000000000..c6ec55ac81 --- /dev/null +++ b/products/registry-server/scripts/check-source-neutrality.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +export PYTHONDONTWRITEBYTECODE=1 + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +python3 "$script_dir/check_source_neutrality.py" diff --git a/products/registry-server/scripts/check_source_neutrality.py b/products/registry-server/scripts/check_source_neutrality.py new file mode 100644 index 0000000000..5b6fcda93c --- /dev/null +++ b/products/registry-server/scripts/check_source_neutrality.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""Reject authored fixture identifiers in shipped Registry Server source.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + + +FORBIDDEN_FIXTURE_IDENTIFIERS = ( + "asset-site-placement", + "asset-site-placement-core", + "asset-item", + "asset-site", + "asset-placement", + "inspection-event", + "/v1/records/assets", + "/v1/records/sites", + "/v1/records/placements", + "household-core", + "publicschema-household-core", + "publicschema-household-demographics", + "group-membership", + "/v1/records/persons", + "/v1/records/households", + "/v1/records/group-memberships", + "disability-core", + "assessment-episode", + "functioning-observation", + "/v1/records/assessment-episodes", + "/v1/records/functioning-observations", + "/v1/records/certifications", + "farmer-core", + "seasonal-activity", + "/v1/records/farmers", + "/v1/records/holdings", + "/v1/records/plots", + "/v1/records/seasonal-activities", + "business-core", + "legal-entity", + "officer-appointment", + "/v1/records/legal-entities", + "/v1/records/filings", + "/v1/records/officer-appointments", +) +FORBIDDEN_RUST_TYPE_IDENTIFIERS = ( + "Asset", + "Site", + "Placement", + "InspectionEvent", + "Person", + "Household", + "GroupMembership", + "AssessmentEpisode", + "FunctioningObservation", + "Certification", + "Farmer", + "Holding", + "Plot", + "SeasonalActivity", + "LegalEntity", + "Filing", + "OfficerAppointment", +) +FORBIDDEN_DOMAIN_COMPONENTS = ( + "asset", + "assets", + "site", + "sites", + "placement", + "placements", + "inspection", + "inspections", + "person", + "persons", + "household", + "households", + "membership", + "memberships", + "assessment", + "assessments", + "observation", + "observations", + "certification", + "certifications", + "disability", + "farmer", + "farmers", + "holding", + "holdings", + "plot", + "plots", + "seasonal", + "business", + "filing", + "filings", + "appointment", + "appointments", +) +SOURCE_ROOTS = ("crates/registry-server", "crates/registry-serverctl") +SOURCE_SUFFIXES = {".rs"} +PRODUCTION_INPUT_DIRECTORIES = ("resources", "schemas", "migrations", "templates") +EXCLUDED_SOURCE_DIRECTORIES = {"tests", "fixtures", "examples", "benches"} +PUBLIC_KERNEL_CONTRACTS = ("products/registry-server/contracts/package-layout.yaml",) +DOMAIN_COMPONENT = re.compile( + r"(?i)(?:^|[._:/-])(?:" + + "|".join(re.escape(value) for value in FORBIDDEN_DOMAIN_COMPONENTS) + + r")(?=$|[._:/-])" +) +DOMAIN_WORD = re.compile( + r"(?i)\b(?:" + + "|".join(re.escape(value) for value in FORBIDDEN_DOMAIN_COMPONENTS) + + r")\b" +) + + +def source_files(repository_root: Path) -> list[Path]: + files: list[Path] = [] + for relative_root in SOURCE_ROOTS: + root = repository_root / relative_root + if root.is_dir(): + source_root = root / "src" + if source_root.is_dir(): + files.extend( + path + for path in source_root.rglob("*") + if path.is_file() + and path.suffix in SOURCE_SUFFIXES + and not (set(path.relative_to(root).parts) & EXCLUDED_SOURCE_DIRECTORIES) + ) + build_script = root / "build.rs" + if build_script.is_file(): + files.append(build_script) + manifest = root / "Cargo.toml" + if manifest.is_file(): + files.append(manifest) + for directory_name in PRODUCTION_INPUT_DIRECTORIES: + directory = root / directory_name + if directory.is_dir(): + files.extend( + path + for path in directory.rglob("*") + if path.is_file() and not (set(path.relative_to(root).parts) & EXCLUDED_SOURCE_DIRECTORIES) + ) + for relative_path in PUBLIC_KERNEL_CONTRACTS: + contract = repository_root / relative_path + if contract.is_file(): + files.append(contract) + return sorted(set(files)) + + +def rust_structure(source: str) -> str: + """Blank Rust comments and literals while preserving code positions.""" + masked = list(source) + index = 0 + block_depth = 0 + while index < len(source): + if block_depth: + if source.startswith("/*", index): + masked[index : index + 2] = " " + block_depth += 1 + index += 2 + elif source.startswith("*/", index): + masked[index : index + 2] = " " + block_depth -= 1 + index += 2 + else: + if source[index] != "\n": + masked[index] = " " + index += 1 + continue + if source.startswith("//", index): + end = source.find("\n", index) + end = len(source) if end < 0 else end + masked[index:end] = " " * (end - index) + index = end + continue + if source.startswith("/*", index): + masked[index : index + 2] = " " + block_depth = 1 + index += 2 + continue + raw = re.match(r"(?:br|r)(?P#{0,255})\"", source[index:]) + if raw: + delimiter = '"' + raw.group("hashes") + end = source.find(delimiter, index + len(raw.group(0))) + end = len(source) if end < 0 else end + len(delimiter) + for position in range(index, end): + if source[position] != "\n": + masked[position] = " " + index = end + continue + if source[index] == "'" and not ( + index + 2 < len(source) and (source[index + 2] == "'" or source[index + 1] == "\\") + ): + index += 1 + continue + if source[index] in {'"', "'"} or source.startswith('b"', index) or source.startswith("b'", index): + quote_index = index + 1 if source.startswith("b", index) else index + quote = source[quote_index] + end = quote_index + 1 + escaped = False + while end < len(source): + character = source[end] + if character == quote and not escaped: + end += 1 + break + escaped = character == "\\" and not escaped + if character != "\\": + escaped = False + end += 1 + for position in range(index, end): + if source[position] != "\n": + masked[position] = " " + index = end + continue + index += 1 + return "".join(masked) + + +def without_cfg_test_items(source: str) -> str: + """Remove Rust items gated by cfg(test), including their nested braces.""" + structure = rust_structure(source) + masked = list(source) + attribute = re.compile(r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]") + for match in attribute.finditer(structure): + brace = structure.find("{", match.end()) + semicolon = structure.find(";", match.end()) + if brace < 0 or (semicolon >= 0 and semicolon < brace): + end = semicolon + 1 if semicolon >= 0 else len(source) + else: + depth = 0 + end = brace + while end < len(structure): + if structure[end] == "{": + depth += 1 + elif structure[end] == "}": + depth -= 1 + if depth == 0: + end += 1 + break + end += 1 + for position in range(match.start(), end): + if source[position] != "\n": + masked[position] = " " + return "".join(masked) + + +def rust_string_literals(source: str) -> list[str]: + """Return simple and raw Rust string bodies for identifier inspection.""" + literals: list[str] = [] + raw_pattern = re.compile(r'(?:br|r)(?P#{0,255})"(?P.*?)"(?P=hashes)', re.DOTALL) + occupied: list[tuple[int, int]] = [] + for match in raw_pattern.finditer(source): + literals.append(match.group("body")) + occupied.append(match.span()) + ordinary_pattern = re.compile(r'b?"(?P(?:\\.|[^"\\])*)"', re.DOTALL) + for match in ordinary_pattern.finditer(source): + if any(start <= match.start() < end for start, end in occupied): + continue + literals.append(match.group("body")) + return literals + + +def cargo_feature_names(source: str) -> list[str]: + names: list[str] = [] + in_features = False + for raw_line in source.splitlines(): + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + in_features = line == "[features]" + continue + if not in_features or not line or line.startswith("#") or "=" not in line: + continue + names.append(line.split("=", 1)[0].strip().strip('"')) + return names + + +def domain_identifier(value: str) -> str | None: + match = DOMAIN_COMPONENT.search(value) + return match.group(0).strip("._:/-") if match else None + + +def domain_word(value: str) -> str | None: + match = DOMAIN_WORD.search(value) + return match.group(0) if match else None + + +def is_production_input(path: Path, crate_root: Path) -> bool: + return bool(set(path.relative_to(crate_root).parts) & set(PRODUCTION_INPUT_DIRECTORIES)) + + +def find_violations(repository_root: Path) -> list[str]: + violations: list[str] = [] + for source in source_files(repository_root): + try: + text = source.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + relative = source.relative_to(repository_root) + crate_root = next( + (repository_root / root for root in SOURCE_ROOTS if source.is_relative_to(repository_root / root)), + None, + ) + inspected = without_cfg_test_items(text) if source.suffix == ".rs" else text + lowered = inspected.lower() + for marker in FORBIDDEN_FIXTURE_IDENTIFIERS: + if marker in lowered: + violations.append(f"{relative}: contains fixture identifier {marker}") + if source.suffix == ".rs": + structure = rust_structure(inspected) + for identifier in FORBIDDEN_RUST_TYPE_IDENTIFIERS: + if re.search(rf"\b{re.escape(identifier)}\b", structure): + violations.append( + f"{relative}: contains fixture Rust type identifier {identifier}" + ) + for literal in rust_string_literals(inspected): + identifier = domain_identifier(literal) + if identifier is not None: + violations.append( + f"{relative}: contains fixture metric/error identifier {identifier}" + ) + elif source.name == "Cargo.toml": + for feature in cargo_feature_names(inspected): + identifier = domain_identifier(feature) + if identifier is not None: + violations.append( + f"{relative}: contains fixture Cargo feature {feature}" + ) + elif crate_root is not None and is_production_input(source, crate_root): + identifier = domain_word(inspected) + if identifier is not None: + violations.append( + f"{relative}: contains fixture production identifier {identifier}" + ) + elif str(relative) in PUBLIC_KERNEL_CONTRACTS: + identifier = domain_identifier(inspected) + if identifier is not None: + violations.append( + f"{relative}: contains fixture production identifier {identifier}" + ) + return sorted(set(violations)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repository-root", type=Path, default=Path(__file__).resolve().parents[3]) + args = parser.parse_args() + violations = find_violations(args.repository_root.resolve()) + if violations: + print("Registry Server source-neutrality check failed:", file=sys.stderr) + print("\n".join(f"- {item}" for item in violations), file=sys.stderr) + return 1 + print("Registry Server source-neutrality check passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/registry-server/scripts/compare-generated-tree.py b/products/registry-server/scripts/compare-generated-tree.py new file mode 100755 index 0000000000..27b2724b1a --- /dev/null +++ b/products/registry-server/scripts/compare-generated-tree.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Compare the frozen Registry Server asset baseline without traversing links.""" + +from __future__ import annotations + +import stat +import sys +from pathlib import Path + + +EXPECTED_PATHS = ( + "generated/manifest/registry-manifest.json", + "generated/metadata/registry.json", + "generated/openapi.json", + "generated/postgres/schema.sql", + "generated/schemas/asset-item.schema.json", + "generated/schemas/asset-placement.schema.json", + "generated/schemas/asset-site.schema.json", + "generated/schemas/inspection-event.schema.json", +) + + +def regular_tree(root: Path) -> dict[str, bytes]: + try: + root_status = root.lstat() + except FileNotFoundError as exc: + raise ValueError(f"missing tree: {root}") from exc + if stat.S_ISLNK(root_status.st_mode) or not stat.S_ISDIR(root_status.st_mode): + raise ValueError(f"tree root must be a real directory: {root}") + + files: dict[str, bytes] = {} + directories = [root] + while directories: + directory = directories.pop() + for child in directory.iterdir(): + status = child.lstat() + relative = child.relative_to(root).as_posix() + if stat.S_ISLNK(status.st_mode): + raise ValueError(f"symbolic link is not permitted: {relative}") + if stat.S_ISDIR(status.st_mode): + directories.append(child) + elif stat.S_ISREG(status.st_mode): + files[relative] = child.read_bytes() + else: + raise ValueError(f"non-regular generated entry is not permitted: {relative}") + return files + + +def compare(baseline: Path, candidate: Path) -> list[str]: + baseline_tree = regular_tree(baseline) + candidate_tree = regular_tree(candidate) + expected = set(EXPECTED_PATHS) + errors: list[str] = [] + for label, tree in (("baseline", baseline_tree), ("candidate", candidate_tree)): + paths = set(tree) + if paths != expected: + missing = sorted(expected - paths) + unexpected = sorted(paths - expected) + if missing: + errors.append(f"{label} is missing expected artifacts: {', '.join(missing)}") + if unexpected: + errors.append(f"{label} has unexpected artifacts: {', '.join(unexpected)}") + for path in EXPECTED_PATHS: + if path in baseline_tree and path in candidate_tree and baseline_tree[path] != candidate_tree[path]: + errors.append(f"generated bytes differ: {path}") + return errors + + +def main(arguments: list[str]) -> int: + if len(arguments) != 2: + print("usage: compare-generated-tree.py BASELINE CANDIDATE", file=sys.stderr) + return 2 + try: + errors = compare(Path(arguments[0]), Path(arguments[1])) + except ValueError as exc: + print(f"generated tree comparison failed: {exc}", file=sys.stderr) + return 1 + if errors: + print("generated tree comparison failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print("generated tree matches the committed asset baseline") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/products/registry-server/scripts/test-adopter-workflow.sh b/products/registry-server/scripts/test-adopter-workflow.sh new file mode 100755 index 0000000000..9f6f72cbd3 --- /dev/null +++ b/products/registry-server/scripts/test-adopter-workflow.sh @@ -0,0 +1,923 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repository_root=$(cd -- "$script_dir/../../.." && pwd) +fixture="$repository_root/products/registry-server/acceptance/asset-site-placement" +baseline="$repository_root/products/registry-server/generated/asset-site-placement" +temporary_root="" +server_pid="" +lock_pid="" +adopter_tls_ca_pem_path="" +adopter_admin_url="" +adopter_migration_role="" +adopter_runtime_role="" +adopter_author_role="" +adopter_databases=() + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf '%s\n' "$1 is required for the adopter workflow." >&2 + exit 2 + fi +} + +cleanup() { + if [[ -n "${lock_pid:-}" ]]; then + kill "$lock_pid" >/dev/null 2>&1 || true + wait "$lock_pid" >/dev/null 2>&1 || true + fi + if [[ -n "${server_pid:-}" ]]; then + kill "$server_pid" >/dev/null 2>&1 || true + wait "$server_pid" >/dev/null 2>&1 || true + fi + for adopter_database_to_drop in "${adopter_databases[@]}"; do + psql "$adopter_admin_url" -v ON_ERROR_STOP=1 -q \ + -c "DROP DATABASE IF EXISTS \"$adopter_database_to_drop\" WITH (FORCE);" >/dev/null 2>&1 || true + done + if [[ -n "${adopter_migration_role:-}" && -n "${adopter_runtime_role:-}" && -n "${adopter_author_role:-}" ]]; then + psql "$adopter_admin_url" -v ON_ERROR_STOP=1 -q \ + -c "DROP ROLE IF EXISTS \"$adopter_author_role\"; DROP ROLE IF EXISTS \"$adopter_runtime_role\"; DROP ROLE IF EXISTS \"$adopter_migration_role\";" >/dev/null 2>&1 || true + fi + case "$temporary_root" in + "$repository_root"/.registry-server-adopter.*) + if [[ -d "$temporary_root" && ! -L "$temporary_root" ]]; then + rm -rf -- "$temporary_root" + fi + ;; + "") ;; + *) + printf '%s\n' 'adopter-workflow temporary directory did not match its validated location' >&2 + return 1 + ;; + esac +} +trap cleanup EXIT HUP INT TERM + +require_command openssl +require_command psql + +if [[ -z "${REGISTRY_SERVER_TEST_DATABASE_URL:-}" ]]; then + printf '%s\n' 'REGISTRY_SERVER_TEST_DATABASE_URL must be set for the adopter workflow.' >&2 + exit 2 +fi +if [[ -z "${REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH:-}" ]]; then + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH must be set for the adopter workflow after the PostgreSQL TLS proof.' >&2 + exit 2 +fi +adopter_tls_ca_pem_path=$REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH +case "$adopter_tls_ca_pem_path" in + /*) ;; + *) + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH must be an absolute file path.' >&2 + exit 2 + ;; +esac +case "$adopter_tls_ca_pem_path" in + *$'\n'* | */../* | */..) + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH must be a lexical file path without parent traversal.' >&2 + exit 2 + ;; +esac +if [[ -L "$adopter_tls_ca_pem_path" || ! -f "$adopter_tls_ca_pem_path" ]]; then + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH must name an existing regular file.' >&2 + exit 2 +fi +if [[ ! -s "$adopter_tls_ca_pem_path" ]]; then + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH must not be empty.' >&2 + exit 2 +fi +ca_pem_bytes=$(wc -c <"$adopter_tls_ca_pem_path") +if [[ "$ca_pem_bytes" -gt 1048576 ]]; then + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH exceeds the 1 MiB CA bound.' >&2 + exit 2 +fi +openssl x509 -in "$adopter_tls_ca_pem_path" -noout >/dev/null 2>&1 || { + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH must contain a PEM certificate.' >&2 + exit 2 +} +umask 077 +temporary_root=$(mktemp -d "$repository_root/.registry-server-adopter.XXXXXX") +export CARGO_INCREMENTAL=0 +export CARGO_PROFILE_DEV_DEBUG=0 +export CARGO_PROFILE_TEST_DEBUG=0 +export RUSTC_WRAPPER="${RUSTC_WRAPPER-}" + +registry_serverctl="$repository_root/target/debug/registry-serverctl" +registry_server="$repository_root/target/debug/registry-server" + +sha256_file() { + python3 - "$1" <<'PY' +import hashlib +import sys +with open(sys.argv[1], "rb") as handle: + print(hashlib.sha256(handle.read()).hexdigest()) +PY +} + +json_field() { + python3 - "$1" "$2" <<'PY' +import json +import sys +value = json.load(open(sys.argv[1], encoding="utf-8")) +for part in sys.argv[2].split("."): + value = value[part] +print(value) +PY +} + +write_public_jwk() { + local private_key=$1 + local key_id=$2 + local output=$3 + openssl pkey -in "$private_key" -pubout -outform DER >"$output.der" + python3 - "$output.der" "$key_id" "$output" <<'PY' +import base64 +import json +import sys +from pathlib import Path +der = Path(sys.argv[1]).read_bytes() +if len(der) < 32: + raise SystemExit("public key document is outside the expected Ed25519 bound") +x = base64.urlsafe_b64encode(der[-32:]).rstrip(b"=").decode("ascii") +jwk = {"alg": "EdDSA", "crv": "Ed25519", "kid": sys.argv[2], "kty": "OKP", "x": x} +Path(sys.argv[3]).write_text(json.dumps(jwk, sort_keys=True, separators=(",", ":")), encoding="utf-8") +PY + rm -f -- "$output.der" +} + +write_trust_anchor() { + local public_jwk=$1 + local output=$2 + python3 - "$public_jwk" "$output" <<'PY' +import json +import sys +jwk = json.load(open(sys.argv[1], encoding="utf-8")) +anchor = { + "apiVersion": "registry.registrystack.org/package-trust/v1", + "databaseId": "asset-site-placement-adopter-db", + "environment": "acceptance", + "instanceId": "asset-site-placement-acceptance", + "keys": [{"jwk": jwk, "keyId": jwk["kid"]}], + "threshold": 1, +} +open(sys.argv[2], "w", encoding="utf-8").write(json.dumps(anchor, sort_keys=True, separators=(",", ":"))) +PY +} + +sign_file_hex() { + local private_key=$1 + local input=$2 + local output=$3 + openssl pkeyutl -sign -rawin -inkey "$private_key" -in "$input" -out "$output.bin" + python3 - "$output.bin" "$output" <<'PY' +import sys +from pathlib import Path +Path(sys.argv[2]).write_text(Path(sys.argv[1]).read_bytes().hex(), encoding="utf-8") +PY + rm -f -- "$output.bin" +} + +write_signature_document() { + local key_id=$1 + local signature_hex=$2 + local output=$3 + python3 - "$key_id" "$signature_hex" "$output" <<'PY' +import json +import sys +document = {"signatures": [{"keyId": sys.argv[1], "signatureHex": open(sys.argv[2], encoding="utf-8").read()}]} +open(sys.argv[3], "w", encoding="utf-8").write(json.dumps(document, sort_keys=True, separators=(",", ":"))) +PY +} + +write_jwks() { + local public_jwk=$1 + local output=$2 + python3 - "$public_jwk" "$output" <<'PY' +import json +import sys +jwk = json.load(open(sys.argv[1], encoding="utf-8")) +open(sys.argv[2], "w", encoding="utf-8").write(json.dumps({"keys": [jwk]}, sort_keys=True, separators=(",", ":"))) +PY +} + +write_jwt() { + local private_key=$1 + local key_id=$2 + local principal=$3 + local purpose=$4 + local output=$5 + python3 - "$key_id" "$principal" "$purpose" "$output.signing-input" <<'PY' +import base64 +import json +import sys +import time + +def b64(value): + return base64.urlsafe_b64encode(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).rstrip(b"=").decode("ascii") + +now = int(time.time()) +claims = { + "aud": "urn:registry-server:adopter", + "client_id": "registry-adopter-client", + "exp": now + 3600, + "iat": now, + "iss": "https://issuer.example/adopter", + "jti": f"adopter-{now}-{sys.argv[2]}-{sys.argv[3] or 'none'}", + "registry_principal": sys.argv[2], + "sub": sys.argv[2], +} +if sys.argv[3]: + claims["registry_purpose"] = sys.argv[3] +header = {"alg": "EdDSA", "kid": sys.argv[1], "typ": "JWT"} +open(sys.argv[4], "w", encoding="ascii").write(f"{b64(header)}.{b64(claims)}") +PY + openssl pkeyutl -sign -rawin -inkey "$private_key" -in "$output.signing-input" -out "$output.signature" + python3 - "$output.signing-input" "$output.signature" "$output" <<'PY' +import base64 +import sys +from pathlib import Path +signing_input = Path(sys.argv[1]).read_text(encoding="ascii") +signature = base64.urlsafe_b64encode(Path(sys.argv[2]).read_bytes()).rstrip(b"=").decode("ascii") +Path(sys.argv[3]).write_text(f"{signing_input}.{signature}", encoding="ascii") +PY + rm -f -- "$output.signing-input" "$output.signature" +} + +render_runtime_config() { + local output=$1 + local package_root=$2 + local active_revision=$3 + local active_sequence=$4 + local statement_timeout_ms=$5 + local runtime_ref=$6 + local migration_ref=$7 + local listener=$8 + local compiler_source_revision=$9 + cat >"$output" <"$output"; then + return 0 + else + status=$? + fi + python3 - "$output" "$command" <<'PY' +import json +import sys +try: + document = json.load(open(sys.argv[1], encoding="utf-8")) + codes = sorted({str(item.get("code")) for item in document.get("diagnostics", []) if item.get("code")}) +except Exception: + codes = [] +summary = ", ".join(codes) if codes else "unavailable" +print(f"registry-serverctl {sys.argv[2]} refused; diagnostics: {summary}", file=sys.stderr) +PY + return "$status" +} + +assert_json_ok() { + python3 - "$1" "$2" <<'PY' +import json +import sys +document = json.load(open(sys.argv[1], encoding="utf-8")) +if document.get("ok") is not True or document.get("command") != sys.argv[2]: + raise SystemExit(f"{sys.argv[2]} did not complete") +PY +} + +assert_json_failure() { + python3 - "$1" "$2" <<'PY' +import json +import sys +document = json.load(open(sys.argv[1], encoding="utf-8")) +if document.get("ok") is not False: + raise SystemExit("expected command refusal") +codes = [diagnostic.get("code") for diagnostic in document.get("diagnostics", [])] +if sys.argv[2] not in codes: + raise SystemExit(f"expected diagnostic {sys.argv[2]}, got {codes}") +PY +} + +wait_ready_status() { + local url=$1 + local expected=$2 + python3 - "$url" "$expected" <<'PY' +import sys +import time +import urllib.error +import urllib.request +url = sys.argv[1] +expected = int(sys.argv[2]) +deadline = time.time() + 30 +last = None +while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=2) as response: + last = response.status + except urllib.error.HTTPError as error: + last = error.code + except Exception: + last = None + if last == expected: + raise SystemExit(0) + time.sleep(0.5) +raise SystemExit(f"readiness did not reach {expected}; last status was {last}") +PY +} + +derive_adopter_urls() { + local database=$1 + python3 - "$REGISTRY_SERVER_TEST_DATABASE_URL" "$database" "$adopter_migration_role" "$adopter_runtime_role" "$adopter_password" <<'PY' +import sys +from urllib.parse import quote, urlsplit, urlunsplit +admin = urlsplit(sys.argv[1]) +database, migration_role, runtime_role, password = sys.argv[2:] +if admin.scheme not in {"postgres", "postgresql"} or not admin.hostname: + raise SystemExit("REGISTRY_SERVER_TEST_DATABASE_URL must be a PostgreSQL URL") +host = admin.hostname +netloc_suffix = host if admin.port is None else f"{host}:{admin.port}" +query = admin.query +def url(role): + userinfo = f"{quote(role, safe='')}:{quote(password, safe='')}" + return urlunsplit((admin.scheme, f"{userinfo}@{netloc_suffix}", f"/{quote(database, safe='')}", query, "")) +print(url(migration_role)) +print(url(runtime_role)) +PY +} + +derive_admin_database_url() { + local database=$1 + python3 - "$REGISTRY_SERVER_TEST_DATABASE_URL" "$database" <<'PY' +import sys +from urllib.parse import quote, urlsplit, urlunsplit +admin = urlsplit(sys.argv[1]) +if admin.scheme not in {"postgres", "postgresql"} or not admin.hostname: + raise SystemExit("REGISTRY_SERVER_TEST_DATABASE_URL must be a PostgreSQL URL") +print(urlunsplit((admin.scheme, admin.netloc, f"/{quote(sys.argv[2], safe='')}", admin.query, ""))) +PY +} + +write_database_url_secrets() { + local database=$1 + local runtime_secret=$2 + local migration_secret=$3 + local urls + local migration_url + local runtime_url + + urls=$(derive_adopter_urls "$database") + migration_url=$(printf '%s\n' "$urls" | sed -n '1p') + runtime_url=$(printf '%s\n' "$urls" | sed -n '2p') + printf '%s' "$runtime_url" >"$temporary_root/secrets/$runtime_secret" + printf '%s' "$migration_url" >"$temporary_root/secrets/$migration_secret" +} + +provision_adopter_database() { + local database=$1 + local admin_database_url + admin_database_url=$(derive_admin_database_url "$database") + psql "$adopter_admin_url" -v ON_ERROR_STOP=1 -q \ + -c "CREATE DATABASE \"$database\";" + psql "$admin_database_url" -v ON_ERROR_STOP=1 -q \ + -c "CREATE EXTENSION IF NOT EXISTS btree_gist;" \ + -c "REVOKE ALL ON DATABASE \"$database\" FROM PUBLIC;" \ + -c "GRANT CONNECT ON DATABASE \"$database\" TO \"$adopter_migration_role\", \"$adopter_runtime_role\", \"$adopter_author_role\";" \ + -c "CREATE SCHEMA registry_internal AUTHORIZATION \"$adopter_migration_role\";" \ + -c "CREATE SCHEMA registry_data AUTHORIZATION \"$adopter_migration_role\";" \ + -c "REVOKE ALL ON SCHEMA registry_internal, registry_data FROM PUBLIC;" >/dev/null +} + +select_free_listener() { + python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(f"127.0.0.1:{sock.getsockname()[1]}") +PY +} + +entity_list_path() { + local package_root=$1 + local entity=$2 + local profile=$3 + python3 - "$package_root/inventories/routes.json" "$entity" "$profile" <<'PY' +import json +import sys +routes = json.load(open(sys.argv[1], encoding="utf-8"))["routes"] +for route in routes: + if ( + route.get("entityId") == sys.argv[2] + and route.get("operation") == "list" + and route.get("method") == "GET" + and route.get("queryKind") == "list" + and sys.argv[3] in route.get("accessProfiles", []) + ): + print(route["path"]) + raise SystemExit(0) +raise SystemExit("list route was not found") +PY +} + +http_get_json() { + local base_url=$1 + local token_file=$2 + local path_and_query=$3 + local output=$4 + python3 - "$base_url" "$token_file" "$path_and_query" "$output" <<'PY' +import json +import sys +import urllib.parse +import urllib.request +from pathlib import Path +base_url, token_file, path_and_query, output = sys.argv[1:] +token = Path(token_file).read_text(encoding="ascii").strip() +url = urllib.parse.urljoin(base_url, path_and_query.lstrip("/")) +request = urllib.request.Request( + url, + headers={"Accept": "application/json", "Authorization": f"Bearer {token}"}, +) +with urllib.request.urlopen(request, timeout=10) as response: + body = response.read() + if response.status != 200: + raise SystemExit(f"GET {path_and_query} returned {response.status}") +json.loads(body) +Path(output).write_bytes(body) +PY +} + +cargo build --manifest-path "$repository_root/Cargo.toml" --locked \ + -p registry-serverctl \ + -p registry-server \ + --features registry-server/runtime +export SSL_CERT_FILE="$adopter_tls_ca_pem_path" + +server_hash_before=$(sha256_file "$registry_server") + +mkdir -p "$temporary_root/secrets" "$temporary_root/empty-package-root" +chmod 700 "$temporary_root/secrets" +printf '%s' '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' >"$temporary_root/secrets/audit-key" +printf '%s' 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789' >"$temporary_root/secrets/cursor-key" + +adopter_suffix="rsadopter$(date +%s)$$" +adopter_schema_test_v1_database="rs_test_v1_${adopter_suffix}" +adopter_schema_test_v2_database="rs_test_v2_${adopter_suffix}" +adopter_production_database="rs_prod_${adopter_suffix}" +adopter_databases=("$adopter_schema_test_v1_database" "$adopter_schema_test_v2_database" "$adopter_production_database") +adopter_migration_role="rs_migration_${adopter_suffix}" +adopter_runtime_role="rs_runtime_${adopter_suffix}" +adopter_author_role="rs_author_${adopter_suffix}" +adopter_password="$(python3 - <<'PY' +import secrets +print(secrets.token_hex(18)) +PY +)" +adopter_admin_url=$REGISTRY_SERVER_TEST_DATABASE_URL + +psql "$adopter_admin_url" -v ON_ERROR_STOP=1 -q \ + -c "CREATE ROLE \"$adopter_migration_role\" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '$adopter_password';" \ + -c "CREATE ROLE \"$adopter_runtime_role\" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '$adopter_password';" \ + -c "CREATE ROLE \"$adopter_author_role\" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '$adopter_password';" + +for adopter_database in "${adopter_databases[@]}"; do + provision_adopter_database "$adopter_database" +done + +adopter_urls=$(derive_adopter_urls "$adopter_production_database") +adopter_migration_url=$(printf '%s\n' "$adopter_urls" | sed -n '1p') +adopter_runtime_url=$(printf '%s\n' "$adopter_urls" | sed -n '2p') +adopter_production_admin_url=$(derive_admin_database_url "$adopter_production_database") +printf '%s' "$adopter_runtime_url" >"$temporary_root/secrets/production-runtime-url" +printf '%s' "$adopter_migration_url" >"$temporary_root/secrets/production-migration-url" +write_database_url_secrets "$adopter_schema_test_v1_database" schema-test-v1-runtime-url schema-test-v1-migration-url +write_database_url_secrets "$adopter_schema_test_v2_database" schema-test-v2-runtime-url schema-test-v2-migration-url + +openssl genpkey -algorithm ED25519 -out "$temporary_root/package-signer.pem" >/dev/null 2>&1 +openssl genpkey -algorithm ED25519 -out "$temporary_root/oidc-signer.pem" >/dev/null 2>&1 +chmod 600 "$temporary_root/package-signer.pem" "$temporary_root/oidc-signer.pem" +write_public_jwk "$temporary_root/package-signer.pem" "adopter-package-key" "$temporary_root/package-signer.public.jwk" +write_public_jwk "$temporary_root/oidc-signer.pem" "adopter-oidc-key" "$temporary_root/oidc-signer.public.jwk" +write_trust_anchor "$temporary_root/package-signer.public.jwk" "$temporary_root/package-trust-anchor.json" +write_jwks "$temporary_root/oidc-signer.public.jwk" "$temporary_root/secrets/oidc-jwks" + +write_jwt "$temporary_root/oidc-signer.pem" "adopter-oidc-key" "synthetic-asset-operator" "asset-management" "$temporary_root/secrets/operator-token" +write_jwt "$temporary_root/oidc-signer.pem" "adopter-oidc-key" "synthetic-site-planner" "site-planning" "$temporary_root/secrets/planner-token" +write_jwt "$temporary_root/oidc-signer.pem" "adopter-oidc-key" "synthetic-site-planner" "" "$temporary_root/secrets/planner-no-purpose-token" + +cat >"$temporary_root/schema-test-credentials.yaml" <<'EOF' +apiVersion: registry.registrystack.org/server-schema-test-credentials/v1 +kind: SchemaTestCredentials +bindings: + - {journeyId: asset-and-site-caller-surfaces, stepId: create-asset, credential: {type: bearer, tokenRef: secret:file/operator-token}} + - {journeyId: asset-and-site-caller-surfaces, stepId: planner-gets-asset, credential: {type: bearer, tokenRef: secret:file/planner-token}} + - {journeyId: asset-and-site-caller-surfaces, stepId: planner-lists-assets, credential: {type: bearer, tokenRef: secret:file/planner-token}} + - {journeyId: asset-and-site-caller-surfaces, stepId: operator-renames-asset, credential: {type: bearer, tokenRef: secret:file/operator-token}} + - {journeyId: asset-and-site-caller-surfaces, stepId: create-site, credential: {type: bearer, tokenRef: secret:file/operator-token}} + - {journeyId: asset-and-site-caller-surfaces, stepId: planner-gets-site, credential: {type: bearer, tokenRef: secret:file/planner-token}} + - {journeyId: asset-and-site-caller-surfaces, stepId: planner-lists-sites, credential: {type: bearer, tokenRef: secret:file/planner-token}} + - {journeyId: asset-and-site-caller-surfaces, stepId: planner-without-purpose-is-concealed, credential: {type: bearer, tokenRef: secret:file/planner-no-purpose-token}} +EOF + +render_runtime_config "$temporary_root/runtime-test-v1.yaml" "$temporary_root/empty-package-root" \ + "sha256:1111111111111111111111111111111111111111111111111111111111111111" 1 60000 \ + "secret:file/schema-test-v1-runtime-url" "secret:file/schema-test-v1-migration-url" \ + "127.0.0.1:0" "asset-site-placement-acceptance-0.1.0" +render_runtime_config "$temporary_root/runtime-author-v1.yaml" "$temporary_root/empty-package-root" \ + "sha256:1111111111111111111111111111111111111111111111111111111111111111" 1 60000 \ + "secret:file/production-runtime-url" "secret:file/missing-migration-url" \ + "127.0.0.1:0" "asset-site-placement-acceptance-0.1.0" + +"$registry_serverctl" check "$fixture" +run_json "$temporary_root/production-check.json" check "$fixture" --production +assert_json_ok "$temporary_root/production-check.json" check +run_json "$temporary_root/access.json" explain access "$fixture" +assert_json_ok "$temporary_root/access.json" explain +python3 - "$temporary_root/production-check.json" "$temporary_root/access.json" <<'PY' +import json +import sys + +production = json.load(open(sys.argv[1], encoding="utf-8")) +if production.get("profile") != "production": + raise SystemExit("production profile did not accept the complete fixture closure") + +access = json.load(open(sys.argv[2], encoding="utf-8")) +entries = access.get("explanation", {}).get("entries", []) +by_entity_operation = {} +for entry in entries: + key = (entry.get("entityId"), entry.get("operation")) + by_entity_operation[key] = set(entry.get("profileIds", [])) +for operation in ("create", "get", "list", "patch"): + if by_entity_operation.get(("asset-placement", operation)) != {"asset-operator", "site-planner"}: + raise SystemExit(f"asset placement {operation} did not compile both expected access profiles") +for operation in ("create", "get", "list"): + if by_entity_operation.get(("inspection-event", operation)) != {"asset-operator"}: + raise SystemExit("inspection events must remain operator-only in the fixture") +PY + +( + cd "$temporary_root" + mkdir ./generated + for selector in openapi schemas manifest metadata sql; do + "$registry_serverctl" generate "$selector" "$fixture" --output "./$selector" + cp -R "./$selector/." ./generated + done +) +python3 "$script_dir/compare-generated-tree.py" "$baseline" "$temporary_root/generated" + +run_json "$temporary_root/schema-test-v1.json" test "$fixture" \ + --runtime-config "$temporary_root/runtime-test-v1.yaml" \ + --credentials "$temporary_root/schema-test-credentials.yaml" \ + --database-id asset-site-placement-adopter-db \ + --signature-threshold 1 \ + --signature-key-id adopter-package-key \ + --output "$temporary_root/schema-test-receipt-v1.json" +assert_json_ok "$temporary_root/schema-test-v1.json" test +schema_fingerprint_v1=$(json_field "$temporary_root/schema-test-v1.json" schemaFingerprint) + +run_json "$temporary_root/package-v1-awaiting.json" package "$fixture" \ + --database-id asset-site-placement-adopter-db \ + --schema-fingerprint "$schema_fingerprint_v1" \ + --test-receipt "$temporary_root/schema-test-receipt-v1.json" \ + --signature-threshold 1 \ + --signature-key-id adopter-package-key \ + --output "$temporary_root/build-v1" +assert_json_ok "$temporary_root/package-v1-awaiting.json" package +if [[ "$(json_field "$temporary_root/package-v1-awaiting.json" state)" != "awaiting_signatures" ]]; then + printf '%s\n' 'initial package did not stop at the external-signature boundary.' >&2 + exit 1 +fi +sign_file_hex "$temporary_root/package-signer.pem" "$temporary_root/build-v1/signing-input.json" "$temporary_root/package-v1.sighex" +write_signature_document "adopter-package-key" "$temporary_root/package-v1.sighex" "$temporary_root/package-v1-signatures.json" +run_json "$temporary_root/package-v1-published.json" package "$fixture" \ + --database-id asset-site-placement-adopter-db \ + --schema-fingerprint "$schema_fingerprint_v1" \ + --test-receipt "$temporary_root/schema-test-receipt-v1.json" \ + --signature-threshold 1 \ + --signature-key-id adopter-package-key \ + --signatures "$temporary_root/package-v1-signatures.json" \ + --output "$temporary_root/build-v1" +assert_json_ok "$temporary_root/package-v1-published.json" package +package_revision_v1=$(json_field "$temporary_root/package-v1-published.json" packageRevision) + +render_runtime_config "$temporary_root/runtime-operator-v1.yaml" "$temporary_root/build-v1/package" \ + "$package_revision_v1" 1 60000 "secret:file/production-runtime-url" \ + "secret:file/production-migration-url" "127.0.0.1:0" \ + "asset-site-placement-acceptance-0.1.0" +render_runtime_config "$temporary_root/runtime-author-v1.yaml" "$temporary_root/build-v1/package" \ + "$package_revision_v1" 1 60000 "secret:file/production-runtime-url" \ + "secret:file/missing-migration-url" "127.0.0.1:0" \ + "asset-site-placement-acceptance-0.1.0" + +if run_json "$temporary_root/author-apply-v1.json" apply --runtime-config "$temporary_root/runtime-author-v1.yaml" --package "$temporary_root/build-v1/package" --initial; then + printf '%s\n' 'author runtime unexpectedly applied a production package.' >&2 + exit 1 +fi +assert_json_failure "$temporary_root/author-apply-v1.json" apply.database_configuration.refused +if [[ "$(psql "$adopter_production_admin_url" -Atqc "SELECT to_regclass('registry_internal.registry_state') IS NULL")" != "t" ]]; then + printf '%s\n' 'author refusal changed the production database state.' >&2 + exit 1 +fi + +run_json "$temporary_root/apply-v1.json" apply --runtime-config "$temporary_root/runtime-operator-v1.yaml" --package "$temporary_root/build-v1/package" --initial +assert_json_ok "$temporary_root/apply-v1.json" apply +run_json "$temporary_root/verify-v1.json" verify --runtime-config "$temporary_root/runtime-operator-v1.yaml" +assert_json_ok "$temporary_root/verify-v1.json" verify + +listener=$(select_free_listener) +server_url="http://$listener/" +render_runtime_config "$temporary_root/runtime-server-v1.yaml" "$temporary_root/build-v1/package" \ + "$package_revision_v1" 1 60000 "secret:file/production-runtime-url" \ + "secret:file/production-migration-url" "$listener" \ + "asset-site-placement-acceptance-0.1.0" +REGISTRY_SERVER_LOG=error "$registry_server" --config "$temporary_root/runtime-server-v1.yaml" >"$temporary_root/server-v1.log" 2>&1 & +server_pid=$! +wait_ready_status "${server_url}ready" 200 + +cat >"$temporary_root/assets-create.jsonl" <<'EOF' +{"operation":"create","data":{"asset-code":"ASSET-PUBLIC-001","label":"Synthetic public workflow asset","asset-class":"equipment"}} +EOF +run_json "$temporary_root/data-validate-v1.json" data validate \ + --package "$temporary_root/build-v1/package" \ + --entity asset-item \ + --profile asset-operator \ + --operation create \ + --input "$temporary_root/assets-create.jsonl" +assert_json_ok "$temporary_root/data-validate-v1.json" "data validate" +run_json "$temporary_root/data-import-v1.json" data import \ + --package "$temporary_root/build-v1/package" \ + --server-url "$server_url" \ + --access-token-file "$temporary_root/secrets/operator-token" \ + --entity asset-item \ + --profile asset-operator \ + --operation create \ + --input "$temporary_root/assets-create.jsonl" \ + --checkpoint "$temporary_root/assets-import.checkpoint.json" +assert_json_ok "$temporary_root/data-import-v1.json" "data import" +asset_list_path_v1=$(entity_list_path "$temporary_root/build-v1/package" asset-item asset-operator) +http_get_json "$server_url" "$temporary_root/secrets/operator-token" \ + "$asset_list_path_v1?accessProfile=asset-operator" "$temporary_root/assets-list-v1.json" +python3 - "$temporary_root/assets-list-v1.json" <<'PY' +import json +import sys +document = json.load(open(sys.argv[1], encoding="utf-8")) +items = document.get("items", []) +if not any(item.get("data", {}).get("asset-code") == "ASSET-PUBLIC-001" for item in items): + raise SystemExit("authorized public data read did not include the created record") +PY + +cp -R "$fixture" "$temporary_root/project-v2" +python3 - "$temporary_root/project-v2/registry.yaml" <<'PY' +import sys +from pathlib import Path +path = Path(sys.argv[1]) +source = path.read_text(encoding="utf-8") +source = source.replace(" sequence: 1\n", " sequence: 2\n", 1) +needle = " - {id: label, type: string, required: true, maxLength: 200, classification: internal}\n" +replacement = needle + " - {id: placement-review-note, type: string, required: false, maxLength: 120, classification: restricted}\n" +if needle not in source: + raise SystemExit("asset item field insertion point was not found") +path.write_text(source.replace(needle, replacement, 1), encoding="utf-8") +PY +render_runtime_config "$temporary_root/runtime-test-v2.yaml" "$temporary_root/build-v1/package" \ + "$package_revision_v1" 1 60000 "secret:file/schema-test-v2-runtime-url" \ + "secret:file/schema-test-v2-migration-url" "127.0.0.1:0" \ + "asset-site-placement-acceptance-0.1.0" + +run_json "$temporary_root/diff-v2.json" diff "$temporary_root/project-v2" --runtime-config "$temporary_root/runtime-operator-v1.yaml" +assert_json_ok "$temporary_root/diff-v2.json" diff +python3 - "$temporary_root/diff-v2.json" <<'PY' +import json +import sys +report = json.load(open(sys.argv[1], encoding="utf-8")) +changes = report.get("changes", []) +change = changes[0].get("change", {}) if len(changes) == 1 else {} +if ( + len(changes) != 1 + or changes[0].get("classification") != "compatible_additive" + or change.get("code") != "field_added_optional" + or change.get("class") != "compatible_additive" +): + raise SystemExit(f"successor was not the expected additive field change: {changes}") +PY +run_json "$temporary_root/schema-test-v2.json" test "$temporary_root/project-v2" \ + --runtime-config "$temporary_root/runtime-test-v2.yaml" \ + --credentials "$temporary_root/schema-test-credentials.yaml" \ + --database-id asset-site-placement-adopter-db \ + --baseline-runtime-config "$temporary_root/runtime-operator-v1.yaml" \ + --signature-threshold 1 \ + --signature-key-id adopter-package-key \ + --output "$temporary_root/schema-test-receipt-v2.json" +assert_json_ok "$temporary_root/schema-test-v2.json" test +schema_fingerprint_v2=$(json_field "$temporary_root/schema-test-v2.json" schemaFingerprint) + +run_json "$temporary_root/package-v2-awaiting.json" package "$temporary_root/project-v2" \ + --database-id asset-site-placement-adopter-db \ + --baseline-runtime-config "$temporary_root/runtime-operator-v1.yaml" \ + --schema-fingerprint "$schema_fingerprint_v2" \ + --test-receipt "$temporary_root/schema-test-receipt-v2.json" \ + --signature-threshold 1 \ + --signature-key-id adopter-package-key \ + --output "$temporary_root/build-v2" +assert_json_ok "$temporary_root/package-v2-awaiting.json" package +sign_file_hex "$temporary_root/package-signer.pem" "$temporary_root/build-v2/signing-input.json" "$temporary_root/package-v2.sighex" +write_signature_document "adopter-package-key" "$temporary_root/package-v2.sighex" "$temporary_root/package-v2-signatures.json" +run_json "$temporary_root/package-v2-published.json" package "$temporary_root/project-v2" \ + --database-id asset-site-placement-adopter-db \ + --baseline-runtime-config "$temporary_root/runtime-operator-v1.yaml" \ + --schema-fingerprint "$schema_fingerprint_v2" \ + --test-receipt "$temporary_root/schema-test-receipt-v2.json" \ + --signature-threshold 1 \ + --signature-key-id adopter-package-key \ + --signatures "$temporary_root/package-v2-signatures.json" \ + --output "$temporary_root/build-v2" +assert_json_ok "$temporary_root/package-v2-published.json" package +package_revision_v2=$(json_field "$temporary_root/package-v2-published.json" packageRevision) + +render_runtime_config "$temporary_root/runtime-operator-v2-fast-timeout.yaml" "$temporary_root/build-v1/package" \ + "$package_revision_v1" 1 1000 "secret:file/production-runtime-url" \ + "secret:file/production-migration-url" "127.0.0.1:0" \ + "asset-site-placement-acceptance-0.1.0" +asset_table=$(python3 - "$temporary_root/build-v1/package/inventories/physical-names.json" <<'PY' +import json +import sys +document = json.load(open(sys.argv[1], encoding="utf-8")) +print(document["entities"]["asset-item"]["table"]) +PY +) +psql "$adopter_migration_url" -v ON_ERROR_STOP=1 -q \ + -c "BEGIN; LOCK TABLE registry_data.\"$asset_table\" IN ACCESS SHARE MODE; SELECT pg_sleep(120);" >/dev/null & +lock_pid=$! +python3 - "$adopter_admin_url" "$adopter_production_database" "$asset_table" "$temporary_root/lock-backend-pid" <<'PY' +import subprocess +import sys +import time +from pathlib import Path +from urllib.parse import quote, urlsplit, urlunsplit +admin, database, table, pid_output = sys.argv[1:] +parsed_admin = urlsplit(admin) +database_url = urlunsplit( + (parsed_admin.scheme, parsed_admin.netloc, f"/{quote(database, safe='')}", parsed_admin.query, "") +) +deadline = time.time() + 20 +while time.time() < deadline: + sql = ( + "SELECT l.pid FROM pg_locks l " + "JOIN pg_class c ON c.oid = l.relation " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + f"WHERE n.nspname = 'registry_data' AND c.relname = '{table}' " + "AND l.mode = 'AccessShareLock' AND l.granted LIMIT 1" + ) + result = subprocess.run(["psql", database_url, "-Atqc", sql], text=True, capture_output=True) + if result.stdout.strip().isdigit(): + Path(pid_output).write_text(result.stdout.strip(), encoding="ascii") + raise SystemExit(0) + time.sleep(0.25) +raise SystemExit("table lock was not acquired") +PY +if run_json "$temporary_root/apply-v2-locked.json" apply --runtime-config "$temporary_root/runtime-operator-v2-fast-timeout.yaml" --package "$temporary_root/build-v2/package"; then + printf '%s\n' 'successor apply unexpectedly succeeded while the managed table was locked.' >&2 + exit 1 +fi +assert_json_failure "$temporary_root/apply-v2-locked.json" apply.migration.failed +wait_ready_status "${server_url}ready" 503 +python3 - "$adopter_admin_url" "$adopter_production_database" "$package_revision_v2" <<'PY' +import subprocess +import sys +from urllib.parse import quote, urlsplit, urlunsplit +admin, database, target = sys.argv[1:] +parsed_admin = urlsplit(admin) +database_url = urlunsplit( + (parsed_admin.scheme, parsed_admin.netloc, f"/{quote(database, safe='')}", parsed_admin.query, "") +) +state = subprocess.check_output([ + "psql", database_url, "-Atqc", + "SELECT maintenance_status || ' ' || maintenance_target_revision FROM registry_internal.registry_state" +], text=True).strip() +if state != f"failed {target}": + raise SystemExit(f"unexpected maintenance state: {state}") +ledger = subprocess.check_output([ + "psql", database_url, "-Atqc", + f"SELECT outcome FROM registry_internal.registry_migrations WHERE target_package_revision = '{target}'" +], text=True).strip() +if ledger != "failed": + raise SystemExit(f"unexpected migration ledger outcome: {ledger}") +PY +lock_backend_pid=$(<"$temporary_root/lock-backend-pid") +if [[ ! "$lock_backend_pid" =~ ^[0-9]+$ ]] \ + || [[ "$(psql "$adopter_production_admin_url" -Atqc "SELECT pg_terminate_backend($lock_backend_pid)")" != "t" ]]; then + printf '%s\n' 'external migration blocker could not be released exactly.' >&2 + exit 1 +fi +wait "$lock_pid" >/dev/null 2>&1 || true +lock_pid="" + +render_runtime_config "$temporary_root/runtime-operator-v2-activation.yaml" "$temporary_root/build-v1/package" \ + "$package_revision_v1" 1 60000 "secret:file/production-runtime-url" \ + "secret:file/production-migration-url" "127.0.0.1:0" \ + "asset-site-placement-acceptance-0.1.0" +run_json "$temporary_root/apply-v2.json" apply --runtime-config "$temporary_root/runtime-operator-v2-activation.yaml" --package "$temporary_root/build-v2/package" +assert_json_ok "$temporary_root/apply-v2.json" apply + +kill "$server_pid" >/dev/null 2>&1 || true +wait "$server_pid" >/dev/null 2>&1 || true +server_pid="" +render_runtime_config "$temporary_root/runtime-server-v2.yaml" "$temporary_root/build-v2/package" \ + "$package_revision_v2" 2 60000 "secret:file/production-runtime-url" \ + "secret:file/production-migration-url" "$listener" \ + "asset-site-placement-acceptance-0.1.0" +REGISTRY_SERVER_LOG=error "$registry_server" --config "$temporary_root/runtime-server-v2.yaml" >"$temporary_root/server-v2.log" 2>&1 & +server_pid=$! +wait_ready_status "${server_url}ready" 200 + +asset_list_path_v2=$(entity_list_path "$temporary_root/build-v2/package" asset-item asset-operator) +http_get_json "$server_url" "$temporary_root/secrets/operator-token" \ + "$asset_list_path_v2?accessProfile=asset-operator" "$temporary_root/assets-list-v2.json" +python3 - "$temporary_root/assets-list-v2.json" <<'PY' +import json +import sys +document = json.load(open(sys.argv[1], encoding="utf-8")) +matching = [item for item in document.get("items", []) if item.get("data", {}).get("asset-code") == "ASSET-PUBLIC-001"] +if not matching: + raise SystemExit("created record did not survive successor activation") +if any("placement-review-note" in item.get("data", {}) for item in matching): + raise SystemExit("restricted successor field was disclosed") +PY + +server_hash_after=$(sha256_file "$registry_server") +if [[ "$server_hash_before" != "$server_hash_after" ]]; then + printf '%s\n' 'registry-server binary changed during the adopter workflow.' >&2 + exit 1 +fi + +printf '%s\n' 'Registry Server clean adopter workflow passed' diff --git a/products/registry-server/scripts/test-postgres-tls.sh b/products/registry-server/scripts/test-postgres-tls.sh new file mode 100755 index 0000000000..f5b8c5acbb --- /dev/null +++ b/products/registry-server/scripts/test-postgres-tls.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -euo pipefail + +require_env() { + local name=$1 + if [[ -z "${!name:-}" ]]; then + printf '%s\n' "$name must be set for the PostgreSQL TLS proof." >&2 + exit 2 + fi +} + +require_env REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID +require_env REGISTRY_SERVER_TEST_TLS_DATABASE_URL +require_env REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL +require_env REGISTRY_SERVER_TEST_TLS_DATABASE_HOST + +postgres_container_id=$REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID +database_url=$REGISTRY_SERVER_TEST_TLS_DATABASE_URL +hostname_mismatch_database_url=$REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL +database_host=$REGISTRY_SERVER_TEST_TLS_DATABASE_HOST +caller_ca_der_path=${REGISTRY_SERVER_TEST_TLS_CA_DER_PATH:-} +caller_ca_pem_path=${REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH:-} + +validate_caller_output_path() { + local name=$1 + local path=$2 + local parent + local parent_real + + [[ -z "$path" ]] && return 0 + case "$path" in + /*) ;; + *) + printf '%s\n' "$name must be an absolute caller-owned file path." >&2 + exit 2 + ;; + esac + case "$path" in + *$'\n'* | */../* | */..) + printf '%s\n' "$name must be a lexical file path without parent traversal." >&2 + exit 2 + ;; + esac + parent=$(dirname -- "$path") + if [[ ! -d "$parent" || -L "$parent" ]]; then + printf '%s\n' "$name parent must be an existing non-symlink directory." >&2 + exit 2 + fi + parent_real=$(cd -- "$parent" && pwd -P) + case "$parent_real" in + / | /tmp | /private/tmp | /var/tmp | /private/var/tmp) + printf '%s\n' "$name parent must not be a broad shared temporary directory." >&2 + exit 2 + ;; + esac + if [[ -L "$path" || ( -e "$path" && ! -f "$path" ) ]]; then + printf '%s\n' "$name must be a regular file target, not a symlink or directory." >&2 + exit 2 + fi +} + +if [[ ! "$postgres_container_id" =~ ^[0-9a-f]{12,64}$ ]]; then + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID must be a Docker container ID.' >&2 + exit 2 +fi +if [[ ! "$database_host" =~ ^[A-Za-z0-9.-]+$ ]]; then + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_DATABASE_HOST must be a DNS hostname.' >&2 + exit 2 +fi +case "$database_url" in + *"@$database_host:"* | *"@$database_host/"*) ;; + *) + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_DATABASE_URL must use REGISTRY_SERVER_TEST_TLS_DATABASE_HOST.' >&2 + exit 2 + ;; +esac +case "$hostname_mismatch_database_url" in + *"@127.0.0.1:"* | *"@127.0.0.1/"*) ;; + *) + printf '%s\n' 'REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL must use 127.0.0.1.' >&2 + exit 2 + ;; +esac +validate_caller_output_path REGISTRY_SERVER_TEST_TLS_CA_DER_PATH "$caller_ca_der_path" +validate_caller_output_path REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH "$caller_ca_pem_path" + +tls_dir=$(mktemp -d /tmp/registry-server-postgres-tls.XXXXXX) +cleanup() { + case "${tls_dir:-}" in + /tmp/registry-server-postgres-tls.*) + if [[ -d "$tls_dir" && "$(basename -- "$tls_dir")" == registry-server-postgres-tls.* ]]; then + rm -rf -- "$tls_dir" + fi + ;; + esac +} +trap cleanup EXIT +umask 077 + +openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 2 \ + -subj '/CN=Registry Server PostgreSQL TLS test CA' \ + -keyout "$tls_dir/trusted-ca.key" -out "$tls_dir/trusted-ca.pem" >/dev/null 2>&1 +openssl req -new -nodes -newkey rsa:2048 \ + -subj "/CN=$database_host" \ + -keyout "$tls_dir/server.key" -out "$tls_dir/server.csr" >/dev/null 2>&1 +printf 'subjectAltName=DNS:%s\n' "$database_host" >"$tls_dir/server.ext" +openssl x509 -req -sha256 -days 2 \ + -in "$tls_dir/server.csr" \ + -CA "$tls_dir/trusted-ca.pem" \ + -CAkey "$tls_dir/trusted-ca.key" \ + -CAcreateserial \ + -extfile "$tls_dir/server.ext" \ + -out "$tls_dir/server.crt" >/dev/null 2>&1 +openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 2 \ + -subj '/CN=Registry Server PostgreSQL TLS wrong CA' \ + -keyout "$tls_dir/wrong-ca.key" -out "$tls_dir/wrong-ca.pem" >/dev/null 2>&1 +openssl x509 -in "$tls_dir/trusted-ca.pem" -outform DER -out "$tls_dir/trusted-ca.der" +openssl x509 -in "$tls_dir/wrong-ca.pem" -outform DER -out "$tls_dir/wrong-ca.der" +chmod 600 "$tls_dir"/*.key +chmod 644 "$tls_dir"/*.pem "$tls_dir"/*.crt "$tls_dir"/*.der +if [[ -n "$caller_ca_der_path" ]]; then + caller_ca_der_tmp=$(mktemp "$(dirname -- "$caller_ca_der_path")/.registry-server-postgres-ca-der.XXXXXX") + cp "$tls_dir/trusted-ca.der" "$caller_ca_der_tmp" + chmod 644 "$caller_ca_der_tmp" + mv -f -- "$caller_ca_der_tmp" "$caller_ca_der_path" +fi +if [[ -n "$caller_ca_pem_path" ]]; then + caller_ca_pem_tmp=$(mktemp "$(dirname -- "$caller_ca_pem_path")/.registry-server-postgres-ca-pem.XXXXXX") + cp "$tls_dir/trusted-ca.pem" "$caller_ca_pem_tmp" + chmod 644 "$caller_ca_pem_tmp" + mv -f -- "$caller_ca_pem_tmp" "$caller_ca_pem_path" +fi + +postgres_data_directory=$(docker exec "$postgres_container_id" sh -c 'printf %s "$PGDATA"') +if [[ ! "$postgres_data_directory" =~ ^/var/lib/postgresql/[A-Za-z0-9_./-]+$ || "$postgres_data_directory" == *..* ]]; then + printf '%s\n' 'PostgreSQL service reported an unsafe PGDATA path.' >&2 + exit 1 +fi + +docker cp "$tls_dir/server.crt" "$postgres_container_id:$postgres_data_directory/server.crt" +docker cp "$tls_dir/server.key" "$postgres_container_id:$postgres_data_directory/server.key" +docker exec --user root "$postgres_container_id" sh -eu -c ' + chown postgres:postgres "$1/server.crt" "$1/server.key" + chmod 644 "$1/server.crt" + chmod 600 "$1/server.key" + printf "\\nssl = on\\nssl_cert_file = '\''server.crt'\''\\nssl_key_file = '\''server.key'\''\\n" >> "$1/postgresql.conf" + sed -i "s/^host /hostssl /" "$1/pg_hba.conf" +' sh "$postgres_data_directory" + +docker exec --user postgres "$postgres_container_id" \ + pg_ctl -D "$postgres_data_directory" reload >/dev/null +for attempt in {1..30}; do + if docker exec "$postgres_container_id" pg_isready -q \ + && pg_isready -q -d "$database_url"; then + break + fi + if [[ "$attempt" == 30 ]]; then + printf '%s\n' 'PostgreSQL service did not become ready after TLS reconfiguration.' >&2 + exit 1 + fi + sleep 1 +done + +export REGISTRY_SERVER_TEST_TLS_CA_DER_PATH="${caller_ca_der_path:-$tls_dir/trusted-ca.der}" +export REGISTRY_SERVER_TEST_TLS_WRONG_CA_DER_PATH="$tls_dir/wrong-ca.der" +export REGISTRY_SERVER_TEST_TLS_DATABASE_URL="$database_url" +export REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL="$hostname_mismatch_database_url" +export CARGO_INCREMENTAL=0 +export CARGO_PROFILE_DEV_DEBUG=0 +export CARGO_PROFILE_TEST_DEBUG=0 +export RUSTC_WRAPPER= + +cargo test --locked -p registry-server --features postgres-tls-test --test postgres_tls diff --git a/products/registry-server/scripts/test-postgres.sh b/products/registry-server/scripts/test-postgres.sh new file mode 100755 index 0000000000..5711c57fe0 --- /dev/null +++ b/products/registry-server/scripts/test-postgres.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -z "${REGISTRY_SERVER_TEST_DATABASE_URL:-}" ]]; then + printf '%s\n' 'REGISTRY_SERVER_TEST_DATABASE_URL must be set for PostgreSQL journeys.' >&2 + exit 2 +fi + +export CARGO_INCREMENTAL=0 +export CARGO_PROFILE_DEV_DEBUG=0 +export CARGO_PROFILE_TEST_DEBUG=0 +export RUSTC_WRAPPER="${RUSTC_WRAPPER-}" + +cargo test --locked -p registry-server --features runtime --test http_auth +cargo test --locked -p registry-server --features runtime --test http_read_only +cargo test --locked -p registry-server --features runtime --test runtime_config +cargo test --locked -p registry-server --features runtime --test startup_http +cargo test --locked -p registry-server --features runtime --test startup_ordering +cargo test --locked -p registry-server --features runtime,tooling --test fixture_tooling +cargo test --locked -p registry-server --features postgres-test --test postgres_kernel +cargo test --locked -p registry-server --features postgres-test --test postgres_compiled_schema +cargo test --locked -p registry-server --features postgres-test --test postgres_partial_unique +cargo test --locked -p registry-server --features postgres-test --test postgres_constraint_races +cargo test --locked -p registry-server --features postgres-test --test postgres_read +cargo test --locked -p registry-server --features postgres-test --test postgres_revision_http +cargo test --locked -p registry-server --features postgres-test --test postgres_mutation +cargo test --locked -p registry-server --features postgres-test --test postgres_webhook_outbox +cargo test --locked -p registry-server --features postgres-test --test postgres_webhook_delivery +cargo test --locked -p registry-server --features postgres-test --test postgres_batch +cargo test --locked -p registry-server --features postgres-test --test postgres_data_farmer +cargo test --locked -p registry-server --features postgres-test --test postgres_data_export +cargo test --locked -p registry-server --features postgres-test --test postgres_pilot_acceptance +cargo test --locked -p registry-server --features postgres-test --test postgres_tombstone_revision +cargo test --locked -p registry-server --features postgres-test --test postgres_package +cargo test --locked -p registry-server --features postgres-test,tooling --test postgres_migration +cargo test --locked -p registry-server --features postgres-test,tooling --test postgres_fixture_journeys +cargo test --locked -p registry-server --features postgres-test,tooling --test schema_fingerprint_rehearsal +cargo test --locked -p registry-server --features postgres-test --test postgres_startup diff --git a/products/registry-server/scripts/test_check_source_neutrality.py b/products/registry-server/scripts/test_check_source_neutrality.py new file mode 100644 index 0000000000..98347adba1 --- /dev/null +++ b/products/registry-server/scripts/test_check_source_neutrality.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +sys.dont_write_bytecode = True +SCRIPT_PATH = Path(__file__).with_name("check_source_neutrality.py") +SPEC = importlib.util.spec_from_file_location("registry_server_source_neutrality", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +CHECKER = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = CHECKER +SPEC.loader.exec_module(CHECKER) + + +class SourceNeutralityTests(unittest.TestCase): + def test_fixture_identifier_in_production_source_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "crates/registry-server/src/lib.rs" + source.parent.mkdir(parents=True) + source.write_text( + 'const ROUTE: &str = "/v1/records/assets";\n', + encoding="utf-8", + ) + violations = CHECKER.find_violations(root) + self.assertTrue(violations, violations) + self.assertIn("/v1/records/assets", violations[0]) + + def test_every_domain_fixture_family_has_a_rejected_route_canary(self) -> None: + for route in ( + "/v1/records/assets", + "/v1/records/persons", + "/v1/records/assessment-episodes", + "/v1/records/farmers", + "/v1/records/legal-entities", + ): + with self.subTest(route=route), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "crates/registry-server/src/lib.rs" + source.parent.mkdir(parents=True) + source.write_text( + f'const FIXTURE_ROUTE: &str = "{route}";\n', + encoding="utf-8", + ) + violations = CHECKER.find_violations(root) + self.assertTrue(violations, violations) + self.assertTrue( + any(route in violation for violation in violations), + violations, + ) + + def test_generic_source_is_allowed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "crates/registry-server/src/lib.rs" + source.parent.mkdir(parents=True) + source.write_text("pub struct CompiledRegistry;\n", encoding="utf-8") + self.assertEqual([], CHECKER.find_violations(root)) + + def test_fixture_identifier_in_test_code_is_allowed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "crates/registry-server/src/lib.rs" + source.parent.mkdir(parents=True) + source.write_text("pub struct CompiledRegistry;\n", encoding="utf-8") + fixture_test = root / "crates/registry-server/tests/asset_fixture.rs" + fixture_test.parent.mkdir(parents=True) + fixture_test.write_text('const FIXTURE: &str = "asset-site-placement";\n', encoding="utf-8") + self.assertEqual([], CHECKER.find_violations(root)) + + def test_fixture_identifier_in_inline_rust_test_is_allowed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "crates/registry-server/src/lib.rs" + source.parent.mkdir(parents=True) + source.write_text( + "pub struct CompiledRegistry;\n" + "#[cfg(test)]\n" + "mod tests {\n" + ' const FIXTURE: &str = "asset-site-placement";\n' + "}\n", + encoding="utf-8", + ) + self.assertEqual([], CHECKER.find_violations(root)) + + def test_bare_domain_rust_type_identifier_is_rejected(self) -> None: + for identifier in ("Person", "Household", "Farmer", "LegalEntity"): + with self.subTest(identifier=identifier), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "crates/registry-server/src/lib.rs" + source.parent.mkdir(parents=True) + source.write_text(f"pub struct {identifier};\n", encoding="utf-8") + violations = CHECKER.find_violations(root) + self.assertTrue( + any("Rust type identifier" in violation for violation in violations), + violations, + ) + + def test_domain_cargo_feature_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + manifest = root / "crates/registry-server/Cargo.toml" + manifest.parent.mkdir(parents=True) + manifest.write_text( + "[package]\nname = \"neutral\"\nversion = \"0.1.0\"\n" + "[features]\nfarmer-pilot = []\n", + encoding="utf-8", + ) + violations = CHECKER.find_violations(root) + self.assertTrue(any("Cargo feature farmer-pilot" in item for item in violations), violations) + + def test_domain_migration_and_resource_inputs_are_rejected(self) -> None: + cases = ( + ("migrations/001.sql", "CREATE TABLE farmer (id uuid);\n"), + ("resources/metrics.yaml", "name: registry.person.requests\n"), + ) + for relative, contents in cases: + with self.subTest(relative=relative), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "crates/registry-server" / relative + source.parent.mkdir(parents=True) + source.write_text(contents, encoding="utf-8") + violations = CHECKER.find_violations(root) + self.assertTrue( + any("production identifier" in violation for violation in violations), + violations, + ) + + def test_domain_metric_and_error_identifiers_are_rejected(self) -> None: + for value in ("registry.farmer.requests", "person.not_found"): + with self.subTest(value=value), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "crates/registry-server/src/lib.rs" + source.parent.mkdir(parents=True) + source.write_text(f'const IDENTIFIER: &str = "{value}";\n', encoding="utf-8") + violations = CHECKER.find_violations(root) + self.assertTrue( + any("metric/error identifier" in violation for violation in violations), + violations, + ) + + def test_public_kernel_contract_canary_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + contract = root / "products/registry-server/contracts/package-layout.yaml" + contract.parent.mkdir(parents=True) + contract.write_text("canary: /v1/records/farmers\n", encoding="utf-8") + violations = CHECKER.find_violations(root) + self.assertTrue(violations, violations) + + def test_fixture_directories_and_ordinary_docs_are_allowed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fixture = root / "crates/registry-server/src/fixtures/domain.rs" + fixture.parent.mkdir(parents=True) + fixture.write_text("pub struct Farmer;\n", encoding="utf-8") + docs = root / "crates/registry-server/README.md" + docs.write_text( + "A person can operate a household or farmer registry.\n", + encoding="utf-8", + ) + self.assertEqual([], CHECKER.find_violations(root)) + + def test_fixture_identifier_in_build_script_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + build_script = root / "crates/registry-server/build.rs" + build_script.parent.mkdir(parents=True) + build_script.write_text( + 'const ROUTE: &str = "/v1/records/assets";\n', + encoding="utf-8", + ) + violations = CHECKER.find_violations(root) + self.assertTrue(violations, violations) + self.assertIn("build.rs", violations[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/registry-server/scripts/test_generated_gates.py b/products/registry-server/scripts/test_generated_gates.py new file mode 100755 index 0000000000..ece149bcb8 --- /dev/null +++ b/products/registry-server/scripts/test_generated_gates.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +sys.dont_write_bytecode = True +SCRIPT_DIR = Path(__file__).parent +COMPARATOR_PATH = SCRIPT_DIR / "compare-generated-tree.py" +SPEC = importlib.util.spec_from_file_location("registry_server_generated_tree", COMPARATOR_PATH) +assert SPEC is not None and SPEC.loader is not None +COMPARATOR = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = COMPARATOR +SPEC.loader.exec_module(COMPARATOR) + + +class GeneratedGateTests(unittest.TestCase): + def test_comparator_rejects_a_missing_committed_artifact(self) -> None: + baseline = SCRIPT_DIR.parent / "generated/asset-site-placement" + with tempfile.TemporaryDirectory() as temporary: + candidate = Path(temporary) / "candidate" + shutil.copytree(baseline, candidate) + (candidate / COMPARATOR.EXPECTED_PATHS[-1]).unlink() + errors = COMPARATOR.compare(baseline, candidate) + self.assertTrue(any("candidate is missing expected artifacts" in error for error in errors), errors) + + def test_generated_gate_script_keeps_a_bounded_database_free_cli_journey(self) -> None: + generated_gate = (SCRIPT_DIR / "check-generated.sh").read_text(encoding="utf-8") + self.assertIn("mktemp -d", generated_gate) + self.assertIn('export RUSTC_WRAPPER="${RUSTC_WRAPPER-}"', generated_gate) + self.assertIn("authoring_baseline", generated_gate) + self.assertIn("--features schema --example authoring-schema", generated_gate) + self.assertIn("products/registry-server/generated/authoring", generated_gate) + for selector in ("openapi", "schemas", "manifest", "metadata", "sql"): + self.assertIn(selector, generated_gate) + self.assertNotIn(" apply ", generated_gate) + self.assertNotIn(" serve ", generated_gate) + self.assertNotIn("REGISTRY_SERVER_TEST_DATABASE_URL", generated_gate) + self.assertIn("compare-generated-tree.py", generated_gate) + + def test_adopter_workflow_uses_public_binaries_database_and_recovery(self) -> None: + adopter_gate = (SCRIPT_DIR / "test-adopter-workflow.sh").read_text(encoding="utf-8") + self.assertIn("mktemp -d", adopter_gate) + self.assertIn('export RUSTC_WRAPPER="${RUSTC_WRAPPER-}"', adopter_gate) + self.assertIn('registry_serverctl="$repository_root/target/debug/registry-serverctl"', adopter_gate) + self.assertIn('registry_server="$repository_root/target/debug/registry-server"', adopter_gate) + self.assertIn('cargo build --manifest-path "$repository_root/Cargo.toml" --locked', adopter_gate) + self.assertIn("-p registry-serverctl", adopter_gate) + self.assertIn("-p registry-server", adopter_gate) + self.assertIn("server_hash_before", adopter_gate) + self.assertIn("server_hash_after", adopter_gate) + self.assertNotIn("cargo run", adopter_gate) + self.assertNotIn("--signing-key", adopter_gate) + + for marker in ( + "REGISTRY_SERVER_TEST_DATABASE_URL", + "CREATE ROLE", + "CREATE DATABASE", + "adopter_schema_test_v1_database", + "adopter_schema_test_v2_database", + "adopter_production_database", + "derive_admin_database_url", + "secret:file/schema-test-v1-runtime-url", + "secret:file/schema-test-v1-migration-url", + "secret:file/schema-test-v2-runtime-url", + "secret:file/schema-test-v2-migration-url", + "secret:file/production-runtime-url", + "secret:file/production-migration-url", + "REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH", + 'export SSL_CERT_FILE="$adopter_tls_ca_pem_path"', + "umask 077", + "maxTokenLifetimeSeconds: 3600", + '"exp": now + 3600', + "jwksSource:", + "kind: static", + "documentRef: secret:file/oidc-jwks", + "write_jwt", + "--production", + "compare-generated-tree.py", + "schemaFingerprint", + "signing-input.json", + "--signatures", + "missing-migration-url", + "apply.database_configuration.refused", + "author refusal changed the production database state", + "apply --runtime-config", + '"$registry_server" --config', + "data validate", + "data import", + "entity_list_path", + "http_get_json", + "authorized public data read", + "field_added_optional", + "compatible_additive", + "LOCK TABLE", + "pg_terminate_backend", + "apply.migration.failed", + "maintenance_status", + "maintenance_target_revision", + "restricted successor field was disclosed", + ): + self.assertIn(marker, adopter_gate) + self.assertNotIn('"psql", admin, "-d", database', adopter_gate) + self.assertNotIn('"scope": "registry:records"', adopter_gate) + + def test_postgres_tls_script_hands_off_public_ca_material_without_retaining_keys(self) -> None: + tls_gate = (SCRIPT_DIR / "test-postgres-tls.sh").read_text(encoding="utf-8") + self.assertIn("validate_caller_output_path", tls_gate) + self.assertIn("REGISTRY_SERVER_TEST_TLS_CA_DER_PATH", tls_gate) + self.assertIn("REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH", tls_gate) + self.assertIn("trusted-ca.der", tls_gate) + self.assertIn("trusted-ca.pem", tls_gate) + self.assertIn("wrong-ca.der", tls_gate) + self.assertIn('mktemp "$(dirname -- "$caller_ca_pem_path")/.registry-server-postgres-ca-pem.XXXXXX"', tls_gate) + self.assertIn('pg_isready -q -d "$database_url"', tls_gate) + self.assertIn('pg_ctl -D "$postgres_data_directory" reload', tls_gate) + self.assertIn('rm -rf -- "$tls_dir"', tls_gate) + self.assertIn('chmod 600 "$tls_dir"/*.key', tls_gate) + self.assertNotIn("trusted-ca.key\" \"$caller", tls_gate) + + def test_comparator_rejects_a_symbolic_link_without_reading_its_target(self) -> None: + if os.name == "nt": + self.skipTest("symbolic-link setup is not portable on Windows") + baseline = SCRIPT_DIR.parent / "generated/asset-site-placement" + with tempfile.TemporaryDirectory() as temporary: + candidate = Path(temporary) / "candidate" + shutil.copytree(baseline, candidate) + target = candidate / COMPARATOR.EXPECTED_PATHS[0] + target.unlink() + target.symlink_to("/not/a-generated-artifact") + with self.assertRaisesRegex(ValueError, "symbolic link"): + COMPARATOR.compare(baseline, candidate) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/registry-server/scripts/test_validate_product.py b/products/registry-server/scripts/test_validate_product.py new file mode 100644 index 0000000000..8618a63f5a --- /dev/null +++ b/products/registry-server/scripts/test_validate_product.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import copy +import importlib.util +import os +import subprocess +import sys +import tomllib +import unittest +from pathlib import Path +from unittest import mock + + +sys.dont_write_bytecode = True +SCRIPT_PATH = Path(__file__).with_name("validate_product.py") +SPEC = importlib.util.spec_from_file_location("registry_server_validate_product", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +VALIDATOR = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = VALIDATOR +SPEC.loader.exec_module(VALIDATOR) + + +class RegistryServerProductCatalogTests(unittest.TestCase): + def test_tracked_product_catalog_is_internally_complete(self) -> None: + self.assertEqual([], VALIDATOR.validate_all()) + + def test_rls_assurance_and_operator_credential_posture_are_exact(self) -> None: + decisions = (VALIDATOR.PRODUCT_ROOT / "DECISIONS.md").read_text(encoding="utf-8") + assurance = ( + "Generated RLS policies defend against application mistakes and pooled-context\n" + " leakage. They do not constrain a party holding the runtime database\n" + " credential, which can set the same custom transaction context; credential\n" + " posture and rotation remain operator controls." + ) + self.assertIn(assurance, decisions) + self.assertNotIn("RLS protects the runtime database credential", decisions) + self.assertNotIn("Registry Server rotates the runtime database credential", decisions) + + def test_every_pre_w5_security_invariant_is_enforced_with_an_executable_negative(self) -> None: + matrix = VALIDATOR.load_yaml( + VALIDATOR.CONTRACTS / "security-invariant-matrix.yaml" + ) + pre_w5 = [ + invariant + for invariant in matrix["invariants"] + if invariant["targetWave"] != "W5" + ] + self.assertTrue(pre_w5) + for invariant in pre_w5: + with self.subTest(invariant=invariant["id"]): + self.assertEqual("enforced", invariant["state"]) + self.assertIn("negativeTest", invariant) + self.assertIn("path", invariant["negativeTest"]) + self.assertIn("name", invariant["negativeTest"]) + + def test_w0_crate_boundary_is_two_crates_with_opt_in_runtime(self) -> None: + root = tomllib.loads((VALIDATOR.REPOSITORY_ROOT / "Cargo.toml").read_text(encoding="utf-8")) + members = root["workspace"]["members"] + self.assertEqual( + ["crates/registry-server", "crates/registry-serverctl"], + [member for member in members if member.startswith("crates/registry-server")], + ) + + server = tomllib.loads( + (VALIDATOR.REPOSITORY_ROOT / "crates/registry-server/Cargo.toml").read_text(encoding="utf-8") + ) + self.assertEqual([], server["features"]["default"]) + self.assertEqual( + { + "dep:axum", + "dep:base64", + "dep:clap", + "dep:chacha20poly1305", + "dep:deadpool-postgres", + "dep:getrandom", + "dep:hex", + "dep:hmac", + "dep:ipnet", + "dep:jsonwebtoken", + "dep:registry-platform-audit", + "dep:registry-platform-authcommon", + "dep:registry-platform-buildinfo", + "dep:registry-platform-config", + "dep:registry-platform-crypto", + "dep:registry-platform-httpsec", + "dep:registry-platform-httputil", + "dep:registry-platform-oidc", + "dep:rustls", + "dep:rustix", + "dep:tokio", + "dep:tokio-postgres", + "dep:tokio-postgres-rustls", + "dep:tracing", + "dep:tracing-subscriber", + "dep:zeroize", + }, + set(server["features"]["runtime"]), + ) + self.assertEqual(["runtime"], server["bin"][0]["required-features"]) + for dependency in ( + "axum", + "base64", + "chacha20poly1305", + "clap", + "deadpool-postgres", + "getrandom", + "hex", + "hmac", + "ipnet", + "jsonwebtoken", + "registry-platform-audit", + "registry-platform-authcommon", + "registry-platform-buildinfo", + "registry-platform-config", + "registry-platform-crypto", + "registry-platform-httpsec", + "registry-platform-httputil", + "registry-platform-oidc", + "rustls", + "rustix", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tracing", + "tracing-subscriber", + "zeroize", + ): + self.assertTrue(server["dependencies"][dependency]["optional"]) + + ctl = tomllib.loads( + (VALIDATOR.REPOSITORY_ROOT / "crates/registry-serverctl/Cargo.toml").read_text(encoding="utf-8") + ) + self.assertEqual( + ["runtime", "tooling"], + ctl["dependencies"]["registry-server"]["features"], + ) + + def test_postgres_entrypoint_refuses_to_silently_skip_without_database_url(self) -> None: + script = VALIDATOR.POSTGRES_ENTRYPOINT + self.assertTrue(os.access(script, os.X_OK)) + environment = os.environ.copy() + environment.pop("REGISTRY_SERVER_TEST_DATABASE_URL", None) + result = subprocess.run( + [str(script)], + cwd=VALIDATOR.REPOSITORY_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(2, result.returncode) + self.assertIn("REGISTRY_SERVER_TEST_DATABASE_URL must be set", result.stderr) + self.assertNotIn("cargo test", result.stdout + result.stderr) + + def test_postgres_entrypoint_keeps_its_exact_owned_command(self) -> None: + errors: list[str] = [] + VALIDATOR.validate_postgres_entrypoint(errors) + self.assertEqual([], errors) + + def test_postgres_constraint_races_follow_partial_unique_in_the_owned_gate(self) -> None: + commands = list(VALIDATOR.POSTGRES_TEST_COMMANDS) + partial_unique = ( + "cargo test --locked -p registry-server --features postgres-test " + "--test postgres_partial_unique" + ) + constraint_races = ( + "cargo test --locked -p registry-server --features postgres-test " + "--test postgres_constraint_races" + ) + self.assertEqual( + commands.index(partial_unique) + 1, + commands.index(constraint_races), + ) + + def test_postgres_migration_requires_tooling_and_follows_package_in_the_owned_gate(self) -> None: + commands = list(VALIDATOR.POSTGRES_TEST_COMMANDS) + package = "cargo test --locked -p registry-server --features postgres-test --test postgres_package" + migration = ( + "cargo test --locked -p registry-server --features postgres-test,tooling " + "--test postgres_migration" + ) + self.assertEqual(commands.index(package) + 1, commands.index(migration)) + + def test_postgres_webhook_outbox_follows_mutation_in_the_owned_gate(self) -> None: + commands = list(VALIDATOR.POSTGRES_TEST_COMMANDS) + mutation = "cargo test --locked -p registry-server --features postgres-test --test postgres_mutation" + webhook_outbox = ( + "cargo test --locked -p registry-server --features postgres-test " + "--test postgres_webhook_outbox" + ) + self.assertEqual(commands.index(mutation) + 1, commands.index(webhook_outbox)) + + def test_postgres_webhook_delivery_follows_atomic_capture_in_the_owned_gate(self) -> None: + commands = list(VALIDATOR.POSTGRES_TEST_COMMANDS) + webhook_outbox = ( + "cargo test --locked -p registry-server --features postgres-test " + "--test postgres_webhook_outbox" + ) + webhook_delivery = ( + "cargo test --locked -p registry-server --features postgres-test " + "--test postgres_webhook_delivery" + ) + self.assertEqual(commands.index(webhook_outbox) + 1, commands.index(webhook_delivery)) + + def test_postgres_data_journeys_follow_batch_in_the_owned_gate(self) -> None: + commands = list(VALIDATOR.POSTGRES_TEST_COMMANDS) + batch = "cargo test --locked -p registry-server --features postgres-test --test postgres_batch" + farmer = ( + "cargo test --locked -p registry-server --features postgres-test " + "--test postgres_data_farmer" + ) + export = ( + "cargo test --locked -p registry-server --features postgres-test " + "--test postgres_data_export" + ) + self.assertEqual(commands.index(batch) + 1, commands.index(farmer)) + self.assertEqual(commands.index(farmer) + 1, commands.index(export)) + + def test_postgres_tls_entrypoint_refuses_to_silently_skip_without_container_id(self) -> None: + script = VALIDATOR.POSTGRES_TLS_ENTRYPOINT + self.assertTrue(os.access(script, os.X_OK)) + environment = os.environ.copy() + environment.pop("REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID", None) + result = subprocess.run( + [str(script)], + cwd=VALIDATOR.REPOSITORY_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(2, result.returncode) + self.assertIn("REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID must be set", result.stderr) + self.assertNotIn("docker", result.stdout + result.stderr) + + def test_postgres_tls_entrypoint_keeps_its_exact_owned_command_and_ci_invocation(self) -> None: + errors: list[str] = [] + VALIDATOR.validate_postgres_tls_entrypoint(errors) + self.assertEqual([], errors) + + def test_planned_invariant_cannot_claim_an_executable_test(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_test(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "security-invariant-matrix.yaml": + row = value["invariants"][0] + row["state"] = "planned" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_test): + VALIDATOR.validate_security({"W0", "W1", "W2", "W3", "W4", "W5"}, errors) + self.assertTrue(any("unknown keys negativeTest" in error for error in errors), errors) + + def test_planned_invariant_requires_real_refusal(self) -> None: + original = VALIDATOR.load_yaml + + def load_without_refusal(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "security-invariant-matrix.yaml": + value["invariants"][0].pop("refusal") + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_refusal): + VALIDATOR.validate_security({"W0", "W1", "W2", "W3", "W4", "W5"}, errors) + self.assertTrue(any("missing keys refusal" in error for error in errors), errors) + + def test_duplicate_security_identifier_is_rejected(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_duplicate(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "security-invariant-matrix.yaml": + value["invariants"][1]["id"] = "RS-SEC-01" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_duplicate): + VALIDATOR.validate_security({"W0", "W1", "W2", "W3", "W4", "W5"}, errors) + self.assertTrue(any("duplicate identifier" in error for error in errors), errors) + + def test_enforced_invariant_must_bind_one_resolving_test(self) -> None: + original = VALIDATOR.load_yaml + + def load_enforced_without_test(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "security-invariant-matrix.yaml": + row = next(row for row in value["invariants"] if row["state"] == "enforced") + row.pop("negativeTest") + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_enforced_without_test): + VALIDATOR.validate_security({"W0", "W1", "W2", "W3", "W4", "W5"}, errors) + self.assertTrue(any("missing keys negativeTest" in error for error in errors), errors) + + def test_helper_named_like_a_test_is_not_an_executable_test(self) -> None: + def test_nested_helper() -> None: + return None + + errors: list[str] = [] + VALIDATOR.executable_test_resolves( + { + "path": "products/registry-server/scripts/test_validate_product.py", + "name": "test_nested_helper", + }, + "negative test", + errors, + ) + self.assertTrue(any("does not resolve" in error for error in errors), errors) + + def test_unresolved_acceptance_reference_is_rejected(self) -> None: + original = VALIDATOR.load_yaml + + def load_unknown_journey(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "definition-of-done.yaml": + value["requirements"][0]["journeys"] = ["RS-J99"] + return value + + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_unknown_journey): + errors = VALIDATOR.validate_all() + self.assertTrue(any("references unknown journey" in error for error in errors), errors) + + def test_definition_of_done_cannot_omit_a_v1_requirement(self) -> None: + original = VALIDATOR.load_yaml + + def load_without_requirement(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "definition-of-done.yaml": + value["requirements"] = [ + row for row in value["requirements"] if row["id"] != "RS-V1-44" + ] + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_requirement): + VALIDATOR.validate_definition_of_done({f"W{index}" for index in range(6)}, errors) + self.assertIn( + "definition of done: must contain RS-V1-01 through RS-V1-44 exactly once in order", + errors, + ) + + def test_definition_of_done_rejects_a_duplicate_v1_requirement(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_duplicate_requirement(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "definition-of-done.yaml": + row = next(row for row in value["requirements"] if row["id"] == "RS-V1-01") + index = value["requirements"].index(row) + value["requirements"].insert(index + 1, copy.deepcopy(row)) + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_duplicate_requirement): + VALIDATOR.validate_definition_of_done({f"W{index}" for index in range(6)}, errors) + self.assertTrue(any("duplicate identifier RS-V1-01" in error for error in errors), errors) + + def test_definition_of_done_rejects_a_nonexistent_evidence_test(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_nonexistent_test(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "definition-of-done.yaml": + row = next(row for row in value["requirements"] if row["id"] == "RS-V1-01") + row["evidence"][0]["name"] = "test_that_does_not_exist" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_nonexistent_test): + VALIDATOR.validate_definition_of_done({f"W{index}" for index in range(6)}, errors) + self.assertTrue(any("exact executable test does not resolve" in error for error in errors), errors) + + def test_enforced_requirement_cannot_retain_a_partial_gap(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_stale_gap(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "definition-of-done.yaml": + row = next(row for row in value["requirements"] if row["state"] == "enforced") + row["gap"] = "stale gap" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_stale_gap): + VALIDATOR.validate_definition_of_done({f"W{index}" for index in range(6)}, errors) + self.assertTrue(any("unknown keys gap" in error for error in errors), errors) + + def test_acceptance_matrix_cannot_omit_a_required_journey(self) -> None: + original = VALIDATOR.load_yaml + + def load_without_journey(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "acceptance-scenario-matrix.yaml": + value["scenarios"].pop() + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_journey): + VALIDATOR.validate_acceptance(errors) + self.assertIn( + "acceptance matrix: must contain RS-J01 through RS-J17 exactly once in order", + errors, + ) + + def test_contract_state_vocabulary_is_closed(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_unknown_state(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "acceptance-scenario-matrix.yaml": + value["scenarios"][0]["state"] = "complete" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_unknown_state): + VALIDATOR.validate_acceptance(errors) + self.assertTrue(any("expected enforced, partial, or planned" in error for error in errors), errors) + + def test_shell_evidence_name_must_match_the_executable_filename(self) -> None: + errors: list[str] = [] + VALIDATOR.executable_test_resolves( + { + "path": "products/registry-server/scripts/check-source-neutrality.sh", + "name": "different-script.sh", + }, + "shell evidence", + errors, + ) + self.assertTrue(any("exact executable test does not resolve" in error for error in errors), errors) + + def test_schedule_exit_criterion_must_resolve(self) -> None: + original = VALIDATOR.load_yaml + + def load_unknown_criterion(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "implementation-schedule.yaml": + value["waves"][0]["exitCriteria"] = ["RS-NOT-DECLARED"] + return value + + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_unknown_criterion): + errors = VALIDATOR.validate_all() + self.assertTrue(any("exit criterion does not resolve" in error for error in errors), errors) + + def test_asset_fixture_cannot_gain_a_household_entity(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_hardcoding(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "registry.yaml" and path.parent.name == "asset-site-placement": + value["entities"].append({"id": "household", "route": "households"}) + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_hardcoding): + VALIDATOR.validate_fixture(errors) + self.assertTrue(any("complete non-person entity set" in error for error in errors), errors) + + def test_asset_fixture_package_has_only_the_production_identity_keys(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_unknown_package_key(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "registry.yaml" and path.parent.name == "asset-site-placement": + value["package"]["repairMissingIdentity"] = True + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_unknown_package_key): + VALIDATOR.validate_fixture(errors) + self.assertTrue( + any("asset fixture.package: unknown keys repairMissingIdentity" in error for error in errors), + errors, + ) + + def test_asset_fixture_package_identity_and_sequence_are_exact(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_implicit_sequence(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "registry.yaml" and path.parent.name == "asset-site-placement": + value["package"]["sequence"] = True + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_implicit_sequence): + VALIDATOR.validate_fixture(errors) + self.assertIn("asset fixture.package.sequence: expected integer", errors) + + def test_package_layout_cannot_drop_a_required_entry(self) -> None: + original = VALIDATOR.load_yaml + + def load_without_entry(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "package-layout.yaml": + value["entries"].pop() + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_entry): + VALIDATOR.validate_package_layout(errors) + self.assertTrue(any("missing required entry tuples" in error for error in errors), errors) + + def test_package_layout_binds_fixture_journeys_as_required_reviewed_source(self) -> None: + original = VALIDATOR.load_yaml + + def load_with_wrong_fixture_role(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "package-layout.yaml": + for entry in value["entries"]: + if entry["path"] == "tests/journeys.yaml": + entry["role"] = "generated-test-receipt" + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_with_wrong_fixture_role): + VALIDATOR.validate_package_layout(errors) + self.assertTrue(any("missing required entry tuples" in error for error in errors), errors) + self.assertTrue(any("unexpected entry tuples" in error for error in errors), errors) + + def test_package_layout_cannot_allow_embedded_signing_key(self) -> None: + original = VALIDATOR.load_yaml + + def load_without_signing_key(path: Path): + value = copy.deepcopy(original(path)) + if path.name == "package-layout.yaml": + value["forbiddenEmbeddedRoles"].remove("signing-key") + return value + + errors: list[str] = [] + with mock.patch.object(VALIDATOR, "load_yaml", side_effect=load_without_signing_key): + VALIDATOR.validate_package_layout(errors) + self.assertTrue(any("complete forbidden role set" in error for error in errors), errors) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/registry-server/scripts/validate_product.py b/products/registry-server/scripts/validate_product.py new file mode 100644 index 0000000000..479e4a3b7e --- /dev/null +++ b/products/registry-server/scripts/validate_product.py @@ -0,0 +1,667 @@ +#!/usr/bin/env python3 +"""Validate Registry Server's tracked product-contract catalog. + +This deliberately validates only product-owned, declarative relationships. The +compiler will own configuration semantics and generated artifact correctness. +""" + +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path +from typing import Any + +try: + import yaml +except ModuleNotFoundError as exc: # pragma: no cover - environment failure + raise SystemExit("PyYAML is required to validate Registry Server contracts") from exc + + +PRODUCT_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT = PRODUCT_ROOT.parents[1] +CONTRACTS = PRODUCT_ROOT / "contracts" +IDENTIFIER = re.compile(r"^[A-Z][A-Z0-9-]+$") +WAVE = re.compile(r"^W[0-9]+$") +PLACEHOLDER = re.compile(r"\b(?:TODO|TBD|FIXME|placeholder)\b", re.IGNORECASE) +CONTRACT_STATES = {"enforced", "partial", "planned"} +V1_REQUIREMENT_IDS = tuple(f"RS-V1-{index:02d}" for index in range(1, 45)) +ACCEPTANCE_JOURNEY_IDS = tuple(f"RS-J{index:02d}" for index in range(1, 18)) +ACCEPTANCE_FIXTURES = { + "RS-J01": ("asset-site-placement", "acceptance/asset-site-placement"), + "RS-J02": ("asset-site-placement", "acceptance/asset-site-placement"), + "RS-J03": ("household", "acceptance/publicschema-household"), + "RS-J04": ("disability", "acceptance/disability"), + "RS-J05": ("farmer", "acceptance/farmer"), + "RS-J06": ("business", "acceptance/business"), +} +RUST_TEST = re.compile( + r"#\[(?:tokio::)?test(?:\([^\]]*\))?\]" + r"(?:\s*#\[[^\]]+\])*\s*(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", + re.MULTILINE, +) +PACKAGE_LAYOUT_ENTRIES = { + ("package.json", "identity", True), + ("effective-model.json", "governed-model", True), + ("inventories/physical-names.json", "physical-name-inventory", True), + ("inventories/routes.json", "route-inventory", True), + ("inventories/access.json", "access-inventory", True), + ("inventories/queries.json", "query-inventory", True), + ("inventories/events.json", "event-inventory", True), + ("metadata/registry.json", "caller-safe-metadata", True), + ("database/ddl.sql", "generated-ddl", True), + ("database/migration-plan.json", "migration-plan", True), + ("openapi/openapi.json", "generated-openapi", True), + ("schemas", "entity-json-schemas", True), + ("manifest/registry-manifest.json", "lossy-manifest-projection", True), + ("tests/journeys.yaml", "fixture-journeys", True), + ("signatures", "package-signatures", False), +} +FORBIDDEN_EMBEDDED_ROLES = { + "deployment-trust-anchor", + "runtime-secret", + "migration-credential", + "signing-key", +} +POSTGRES_ENTRYPOINT = PRODUCT_ROOT / "scripts/test-postgres.sh" +POSTGRES_TEST_COMMANDS = ( + "cargo test --locked -p registry-server --features runtime --test http_auth", + "cargo test --locked -p registry-server --features runtime --test http_read_only", + "cargo test --locked -p registry-server --features runtime --test runtime_config", + "cargo test --locked -p registry-server --features runtime --test startup_http", + "cargo test --locked -p registry-server --features runtime --test startup_ordering", + "cargo test --locked -p registry-server --features runtime,tooling --test fixture_tooling", + "cargo test --locked -p registry-server --features postgres-test --test postgres_kernel", + "cargo test --locked -p registry-server --features postgres-test --test postgres_compiled_schema", + "cargo test --locked -p registry-server --features postgres-test --test postgres_partial_unique", + "cargo test --locked -p registry-server --features postgres-test --test postgres_constraint_races", + "cargo test --locked -p registry-server --features postgres-test --test postgres_read", + "cargo test --locked -p registry-server --features postgres-test --test postgres_revision_http", + "cargo test --locked -p registry-server --features postgres-test --test postgres_mutation", + "cargo test --locked -p registry-server --features postgres-test --test postgres_webhook_outbox", + "cargo test --locked -p registry-server --features postgres-test --test postgres_webhook_delivery", + "cargo test --locked -p registry-server --features postgres-test --test postgres_batch", + "cargo test --locked -p registry-server --features postgres-test --test postgres_data_farmer", + "cargo test --locked -p registry-server --features postgres-test --test postgres_data_export", + "cargo test --locked -p registry-server --features postgres-test --test postgres_pilot_acceptance", + "cargo test --locked -p registry-server --features postgres-test --test postgres_tombstone_revision", + "cargo test --locked -p registry-server --features postgres-test --test postgres_package", + "cargo test --locked -p registry-server --features postgres-test,tooling --test postgres_migration", + "cargo test --locked -p registry-server --features postgres-test,tooling --test postgres_fixture_journeys", + "cargo test --locked -p registry-server --features postgres-test,tooling --test schema_fingerprint_rehearsal", + "cargo test --locked -p registry-server --features postgres-test --test postgres_startup", +) +POSTGRES_TLS_ENTRYPOINT = PRODUCT_ROOT / "scripts/test-postgres-tls.sh" +POSTGRES_TLS_TEST_COMMAND = "cargo test --locked -p registry-server --features postgres-tls-test --test postgres_tls" +CI_WORKFLOW = REPOSITORY_ROOT / ".github/workflows/ci.yml" + + +def load_yaml(path: Path) -> Any: + with path.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def as_mapping(value: Any, label: str, errors: list[str]) -> dict[str, Any]: + if not isinstance(value, dict): + errors.append(f"{label}: expected mapping") + return {} + return value + + +def as_list(value: Any, label: str, errors: list[str]) -> list[Any]: + if not isinstance(value, list): + errors.append(f"{label}: expected sequence") + return [] + return value + + +def exact_keys(value: dict[str, Any], expected: set[str], label: str, errors: list[str]) -> None: + missing = sorted(expected - value.keys()) + unknown = sorted(value.keys() - expected) + if missing: + errors.append(f"{label}: missing keys {', '.join(missing)}") + if unknown: + errors.append(f"{label}: unknown keys {', '.join(unknown)}") + + +def nonempty_string(value: Any, label: str, errors: list[str]) -> None: + if not isinstance(value, str) or not value.strip(): + errors.append(f"{label}: expected a non-empty string") + + +def relative_path(value: Any, label: str, errors: list[str]) -> bool: + if not isinstance(value, str) or not value or value.startswith("/") or ".." in Path(value).parts: + errors.append(f"{label}: expected a repository-relative path without parent traversal") + return False + return True + + +def no_placeholders(value: Any, label: str, errors: list[str]) -> None: + if isinstance(value, str) and PLACEHOLDER.search(value): + errors.append(f"{label}: contains prohibited placeholder text") + elif isinstance(value, dict): + for key, child in value.items(): + no_placeholders(child, f"{label}.{key}", errors) + elif isinstance(value, list): + for index, child in enumerate(value): + no_placeholders(child, f"{label}[{index}]", errors) + + +def unique_ids(items: list[Any], label: str, errors: list[str]) -> set[str]: + result: set[str] = set() + for index, raw in enumerate(items): + item = as_mapping(raw, f"{label}[{index}]", errors) + identifier = item.get("id") + if not isinstance(identifier, str) or not IDENTIFIER.fullmatch(identifier): + errors.append(f"{label}[{index}].id: expected an uppercase stable identifier") + continue + if identifier in result: + errors.append(f"{label}: duplicate identifier {identifier}") + result.add(identifier) + return result + + +def executable_test_resolves(value: Any, label: str, errors: list[str]) -> None: + test = as_mapping(value, label, errors) + exact_keys(test, {"path", "name"}, label, errors) + raw_path, name = test.get("path"), test.get("name") + if not relative_path(raw_path, f"{label}.path", errors): + return + if not isinstance(name, str) or not name: + errors.append(f"{label}.name: expected one exact executable name") + return + source = (REPOSITORY_ROOT / raw_path).resolve() + try: + source.relative_to(REPOSITORY_ROOT.resolve()) + except ValueError: + errors.append(f"{label}: test path escapes repository") + return + if not source.is_file(): + errors.append(f"{label}: test source does not exist: {raw_path}") + return + if source.suffix == ".rs": + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): + errors.append(f"{label}.name: expected one exact Rust test name") + return + matches = RUST_TEST.findall(source.read_text(encoding="utf-8")).count(name) + elif source.suffix == ".py": + try: + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + except SyntaxError as exc: + errors.append(f"{label}: cannot parse Python test source: {exc.msg}") + return + if not name.startswith("test_"): + errors.append(f"{label}.name: Python executable tests must start with test_") + return + matches = python_test_definitions(tree).count(name) + elif source.suffix == ".sh": + matches = int(name == source.name and bool(source.stat().st_mode & 0o111)) + else: + errors.append(f"{label}: executable path must end in .rs, .py, or .sh") + return + if matches != 1: + errors.append(f"{label}: exact executable test does not resolve: {raw_path}::{name}") + + +def class_base_name(base: ast.expr) -> str | None: + if isinstance(base, ast.Name): + return base.id + if isinstance(base, ast.Attribute) and isinstance(base.value, ast.Name): + return f"{base.value.id}.{base.attr}" + return None + + +def python_test_definitions(tree: ast.Module) -> list[str]: + """Return top-level tests and direct methods of TestCase subclasses only.""" + testcase_classes: set[str] = set() + classes = [node for node in tree.body if isinstance(node, ast.ClassDef)] + changed = True + while changed: + changed = False + for node in classes: + bases = {class_base_name(base) for base in node.bases} + if node.name not in testcase_classes and ( + "TestCase" in bases or "unittest.TestCase" in bases or bool(bases & testcase_classes) + ): + testcase_classes.add(node.name) + changed = True + tests = [ + node.name + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_") + ] + for node in classes: + if node.name in testcase_classes: + tests.extend( + child.name + for child in node.body + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) + and child.name.startswith("test_") + ) + return tests + + +def validate_schedule(errors: list[str]) -> set[str]: + value = as_mapping(load_yaml(CONTRACTS / "implementation-schedule.yaml"), "schedule", errors) + exact_keys(value, {"apiVersion", "product", "currentWave", "waves"}, "schedule", errors) + if value.get("product") != "registry-server": + errors.append("schedule: wrong product") + current = value.get("currentWave") + if not isinstance(current, str) or not WAVE.fullmatch(current): + errors.append("schedule.currentWave: expected a wave identifier") + waves = as_list(value.get("waves"), "schedule.waves", errors) + identifiers: set[str] = set() + for index, raw in enumerate(waves): + wave = as_mapping(raw, f"schedule.waves[{index}]", errors) + exact_keys(wave, {"id", "outcome", "exitCriteria"}, f"schedule.waves[{index}]", errors) + identifier = wave.get("id") + if not isinstance(identifier, str) or not WAVE.fullmatch(identifier): + errors.append(f"schedule.waves[{index}].id: expected a wave identifier") + elif identifier in identifiers: + errors.append(f"schedule.waves: duplicate wave {identifier}") + else: + identifiers.add(identifier) + nonempty_string(wave.get("outcome"), f"schedule.waves[{index}].outcome", errors) + criteria = as_list(wave.get("exitCriteria"), f"schedule.waves[{index}].exitCriteria", errors) + if not criteria or any(not isinstance(item, str) or not item for item in criteria): + errors.append(f"schedule.waves[{index}].exitCriteria: expected non-empty identifiers") + if current not in identifiers: + errors.append("schedule.currentWave: does not name a declared wave") + return identifiers + + +def validate_evidence(value: Any, label: str, errors: list[str]) -> None: + evidence = as_list(value, label, errors) + if not evidence: + errors.append(f"{label}: expected at least one executable binding") + return + seen: set[tuple[Any, Any]] = set() + for index, raw in enumerate(evidence): + item = as_mapping(raw, f"{label}[{index}]", errors) + binding = (repr(item.get("path")), repr(item.get("name"))) + if binding in seen: + errors.append(f"{label}: duplicate executable binding {binding}") + seen.add(binding) + executable_test_resolves(item, f"{label}[{index}]", errors) + + +def validate_definition_of_done(waves: set[str], errors: list[str]) -> set[str]: + value = as_mapping(load_yaml(CONTRACTS / "definition-of-done.yaml"), "definition of done", errors) + exact_keys(value, {"apiVersion", "product", "requirements"}, "definition of done", errors) + if value.get("product") != "registry-server": + errors.append("definition of done: wrong product") + requirements = as_list(value.get("requirements"), "definition of done.requirements", errors) + identifiers = unique_ids(requirements, "definition of done.requirements", errors) + v1_identifiers = [ + item.get("id") + for item in requirements + if isinstance(item, dict) + and isinstance(item.get("id"), str) + and re.fullmatch(r"RS-V1-[0-9]{2}", item["id"]) + ] + if v1_identifiers != list(V1_REQUIREMENT_IDS): + errors.append("definition of done: must contain RS-V1-01 through RS-V1-44 exactly once in order") + for index, raw in enumerate(requirements): + item = as_mapping(raw, f"definition of done.requirements[{index}]", errors) + state = item.get("state") + expected = {"id", "phase", "state", "doneWhen", "journeys"} + if state in {"enforced", "partial"}: + expected.add("evidence") + if state in {"partial", "planned"}: + expected.add("gap") + exact_keys(item, expected, f"definition of done.requirements[{index}]", errors) + phase = item.get("phase") + if phase not in waves: + errors.append(f"definition of done.requirements[{index}].phase: unknown wave") + if state not in CONTRACT_STATES: + errors.append(f"definition of done.requirements[{index}].state: expected enforced, partial, or planned") + identifier = item.get("id") + nonempty_string(item.get("doneWhen"), f"definition of done.requirements[{index}].doneWhen", errors) + journeys = as_list(item.get("journeys"), f"definition of done.requirements[{index}].journeys", errors) + if not journeys or any(not isinstance(journey, str) or not journey for journey in journeys): + errors.append(f"definition of done.requirements[{index}].journeys: expected non-empty identifiers") + if all(isinstance(journey, str) for journey in journeys) and len(journeys) != len(set(journeys)): + errors.append(f"definition of done.requirements[{index}].journeys: duplicate identifier") + if state in {"enforced", "partial"}: + validate_evidence(item.get("evidence"), f"definition of done.requirements[{index}].evidence", errors) + if state in {"partial", "planned"}: + nonempty_string(item.get("gap"), f"definition of done.requirements[{index}].gap", errors) + return identifiers + + +def validate_acceptance(errors: list[str]) -> set[str]: + value = as_mapping(load_yaml(CONTRACTS / "acceptance-scenario-matrix.yaml"), "acceptance matrix", errors) + exact_keys(value, {"apiVersion", "product", "scenarios"}, "acceptance matrix", errors) + if value.get("product") != "registry-server": + errors.append("acceptance matrix: wrong product") + scenarios = as_list(value.get("scenarios"), "acceptance matrix.scenarios", errors) + identifiers = unique_ids(scenarios, "acceptance matrix.scenarios", errors) + ordered_identifiers = [item.get("id") for item in scenarios if isinstance(item, dict)] + if ordered_identifiers != list(ACCEPTANCE_JOURNEY_IDS): + errors.append("acceptance matrix: must contain RS-J01 through RS-J17 exactly once in order") + for index, raw in enumerate(scenarios): + item = as_mapping(raw, f"acceptance matrix.scenarios[{index}]", errors) + identifier = item.get("id") + state = item.get("state") + expected = {"id", "state", "doneWhen"} + if isinstance(identifier, str) and identifier in ACCEPTANCE_FIXTURES: + expected.update({"domain", "fixture"}) + if state in {"enforced", "partial"}: + expected.add("evidence") + if state in {"partial", "planned"}: + expected.add("gap") + exact_keys(item, expected, f"acceptance matrix.scenarios[{index}]", errors) + if state not in CONTRACT_STATES: + errors.append(f"acceptance matrix.scenarios[{index}].state: expected enforced, partial, or planned") + nonempty_string(item.get("doneWhen"), f"acceptance matrix.scenarios[{index}].doneWhen", errors) + if state in {"enforced", "partial"}: + validate_evidence(item.get("evidence"), f"acceptance matrix.scenarios[{index}].evidence", errors) + if state in {"partial", "planned"}: + nonempty_string(item.get("gap"), f"acceptance matrix.scenarios[{index}].gap", errors) + expected_fixture = ACCEPTANCE_FIXTURES.get(identifier) if isinstance(identifier, str) else None + if expected_fixture is not None: + if (item.get("domain"), item.get("fixture")) != expected_fixture: + errors.append(f"acceptance matrix.{identifier}: wrong coequal domain fixture binding") + fixture = item.get("fixture") + if relative_path(fixture, f"acceptance matrix.scenarios[{index}].fixture", errors): + if not (PRODUCT_ROOT / str(fixture) / "registry.yaml").is_file(): + errors.append(f"acceptance matrix.scenarios[{index}]: fixture is missing registry.yaml") + return identifiers + + +def validate_artifacts(errors: list[str]) -> None: + value = as_mapping(load_yaml(CONTRACTS / "artifact-inventory.yaml"), "artifact inventory", errors) + exact_keys(value, {"apiVersion", "product", "artifacts"}, "artifact inventory", errors) + artifacts = as_list(value.get("artifacts"), "artifact inventory.artifacts", errors) + paths: set[str] = set() + for index, raw in enumerate(artifacts): + item = as_mapping(raw, f"artifact inventory.artifacts[{index}]", errors) + exact_keys(item, {"path", "kind", "state"}, f"artifact inventory.artifacts[{index}]", errors) + path = item.get("path") + if not relative_path(path, f"artifact inventory.artifacts[{index}].path", errors): + continue + if path in paths: + errors.append(f"artifact inventory: duplicate path {path}") + paths.add(path) + if item.get("state") not in {"authored", "planned"}: + errors.append(f"artifact inventory.artifacts[{index}].state: expected authored or planned") + if item.get("state") == "authored" and not (PRODUCT_ROOT / str(path)).exists(): + errors.append(f"artifact inventory: authored artifact is missing: {path}") + nonempty_string(item.get("kind"), f"artifact inventory.artifacts[{index}].kind", errors) + + +def validate_package_layout(errors: list[str]) -> None: + value = as_mapping(load_yaml(CONTRACTS / "package-layout.yaml"), "package layout", errors) + exact_keys(value, {"apiVersion", "product", "packageVersion", "entries", "forbiddenEmbeddedRoles"}, "package layout", errors) + entries = as_list(value.get("entries"), "package layout.entries", errors) + paths: set[str] = set() + inventory: set[tuple[str, str, bool]] = set() + for index, raw in enumerate(entries): + item = as_mapping(raw, f"package layout.entries[{index}]", errors) + exact_keys(item, {"path", "role", "required"}, f"package layout.entries[{index}]", errors) + path = item.get("path") + if relative_path(path, f"package layout.entries[{index}].path", errors): + if path in paths: + errors.append(f"package layout: duplicate path {path}") + paths.add(path) + nonempty_string(item.get("role"), f"package layout.entries[{index}].role", errors) + if not isinstance(item.get("required"), bool): + errors.append(f"package layout.entries[{index}].required: expected boolean") + elif isinstance(path, str) and isinstance(item.get("role"), str): + inventory.add((path, item["role"], item["required"])) + if inventory != PACKAGE_LAYOUT_ENTRIES: + missing = sorted(PACKAGE_LAYOUT_ENTRIES - inventory) + unexpected = sorted(inventory - PACKAGE_LAYOUT_ENTRIES) + if missing: + errors.append(f"package layout: missing required entry tuples {missing}") + if unexpected: + errors.append(f"package layout: unexpected entry tuples {unexpected}") + forbidden = as_list(value.get("forbiddenEmbeddedRoles"), "package layout.forbiddenEmbeddedRoles", errors) + if ( + any(not isinstance(role, str) for role in forbidden) + or set(forbidden) != FORBIDDEN_EMBEDDED_ROLES + or len(forbidden) != len(FORBIDDEN_EMBEDDED_ROLES) + ): + errors.append("package layout.forbiddenEmbeddedRoles: must equal the complete forbidden role set") + + +def validate_postgres_entrypoint(errors: list[str]) -> None: + if not POSTGRES_ENTRYPOINT.is_file(): + errors.append("PostgreSQL entrypoint: scripts/test-postgres.sh is missing") + return + if not POSTGRES_ENTRYPOINT.stat().st_mode & 0o111: + errors.append("PostgreSQL entrypoint: scripts/test-postgres.sh is not executable") + source = POSTGRES_ENTRYPOINT.read_text(encoding="utf-8") + required_fragments = ( + "${REGISTRY_SERVER_TEST_DATABASE_URL:-}", + "REGISTRY_SERVER_TEST_DATABASE_URL must be set for PostgreSQL journeys.", + "exit 2", + "export CARGO_INCREMENTAL=0", + "export CARGO_PROFILE_DEV_DEBUG=0", + "export CARGO_PROFILE_TEST_DEBUG=0", + 'export RUSTC_WRAPPER="${RUSTC_WRAPPER-}"', + ) + for fragment in required_fragments: + if fragment not in source: + errors.append(f"PostgreSQL entrypoint: missing required fail-closed setting {fragment!r}") + cargo_commands = [line.strip() for line in source.splitlines() if line.lstrip().startswith("cargo ")] + if cargo_commands != list(POSTGRES_TEST_COMMANDS): + errors.append( + "PostgreSQL entrypoint: must run exactly the owned locked HTTP, kernel, " + "startup, compiled-schema, read, mutation, and package commands" + ) + + +def validate_postgres_tls_entrypoint(errors: list[str]) -> None: + if not POSTGRES_TLS_ENTRYPOINT.is_file(): + errors.append("PostgreSQL TLS entrypoint: scripts/test-postgres-tls.sh is missing") + return + if not POSTGRES_TLS_ENTRYPOINT.stat().st_mode & 0o111: + errors.append("PostgreSQL TLS entrypoint: scripts/test-postgres-tls.sh is not executable") + source = POSTGRES_TLS_ENTRYPOINT.read_text(encoding="utf-8") + required_fragments = ( + "mktemp -d /tmp/registry-server-postgres-tls.XXXXXX", + "trap cleanup EXIT", + "/tmp/registry-server-postgres-tls.*)", + 'rm -rf -- "$tls_dir"', + "docker cp", + 'pg_ctl -D "$postgres_data_directory" reload', + 'pg_isready -q -d "$database_url"', + "openssl x509 -req", + "subjectAltName=DNS:%s", + "REGISTRY_SERVER_TEST_TLS_CA_DER_PATH", + "REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH", + "REGISTRY_SERVER_TEST_TLS_WRONG_CA_DER_PATH", + "REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL", + "export CARGO_INCREMENTAL=0", + "export CARGO_PROFILE_DEV_DEBUG=0", + "export CARGO_PROFILE_TEST_DEBUG=0", + "export RUSTC_WRAPPER=", + ) + for fragment in required_fragments: + if fragment not in source: + errors.append(f"PostgreSQL TLS entrypoint: missing required proof setting {fragment!r}") + if "TMPDIR" in source: + errors.append("PostgreSQL TLS entrypoint: temporary cleanup must be limited to /tmp") + cargo_commands = [line.strip() for line in source.splitlines() if line.lstrip().startswith("cargo ")] + if cargo_commands != [POSTGRES_TLS_TEST_COMMAND]: + errors.append("PostgreSQL TLS entrypoint: must run exactly the owned locked postgres_tls command") + if not CI_WORKFLOW.is_file(): + errors.append("PostgreSQL TLS entrypoint: CI workflow is missing") + return + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + required_workflow_fragments = ( + "ports:\n - 5432/tcp", + "REGISTRY_SERVER_TEST_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server", + "REGISTRY_SERVER_TEST_TLS_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server", + "REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL: postgresql://registry_server:registry_server_test@127.0.0.1:${{ job.services.postgres.ports[5432] }}/registry_server", + "REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }}", + "REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH: ${{ runner.temp }}/registry-server-postgres-trusted-ca.pem", + "run: products/registry-server/scripts/test-postgres-tls.sh", + ) + for fragment in required_workflow_fragments: + if fragment not in workflow: + errors.append(f"PostgreSQL TLS entrypoint: CI is missing required integration {fragment!r}") + + +def validate_security(waves: set[str], errors: list[str]) -> None: + matrix = as_mapping(load_yaml(CONTRACTS / "security-invariant-matrix.yaml"), "security matrix", errors) + traceability = as_mapping(load_yaml(CONTRACTS / "security-test-traceability.yaml"), "security traceability", errors) + exact_keys(matrix, {"apiVersion", "product", "invariants"}, "security matrix", errors) + exact_keys(traceability, {"apiVersion", "product", "traceability"}, "security traceability", errors) + invariants = as_list(matrix.get("invariants"), "security matrix.invariants", errors) + rows = {row.get("id"): row for row in invariants if isinstance(row, dict) and isinstance(row.get("id"), str)} + unique_ids(invariants, "security matrix.invariants", errors) + if set(rows) != {f"RS-SEC-{index:02d}" for index in range(1, 20)}: + errors.append("security matrix: must contain the complete closed product invariant identifiers") + negatives: set[str] = set() + for index, raw in enumerate(invariants): + item = as_mapping(raw, f"security matrix.invariants[{index}]", errors) + state = item.get("state") + if state == "planned": + exact_keys(item, {"id", "state", "targetWave", "threat", "enforcementPoint", "refusal", "negativeId"}, f"security matrix.invariants[{index}]", errors) + elif state == "enforced": + exact_keys(item, {"id", "state", "targetWave", "threat", "enforcementPoint", "refusal", "negativeId", "negativeTest"}, f"security matrix.invariants[{index}]", errors) + executable_test_resolves(item.get("negativeTest"), f"security matrix.invariants[{index}].negativeTest", errors) + else: + errors.append(f"security matrix.invariants[{index}].state: expected planned or enforced") + continue + if item.get("targetWave") not in waves: + errors.append(f"security matrix.invariants[{index}].targetWave: unknown wave") + for key in ("threat", "enforcementPoint", "refusal"): + nonempty_string(item.get(key), f"security matrix.invariants[{index}].{key}", errors) + negative = item.get("negativeId") + if not isinstance(negative, str) or not IDENTIFIER.fullmatch(negative): + errors.append(f"security matrix.invariants[{index}].negativeId: expected stable identifier") + elif negative in negatives: + errors.append(f"security matrix: duplicate negative identifier {negative}") + else: + negatives.add(negative) + traces = as_list(traceability.get("traceability"), "security traceability.traceability", errors) + unique_ids(traces, "security traceability.traceability", errors) + trace_rows = {row.get("id"): row for row in traces if isinstance(row, dict) and isinstance(row.get("id"), str)} + if set(trace_rows) != set(rows): + errors.append("security traceability: must have one row for every invariant") + for identifier, invariant in rows.items(): + trace = trace_rows.get(identifier) + if not isinstance(trace, dict): + continue + state = invariant.get("state") + expected = {"id", "state", "negativeId"} if state == "planned" else {"id", "state", "negativeId", "negativeTest"} + exact_keys(trace, expected, f"security traceability.{identifier}", errors) + if trace.get("state") != state or trace.get("negativeId") != invariant.get("negativeId"): + errors.append(f"security traceability.{identifier}: lifecycle does not match invariant") + if state == "enforced": + executable_test_resolves(trace.get("negativeTest"), f"security traceability.{identifier}.negativeTest", errors) + if trace.get("negativeTest") != invariant.get("negativeTest"): + errors.append(f"security traceability.{identifier}: negative test does not match invariant") + + +def validate_fixture(errors: list[str]) -> None: + fixture = PRODUCT_ROOT / "acceptance/asset-site-placement/registry.yaml" + document = as_mapping(load_yaml(fixture), "asset fixture", errors) + exact_keys( + document, + { + "apiVersion", + "kind", + "registry", + "package", + "manifestProjection", + "modules", + "entities", + "accessProfiles", + "vocabularies", + }, + "asset fixture", + errors, + ) + if document.get("apiVersion") != "registry.registrystack.org/v1alpha1" or document.get("kind") != "RegistryProject": + errors.append("asset fixture: must identify the strict Registry Project authoring form") + registry = as_mapping(document.get("registry"), "asset fixture.registry", errors) + exact_keys(registry, {"id", "version", "defaultLanguage"}, "asset fixture.registry", errors) + if registry.get("id") != "asset-site-placement": + errors.append("asset fixture: wrong non-person project identity") + package = as_mapping(document.get("package"), "asset fixture.package", errors) + exact_keys( + package, + {"environment", "instanceId", "sequence", "sourceRevision"}, + "asset fixture.package", + errors, + ) + expected_package = { + "environment": "acceptance", + "instanceId": "asset-site-placement-acceptance", + "sequence": 1, + "sourceRevision": "asset-site-placement-acceptance-0.1.0", + } + if type(package.get("sequence")) is not int: + errors.append("asset fixture.package.sequence: expected integer") + if package != expected_package: + errors.append("asset fixture.package: must equal the committed acceptance package identity") + entities = as_list(document.get("entities"), "asset fixture.entities", errors) + entity_ids = {item.get("id") for item in entities if isinstance(item, dict)} + required_entities = {"asset-item", "asset-site", "asset-placement", "inspection-event"} + if entity_ids != required_entities: + errors.append("asset fixture: must declare exactly the complete non-person entity set") + routes = {item.get("route") for item in entities if isinstance(item, dict)} + if routes != {"assets", "sites", "placements", "inspections"}: + errors.append("asset fixture: routes must be explicit and configuration-owned") + create_only = [item for item in entities if isinstance(item, dict) and item.get("id") == "inspection-event"] + if len(create_only) != 1 or create_only[0].get("mutationMode") != "create_only": + errors.append("asset fixture: inspection event must prove create-only configuration") + placement = next((item for item in entities if isinstance(item, dict) and item.get("id") == "asset-placement"), {}) + temporal = placement.get("temporal") if isinstance(placement, dict) else None + if not isinstance(temporal, dict) or temporal.get("scopeFields") != ["asset"]: + errors.append("asset fixture: placement must declare scoped valid-time") + profiles = as_list(document.get("accessProfiles"), "asset fixture.accessProfiles", errors) + if [profile.get("id") for profile in profiles if isinstance(profile, dict)] != [ + "asset-operator", + "site-planner", + ]: + errors.append("asset fixture: must declare the exact two configured access profiles") + + +def validate_all() -> list[str]: + errors: list[str] = [] + for contract in CONTRACTS.glob("*.yaml"): + no_placeholders(load_yaml(contract), contract.name, errors) + waves = validate_schedule(errors) + requirements = validate_definition_of_done(waves, errors) + journeys = validate_acceptance(errors) + validate_artifacts(errors) + validate_package_layout(errors) + validate_postgres_entrypoint(errors) + validate_postgres_tls_entrypoint(errors) + validate_security(waves, errors) + validate_fixture(errors) + for requirement in as_list(load_yaml(CONTRACTS / "definition-of-done.yaml").get("requirements"), "definition requirements", errors): + if isinstance(requirement, dict): + for journey in requirement.get("journeys", []): + if journey not in journeys: + errors.append(f"definition of done: {requirement.get('id')} references unknown journey {journey}") + if "RS-W0-CONTRACTS" not in requirements: + errors.append("definition of done: W0 contract requirement is missing") + schedule = as_mapping(load_yaml(CONTRACTS / "implementation-schedule.yaml"), "schedule", errors) + for index, raw_wave in enumerate(as_list(schedule.get("waves"), "schedule.waves", errors)): + wave = as_mapping(raw_wave, f"schedule.waves[{index}]", errors) + for criterion in as_list(wave.get("exitCriteria"), f"schedule.waves[{index}].exitCriteria", errors): + if criterion not in requirements: + errors.append(f"schedule.waves[{index}]: exit criterion does not resolve: {criterion}") + return errors + + +def main() -> int: + errors = validate_all() + if errors: + print("Registry Server product contract validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print("Registry Server product contracts are internally complete") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index a76c52e9f8..56623d5cec 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -163,6 +163,23 @@ "Relay client source neutrality", "run: products/relay-v2/scripts/check-source-neutrality.sh", ), + ("Registry Server product contract gate", "registry-server-contracts:"), + ( + "Registry Server contract consistency", + "run: products/registry-server/scripts/check-contracts.sh", + ), + ( + "Registry Server PostgreSQL journeys", + "run: products/registry-server/scripts/test-postgres.sh", + ), + ( + "Registry Server adopter workflow", + "run: products/registry-server/scripts/test-adopter-workflow.sh", + ), + ( + "Registry Server PostgreSQL 17 image pin", + "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675", + ), ( "Release Linux Node client path filter", "release_linux_node_clients: ${{ steps.filter.outputs.release_linux_node_clients }}", diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 2481125f1b..fd0ae0d78f 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -714,6 +714,38 @@ def test_missing_relay_client_contract_gates_are_reported(self) -> None: text = self.workflow.replace(snippet, replacement) self.assertIn(gate, self.module.missing_gates(text)) + def test_missing_registry_server_product_gates_are_reported(self) -> None: + for snippet, replacement, gate in ( + ( + "registry-server-contracts:", + "registry-server-disabled:", + "Registry Server product contract gate", + ), + ( + "run: products/registry-server/scripts/check-contracts.sh", + "run: true # Registry Server contracts disabled", + "Registry Server contract consistency", + ), + ( + "run: products/registry-server/scripts/test-postgres.sh", + "run: true # Registry Server PostgreSQL disabled", + "Registry Server PostgreSQL journeys", + ), + ( + "run: products/registry-server/scripts/test-adopter-workflow.sh", + "run: true # Registry Server adopter workflow disabled", + "Registry Server adopter workflow", + ), + ( + "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675", + "postgres:17.11", + "Registry Server PostgreSQL 17 image pin", + ), + ): + with self.subTest(gate=gate): + text = self.workflow.replace(snippet, replacement, 1) + self.assertIn(gate, self.module.missing_gates(text)) + def test_linux_node_release_proof_is_two_runner_read_only_and_aggregated( self, ) -> None: From b26275aad1a85a14b99e0898abda401ab938872d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 30 Aug 2026 13:19:44 +0700 Subject: [PATCH 02/19] feat(server): add governed semantic metadata projection Signed-off-by: Jeremi Joslin --- crates/registry-manifest-core/src/lib.rs | 7 + .../tests/metadata_core.rs | 17 + crates/registry-server/src/artifacts.rs | 11 +- crates/registry-server/src/compiler.rs | 244 ++++++++++-- crates/registry-server/src/contract.rs | 142 ++++++- .../registry-server/src/manifest_adapter.rs | 300 ++++++++++++--- crates/registry-server/src/package.rs | 12 + .../tests/compiler_contract.rs | 84 +++- .../registry-server/tests/postgres_package.rs | 9 + crates/registry-serverctl/src/lib.rs | 9 +- crates/registry-serverctl/tests/cli.rs | 43 ++- products/registry-server/DECISIONS.md | 12 + products/registry-server/README.md | 28 ++ .../publicschema-household/registry.yaml | 89 ++++- .../contracts/package-layout.yaml | 1 + .../generated/manifest/dcat.jsonld | 1 + .../authoring/registry-project.schema.json | 361 +++++++++++++++++- .../scripts/compare-generated-tree.py | 1 + .../scripts/validate_product.py | 1 + 19 files changed, 1268 insertions(+), 104 deletions(-) create mode 100644 products/registry-server/generated/asset-site-placement/generated/manifest/dcat.jsonld diff --git a/crates/registry-manifest-core/src/lib.rs b/crates/registry-manifest-core/src/lib.rs index 2bea032571..b2b8cccd7c 100644 --- a/crates/registry-manifest-core/src/lib.rs +++ b/crates/registry-manifest-core/src/lib.rs @@ -2794,6 +2794,13 @@ pub fn render_base_dcat(compiled: &CompiledMetadata) -> Value { .map(|dataset| base_dcat_dataset(compiled, dataset)) .collect::>(), }); + let data_services = compiled + .data_services() + .map(|service| data_service_node(compiled, service)) + .collect::>(); + if !data_services.is_empty() { + catalog["dcat:service"] = Value::Array(data_services); + } let mut included = standard_reference_nodes(compiled); included.extend(dcat_range_reference_nodes(&catalog)); append_included_nodes(&mut catalog, included); diff --git a/crates/registry-manifest-core/tests/metadata_core.rs b/crates/registry-manifest-core/tests/metadata_core.rs index 43b9e7a323..5465d06054 100644 --- a/crates/registry-manifest-core/tests/metadata_core.rs +++ b/crates/registry-manifest-core/tests/metadata_core.rs @@ -1426,6 +1426,23 @@ fn cpsv_ap_service_first_fixture_matches_contract_golden() { })); } +#[test] +fn base_dcat_includes_declared_data_services() { + let compiled = compile_manifest(&service_first_fixture()).expect("compile"); + let dcat = render_base_dcat(&compiled); + let services = dcat["dcat:service"] + .as_array() + .expect("base DCAT catalog data services"); + + assert!(!services.is_empty()); + assert!(services + .iter() + .all(|service| service["@type"] == "dcat:DataService")); + assert!(services.iter().any(|service| { + service["dcat:endpointURL"] == "https://health.example.gov/api/coverage/verify" + })); +} + fn has_json_type(node: &Value, expected: &str) -> bool { match node.get("@type") { Some(Value::String(kind)) => kind == expected, diff --git a/crates/registry-server/src/artifacts.rs b/crates/registry-server/src/artifacts.rs index 4d5f99e047..36e12bf482 100644 --- a/crates/registry-server/src/artifacts.rs +++ b/crates/registry-server/src/artifacts.rs @@ -12,7 +12,7 @@ use crate::contract::{ }; use crate::diagnostics::Diagnostic; use crate::generated_ddl::DdlInventory; -use crate::manifest_adapter::project_manifest_bytes; +use crate::manifest_adapter::project_manifest_artifacts; use crate::model::{ CompiledAccessInventory, CompiledEntity, CompiledEventDeliveryInventory, CompiledMetadataInventory, CompiledModuleIdentity, CompiledQueryInventory, CompiledQueryKind, @@ -136,11 +136,18 @@ pub(crate) fn generate_artifacts( let openapi = openapi_document(registry_id, version, entities, routes, &schemas); insert_json_value(&mut artifacts, "generated/openapi.json", &openapi)?; if let Some(projection) = manifest_projection { + let projected = project_manifest_artifacts(registry_id, projection, entities)?; insert_bytes( &mut artifacts, "generated/manifest/registry-manifest.json", "application/json", - project_manifest_bytes(registry_id, projection, entities)?, + projected.manifest, + ); + insert_bytes( + &mut artifacts, + "generated/manifest/dcat.jsonld", + "application/ld+json", + projected.dcat, ); } Ok(GeneratedArtifacts { artifacts }) diff --git a/crates/registry-server/src/compiler.rs b/crates/registry-server/src/compiler.rs index 036d57db78..2fe881519e 100644 --- a/crates/registry-server/src/compiler.rs +++ b/crates/registry-server/src/compiler.rs @@ -13,8 +13,9 @@ use crate::artifacts::generate_artifacts; use crate::contract::{ parsed_bbox, valid_decimal_bounds, valid_structured_schema, AccessProfileSource, Classification, ConstraintSource, EntityExtensionSource, EntitySource, EventTrigger, - FieldSource, FieldTypeSource, MutationMode, Operation, RegistryModule, RegistryProject, - UniqueWhenPredicate, ValidTimeRole, WebhookDeadLetterMode, MAX_STRUCTURED_VALUE_BYTES, + FieldSource, FieldTypeSource, ManifestProjectionTextSource, MutationMode, Operation, + RegistryModule, RegistryProject, UniqueWhenPredicate, ValidTimeRole, WebhookDeadLetterMode, + MAX_STRUCTURED_VALUE_BYTES, }; use crate::diagnostics::{CompileFailure, Diagnostic}; use crate::generated_ddl::generate_ddl; @@ -230,10 +231,9 @@ fn validate_project_header( "manifest_projection.catalog.base_url.empty", errors, ); - nonempty( + validate_projection_text( &projection.catalog.title, "project.manifestProjection.catalog.title", - "manifest_projection.catalog.title.empty", errors, ); nonempty( @@ -242,34 +242,23 @@ fn validate_project_header( "manifest_projection.catalog.publisher.name_empty", errors, ); - if projection - .catalog - .description - .as_deref() - .is_some_and(|value| value.trim().is_empty()) - { - errors.push(Diagnostic::error( - "manifest_projection.catalog.description_empty", + if let Some(description) = projection.catalog.description.as_ref() { + validate_projection_text( + description, "project.manifestProjection.catalog.description", - "optional Registry Manifest projection text must not be empty", - )); + errors, + ); } - if projection - .dataset - .description - .as_deref() - .is_some_and(|value| value.trim().is_empty()) - { - errors.push(Diagnostic::error( - "manifest_projection.dataset.description_empty", + if let Some(description) = projection.dataset.description.as_ref() { + validate_projection_text( + description, "project.manifestProjection.dataset.description", - "optional Registry Manifest projection text must not be empty", - )); + errors, + ); } - nonempty( + validate_projection_text( &projection.dataset.title, "project.manifestProjection.dataset.title", - "manifest_projection.dataset.title.empty", errors, ); if projection @@ -284,6 +273,31 @@ fn validate_project_header( "optional Registry Manifest projection text must not be empty", )); } + if let Some(service) = projection.data_service.as_ref() { + validate_id( + &service.id, + "project.manifestProjection.dataService.id", + errors, + ); + validate_projection_text( + &service.title, + "project.manifestProjection.dataService.title", + errors, + ); + if let Some(description) = service.description.as_ref() { + validate_projection_text( + description, + "project.manifestProjection.dataService.description", + errors, + ); + } + nonempty( + &service.endpoint_url, + "project.manifestProjection.dataService.endpointUrl", + "manifest_projection.data_service.endpoint_url_empty", + errors, + ); + } } } @@ -347,6 +361,155 @@ fn validate_manifest_projection( "the Registry Manifest projection access profile must have one disclosure mode", )); } + + let visible = entities + .values() + .filter(|entity| entity.classification <= projection.classification_ceiling) + .filter_map(|entity| { + let profile = entity.access_profiles.get(&projection.access_profile)?; + (profile.operations.contains(&Operation::Get) + || profile.operations.contains(&Operation::List)) + .then(|| { + let fields = entity + .fields + .values() + .filter(|field| profile.readable_fields.contains(&field.id)) + .filter(|field| field.classification <= projection.classification_ceiling) + .map(|field| (field.id.as_str(), field)) + .collect::>(); + (entity.id.as_str(), (entity, fields)) + }) + }) + .collect::>(); + + let mut entity_ids = BTreeSet::new(); + for metadata in &projection.entities { + let path = format!("project.manifestProjection.entities[{}]", metadata.id); + if !entity_ids.insert(metadata.id.as_str()) { + errors.push(Diagnostic::error( + "manifest_projection.entity.duplicate", + path, + "Registry Manifest entity metadata must be unique", + )); + continue; + } + let Some((_entity, visible_fields)) = visible.get(metadata.id.as_str()) else { + errors.push(Diagnostic::error( + "manifest_projection.entity.not_visible", + path, + "Registry Manifest metadata may describe only an entity visible through the selected access profile", + )); + continue; + }; + if let Some(title) = metadata.title.as_ref() { + validate_projection_text(title, &format!("{path}.title"), errors); + } + if let Some(description) = metadata.description.as_ref() { + validate_projection_text(description, &format!("{path}.description"), errors); + } + let mut identifier_fields = BTreeSet::new(); + for identifier in &metadata.identifiers { + let field_is_projected = visible_fields + .get(identifier.field.as_str()) + .is_some_and(|field| manifest_projects_field(field)); + if !identifier_fields.insert(identifier.field.as_str()) + || !field_is_projected + || identifier.kind.trim().is_empty() + { + errors.push(Diagnostic::error( + "manifest_projection.identifier.invalid", + format!("{path}.identifiers[{}]", identifier.field), + "Registry Manifest identifiers must uniquely reference visible fields and declare a kind", + )); + } + } + let mut field_ids = BTreeSet::new(); + for field_metadata in &metadata.fields { + let field_path = format!("{path}.fields[{}]", field_metadata.id); + if !field_ids.insert(field_metadata.id.as_str()) { + errors.push(Diagnostic::error( + "manifest_projection.field.duplicate", + field_path, + "Registry Manifest field metadata must be unique within an entity", + )); + continue; + } + let Some(field) = visible_fields.get(field_metadata.id.as_str()) else { + errors.push(Diagnostic::error( + "manifest_projection.field.not_visible", + field_path, + "Registry Manifest metadata may describe only a field visible through the selected access profile", + )); + continue; + }; + let is_reference = matches!(&field.field_type, FieldTypeSource::Reference { .. }); + if !is_reference && !manifest_projects_field(field) { + errors.push(Diagnostic::error( + "manifest_projection.field.not_representable", + field_path, + "Registry Manifest field metadata may describe only a field representable by the portable Manifest model", + )); + continue; + } + let has_scalar_metadata = !field_metadata.concepts.is_empty() + || field_metadata.unit.is_some() + || field_metadata.language.is_some(); + let has_relationship_metadata = field_metadata.relationship_role.is_some() + || field_metadata.relationship_concept_uri.is_some(); + if (is_reference && has_scalar_metadata) || (!is_reference && has_relationship_metadata) + { + errors.push(Diagnostic::error( + "manifest_projection.field.metadata_kind", + field_path, + "Registry Manifest scalar and relationship metadata must match the configured field type", + )); + } + } + } + + let visible_vocabularies = visible + .values() + .flat_map(|(_entity, fields)| fields.values()) + .filter_map(|field| match &field.field_type { + FieldTypeSource::VocabularyCode { vocabulary, values } => { + Some((vocabulary.as_str(), values.as_slice())) + } + _ => None, + }) + .collect::>(); + let mut vocabulary_ids = BTreeSet::new(); + for metadata in &projection.vocabularies { + let path = format!("project.manifestProjection.vocabularies[{}]", metadata.id); + if !vocabulary_ids.insert(metadata.id.as_str()) { + errors.push(Diagnostic::error( + "manifest_projection.vocabulary.duplicate", + path, + "Registry Manifest vocabulary metadata must be unique", + )); + continue; + } + let Some(values) = visible_vocabularies.get(metadata.id.as_str()) else { + errors.push(Diagnostic::error( + "manifest_projection.vocabulary.not_visible", + path, + "Registry Manifest metadata may describe only a vocabulary used by a visible field", + )); + continue; + }; + let mut codes = BTreeSet::new(); + for concept in &metadata.concepts { + if !codes.insert(concept.code.as_str()) || !values.contains(&concept.code) { + errors.push(Diagnostic::error( + "manifest_projection.vocabulary.concept_invalid", + format!("{path}.concepts[{}]", concept.code), + "Registry Manifest vocabulary concepts must uniquely reference configured codes", + )); + } + if let Some(label) = concept.label.as_ref() { + validate_projection_text(label, &format!("{path}.concepts[].label"), errors); + } + } + } } fn order_modules( @@ -2734,6 +2897,35 @@ fn validate_language(value: &str, errors: &mut Vec) { } } +fn validate_projection_text( + value: &ManifestProjectionTextSource, + path: &str, + errors: &mut Vec, +) { + let invalid = match value { + ManifestProjectionTextSource::Plain(value) => value.trim().is_empty(), + ManifestProjectionTextSource::Localized(values) => { + values.is_empty() || values.values().any(|value| value.trim().is_empty()) + } + }; + if invalid { + errors.push(Diagnostic::error( + "manifest_projection.text.empty", + path, + "Registry Manifest projection text and every localized value must not be empty", + )); + } +} + +fn manifest_projects_field(field: &CompiledField) -> bool { + !matches!( + &field.field_type, + FieldTypeSource::Reference { .. } + | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Structured { .. } + ) +} + fn nonempty(value: &str, path: &str, code: &str, errors: &mut Vec) { if value.trim().is_empty() { errors.push(Diagnostic::error( diff --git a/crates/registry-server/src/contract.rs b/crates/registry-server/src/contract.rs index 55219d634a..1e3cf13157 100644 --- a/crates/registry-server/src/contract.rs +++ b/crates/registry-server/src/contract.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use jsonschema::{Draft, JSONSchema}; use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; @@ -60,6 +60,20 @@ pub struct ManifestProjectionSource { pub classification_ceiling: Classification, pub catalog: ManifestProjectionCatalogSource, pub dataset: ManifestProjectionDatasetSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_service: Option, + #[serde(default)] + pub entities: Vec, + #[serde(default)] + pub vocabularies: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ManifestProjectionTextSource { + Plain(String), + Localized(BTreeMap), } #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -67,12 +81,38 @@ pub struct ManifestProjectionSource { #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct ManifestProjectionCatalogSource { pub base_url: String, - pub title: String, + pub title: ManifestProjectionTextSource, #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, + pub description: Option, pub publisher: ManifestProjectionPublisherSource, #[serde(default, skip_serializing_if = "Option::is_none")] pub participant_id: Option, + #[serde(default)] + pub conforms_to: Vec, + #[serde(default)] + pub standards: ManifestProjectionStandardsSource, + #[serde(default)] + pub application_profiles: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionStandardsSource { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dcat: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shacl: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub json_schema: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionApplicationProfileSource { + pub id: String, + pub version: String, } #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -90,13 +130,105 @@ pub struct ManifestProjectionPublisherSource { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct ManifestProjectionDatasetSource { - pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, + pub id: Option, + pub title: ManifestProjectionTextSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub owner: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, + #[serde(default)] + pub conforms_to: Vec, + #[serde(default)] + pub applicable_legislation: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spatial_coverage: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionDataServiceSource { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub iri: Option, + pub title: ManifestProjectionTextSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub endpoint_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conforms_to: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionEntitySource { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub concept_uri: Option, + #[serde(default)] + pub identifiers: Vec, + #[serde(default)] + pub fields: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionIdentifierSource { + pub field: String, + pub kind: String, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionFieldSource { + pub id: String, + #[serde(default)] + pub concepts: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relationship_role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relationship_concept_uri: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionVocabularySource { + pub id: String, + pub scheme_iri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_ref: Option, + #[serde(default)] + pub concepts: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionVocabularyConceptSource { + pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub iri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, } #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] diff --git a/crates/registry-server/src/manifest_adapter.rs b/crates/registry-server/src/manifest_adapter.rs index 3e76de6e63..a7ef6bfa3a 100644 --- a/crates/registry-server/src/manifest_adapter.rs +++ b/crates/registry-server/src/manifest_adapter.rs @@ -4,35 +4,44 @@ use std::collections::{BTreeMap, BTreeSet}; use registry_manifest_core::{ - compile_manifest, AccessRights, AdmsStatus, CatalogManifest, DatasetManifest, FieldConstraints, - FieldManifest, FieldType, LocalizedText, MetadataError, MetadataManifest, PublisherManifest, - RelationshipManifest, Sensitivity, + compile_manifest, render_base_dcat, AccessRights, AdmsStatus, ApplicationProfile, + CatalogManifest, CodelistConcept, CodelistManifest, DataServiceManifest, DatasetManifest, + FieldConstraints, FieldManifest, FieldType, IdentifierManifest, LocalizedText, MetadataError, + MetadataManifest, PublisherManifest, RelationshipManifest, Sensitivity, StandardsManifest, }; use registry_platform_canonical_json::canonicalize_json; use serde_json::Value; use crate::artifacts::decimal_pattern; use crate::contract::{ - Classification, FieldTypeSource, ManifestProjectionDatasetStatus, ManifestProjectionSource, - Operation, + Classification, FieldTypeSource, ManifestProjectionDatasetStatus, + ManifestProjectionEntitySource, ManifestProjectionFieldSource, ManifestProjectionSource, + ManifestProjectionTextSource, ManifestProjectionVocabularySource, Operation, }; use crate::diagnostics::Diagnostic; use crate::model::{CompiledEntity, CompiledField}; -pub(crate) fn project_manifest_bytes( +pub(crate) struct ProjectedManifestArtifacts { + pub manifest: Vec, + pub dcat: Vec, +} + +pub(crate) fn project_manifest_artifacts( registry_id: &str, projection: &ManifestProjectionSource, entities: &BTreeMap, -) -> Result, Diagnostic> { +) -> Result { let manifest = project_manifest(registry_id, projection, entities); - compile_manifest(&manifest).map_err(manifest_diagnostic)?; let mut value = serde_json::to_value(&manifest).map_err(|_| manifest_canonicalization_diagnostic())?; strip_null_members(&mut value); let manifest: MetadataManifest = serde_json::from_value(value.clone()) .map_err(|_| manifest_canonicalization_diagnostic())?; - compile_manifest(&manifest).map_err(manifest_diagnostic)?; - canonicalize_json(&value).map_err(|_| manifest_canonicalization_diagnostic()) + let compiled = compile_manifest(&manifest).map_err(manifest_diagnostic)?; + let manifest = canonicalize_json(&value).map_err(|_| manifest_canonicalization_diagnostic())?; + let dcat = canonicalize_json(&render_base_dcat(&compiled)) + .map_err(|_| manifest_canonicalization_diagnostic())?; + Ok(ProjectedManifestArtifacts { manifest, dcat }) } fn project_manifest( @@ -41,6 +50,12 @@ fn project_manifest( entities: &BTreeMap, ) -> MetadataManifest { let visible_entities = visible_entities(projection, entities); + let dataset_id = projection + .dataset + .id + .as_deref() + .unwrap_or(registry_id) + .to_owned(); let access_rights = if selected_profile_is_anonymous(projection, &visible_entities) { AccessRights::Public } else { @@ -52,21 +67,29 @@ fn project_manifest( catalog: CatalogManifest { id: registry_id.to_owned(), base_url: projection.catalog.base_url.clone(), - title: LocalizedText::Plain(projection.catalog.title.clone()), - description: projection - .catalog - .description - .clone() - .map(LocalizedText::Plain), + title: localized_text(&projection.catalog.title), + description: projection.catalog.description.as_ref().map(localized_text), publisher: PublisherManifest { name: projection.catalog.publisher.name.clone(), iri: projection.catalog.publisher.iri.clone(), authority_type: projection.catalog.publisher.authority_type.clone(), }, participant_id: projection.catalog.participant_id.clone(), - conforms_to: Vec::new(), - standards: Default::default(), - application_profiles: Vec::new(), + conforms_to: projection.catalog.conforms_to.clone(), + standards: StandardsManifest { + dcat: projection.catalog.standards.dcat.clone(), + shacl: projection.catalog.standards.shacl.clone(), + json_schema: projection.catalog.standards.json_schema.clone(), + }, + application_profiles: projection + .catalog + .application_profiles + .iter() + .map(|profile| ApplicationProfile { + id: profile.id.clone(), + version: profile.version.clone(), + }) + .collect(), }, vocabularies: BTreeMap::new(), profiles: Vec::new(), @@ -76,23 +99,32 @@ fn project_manifest( evidence_types: Vec::new(), authorities: Vec::new(), public_services: Vec::new(), - data_services: Vec::new(), + data_services: projection + .data_service + .iter() + .map(|service| DataServiceManifest { + id: service.id.clone(), + iri: service.iri.clone(), + title: localized_text(&service.title), + description: service.description.as_ref().map(localized_text), + endpoint_url: Some(service.endpoint_url.clone()), + endpoint_description: service.endpoint_description.clone(), + serves_datasets: vec![dataset_id.clone()], + conforms_to: service.conforms_to.clone(), + }) + .collect(), forms: Vec::new(), datasets: vec![DatasetManifest { - id: registry_id.to_owned(), - title: LocalizedText::Plain(projection.dataset.title.clone()), - description: projection - .dataset - .description - .clone() - .map(LocalizedText::Plain), + id: dataset_id, + title: localized_text(&projection.dataset.title), + description: projection.dataset.description.as_ref().map(localized_text), owner: projection.dataset.owner.clone(), sensitivity: sensitivity(projection.classification_ceiling), access_rights, update_frequency: Default::default(), - conforms_to: Vec::new(), - applicable_legislation: Vec::new(), - spatial_coverage: None, + conforms_to: projection.dataset.conforms_to.clone(), + applicable_legislation: projection.dataset.applicable_legislation.clone(), + spatial_coverage: projection.dataset.spatial_coverage.clone(), status: projection.dataset.status.map(adms_status), public_services: Vec::new(), policy: None, @@ -102,7 +134,14 @@ fn project_manifest( .map(|entity| project_entity(projection, entity, &visible_entities)) .collect(), }], - codelists: Vec::new(), + codelists: project_codelists(projection, &visible_entities), + } +} + +fn localized_text(source: &ManifestProjectionTextSource) -> LocalizedText { + match source { + ManifestProjectionTextSource::Plain(value) => LocalizedText::Plain(value.clone()), + ManifestProjectionTextSource::Localized(values) => LocalizedText::Localized(values.clone()), } } @@ -142,6 +181,10 @@ fn project_entity( entity: &CompiledEntity, visible_entities: &[&CompiledEntity], ) -> registry_manifest_core::EntityManifest { + let metadata = projection + .entities + .iter() + .find(|metadata| metadata.id == entity.id); let visible_entity_ids = visible_entities .iter() .map(|entity| entity.id.as_str()) @@ -157,28 +200,60 @@ fn project_entity( .values() .filter(|field| readable_fields.contains(&field.id)) .filter(|field| field.classification <= projection.classification_ceiling) - .filter_map(project_field) + .filter_map(|field| project_field(field, field_metadata(metadata, &field.id), projection)) .collect(); let relationships = entity .fields .values() .filter(|field| readable_fields.contains(&field.id)) .filter(|field| field.classification <= projection.classification_ceiling) - .filter_map(|field| project_relationship(field, &visible_entity_ids)) + .filter_map(|field| { + project_relationship( + field, + field_metadata(metadata, &field.id), + &visible_entity_ids, + ) + }) .collect(); registry_manifest_core::EntityManifest { name: entity.id.clone(), - title: None, - description: None, - concept_uri: None, - identifiers: Vec::new(), + title: metadata + .and_then(|metadata| metadata.title.as_ref()) + .map(localized_text), + description: metadata + .and_then(|metadata| metadata.description.as_ref()) + .map(localized_text), + concept_uri: metadata.and_then(|metadata| metadata.concept_uri.clone()), + identifiers: metadata + .map(|metadata| { + metadata + .identifiers + .iter() + .map(|identifier| IdentifierManifest { + name: identifier.field.clone(), + kind: identifier.kind.clone(), + }) + .collect() + }) + .unwrap_or_default(), fields, relationships, } } -fn project_field(field: &CompiledField) -> Option { +fn field_metadata<'a>( + entity: Option<&'a ManifestProjectionEntitySource>, + field_id: &str, +) -> Option<&'a ManifestProjectionFieldSource> { + entity.and_then(|entity| entity.fields.iter().find(|field| field.id == field_id)) +} + +fn project_field( + field: &CompiledField, + metadata: Option<&ManifestProjectionFieldSource>, + projection: &ManifestProjectionSource, +) -> Option { let (field_type, constraints) = match &field.field_type { FieldTypeSource::Boolean => (FieldType::Boolean, FieldConstraints::default()), FieldTypeSource::String { @@ -228,15 +303,28 @@ fn project_field(field: &CompiledField) -> Option { field_type, required: field.required, constraints, - concepts: Vec::new(), - codelist: None, - unit: None, - language: None, + concepts: metadata + .map(|metadata| metadata.concepts.clone()) + .unwrap_or_default(), + codelist: match &field.field_type { + FieldTypeSource::VocabularyCode { vocabulary, .. } + if projection + .vocabularies + .iter() + .any(|metadata| metadata.id.as_str() == vocabulary) => + { + Some(vocabulary.clone()) + } + _ => None, + }, + unit: metadata.and_then(|metadata| metadata.unit.clone()), + language: metadata.and_then(|metadata| metadata.language.clone()), }) } fn project_relationship( field: &CompiledField, + metadata: Option<&ManifestProjectionFieldSource>, visible_entity_ids: &BTreeSet<&str>, ) -> Option { let FieldTypeSource::Reference { target, .. } = &field.field_type else { @@ -253,11 +341,73 @@ fn project_relationship( } else { "zero_or_one".to_owned() }), - role: None, - concept_uri: None, + role: metadata.and_then(|metadata| metadata.relationship_role.clone()), + concept_uri: metadata.and_then(|metadata| metadata.relationship_concept_uri.clone()), }) } +fn project_codelists( + projection: &ManifestProjectionSource, + visible_entities: &[&CompiledEntity], +) -> Vec { + let used = visible_entities + .iter() + .flat_map(|entity| { + let readable_fields = entity + .access_profiles + .get(&projection.access_profile) + .map(|profile| &profile.readable_fields); + entity.fields.values().filter(move |field| { + readable_fields.is_some_and(|fields| fields.contains(&field.id)) + && field.classification <= projection.classification_ceiling + }) + }) + .filter_map(|field| match &field.field_type { + FieldTypeSource::VocabularyCode { vocabulary, values } => { + Some((vocabulary.as_str(), values.as_slice())) + } + _ => None, + }) + .collect::>(); + projection + .vocabularies + .iter() + .filter_map(|metadata| { + let values = used.get(metadata.id.as_str())?; + let concepts = values + .iter() + .map(|code| project_codelist_concept(metadata, code)) + .collect(); + Some(CodelistManifest { + id: metadata.id.clone(), + scheme_iri: metadata.scheme_iri.clone(), + version: metadata.version.clone(), + valid_from: None, + valid_to: None, + external_ref: metadata.external_ref.clone(), + concepts, + }) + }) + .collect() +} + +fn project_codelist_concept( + metadata: &ManifestProjectionVocabularySource, + code: &str, +) -> CodelistConcept { + let authored = metadata + .concepts + .iter() + .find(|concept| concept.code == code); + CodelistConcept { + code: code.to_owned(), + iri: authored.and_then(|concept| concept.iri.clone()), + label: authored + .and_then(|concept| concept.label.as_ref()) + .map(localized_text), + } +} + fn sensitivity(classification: Classification) -> Sensitivity { match classification { Classification::Public => Sensitivity::Public, @@ -332,19 +482,57 @@ mod tests { #[test] fn decimal_projection_preserves_the_canonical_string_wire_contract() { - let projected = project_field(&CompiledField { - id: "measurement".to_owned(), - field_type: FieldTypeSource::Decimal { - precision: 12, - scale: 4, - minimum: None, - maximum: None, + let projected = project_field( + &CompiledField { + id: "measurement".to_owned(), + field_type: FieldTypeSource::Decimal { + precision: 12, + scale: 4, + minimum: None, + maximum: None, + }, + required: true, + classification: Classification::Internal, + valid_time_role: None, + physical_name: "field_measurement".to_owned(), }, - required: true, - classification: Classification::Internal, - valid_time_role: None, - physical_name: "field_measurement".to_owned(), - }) + None, + &crate::contract::ManifestProjectionSource { + access_profile: "reader".to_owned(), + classification_ceiling: Classification::Internal, + catalog: crate::contract::ManifestProjectionCatalogSource { + base_url: "https://registry.example.test".to_owned(), + title: crate::contract::ManifestProjectionTextSource::Plain( + "Registry".to_owned(), + ), + description: None, + publisher: crate::contract::ManifestProjectionPublisherSource { + name: "Registry".to_owned(), + iri: None, + authority_type: None, + }, + participant_id: None, + conforms_to: Vec::new(), + standards: Default::default(), + application_profiles: Vec::new(), + }, + dataset: crate::contract::ManifestProjectionDatasetSource { + id: None, + title: crate::contract::ManifestProjectionTextSource::Plain( + "Dataset".to_owned(), + ), + description: None, + owner: None, + status: None, + conforms_to: Vec::new(), + applicable_legislation: Vec::new(), + spatial_coverage: None, + }, + data_service: None, + entities: Vec::new(), + vocabularies: Vec::new(), + }, + ) .expect("decimal is representable in the portable Manifest"); assert_eq!(projected.field_type, FieldType::String); diff --git a/crates/registry-server/src/package.rs b/crates/registry-server/src/package.rs index 173c3712ff..67207d9934 100644 --- a/crates/registry-server/src/package.rs +++ b/crates/registry-server/src/package.rs @@ -147,6 +147,7 @@ pub enum PackageFileRole { GeneratedOpenapi, EntityJsonSchema, LossyManifestProjection, + DcatCatalogProjection, ReviewedMigrationDescriptor, ReviewedMigrationStepSql, ReviewedMigrationAssertionSql, @@ -1795,6 +1796,16 @@ fn add_compiled_artifacts( .bytes .clone(), )?; + insert_generated( + files, + "manifest/dcat.jsonld", + compiled + .artifacts() + .get("generated/manifest/dcat.jsonld") + .ok_or(PackageError::Derivation)? + .bytes + .clone(), + )?; for (path, artifact) in compiled.artifacts().entries() { let Some(schema_name) = path.strip_prefix("generated/schemas/") else { continue; @@ -1867,6 +1878,7 @@ fn package_role_for_path(path: &str) -> Result { PackageFileRole::EntityJsonSchema } "manifest/registry-manifest.json" => PackageFileRole::LossyManifestProjection, + "manifest/dcat.jsonld" => PackageFileRole::DcatCatalogProjection, _ => return Err(PackageError::Closure), }) } diff --git a/crates/registry-server/tests/compiler_contract.rs b/crates/registry-server/tests/compiler_contract.rs index f6cc429f3b..e4fa5525c9 100644 --- a/crates/registry-server/tests/compiler_contract.rs +++ b/crates/registry-server/tests/compiler_contract.rs @@ -373,8 +373,42 @@ fn all_acceptance_fixtures_compile_manifest_projection_under_production() { .unwrap_or_else(|| panic!("{domain} Manifest projection is generated")); let manifest: MetadataManifest = serde_json::from_slice(&artifact.bytes) .unwrap_or_else(|error| panic!("{domain} Manifest projection parses: {error}")); - compile_manifest(&manifest) + let manifest = compile_manifest(&manifest) .unwrap_or_else(|error| panic!("{domain} Manifest projection compiles: {error:?}")); + let dcat = compiled + .artifacts() + .get("generated/manifest/dcat.jsonld") + .unwrap_or_else(|| panic!("{domain} DCAT projection is generated")); + let dcat = parse_json_strict(&dcat.bytes) + .unwrap_or_else(|error| panic!("{domain} DCAT projection parses: {error}")); + + if domain == "publicschema-household" { + let dataset = manifest + .dataset("household-registry") + .expect("configured dataset id is preserved"); + assert_eq!( + dataset.entities["person"].concept_uri.as_deref(), + Some("https://publicschema.org/Person") + ); + assert_eq!( + dataset.entities["group-membership"] + .relationships + .iter() + .find(|relationship| relationship.name == "household") + .expect("household relationship is projected") + .concept_uri + .as_deref(), + Some("https://publicschema.org/group") + ); + assert_eq!(manifest.data_services().count(), 1); + assert!(manifest + .codelists() + .any(|codelist| codelist.id == "household-relationship")); + assert_eq!( + dcat["dcat:service"][0]["dcat:endpointURL"], + "https://publicschema-household.example.gov/v1" + ); + } } } @@ -442,6 +476,54 @@ fn manifest_projection_filters_by_selected_profile_and_classification_ceiling() .any(|relationship| relationship.name == "hidden-ref")); } +#[test] +fn manifest_projection_metadata_cannot_describe_hidden_entities_or_fields() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"public-slice","version":"1","defaultLanguage":"en"}, + "manifestProjection":{ + "accessProfile":"reader", + "classificationCeiling":"restricted", + "catalog":{"baseUrl":"https://public-slice.example.test","title":"Public Slice","publisher":{"name":"Publisher"}}, + "dataset":{"title":"Public Slice Dataset"}, + "entities":[ + {"id":"record","fields":[ + {"id":"secret-note","concepts":["https://example.test/secret-note"]}, + {"id":"profile","concepts":["https://example.test/Profile"]} + ]}, + {"id":"secret-record","conceptUri":"https://example.test/SecretRecord"} + ] + }, + "entities":[ + {"id":"record","route":"records","mutationMode":"create_only","classification":"public", + "fields":[ + {"id":"name","type":"string","maxLength":64,"classification":"public"}, + {"id":"secret-note","type":"string","maxLength":64,"classification":"restricted"}, + {"id":"profile","type":"structured","maxBytes":1024,"schema":{"type":"object","additionalProperties":false},"classification":"public"} + ], + "accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["name","profile"]}]}, + {"id":"secret-record","route":"secret-records","mutationMode":"create_only","classification":"restricted", + "fields":[{"id":"name","type":"string","maxLength":64,"classification":"restricted"}], + "accessProfiles":[{"id":"other-reader","principalClaim":"principal","operations":["get"],"readableFields":["name"]}]} + ] + }"#, + ) + .expect("project parses"); + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("metadata outside the projected disclosure slice is refused"); + let codes = failure + .diagnostics() + .iter() + .map(|diagnostic| diagnostic.code.as_str()) + .collect::>(); + + assert!(codes.contains("manifest_projection.field.not_visible")); + assert!(codes.contains("manifest_projection.field.not_representable")); + assert!(codes.contains("manifest_projection.entity.not_visible")); +} + #[test] fn manifest_projection_omits_physical_runtime_and_security_terms() { let compiled = compile_project(&asset_project(), &[], CompileProfile::Authoring) diff --git a/crates/registry-server/tests/postgres_package.rs b/crates/registry-server/tests/postgres_package.rs index 23a0710f60..e904938590 100644 --- a/crates/registry-server/tests/postgres_package.rs +++ b/crates/registry-server/tests/postgres_package.rs @@ -132,6 +132,10 @@ fn package_layout_contract_required_manifest_projection_is_in_prepared_closure() && layout.contains("lossy-manifest-projection"), "package-layout.yaml requires a lossy manifest projection" ); + assert!( + layout.contains("manifest/dcat.jsonld") && layout.contains("dcat-catalog-projection"), + "package-layout.yaml requires a DCAT catalog projection" + ); let module_bytes = module_bytes(PlanChoice::Schema); let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); @@ -159,6 +163,10 @@ fn package_layout_contract_required_manifest_projection_is_in_prepared_closure() assert!(package .file_bytes() .contains_key("manifest/registry-manifest.json")); + assert!(package.manifest().files.iter().any(|entry| { + entry.path == "manifest/dcat.jsonld" && entry.role == PackageFileRole::DcatCatalogProjection + })); + assert!(package.file_bytes().contains_key("manifest/dcat.jsonld")); assert!(package .manifest() .files @@ -203,6 +211,7 @@ fn package_layout_contract_required_manifest_projection_is_in_prepared_closure() "inventories/physical-names.json", "inventories/queries.json", "inventories/routes.json", + "manifest/dcat.jsonld", "manifest/registry-manifest.json", "metadata/registry.json", "openapi/openapi.json", diff --git a/crates/registry-serverctl/src/lib.rs b/crates/registry-serverctl/src/lib.rs index 5d0c461fc5..1d314d7ba2 100644 --- a/crates/registry-serverctl/src/lib.rs +++ b/crates/registry-serverctl/src/lib.rs @@ -2248,6 +2248,11 @@ fn load_module_files( "module sources cannot be read", ) })?; + // Finder metadata is not an authored module. Ignore only this exact + // regular file; every other unexpected entry remains fail-closed. + if entry.file_name() == ".DS_Store" && file_type.is_file() { + continue; + } if file_type.is_symlink() || !file_type.is_dir() { return Err(diagnostic( "source.modules.invalid", @@ -2399,9 +2404,7 @@ fn selected_artifacts( .filter(|artifact| match selector { ArtifactSelector::Openapi => artifact.path == "generated/openapi.json", ArtifactSelector::Schemas => artifact.path.starts_with("generated/schemas/"), - ArtifactSelector::Manifest => { - artifact.path == "generated/manifest/registry-manifest.json" - } + ArtifactSelector::Manifest => artifact.path.starts_with("generated/manifest/"), ArtifactSelector::Metadata => artifact.path == "generated/metadata/registry.json", ArtifactSelector::Sql => artifact.path == "generated/postgres/schema.sql", }) diff --git a/crates/registry-serverctl/tests/cli.rs b/crates/registry-serverctl/tests/cli.rs index 2e74c2df1b..e91ac6ccc0 100644 --- a/crates/registry-serverctl/tests/cli.rs +++ b/crates/registry-serverctl/tests/cli.rs @@ -742,10 +742,49 @@ fn manifest_selector_requires_the_compiled_manifest_projection() { assert!(output_root .join("generated/manifest/registry-manifest.json") .is_file()); + assert!(output_root.join("generated/manifest/dcat.jsonld").is_file()); let report = json_stdout(&output); + let artifacts = report["artifacts"] + .as_array() + .expect("artifacts is an array"); + assert_eq!( + artifacts + .iter() + .map(|artifact| artifact["path"].as_str().expect("artifact path")) + .collect::>(), + vec![ + "generated/manifest/dcat.jsonld", + "generated/manifest/registry-manifest.json" + ] + ); +} + +#[test] +fn module_discovery_ignores_only_regular_finder_metadata() { + let project = TestProject::asset_fixture(); + fs::create_dir(project.path().join("modules")).expect("modules directory creates"); + fs::write(project.path().join("modules/.DS_Store"), b"finder metadata") + .expect("Finder metadata writes"); + let accepted = registry_serverctl(&[ + "--format", + "json", + "check", + project.path().to_str().expect("path is UTF-8"), + ]); + assert!(accepted.status.success(), "{accepted:?}"); + + fs::write(project.path().join("modules/notes.txt"), b"unexpected") + .expect("unexpected entry writes"); + let refused = registry_serverctl(&[ + "--format", + "json", + "check", + project.path().to_str().expect("path is UTF-8"), + ]); + assert_eq!(refused.status.code(), Some(1)); assert_eq!( - report["artifacts"][0]["path"], - "generated/manifest/registry-manifest.json" + json_stdout(&refused)["diagnostics"][0]["code"], + "source.modules.invalid" ); } diff --git a/products/registry-server/DECISIONS.md b/products/registry-server/DECISIONS.md index 0651be257d..6cfb470d0c 100644 --- a/products/registry-server/DECISIONS.md +++ b/products/registry-server/DECISIONS.md @@ -8,6 +8,18 @@ - Packages contain governed model and generated artifacts. Runtime configuration binds deployment-specific values and secrets and is not part of the signed model. +- Domain semantics are optional configuration overlays. Registry Server does + not hardcode Person, Household, GroupMembership, or any other domain model. + An overlay may add localized labels, concept URIs, identifiers, relationship + roles, and codelist metadata only for entities and fields visible through its + selected access profile and classification ceiling. +- Registry Manifest remains the owner of standards-oriented metadata and DCAT + rendering. Registry Server emits a one-way, lossy Manifest source plus its + DCAT JSON-LD projection in the governed package. It does not maintain a + second catalogue model or claim conformance that was not explicitly authored. +- Evidence and Relay integrations use their published protocol surfaces and + platform primitives. Registry Server does not depend on their product crates + or duplicate their policy, disclosure, credential, or publication engines. - Registry Server accepts one PostgreSQL client path: `tokio-postgres` 0.7.18, `deadpool-postgres` 0.14.2, and `tokio-postgres-rustls` 0.14.0. The real PostgreSQL kernel proves dynamic result handling, transactions, cancellation, diff --git a/products/registry-server/README.md b/products/registry-server/README.md index 2d6339d163..c072c4c0b4 100644 --- a/products/registry-server/README.md +++ b/products/registry-server/README.md @@ -122,6 +122,34 @@ products/registry-server/demo/run.sh The launcher retains every key and token in an ignored owner-only directory and prints a separate query helper rather than printing bearer credentials. +## Portable metadata and composition + +Domain-semantic entries in `manifestProjection` are optional overlays on the +configured data model. They can declare localized catalogue text, dataset and +API metadata, entity concept URIs, identifiers, field concepts, relationship +roles, and codelist schemes. The compiler refuses overlay entries outside the +selected access profile and classification ceiling. It does not infer or +hardcode a domain model. + +The PublicSchema-shaped household fixture demonstrates Person, Household, and +GroupMembership alignment entirely in configuration: + +```bash +registry-serverctl generate manifest \ + products/registry-server/acceptance/publicschema-household \ + --output ./household-metadata +``` + +This produces the canonical Registry Manifest source and a DCAT JSON-LD +catalogue. Registry Manifest owns the standards rendering, so Registry Server +does not carry a second DCAT implementation. + +Evidence can consume an authenticated Registry Server REST route through its +existing bounded `http-json` source and an explicitly reviewed adapter. Relay +remains a separate publication boundary. A direct Relay source adapter should +be added only for a concrete publication journey, rather than coupling either +product to Registry Server internals. + ## Relationship to Registry Stack Registry Server is a writable source-of-truth product. Registry Relay remains diff --git a/products/registry-server/acceptance/publicschema-household/registry.yaml b/products/registry-server/acceptance/publicschema-household/registry.yaml index d86cc87cb4..743cfbdd46 100644 --- a/products/registry-server/acceptance/publicschema-household/registry.yaml +++ b/products/registry-server/acceptance/publicschema-household/registry.yaml @@ -14,16 +14,97 @@ manifestProjection: classificationCeiling: restricted catalog: baseUrl: https://publicschema-household.example.gov - title: PublicSchema Household Registry Catalog - description: Portable metadata for household, person, and group membership records. + title: + en: PublicSchema Household Registry Catalog + fr: Catalogue du registre des ménages PublicSchema + description: + en: Portable metadata for household, person, and group membership records. + fr: Métadonnées portables pour les personnes, les ménages et leur appartenance. publisher: name: PublicSchema Household Authority iri: https://publicschema-household.example.gov/authority + conformsTo: [https://www.w3.org/TR/vocab-dcat-3/] + standards: + dcat: "3.0" + jsonSchema: "2020-12" dataset: - title: PublicSchema Household Registry - description: Household, person, and time-bounded group membership metadata. + id: household-registry + title: + en: PublicSchema Household Registry + fr: Registre des ménages PublicSchema + description: + en: Household, person, and time-bounded group membership metadata. + fr: Métadonnées sur les ménages, les personnes et les appartenances limitées dans le temps. owner: PublicSchema Household Authority status: active + dataService: + id: household-registry-api + iri: https://publicschema-household.example.gov/services/registry-api + title: + en: Household Registry REST API + fr: API REST du registre des ménages + endpointUrl: https://publicschema-household.example.gov/v1 + endpointDescription: https://publicschema-household.example.gov/openapi.json + conformsTo: https://spec.openapis.org/oas/v3.1.0 + entities: + - id: person + title: {en: Person, fr: Personne} + description: + en: A person recorded by the household registry. + fr: Une personne enregistrée dans le registre des ménages. + conceptUri: https://publicschema.org/Person + identifiers: + - {field: person-code, kind: local} + fields: + - {id: person-code, concepts: [https://publicschema.org/identifier]} + - {id: legal-name, concepts: [https://publicschema.org/name]} + - {id: family-name, concepts: [https://publicschema.org/family_name]} + - {id: date-of-birth, concepts: [https://publicschema.org/date_of_birth]} + - id: household + title: {en: Household, fr: Ménage} + description: + en: A social and economic unit represented as a configured registry entity. + fr: Une unité sociale et économique représentée comme une entité configurée du registre. + conceptUri: https://publicschema.org/Household + identifiers: + - {field: household-code, kind: local} + fields: + - {id: household-code, concepts: [https://publicschema.org/identifier]} + - {id: household-name, concepts: [https://publicschema.org/name]} + - id: group-membership + title: {en: Group membership, fr: Appartenance à un groupe} + description: + en: A time-bounded link between a person and a household. + fr: Un lien limité dans le temps entre une personne et un ménage. + conceptUri: https://publicschema.org/GroupMembership + fields: + - id: person + relationshipRole: member + relationshipConceptUri: https://publicschema.org/person + - id: household + relationshipRole: group + relationshipConceptUri: https://publicschema.org/group + - {id: relationship, concepts: [https://publicschema.org/role]} + - {id: valid-from, concepts: [https://publicschema.org/start_date]} + - {id: valid-to, concepts: [https://publicschema.org/end_date]} + vocabularies: + - id: household-relationship + schemeIri: https://publicschema.org/GroupRole + version: "0.3.0" + externalRef: https://publicschema.org/GroupRole + concepts: + - {code: head, iri: https://publicschema.org/GroupRole/head, label: {en: Head, fr: Chef}} + - {code: spouse, iri: https://publicschema.org/GroupRole/spouse, label: {en: Spouse, fr: Conjoint}} + - {code: child, iri: https://publicschema.org/GroupRole/child, label: {en: Child, fr: Enfant}} + - {code: dependent, iri: https://publicschema.org/GroupRole/dependent, label: {en: Dependent, fr: Personne à charge}} + - {code: other, iri: https://publicschema.org/GroupRole/other, label: {en: Other, fr: Autre}} + - id: household-type + schemeIri: https://publicschema-household.example.gov/vocab/household-type + - id: residency-status + schemeIri: https://publicschema-household.example.gov/vocab/residency-status + - id: preferred-language + schemeIri: https://id.loc.gov/vocabulary/iso639-1 + externalRef: https://id.loc.gov/vocabulary/iso639-1.html modules: - id: publicschema-household-core version: 0.1.0 diff --git a/products/registry-server/contracts/package-layout.yaml b/products/registry-server/contracts/package-layout.yaml index 20ff44affc..ebb292ded9 100644 --- a/products/registry-server/contracts/package-layout.yaml +++ b/products/registry-server/contracts/package-layout.yaml @@ -15,6 +15,7 @@ entries: - {path: openapi/openapi.json, role: generated-openapi, required: true} - {path: schemas, role: entity-json-schemas, required: true} - {path: manifest/registry-manifest.json, role: lossy-manifest-projection, required: true} + - {path: manifest/dcat.jsonld, role: dcat-catalog-projection, required: true} - {path: tests/journeys.yaml, role: fixture-journeys, required: true} - {path: signatures, role: package-signatures, required: false} diff --git a/products/registry-server/generated/asset-site-placement/generated/manifest/dcat.jsonld b/products/registry-server/generated/asset-site-placement/generated/manifest/dcat.jsonld new file mode 100644 index 0000000000..7eabc7457f --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/manifest/dcat.jsonld @@ -0,0 +1 @@ +{"@context":{"adms":"http://www.w3.org/ns/adms#","adms:status":{"@type":"@id"},"dcat":"http://www.w3.org/ns/dcat#","dcat:accessService":{"@type":"@id"},"dcat:accessURL":{"@type":"@id"},"dcat:dataset":{"@type":"@id"},"dcat:distribution":{"@type":"@id"},"dcat:endpointDescription":{"@type":"@id"},"dcat:endpointURL":{"@type":"@id"},"dcat:landingPage":{"@type":"@id"},"dcat:mediaType":{"@type":"@id"},"dcat:servesDataset":{"@type":"@id"},"dcat:theme":{"@type":"@id"},"dcat:themeTaxonomy":{"@type":"@id"},"dcterms":"http://purl.org/dc/terms/","dcterms:accessRights":{"@type":"@id"},"dcterms:accrualPeriodicity":{"@type":"@id"},"dcterms:conformsTo":{"@type":"@id"},"dcterms:format":{"@type":"@id"},"dcterms:isPartOf":{"@type":"@id"},"dcterms:spatial":{"@type":"@id"},"dcterms:type":{"@type":"@id"},"foaf":"http://xmlns.com/foaf/0.1/","odrl":"http://www.w3.org/ns/odrl/2/","odrl:action":{"@type":"@id"},"odrl:assignee":{"@type":"@id"},"odrl:assigner":{"@type":"@id"},"odrl:hasPolicy":{"@type":"@id"},"odrl:leftOperand":{"@type":"@id"},"odrl:operator":{"@type":"@id"},"odrl:profile":{"@type":"@id"},"odrl:target":{"@type":"@id"},"odrl:uid":{"@type":"@id"},"odrl:unit":{"@type":"@id"},"rdfs":"http://www.w3.org/2000/01/rdf-schema#","rdfs:seeAlso":{"@type":"@id"},"registry_manifest":"https://id.registrystack.org/ns/registry-manifest/v1#","sh":"http://www.w3.org/ns/shacl#","sh:class":{"@type":"@id"},"sh:datatype":{"@type":"@id"},"sh:nodeKind":{"@type":"@id"},"sh:path":{"@type":"@id"},"sh:targetClass":{"@type":"@id"},"skos":"http://www.w3.org/2004/02/skos/core#","skos:hasTopConcept":{"@type":"@id"},"skos:inScheme":{"@type":"@id"},"xsd":"http://www.w3.org/2001/XMLSchema#"},"@id":"https://asset-site-placement.example.gov/metadata/dcat.jsonld","@included":[{"@id":"#dataset-asset-site-placement","@type":"foaf:Document"},{"@id":"http://eurovoc.europa.eu/100141","@type":"skos:ConceptScheme","dcterms:title":"100141","skos:prefLabel":"100141"},{"@id":"http://publications.europa.eu/resource/authority/data-theme","@type":"skos:ConceptScheme","dcterms:title":"data theme","skos:prefLabel":"data theme"},{"@id":"https://asset-site-placement.example.gov","@type":"foaf:Document"}],"@type":"dcat:Catalog","dcat:dataset":[{"@id":"#dataset-asset-site-placement","@type":"dcat:Dataset","dcat:landingPage":"#dataset-asset-site-placement","dcterms:conformsTo":[],"dcterms:description":"Asset placement, site, item, and inspection metadata.","dcterms:identifier":"asset-site-placement","dcterms:title":"Asset Site Placement Registry","odrl:hasPolicy":{"@id":"#policy-asset-site-placement-offer","@type":"odrl:Offer","odrl:assigner":{"@id":"https://asset-site-placement.example.gov/authority"},"odrl:permission":[{"odrl:action":{"@id":"odrl:use"},"odrl:assigner":{"@id":"https://asset-site-placement.example.gov/authority"},"odrl:target":{"@id":"#dataset-asset-site-placement"}}],"odrl:uid":"#policy-asset-site-placement-offer"}}],"dcat:landingPage":"https://asset-site-placement.example.gov","dcat:themeTaxonomy":["http://publications.europa.eu/resource/authority/data-theme","http://eurovoc.europa.eu/100141"],"dcterms:conformsTo":[],"dcterms:description":"Portable metadata for the asset site placement registry.","dcterms:identifier":"asset-site-placement","dcterms:publisher":{"@id":"https://asset-site-placement.example.gov/authority","@type":"foaf:Agent","foaf:name":"Asset Site Placement Authority"},"dcterms:title":"Asset Site Placement Catalog"} \ No newline at end of file diff --git a/products/registry-server/generated/authoring/registry-project.schema.json b/products/registry-server/generated/authoring/registry-project.schema.json index 821d14108f..301bf94ded 100644 --- a/products/registry-server/generated/authoring/registry-project.schema.json +++ b/products/registry-server/generated/authoring/registry-project.schema.json @@ -855,16 +855,50 @@ ], "type": "object" }, + "ManifestProjectionApplicationProfileSource": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version" + ], + "type": "object" + }, "ManifestProjectionCatalogSource": { "additionalProperties": false, "properties": { + "applicationProfiles": { + "default": [], + "items": { + "$ref": "#/$defs/ManifestProjectionApplicationProfileSource" + }, + "type": "array" + }, "baseUrl": { "type": "string" }, + "conformsTo": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "description": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionTextSource" + }, + { + "type": "null" + } ] }, "participantId": { @@ -876,8 +910,12 @@ "publisher": { "$ref": "#/$defs/ManifestProjectionPublisherSource" }, + "standards": { + "$ref": "#/$defs/ManifestProjectionStandardsSource", + "default": {} + }, "title": { - "type": "string" + "$ref": "#/$defs/ManifestProjectionTextSource" } }, "required": [ @@ -887,10 +925,82 @@ ], "type": "object" }, + "ManifestProjectionDataServiceSource": { + "additionalProperties": false, + "properties": { + "conformsTo": { + "type": [ + "string", + "null" + ] + }, + "description": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionTextSource" + }, + { + "type": "null" + } + ] + }, + "endpointDescription": { + "type": [ + "string", + "null" + ] + }, + "endpointUrl": { + "type": "string" + }, + "id": { + "type": "string" + }, + "iri": { + "type": [ + "string", + "null" + ] + }, + "title": { + "$ref": "#/$defs/ManifestProjectionTextSource" + } + }, + "required": [ + "id", + "title", + "endpointUrl" + ], + "type": "object" + }, "ManifestProjectionDatasetSource": { "additionalProperties": false, "properties": { + "applicableLegislation": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "conformsTo": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "description": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionTextSource" + }, + { + "type": "null" + } + ] + }, + "id": { "type": [ "string", "null" @@ -902,6 +1012,12 @@ "null" ] }, + "spatialCoverage": { + "type": [ + "string", + "null" + ] + }, "status": { "anyOf": [ { @@ -913,7 +1029,7 @@ ] }, "title": { - "type": "string" + "$ref": "#/$defs/ManifestProjectionTextSource" } }, "required": [ @@ -931,6 +1047,117 @@ ], "type": "string" }, + "ManifestProjectionEntitySource": { + "additionalProperties": false, + "properties": { + "conceptUri": { + "type": [ + "string", + "null" + ] + }, + "description": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionTextSource" + }, + { + "type": "null" + } + ] + }, + "fields": { + "default": [], + "items": { + "$ref": "#/$defs/ManifestProjectionFieldSource" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "identifiers": { + "default": [], + "items": { + "$ref": "#/$defs/ManifestProjectionIdentifierSource" + }, + "type": "array" + }, + "title": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionTextSource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ManifestProjectionFieldSource": { + "additionalProperties": false, + "properties": { + "concepts": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "relationshipConceptUri": { + "type": [ + "string", + "null" + ] + }, + "relationshipRole": { + "type": [ + "string", + "null" + ] + }, + "unit": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ManifestProjectionIdentifierSource": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "field", + "kind" + ], + "type": "object" + }, "ManifestProjectionPublisherSource": { "additionalProperties": false, "properties": { @@ -967,8 +1194,32 @@ "classificationCeiling": { "$ref": "#/$defs/Classification" }, + "dataService": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionDataServiceSource" + }, + { + "type": "null" + } + ] + }, "dataset": { "$ref": "#/$defs/ManifestProjectionDatasetSource" + }, + "entities": { + "default": [], + "items": { + "$ref": "#/$defs/ManifestProjectionEntitySource" + }, + "type": "array" + }, + "vocabularies": { + "default": [], + "items": { + "$ref": "#/$defs/ManifestProjectionVocabularySource" + }, + "type": "array" } }, "required": [ @@ -979,6 +1230,106 @@ ], "type": "object" }, + "ManifestProjectionStandardsSource": { + "additionalProperties": false, + "properties": { + "dcat": { + "type": [ + "string", + "null" + ] + }, + "jsonSchema": { + "type": [ + "string", + "null" + ] + }, + "shacl": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ManifestProjectionTextSource": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + ] + }, + "ManifestProjectionVocabularyConceptSource": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "iri": { + "type": [ + "string", + "null" + ] + }, + "label": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionTextSource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "code" + ], + "type": "object" + }, + "ManifestProjectionVocabularySource": { + "additionalProperties": false, + "properties": { + "concepts": { + "default": [], + "items": { + "$ref": "#/$defs/ManifestProjectionVocabularyConceptSource" + }, + "type": "array" + }, + "externalRef": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "schemeIri": { + "type": "string" + }, + "version": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "schemeIri" + ], + "type": "object" + }, "ModuleLockSource": { "additionalProperties": false, "properties": { diff --git a/products/registry-server/scripts/compare-generated-tree.py b/products/registry-server/scripts/compare-generated-tree.py index 27b2724b1a..1b9d7df631 100755 --- a/products/registry-server/scripts/compare-generated-tree.py +++ b/products/registry-server/scripts/compare-generated-tree.py @@ -10,6 +10,7 @@ EXPECTED_PATHS = ( "generated/manifest/registry-manifest.json", + "generated/manifest/dcat.jsonld", "generated/metadata/registry.json", "generated/openapi.json", "generated/postgres/schema.sql", diff --git a/products/registry-server/scripts/validate_product.py b/products/registry-server/scripts/validate_product.py index 479e4a3b7e..077717cde8 100644 --- a/products/registry-server/scripts/validate_product.py +++ b/products/registry-server/scripts/validate_product.py @@ -55,6 +55,7 @@ ("openapi/openapi.json", "generated-openapi", True), ("schemas", "entity-json-schemas", True), ("manifest/registry-manifest.json", "lossy-manifest-projection", True), + ("manifest/dcat.jsonld", "dcat-catalog-projection", True), ("tests/journeys.yaml", "fixture-journeys", True), ("signatures", "package-signatures", False), } From 90714860afdfc6285180075bfa07aed39637a275 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 30 Aug 2026 16:03:32 +0700 Subject: [PATCH 03/19] docs(registry-server): focus event hook direction Signed-off-by: Jeremi Joslin --- products/registry-server/DECISIONS.md | 4 +- .../registry-server/EVENTS-AND-WEBHOOKS.md | 226 ++++++++++++++++++ products/registry-server/README.md | 5 + .../contracts/artifact-inventory.yaml | 1 + 4 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 products/registry-server/EVENTS-AND-WEBHOOKS.md diff --git a/products/registry-server/DECISIONS.md b/products/registry-server/DECISIONS.md index 6cfb470d0c..5a2ecfff2f 100644 --- a/products/registry-server/DECISIONS.md +++ b/products/registry-server/DECISIONS.md @@ -3,8 +3,8 @@ - Version 1 supports PostgreSQL only. SQLite is not a deployment compatibility promise. - Extension points begin with transactionally created outbox events and - authenticated webhooks. Arbitrary synchronous code hooks are not part of the - server. + authenticated webhooks under `EVENTS-AND-WEBHOOKS.md`. Arbitrary synchronous + code hooks are not part of the server. - Packages contain governed model and generated artifacts. Runtime configuration binds deployment-specific values and secrets and is not part of the signed model. diff --git a/products/registry-server/EVENTS-AND-WEBHOOKS.md b/products/registry-server/EVENTS-AND-WEBHOOKS.md new file mode 100644 index 0000000000..b705b2eb7f --- /dev/null +++ b/products/registry-server/EVENTS-AND-WEBHOOKS.md @@ -0,0 +1,226 @@ +# Events and webhooks + +**Status:** Proposed direction for the next implementation slice + +## Goal + +Give a project one safe, reliable extension point: after a committed record +change, send a small authenticated event to a configured service. This should +cover the common integration need without turning Registry Server into a +workflow engine or plugin host. + +The mechanism is domain-neutral. A project may declare events for a person, +household membership, farm, disability assessment, company, asset, or any +other configured entity. Registry Server has no built-in knowledge of those +models. + +The existing transactional outbox and delivery worker are the starting point, +not a conformance claim for this spec. The immediate deltas are simpler +authoring, field conditions, CloudEvents, operator commands, a webhook demo, +payload erasure and retention, and upgrade-safe queued delivery. + +The core threat is a hook leaking registry data, widening authority at +deployment, or causing an unaccounted side effect. The invariant is that only +the compiled projection may reach the exact activated logical destination, +only after an authorized mutation commits, with durable audit before egress. + +## Version 1 contract + +### Authoring + +An event belongs to an entity and declares only what changes product meaning: + +```yaml +events: + - id: case-approved-v1 + trigger: patched + projection: [status, programme] + when: + kind: fields + changed: [status] + beforeEquals: {status: pending} + afterEquals: {status: approved} + webhook: + destinationId: eligibility-service +``` + +- `id` is the stable external event contract. A breaking payload change uses a + new versioned id. +- `trigger` is one of `created`, `patched`, or `tombstoned`. +- `projection` is the complete set of record values that may leave the + registry. System event metadata does not need to be listed. +- `when` is optional. Version 1 supports only `kind: fields`. `changed`, + `beforeEquals`, and `afterEquals` are optional, combine with AND, and accept + declared fields with scalar or null comparison values. At least one test is + required when `when` is present. `created` may use `afterEquals`, `patched` + may use all three tests, and `tombstoned` may use `beforeEquals`; invalid + combinations fail compilation. +- One event has at most one webhook destination. A project that needs fanout + uses an external event gateway until native fanout is justified. +- Production compilation rejects an event without a delivery because Version + 1 has no supported outbox consumer API. + +Modules may add events to an existing entity using the normal deterministic +entity-extension mechanism. They may not silently replace an event owned by +another module. + +Destination URLs, TLS policy, network policy, and HMAC keys remain deployment +configuration. The governed project refers only to a logical destination id. +Runtime configuration must bind the exact compiled destination set and may +tighten operational ceilings, never widen delivery authority. + +The compiler derives the event classification from the highest-classified +projected field. The project does not restate it. Activation requires the +runtime destination to permit that classification, but the destination can +never add to the compiled projection. + +### Event evaluation and capture + +Conditions are evaluated against the validated before and after snapshots. +Evaluation and outbox insertion happen inside the record mutation transaction, +after authorization and validation. A failure creates neither the record +change nor the event. No user script, network call, or webhook runs inside that +transaction. + +The captured event is immutable and binds its event id, entity, record id, +revision, trigger, projected values, package revision, schema fingerprint, +destination, and delivery policy. A later package activation must not +reinterpret it. Activation refuses a destination change that would strand a +retained non-terminal delivery. + +### Wire format + +Webhooks use CloudEvents 1.0 HTTP binary mode with canonical JSON data: + +- `ce-specversion: 1.0` +- `ce-id`: the stable event UUID +- `ce-source`: a stable URN for the Registry instance +- `ce-type`: the authored event id +- `ce-time`: the mutation commit time +- `ce-dataschema`: a URN containing the Registry id, event id, and generated + event-schema fingerprint + +The body contains `entity`, `recordId`, `revision`, `trigger`, +`packageRevision`, and `values`. `values` contains exactly the declared +projection. Record identifiers are deliberately kept out of CloudEvents +headers because infrastructure commonly logs headers. + +Registry delivery headers add `Idempotency-Key`, +`X-Registry-Event-Generation`, `X-Registry-Delivery-Attempt`, and +`X-Registry-Delivery-Time`, plus `X-Registry-Signature`. The versioned +HMAC-SHA-256 signature uses an unambiguous length-prefixed encoding and covers +the exact CloudEvents attributes, delivery metadata, HTTP method, request +target, content type, and canonical body. Receivers verify the signature, +bounded delivery-time skew, and idempotency key before applying effects. The +shared secret is resolved only from runtime secret configuration and is never +project-authored, logged, or included in generated examples. + +### Delivery behavior + +Delivery is asynchronous, after commit, and at least once: + +- Any `2xx` response acknowledges delivery. Redirects, transport failures, + timeouts, and other statuses retry within a bounded product-owned profile. +- HMAC-SHA-256, dead-lettering, operator replay, a five-second attempt timeout, + and the bounded retry profile are secure defaults, not per-event authoring + choices. `registry-serverctl explain events` shows the effective values. +- The event id and idempotency key remain stable across automatic retries. + Consumers must deduplicate by `Idempotency-Key`. +- A dead-letter replay keeps the event id, increments the generation, and gets + a new idempotency key. Replay is audited and allowed only for a terminal + dead-lettered delivery with retained payload. +- No global or per-record delivery order is promised. The record revision lets + consumers detect stale or missing transitions. + +A durable, value-free attempt audit commits before network egress. A terminal +audit and delivery-state transition commit together after the outcome. Audit +failure prevents the send or terminal transition rather than creating an +unaccounted delivery. + +The payload is erased immediately after successful delivery. Pending and +dead-letter payloads have a deployment-selectable retention period capped at +30 days. After expiry, replay is impossible. Digests and value-free operational +metadata follow the normal audit retention policy. Audit and operational logs +contain no projected values, raw record ids, destination URLs, or secrets. +Payload erasure and its terminal audit record commit atomically. + +The public record API exposes no outbox, payload, delivery, or replay route. + +### Platform reuse + +Reuse `registry-platform-httputil` for bounded destination and SSRF policy, +`registry-platform-canonical-json` for exact bytes, and the existing platform +audit, secret, configuration, and cryptographic primitives. Improve those +crates when a missing primitive is genuinely cross-product. Registry Server +continues to own event meaning, capture, retry state, replay, retention, and +the versioned signature contract. Do not add a new generic hook, CloudEvents, +or Rhai platform crate for this slice. + +## Developer and operator experience + +The first complete journey must be possible without reading Rust code: + +- `registry-serverctl check` reports field-addressed event errors. +- `registry-serverctl explain events` shows triggers, conditions, projections, + classifications, destinations, payload bounds, and fixed delivery behavior, + but no deployed URLs or secrets. +- `registry-serverctl webhook sample` writes an exact example request with + synthetic values and a placeholder signature. +- `registry-serverctl webhook list` shows value-free pending and dead-letter + status. +- `registry-serverctl webhook replay` replays one eligible dead letter using + its event id, delivery id, and expected generation. +- `products/registry-server/demo/run.sh --webhook` starts Mint, PostgreSQL, + Registry Server, and a local HMAC-verifying receiver. It demonstrates one + successful event and one automatic retry without printing the token or key. + +## Definition of done + +Version 1 is done when one configured project can create or patch a record and +the demo proves the resulting CloudEvent is transactionally captured, +minimized, authenticated, retried, dead-lettered, inspected, and replayed. +Focused negative tests must prove rollback creates no event, conditions do not +overmatch, projections cannot cross their classification ceiling, runtime +bindings cannot widen or redirect authority, signatures bind the exact request, +payload retention is enforced, and a compatible package upgrade cannot strand +a pending delivery. + +## Future direction + +These are intentionally deferred, but the Version 1 shapes must leave room for +them: + +1. **Rhai rules.** Add `when.kind: rhai` only after field conditions prove + insufficient. Scripts are reviewed, hash-covered package artifacts with a + small versioned ABI, deterministic fresh state, fixed resource limits, and + minimized before/after inputs. They return only a Boolean or closed + validation result. They receive no I/O, secrets, credentials, database + handle, destination authority, clock, randomness, or audit ownership. +2. **Validation and computed fields.** Reuse the same bounded Rhai kernel for + record validation first, then deterministic computed fields if a concrete + project needs them. Script failure fails the mutation atomically. Do not + extract a shared platform Rhai crate until Registry Server is a real second + consumer and the common kernel is clear. +3. **Richer event selection.** Add multiple field predicates, relationship + changes, and safe transforms through a versioned tagged condition ABI. Do + not grow an ad hoc expression language alongside Rhai. +4. **More delivery adapters.** Consider native fanout, CloudEvents structured + mode, queues, or message brokers only for proven deployments. Keep the + transactional event contract independent of transport. +5. **Inbound integration and scheduling.** Treat inbound webhooks, scheduled + jobs, multi-step actions, approvals, and workflows as explicit adapters or + separate products unless a recurring Registry Server responsibility is + demonstrated. +6. **Explicit business commands.** If projects need atomic multi-record + behavior, prefer reviewed command endpoints with a bounded mutation plan, + validation, authorization, audit, and events over implicit global model + callbacks. +7. **UI and AI authoring.** A future UI and the separate control tool may build + on generated schemas, sample events, checks, explanations, and demos. AI may + propose configuration and tests but never bypass review, package signing, + runtime destination policy, or operator replay authority. + +Arbitrary synchronous callbacks, Django-style global signals, dynamic Rust +plugins, and scripts with ambient I/O are not on the roadmap. We retain Odoo's +useful ability for modules to extend an existing model, while keeping extension +behavior explicit, inspectable, transaction-safe, and independently operable. diff --git a/products/registry-server/README.md b/products/registry-server/README.md index c072c4c0b4..cae68692e4 100644 --- a/products/registry-server/README.md +++ b/products/registry-server/README.md @@ -87,6 +87,11 @@ and governed migrations. It does not provide a UI, GraphQL, workflow, eligibility, payment, identity matching, SQLite support, a multi-registry control plane, or runtime code plugins. +The focused direction for hooks is documented in +[`EVENTS-AND-WEBHOOKS.md`](EVENTS-AND-WEBHOOKS.md). Version 1 uses explicit +transactional events and authenticated after-commit webhooks, with future Rhai +rules kept behind the same governed extension boundary. + PostgreSQL is the sole Version 1 database. The administrator installs `btree_gist`; neither the runtime nor migration role installs extensions. diff --git a/products/registry-server/contracts/artifact-inventory.yaml b/products/registry-server/contracts/artifact-inventory.yaml index 15e57cf620..652e1b9b76 100644 --- a/products/registry-server/contracts/artifact-inventory.yaml +++ b/products/registry-server/contracts/artifact-inventory.yaml @@ -5,6 +5,7 @@ artifacts: - {path: DEFINITION-OF-DONE.md, kind: completion-contract, state: authored} - {path: IMPLEMENTATION.md, kind: implementation-guide, state: authored} - {path: ACCEPTANCE-JOURNEYS.md, kind: acceptance-guide, state: authored} + - {path: EVENTS-AND-WEBHOOKS.md, kind: extension-direction-spec, state: authored} - {path: contracts/definition-of-done.yaml, kind: machine-readable-dod, state: authored} - {path: contracts/implementation-schedule.yaml, kind: delivery-schedule, state: authored} - {path: contracts/acceptance-scenario-matrix.yaml, kind: acceptance-matrix, state: authored} From a1f455ba926e18ab698c83d2207d67a9292e303e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 30 Aug 2026 17:11:30 +0700 Subject: [PATCH 04/19] feat(registry-server): add governed event webhooks Signed-off-by: Jeremi Joslin --- Cargo.lock | 1 + .../src/destination.rs | 169 +++-- crates/registry-server/src/artifacts.rs | 86 ++- crates/registry-server/src/audit.rs | 9 + crates/registry-server/src/compiler.rs | 339 ++++++--- crates/registry-server/src/contract.rs | 55 +- .../registry-server/src/event_destination.rs | 84 +++ crates/registry-server/src/migration.rs | 23 +- crates/registry-server/src/model.rs | 19 +- crates/registry-server/src/mutation.rs | 350 ++++++++-- crates/registry-server/src/outbox.rs | 110 ++- .../registry-server/src/postgres/interlock.rs | 66 ++ crates/registry-server/src/runtime_config.rs | 59 ++ crates/registry-server/src/schema.rs | 35 + crates/registry-server/src/startup.rs | 25 +- crates/registry-server/src/webhook.rs | 660 +++++++++++++----- .../registry-server/tests/compiler_webhook.rs | 296 +++++--- .../fixtures/fixture-tooling/project.yaml | 2 - .../registry-server/tests/http_read_only.rs | 9 - .../tests/postgres_fixture_journeys.rs | 7 +- .../tests/postgres_mutation.rs | 23 +- .../registry-server/tests/postgres_package.rs | 639 ++++++++++++++++- .../tests/postgres_pilot_acceptance.rs | 23 +- .../tests/postgres_tombstone_revision.rs | 18 +- .../tests/postgres_webhook_delivery.rs | 486 +++++++++++-- .../tests/postgres_webhook_outbox.rs | 450 +++++++++++- .../registry-server/tests/runtime_config.rs | 132 ++-- .../tests/support/pilot_acceptance_harness.rs | 12 + crates/registry-serverctl/Cargo.toml | 1 + crates/registry-serverctl/README.md | 20 + .../registry-serverctl/src/apply_lifecycle.rs | 8 +- crates/registry-serverctl/src/lib.rs | 261 ++++++- .../src/webhook_lifecycle.rs | 564 +++++++++++++++ crates/registry-serverctl/tests/cli.rs | 18 +- crates/registry-serverctl/tests/webhook.rs | 277 ++++++++ .../registry-server/EVENTS-AND-WEBHOOKS.md | 29 +- .../asset-site-placement/registry.yaml | 2 - .../contracts/acceptance-scenario-matrix.yaml | 4 +- .../contracts/definition-of-done.yaml | 2 +- .../contracts/security-invariant-matrix.yaml | 2 +- products/registry-server/demo/README.md | 20 + products/registry-server/demo/run.sh | 144 +++- products/registry-server/demo/support/demo.py | 467 ++++++++++++- .../registry-server/demo/support/test_demo.py | 131 +++- .../authoring/registry-project.schema.json | 141 ++-- 45 files changed, 5432 insertions(+), 846 deletions(-) create mode 100644 crates/registry-serverctl/src/webhook_lifecycle.rs create mode 100644 crates/registry-serverctl/tests/webhook.rs diff --git a/Cargo.lock b/Cargo.lock index b6dda227df..7e75f02908 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4223,6 +4223,7 @@ dependencies = [ "sha2 0.11.0", "thiserror 2.0.20", "tokio", + "uuid", "zeroize", ] diff --git a/crates/registry-platform-httputil/src/destination.rs b/crates/registry-platform-httputil/src/destination.rs index 3d1c88c7d1..945b71335e 100644 --- a/crates/registry-platform-httputil/src/destination.rs +++ b/crates/registry-platform-httputil/src/destination.rs @@ -1409,16 +1409,19 @@ pub type CredentialDestinationRequestTemplate = /// Closed event-delivery request template. pub type EventDestinationRequestTemplate = BoundedDestinationRequestTemplate; -/// Values for the closed event-delivery header set. +/// Values for the closed CloudEvents HTTP binary event-delivery header set. /// /// The product owns the signature and retry policy. This transport only binds /// the supplied values to fixed header names and preserves their exact bytes. pub struct EventDeliveryHeaders<'a> { - pub event_id: &'a [u8], + pub id: &'a [u8], + pub source: &'a [u8], pub event_type: &'a [u8], + pub time: &'a [u8], + pub dataschema: &'a [u8], pub generation: &'a [u8], pub attempt: &'a [u8], - pub timestamp: &'a [u8], + pub delivery_time: &'a [u8], pub idempotency_key: &'a [u8], pub signature: &'a [u8], } @@ -1430,10 +1433,13 @@ impl fmt::Debug for EventDeliveryHeaders<'_> { } const MAX_EVENT_ID_HEADER_BYTES: usize = 128; +const MAX_EVENT_SOURCE_HEADER_BYTES: usize = 2_048; const MAX_EVENT_TYPE_HEADER_BYTES: usize = 256; +const MAX_EVENT_TIME_HEADER_BYTES: usize = 64; +const MAX_EVENT_DATASCHEMA_HEADER_BYTES: usize = 2_048; const MAX_EVENT_GENERATION_HEADER_BYTES: usize = 32; const MAX_EVENT_ATTEMPT_HEADER_BYTES: usize = 32; -const MAX_EVENT_TIMESTAMP_HEADER_BYTES: usize = 64; +const MAX_EVENT_DELIVERY_TIME_HEADER_BYTES: usize = 64; const MAX_EVENT_IDEMPOTENCY_KEY_HEADER_BYTES: usize = 256; const MAX_EVENT_SIGNATURE_HEADER_BYTES: usize = MAX_DESTINATION_HEADER_VALUE_BYTES; @@ -1520,14 +1526,30 @@ impl BoundedDestinationRequestTemplate { name: "content-type", value: b"application/json", }, + HeaderTemplateInput::Exact { + name: "ce-specversion", + value: b"1.0", + }, HeaderTemplateInput::Dynamic { - name: "x-registry-event-id", + name: "ce-id", max_value_bytes: MAX_EVENT_ID_HEADER_BYTES, }, HeaderTemplateInput::Dynamic { - name: "x-registry-event-type", + name: "ce-source", + max_value_bytes: MAX_EVENT_SOURCE_HEADER_BYTES, + }, + HeaderTemplateInput::Dynamic { + name: "ce-type", max_value_bytes: MAX_EVENT_TYPE_HEADER_BYTES, }, + HeaderTemplateInput::Dynamic { + name: "ce-time", + max_value_bytes: MAX_EVENT_TIME_HEADER_BYTES, + }, + HeaderTemplateInput::Dynamic { + name: "ce-dataschema", + max_value_bytes: MAX_EVENT_DATASCHEMA_HEADER_BYTES, + }, HeaderTemplateInput::Dynamic { name: "x-registry-event-generation", max_value_bytes: MAX_EVENT_GENERATION_HEADER_BYTES, @@ -1537,8 +1559,8 @@ impl BoundedDestinationRequestTemplate { max_value_bytes: MAX_EVENT_ATTEMPT_HEADER_BYTES, }, HeaderTemplateInput::Dynamic { - name: "x-registry-event-timestamp", - max_value_bytes: MAX_EVENT_TIMESTAMP_HEADER_BYTES, + name: "x-registry-delivery-time", + max_value_bytes: MAX_EVENT_DELIVERY_TIME_HEADER_BYTES, }, HeaderTemplateInput::Dynamic { name: "idempotency-key", @@ -1580,11 +1602,14 @@ impl BoundedDestinationRequestTemplate { self.render_zeroizing( &[], &[ - headers.event_id, + headers.id, + headers.source, headers.event_type, + headers.time, + headers.dataschema, headers.generation, headers.attempt, - headers.timestamp, + headers.delivery_time, headers.idempotency_key, headers.signature, ], @@ -2447,11 +2472,15 @@ fn closed_oauth_headers( } fn closed_event_headers(headers: &[HeaderTemplateInput<'_>]) -> bool { - const EXACT: [(&str, usize, Option<&[u8]>); 9] = [ + const EXACT: [(&str, usize, Option<&[u8]>); 13] = [ ("accept", 0, Some(b"application/json")), ("content-type", 0, Some(b"application/json")), - ("x-registry-event-id", MAX_EVENT_ID_HEADER_BYTES, None), - ("x-registry-event-type", MAX_EVENT_TYPE_HEADER_BYTES, None), + ("ce-specversion", 0, Some(b"1.0")), + ("ce-id", MAX_EVENT_ID_HEADER_BYTES, None), + ("ce-source", MAX_EVENT_SOURCE_HEADER_BYTES, None), + ("ce-type", MAX_EVENT_TYPE_HEADER_BYTES, None), + ("ce-time", MAX_EVENT_TIME_HEADER_BYTES, None), + ("ce-dataschema", MAX_EVENT_DATASCHEMA_HEADER_BYTES, None), ( "x-registry-event-generation", MAX_EVENT_GENERATION_HEADER_BYTES, @@ -2463,8 +2492,8 @@ fn closed_event_headers(headers: &[HeaderTemplateInput<'_>]) -> bool { None, ), ( - "x-registry-event-timestamp", - MAX_EVENT_TIMESTAMP_HEADER_BYTES, + "x-registry-delivery-time", + MAX_EVENT_DELIVERY_TIME_HEADER_BYTES, None, ), ( @@ -4111,6 +4140,8 @@ mod tests { use super::*; + const MAX_TEST_EVENT_REQUEST_BYTES: usize = 16_384; + fn ip(raw: &str) -> IpAddr { raw.parse().expect("test IP parses") } @@ -4136,11 +4167,15 @@ mod tests { fn event_headers<'a>() -> EventDeliveryHeaders<'a> { EventDeliveryHeaders { - event_id: b"018f1f47-a922-7e31-8000-000000000001", - event_type: b"widget.tombstoned", + id: b"018f1f47-a922-7e31-8000-000000000001", + source: b"urn:registry:example-registry:example-instance", + event_type: b"case-approved-v1", + time: b"2026-08-30T02:03:04Z", + dataschema: + b"urn:registry:schema:example-registry:case-approved-v1:sha256:0123456789abcdef", generation: b"42", attempt: b"1", - timestamp: b"2026-08-30T02:03:04Z", + delivery_time: b"2026-08-30T02:03:05Z", idempotency_key: b"delivery-018f1f47-a922-7e31-8000-000000000001", signature: b"v1=caller-owned-signature", } @@ -6130,16 +6165,21 @@ mod tests { #[test] fn event_post_is_confined_to_its_closed_slot_and_canonical_shape() { let canonical = br#"{"event":"widget.tombstoned","generation":42}"#; - let template = - EventDestinationRequestTemplate::event_delivery("/hooks/registry", 256, 10_240) - .expect("closed event template"); + let template = EventDestinationRequestTemplate::event_delivery( + "/hooks/registry", + 256, + MAX_TEST_EVENT_REQUEST_BYTES, + ) + .expect("closed event template"); let request = template .render_event(event_headers(), canonical.to_vec()) .expect("canonical event renders"); let diagnostic = format!("{template:?} {request:?} {:?}", event_headers()); for canary in [ "hooks/registry", - "widget.tombstoned", + "case-approved-v1", + "example-instance", + "0123456789abcdef", "delivery-018f1f47", "caller-owned-signature", "generation\":42", @@ -6210,6 +6250,15 @@ mod tests { .unwrap_err(), DestinationRequestError::InvalidHeaderValue ); + let oversized_source = vec![b's'; MAX_EVENT_SOURCE_HEADER_BYTES + 1]; + let mut oversized_headers = event_headers(); + oversized_headers.source = &oversized_source; + assert_eq!( + template + .render_event(oversized_headers, canonical.to_vec()) + .unwrap_err(), + DestinationRequestError::TemplateBoundsExceeded + ); assert_eq!( template .render_event(event_headers(), vec![b'x'; 257]) @@ -6254,11 +6303,15 @@ mod tests { let names = [ "accept", "content-type", - "x-registry-event-id", - "x-registry-event-type", + "ce-specversion", + "ce-id", + "ce-source", + "ce-type", + "ce-time", + "ce-dataschema", "x-registry-event-generation", "x-registry-delivery-attempt", - "x-registry-event-timestamp", + "x-registry-delivery-time", "idempotency-key", "x-registry-signature", ]; @@ -6277,13 +6330,31 @@ mod tests { .and_then(|value| value.to_str().ok()) == Some("application/json") && headers - .get("x-registry-event-id") + .get("ce-specversion") + .and_then(|value| value.to_str().ok()) + == Some("1.0") + && headers + .get("ce-id") .and_then(|value| value.to_str().ok()) == Some("018f1f47-a922-7e31-8000-000000000001") && headers - .get("x-registry-event-type") + .get("ce-source") + .and_then(|value| value.to_str().ok()) + == Some("urn:registry:example-registry:example-instance") + && headers + .get("ce-type") + .and_then(|value| value.to_str().ok()) + == Some("case-approved-v1") + && headers + .get("ce-time") + .and_then(|value| value.to_str().ok()) + == Some("2026-08-30T02:03:04Z") + && headers + .get("ce-dataschema") .and_then(|value| value.to_str().ok()) - == Some("widget.tombstoned") + == Some( + "urn:registry:schema:example-registry:case-approved-v1:sha256:0123456789abcdef", + ) && headers .get("x-registry-event-generation") .and_then(|value| value.to_str().ok()) @@ -6293,9 +6364,9 @@ mod tests { .and_then(|value| value.to_str().ok()) == Some("1") && headers - .get("x-registry-event-timestamp") + .get("x-registry-delivery-time") .and_then(|value| value.to_str().ok()) - == Some("2026-08-30T02:03:04Z") + == Some("2026-08-30T02:03:05Z") && headers .get("idempotency-key") .and_then(|value| value.to_str().ok()) @@ -6304,6 +6375,9 @@ mod tests { .get("x-registry-signature") .and_then(|value| value.to_str().ok()) == Some("v1=caller-owned-signature") + && !headers.contains_key("x-registry-event-id") + && !headers.contains_key("x-registry-event-type") + && !headers.contains_key("x-registry-event-timestamp") && !headers.contains_key(AUTHORIZATION); *route_captured.lock().expect("capture lock") = Some((exact, body.to_vec())); @@ -6329,9 +6403,12 @@ mod tests { &[], ) .expect("development event policy"); - let template = - EventDestinationRequestTemplate::event_delivery("/hooks/registry", 1_024, 10_240) - .expect("closed event template"); + let template = EventDestinationRequestTemplate::event_delivery( + "/hooks/registry", + 1_024, + MAX_TEST_EVENT_REQUEST_BYTES, + ) + .expect("closed event template"); let expected = br#"{"event":"widget.tombstoned","generation":42}"#; let request = template .render_event(event_headers(), expected.to_vec()) @@ -6436,11 +6513,14 @@ mod tests { }; let body = br#"{"event":"widget.tombstoned"}"#; - let redirect_request = - EventDestinationRequestTemplate::event_delivery("/hooks/redirect", 256, 10_240) - .expect("redirect event template") - .render_event(event_headers(), body.to_vec()) - .expect("redirect event request"); + let redirect_request = EventDestinationRequestTemplate::event_delivery( + "/hooks/redirect", + 256, + MAX_TEST_EVENT_REQUEST_BYTES, + ) + .expect("redirect event template") + .render_event(event_headers(), body.to_vec()) + .expect("redirect event request"); let redirect_response = policy .send_with_resolver( redirect_request, @@ -6453,11 +6533,14 @@ mod tests { assert_eq!(redirect_response.status(), StatusCode::FOUND); assert_eq!(redirected.load(Ordering::SeqCst), 0); - let large_request = - EventDestinationRequestTemplate::event_delivery("/hooks/large", 256, 10_240) - .expect("large-response event template") - .render_event(event_headers(), body.to_vec()) - .expect("large-response event request"); + let large_request = EventDestinationRequestTemplate::event_delivery( + "/hooks/large", + 256, + MAX_TEST_EVENT_REQUEST_BYTES, + ) + .expect("large-response event template") + .render_event(event_headers(), body.to_vec()) + .expect("large-response event request"); let large_response = policy .send_with_resolver( large_request, diff --git a/crates/registry-server/src/artifacts.rs b/crates/registry-server/src/artifacts.rs index 36e12bf482..8a3f7bbe6a 100644 --- a/crates/registry-server/src/artifacts.rs +++ b/crates/registry-server/src/artifacts.rs @@ -8,7 +8,8 @@ use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use crate::contract::{ - FieldTypeSource, ManifestProjectionSource, MutationMode, Operation, PackageIdentitySource, + EventSource, EventTrigger, FieldTypeSource, ManifestProjectionSource, MutationMode, Operation, + PackageIdentitySource, }; use crate::diagnostics::Diagnostic; use crate::generated_ddl::DdlInventory; @@ -22,6 +23,13 @@ use crate::physical_names::{hex_prefix, PhysicalNameInventory}; pub const REGISTRY_METADATA_ARTIFACT_PATH: &str = "generated/metadata/registry.json"; +pub(crate) struct EventDataSchemaBinding { + pub schema: Value, + pub fingerprint: String, + pub data_schema: String, + pub artifact_path: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct GeneratedArtifact { @@ -133,6 +141,20 @@ pub(crate) fn generate_artifacts( insert_json_value(&mut artifacts, &path, &schema)?; schemas.insert(entity.id.clone(), schema); } + for delivery in &event_deliveries.deliveries { + let entity = entities + .get(&delivery.entity_id) + .expect("compiled event delivery refers to a compiled entity"); + let event = entity + .events + .get(&delivery.event_id) + .expect("compiled event delivery refers to a compiled event"); + let binding = event_data_schema_binding(registry_id, entity, event)?; + debug_assert_eq!(delivery.data_schema, binding.data_schema); + debug_assert_eq!(delivery.data_schema_fingerprint, binding.fingerprint); + debug_assert_eq!(delivery.data_schema_artifact_path, binding.artifact_path); + insert_json_value(&mut artifacts, &binding.artifact_path, &binding.schema)?; + } let openapi = openapi_document(registry_id, version, entities, routes, &schemas); insert_json_value(&mut artifacts, "generated/openapi.json", &openapi)?; if let Some(projection) = manifest_projection { @@ -153,6 +175,68 @@ pub(crate) fn generate_artifacts( Ok(GeneratedArtifacts { artifacts }) } +pub(crate) fn event_data_schema_binding( + registry_id: &str, + entity: &CompiledEntity, + event: &EventSource, +) -> Result { + let mut value_properties = Map::new(); + for field_id in &event.projection { + let field = entity + .fields + .get(field_id) + .expect("validated event projection refers to a compiled field"); + let schema = field_schema(&field.field_type); + let schema = if field.required { + schema + } else { + json!({"anyOf": [schema, {"type": "null"}]}) + }; + value_properties.insert(field_id.clone(), schema); + } + let trigger = match event.trigger { + EventTrigger::Created => "created", + EventTrigger::Patched => "patched", + EventTrigger::Tombstoned => "tombstoned", + }; + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "entity": {"const": entity.id}, + "recordId": {"type": "string", "format": "uuid"}, + "revision": {"type": "integer", "format": "int64", "minimum": 1}, + "trigger": {"const": trigger}, + "packageRevision": {"type": "string"}, + "values": { + "type": "object", + "additionalProperties": false, + "properties": value_properties, + "required": event.projection, + } + }, + "required": ["entity", "recordId", "revision", "trigger", "packageRevision", "values"] + }); + let bytes = canonicalize_json(&schema).map_err(|_| canonicalization_error())?; + let digest = Sha256::digest(&bytes); + let fingerprint = format!("sha256:{}", hex_prefix(&digest, digest.len())); + let data_schema = format!( + "urn:registry-server:event-schema:{registry_id}:{}:{}:{fingerprint}", + entity.id, event.id + ); + let artifact_path = format!( + "generated/event-schemas/{}.{}.schema.json", + entity.id, event.id + ); + Ok(EventDataSchemaBinding { + schema, + fingerprint, + data_schema, + artifact_path, + }) +} + fn entity_schema(entity: &CompiledEntity) -> Value { let mut properties = Map::new(); let mut required = Vec::new(); diff --git a/crates/registry-server/src/audit.rs b/crates/registry-server/src/audit.rs index 82aed08182..5469fea53d 100644 --- a/crates/registry-server/src/audit.rs +++ b/crates/registry-server/src/audit.rs @@ -88,6 +88,7 @@ pub(crate) enum WebhookAuditOutcome { DestinationPolicyRefused, DestinationBindingRefused, PayloadRefused, + PayloadExpired, WorkerInterrupted, ReplayRequested, } @@ -98,6 +99,7 @@ pub(crate) enum WebhookAuditDisposition { Delivered, RetryPending, DeadLettered, + Expired, ReplayPending, } @@ -356,6 +358,11 @@ pub(crate) async fn append_webhook_audit( | WebhookAuditOutcome::WorkerInterrupted, WebhookAuditDisposition::RetryPending | WebhookAuditDisposition::DeadLettered, ) => event.attempt > 0, + ( + WebhookAuditPhase::Terminal, + WebhookAuditOutcome::PayloadExpired, + WebhookAuditDisposition::Expired, + ) => event.attempt >= 0, ( WebhookAuditPhase::Replay, WebhookAuditOutcome::ReplayRequested, @@ -424,6 +431,7 @@ fn webhook_outcome_name(outcome: WebhookAuditOutcome) -> &'static str { WebhookAuditOutcome::DestinationPolicyRefused => "destination_policy_refused", WebhookAuditOutcome::DestinationBindingRefused => "destination_binding_refused", WebhookAuditOutcome::PayloadRefused => "payload_refused", + WebhookAuditOutcome::PayloadExpired => "payload_expired", WebhookAuditOutcome::WorkerInterrupted => "worker_interrupted", WebhookAuditOutcome::ReplayRequested => "replay_requested", } @@ -435,6 +443,7 @@ fn webhook_disposition_name(disposition: WebhookAuditDisposition) -> &'static st WebhookAuditDisposition::Delivered => "delivered", WebhookAuditDisposition::RetryPending => "retry_pending", WebhookAuditDisposition::DeadLettered => "dead_lettered", + WebhookAuditDisposition::Expired => "expired", WebhookAuditDisposition::ReplayPending => "replay_pending", } } diff --git a/crates/registry-server/src/compiler.rs b/crates/registry-server/src/compiler.rs index 2fe881519e..351b8f4e7b 100644 --- a/crates/registry-server/src/compiler.rs +++ b/crates/registry-server/src/compiler.rs @@ -9,13 +9,13 @@ use sha2::{Digest, Sha256}; use time::{format_description::well_known::Rfc3339, Date, Month, OffsetDateTime}; use uuid::Uuid; -use crate::artifacts::generate_artifacts; +use crate::artifacts::{event_data_schema_binding, generate_artifacts}; use crate::contract::{ parsed_bbox, valid_decimal_bounds, valid_structured_schema, AccessProfileSource, - Classification, ConstraintSource, EntityExtensionSource, EntitySource, EventTrigger, - FieldSource, FieldTypeSource, ManifestProjectionTextSource, MutationMode, Operation, - RegistryModule, RegistryProject, UniqueWhenPredicate, ValidTimeRole, WebhookDeadLetterMode, - MAX_STRUCTURED_VALUE_BYTES, + Classification, ConstraintSource, EntityExtensionSource, EntitySource, EventConditionSource, + EventScalarValue, EventTrigger, FieldSource, FieldTypeSource, ManifestProjectionTextSource, + MutationMode, Operation, RegistryModule, RegistryProject, UniqueWhenPredicate, ValidTimeRole, + WebhookAuthenticationProfile, WebhookDeadLetterMode, MAX_STRUCTURED_VALUE_BYTES, }; use crate::diagnostics::{CompileFailure, Diagnostic}; use crate::generated_ddl::generate_ddl; @@ -26,8 +26,8 @@ use crate::model::{ CompiledQueryFilterOperator, CompiledQueryInventory, CompiledQueryKind, CompiledQueryOperation, CompiledQuerySortDirection, CompiledQuerySortField, CompiledQueryTemporalBinding, CompiledQueryTemporalSemantics, CompiledRegistry, CompiledRevisionKind, CompiledRoute, - CompiledRouteInventory, CompiledTemporal, CompiledWebhookDeliveryMode, HttpMethod, - MAX_REVISION_HISTORY_RECORDS, + CompiledRouteInventory, CompiledTemporal, CompiledWebhookDeliveryMode, + CompiledWebhookRetryProfile, HttpMethod, MAX_REVISION_HISTORY_RECORDS, }; use crate::physical_names::{ hex_prefix, EntityPhysicalNames, PhysicalNameBuilder, PhysicalNameInventory, @@ -37,21 +37,20 @@ pub const AUTHORING_API_VERSION: &str = "registry.registrystack.org/v1alpha1"; pub const MAX_BATCH_ITEMS: u16 = 100; pub const MAX_BATCH_BYTES: u32 = 2_097_152; pub const MIN_WEBHOOK_ATTEMPT_TIMEOUT_MS: u32 = 100; -/// Maximum per-attempt timeout accepted by the governed event transport. -/// -/// This matches the platform event-destination operation ceiling. Runtime -/// activation may narrow it, but can never widen the compiled authority. -pub const MAX_WEBHOOK_ATTEMPT_TIMEOUT_MS: u32 = 10_000; -pub const MIN_WEBHOOK_BACKOFF_MS: u32 = 100; -pub const MAX_WEBHOOK_BACKOFF_MS: u32 = 3_600_000; -pub const MAX_WEBHOOK_ATTEMPTS: u8 = 20; +pub const WEBHOOK_ATTEMPT_TIMEOUT_MS: u32 = 5_000; +pub const WEBHOOK_INITIAL_BACKOFF_MS: u32 = 1_000; +pub const WEBHOOK_MAXIMUM_BACKOFF_MS: u32 = 8_000; +pub const WEBHOOK_MAXIMUM_ATTEMPTS: u8 = 5; +pub const MAX_WEBHOOK_ATTEMPT_TIMEOUT_MS: u32 = WEBHOOK_ATTEMPT_TIMEOUT_MS; +pub const MAX_WEBHOOK_ATTEMPTS: u8 = WEBHOOK_MAXIMUM_ATTEMPTS; +pub const MAX_EVENT_PACKAGE_REVISION_BYTES: u32 = 256; /// Maximum canonical event body accepted by the governed webhook transport. /// /// This intentionally matches the platform event-destination body ceiling. /// Keeping it in the pure compiler avoids pulling an HTTP client into the /// default no-I/O authoring graph; the runtime integration pins the equality. pub const MAX_WEBHOOK_PAYLOAD_BYTES: u32 = 1_048_576; -const WEBHOOK_BACKOFF_MULTIPLIER: u8 = 2; +pub const WEBHOOK_BACKOFF_MULTIPLIER: u8 = 2; #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -77,7 +76,7 @@ pub fn compile_project( apply_extensions(&mut sources, &module_order, &module_map, &mut diagnostics); expand_project_access(project, &mut sources, &mut diagnostics); resolve_vocabularies(project, &mut sources, &mut diagnostics); - validate_entities(&sources, &mut diagnostics); + validate_entities(&sources, profile, &mut diagnostics); if !diagnostics.is_empty() { return Err(CompileFailure::from_errors(diagnostics)); } @@ -93,7 +92,9 @@ pub fn compile_project( ) .map_err(CompileFailure::from_one)?; let query_inventory = compile_query_inventory(&entities, &mut diagnostics); - let event_delivery_inventory = compile_event_delivery_inventory(&entities); + let event_delivery_inventory = + compile_event_delivery_inventory(&project.registry.id, &entities) + .map_err(CompileFailure::from_one)?; validate_manifest_projection(project, &entities, &mut diagnostics); if !diagnostics.is_empty() { return Err(CompileFailure::from_errors(diagnostics)); @@ -1011,8 +1012,13 @@ fn resolve_vocabularies( } } -fn validate_entities(entities: &BTreeMap, errors: &mut Vec) { +fn validate_entities( + entities: &BTreeMap, + profile: CompileProfile, + errors: &mut Vec, +) { let mut routes = BTreeSet::new(); + let mut event_ids = BTreeSet::new(); for entity in entities.values() { validate_id(&entity.id, "entities[].id", errors); validate_id(&entity.route, "entities[].route", errors); @@ -1058,7 +1064,7 @@ fn validate_entities(entities: &BTreeMap, errors: &mut Vec validate_constraints(entity, errors); validate_indexes(entity, errors); validate_profiles(entity, errors); - validate_events(entity, errors); + validate_events(entity, profile, &mut event_ids, errors); } } @@ -1692,7 +1698,12 @@ fn validate_profiles(entity: &EntitySource, errors: &mut Vec) { } } -fn validate_events(entity: &EntitySource, errors: &mut Vec) { +fn validate_events( + entity: &EntitySource, + profile: CompileProfile, + registry_event_ids: &mut BTreeSet, + errors: &mut Vec, +) { let fields: BTreeMap<&str, &FieldSource> = entity .fields .iter() @@ -1707,6 +1718,12 @@ fn validate_events(entity: &EntitySource, errors: &mut Vec) { "entities[].events[].id", "an event identifier is duplicated", )); + } else if !registry_event_ids.insert(event.id.clone()) { + errors.push(Diagnostic::error( + "event.id.registry_duplicate", + "entities[].events[].id", + "an event identifier must be unique across the Registry", + )); } if event.projection.is_empty() { errors.push(Diagnostic::error( @@ -1726,11 +1743,12 @@ fn validate_events(entity: &EntitySource, errors: &mut Vec) { "an event projection refers to an unknown field", )); } - let maximum_payload_bytes = maximum_event_payload_bytes(&event.projection, |field| { - fields - .get(field) - .map(|field| (&field.field_type, field.required)) - }); + let maximum_payload_bytes = + maximum_event_payload_bytes(&entity.id, &event.projection, |field| { + fields + .get(field) + .map(|field| (&field.field_type, field.required)) + }); if matches!( event.trigger, EventTrigger::Patched | EventTrigger::Tombstoned @@ -1749,7 +1767,15 @@ fn validate_events(entity: &EntitySource, errors: &mut Vec) { "a tombstone event requires tombstone behavior", )); } + validate_event_condition(event, &fields, errors); let Some(webhook) = event.webhook.as_ref() else { + if profile == CompileProfile::Production { + errors.push(Diagnostic::error( + "event.delivery.required", + "entities[].events[].webhook", + "a production event requires a supported delivery", + )); + } continue; }; if maximum_payload_bytes @@ -1768,51 +1794,74 @@ fn validate_events(entity: &EntitySource, errors: &mut Vec) { "a webhook destination must use the closed logical identifier grammar", )); } - if event.projection.iter().any(|field| { - fields - .get(field.as_str()) - .is_some_and(|field| field.classification > webhook.classification_ceiling) - }) { - errors.push(Diagnostic::error( - "event.webhook.classification_ceiling.underdeclared", - "entities[].events[].webhook.classificationCeiling", - "the webhook classification ceiling is below a projected field", - )); - } - let delivery = &webhook.delivery; - if !(MIN_WEBHOOK_ATTEMPT_TIMEOUT_MS..=MAX_WEBHOOK_ATTEMPT_TIMEOUT_MS) - .contains(&delivery.attempt_timeout_ms) - { - errors.push(Diagnostic::error( - "event.webhook.timeout.invalid", - "entities[].events[].webhook.delivery.attemptTimeoutMs", - "the webhook per-attempt timeout is outside the supported bounds", - )); - } - if !(MIN_WEBHOOK_BACKOFF_MS..=MAX_WEBHOOK_BACKOFF_MS).contains(&delivery.initial_backoff_ms) - || !(MIN_WEBHOOK_BACKOFF_MS..=MAX_WEBHOOK_BACKOFF_MS) - .contains(&delivery.maximum_backoff_ms) - || delivery.initial_backoff_ms > delivery.maximum_backoff_ms - { - errors.push(Diagnostic::error( - "event.webhook.backoff.invalid", - "entities[].events[].webhook.delivery", - "webhook backoff bounds must be positive, bounded, and internally coherent", - )); - } - if delivery.maximum_attempts == 0 || delivery.maximum_attempts > MAX_WEBHOOK_ATTEMPTS { + } +} + +fn validate_event_condition( + event: &crate::contract::EventSource, + fields: &BTreeMap<&str, &FieldSource>, + errors: &mut Vec, +) { + let Some(EventConditionSource::Fields { + changed, + before_equals, + after_equals, + }) = event.when.as_ref() + else { + return; + }; + if changed.is_empty() && before_equals.is_empty() && after_equals.is_empty() { + errors.push(Diagnostic::error( + "event.when.empty", + "entities[].events[].when", + "a field event condition requires at least one predicate", + )); + } + let compatible = match event.trigger { + EventTrigger::Created => changed.is_empty() && before_equals.is_empty(), + EventTrigger::Patched => true, + EventTrigger::Tombstoned => changed.is_empty() && after_equals.is_empty(), + }; + if !compatible { + errors.push(Diagnostic::error( + "event.when.trigger_incompatible", + "entities[].events[].when", + "the field predicates are unavailable for this event trigger", + )); + } + for field in changed { + if !fields.contains_key(field.as_str()) { errors.push(Diagnostic::error( - "event.webhook.attempts.invalid", - "entities[].events[].webhook.delivery.maximumAttempts", - "webhook maximum attempts must be within the supported bound", + "event.when.field_unknown", + "entities[].events[].when.changed", + "an event condition refers to an unknown field", )); } - if delivery.dead_letter != Some(WebhookDeadLetterMode::Required) { - errors.push(Diagnostic::error( - "event.webhook.dead_letter.required", - "entities[].events[].webhook.delivery.deadLetter", - "webhook delivery requires dead-letter handling", - )); + } + for (path, predicates) in [ + ("entities[].events[].when.beforeEquals", before_equals), + ("entities[].events[].when.afterEquals", after_equals), + ] { + for (field, value) in predicates { + let Some(source) = fields.get(field.as_str()) else { + errors.push(Diagnostic::error( + "event.when.field_unknown", + path, + "an event condition refers to an unknown field", + )); + continue; + }; + if matches!(value, EventScalarValue::Null) { + continue; + } + let value = serde_json::to_value(value).expect("event scalar value serializes"); + if canonical_field_literal(&value, &source.field_type).is_none() { + errors.push(Diagnostic::error( + "event.when.value_invalid", + path, + "an event comparison value must be canonical for its declared field type", + )); + } } } } @@ -1830,6 +1879,40 @@ fn valid_logical_destination_id(value: &str) -> bool { } fn maximum_event_payload_bytes<'a>( + entity_id: &str, + projection: &BTreeSet, + field: impl Fn(&str) -> Option<(&'a FieldTypeSource, bool)>, +) -> Option { + let values = maximum_event_values_bytes(projection, field)?; + // Canonical body object braces, five separators, and the six fixed key + // encodings (two quotes plus a colon per key). + let mut total = 2_u64.checked_add(5)?; + for key in [ + "entity", + "recordId", + "revision", + "trigger", + "packageRevision", + "values", + ] { + total = total.checked_add(key.len() as u64 + 3)?; + } + // Entity ids and triggers use the compiler's closed ASCII grammars. + total = total.checked_add(entity_id.len() as u64 + 2)?; + // A UUID string, the largest positive i64 revision, and the longest + // trigger string, including JSON quotes where applicable. + total = total.checked_add(38)?.checked_add(19)?.checked_add(12)?; + // Persisted package revisions are bounded to 256 bytes. Six bytes per + // byte plus quotes safely covers JSON's longest control-character escape. + total = total.checked_add( + u64::from(MAX_EVENT_PACKAGE_REVISION_BYTES) + .checked_mul(6)? + .checked_add(2)?, + )?; + total.checked_add(values) +} + +fn maximum_event_values_bytes<'a>( projection: &BTreeSet, field: impl Fn(&str) -> Option<(&'a FieldTypeSource, bool)>, ) -> Option { @@ -1854,6 +1937,19 @@ fn maximum_event_payload_bytes<'a>( Some(total) } +pub(crate) fn maximum_compiled_event_payload_bytes( + entity: &CompiledEntity, + event: &crate::contract::EventSource, +) -> Option { + let maximum = maximum_event_payload_bytes(&entity.id, &event.projection, |field| { + entity + .fields + .get(field) + .map(|field| (&field.field_type, field.required)) + })?; + u32::try_from(maximum).ok() +} + fn maximum_field_json_bytes(field_type: &FieldTypeSource) -> Option { let bytes = match field_type { FieldTypeSource::Boolean => 5, @@ -1893,54 +1989,81 @@ fn maximum_field_json_bytes(field_type: &FieldTypeSource) -> Option { } fn compile_event_delivery_inventory( + registry_id: &str, entities: &BTreeMap, -) -> CompiledEventDeliveryInventory { +) -> Result { let mut deliveries = entities .values() .flat_map(|entity| { entity.events.values().filter_map(move |event| { - let webhook = event.webhook.as_ref()?; - let delivery = &webhook.delivery; - Some(CompiledEventDelivery { - id: format!("events.{}.{}.webhook", entity.id, event.id), - entity_id: entity.id.clone(), - event_id: event.id.clone(), - trigger: event.trigger, - destination_id: webhook.destination_id.clone(), - projection_fields: event.projection.iter().cloned().collect(), - classification_ceiling: webhook.classification_ceiling, - authentication_profile: webhook.authentication_profile, - delivery_mode: CompiledWebhookDeliveryMode::AfterCommit, - attempt_timeout_ms: delivery.attempt_timeout_ms, - initial_backoff_ms: delivery.initial_backoff_ms, - maximum_backoff_ms: delivery.maximum_backoff_ms, - exponential_backoff_multiplier: WEBHOOK_BACKOFF_MULTIPLIER, - maximum_attempts: delivery.maximum_attempts, - retry_delays_ms: webhook_retry_delays( - delivery.initial_backoff_ms, - delivery.maximum_backoff_ms, - delivery.maximum_attempts, - ), - maximum_payload_bytes: u32::try_from( - maximum_event_payload_bytes(&event.projection, |field| { - entity - .fields - .get(field) - .map(|field| (&field.field_type, field.required)) - }) - .expect("validated webhook projection fields are bounded"), - ) - .expect("validated webhook projection fits the transport bound"), - dead_letter: delivery - .dead_letter - .expect("validated webhook delivery requires dead letter"), - operator_replay: delivery.operator_replay, - }) + event + .webhook + .as_ref() + .map(|webhook| (entity, event, webhook)) }) }) - .collect::>(); + .map(|(entity, event, webhook)| { + let binding = event_data_schema_binding(registry_id, entity, event)?; + let classification_ceiling = event + .projection + .iter() + .chain(event_condition_fields(event)) + .filter_map(|field| entity.fields.get(field)) + .map(|field| field.classification) + .max() + .expect("validated event projection is non-empty"); + Ok(CompiledEventDelivery { + id: format!("events.{}.{}.webhook", entity.id, event.id), + entity_id: entity.id.clone(), + event_id: event.id.clone(), + trigger: event.trigger, + destination_id: webhook.destination_id.clone(), + projection_fields: event.projection.iter().cloned().collect(), + when: event.when.clone(), + classification_ceiling, + data_schema: binding.data_schema, + data_schema_fingerprint: binding.fingerprint, + data_schema_artifact_path: binding.artifact_path, + authentication_profile: WebhookAuthenticationProfile::HmacSha256V1, + delivery_mode: CompiledWebhookDeliveryMode::AfterCommit, + retry_profile: CompiledWebhookRetryProfile::RegistryV1, + attempt_timeout_ms: WEBHOOK_ATTEMPT_TIMEOUT_MS, + initial_backoff_ms: WEBHOOK_INITIAL_BACKOFF_MS, + maximum_backoff_ms: WEBHOOK_MAXIMUM_BACKOFF_MS, + exponential_backoff_multiplier: WEBHOOK_BACKOFF_MULTIPLIER, + maximum_attempts: WEBHOOK_MAXIMUM_ATTEMPTS, + retry_delays_ms: webhook_retry_delays( + WEBHOOK_INITIAL_BACKOFF_MS, + WEBHOOK_MAXIMUM_BACKOFF_MS, + WEBHOOK_MAXIMUM_ATTEMPTS, + ), + maximum_payload_bytes: maximum_compiled_event_payload_bytes(entity, event) + .expect("validated webhook projection fields are bounded"), + dead_letter: WebhookDeadLetterMode::Required, + operator_replay: true, + }) + }) + .collect::, Diagnostic>>()?; deliveries.sort_by(|left, right| left.id.cmp(&right.id)); - CompiledEventDeliveryInventory { deliveries } + Ok(CompiledEventDeliveryInventory { deliveries }) +} + +fn event_condition_fields( + event: &crate::contract::EventSource, +) -> Box + '_> { + match event.when.as_ref() { + Some(EventConditionSource::Fields { + changed, + before_equals, + after_equals, + }) => Box::new( + changed + .iter() + .chain(before_equals.keys()) + .chain(after_equals.keys()), + ), + None => Box::new(std::iter::empty()), + } } fn webhook_retry_delays(initial_ms: u32, maximum_ms: u32, maximum_attempts: u8) -> Vec { diff --git a/crates/registry-server/src/contract.rs b/crates/registry-server/src/contract.rs index 1e3cf13157..a5001af418 100644 --- a/crates/registry-server/src/contract.rs +++ b/crates/registry-server/src/contract.rs @@ -1431,9 +1431,48 @@ pub struct EventSource { pub trigger: EventTrigger, pub projection: BTreeSet, #[serde(default, skip_serializing_if = "Option::is_none")] + pub when: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub webhook: Option, } +/// Closed Version 1 event selection language. +/// +/// A tagged shape leaves room for a later, separately governed rule ABI +/// without turning fields into an ad hoc expression language. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde( + deny_unknown_fields, + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum EventConditionSource { + Fields { + #[serde(default)] + changed: BTreeSet, + #[serde(default)] + before_equals: BTreeMap, + #[serde(default)] + after_equals: BTreeMap, + }, +} + +/// A comparison literal in the closed field-condition language. +/// +/// Objects and arrays are refused during source parsing. The compiler then +/// validates each scalar against the declared Registry field type. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum EventScalarValue { + Null, + Boolean(bool), + Number(serde_json::Number), + String(String), +} + /// Governed, destination-neutral webhook subscription. /// /// Deployment configuration may bind `destination_id` to transport details @@ -1443,9 +1482,6 @@ pub struct EventSource { #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct WebhookSource { pub destination_id: String, - pub classification_ceiling: Classification, - pub authentication_profile: WebhookAuthenticationProfile, - pub delivery: WebhookDeliverySource, } #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -1455,19 +1491,6 @@ pub enum WebhookAuthenticationProfile { HmacSha256V1, } -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct WebhookDeliverySource { - pub attempt_timeout_ms: u32, - pub initial_backoff_ms: u32, - pub maximum_backoff_ms: u32, - pub maximum_attempts: u8, - #[serde(default)] - pub dead_letter: Option, - pub operator_replay: bool, -} - #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/registry-server/src/event_destination.rs b/crates/registry-server/src/event_destination.rs index 487acfc3fc..8ad2c25a37 100644 --- a/crates/registry-server/src/event_destination.rs +++ b/crates/registry-server/src/event_destination.rs @@ -24,6 +24,7 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use thiserror::Error; +use crate::contract::Classification; use crate::{ compiler::{ MAX_WEBHOOK_ATTEMPTS, MAX_WEBHOOK_ATTEMPT_TIMEOUT_MS, MAX_WEBHOOK_PAYLOAD_BYTES, @@ -73,6 +74,41 @@ pub type Result = std::result::Result; pub struct ActivatedEventDestinationRegistry { binding_digest: String, bindings: BTreeMap, + payload_retention: Duration, +} + +/// Value-free destination identity supplied to package activation. +/// +/// This inventory deliberately contains no URL, path, TLS material, or secret +/// reference. It is sufficient only to prove that retained non-terminal work +/// can still use the exact destination binding under which it was captured. +#[derive(Clone, Default)] +pub struct EventDestinationCompatibilityInventory { + binding_digests: BTreeMap, +} + +impl EventDestinationCompatibilityInventory { + #[must_use] + pub fn binding_digest(&self, logical_destination_id: &str) -> Option<&str> { + self.binding_digests + .get(logical_destination_id) + .map(String::as_str) + } + + pub(crate) fn binding_digests(&self) -> impl Iterator { + self.binding_digests + .iter() + .map(|(logical_id, digest)| (logical_id.as_str(), digest.as_str())) + } +} + +impl fmt::Debug for EventDestinationCompatibilityInventory { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EventDestinationCompatibilityInventory") + .field("binding_count", &self.binding_digests.len()) + .finish() + } } impl ActivatedEventDestinationRegistry { @@ -106,6 +142,7 @@ impl ActivatedEventDestinationRegistry { if deliveries.iter().any(|delivery| { config.delivery_ceilings.attempt_timeout_milliseconds > delivery.attempt_timeout_ms || config.delivery_ceilings.maximum_attempts > delivery.maximum_attempts + || delivery.classification_ceiling > config.classification_ceiling }) { return Err(EventDestinationActivationError::DeliveryCeilingWidening); } @@ -123,9 +160,21 @@ impl ActivatedEventDestinationRegistry { Ok(Self { binding_digest: configured.binding_digest()?, bindings, + payload_retention: Duration::from_secs(7 * 24 * 60 * 60), }) } + pub(crate) fn with_payload_retention(mut self, payload_retention: Duration) -> Self { + self.payload_retention = payload_retention; + self + } + + /// Deployment-selected lifetime of a retained pending or dead-letter body. + #[must_use] + pub fn payload_retention(&self) -> Duration { + self.payload_retention + } + /// Digest of the exact non-secret deployment binding document. #[must_use] pub fn binding_digest(&self) -> &str { @@ -137,6 +186,21 @@ impl ActivatedEventDestinationRegistry { pub fn lookup(&self, compiled_logical_id: &str) -> Option<&ActivatedEventDestination> { self.bindings.get(compiled_logical_id) } + + /// Build the minimized inventory used to keep queued delivery compatible + /// across a package activation. + #[must_use] + pub fn compatibility_inventory(&self) -> EventDestinationCompatibilityInventory { + EventDestinationCompatibilityInventory { + binding_digests: self + .bindings + .iter() + .map(|(logical_id, destination)| { + (logical_id.clone(), destination.binding_digest.clone()) + }) + .collect(), + } + } } impl fmt::Debug for ActivatedEventDestinationRegistry { @@ -145,6 +209,7 @@ impl fmt::Debug for ActivatedEventDestinationRegistry { .debug_struct("ActivatedEventDestinationRegistry") .field("binding_digest", &self.binding_digest) .field("binding_count", &self.bindings.len()) + .field("payload_retention", &self.payload_retention) .finish() } } @@ -152,6 +217,7 @@ impl fmt::Debug for ActivatedEventDestinationRegistry { /// One activated logical event destination. pub struct ActivatedEventDestination { binding_digest: String, + request_target: String, policy: Arc, request_template: EventDestinationRequestTemplate, hmac_sha256_key: ProtectedSecret, @@ -178,6 +244,15 @@ impl ActivatedEventDestination { &self.request_template } + /// Borrow the exact validated request target used by the closed template. + /// + /// Registry Server binds these same bytes into its product-owned + /// signature, so signing and transport cannot disagree about the path. + #[must_use] + pub fn request_target(&self) -> &str { + &self.request_target + } + /// Borrow signing bytes only for the duration of the supplied operation. pub fn with_hmac_sha256_key(&self, use_key: impl FnOnce(&[u8]) -> T) -> T { use_key(self.hmac_sha256_key.expose_secret()) @@ -201,6 +276,7 @@ impl fmt::Debug for ActivatedEventDestination { formatter .debug_struct("ActivatedEventDestination") .field("binding_digest", &self.binding_digest) + .field("request_target", &"[REDACTED]") .field("policy", &self.policy) .field("request_template", &self.request_template) .field("hmac_sha256_key", &"[REDACTED]") @@ -264,6 +340,7 @@ struct EventDestinationConfig { dns_family: EventDestinationDnsFamily, allowed_private_cidrs: Vec, hmac_sha256_key_ref: SecretReference, + classification_ceiling: Classification, tls: Option, delivery_ceilings: EventDestinationDeliveryCeilings, } @@ -285,6 +362,7 @@ impl EventDestinationConfig { dns_family: raw.dns_family, allowed_private_cidrs, hmac_sha256_key_ref, + classification_ceiling: raw.classification_ceiling, tls, delivery_ceilings, }; @@ -362,6 +440,7 @@ impl EventDestinationConfig { Ok(ActivatedEventDestination { binding_digest: self.binding_digest(logical_id)?, + request_target: self.path.clone(), policy: Arc::new(policy), request_template, hmac_sha256_key, @@ -387,6 +466,7 @@ impl EventDestinationConfig { "dnsFamily": self.dns_family.as_str(), "allowedPrivateCidrs": self.allowed_private_cidrs.iter().map(ToString::to_string).collect::>(), "hmacSha256KeyRef": self.hmac_sha256_key_ref.as_str(), + "classificationCeiling": self.classification_ceiling, "tls": tls, "deliveryCeilings": { "attemptTimeoutMilliseconds": self.delivery_ceilings.attempt_timeout_milliseconds, @@ -469,6 +549,7 @@ impl EventDestinationDeliveryCeilings { #[serde(rename_all = "camelCase")] enum EventDestinationNetworkProfile { ProductionHttps, + LoopbackDevelopmentHttp, #[cfg(feature = "postgres-test")] PinnedLoopbackHttpsTest, } @@ -477,6 +558,7 @@ impl EventDestinationNetworkProfile { fn platform(self) -> DestinationProfile { match self { Self::ProductionHttps => DestinationProfile::ProductionHttps, + Self::LoopbackDevelopmentHttp => DestinationProfile::LoopbackDevelopmentHttp, #[cfg(feature = "postgres-test")] Self::PinnedLoopbackHttpsTest => DestinationProfile::PinnedLoopbackHttpsTest, } @@ -485,6 +567,7 @@ impl EventDestinationNetworkProfile { fn as_str(self) -> &'static str { match self { Self::ProductionHttps => "productionHttps", + Self::LoopbackDevelopmentHttp => "loopbackDevelopmentHttp", #[cfg(feature = "postgres-test")] Self::PinnedLoopbackHttpsTest => "pinnedLoopbackHttpsTest", } @@ -530,6 +613,7 @@ pub(crate) struct RawEventDestinationConfig { dns_family: EventDestinationDnsFamily, allowed_private_cidrs: Vec, hmac_sha256_key_ref: String, + classification_ceiling: Classification, #[serde(default)] tls: Option, delivery_ceilings: RawEventDestinationDeliveryCeilings, diff --git a/crates/registry-server/src/migration.rs b/crates/registry-server/src/migration.rs index 8859f7b7b6..d65f8ab5b4 100644 --- a/crates/registry-server/src/migration.rs +++ b/crates/registry-server/src/migration.rs @@ -12,6 +12,7 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use crate::event_destination::EventDestinationCompatibilityInventory; use crate::migration_plan::{ ExternalBackupBinding, ReviewedMigrationStepDescriptor, ValidatedReviewedMigrationPlan, }; @@ -120,6 +121,7 @@ pub struct ApplyVerifiedPackageRequest<'a> { roles: ApplyRoles<'a>, timeouts: ApplyTimeouts, backup_evidence: &'a [DestructiveBackupEvidence<'a>], + event_destination_compatibility_inventory: Option<&'a EventDestinationCompatibilityInventory>, fault_after_committed_chunks: Option, } @@ -139,6 +141,7 @@ impl<'a> ApplyVerifiedPackageRequest<'a> { roles, timeouts, backup_evidence: &[], + event_destination_compatibility_inventory: None, fault_after_committed_chunks: None, } } @@ -152,6 +155,19 @@ impl<'a> ApplyVerifiedPackageRequest<'a> { self } + /// Bind successor activation to the target runtime's activated, + /// non-secret logical destination inventory. Omitting this inventory is + /// equivalent to an empty inventory and therefore fails closed when any + /// retained non-terminal webhook work exists. + #[must_use] + pub fn with_event_destination_compatibility_inventory( + mut self, + inventory: &'a EventDestinationCompatibilityInventory, + ) -> Self { + self.event_destination_compatibility_inventory = Some(inventory); + self + } + #[cfg(feature = "postgres-test")] #[must_use] #[doc(hidden)] @@ -292,7 +308,12 @@ pub async fn apply_verified_package( .map_err(|_| MigrationError::ApplyFailed)?; let began = if let Some(current) = current { connection - .begin_successor_package(current, &target, &ledger) + .begin_successor_package( + current, + &target, + &ledger, + request.event_destination_compatibility_inventory, + ) .await } else { connection diff --git a/crates/registry-server/src/model.rs b/crates/registry-server/src/model.rs index 09d478b8a7..4b89c6bc9e 100644 --- a/crates/registry-server/src/model.rs +++ b/crates/registry-server/src/model.rs @@ -6,9 +6,10 @@ use serde::{Deserialize, Serialize}; use crate::artifacts::GeneratedArtifacts; use crate::contract::{ - AccessProfileSource, BatchSource, Classification, ConstraintSource, EventSource, - FieldTypeSource, ManifestProjectionSource, MutationMode, Operation, PackageIdentitySource, - TemporalSource, ValidTimeRole, WebhookAuthenticationProfile, WebhookDeadLetterMode, + AccessProfileSource, BatchSource, Classification, ConstraintSource, EventConditionSource, + EventSource, FieldTypeSource, ManifestProjectionSource, MutationMode, Operation, + PackageIdentitySource, TemporalSource, ValidTimeRole, WebhookAuthenticationProfile, + WebhookDeadLetterMode, }; use crate::diagnostics::Diagnostic; use crate::generated_ddl::DdlInventory; @@ -106,9 +107,15 @@ pub struct CompiledEventDelivery { pub trigger: crate::contract::EventTrigger, pub destination_id: String, pub projection_fields: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub when: Option, pub classification_ceiling: Classification, + pub data_schema: String, + pub data_schema_fingerprint: String, + pub data_schema_artifact_path: String, pub authentication_profile: WebhookAuthenticationProfile, pub delivery_mode: CompiledWebhookDeliveryMode, + pub retry_profile: CompiledWebhookRetryProfile, pub attempt_timeout_ms: u32, pub initial_backoff_ms: u32, pub maximum_backoff_ms: u32, @@ -129,6 +136,12 @@ pub enum CompiledWebhookDeliveryMode { AfterCommit, } +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompiledWebhookRetryProfile { + RegistryV1, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct CompiledEventDeliveryInventory { diff --git a/crates/registry-server/src/mutation.rs b/crates/registry-server/src/mutation.rs index 4b6cc76f0b..e868417188 100644 --- a/crates/registry-server/src/mutation.rs +++ b/crates/registry-server/src/mutation.rs @@ -16,10 +16,15 @@ use tokio_postgres::types::ToSql; use tokio_postgres::{error::SqlState, GenericClient, Transaction}; use uuid::Uuid; +use crate::artifacts::event_data_schema_binding; use crate::audit::{ append_terminal_audit, profile_is_keyed, record_pre_io_audit, PreIoAudit, PreIoAuditKind, RegistryAuditError, TerminalAudit, TerminalAuditOutcome, }; +use crate::compiler::{ + WEBHOOK_ATTEMPT_TIMEOUT_MS, WEBHOOK_BACKOFF_MULTIPLIER, WEBHOOK_INITIAL_BACKOFF_MS, + WEBHOOK_MAXIMUM_ATTEMPTS, WEBHOOK_MAXIMUM_BACKOFF_MS, +}; use crate::contract::{ AccessProfileSource, EventTrigger, FieldTypeSource, MutationMode, Operation, }; @@ -31,7 +36,7 @@ use crate::idempotency::{ }; use crate::model::{ CompiledEntity, CompiledEventDelivery, CompiledRegistry, CompiledRoute, - CompiledWebhookDeliveryMode, HttpMethod, + CompiledWebhookDeliveryMode, CompiledWebhookRetryProfile, HttpMethod, }; use crate::outbox::{insert_configured_events, OutboxError, OutboxMutation}; use crate::postgres::{ @@ -84,8 +89,12 @@ pub async fn install_mutation_schema( record_revision bigint NOT NULL CHECK (record_revision > 0), package_revision text NOT NULL CHECK (package_revision <> ''), schema_fingerprint text NOT NULL CHECK (schema_fingerprint <> ''), - payload bytea NOT NULL - CHECK (octet_length(payload) > 0 AND octet_length(payload) <= 2097152), + payload bytea + CONSTRAINT registry_outbox_payload_bounds CHECK ( + payload IS NULL OR + (octet_length(payload) > 0 AND octet_length(payload) <= 2097152) + ), + payload_expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), UNIQUE (event_id, package_revision, schema_fingerprint) ); @@ -101,6 +110,10 @@ pub async fn install_mutation_schema( CHECK (package_revision <> '' AND octet_length(package_revision) <= 256), schema_fingerprint text NOT NULL CHECK (schema_fingerprint <> '' AND octet_length(schema_fingerprint) <= 256), + data_schema text NOT NULL + CONSTRAINT registry_webhook_delivery_data_schema_bounds CHECK ( + data_schema <> '' AND octet_length(data_schema) <= 2048 + ), classification_ceiling text NOT NULL CHECK (classification_ceiling IN ('public', 'internal', 'restricted')), authentication_profile text NOT NULL @@ -145,7 +158,9 @@ pub async fn install_mutation_schema( CHECK (compiled_delivery_id <> '' AND octet_length(compiled_delivery_id) <= 256), generation bigint NOT NULL CHECK (generation > 0), state text NOT NULL - CHECK (state IN ('pending', 'leased', 'delivered', 'dead_lettered')), + CONSTRAINT registry_webhook_delivery_state_values CHECK ( + state IN ('pending', 'leased', 'delivered', 'dead_lettered', 'expired') + ), attempt smallint NOT NULL CHECK (attempt BETWEEN 0 AND 20), next_attempt_at timestamptz, attempt_started_at timestamptz, @@ -153,20 +168,22 @@ pub async fn install_mutation_schema( lease_token uuid, delivered_at timestamptz, dead_lettered_at timestamptz, + expired_at timestamptz, updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), PRIMARY KEY (event_id, compiled_delivery_id), FOREIGN KEY (event_id, compiled_delivery_id) REFERENCES registry_internal.registry_webhook_deliveries (event_id, compiled_delivery_id) ON DELETE RESTRICT, - CHECK ( + CONSTRAINT registry_webhook_delivery_state_shape CHECK ( (state = 'pending' AND next_attempt_at IS NOT NULL AND attempt_started_at IS NULL AND lease_expires_at IS NULL AND lease_token IS NULL AND delivered_at IS NULL - AND dead_lettered_at IS NULL) + AND dead_lettered_at IS NULL + AND expired_at IS NULL) OR (state = 'leased' AND attempt > 0 AND next_attempt_at IS NULL @@ -174,7 +191,8 @@ pub async fn install_mutation_schema( AND lease_expires_at > attempt_started_at AND lease_token IS NOT NULL AND delivered_at IS NULL - AND dead_lettered_at IS NULL) + AND dead_lettered_at IS NULL + AND expired_at IS NULL) OR (state = 'delivered' AND attempt > 0 AND next_attempt_at IS NULL @@ -182,7 +200,8 @@ pub async fn install_mutation_schema( AND lease_expires_at IS NULL AND lease_token IS NULL AND delivered_at IS NOT NULL - AND dead_lettered_at IS NULL) + AND dead_lettered_at IS NULL + AND expired_at IS NULL) OR (state = 'dead_lettered' AND attempt > 0 AND next_attempt_at IS NULL @@ -191,6 +210,14 @@ pub async fn install_mutation_schema( AND lease_token IS NULL AND delivered_at IS NULL AND dead_lettered_at IS NOT NULL) + OR (state = 'expired' + AND next_attempt_at IS NULL + AND attempt_started_at IS NULL + AND lease_expires_at IS NULL + AND lease_token IS NULL + AND delivered_at IS NULL + AND dead_lettered_at IS NULL + AND expired_at IS NOT NULL) ) ); CREATE INDEX IF NOT EXISTS registry_webhook_delivery_state_due_idx @@ -242,6 +269,186 @@ pub async fn install_mutation_schema( ) .await .map_err(|_| MutationError::Unavailable)?; + // `CREATE TABLE IF NOT EXISTS` does not evolve databases activated by an + // earlier Registry Server build. Legacy outbox rows receive the + // conservative seven-day default from their original capture time. A + // legacy webhook row has no V1 data-schema binding, so it cannot safely be + // reinterpreted as a V1 delivery and requires explicit operator migration. + // Keep this upgrade idempotent so package activation cannot leave the + // runtime expecting a column or nullability contract the durable outbox + // does not have. + migration + .batch_execute( + "ALTER TABLE registry_internal.registry_outbox + ADD COLUMN IF NOT EXISTS payload_expires_at timestamptz; + UPDATE registry_internal.registry_outbox + SET payload_expires_at = created_at + interval '7 days' + WHERE payload_expires_at IS NULL; + DO $registry_outbox_upgrade$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_attribute + WHERE attrelid = 'registry_internal.registry_outbox'::regclass + AND attname = 'payload' AND attnotnull + ) THEN + ALTER TABLE registry_internal.registry_outbox + ALTER COLUMN payload DROP NOT NULL; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = 'registry_internal.registry_outbox'::regclass + AND conname = 'registry_outbox_payload_check' + ) THEN + ALTER TABLE registry_internal.registry_outbox + DROP CONSTRAINT registry_outbox_payload_check; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = 'registry_internal.registry_outbox'::regclass + AND conname = 'registry_outbox_payload_bounds' + ) THEN + ALTER TABLE registry_internal.registry_outbox + ADD CONSTRAINT registry_outbox_payload_bounds CHECK ( + payload IS NULL OR + (octet_length(payload) > 0 AND octet_length(payload) <= 2097152) + ); + END IF; + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_attribute + WHERE attrelid = 'registry_internal.registry_outbox'::regclass + AND attname = 'payload_expires_at' AND NOT attnotnull + ) THEN + ALTER TABLE registry_internal.registry_outbox + ALTER COLUMN payload_expires_at SET NOT NULL; + END IF; + END + $registry_outbox_upgrade$; + ALTER TABLE registry_internal.registry_webhook_deliveries + ADD COLUMN IF NOT EXISTS data_schema text; + DO $registry_webhook_delivery_upgrade$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM registry_internal.registry_webhook_deliveries + WHERE data_schema IS NULL + ) THEN + RAISE EXCEPTION USING + MESSAGE = 'pre-V1 webhook history requires explicit operator migration'; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_attribute + WHERE attrelid = + 'registry_internal.registry_webhook_deliveries'::regclass + AND attname = 'data_schema' AND NOT attnotnull + ) THEN + ALTER TABLE registry_internal.registry_webhook_deliveries + ALTER COLUMN data_schema SET NOT NULL; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = + 'registry_internal.registry_webhook_deliveries'::regclass + AND conname = 'registry_webhook_delivery_data_schema_bounds' + ) THEN + ALTER TABLE registry_internal.registry_webhook_deliveries + ADD CONSTRAINT registry_webhook_delivery_data_schema_bounds CHECK ( + data_schema <> '' AND octet_length(data_schema) <= 2048 + ); + END IF; + END + $registry_webhook_delivery_upgrade$; + ALTER TABLE registry_internal.registry_webhook_delivery_state + ADD COLUMN IF NOT EXISTS expired_at timestamptz; + DO $registry_webhook_state_upgrade$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = + 'registry_internal.registry_webhook_delivery_state'::regclass + AND conname = 'registry_webhook_delivery_state_state_check' + ) THEN + ALTER TABLE registry_internal.registry_webhook_delivery_state + DROP CONSTRAINT registry_webhook_delivery_state_state_check; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = + 'registry_internal.registry_webhook_delivery_state'::regclass + AND conname = 'registry_webhook_delivery_state_check' + ) THEN + ALTER TABLE registry_internal.registry_webhook_delivery_state + DROP CONSTRAINT registry_webhook_delivery_state_check; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = + 'registry_internal.registry_webhook_delivery_state'::regclass + AND conname = 'registry_webhook_delivery_state_values' + ) THEN + ALTER TABLE registry_internal.registry_webhook_delivery_state + ADD CONSTRAINT registry_webhook_delivery_state_values CHECK ( + state IN ( + 'pending', 'leased', 'delivered', 'dead_lettered', 'expired' + ) + ); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = + 'registry_internal.registry_webhook_delivery_state'::regclass + AND conname = 'registry_webhook_delivery_state_shape' + ) THEN + ALTER TABLE registry_internal.registry_webhook_delivery_state + ADD CONSTRAINT registry_webhook_delivery_state_shape CHECK ( + (state = 'pending' + AND next_attempt_at IS NOT NULL + AND attempt_started_at IS NULL + AND lease_expires_at IS NULL + AND lease_token IS NULL + AND delivered_at IS NULL + AND dead_lettered_at IS NULL + AND expired_at IS NULL) + OR (state = 'leased' + AND attempt > 0 + AND next_attempt_at IS NULL + AND attempt_started_at IS NOT NULL + AND lease_expires_at > attempt_started_at + AND lease_token IS NOT NULL + AND delivered_at IS NULL + AND dead_lettered_at IS NULL + AND expired_at IS NULL) + OR (state = 'delivered' + AND attempt > 0 + AND next_attempt_at IS NULL + AND attempt_started_at IS NULL + AND lease_expires_at IS NULL + AND lease_token IS NULL + AND delivered_at IS NOT NULL + AND dead_lettered_at IS NULL + AND expired_at IS NULL) + OR (state = 'dead_lettered' + AND attempt > 0 + AND next_attempt_at IS NULL + AND attempt_started_at IS NULL + AND lease_expires_at IS NULL + AND lease_token IS NULL + AND delivered_at IS NULL + AND dead_lettered_at IS NOT NULL) + OR (state = 'expired' + AND next_attempt_at IS NULL + AND attempt_started_at IS NULL + AND lease_expires_at IS NULL + AND lease_token IS NULL + AND delivered_at IS NULL + AND dead_lettered_at IS NULL + AND expired_at IS NOT NULL) + ); + END IF; + END + $registry_webhook_state_upgrade$;", + ) + .await + .map_err(|_| MutationError::Unavailable)?; let role = runtime_role.as_str(); migration .batch_execute(&format!( @@ -257,6 +464,7 @@ pub async fn install_mutation_schema( registry_internal.registry_webhook_deliveries, registry_internal.registry_audit, registry_internal.registry_idempotency TO \"{role}\"; + GRANT UPDATE (payload) ON registry_internal.registry_outbox TO \"{role}\"; GRANT SELECT, INSERT, UPDATE ON registry_internal.registry_webhook_delivery_state TO \"{role}\"; GRANT SELECT, INSERT, UPDATE ON registry_internal.registry_audit_head TO \"{role}\"; @@ -408,30 +616,45 @@ fn exact_entity_event_deliveries( .webhook .as_ref() .ok_or(MutationError::InvalidRequest)?; - let source_delivery = &webhook.delivery; let expected_projection = event.projection.iter().cloned().collect::>(); + let classification_ceiling = event + .projection + .iter() + .chain(event_condition_fields(event)) + .filter_map(|field| entity.fields.get(field)) + .map(|field| field.classification) + .max() + .ok_or(MutationError::InvalidRequest)?; + let data_schema = event_data_schema_binding(registry.registry_id(), entity, event) + .map_err(|_| MutationError::InvalidRequest)?; if !delivery_ids.insert(delivery.id.as_str()) || !delivered_events.insert(delivery.event_id.as_str()) || delivery.id != format!("events.{}.{}.webhook", entity.id, event.id) || delivery.trigger != event.trigger || delivery.destination_id != webhook.destination_id || delivery.projection_fields != expected_projection - || delivery.classification_ceiling != webhook.classification_ceiling - || delivery.authentication_profile != webhook.authentication_profile + || delivery.when != event.when + || delivery.classification_ceiling != classification_ceiling + || delivery.data_schema != data_schema.data_schema + || delivery.data_schema_fingerprint != data_schema.fingerprint + || delivery.data_schema_artifact_path != data_schema.artifact_path + || delivery.authentication_profile + != crate::contract::WebhookAuthenticationProfile::HmacSha256V1 || delivery.delivery_mode != CompiledWebhookDeliveryMode::AfterCommit - || delivery.attempt_timeout_ms != source_delivery.attempt_timeout_ms - || delivery.initial_backoff_ms != source_delivery.initial_backoff_ms - || delivery.maximum_backoff_ms != source_delivery.maximum_backoff_ms - || delivery.exponential_backoff_multiplier != 2 - || delivery.maximum_attempts != source_delivery.maximum_attempts + || delivery.retry_profile != CompiledWebhookRetryProfile::RegistryV1 + || delivery.attempt_timeout_ms != WEBHOOK_ATTEMPT_TIMEOUT_MS + || delivery.initial_backoff_ms != WEBHOOK_INITIAL_BACKOFF_MS + || delivery.maximum_backoff_ms != WEBHOOK_MAXIMUM_BACKOFF_MS + || delivery.exponential_backoff_multiplier != WEBHOOK_BACKOFF_MULTIPLIER + || delivery.maximum_attempts != WEBHOOK_MAXIMUM_ATTEMPTS || delivery.retry_delays_ms != expected_retry_delays( - source_delivery.initial_backoff_ms, - source_delivery.maximum_backoff_ms, - source_delivery.maximum_attempts, + WEBHOOK_INITIAL_BACKOFF_MS, + WEBHOOK_MAXIMUM_BACKOFF_MS, + WEBHOOK_MAXIMUM_ATTEMPTS, ) - || Some(delivery.dead_letter) != source_delivery.dead_letter - || delivery.operator_replay != source_delivery.operator_replay + || delivery.dead_letter != crate::contract::WebhookDeadLetterMode::Required + || !delivery.operator_replay || Some(delivery.maximum_payload_bytes) != expected_maximum_event_payload_bytes(entity, event) { @@ -448,53 +671,26 @@ fn exact_entity_event_deliveries( Ok(deliveries) } +fn event_condition_fields(event: &crate::contract::EventSource) -> impl Iterator { + let mut fields = BTreeSet::new(); + if let Some(crate::contract::EventConditionSource::Fields { + changed, + before_equals, + after_equals, + }) = &event.when + { + fields.extend(changed.iter()); + fields.extend(before_equals.keys()); + fields.extend(after_equals.keys()); + } + fields.into_iter() +} + fn expected_maximum_event_payload_bytes( entity: &CompiledEntity, event: &crate::contract::EventSource, ) -> Option { - let mut total = 2_u64.checked_add(event.projection.len().saturating_sub(1) as u64)?; - for field_id in &event.projection { - let field = entity.fields.get(field_id)?; - let maximum_value_bytes = maximum_field_json_bytes(&field.field_type)?; - let maximum_value_bytes = if field.required { - maximum_value_bytes - } else { - maximum_value_bytes.max(4) - }; - total = total - .checked_add(field_id.len() as u64 + 3)? - .checked_add(maximum_value_bytes)?; - } - let total = u32::try_from(total).ok()?; - (total <= crate::compiler::MAX_WEBHOOK_PAYLOAD_BYTES).then_some(total) -} - -fn maximum_field_json_bytes(field_type: &FieldTypeSource) -> Option { - match field_type { - FieldTypeSource::Boolean => Some(5), - FieldTypeSource::String { max_length, .. } | FieldTypeSource::Text { max_length } => { - 2_u64.checked_add(u64::from(*max_length).checked_mul(6)?) - } - FieldTypeSource::Int64 => Some(20), - FieldTypeSource::Decimal { - precision, scale, .. - } => Some( - u64::from(*precision) - + u64::from(*scale > 0) - + u64::from(*scale > 0 && scale == precision) - + 3, - ), - FieldTypeSource::Date => Some(12), - FieldTypeSource::Timestamp => Some(64), - FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => Some(38), - FieldTypeSource::VocabularyCode { values, .. } => values - .iter() - .filter_map(|value| canonicalize_json(&Value::String(value.clone())).ok()) - .map(|value| value.len() as u64) - .max(), - FieldTypeSource::Crs84Point { .. } => Some(128), - FieldTypeSource::Structured { max_bytes, .. } => Some(u64::from(*max_bytes)), - } + crate::compiler::maximum_compiled_event_payload_bytes(entity, event) } fn expected_retry_delays(initial_ms: u32, maximum_ms: u32, maximum_attempts: u8) -> Vec { @@ -868,11 +1064,20 @@ impl MutationCoordinator { OutboxMutation { trigger: mutation_trigger(request.plan.route.operation), entity_id: &request.plan.entity.id, + record_id: ¤t.record_id, record_reference: &record_reference, record_revision: current.record_revision, package_revision: &self.expected.package_revision, schema_fingerprint: &self.expected.schema_fingerprint, - data: ¤t.data, + before: current.before_data.as_ref(), + after: (request.plan.route.operation != Operation::Tombstone) + .then_some(¤t.data), + payload_retention: self + .event_destinations + .as_deref() + .map_or(Duration::from_secs(7 * 24 * 60 * 60), |destinations| { + destinations.payload_retention() + }), }, ) .await?; @@ -1039,11 +1244,19 @@ impl MutationCoordinator { OutboxMutation { trigger: mutation_trigger(item_plan.route.operation), entity_id: &item_plan.entity.id, + record_id: ¤t.record_id, record_reference: &record_reference, record_revision: current.record_revision, package_revision: &self.expected.package_revision, schema_fingerprint: &self.expected.schema_fingerprint, - data: ¤t.data, + before: current.before_data.as_ref(), + after: Some(¤t.data), + payload_retention: self + .event_destinations + .as_deref() + .map_or(Duration::from_secs(7 * 24 * 60 * 60), |destinations| { + destinations.payload_retention() + }), }, ) .await?; @@ -1336,6 +1549,7 @@ struct CurrentRow { record_revision: i64, predecessor_revision: Option, record_lifecycle: String, + before_data: Option>, data: Map, } @@ -1367,10 +1581,12 @@ async fn apply_current_row( if expected.ct_eq(current_etag.as_bytes()).unwrap_u8() != 1 { return Err(MutationError::PreconditionFailed); } + let before_data = current.data.clone(); let data = apply_patch_document(request, ¤t.data)?; let mut row = apply_patch_row(transaction, request, current.record_revision, data).await?; row.predecessor_revision = Some(current.record_revision); + row.before_data = Some(before_data); Ok(row) } Operation::Tombstone => { @@ -1551,6 +1767,7 @@ async fn apply_tombstone_row( record_revision: next_revision, predecessor_revision: Some(current.record_revision), record_lifecycle: "tombstoned".to_owned(), + before_data: Some(current.data.clone()), data: current.data, }) } @@ -1803,6 +2020,7 @@ fn row_to_current( record_revision, predecessor_revision: None, record_lifecycle, + before_data: None, data, }) } diff --git a/crates/registry-server/src/outbox.rs b/crates/registry-server/src/outbox.rs index e59f66cc47..313f5afbc6 100644 --- a/crates/registry-server/src/outbox.rs +++ b/crates/registry-server/src/outbox.rs @@ -3,15 +3,17 @@ //! Immutable configured events created inside the owning record transaction. use std::collections::BTreeMap; +use std::time::Duration; use registry_platform_canonical_json::canonicalize_json; -use serde_json::{Map, Value}; +use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use tokio_postgres::Transaction; use uuid::Uuid; use crate::contract::{ - Classification, EventSource, EventTrigger, WebhookAuthenticationProfile, WebhookDeadLetterMode, + Classification, EventConditionSource, EventScalarValue, EventSource, EventTrigger, + WebhookAuthenticationProfile, WebhookDeadLetterMode, }; use crate::event_destination::ActivatedEventDestinationRegistry; use crate::model::{CompiledEventDelivery, CompiledWebhookDeliveryMode}; @@ -27,11 +29,14 @@ pub enum OutboxError { pub(crate) struct OutboxMutation<'a> { pub trigger: EventTrigger, pub entity_id: &'a str, + pub record_id: &'a str, pub record_reference: &'a str, pub record_revision: i64, pub package_revision: &'a str, pub schema_fingerprint: &'a str, - pub data: &'a Map, + pub before: Option<&'a Map>, + pub after: Option<&'a Map>, + pub payload_retention: Duration, } pub(crate) async fn insert_configured_events( @@ -45,6 +50,9 @@ pub(crate) async fn insert_configured_events( .values() .filter(|event| event.trigger == mutation.trigger) { + if !condition_matches(event.when.as_ref(), mutation.before, mutation.after)? { + continue; + } let delivery = deliveries .iter() .find(|delivery| delivery.event_id == event.id); @@ -64,16 +72,25 @@ pub(crate) async fn insert_configured_events( .collect() }, ); - let mut projection = Map::new(); + let snapshot = match mutation.trigger { + EventTrigger::Created | EventTrigger::Patched => mutation.after, + EventTrigger::Tombstoned => mutation.before, + } + .ok_or(OutboxError::InvalidProjection)?; + let mut values = Map::new(); for field in projection_fields { - let value = mutation - .data - .get(field) - .ok_or(OutboxError::InvalidProjection)?; - projection.insert(field.to_owned(), value.clone()); + let value = snapshot.get(field).ok_or(OutboxError::InvalidProjection)?; + values.insert(field.to_owned(), value.clone()); } - let payload = canonicalize_json(&Value::Object(projection)) - .map_err(|_| OutboxError::InvalidProjection)?; + let payload = canonicalize_json(&json!({ + "entity": mutation.entity_id, + "recordId": mutation.record_id, + "revision": mutation.record_revision, + "trigger": trigger_name(mutation.trigger), + "packageRevision": mutation.package_revision, + "values": values, + })) + .map_err(|_| OutboxError::InvalidProjection)?; let event_id = Uuid::new_v4(); let activated = if let Some(delivery) = delivery { if payload.len() @@ -96,12 +113,18 @@ pub(crate) async fn insert_configured_events( } else { None }; + let retention_milliseconds = i64::try_from(mutation.payload_retention.as_millis()) + .ok() + .filter(|value| (86_400_000..=2_592_000_000).contains(value)) + .ok_or(OutboxError::Unavailable)?; let changed = transaction .execute( "INSERT INTO registry_internal.registry_outbox (event_id, event_type, trigger, entity_id, record_reference, - record_revision, package_revision, schema_fingerprint, payload) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + record_revision, package_revision, schema_fingerprint, payload, + payload_expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, + transaction_timestamp() + $10::bigint * interval '1 millisecond')", &[ &event_id, &event.id, @@ -112,6 +135,7 @@ pub(crate) async fn insert_configured_events( &mutation.package_revision, &mutation.schema_fingerprint, &payload, + &retention_milliseconds, ], ) .await @@ -139,6 +163,58 @@ pub(crate) async fn insert_configured_events( Ok(()) } +fn condition_matches( + condition: Option<&EventConditionSource>, + before: Option<&Map>, + after: Option<&Map>, +) -> Result { + let Some(EventConditionSource::Fields { + changed, + before_equals, + after_equals, + }) = condition + else { + return Ok(true); + }; + for field in changed { + let before_value = before + .and_then(|snapshot| snapshot.get(field)) + .ok_or(OutboxError::InvalidProjection)?; + let after_value = after + .and_then(|snapshot| snapshot.get(field)) + .ok_or(OutboxError::InvalidProjection)?; + if before_value == after_value { + return Ok(false); + } + } + for (field, expected) in before_equals { + let actual = before + .and_then(|snapshot| snapshot.get(field)) + .ok_or(OutboxError::InvalidProjection)?; + if actual != &scalar_value(expected) { + return Ok(false); + } + } + for (field, expected) in after_equals { + let actual = after + .and_then(|snapshot| snapshot.get(field)) + .ok_or(OutboxError::InvalidProjection)?; + if actual != &scalar_value(expected) { + return Ok(false); + } + } + Ok(true) +} + +fn scalar_value(value: &EventScalarValue) -> Value { + match value { + EventScalarValue::Null => Value::Null, + EventScalarValue::Boolean(value) => Value::Bool(*value), + EventScalarValue::Number(value) => Value::Number(value.clone()), + EventScalarValue::String(value) => Value::String(value.clone()), + } +} + struct WebhookCapture<'a> { delivery: &'a CompiledEventDelivery, payload: &'a [u8], @@ -177,14 +253,15 @@ async fn insert_webhook_delivery( "INSERT INTO registry_internal.registry_webhook_deliveries (event_id, compiled_delivery_id, logical_destination_id, destination_binding_digest, package_revision, schema_fingerprint, + data_schema, classification_ceiling, authentication_profile, delivery_mode, attempt_timeout_ms, initial_backoff_ms, maximum_backoff_ms, exponential_backoff_multiplier, maximum_attempts, retry_delays_ms, maximum_payload_bytes, payload_digest, deployed_attempt_timeout_ms, deployed_maximum_attempts, dead_letter, operator_replay) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14, $15, $16, $17, $18, - $19, $20, $21)", + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, + $20, $21, $22)", &[ &event_id, &delivery.id, @@ -192,6 +269,7 @@ async fn insert_webhook_delivery( &destination_binding_digest, &package_revision, &schema_fingerprint, + &delivery.data_schema, &classification_name(delivery.classification_ceiling), &authentication_profile_name(delivery.authentication_profile), &delivery_mode_name(delivery.delivery_mode), diff --git a/crates/registry-server/src/postgres/interlock.rs b/crates/registry-server/src/postgres/interlock.rs index b5738f3af5..8ecd4b175d 100644 --- a/crates/registry-server/src/postgres/interlock.rs +++ b/crates/registry-server/src/postgres/interlock.rs @@ -9,6 +9,7 @@ use tokio_postgres::NoTls; use tokio_postgres::{Client, GenericClient}; use uuid::Uuid; +use crate::event_destination::EventDestinationCompatibilityInventory; use crate::generated_ddl::DdlStatementKind; use crate::migration_plan::{ AffectedRowBounds, ReviewedMigrationStepDescriptor, ValidatedReviewedMigrationAssertion, @@ -693,6 +694,7 @@ impl DedicatedApplyConnection { current: &ExpectedRegistryIdentity, target: &ExpectedRegistryIdentity, ledger: &MigrationLedgerEntry, + event_destination_compatibility_inventory: Option<&EventDestinationCompatibilityInventory>, ) -> Result<()> { ensure_verified_package_session(self.locked, self.verified_migration_role)?; current.validate()?; @@ -708,6 +710,11 @@ impl DedicatedApplyConnection { )); } let transaction = self.client.transaction().await?; + verify_retained_webhook_delivery_bindings( + &transaction, + event_destination_compatibility_inventory, + ) + .await?; let changed = transaction .execute( "UPDATE registry_internal.registry_state @@ -1083,6 +1090,65 @@ impl DedicatedApplyConnection { } } +/// Refuse a successor before changing maintenance state when its activated +/// non-secret destination bindings cannot finish every retained non-terminal +/// delivery. The package session's exclusive advisory lock prevents Registry +/// workers or mutations from changing this inventory while the check and +/// maintenance transition commit together. +async fn verify_retained_webhook_delivery_bindings( + transaction: &impl GenericClient, + inventory: Option<&EventDestinationCompatibilityInventory>, +) -> Result<()> { + let tables = transaction + .query_one( + "SELECT to_regclass('registry_internal.registry_webhook_deliveries') IS NOT NULL, + to_regclass('registry_internal.registry_webhook_delivery_state') IS NOT NULL", + &[], + ) + .await?; + let deliveries_exist = tables.try_get::<_, bool>(0)?; + let states_exist = tables.try_get::<_, bool>(1)?; + if !deliveries_exist && !states_exist { + return Ok(()); + } + if !deliveries_exist || !states_exist { + return Err(PostgresKernelError::RegistryUnavailable); + } + + let (logical_destination_ids, binding_digests): (Vec, Vec) = inventory + .into_iter() + .flat_map(EventDestinationCompatibilityInventory::binding_digests) + .map(|(logical_id, digest)| (logical_id.to_owned(), digest.to_owned())) + .unzip(); + let incompatible = transaction + .query_opt( + "SELECT 1 + FROM registry_internal.registry_webhook_delivery_state AS state + JOIN registry_internal.registry_webhook_deliveries AS delivery + ON delivery.event_id = state.event_id + AND delivery.compiled_delivery_id = state.compiled_delivery_id + JOIN registry_internal.registry_outbox AS outbox + ON outbox.event_id = delivery.event_id + WHERE state.state IN ('pending', 'leased') + AND outbox.payload IS NOT NULL + AND outbox.payload_expires_at > transaction_timestamp() + AND NOT EXISTS ( + SELECT 1 + FROM unnest($1::text[], $2::text[]) + AS activated(logical_destination_id, binding_digest) + WHERE activated.logical_destination_id = delivery.logical_destination_id + AND activated.binding_digest = delivery.destination_binding_digest + ) + LIMIT 1", + &[&logical_destination_ids, &binding_digests], + ) + .await?; + if incompatible.is_some() { + return Err(PostgresKernelError::RegistryUnavailable); + } + Ok(()) +} + fn ledger_step( ledger: &MigrationLedgerEntry, migration_ordinal: i32, diff --git a/crates/registry-server/src/runtime_config.rs b/crates/registry-server/src/runtime_config.rs index 2b26f28c1c..4d8c84335b 100644 --- a/crates/registry-server/src/runtime_config.rs +++ b/crates/registry-server/src/runtime_config.rs @@ -48,6 +48,8 @@ const MAX_JWKS_DOCUMENT_BYTES: u64 = 1024 * 1024; const MIN_RSA_MODULUS_BITS: usize = 2048; const MAX_RSA_MODULUS_BITS: usize = 8192; const MAX_RSA_EXPONENT_BYTES: usize = 8; +const DEFAULT_WEBHOOK_PAYLOAD_RETENTION_DAYS: u8 = 7; +const MAX_WEBHOOK_PAYLOAD_RETENTION_DAYS: u8 = 30; #[derive(Debug, Error, Clone, Copy, Eq, PartialEq)] pub enum RuntimeConfigError { @@ -199,6 +201,7 @@ pub struct RuntimeConfig { audit: AuditConfig, cursor: CursorConfig, event_destinations: EventDestinationConfigs, + event_delivery: EventDeliveryConfig, operational_timeouts: OperationalTimeouts, } @@ -214,6 +217,7 @@ impl RuntimeConfig { let cursor = CursorConfig::from_raw(raw.cursor)?; let event_destinations = EventDestinationConfigs::from_raw(raw.event_destinations) .map_err(|_| RuntimeConfigError::InvalidEventDestination)?; + let event_delivery = EventDeliveryConfig::from_raw(raw.event_delivery)?; let operational_timeouts = OperationalTimeouts::from_raw(raw.operational_timeouts)?; Ok(Self { listener, @@ -225,6 +229,7 @@ impl RuntimeConfig { audit, cursor, event_destinations, + event_delivery, operational_timeouts, }) } @@ -257,6 +262,10 @@ impl RuntimeConfig { &self.cursor } + pub fn event_delivery(&self) -> &EventDeliveryConfig { + &self.event_delivery + } + pub async fn oidc_key_source(&self) -> Result> { self.authentication .oidc @@ -276,6 +285,9 @@ impl RuntimeConfig { .secret_resolver() .map_err(|_| crate::event_destination::EventDestinationActivationError::Secret)?; ActivatedEventDestinationRegistry::activate(compiled, &self.event_destinations, &resolver) + .map(|destinations| { + destinations.with_payload_retention(self.event_delivery.payload_retention) + }) } pub fn operational_timeouts(&self) -> &OperationalTimeouts { @@ -387,6 +399,7 @@ impl fmt::Debug for RuntimeConfig { .field("audit", &self.audit) .field("cursor", &self.cursor) .field("event_destinations", &self.event_destinations) + .field("event_delivery", &self.event_delivery) .field("operational_timeouts", &self.operational_timeouts) .finish() } @@ -1283,6 +1296,31 @@ pub struct CursorConfig { max_age: Duration, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EventDeliveryConfig { + payload_retention: Duration, +} + +impl EventDeliveryConfig { + fn from_raw(raw: RawEventDeliveryConfig) -> Result { + if raw.payload_retention_days == 0 + || raw.payload_retention_days > MAX_WEBHOOK_PAYLOAD_RETENTION_DAYS + { + return Err(RuntimeConfigError::InvalidBounds); + } + Ok(Self { + payload_retention: Duration::from_secs( + u64::from(raw.payload_retention_days) * 24 * 60 * 60, + ), + }) + } + + #[must_use] + pub fn payload_retention(&self) -> Duration { + self.payload_retention + } +} + impl CursorConfig { fn from_raw(raw: RawCursorConfig) -> Result { Ok(Self { @@ -1395,6 +1433,8 @@ struct RawRuntimeConfig { cursor: RawCursorConfig, #[serde(default)] event_destinations: RawEventDestinationConfigs, + #[serde(default)] + event_delivery: RawEventDeliveryConfig, operational_timeouts: RawOperationalTimeouts, } @@ -1553,6 +1593,25 @@ struct RawCursorConfig { max_age_seconds: u64, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawEventDeliveryConfig { + #[serde(default = "default_webhook_payload_retention_days")] + payload_retention_days: u8, +} + +impl Default for RawEventDeliveryConfig { + fn default() -> Self { + Self { + payload_retention_days: default_webhook_payload_retention_days(), + } + } +} + +const fn default_webhook_payload_retention_days() -> u8 { + DEFAULT_WEBHOOK_PAYLOAD_RETENTION_DAYS +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawOperationalTimeouts { diff --git a/crates/registry-server/src/schema.rs b/crates/registry-server/src/schema.rs index 787e9e19ce..426c8a8cdb 100644 --- a/crates/registry-server/src/schema.rs +++ b/crates/registry-server/src/schema.rs @@ -197,6 +197,41 @@ mod tests { assert!(!schema.is_valid(&instance)); } + #[test] + fn schema_accepts_the_minimal_tagged_event_and_webhook_shape() { + let schema = compile(&schema_document()); + let mut instance = fixture("asset-site-placement"); + instance["entities"][0]["events"] = serde_json::json!([{ + "id": "asset-created-v1", + "trigger": "created", + "projection": ["asset-code", "label"], + "when": { + "kind": "fields", + "afterEquals": {"asset-class": "equipment"} + }, + "webhook": {"destinationId": "asset-operations"} + }]); + + assert!(schema.is_valid(&instance)); + } + + #[test] + fn schema_rejects_per_event_delivery_policy() { + let schema = compile(&schema_document()); + let mut instance = fixture("asset-site-placement"); + instance["entities"][0]["events"] = serde_json::json!([{ + "id": "asset-created-v1", + "trigger": "created", + "projection": ["asset-code"], + "webhook": { + "destinationId": "asset-operations", + "authenticationProfile": "hmac_sha256_v1" + } + }]); + + assert!(!schema.is_valid(&instance)); + } + #[test] fn committed_authoring_schema_matches_generated_bytes() { let committed = Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/registry-server/src/startup.rs b/crates/registry-server/src/startup.rs index 9a8c32960a..48b598e94a 100644 --- a/crates/registry-server/src/startup.rs +++ b/crates/registry-server/src/startup.rs @@ -679,16 +679,21 @@ async fn finish_prepared_server( config.operational_timeouts().record_lock, audit_profile.clone(), )); - let webhook_worker = (!registry.event_deliveries().deliveries.is_empty()).then(|| { - WebhookWorker::new(WebhookDeliveryService::new( - pool.clone(), - Arc::clone(&event_destinations), - expected.clone(), - lock_key, - config.operational_timeouts().record_lock, - audit_profile.clone(), - )) - }); + let webhook_delivery = WebhookDeliveryService::new( + pool.clone(), + Arc::clone(&event_destinations), + expected.clone(), + lock_key, + config.operational_timeouts().record_lock, + audit_profile.clone(), + ); + webhook_delivery + .verify_retained_bindings() + .await + .map_err(|_| StartupError::EventDestinations)?; + // The worker also owns payload expiry, so it runs even when the active + // package declares no events. Compatible retained work is checked above. + let webhook_worker = Some(WebhookWorker::new(webhook_delivery)); let mutations = Arc::new(PostgresRecordMutationService::new_with_event_destinations( pool, Arc::clone(®istry), diff --git a/crates/registry-server/src/webhook.rs b/crates/registry-server/src/webhook.rs index 1c9f2d6c45..be9fd0a374 100644 --- a/crates/registry-server/src/webhook.rs +++ b/crates/registry-server/src/webhook.rs @@ -2,6 +2,7 @@ //! Package-bound, at-least-once webhook delivery state machine. +use std::path::Path; #[cfg(feature = "postgres-test")] use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -25,13 +26,16 @@ use crate::audit::{ WebhookAuditPhase, }; use crate::event_destination::ActivatedEventDestinationRegistry; +use crate::package::load_package; use crate::postgres::{ExpectedRegistryIdentity, RegistryLockKey, RuntimePool}; +use crate::runtime_config::load_runtime_config; use crate::startup::{OperationalEvent, WebhookStateTransitionCode}; const LEASE_FINALIZATION_ALLOWANCE: Duration = Duration::from_secs(5); const WORKER_POLL_INTERVAL: Duration = Duration::from_millis(100); const SIGNATURE_DOMAIN: &[u8] = b"registry-server-webhook-signature-v1"; const IDEMPOTENCY_DOMAIN: &[u8] = b"registry-server-webhook-idempotency-v1"; +pub const MAX_WEBHOOK_STATUS_RESULTS: u16 = 100; type HmacSha256 = Hmac; @@ -41,6 +45,114 @@ pub enum WebhookDeliveryError { Unavailable, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum WebhookOperatorError { + #[error("webhook operator request is unavailable")] + Unavailable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebhookDeliveryStatusKind { + Pending, + DeadLettered, + Expired, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WebhookDeliveryStatus { + pub event_id: Uuid, + pub compiled_delivery_id: String, + pub generation: i64, + pub state: WebhookDeliveryStatusKind, + pub attempt: i16, + pub payload_available: bool, + pub payload_expires_at: String, +} + +/// Verified, product-owned operator boundary used by `registry-serverctl`. +/// +/// Construction closes package, database identity, destination, and audit +/// bindings before list or replay is available. The CLI therefore owns no SQL, +/// retry transition, or signing behavior. +pub struct WebhookOperatorService { + delivery: WebhookDeliveryService, +} + +impl WebhookOperatorService { + pub async fn from_runtime_config(path: &Path) -> Result { + let config = load_runtime_config(path).map_err(|_| WebhookOperatorError::Unavailable)?; + let package_root = config.package().root().to_path_buf(); + { + let context = config.package_load_context(); + load_package(&package_root, &context).map_err(|_| WebhookOperatorError::Unavailable)?; + } + let connection = config + .runtime_database_connection_config() + .map_err(|_| WebhookOperatorError::Unavailable)?; + let pool = connection + .build_pool() + .map_err(|_| WebhookOperatorError::Unavailable)?; + let mut client = pool + .get() + .await + .map_err(|_| WebhookOperatorError::Unavailable)?; + let context = config.package_load_context(); + let startup = crate::startup::prepare_startup( + &package_root, + &context, + &mut client, + config.database().roles().migration(), + config.database().roles().runtime(), + ) + .await + .map_err(|_| WebhookOperatorError::Unavailable)?; + drop(client); + let destinations = Arc::new( + config + .activate_event_destinations(startup.package().registry()) + .map_err(|_| WebhookOperatorError::Unavailable)?, + ); + let audit_profile = config + .audit_profile() + .map_err(|_| WebhookOperatorError::Unavailable)?; + let delivery = WebhookDeliveryService::new( + pool, + destinations, + startup.expected_identity().clone(), + startup.lock_key(), + config.operational_timeouts().record_lock, + audit_profile, + ); + delivery + .verify_retained_bindings() + .await + .map_err(|_| WebhookOperatorError::Unavailable)?; + Ok(Self { delivery }) + } + + pub async fn list( + &self, + limit: u16, + ) -> Result, WebhookOperatorError> { + self.delivery + .list(limit) + .await + .map_err(|_| WebhookOperatorError::Unavailable) + } + + pub async fn replay( + &self, + event_id: Uuid, + compiled_delivery_id: &str, + expected_generation: i64, + ) -> Result { + self.delivery + .replay(event_id, compiled_delivery_id, expected_generation) + .await + .map_err(|_| WebhookOperatorError::Unavailable) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WebhookWorkOutcome { Idle, @@ -93,7 +205,135 @@ impl WebhookDeliveryService { self.finalize(&claim, outcome).await } - /// Reset one terminal delivery for an explicitly permitted operator replay. + /// Refuse startup or operator use if retained work cannot use its exact + /// captured destination under the active deployment bindings. + pub async fn verify_retained_bindings(&self) -> Result<(), WebhookDeliveryError> { + let mut client = self + .pool + .get() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let transaction = client + .transaction() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + self.verify_transaction(&transaction).await?; + let rows = transaction + .query( + "SELECT DISTINCT delivery.logical_destination_id, + delivery.destination_binding_digest + FROM registry_internal.registry_webhook_delivery_state AS state + JOIN registry_internal.registry_webhook_deliveries AS delivery + ON delivery.event_id = state.event_id + AND delivery.compiled_delivery_id = state.compiled_delivery_id + JOIN registry_internal.registry_outbox AS outbox + ON outbox.event_id = delivery.event_id + WHERE state.state IN ('pending', 'leased') + AND outbox.payload IS NOT NULL + AND outbox.payload_expires_at > transaction_timestamp()", + &[], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + for row in rows { + let logical_id = bounded_text(&row, 0, 64)?; + let binding_digest = bounded_text(&row, 1, 71)?; + if self + .destinations + .lookup(&logical_id) + .is_none_or(|destination| destination.binding_digest() != binding_digest) + { + return Err(WebhookDeliveryError::Unavailable); + } + } + transaction + .commit() + .await + .map_err(|_| WebhookDeliveryError::Unavailable) + } + + /// Return bounded, value-free pending and terminal operator metadata. + pub async fn list( + &self, + limit: u16, + ) -> Result, WebhookDeliveryError> { + if limit == 0 || limit > MAX_WEBHOOK_STATUS_RESULTS { + return Err(WebhookDeliveryError::Unavailable); + } + let mut client = self + .pool + .get() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let transaction = client + .transaction() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + self.verify_transaction(&transaction).await?; + let rows = transaction + .query( + "SELECT state.event_id, state.compiled_delivery_id, + state.generation, state.state, state.attempt, + outbox.payload IS NOT NULL + AND outbox.payload_expires_at > transaction_timestamp(), + outbox.payload_expires_at + FROM registry_internal.registry_webhook_delivery_state AS state + JOIN registry_internal.registry_outbox AS outbox + ON outbox.event_id = state.event_id + WHERE state.state IN ('pending', 'dead_lettered', 'expired') + ORDER BY state.updated_at DESC, state.event_id, + state.compiled_delivery_id + LIMIT $1", + &[&i64::from(limit)], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let mut statuses = Vec::with_capacity(rows.len()); + for row in rows { + let event_id = row + .try_get::<_, Uuid>(0) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let compiled_delivery_id = bounded_delivery_id(&row, 1)?; + let generation = row + .try_get::<_, i64>(2) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let stored_state = bounded_text(&row, 3, 32)?; + let attempt = row + .try_get::<_, i16>(4) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let payload_available = row + .try_get::<_, bool>(5) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let payload_expires_at = row + .try_get::<_, SystemTime>(6) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let state = match stored_state.as_str() { + "pending" if payload_available => WebhookDeliveryStatusKind::Pending, + "pending" | "expired" => WebhookDeliveryStatusKind::Expired, + "dead_lettered" => WebhookDeliveryStatusKind::DeadLettered, + _ => return Err(WebhookDeliveryError::Unavailable), + }; + statuses.push(WebhookDeliveryStatus { + event_id, + compiled_delivery_id, + generation, + state, + attempt, + payload_available, + payload_expires_at: OffsetDateTime::from(payload_expires_at) + .format(&Rfc3339) + .map_err(|_| WebhookDeliveryError::Unavailable)?, + }); + } + transaction + .commit() + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + Ok(statuses) + } + + /// Reset one terminal delivery for an explicitly permitted operator replay + /// and return the committed replacement generation. /// /// Every absent, stale, forbidden, or nonterminal target returns the same /// value-free refusal. @@ -102,7 +342,7 @@ impl WebhookDeliveryService { event_id: Uuid, compiled_delivery_id: &str, expected_generation: i64, - ) -> Result<(), WebhookDeliveryError> { + ) -> Result { if compiled_delivery_id.is_empty() || compiled_delivery_id.len() > 256 || expected_generation <= 0 @@ -121,22 +361,21 @@ impl WebhookDeliveryService { self.verify_transaction(&transaction).await?; let row = transaction .query_opt( - "SELECT state.generation, state.state, delivery.operator_replay + "SELECT state.generation, state.state, delivery.operator_replay, + delivery.package_revision, delivery.logical_destination_id, + delivery.destination_binding_digest FROM registry_internal.registry_webhook_delivery_state AS state JOIN registry_internal.registry_webhook_deliveries AS delivery ON delivery.event_id = state.event_id AND delivery.compiled_delivery_id = state.compiled_delivery_id + JOIN registry_internal.registry_outbox AS outbox + ON outbox.event_id = delivery.event_id WHERE state.event_id = $1 AND state.compiled_delivery_id = $2 - AND delivery.package_revision = $3 - AND delivery.schema_fingerprint = $4 + AND outbox.payload IS NOT NULL + AND outbox.payload_expires_at > transaction_timestamp() FOR UPDATE OF state", - &[ - &event_id, - &compiled_delivery_id, - &self.expected.package_revision, - &self.expected.schema_fingerprint, - ], + &[&event_id, &compiled_delivery_id], ) .await .map_err(|_| WebhookDeliveryError::Unavailable)? @@ -150,9 +389,18 @@ impl WebhookDeliveryService { let operator_replay = row .try_get::<_, bool>(2) .map_err(|_| WebhookDeliveryError::Unavailable)?; + let package_revision = bounded_text(&row, 3, 256)?; + let logical_destination_id = bounded_text(&row, 4, 64)?; + let destination_binding_digest = bounded_text(&row, 5, 71)?; if generation != expected_generation || !operator_replay - || !matches!(state.as_str(), "delivered" | "dead_lettered") + || state != "dead_lettered" + || self + .destinations + .lookup(&logical_destination_id) + .is_none_or(|destination| { + destination.binding_digest() != destination_binding_digest + }) { return Err(WebhookDeliveryError::Unavailable); } @@ -165,7 +413,7 @@ impl WebhookDeliveryService { WebhookAudit { event_id, compiled_delivery_id, - package_revision: &self.expected.package_revision, + package_revision: &package_revision, generation: next_generation, attempt: 0, phase: WebhookAuditPhase::Replay, @@ -187,11 +435,12 @@ impl WebhookDeliveryService { lease_token = NULL, delivered_at = NULL, dead_lettered_at = NULL, + expired_at = NULL, updated_at = transaction_timestamp() WHERE event_id = $1 AND compiled_delivery_id = $2 AND generation = $3 - AND state IN ('delivered', 'dead_lettered')", + AND state = 'dead_lettered'", &[ &event_id, &compiled_delivery_id, @@ -207,7 +456,8 @@ impl WebhookDeliveryService { transaction .commit() .await - .map_err(|_| WebhookDeliveryError::Unavailable) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + Ok(next_generation) } async fn claim(&self) -> Result, WebhookDeliveryError> { @@ -228,29 +478,30 @@ impl WebhookDeliveryService { webhook_failure(WebhookStateTransitionCode::ClaimRecoveryFailed); return Err(WebhookDeliveryError::Unavailable); } + self.expire_retained_payload(&transaction).await?; let row = transaction .query_opt( "SELECT state.event_id, state.compiled_delivery_id, state.generation, state.attempt, delivery.deployed_attempt_timeout_ms, delivery.deployed_maximum_attempts, - delivery.retry_delays_ms + delivery.retry_delays_ms, + delivery.package_revision FROM registry_internal.registry_webhook_delivery_state AS state JOIN registry_internal.registry_webhook_deliveries AS delivery ON delivery.event_id = state.event_id AND delivery.compiled_delivery_id = state.compiled_delivery_id + JOIN registry_internal.registry_outbox AS outbox + ON outbox.event_id = delivery.event_id WHERE state.state = 'pending' AND state.next_attempt_at <= transaction_timestamp() AND state.attempt < delivery.deployed_maximum_attempts - AND delivery.package_revision = $1 - AND delivery.schema_fingerprint = $2 + AND outbox.payload IS NOT NULL + AND outbox.payload_expires_at > transaction_timestamp() ORDER BY state.next_attempt_at, state.event_id, state.compiled_delivery_id FOR UPDATE OF state SKIP LOCKED LIMIT 1", - &[ - &self.expected.package_revision, - &self.expected.schema_fingerprint, - ], + &[], ) .await .map_err(|_| { @@ -283,6 +534,8 @@ impl WebhookDeliveryService { let retry_delays_ms = row .try_get::<_, Vec>(6) .map_err(|_| WebhookDeliveryError::Unavailable)?; + let package_revision = + bounded_text(&row, 7, 256).map_err(|_| WebhookDeliveryError::Unavailable)?; let attempt = prior_attempt .checked_add(1) .filter(|attempt| *attempt <= deployed_maximum_attempts) @@ -347,7 +600,7 @@ impl WebhookDeliveryService { WebhookAudit { event_id, compiled_delivery_id: &compiled_delivery_id, - package_revision: &self.expected.package_revision, + package_revision: &package_revision, generation, attempt, phase: WebhookAuditPhase::Attempt, @@ -374,6 +627,7 @@ impl WebhookDeliveryService { lease_token, deployed_maximum_attempts, retry_delays_ms, + package_revision, })) } @@ -386,22 +640,18 @@ impl WebhookDeliveryService { "SELECT state.event_id, state.compiled_delivery_id, state.generation, state.attempt, state.lease_token, delivery.deployed_maximum_attempts, - delivery.retry_delays_ms + delivery.retry_delays_ms, + delivery.package_revision FROM registry_internal.registry_webhook_delivery_state AS state JOIN registry_internal.registry_webhook_deliveries AS delivery ON delivery.event_id = state.event_id AND delivery.compiled_delivery_id = state.compiled_delivery_id WHERE state.state = 'leased' AND state.lease_expires_at <= transaction_timestamp() - AND delivery.package_revision = $1 - AND delivery.schema_fingerprint = $2 ORDER BY state.lease_expires_at, state.event_id, state.compiled_delivery_id FOR UPDATE OF state SKIP LOCKED LIMIT 1", - &[ - &self.expected.package_revision, - &self.expected.schema_fingerprint, - ], + &[], ) .await .map_err(|_| WebhookDeliveryError::Unavailable)?; @@ -427,6 +677,7 @@ impl WebhookDeliveryService { let retry_delays_ms = row .try_get::<_, Vec>(6) .map_err(|_| WebhookDeliveryError::Unavailable)?; + let package_revision = bounded_text(&row, 7, 256)?; validate_captured_policy(100, deployed_maximum_attempts, &retry_delays_ms)?; let dead_lettered = attempt >= deployed_maximum_attempts; append_webhook_audit( @@ -435,7 +686,7 @@ impl WebhookDeliveryService { WebhookAudit { event_id, compiled_delivery_id: &compiled_delivery_id, - package_revision: &self.expected.package_revision, + package_revision: &package_revision, generation, attempt, phase: WebhookAuditPhase::Terminal, @@ -487,7 +738,7 @@ impl WebhookDeliveryService { .execute( "UPDATE registry_internal.registry_webhook_delivery_state SET state = 'pending', - next_attempt_at = attempt_started_at + next_attempt_at = transaction_timestamp() + $6::bigint * interval '1 millisecond', attempt_started_at = NULL, lease_expires_at = NULL, @@ -518,6 +769,96 @@ impl WebhookDeliveryService { Ok(()) } + async fn expire_retained_payload( + &self, + transaction: &Transaction<'_>, + ) -> Result<(), WebhookDeliveryError> { + let row = transaction + .query_opt( + "SELECT state.event_id, state.compiled_delivery_id, + state.generation, state.attempt, delivery.package_revision + FROM registry_internal.registry_webhook_delivery_state AS state + JOIN registry_internal.registry_webhook_deliveries AS delivery + ON delivery.event_id = state.event_id + AND delivery.compiled_delivery_id = state.compiled_delivery_id + JOIN registry_internal.registry_outbox AS outbox + ON outbox.event_id = delivery.event_id + WHERE state.state IN ('pending', 'dead_lettered') + AND outbox.payload IS NOT NULL + AND outbox.payload_expires_at <= transaction_timestamp() + ORDER BY outbox.payload_expires_at, state.event_id, + state.compiled_delivery_id + FOR UPDATE OF state, outbox SKIP LOCKED + LIMIT 1", + &[], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let Some(row) = row else { + return Ok(()); + }; + let event_id = row + .try_get::<_, Uuid>(0) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let compiled_delivery_id = bounded_delivery_id(&row, 1)?; + let generation = row + .try_get::<_, i64>(2) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let attempt = row + .try_get::<_, i16>(3) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let package_revision = bounded_text(&row, 4, 256)?; + append_webhook_audit( + transaction, + &self.audit_profile, + WebhookAudit { + event_id, + compiled_delivery_id: &compiled_delivery_id, + package_revision: &package_revision, + generation, + attempt, + phase: WebhookAuditPhase::Terminal, + outcome: WebhookAuditOutcome::PayloadExpired, + disposition: WebhookAuditDisposition::Expired, + }, + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let state_changed = transaction + .execute( + "UPDATE registry_internal.registry_webhook_delivery_state + SET state = CASE WHEN state = 'pending' THEN 'expired' ELSE state END, + next_attempt_at = NULL, + attempt_started_at = NULL, + lease_expires_at = NULL, + lease_token = NULL, + expired_at = transaction_timestamp(), + updated_at = transaction_timestamp() + WHERE event_id = $1 + AND compiled_delivery_id = $2 + AND generation = $3 + AND state IN ('pending', 'dead_lettered')", + &[&event_id, &compiled_delivery_id, &generation], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let payload_changed = transaction + .execute( + "UPDATE registry_internal.registry_outbox + SET payload = NULL + WHERE event_id = $1 + AND payload IS NOT NULL + AND payload_expires_at <= transaction_timestamp()", + &[&event_id], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + if state_changed != 1 || payload_changed != 1 { + return Err(WebhookDeliveryError::Unavailable); + } + Ok(()) + } + async fn reload_and_send( &self, claim: &DeliveryClaim, @@ -543,10 +884,17 @@ impl WebhookDeliveryService { { return Ok(WebhookAuditOutcome::DestinationBindingRefused); } - let timestamp = OffsetDateTime::from(claim.attempt_started_at) + let delivery_time = OffsetDateTime::from(claim.attempt_started_at) + .format(&Rfc3339) + .map_err(|_| WebhookDeliveryError::Unavailable)?; + let event_time = OffsetDateTime::from(material.event_time) .format(&Rfc3339) .map_err(|_| WebhookDeliveryError::Unavailable)?; let event_id = claim.event_id.to_string(); + let source = format!( + "urn:registrystack:registry:{}:instance:{}", + self.expected.package_id, self.expected.instance_id + ); let generation = claim.generation.to_string(); let attempt = claim.attempt.to_string(); let idempotency_key = webhook_idempotency_key( @@ -560,11 +908,17 @@ impl WebhookDeliveryService { webhook_signature( key, SignatureFields { - event_id: &event_id, + id: &event_id, + source: &source, event_type: &material.event_type, + time: &event_time, + data_schema: &material.data_schema, generation: &generation, attempt: &attempt, - timestamp: ×tamp, + delivery_time: &delivery_time, + method: "POST", + request_target: destination.request_target(), + content_type: "application/json", idempotency_key: &idempotency_key, body: &material.body, }, @@ -575,11 +929,14 @@ impl WebhookDeliveryService { }; let request = match destination.request_template().render_event( EventDeliveryHeaders { - event_id: event_id.as_bytes(), + id: event_id.as_bytes(), + source: source.as_bytes(), event_type: material.event_type.as_bytes(), + time: event_time.as_bytes(), + dataschema: material.data_schema.as_bytes(), generation: generation.as_bytes(), attempt: attempt.as_bytes(), - timestamp: timestamp.as_bytes(), + delivery_time: delivery_time.as_bytes(), idempotency_key: idempotency_key.as_bytes(), signature: signature.as_bytes(), }, @@ -641,7 +998,9 @@ impl WebhookDeliveryService { delivery.deployed_maximum_attempts, delivery.authentication_profile, delivery.delivery_mode, - delivery.dead_letter + delivery.dead_letter, + delivery.data_schema, + outbox.created_at FROM registry_internal.registry_webhook_delivery_state AS state JOIN registry_internal.registry_webhook_deliveries AS delivery ON delivery.event_id = state.event_id @@ -657,6 +1016,8 @@ impl WebhookDeliveryService { AND state.lease_token = $5 AND state.state = 'leased' AND state.lease_expires_at > transaction_timestamp() + AND outbox.payload IS NOT NULL + AND outbox.payload_expires_at > transaction_timestamp() FOR SHARE OF state", &[ &claim.event_id, @@ -672,8 +1033,9 @@ impl WebhookDeliveryService { let event_type = bounded_text(&row, 0, 256).map_err(|_| MaterialLoadError::PayloadRefused)?; let body = row - .try_get::<_, Vec>(1) + .try_get::<_, Option>>(1) .map_err(|_| MaterialLoadError::PayloadRefused)?; + let body = body.ok_or(MaterialLoadError::PayloadRefused)?; let outbox_package_revision = bounded_text(&row, 2, 256).map_err(|_| MaterialLoadError::PayloadRefused)?; let outbox_schema_fingerprint = @@ -700,6 +1062,11 @@ impl WebhookDeliveryService { bounded_text(&row, 11, 32).map_err(|_| MaterialLoadError::PayloadRefused)?; let dead_letter = bounded_text(&row, 12, 32).map_err(|_| MaterialLoadError::PayloadRefused)?; + let data_schema = + bounded_text(&row, 13, 2_048).map_err(|_| MaterialLoadError::PayloadRefused)?; + let event_time = row + .try_get::<_, SystemTime>(14) + .map_err(|_| MaterialLoadError::PayloadRefused)?; transaction .commit() .await @@ -708,8 +1075,8 @@ impl WebhookDeliveryService { let parsed = parse_json_strict(&body).map_err(|_| MaterialLoadError::PayloadRefused)?; let canonical = canonicalize_json(&parsed).map_err(|_| MaterialLoadError::PayloadRefused)?; - if outbox_package_revision != self.expected.package_revision - || outbox_schema_fingerprint != self.expected.schema_fingerprint + if outbox_package_revision != claim.package_revision + || outbox_schema_fingerprint.is_empty() || authentication_profile != "hmac_sha256_v1" || delivery_mode != "after_commit" || dead_letter != "required" @@ -735,6 +1102,8 @@ impl WebhookDeliveryService { logical_destination_id, deployed_attempt_timeout_ms, deployed_maximum_attempts, + data_schema, + event_time, }) } @@ -775,7 +1144,7 @@ impl WebhookDeliveryService { WebhookAudit { event_id: claim.event_id, compiled_delivery_id: &claim.compiled_delivery_id, - package_revision: &self.expected.package_revision, + package_revision: &claim.package_revision, generation: claim.generation, attempt: claim.attempt, phase: WebhookAuditPhase::Terminal, @@ -806,7 +1175,7 @@ impl WebhookDeliveryService { .execute( "UPDATE registry_internal.registry_webhook_delivery_state SET state = 'pending', - next_attempt_at = attempt_started_at + next_attempt_at = transaction_timestamp() + $6::bigint * interval '1 millisecond', attempt_started_at = NULL, lease_expires_at = NULL, @@ -835,6 +1204,20 @@ impl WebhookDeliveryService { if changed != 1 { return Err(WebhookDeliveryError::Unavailable); } + if work_outcome == WebhookWorkOutcome::Delivered { + let erased = transaction + .execute( + "UPDATE registry_internal.registry_outbox + SET payload = NULL + WHERE event_id = $1 AND payload IS NOT NULL", + &[&claim.event_id], + ) + .await + .map_err(|_| WebhookDeliveryError::Unavailable)?; + if erased != 1 { + return Err(WebhookDeliveryError::Unavailable); + } + } transaction .commit() .await @@ -1080,6 +1463,7 @@ struct DeliveryClaim { lease_token: Uuid, deployed_maximum_attempts: i16, retry_delays_ms: Vec, + package_revision: String, } struct DeliveryMaterial { @@ -1090,6 +1474,8 @@ struct DeliveryMaterial { logical_destination_id: String, deployed_attempt_timeout_ms: i64, deployed_maximum_attempts: i16, + data_schema: String, + event_time: SystemTime, } enum MaterialLoadError { @@ -1192,12 +1578,19 @@ fn webhook_idempotency_key( format!("sha256:{}", hex::encode(Sha256::digest(input))) } +#[derive(Clone, Copy)] struct SignatureFields<'a> { - event_id: &'a str, + id: &'a str, + source: &'a str, event_type: &'a str, + time: &'a str, + data_schema: &'a str, generation: &'a str, attempt: &'a str, - timestamp: &'a str, + delivery_time: &'a str, + method: &'a str, + request_target: &'a str, + content_type: &'a str, idempotency_key: &'a str, body: &'a [u8], } @@ -1209,11 +1602,18 @@ fn webhook_signature( let mut input = Vec::new(); input.extend_from_slice(SIGNATURE_DOMAIN); for value in [ - fields.event_id.as_bytes(), + b"1.0".as_slice(), + fields.id.as_bytes(), + fields.source.as_bytes(), fields.event_type.as_bytes(), + fields.time.as_bytes(), + fields.data_schema.as_bytes(), fields.generation.as_bytes(), fields.attempt.as_bytes(), - fields.timestamp.as_bytes(), + fields.delivery_time.as_bytes(), + fields.method.as_bytes(), + fields.request_target.as_bytes(), + fields.content_type.as_bytes(), fields.idempotency_key.as_bytes(), fields.body, ] { @@ -1239,130 +1639,54 @@ mod tests { #[test] fn hmac_sha256_v1_binds_every_header_and_exact_canonical_body() { let key = [0x5a; 32]; - let signature = |key: &[u8], - event_id: &str, - event_type: &str, - generation: &str, - attempt: &str, - timestamp: &str, - idempotency_key: &str, - body: &[u8]| { - webhook_signature( - key, - SignatureFields { - event_id, - event_type, - generation, - attempt, - timestamp, - idempotency_key, - body, - }, - ) - .expect("bounded signature computes") + let fields = SignatureFields { + id: "00000000-0000-4000-8000-000000000001", + source: "urn:registrystack:registry:example:instance:primary", + event_type: "case-created-v1", + time: "2026-08-30T00:00:00Z", + data_schema: + "urn:registrystack:registry:example:event:case-created-v1:schema:sha256:aaa", + generation: "1", + attempt: "1", + delivery_time: "2026-08-30T00:00:01Z", + method: "POST", + request_target: "/hooks/registry", + content_type: "application/json", + idempotency_key: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + body: br#"{"entity":"case","values":{"label":"value"}}"#, }; - let event_id = "00000000-0000-4000-8000-000000000001"; - let event_type = "case-created"; - let generation = "1"; - let attempt = "1"; - let timestamp = "2026-08-30T00:00:00Z"; - let idempotency_key = - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let body = br#"{"label":"value"}"#; - let baseline = signature( - &key, - event_id, - event_type, - generation, - attempt, - timestamp, + let baseline = webhook_signature(&key, fields).expect("bounded signature computes"); + macro_rules! assert_field_is_bound { + ($member:ident, $changed:expr) => {{ + let mut changed = fields; + changed.$member = $changed; + assert_ne!( + baseline, + webhook_signature(&key, changed).expect("changed signature computes") + ); + }}; + } + assert_field_is_bound!(id, "00000000-0000-4000-8000-000000000002"); + assert_field_is_bound!(source, "urn:registrystack:registry:other:instance:primary"); + assert_field_is_bound!(event_type, "case-patched-v1"); + assert_field_is_bound!(time, "2026-08-30T00:00:02Z"); + assert_field_is_bound!(data_schema, "urn:registrystack:schema:changed"); + assert_field_is_bound!(generation, "2"); + assert_field_is_bound!(attempt, "2"); + assert_field_is_bound!(delivery_time, "2026-08-30T00:00:03Z"); + assert_field_is_bound!(method, "PUT"); + assert_field_is_bound!(request_target, "/hooks/other"); + assert_field_is_bound!(content_type, "application/cloudevents+json"); + assert_field_is_bound!( idempotency_key, - body, + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + assert_field_is_bound!(body, br#"{"entity":"case","values":{"label":"changed"}}"#); + assert_ne!( + baseline, + webhook_signature(&[0x6b; 32], fields).expect("changed key computes") ); - for changed in [ - signature( - &key, - "00000000-0000-4000-8000-000000000002", - event_type, - generation, - attempt, - timestamp, - idempotency_key, - body, - ), - signature( - &key, - event_id, - "case-patched", - generation, - attempt, - timestamp, - idempotency_key, - body, - ), - signature( - &key, - event_id, - event_type, - "2", - attempt, - timestamp, - idempotency_key, - body, - ), - signature( - &key, - event_id, - event_type, - generation, - "2", - timestamp, - idempotency_key, - body, - ), - signature( - &key, - event_id, - event_type, - generation, - attempt, - "2026-08-30T00:00:01Z", - idempotency_key, - body, - ), - signature( - &key, - event_id, - event_type, - generation, - attempt, - timestamp, - "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - body, - ), - signature( - &key, - event_id, - event_type, - generation, - attempt, - timestamp, - idempotency_key, - br#"{"label":"changed"}"#, - ), - signature( - &[0x6b; 32], - event_id, - event_type, - generation, - attempt, - timestamp, - idempotency_key, - body, - ), - ] { - assert_ne!(baseline, changed); - } assert!(baseline.starts_with("v1=")); assert!(!baseline[3..].contains('=')); } diff --git a/crates/registry-server/tests/compiler_webhook.rs b/crates/registry-server/tests/compiler_webhook.rs index add6014884..7fbd2c9024 100644 --- a/crates/registry-server/tests/compiler_webhook.rs +++ b/crates/registry-server/tests/compiler_webhook.rs @@ -3,11 +3,11 @@ use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; use registry_server::compiler::{compile_project, module_digest, CompileProfile}; use registry_server::contract::{ - parse_module_json, parse_project_json, ModuleLockSource, RegistryModule, RegistryProject, - WebhookAuthenticationProfile, WebhookDeadLetterMode, + parse_module_json, parse_project_json, Classification, ModuleLockSource, RegistryModule, + RegistryProject, WebhookAuthenticationProfile, WebhookDeadLetterMode, }; use registry_server::diagnostics::CompileFailure; -use registry_server::model::CompiledWebhookDeliveryMode; +use registry_server::model::{CompiledWebhookDeliveryMode, CompiledWebhookRetryProfile}; use serde_json::{json, Value}; fn project_value() -> Value { @@ -30,18 +30,12 @@ fn project_value() -> Value { "id": "case-created", "trigger": "created", "projection": ["label", "region"], + "when": { + "kind": "fields", + "afterEquals": {"region": "north"} + }, "webhook": { - "destinationId": "case-operations", - "classificationCeiling": "internal", - "authenticationProfile": "hmac_sha256_v1", - "delivery": { - "attemptTimeoutMs": 5000, - "initialBackoffMs": 250, - "maximumBackoffMs": 2000, - "maximumAttempts": 5, - "deadLetter": "required", - "operatorReplay": false - } + "destinationId": "case-operations" } }, { "id": "case-patched-outbox", @@ -79,12 +73,6 @@ fn webhook_mut(value: &mut Value) -> &mut serde_json::Map { .expect("webhook object") } -fn delivery_mut(value: &mut Value) -> &mut serde_json::Map { - webhook_mut(value)["delivery"] - .as_object_mut() - .expect("delivery object") -} - #[test] fn governed_webhook_compiles_to_deterministic_destination_neutral_inventory() { let source = project_value(); @@ -100,6 +88,16 @@ fn governed_webhook_compiles_to_deterministic_destination_neutral_inventory() { assert_eq!(delivery.event_id, "case-created"); assert_eq!(delivery.destination_id, "case-operations"); assert_eq!(delivery.projection_fields, ["label", "region"]); + assert_eq!(delivery.classification_ceiling, Classification::Internal); + assert!(delivery.when.is_some()); + assert!(delivery.data_schema.starts_with( + "urn:registry-server:event-schema:webhook-contract:case:case-created:sha256:" + )); + assert!(delivery.data_schema_fingerprint.starts_with("sha256:")); + assert_eq!( + delivery.data_schema_artifact_path, + "generated/event-schemas/case.case-created.schema.json" + ); assert_eq!( delivery.authentication_profile, WebhookAuthenticationProfile::HmacSha256V1 @@ -108,11 +106,19 @@ fn governed_webhook_compiles_to_deterministic_destination_neutral_inventory() { delivery.delivery_mode, CompiledWebhookDeliveryMode::AfterCommit ); + assert_eq!( + delivery.retry_profile, + CompiledWebhookRetryProfile::RegistryV1 + ); + assert_eq!(delivery.attempt_timeout_ms, 5000); + assert_eq!(delivery.initial_backoff_ms, 1000); + assert_eq!(delivery.maximum_backoff_ms, 8000); + assert_eq!(delivery.maximum_attempts, 5); assert_eq!(delivery.exponential_backoff_multiplier, 2); - assert_eq!(delivery.retry_delays_ms, [250, 500, 1000, 2000]); - assert_eq!(delivery.maximum_payload_bytes, 600); + assert_eq!(delivery.retry_delays_ms, [1000, 2000, 4000, 8000]); + assert_eq!(delivery.maximum_payload_bytes, 2288); assert_eq!(delivery.dead_letter, WebhookDeadLetterMode::Required); - assert!(!delivery.operator_replay); + assert!(delivery.operator_replay); let artifact = first .artifacts() @@ -127,6 +133,16 @@ fn governed_webhook_compiles_to_deterministic_destination_neutral_inventory() { parsed, serde_json::to_value(inventory).expect("inventory serializes") ); + let schema = first + .artifacts() + .get(&delivery.data_schema_artifact_path) + .expect("event data schema is generated"); + assert_eq!(schema.sha256, delivery.data_schema_fingerprint); + let schema_value = parse_json_strict(&schema.bytes).expect("event schema is strict JSON"); + assert_eq!( + schema_value["properties"]["values"]["required"], + json!(["label", "region"]) + ); let text = String::from_utf8(artifact.bytes.clone()).expect("artifact is UTF-8"); for forbidden in ["http://", "https://", "secret", "tls", "certificate"] { assert!(!text.to_ascii_lowercase().contains(forbidden)); @@ -154,30 +170,12 @@ fn destination_auth_delivery_and_deployed_members_are_closed_and_value_free() { } } - for (path, value) in [ - ("authenticationProfile", "bearer_token"), - ("delivery.mode", "before_commit"), - ] { - let mut source = project_value(); - if path == "authenticationProfile" { - webhook_mut(&mut source).insert(path.to_owned(), json!(value)); - } else { - delivery_mut(&mut source).insert("mode".to_owned(), json!(value)); - } - let failure = parse_project_json( - &serde_json::to_vec(&source).expect("unsupported profile source serializes"), - ) - .expect_err("unsupported closed mode is refused during strict parse"); - assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); - assert!(!serde_json::to_string(&failure) - .expect("failure serializes") - .contains(value)); - } - for (member, canary) in [ ("destinationUrl", "https://deployed.example/webhook-canary"), ("secret", "raw-webhook-secret-canary"), ("tlsCertificate", "raw-tls-certificate-canary"), + ("classificationCeiling", "restricted"), + ("authenticationProfile", "hmac_sha256_v1"), ] { let mut source = project_value(); webhook_mut(&mut source).insert(member.to_owned(), json!(canary)); @@ -189,10 +187,16 @@ fn destination_auth_delivery_and_deployed_members_are_closed_and_value_free() { let diagnostic = serde_json::to_string(&failure).expect("failure serializes"); assert!(!diagnostic.contains(canary)); } + + let mut source = project_value(); + webhook_mut(&mut source).insert("delivery".to_owned(), json!({"attemptTimeoutMs": 5000})); + let failure = parse_project_json(&serde_json::to_vec(&source).expect("source serializes")) + .expect_err("per-event delivery policy is not authored"); + assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); } #[test] -fn webhook_projection_and_classification_ceiling_are_closed() { +fn webhook_projection_is_closed_and_classification_is_derived() { let mut missing = project_value(); missing["entities"][0]["events"][0] .as_object_mut() @@ -210,28 +214,91 @@ fn webhook_projection_and_classification_ceiling_are_closed() { unknown["entities"][0]["events"][0]["projection"] = json!(["unknown-field"]); assert_compile_code(&unknown, "event.projection.field_unknown"); - let mut projected_above_ceiling = project_value(); - projected_above_ceiling["entities"][0]["events"][0]["projection"] = json!(["secret"]); - assert_compile_code( - &projected_above_ceiling, - "event.webhook.classification_ceiling.underdeclared", + let mut restricted = project_value(); + restricted["entities"][0]["events"][0]["projection"] = json!(["secret"]); + restricted["entities"][0]["events"][0] + .as_object_mut() + .expect("event object") + .remove("when"); + assert_eq!( + compile(&restricted) + .expect("classification follows the projection") + .event_deliveries() + .deliveries[0] + .classification_ceiling, + Classification::Restricted ); let mut minimized = project_value(); minimized["entities"][0]["classification"] = json!("restricted"); minimized["entities"][0]["events"][0]["projection"] = json!(["label"]); - minimized["entities"][0]["events"][0]["webhook"]["classificationCeiling"] = json!("public"); + minimized["entities"][0]["events"][0] + .as_object_mut() + .expect("event object") + .remove("when"); let minimized = compile(&minimized) .expect("a restricted entity may deliver only explicitly projected public fields"); assert_eq!( minimized.event_deliveries().deliveries[0].projection_fields, ["label"] ); + assert_eq!( + minimized.event_deliveries().deliveries[0].classification_ceiling, + Classification::Public + ); + + let mut condition_observes_restricted = project_value(); + condition_observes_restricted["entities"][0]["events"][0]["projection"] = json!(["label"]); + condition_observes_restricted["entities"][0]["events"][0]["when"] = json!({ + "kind": "fields", + "afterEquals": {"secret": "eligible"} + }); + assert_eq!( + compile(&condition_observes_restricted) + .expect("observable condition classification is compiled") + .event_deliveries() + .deliveries[0] + .classification_ceiling, + Classification::Restricted, + "event occurrence must carry the classification of predicate inputs" + ); let mut oversized = project_value(); oversized["entities"][0]["fields"][0]["maxLength"] = json!(300_000); assert_compile_code(&oversized, "event.webhook.projection_too_large"); + let mut exact_envelope_boundary = project_value(); + exact_envelope_boundary["entities"][0]["fields"][0] = json!({ + "id": "label", + "type": "structured", + "maxBytes": 1_046_878, + "schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "additionalProperties": false + }, + "required": true, + "classification": "public" + }); + exact_envelope_boundary["entities"][0]["events"][0]["projection"] = json!(["label"]); + exact_envelope_boundary["entities"][0]["events"][0] + .as_object_mut() + .expect("event object") + .remove("when"); + assert_eq!( + compile(&exact_envelope_boundary) + .expect("a full event body at the transport bound compiles") + .event_deliveries() + .deliveries[0] + .maximum_payload_bytes, + 1_048_576 + ); + exact_envelope_boundary["entities"][0]["fields"][0]["maxBytes"] = json!(1_046_879); + assert_compile_code( + &exact_envelope_boundary, + "event.webhook.projection_too_large", + ); + let mut exact_transport_mismatch = project_value(); exact_transport_mismatch["entities"][0]["fields"][0] = json!({ "id": "label", @@ -319,38 +386,62 @@ fn webhook_projection_and_classification_ceiling_are_closed() { } #[test] -fn webhook_timeout_backoff_attempt_and_dead_letter_bounds_are_closed() { - for (member, value, code) in [ - ("attemptTimeoutMs", 0_u32, "event.webhook.timeout.invalid"), - ("attemptTimeoutMs", 10_001, "event.webhook.timeout.invalid"), - ("initialBackoffMs", 0, "event.webhook.backoff.invalid"), - ( - "maximumBackoffMs", - 3_600_001, - "event.webhook.backoff.invalid", - ), - ("maximumAttempts", 0, "event.webhook.attempts.invalid"), - ("maximumAttempts", 21, "event.webhook.attempts.invalid"), +fn field_conditions_are_typed_nonempty_and_trigger_compatible() { + let mut patched = project_value(); + patched["entities"][0]["events"][0]["trigger"] = json!("patched"); + patched["entities"][0]["events"][0]["when"] = json!({ + "kind": "fields", + "changed": ["region"], + "beforeEquals": {"region": null}, + "afterEquals": {"region": "north"} + }); + compile(&patched).expect("patched events support all Version 1 field predicates"); + + let mut empty = project_value(); + empty["entities"][0]["events"][0]["when"] = json!({"kind": "fields"}); + assert_compile_code(&empty, "event.when.empty"); + + let mut incompatible_created = project_value(); + incompatible_created["entities"][0]["events"][0]["when"] = json!({ + "kind": "fields", + "changed": ["region"] + }); + assert_compile_code(&incompatible_created, "event.when.trigger_incompatible"); + + let mut incompatible_tombstone = project_value(); + incompatible_tombstone["entities"][0]["events"][0]["trigger"] = json!("tombstoned"); + incompatible_tombstone["entities"][0]["events"][0]["when"] = json!({ + "kind": "fields", + "afterEquals": {"region": "north"} + }); + assert_compile_code(&incompatible_tombstone, "event.when.trigger_incompatible"); + + for when in [ + json!({"kind": "fields", "changed": ["unknown"]}), + json!({"kind": "fields", "beforeEquals": {"unknown": "value"}}), + json!({"kind": "fields", "afterEquals": {"unknown": "value"}}), ] { - let mut source = project_value(); - delivery_mut(&mut source).insert(member.to_owned(), json!(value)); - assert_compile_code(&source, code); + let mut source = patched.clone(); + source["entities"][0]["events"][0]["when"] = when; + assert_compile_code(&source, "event.when.field_unknown"); } - let mut incoherent = project_value(); - delivery_mut(&mut incoherent).insert("initialBackoffMs".to_owned(), json!(2001)); - assert_compile_code(&incoherent, "event.webhook.backoff.invalid"); - - let mut missing_dead_letter = project_value(); - delivery_mut(&mut missing_dead_letter).remove("deadLetter"); - assert_compile_code(&missing_dead_letter, "event.webhook.dead_letter.required"); + let mut wrong_type = patched; + wrong_type["entities"][0]["events"][0]["when"] = json!({ + "kind": "fields", + "afterEquals": {"region": 7} + }); + assert_compile_code(&wrong_type, "event.when.value_invalid"); - let mut missing_replay = project_value(); - delivery_mut(&mut missing_replay).remove("operatorReplay"); + let mut structured = project_value(); + structured["entities"][0]["events"][0]["when"] = json!({ + "kind": "fields", + "afterEquals": {"region": {"unexpected": true}} + }); let failure = parse_project_json( - &serde_json::to_vec(&missing_replay).expect("missing replay source serializes"), + &serde_json::to_vec(&structured).expect("structured predicate source serializes"), ) - .expect_err("operator replay permission must be explicit"); + .expect_err("comparison values are scalar or null"); assert_eq!(failure.diagnostics()[0].code, "source.shape.invalid"); } @@ -405,12 +496,36 @@ fn additive_modules_add_nonconflicting_subscriptions_deterministically_and_refus } #[test] -fn outbox_only_event_compatibility_emits_an_empty_delivery_inventory() { +fn event_ids_are_unique_across_entities_for_unambiguous_external_types() { let mut source = project_value(); - source["entities"][0]["events"][0] - .as_object_mut() - .expect("event object") - .remove("webhook"); + source["entities"] + .as_array_mut() + .expect("entities array") + .push(json!({ + "id": "appeal", + "route": "appeals", + "mutationMode": "create_only", + "fields": [ + {"id": "label", "type": "string", "maxLength": 64, "classification": "public"} + ], + "events": [{ + "id": "case-created", + "trigger": "created", + "projection": ["label"], + "webhook": {"destinationId": "appeal-operations"} + }] + })); + assert_compile_code(&source, "event.id.registry_duplicate"); +} + +#[test] +fn outbox_only_event_is_authoring_only_and_production_requires_delivery() { + let mut source = project_value(); + source["entities"][0]["events"] = json!([{ + "id": "case-created", + "trigger": "created", + "projection": ["label"] + }]); let compiled = compile(&source).expect("outbox-only events remain valid"); assert!(compiled.event_deliveries().deliveries.is_empty()); let artifact = compiled @@ -421,6 +536,13 @@ fn outbox_only_event_compatibility_emits_an_empty_delivery_inventory() { assert!(compiled.entities()["case"].events["case-created"] .webhook .is_none()); + + let failure = compile_project(&parse_project(&source), &[], CompileProfile::Production) + .expect_err("production has no supported outbox-only consumer API"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "event.delivery.required")); } fn webhook_module(id: &str, event_id: &str, destination_id: &str) -> RegistryModule { @@ -435,17 +557,7 @@ fn webhook_module(id: &str, event_id: &str, destination_id: &str) -> RegistryMod "trigger": "created", "projection": ["label"], "webhook": { - "destinationId": destination_id, - "classificationCeiling": "internal", - "authenticationProfile": "hmac_sha256_v1", - "delivery": { - "attemptTimeoutMs": 1000, - "initialBackoffMs": 100, - "maximumBackoffMs": 1000, - "maximumAttempts": 3, - "deadLetter": "required", - "operatorReplay": true - } + "destinationId": destination_id } }] }] diff --git a/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml b/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml index 21f5f54520..07d7530d44 100644 --- a/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml +++ b/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml @@ -39,8 +39,6 @@ entities: - {id: quantity, type: int64, required: true, classification: public} constraints: - {kind: unique, fields: [label]} - events: - - {id: widget-created, trigger: created, projection: [jurisdiction, label, quantity]} accessProfiles: - id: operator default: true diff --git a/crates/registry-server/tests/http_read_only.rs b/crates/registry-server/tests/http_read_only.rs index 1497ee4679..2cd9f5d147 100644 --- a/crates/registry-server/tests/http_read_only.rs +++ b/crates/registry-server/tests/http_read_only.rs @@ -126,15 +126,6 @@ entities: projection: [classified-status, valid-from] webhook: destinationId: classified-operations-destination - classificationCeiling: restricted - authenticationProfile: hmac_sha256_v1 - delivery: - attemptTimeoutMs: 5000 - initialBackoffMs: 250 - maximumBackoffMs: 2000 - maximumAttempts: 5 - deadLetter: required - operatorReplay: false accessProfiles: - id: caseworker principalClaim: registry_principal diff --git a/crates/registry-server/tests/postgres_fixture_journeys.rs b/crates/registry-server/tests/postgres_fixture_journeys.rs index c054091b08..7d0dff1a55 100644 --- a/crates/registry-server/tests/postgres_fixture_journeys.rs +++ b/crates/registry-server/tests/postgres_fixture_journeys.rs @@ -454,8 +454,6 @@ async fn assert_exact_durable_journey_outcomes( (SELECT count(*) FROM registry_internal.registry_idempotency WHERE response_status = 200), (SELECT count(*) FROM registry_internal.registry_outbox), - (SELECT count(*) FROM registry_internal.registry_outbox - WHERE event_type = 'widget-created' AND trigger = 'created'), (SELECT count(*) FROM registry_internal.registry_audit)", &[], ) @@ -469,9 +467,8 @@ async fn assert_exact_durable_journey_outcomes( assert_eq!(counts.get::<_, i64>(5), 1); assert_eq!(counts.get::<_, i64>(6), 1); assert_eq!(counts.get::<_, i64>(7), 2); - assert_eq!(counts.get::<_, i64>(8), 3); - assert_eq!(counts.get::<_, i64>(9), 3); - assert_eq!(counts.get::<_, i64>(10), 11); + assert_eq!(counts.get::<_, i64>(8), 0); + assert_eq!(counts.get::<_, i64>(9), 11); let audit_rows = database .admin diff --git a/crates/registry-server/tests/postgres_mutation.rs b/crates/registry-server/tests/postgres_mutation.rs index 934aa1633d..f894f198ba 100644 --- a/crates/registry-server/tests/postgres_mutation.rs +++ b/crates/registry-server/tests/postgres_mutation.rs @@ -14,6 +14,7 @@ use axum::body::{to_bytes, Body}; use axum::http::{HeaderName, HeaderValue, Method, Request, StatusCode}; use postgres_harness::TestDatabase; use registry_platform_audit::{verify_jsonl_lines_with_hasher, AuditEnvelope, AuditProfile}; +use registry_platform_canonical_json::canonicalize_json; use registry_server::api::{ router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, VerifiedClaimValue, VerifiedRequestClaims, @@ -2333,9 +2334,21 @@ async fn assert_patch_preserved_omitted_field( ) .await .expect("administrator can inspect configured post-write event"); - assert!(events.iter().any(|row| { - row.get::<_, Vec>(0) == br#"{"label":"after-patch","quantity":41}"#.as_slice() - })); + let expected_event = canonicalize_json(&json!({ + "entity": "widget", + "recordId": record_id, + "revision": 2, + "trigger": "patched", + "packageRevision": "package-mutation-1", + "values": { + "label": "after-patch", + "quantity": 41, + }, + })) + .expect("expected configured event canonicalizes"); + assert!(events + .iter() + .any(|row| row.get::<_, Vec>(0) == expected_event.as_slice())); let quantity_physical = compiled_registry().entities()["widget"].fields["quantity"] .physical_name .clone(); @@ -2441,7 +2454,9 @@ async fn assert_journals_are_minimized_and_chained( RECORD_RECOVERY, RECORD_CONCURRENT, ] { - assert!(!payload.contains(record)); + if table_and_column.0 != "registry_outbox" { + assert!(!payload.contains(record)); + } assert!(!reference.contains(record)); } } diff --git a/crates/registry-server/tests/postgres_package.rs b/crates/registry-server/tests/postgres_package.rs index e904938590..a852ac48a0 100644 --- a/crates/registry-server/tests/postgres_package.rs +++ b/crates/registry-server/tests/postgres_package.rs @@ -16,6 +16,7 @@ use registry_platform_canonical_json::canonicalize_json; use registry_platform_crypto::{generate_private_jwk, sign, GeneratedKeyAlgorithm, PrivateJwk}; use registry_server::compiler::{compile_project, module_digest, CompileProfile}; use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::event_destination::EventDestinationCompatibilityInventory; use registry_server::migration::{ apply_verified_package, ApplyPrecondition, ApplyRoles, ApplyTimeouts, ApplyVerifiedPackageRequest, MigrationError, @@ -31,10 +32,12 @@ use registry_server::postgres::{ begin_record_transaction, install_compiled_schema, managed_schema_fingerprint, ClaimContext, ExpectedManagedCatalog, ExpectedRegistryIdentity, RegistryLockKey, }; +use registry_server::runtime_config::parse_runtime_config; use registry_server::startup::{prepare_startup, StartupError}; use serde::Serialize; use sha2::{Digest, Sha256}; use tokio_postgres::GenericClient; +use uuid::Uuid; const INSTANCE: &str = "instance-under-test"; const DATABASE: &str = "database-under-test"; @@ -1845,17 +1848,361 @@ async fn real_postgres_package_startup_apply_failure_and_old_process_are_closed( database.cleanup().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn successor_apply_refuses_to_strand_retained_webhook_work() { + let database = TestDatabase::create(1).await; + database + .admin + .batch_execute("CREATE EXTENSION btree_gist") + .await + .expect("administrator installs prerequisite"); + let (mut migration, migration_task) = database.connect_migration().await; + let destination_fixture = EventDestinationCompatibilityFixture::create(); + + let first = PackageFixture::build( + "local", + 1, + None, + fingerprint(1), + PlanChoice::WebhookSchema, + None, + ); + let provisional_first = load_package( + first.root.path(), + &local_context(PackageIntent::InitialActivation), + ) + .expect("initial webhook package verifies before fingerprinting"); + let transaction = migration + .transaction() + .await + .expect("initial webhook fingerprint transaction starts"); + install_compiled_schema( + &transaction, + provisional_first.registry(), + &database.runtime_role, + ) + .await + .expect("initial webhook schema installs for fingerprinting"); + let first_catalog = ExpectedManagedCatalog::compiled(provisional_first.registry()); + let first_fingerprint = + managed_schema_fingerprint(&transaction, &database.runtime_role, &first_catalog) + .await + .expect("initial webhook fingerprint derives"); + transaction + .rollback() + .await + .expect("initial webhook fingerprint transaction rolls back"); + rewrite_unsigned(first.root.path(), |manifest| { + manifest.schema_fingerprint.clone_from(&first_fingerprint); + }); + let verified_first = load_package( + first.root.path(), + &local_context(PackageIntent::InitialActivation), + ) + .expect("initial webhook package reloads with exact fingerprint"); + let active = apply_package( + &database, + &verified_first, + ApplyPrecondition::InitialActivation, + Duration::from_secs(1), + Duration::from_secs(1), + ) + .await + .expect("initial webhook package activates"); + + let exact_inventory = destination_fixture.inventory(verified_first.registry(), "/events/v1"); + let exact_digest = exact_inventory + .binding_digest("neutral-events") + .expect("compiled logical destination has an activated digest") + .to_owned(); + let data_schema = verified_first.registry().event_deliveries().deliveries[0] + .data_schema + .as_str(); + let pending_event = Uuid::new_v4(); + insert_upgrade_webhook_delivery( + &database, + &active, + pending_event, + "neutral-events", + &exact_digest, + data_schema, + UpgradeDeliveryState::Pending, + ) + .await; + insert_upgrade_webhook_delivery( + &database, + &active, + Uuid::new_v4(), + "removed-delivered-destination", + &fingerprint(31), + data_schema, + UpgradeDeliveryState::Delivered, + ) + .await; + let erased_pending_event = Uuid::new_v4(); + insert_upgrade_webhook_delivery( + &database, + &active, + erased_pending_event, + "removed-erased-pending-destination", + &fingerprint(34), + data_schema, + UpgradeDeliveryState::Pending, + ) + .await; + database + .admin + .execute( + "UPDATE registry_internal.registry_outbox + SET payload = NULL + WHERE event_id = $1", + &[&erased_pending_event], + ) + .await + .expect("upgrade test erases one pending payload before retention cleanup"); + let expired_pending_event = Uuid::new_v4(); + insert_upgrade_webhook_delivery( + &database, + &active, + expired_pending_event, + "removed-expired-pending-destination", + &fingerprint(35), + data_schema, + UpgradeDeliveryState::Pending, + ) + .await; + database + .admin + .execute( + "UPDATE registry_internal.registry_outbox + SET payload_expires_at = transaction_timestamp() - interval '1 second' + WHERE event_id = $1", + &[&expired_pending_event], + ) + .await + .expect("upgrade test expires one pending payload before retention cleanup"); + insert_upgrade_webhook_delivery( + &database, + &active, + Uuid::new_v4(), + "removed-dead-letter-destination", + &fingerprint(32), + data_schema, + UpgradeDeliveryState::DeadLettered, + ) + .await; + insert_upgrade_webhook_delivery( + &database, + &active, + Uuid::new_v4(), + "removed-expired-destination", + &fingerprint(33), + data_schema, + UpgradeDeliveryState::Expired, + ) + .await; + + let second = PackageFixture::build( + "local", + 2, + Some(&active.package_revision), + first_fingerprint, + PlanChoice::WebhookSecondTable, + None, + ); + let activation_context = local_context(PackageIntent::Activation { + active_revision: &active.package_revision, + active_sequence: 1, + }); + let provisional_second = load_package(second.root.path(), &activation_context) + .expect("unrelated additive webhook successor verifies before fingerprinting"); + let transaction = migration + .transaction() + .await + .expect("successor webhook fingerprint transaction starts"); + for statement in &provisional_second.manifest().migration_plan.statements { + transaction + .batch_execute(&statement.sql) + .await + .expect("successor additive DDL applies for fingerprinting"); + } + let second_table = &provisional_second.registry().entities()["second-record"].physical_table; + transaction + .batch_execute(&format!( + "REVOKE ALL ON TABLE registry_data.{} FROM PUBLIC, \"{}\"; + GRANT SELECT, INSERT ON TABLE registry_data.{} TO \"{}\";", + quote_identifier(second_table), + database.runtime_role.as_str(), + quote_identifier(second_table), + database.runtime_role.as_str(), + )) + .await + .expect("successor fingerprint transaction installs target runtime ACL"); + let second_catalog = ExpectedManagedCatalog::compiled(provisional_second.registry()); + let second_fingerprint = + managed_schema_fingerprint(&transaction, &database.runtime_role, &second_catalog) + .await + .expect("successor webhook fingerprint derives"); + transaction + .rollback() + .await + .expect("successor webhook fingerprint transaction rolls back"); + rewrite_unsigned(second.root.path(), |manifest| { + manifest.schema_fingerprint.clone_from(&second_fingerprint); + }); + let verified_second = load_package(second.root.path(), &activation_context) + .expect("successor webhook package reloads with exact fingerprint"); + migration_task.abort(); + + let before_refusals = registry_state_snapshot(&database.admin).await; + let changed_inventory = + destination_fixture.inventory(verified_second.registry(), "/events/changed"); + let removed_inventory = EventDestinationCompatibilityInventory::default(); + let changed_refused = apply_package_with_event_destination_compatibility( + &database, + &verified_second, + ApplyPrecondition::Successor { current: &active }, + &changed_inventory, + ) + .await; + assert_eq!(changed_refused.err(), Some(MigrationError::ApplyFailed)); + assert_eq!( + registry_state_snapshot(&database.admin).await, + before_refusals, + "a changed pending binding is refused before maintenance state changes" + ); + + let lease_token = Uuid::new_v4(); + database + .admin + .execute( + "UPDATE registry_internal.registry_webhook_delivery_state + SET state = 'leased', attempt = 1, next_attempt_at = NULL, + attempt_started_at = transaction_timestamp(), + lease_expires_at = transaction_timestamp() + interval '1 hour', + lease_token = $2, updated_at = transaction_timestamp() + WHERE event_id = $1", + &[&pending_event, &lease_token], + ) + .await + .expect("upgrade test simulates retained leased work"); + let removed_refused = apply_package_with_event_destination_compatibility( + &database, + &verified_second, + ApplyPrecondition::Successor { current: &active }, + &removed_inventory, + ) + .await; + assert_eq!(removed_refused.err(), Some(MigrationError::ApplyFailed)); + assert_eq!( + registry_state_snapshot(&database.admin).await, + before_refusals, + "a removed leased binding is refused before maintenance state changes" + ); + let retained_lease: String = database + .admin + .query_one( + "SELECT state FROM registry_internal.registry_webhook_delivery_state + WHERE event_id = $1", + &[&pending_event], + ) + .await + .expect("leased state remains after refused activation") + .get(0); + assert_eq!(retained_lease, "leased"); + database + .admin + .execute( + "UPDATE registry_internal.registry_webhook_delivery_state + SET state = 'pending', attempt = 0, + next_attempt_at = transaction_timestamp(), attempt_started_at = NULL, + lease_expires_at = NULL, lease_token = NULL, + updated_at = transaction_timestamp() + WHERE event_id = $1 AND state = 'leased' AND lease_token = $2", + &[&pending_event, &lease_token], + ) + .await + .expect("upgrade test restores pending old delivery for worker proof"); + + let target_inventory = destination_fixture.inventory(verified_second.registry(), "/events/v1"); + assert_eq!( + target_inventory.binding_digest("neutral-events"), + Some(exact_digest.as_str()) + ); + let upgraded = apply_package_with_event_destination_compatibility( + &database, + &verified_second, + ApplyPrecondition::Successor { current: &active }, + &target_inventory, + ) + .await + .expect("an unrelated additive successor with the exact binding activates"); + assert_eq!(upgraded.package_sequence, 2); + let after_upgrade = registry_state_snapshot(&database.admin).await; + assert_eq!(after_upgrade.4, upgraded.package_revision); + assert_eq!(after_upgrade.7, "ready"); + + let retained = database + .admin + .query_one( + "SELECT delivery.package_revision, delivery.logical_destination_id, + delivery.destination_binding_digest, state.state + FROM registry_internal.registry_webhook_deliveries AS delivery + JOIN registry_internal.registry_webhook_delivery_state AS state + ON state.event_id = delivery.event_id + AND state.compiled_delivery_id = delivery.compiled_delivery_id + WHERE delivery.event_id = $1", + &[&pending_event], + ) + .await + .expect("retained pre-upgrade delivery remains queryable"); + assert_eq!(retained.get::<_, String>(0), active.package_revision); + assert_eq!(retained.get::<_, String>(1), "neutral-events"); + assert_eq!(retained.get::<_, String>(2), exact_digest); + assert_eq!(retained.get::<_, String>(3), "pending"); + let ignored_event_ids = vec![erased_pending_event, expired_pending_event]; + let ignored_pending: Vec<(String, bool, bool)> = database + .admin + .query( + "SELECT state.state, outbox.payload IS NULL, + outbox.payload_expires_at <= transaction_timestamp() + FROM registry_internal.registry_webhook_delivery_state AS state + JOIN registry_internal.registry_outbox AS outbox + ON outbox.event_id = state.event_id + WHERE state.event_id = ANY($1::uuid[]) + ORDER BY state.event_id", + &[&ignored_event_ids], + ) + .await + .expect("ignored pending retention states remain inspectable") + .into_iter() + .map(|row| (row.get(0), row.get(1), row.get(2))) + .collect(); + assert_eq!(ignored_pending.len(), 2); + assert!(ignored_pending + .iter() + .all(|(state, payload_erased, payload_expired)| { + state == "pending" && (*payload_erased || *payload_expired) + })); + + database.cleanup().await; +} + #[derive(Clone, Copy)] enum PlanChoice { Schema, SecondTable, ThirdTable, + WebhookSchema, + WebhookSecondTable, } fn predecessor_plan_choice(plan: PlanChoice) -> PlanChoice { match plan { PlanChoice::Schema | PlanChoice::SecondTable => PlanChoice::Schema, PlanChoice::ThirdTable => PlanChoice::SecondTable, + PlanChoice::WebhookSchema => PlanChoice::WebhookSchema, + PlanChoice::WebhookSecondTable => PlanChoice::WebhookSchema, } } @@ -1864,6 +2211,8 @@ fn canonical_sequence_for_plan(plan: PlanChoice) -> u64 { PlanChoice::Schema => 1, PlanChoice::SecondTable => 2, PlanChoice::ThirdTable => 3, + PlanChoice::WebhookSchema => 1, + PlanChoice::WebhookSecondTable => 2, } } @@ -2045,7 +2394,10 @@ fn project_bytes(environment: &str, sequence: u64, module_digest: &str) -> Vec Vec { - let second = if matches!(plan, PlanChoice::SecondTable | PlanChoice::ThirdTable) { + let second = if matches!( + plan, + PlanChoice::SecondTable | PlanChoice::ThirdTable | PlanChoice::WebhookSecondTable + ) { r#",{"id":"second-record","route":"second-records","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}],"accessProfiles":[{"id":"writer","principalClaim":"principal","operations":["get","create"],"readableFields":["code"],"writableFields":["code"]}]}"# } else { "" @@ -2055,8 +2407,16 @@ fn module_bytes(plan: PlanChoice) -> Vec { } else { "" }; + let events = if matches!( + plan, + PlanChoice::WebhookSchema | PlanChoice::WebhookSecondTable + ) { + r#","events":[{"id":"neutral-created-v1","trigger":"created","projection":["code"],"webhook":{"destinationId":"neutral-events"}}]"# + } else { + "" + }; format!( - r#"{{"id":"core","version":"1","entities":[{{"id":"neutral-record","route":"neutral-records","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}}],"accessProfiles":[{{"id":"reader","principalClaim":"principal","operations":["get","list"],"readableFields":["code"]}}]}}{second}{third}]}}"# + r#"{{"id":"core","version":"1","entities":[{{"id":"neutral-record","route":"neutral-records","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}}],"accessProfiles":[{{"id":"reader","principalClaim":"principal","operations":["get","list"],"readableFields":["code"]}}]{events}}}{second}{third}]}}"# ) .into_bytes() } @@ -2134,6 +2494,281 @@ async fn apply_package( .await } +async fn apply_package_with_event_destination_compatibility( + database: &TestDatabase, + package: ®istry_server::package::VerifiedPackage, + precondition: ApplyPrecondition<'_>, + inventory: &EventDestinationCompatibilityInventory, +) -> registry_server::migration::Result { + let timeouts = ApplyTimeouts::new(Duration::from_secs(1), Duration::from_secs(1)) + .expect("test apply timeouts are bounded"); + apply_verified_package( + ApplyVerifiedPackageRequest::new( + &database.migration_config, + package, + precondition, + ApplyRoles::new(&database.migration_role, &database.runtime_role), + timeouts, + ) + .with_event_destination_compatibility_inventory(inventory), + ) + .await +} + +#[derive(Clone, Copy)] +enum UpgradeDeliveryState { + Pending, + Delivered, + DeadLettered, + Expired, +} + +async fn insert_upgrade_webhook_delivery( + database: &TestDatabase, + active: &ExpectedRegistryIdentity, + event_id: Uuid, + logical_destination_id: &str, + binding_digest: &str, + data_schema: &str, + state: UpgradeDeliveryState, +) { + let compiled_delivery_id = "events.neutral-record.neutral-created-v1.webhook"; + let payload = br#"{"code":"old"}"#.as_slice(); + let payload_digest = Sha256::digest(payload).to_vec(); + let retry_delays_ms = vec![1_000_i64, 2_000, 4_000, 8_000]; + database + .admin + .execute( + "INSERT INTO registry_internal.registry_outbox + (event_id, event_type, trigger, entity_id, record_reference, + record_revision, package_revision, schema_fingerprint, payload, + payload_expires_at) + VALUES ($1, 'neutral-created-v1', 'created', 'neutral-record', + 'record-reference', 1, $2, $3, $4, + transaction_timestamp() + interval '7 days')", + &[ + &event_id, + &active.package_revision, + &active.schema_fingerprint, + &payload, + ], + ) + .await + .expect("upgrade test outbox row inserts"); + database + .admin + .execute( + "INSERT INTO registry_internal.registry_webhook_deliveries + (event_id, compiled_delivery_id, logical_destination_id, + destination_binding_digest, package_revision, schema_fingerprint, + data_schema, classification_ceiling, authentication_profile, delivery_mode, + attempt_timeout_ms, initial_backoff_ms, maximum_backoff_ms, + exponential_backoff_multiplier, maximum_attempts, retry_delays_ms, + maximum_payload_bytes, payload_digest, deployed_attempt_timeout_ms, + deployed_maximum_attempts, dead_letter, operator_replay) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'internal', 'hmac_sha256_v1', + 'after_commit', 5000, 1000, 8000, 2, 5, $8, 1024, $9, + 4000, 4, 'required', true)", + &[ + &event_id, + &compiled_delivery_id, + &logical_destination_id, + &binding_digest, + &active.package_revision, + &active.schema_fingerprint, + &data_schema, + &retry_delays_ms, + &payload_digest, + ], + ) + .await + .expect("upgrade test captured delivery inserts"); + database + .admin + .execute( + "INSERT INTO registry_internal.registry_webhook_delivery_state + (event_id, compiled_delivery_id, generation, state, attempt, next_attempt_at) + VALUES ($1, $2, 1, 'pending', 0, transaction_timestamp())", + &[&event_id, &compiled_delivery_id], + ) + .await + .expect("upgrade test pending state inserts"); + + let terminal_update = match state { + UpgradeDeliveryState::Pending => return, + UpgradeDeliveryState::Delivered => { + "SET state = 'delivered', attempt = 1, next_attempt_at = NULL, + delivered_at = transaction_timestamp(), updated_at = transaction_timestamp()" + } + UpgradeDeliveryState::DeadLettered => { + "SET state = 'dead_lettered', attempt = 1, next_attempt_at = NULL, + dead_lettered_at = transaction_timestamp(), updated_at = transaction_timestamp()" + } + UpgradeDeliveryState::Expired => { + "SET state = 'expired', next_attempt_at = NULL, + expired_at = transaction_timestamp(), updated_at = transaction_timestamp()" + } + }; + database + .admin + .execute( + &format!( + "UPDATE registry_internal.registry_webhook_delivery_state {terminal_update} + WHERE event_id = $1 AND compiled_delivery_id = $2" + ), + &[&event_id, &compiled_delivery_id], + ) + .await + .expect("upgrade test terminal state installs"); + if matches!( + state, + UpgradeDeliveryState::Delivered | UpgradeDeliveryState::Expired + ) { + database + .admin + .execute( + "UPDATE registry_internal.registry_outbox + SET payload = NULL, + payload_expires_at = CASE + WHEN $2 THEN transaction_timestamp() - interval '1 second' + ELSE payload_expires_at + END + WHERE event_id = $1", + &[&event_id, &matches!(state, UpgradeDeliveryState::Expired)], + ) + .await + .expect("upgrade test terminal payload is erased"); + } +} + +struct EventDestinationCompatibilityFixture { + _root: TempRoot, + secret_root: PathBuf, + package_root: PathBuf, + trust_anchor: PathBuf, +} + +impl EventDestinationCompatibilityFixture { + fn create() -> Self { + let root = TempRoot::create(); + fs::create_dir(root.path()).expect("destination compatibility root creates"); + let secret_root = root.path().join("secrets"); + let package_root = root.path().join("package"); + fs::create_dir(&secret_root).expect("destination compatibility secrets create"); + fs::create_dir(&package_root).expect("destination compatibility package root creates"); + let trust_anchor = root.path().join("trust-anchor.json"); + fs::write(&trust_anchor, "{}").expect("destination compatibility trust file writes"); + let key_path = secret_root.join("webhook-key"); + fs::write(&key_path, [0x51_u8; 32]).expect("destination compatibility key writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)) + .expect("destination compatibility key permissions set"); + } + Self { + _root: root, + secret_root, + package_root, + trust_anchor, + } + } + + fn inventory( + &self, + registry: ®istry_server::CompiledRegistry, + path: &str, + ) -> EventDestinationCompatibilityInventory { + let raw = format!( + r#" +listener: + bind: 127.0.0.1:8080 + trustedProxy: direct +identity: + environment: local + instanceId: {INSTANCE} + databaseId: {DATABASE} + databaseInitializationEnvironment: local +secretProviders: + file: + root: {} +database: + runtimeUrlRef: secret:file/runtime-database-url + migrationUrlRef: secret:file/migration-database-url + pool: + maxSize: 4 + waitTimeoutMilliseconds: 1000 + createTimeoutMilliseconds: 1000 + recycleTimeoutMilliseconds: 1000 + roles: + migration: registry_migration + runtime: registry_runtime +package: + root: {} + trustAnchorPath: {} + compilerSourceRevision: {SOURCE_REVISION} + activeRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + activeSequence: 1 +authentication: + oidc: + issuer: https://issuer.example + audience: urn:registry-server:webhook-upgrade + allowedAlgorithm: EdDSA + accessTokenType: JWT + scopeClaim: scope + scopeSeparator: " " + allowedClients: [registry-client] + deniedKids: [denied-kid] + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 60000 + jwksCache: + cacheTtlSeconds: 600 + negativeCacheTtlSeconds: 60 + refreshCooldownSeconds: 30 + maxDocumentBytes: 65536 + requestTimeoutMilliseconds: 5000 + outageToleranceSeconds: 900 + authorityClaims: + principal: registry_principal + purpose: registry_purpose + rowBoundaryClaims: + - {{name: jurisdiction, type: directString}} +audit: + hashKeyRef: secret:file/audit-key +cursor: + secretRef: secret:file/cursor-key + maxAgeSeconds: 300 +eventDestinations: + neutral-events: + origin: https://events.example/ + path: {path} + networkProfile: productionHttps + dnsFamily: dualStackStrict + allowedPrivateCidrs: [] + hmacSha256KeyRef: secret:file/webhook-key + classificationCeiling: internal + deliveryCeilings: + attemptTimeoutMilliseconds: 4000 + maximumAttempts: 4 +operationalTimeouts: + httpRequestMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + recordLockMilliseconds: 5000 + migrationLockMilliseconds: 30000 + migrationStatementMilliseconds: 60000 +"#, + self.secret_root.display(), + self.package_root.display(), + self.trust_anchor.display(), + ); + parse_runtime_config(&raw) + .expect("upgrade compatibility runtime parses") + .activate_event_destinations(registry) + .expect("upgrade compatibility destination activates") + .compatibility_inventory() + } +} + async fn registry_state_snapshot( client: &impl GenericClient, ) -> ( diff --git a/crates/registry-server/tests/postgres_pilot_acceptance.rs b/crates/registry-server/tests/postgres_pilot_acceptance.rs index 6ae2165141..1d3470b4e6 100644 --- a/crates/registry-server/tests/postgres_pilot_acceptance.rs +++ b/crates/registry-server/tests/postgres_pilot_acceptance.rs @@ -197,21 +197,14 @@ async fn asset_site_placement_journey(harness: &PilotHarness) { &token, ) .await; - let event_count: i64 = harness - .database - .admin - .query_one( - "SELECT count(*) FROM registry_internal.registry_outbox WHERE event_type = 'inspection-created'", - &[], - ) - .await - .expect("administrator samples the configured create event type") - .get(0); - assert_eq!(event_count, 1); } async fn household_journey(harness: &PilotHarness) { - let token = harness.token("household-administration", &[]); + let token = harness.token_with_scopes( + "household-administration", + &["registry:household:operate"], + &[], + ); let openapi = assert_fixture_surface(harness, "household-operator", &token, "household").await; assert_eq!( openapi["components"]["schemas"]["person"]["properties"]["residency-status"] @@ -907,7 +900,11 @@ async fn assert_fixture_surface( Vec::new(), ) .await; - assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.status(), + StatusCode::OK, + "compiled OpenAPI is visible to the {profile} profile" + ); let openapi = response_json(response).await; let families = [ ("asset", "/v1/records/assets", "asset-item"), diff --git a/crates/registry-server/tests/postgres_tombstone_revision.rs b/crates/registry-server/tests/postgres_tombstone_revision.rs index 7af247622d..c300d1d574 100644 --- a/crates/registry-server/tests/postgres_tombstone_revision.rs +++ b/crates/registry-server/tests/postgres_tombstone_revision.rs @@ -130,7 +130,7 @@ async fn tombstone_revisions_survive_package_upgrade_and_replay_exactly() { ); assert_current_row_tombstoned(&fixture.database, &fixture.table, &record_id).await; assert_three_revisions_one_record_across_package_upgrade(&fixture.database, &record_id).await; - assert_tombstone_event_is_canonical(&fixture.database).await; + assert_tombstone_event_is_canonical(&fixture.database, &record_id).await; let event_id = tombstone_event_id(&fixture.database).await; let before_replay = durable_counts(&fixture.database, &fixture.table).await; @@ -927,7 +927,7 @@ async fn assert_three_revisions_one_record_across_package_upgrade( ); } -async fn assert_tombstone_event_is_canonical(database: &TestDatabase) { +async fn assert_tombstone_event_is_canonical(database: &TestDatabase, record_id: &str) { let row = database .admin .query_one( @@ -947,8 +947,20 @@ async fn assert_tombstone_event_is_canonical(database: &TestDatabase) { assert_eq!(row.get::<_, i64>(4), 3); assert_eq!(row.get::<_, String>(5), "package-tombstone-2"); assert!(row.get::<_, String>(6).starts_with("sha256:")); + let expected_payload = canonicalize_json(&json!({ + "entity": "widget", + "recordId": record_id, + "revision": 3, + "trigger": "tombstoned", + "packageRevision": "package-tombstone-2", + "values": { + "label": "patched-label", + "quantity": 7, + }, + })) + .expect("expected tombstone event canonicalizes"); assert!( - row.get::<_, Vec>(7) == br#"{"label":"patched-label","quantity":7}"#.as_slice(), + row.get::<_, Vec>(7) == expected_payload.as_slice(), "canonical tombstone outbox projection did not match expected bytes" ); } diff --git a/crates/registry-server/tests/postgres_webhook_delivery.rs b/crates/registry-server/tests/postgres_webhook_delivery.rs index 6eda17dda5..10cd2886e8 100644 --- a/crates/registry-server/tests/postgres_webhook_delivery.rs +++ b/crates/registry-server/tests/postgres_webhook_delivery.rs @@ -18,6 +18,7 @@ use hmac::{Hmac, KeyInit, Mac}; use postgres_harness::TestDatabase; use rcgen::{generate_simple_self_signed, CertifiedKey}; use registry_platform_audit::AuditProfile; +use registry_platform_canonical_json::canonicalize_json; use registry_server::compiler::{compile_project, CompileProfile}; use registry_server::contract::parse_project_json; use registry_server::event_destination::ActivatedEventDestinationRegistry; @@ -27,7 +28,9 @@ use registry_server::postgres::{ RowBoundaryContext, }; use registry_server::runtime_config::parse_runtime_config; -use registry_server::webhook::{WebhookDeliveryError, WebhookDeliveryService, WebhookWorkOutcome}; +use registry_server::webhook::{ + WebhookDeliveryError, WebhookDeliveryService, WebhookDeliveryStatusKind, WebhookWorkOutcome, +}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; @@ -43,6 +46,10 @@ const PACKAGE_REVISION: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const SCHEMA_FINGERPRINT: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const SUCCESSOR_PACKAGE_REVISION: &str = + "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const SUCCESSOR_SCHEMA_FINGERPRINT: &str = + "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; const DESTINATION_ID: &str = "case-operations"; const DELIVERY_PATH: &str = "/registry-events"; const HMAC_KEY: &[u8] = b"webhook-delivery-signing-key-0123456789abcdef"; @@ -109,6 +116,14 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun .get_for_test() .await .expect("runtime mutation connection is available"); + assert_eq!( + service.list(0).await, + Err(WebhookDeliveryError::Unavailable) + ); + assert_eq!( + service.list(101).await, + Err(WebhookDeliveryError::Unavailable) + ); receiver.enqueue(ResponsePlan::Status(500)).await; receiver.enqueue(ResponsePlan::Status(204)).await; @@ -141,17 +156,24 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun receiver.wait_for_count(1).await; let first_attempt = receiver.request(0).await; assert_exact_request(&first_attempt, &first).await; - let next_attempt_at = delivery_next_attempt_at(&database, &first).await; - let attempt_started_at = header_time(&first_attempt, "x-registry-event-timestamp"); + assert_exact_audit_outcome( + &database, + &audit_profile, + &first, + 1, + 1, + "attempt", + "attempt_started", + ) + .await; + let retry_delay = delivery_retry_delay(&database, &first).await; assert_eq!( - next_attempt_at - .duration_since(attempt_started_at) - .expect("retry is scheduled after its exact attempt start"), - Duration::from_millis(100), - "retry uses the exact compiler-produced delay from the original attempt start" + retry_delay, + Duration::from_millis(1_000), + "retry waits the exact compiler-produced delay after the failed attempt is finalized" ); - tokio::time::sleep(Duration::from_millis(120)).await; + tokio::time::sleep(Duration::from_millis(1_020)).await; assert_eq!( service.deliver_once().await, Ok(WebhookWorkOutcome::Delivered) @@ -178,6 +200,73 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun "http_non_success", ) .await; + assert!(!outbox_payload_available(&database, &first).await); + assert_eq!( + service + .replay(first.event_id, &first.compiled_delivery_id, 1) + .await, + Err(WebhookDeliveryError::Unavailable), + "delivered work is never replayable" + ); + + let pending_expired = create_event( + &database, + &coordinator, + &mut mutation_client, + &plan, + &claims, + "delivery-payload-retention-expired", + "expired-before-egress", + ) + .await; + database + .admin + .execute( + "UPDATE registry_internal.registry_outbox + SET payload_expires_at = transaction_timestamp() - interval '1 second' + WHERE event_id = $1", + &[&pending_expired.event_id], + ) + .await + .expect("administrator expires one retained pending payload"); + let egress_before_expiry = receiver.count().await; + assert_eq!(service.deliver_once().await, Ok(WebhookWorkOutcome::Idle)); + assert_eq!(receiver.count().await, egress_before_expiry); + assert_eq!( + delivery_state(&database, &pending_expired).await, + (1, "expired".to_owned(), 0) + ); + assert!(!outbox_payload_available(&database, &pending_expired).await); + assert_exact_audit_outcome( + &database, + &audit_profile, + &pending_expired, + 1, + 0, + "terminal", + "payload_expired", + ) + .await; + assert_eq!( + service + .replay( + pending_expired.event_id, + &pending_expired.compiled_delivery_id, + 1, + ) + .await, + Err(WebhookDeliveryError::Unavailable) + ); + let statuses = service + .list(100) + .await + .expect("bounded operator list loads"); + let expired_status = statuses + .iter() + .find(|status| status.event_id == pending_expired.event_id) + .expect("expired work remains visible without values"); + assert_eq!(expired_status.state, WebhookDeliveryStatusKind::Expired); + assert!(!expired_status.payload_available); assert_exact_audit_outcome( &database, &audit_profile, @@ -188,7 +277,6 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun "delivered", ) .await; - receiver .enqueue(ResponsePlan::Delay(Duration::from_millis(250), 204)) .await; @@ -209,7 +297,7 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun service.deliver_once().await, Ok(WebhookWorkOutcome::RetryScheduled) ); - tokio::time::sleep(Duration::from_millis(120)).await; + tokio::time::sleep(Duration::from_millis(1_020)).await; assert_eq!( service.deliver_once().await, Ok(WebhookWorkOutcome::DeadLettered) @@ -347,10 +435,17 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun ) .await .expect("administrator simulates one expired post-audit lease"); + assert_eq!(service.deliver_once().await, Ok(WebhookWorkOutcome::Idle)); + assert_eq!( + delivery_retry_delay(&database, &recovered).await, + Duration::from_millis(1_000), + "interrupted work receives the same full post-finalization backoff" + ); + tokio::time::sleep(Duration::from_millis(1_020)).await; assert_eq!( service.deliver_once().await, Ok(WebhookWorkOutcome::Delivered), - "recovery consumes the interrupted attempt and claims the next bounded attempt" + "recovery consumes the interrupted attempt before claiming the next bounded attempt" ); receiver.wait_for_count(6).await; assert_eq!( @@ -416,7 +511,7 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun service.deliver_once().await, Ok(WebhookWorkOutcome::RetryScheduled) ); - tokio::time::sleep(Duration::from_millis(120)).await; + tokio::time::sleep(Duration::from_millis(1_020)).await; assert_eq!( service.deliver_once().await, Ok(WebhookWorkOutcome::DeadLettered) @@ -443,6 +538,48 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun ) .await; + database + .admin + .execute( + "UPDATE registry_internal.registry_outbox + SET payload_expires_at = transaction_timestamp() - interval '1 second' + WHERE event_id = $1", + &[&transport_event.event_id], + ) + .await + .expect("administrator expires one retained dead letter"); + assert_eq!(service.deliver_once().await, Ok(WebhookWorkOutcome::Idle)); + assert_eq!( + delivery_state(&database, &transport_event).await, + (1, "dead_lettered".to_owned(), 2), + "retention erasure preserves the terminal failure state" + ); + assert!(!outbox_payload_available(&database, &transport_event).await); + let statuses = service + .list(100) + .await + .expect("bounded operator list loads"); + let dead_letter_status = statuses + .iter() + .find(|status| status.event_id == transport_event.event_id) + .expect("dead letter remains visible without values"); + assert_eq!( + dead_letter_status.state, + WebhookDeliveryStatusKind::DeadLettered + ); + assert!(!dead_letter_status.payload_available); + assert_eq!( + service + .replay( + transport_event.event_id, + &transport_event.compiled_delivery_id, + 1, + ) + .await, + Err(WebhookDeliveryError::Unavailable), + "an erased dead letter cannot be replayed" + ); + let egress_before_refusals = receiver.count().await; let binding_refused = create_event( &database, @@ -483,7 +620,7 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun "destination_binding_refused", ) .await; - tokio::time::sleep(Duration::from_millis(120)).await; + tokio::time::sleep(Duration::from_millis(1_020)).await; assert_eq!( service.deliver_once().await, Ok(WebhookWorkOutcome::DeadLettered) @@ -499,6 +636,21 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun "destination_binding_refused", ) .await; + service + .verify_retained_bindings() + .await + .expect("a terminal dead letter never blocks successor startup"); + assert_eq!( + service + .replay( + binding_refused.event_id, + &binding_refused.compiled_delivery_id, + 1, + ) + .await, + Err(WebhookDeliveryError::Unavailable), + "replay fails closed when the current destination does not match the captured binding" + ); let payload_refused = create_event( &database, @@ -538,7 +690,7 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun "payload_refused", ) .await; - tokio::time::sleep(Duration::from_millis(120)).await; + tokio::time::sleep(Duration::from_millis(1_020)).await; assert_eq!( service.deliver_once().await, Ok(WebhookWorkOutcome::DeadLettered) @@ -644,11 +796,143 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun database.cleanup().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_webhook_delivery_finishes_prior_package_work_after_compatible_upgrade() { + let receiver = HttpsReceiver::start().await; + let database = TestDatabase::create(6).await; + let (migration, migration_task) = database.connect_migration().await; + let compiled = compiled_registry(); + install_compiled_schema(&migration, &compiled, &database.runtime_role) + .await + .expect("migration installs webhook delivery state"); + let original_identity = expected_identity(); + initialize_registry_state(&migration, &original_identity).await; + migration_task.abort(); + + let fixture = DestinationFixture::new(&receiver); + let destinations = Arc::new(fixture.activate(&compiled)); + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let audit_profile = AuditProfile::production_from_secret_bytes(vec![0x7c; 32].into()) + .expect("test owns a keyed audit profile"); + let lock_key = RegistryLockKey::derive("webhook-delivery-registry") + .expect("test lock identity is bounded"); + let coordinator = MutationCoordinator::new_with_event_destinations( + lock_key, + Duration::from_secs(2), + original_identity.clone(), + audit_profile.clone(), + Some(Arc::clone(&destinations)), + ); + let plan = MutationPlan::from_compiled(&compiled, "records.case.create") + .expect("create plan retains the exact compiler delivery"); + let claims = mutation_claims(&compiled); + let mut mutation_client = pool + .get_for_test() + .await + .expect("runtime mutation connection is available"); + let captured = create_event( + &database, + &coordinator, + &mut mutation_client, + &plan, + &claims, + "delivery-before-compatible-upgrade", + "captured-before-upgrade", + ) + .await; + assert_eq!(captured.package_revision, PACKAGE_REVISION); + assert_eq!( + captured.data_schema, + compiled.event_deliveries().deliveries[0].data_schema + ); + + let successor_identity = ExpectedRegistryIdentity { + package_revision: SUCCESSOR_PACKAGE_REVISION.to_owned(), + schema_fingerprint: SUCCESSOR_SCHEMA_FINGERPRINT.to_owned(), + package_sequence: 2, + ..original_identity + }; + let changed = database + .admin + .execute( + "UPDATE registry_internal.registry_state + SET active_package_revision = $1, schema_fingerprint = $2, + package_sequence = $3 + WHERE singleton", + &[ + &successor_identity.package_revision, + &successor_identity.schema_fingerprint, + &successor_identity.package_sequence, + ], + ) + .await + .expect("compatible successor identity activates"); + assert_eq!(changed, 1); + + let service = WebhookDeliveryService::new( + pool.clone(), + Arc::clone(&destinations), + successor_identity, + lock_key, + Duration::from_secs(2), + audit_profile.clone(), + ); + service + .verify_retained_bindings() + .await + .expect("unchanged destination remains compatible with retained work"); + receiver.enqueue(ResponsePlan::Status(204)).await; + assert_eq!( + service.deliver_once().await, + Ok(WebhookWorkOutcome::Delivered), + "the successor worker delivers immutable work captured by the prior package" + ); + receiver.wait_for_count(1).await; + assert_exact_request(&receiver.request(0).await, &captured).await; + assert_exact_audit_outcome( + &database, + &audit_profile, + &captured, + 1, + 1, + "terminal", + "delivered", + ) + .await; + let payload_available: bool = database + .admin + .query_one( + "SELECT payload IS NOT NULL + FROM registry_internal.registry_outbox + WHERE event_id = $1", + &[&captured.event_id], + ) + .await + .expect("administrator can inspect post-delivery retention state") + .get(0); + assert!( + !payload_available, + "successful delivery atomically erases values" + ); + + drop(mutation_client); + drop(service); + drop(pool); + receiver.stop().await; + database.cleanup().await; +} + #[derive(Clone)] struct CapturedEvent { event_id: Uuid, compiled_delivery_id: String, payload: Vec, + data_schema: String, + package_revision: String, + created_at: SystemTime, } async fn create_event( @@ -686,7 +970,8 @@ async fn create_event( let row = database .admin .query_one( - "SELECT outbox.event_id, delivery.compiled_delivery_id, outbox.payload + "SELECT outbox.event_id, delivery.compiled_delivery_id, outbox.payload, + delivery.data_schema, delivery.package_revision, outbox.created_at FROM registry_internal.registry_outbox AS outbox JOIN registry_internal.registry_webhook_deliveries AS delivery ON delivery.event_id = outbox.event_id @@ -696,11 +981,42 @@ async fn create_event( ) .await .expect("administrator can inspect the newest capture identity"); - CapturedEvent { + let captured = CapturedEvent { event_id: row.get(0), compiled_delivery_id: row.get(1), payload: row.get(2), - } + data_schema: row.get(3), + package_revision: row.get(4), + created_at: row.get(5), + }; + let body: Value = + serde_json::from_slice(&captured.payload).expect("captured event body is strict JSON"); + let record_id = body + .get("recordId") + .and_then(Value::as_str) + .expect("captured event contains a raw record id"); + Uuid::parse_str(record_id).expect("captured record id is a UUID"); + assert_eq!( + body, + json!({ + "entity": "case", + "recordId": record_id, + "revision": 1, + "trigger": "created", + "packageRevision": PACKAGE_REVISION, + "values": { + "label": label, + "restricted_note": RECORD_VALUE_CANARY, + }, + }), + "event body carries only the fixed envelope and declared projection" + ); + assert_eq!( + captured.payload, + canonicalize_json(&body).expect("captured body canonicalizes"), + "durable and transmitted body bytes are canonical" + ); + captured } async fn assert_seed_is_exact( @@ -764,18 +1080,36 @@ async fn delivery_state(database: &TestDatabase, event: &CapturedEvent) -> (i64, (row.get(0), row.get(1), row.get(2)) } -async fn delivery_next_attempt_at(database: &TestDatabase, event: &CapturedEvent) -> SystemTime { +async fn outbox_payload_available(database: &TestDatabase, event: &CapturedEvent) -> bool { database .admin .query_one( - "SELECT next_attempt_at + "SELECT payload IS NOT NULL + FROM registry_internal.registry_outbox + WHERE event_id = $1", + &[&event.event_id], + ) + .await + .expect("administrator can inspect retained payload availability") + .get(0) +} + +async fn delivery_retry_delay(database: &TestDatabase, event: &CapturedEvent) -> Duration { + let row = database + .admin + .query_one( + "SELECT next_attempt_at, updated_at FROM registry_internal.registry_webhook_delivery_state WHERE event_id = $1 AND compiled_delivery_id = $2", &[&event.event_id, &event.compiled_delivery_id], ) .await - .expect("administrator can inspect the exact retry schedule") - .get(0) + .expect("administrator can inspect the exact retry schedule"); + let next_attempt_at = row.get::<_, SystemTime>(0); + let updated_at = row.get::<_, SystemTime>(1); + next_attempt_at + .duration_since(updated_at) + .expect("retry is scheduled after finalization") } async fn revoke_audit_insert(database: &TestDatabase) { @@ -844,7 +1178,7 @@ async fn audit_outcomes( .key_hasher() .audit_reference_hash( "registry-server-webhook-event-v1", - PACKAGE_REVISION, + &event.package_revision, &event.event_id.to_string(), ) .expect("test can derive the keyed event reference"); @@ -919,13 +1253,6 @@ fn header<'a>(request: &'a ReceivedRequest, name: &str) -> &'a str { .expect("closed webhook header is present") } -fn header_time(request: &ReceivedRequest, name: &str) -> SystemTime { - SystemTime::from( - OffsetDateTime::parse(header(request, name), &Rfc3339) - .expect("webhook timestamp is strict RFC3339"), - ) -} - async fn assert_exact_request(request: &ReceivedRequest, event: &CapturedEvent) { assert_eq!(request.method, "POST"); assert_eq!(request.target, DELIVERY_PATH); @@ -933,43 +1260,73 @@ async fn assert_exact_request(request: &ReceivedRequest, event: &CapturedEvent) request.body == event.payload, "request body is the exact captured canonical bytes" ); + assert_eq!(header(request, "ce-id"), event.event_id.to_string()); + assert_eq!(header(request, "ce-specversion"), "1.0"); + assert_eq!( + header(request, "ce-source"), + "urn:registrystack:registry:webhook-delivery-registry:instance:webhook-delivery-instance" + ); + assert_eq!(header(request, "ce-type"), "case-created"); assert_eq!( - header(request, "x-registry-event-id"), - event.event_id.to_string() + header(request, "ce-time"), + OffsetDateTime::from(event.created_at) + .format(&Rfc3339) + .expect("captured event time formats") ); - assert_eq!(header(request, "x-registry-event-type"), "case-created"); + assert_eq!(header(request, "ce-dataschema"), event.data_schema); assert_eq!(header(request, "x-registry-event-generation"), "1"); assert_eq!(header(request, "content-type"), "application/json"); - let signature = independent_signature( - header(request, "x-registry-event-id"), - header(request, "x-registry-event-type"), - header(request, "x-registry-event-generation"), - header(request, "x-registry-delivery-attempt"), - header(request, "x-registry-event-timestamp"), - header(request, "idempotency-key"), - &request.body, - ); + let signature = independent_signature(IndependentSignatureFields { + event_id: header(request, "ce-id"), + source: header(request, "ce-source"), + event_type: header(request, "ce-type"), + time: header(request, "ce-time"), + data_schema: header(request, "ce-dataschema"), + generation: header(request, "x-registry-event-generation"), + attempt: header(request, "x-registry-delivery-attempt"), + delivery_time: header(request, "x-registry-delivery-time"), + method: &request.method, + request_target: &request.target, + content_type: header(request, "content-type"), + idempotency_key: header(request, "idempotency-key"), + body: &request.body, + }); assert_eq!(header(request, "x-registry-signature"), signature); } -fn independent_signature( - event_id: &str, - event_type: &str, - generation: &str, - attempt: &str, - timestamp: &str, - idempotency_key: &str, - body: &[u8], -) -> String { +struct IndependentSignatureFields<'a> { + event_id: &'a str, + source: &'a str, + event_type: &'a str, + time: &'a str, + data_schema: &'a str, + generation: &'a str, + attempt: &'a str, + delivery_time: &'a str, + method: &'a str, + request_target: &'a str, + content_type: &'a str, + idempotency_key: &'a str, + body: &'a [u8], +} + +fn independent_signature(fields: IndependentSignatureFields<'_>) -> String { let mut input = SIGNATURE_DOMAIN.to_vec(); for value in [ - event_id.as_bytes(), - event_type.as_bytes(), - generation.as_bytes(), - attempt.as_bytes(), - timestamp.as_bytes(), - idempotency_key.as_bytes(), - body, + b"1.0".as_slice(), + fields.event_id.as_bytes(), + fields.source.as_bytes(), + fields.event_type.as_bytes(), + fields.time.as_bytes(), + fields.data_schema.as_bytes(), + fields.generation.as_bytes(), + fields.attempt.as_bytes(), + fields.delivery_time.as_bytes(), + fields.method.as_bytes(), + fields.request_target.as_bytes(), + fields.content_type.as_bytes(), + fields.idempotency_key.as_bytes(), + fields.body, ] { input.extend_from_slice(&(value.len() as u64).to_be_bytes()); input.extend_from_slice(value); @@ -1003,17 +1360,7 @@ fn compiled_registry() -> registry_server::CompiledRegistry { "events":[{ "id":"case-created","trigger":"created","projection":["label","restricted_note"], "webhook":{ - "destinationId":"case-operations", - "classificationCeiling":"restricted", - "authenticationProfile":"hmac_sha256_v1", - "delivery":{ - "attemptTimeoutMs":100, - "initialBackoffMs":100, - "maximumBackoffMs":100, - "maximumAttempts":2, - "deadLetter":"required", - "operatorReplay":true - } + "destinationId":"case-operations" } }] }] @@ -1189,6 +1536,7 @@ eventDestinations: dnsFamily: ipv4Only allowedPrivateCidrs: [] hmacSha256KeyRef: secret:file/{KEY_REF_CANARY} + classificationCeiling: restricted tls: caBundleRef: secret:file/{CA_REF_CANARY} deliveryCeilings: diff --git a/crates/registry-server/tests/postgres_webhook_outbox.rs b/crates/registry-server/tests/postgres_webhook_outbox.rs index 820f9de106..38f12dd69b 100644 --- a/crates/registry-server/tests/postgres_webhook_outbox.rs +++ b/crates/registry-server/tests/postgres_webhook_outbox.rs @@ -14,13 +14,15 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use postgres_harness::TestDatabase; use registry_platform_audit::AuditProfile; +use registry_platform_canonical_json::canonicalize_json; use registry_server::compiler::{compile_project, CompileProfile}; use registry_server::contract::parse_project_json; use registry_server::event_destination::ActivatedEventDestinationRegistry; +use registry_server::idempotency::PermittedResponseHeader; use registry_server::model::CompiledEventDelivery; use registry_server::mutation::{ - MutationBody, MutationCoordinator, MutationError, MutationFaultPoint, MutationPlan, - MutationRequest, + install_mutation_schema, MutationBody, MutationCoordinator, MutationError, MutationFaultPoint, + MutationPlan, MutationRequest, PatchOperation, }; use registry_server::postgres::{ install_compiled_schema, managed_schema_fingerprint, ClaimContext, ExpectedManagedCatalog, @@ -126,6 +128,8 @@ async fn real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_determ .expect("test owns a strongly keyed audit profile"); let plan = MutationPlan::from_compiled(&compiled, "records.case.create") .expect("create plan retains the exact compiler delivery"); + let patch_plan = MutationPlan::from_compiled(&compiled, "records.case.patch") + .expect("patch plan retains the conditional compiler delivery"); let claims = mutation_claims(&compiled); let table = &compiled.entities()["case"].physical_table; let lock_key = @@ -242,7 +246,7 @@ async fn real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_determ &binding_digest, &identity, ); - assert!(first_capture.payload == expected_payload()); + assert_eq!(first_capture.payload, expected_payload(raw_record_id)); assert!(first_capture.payload.len() <= compiled_delivery.maximum_payload_bytes as usize); assert_delivery_is_transport_and_value_free(&database, first_capture.event_id, raw_record_id) .await; @@ -303,6 +307,94 @@ async fn real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_determ ); assert_eq!(durable_counts(&database, table).await.delivery, 2); + let first_etag = response_etag(&first); + let before_same_value = durable_counts(&database, table).await; + let same_value = coordinator + .execute( + &mut client, + patch_request( + &patch_plan, + &claims, + "same-value-patch", + raw_record_id, + &first_etag, + "first", + ), + ) + .await + .expect("a normalized same-value patch commits without overmatching changed"); + let after_same_value = durable_counts(&database, table).await; + assert_eq!(after_same_value.outbox, before_same_value.outbox); + assert_eq!(after_same_value.delivery, before_same_value.delivery); + assert_eq!( + after_same_value.delivery_state, + before_same_value.delivery_state + ); + + let same_value_etag = response_etag(&same_value); + let before_match = durable_counts(&database, table).await; + let matching = coordinator + .execute( + &mut client, + patch_request( + &patch_plan, + &claims, + "matching-conditional-patch", + raw_record_id, + &same_value_etag, + "approved", + ), + ) + .await + .expect("all field predicates match inside the patch transaction"); + let after_match = durable_counts(&database, table).await; + assert_eq!(after_match.outbox, before_match.outbox + 1); + assert_eq!(after_match.delivery, before_match.delivery + 1); + assert_eq!(after_match.delivery_state, before_match.delivery_state + 1); + let conditional_capture = capture(&database, 2).await; + let conditional_body: Value = serde_json::from_slice(&conditional_capture.payload) + .expect("conditional event body is JSON"); + assert_eq!( + conditional_body, + json!({ + "entity": "case", + "recordId": raw_record_id, + "revision": 3, + "trigger": "patched", + "packageRevision": PACKAGE_REVISION, + "values": { + "label": "approved", + }, + }) + ); + + let matching_etag = response_etag(&matching); + let before_failed_conjunct = durable_counts(&database, table).await; + coordinator + .execute( + &mut client, + patch_request( + &patch_plan, + &claims, + "failed-before-equals-conjunct", + raw_record_id, + &matching_etag, + "changed-again", + ), + ) + .await + .expect("patch commits while one condition conjunct fails"); + let after_failed_conjunct = durable_counts(&database, table).await; + assert_eq!(after_failed_conjunct.outbox, before_failed_conjunct.outbox); + assert_eq!( + after_failed_conjunct.delivery, + before_failed_conjunct.delivery + ); + assert_eq!( + after_failed_conjunct.delivery_state, + before_failed_conjunct.delivery_state + ); + drop(replay_client_a); drop(replay_client_b); drop(client); @@ -310,6 +402,198 @@ async fn real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_determ database.cleanup().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_postgres_empty_pre_v1_webhook_schema_upgrades_idempotently() { + let database = TestDatabase::create(4).await; + let (migration, migration_task) = database.connect_migration().await; + install_pre_v1_webhook_schema(&migration).await; + + install_mutation_schema(&migration, &database.runtime_role) + .await + .expect("empty pre-V1 webhook schema upgrades"); + install_mutation_schema(&migration, &database.runtime_role) + .await + .expect("the internal schema upgrade is idempotent"); + + let columns = migration + .query( + "SELECT table_name, column_name, is_nullable + FROM information_schema.columns + WHERE table_schema = 'registry_internal' + AND ((table_name = 'registry_outbox' + AND column_name = 'payload_expires_at') + OR (table_name = 'registry_webhook_deliveries' + AND column_name = 'data_schema') + OR (table_name = 'registry_webhook_delivery_state' + AND column_name = 'expired_at')) + ORDER BY table_name, column_name", + &[], + ) + .await + .expect("migration can inspect upgraded internal columns") + .into_iter() + .map(|row| { + ( + row.get::<_, String>(0), + row.get::<_, String>(1), + row.get::<_, String>(2), + ) + }) + .collect::>(); + assert_eq!( + columns, + [ + ( + "registry_outbox".to_owned(), + "payload_expires_at".to_owned(), + "NO".to_owned(), + ), + ( + "registry_webhook_deliveries".to_owned(), + "data_schema".to_owned(), + "NO".to_owned(), + ), + ( + "registry_webhook_delivery_state".to_owned(), + "expired_at".to_owned(), + "YES".to_owned(), + ), + ] + ); + + migration_task.abort(); + database.cleanup().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_postgres_pre_v1_webhook_history_refuses_silent_v1_reinterpretation() { + let database = TestDatabase::create(4).await; + let (migration, migration_task) = database.connect_migration().await; + install_pre_v1_webhook_schema(&migration).await; + let event_id = Uuid::new_v4(); + let legacy_body = br#"{"label":"legacy-values-only"}"#.to_vec(); + migration + .execute( + "INSERT INTO registry_internal.registry_outbox + (event_id, event_type, trigger, entity_id, record_reference, + record_revision, package_revision, schema_fingerprint, payload) + VALUES ($1, 'case-created', 'created', 'case', 'legacy-reference', + 1, $2, $3, $4)", + &[ + &event_id, + &PACKAGE_REVISION, + &SCHEMA_FINGERPRINT, + &legacy_body, + ], + ) + .await + .expect("pre-V1 outbox row installs"); + migration + .execute( + "INSERT INTO registry_internal.registry_webhook_deliveries + (event_id, compiled_delivery_id, logical_destination_id, + destination_binding_digest, package_revision, schema_fingerprint, + classification_ceiling, authentication_profile, delivery_mode, + attempt_timeout_ms, initial_backoff_ms, maximum_backoff_ms, + exponential_backoff_multiplier, maximum_attempts, retry_delays_ms, + maximum_payload_bytes, payload_digest, deployed_attempt_timeout_ms, + deployed_maximum_attempts, dead_letter, operator_replay) + VALUES ($1, 'case-created:webhook', $2, + 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + $3, $4, 'restricted', 'hmac_sha256_v1', 'after_commit', + 5000, 1000, 8000, 2, 5, ARRAY[1000,2000,4000,8000]::bigint[], + 1048576, $5, 5000, 5, 'required', true)", + &[ + &event_id, + &DESTINATION_ID, + &PACKAGE_REVISION, + &SCHEMA_FINGERPRINT, + &Sha256::digest(&legacy_body).to_vec(), + ], + ) + .await + .expect("pre-V1 webhook history installs"); + + assert_eq!( + install_mutation_schema(&migration, &database.runtime_role).await, + Err(MutationError::Unavailable), + "a missing captured data-schema binding is never synthesized" + ); + + migration_task.abort(); + database.cleanup().await; +} + +async fn install_pre_v1_webhook_schema(migration: &tokio_postgres::Client) { + migration + .batch_execute( + "CREATE TABLE registry_internal.registry_outbox ( + outbox_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + event_id uuid NOT NULL UNIQUE, + event_type text NOT NULL, + trigger text NOT NULL, + entity_id text NOT NULL, + record_reference text NOT NULL, + record_revision bigint NOT NULL, + package_revision text NOT NULL, + schema_fingerprint text NOT NULL, + payload bytea NOT NULL CHECK ( + octet_length(payload) > 0 AND octet_length(payload) <= 2097152 + ), + created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + UNIQUE (event_id, package_revision, schema_fingerprint) + ); + CREATE TABLE registry_internal.registry_webhook_deliveries ( + event_id uuid NOT NULL, + compiled_delivery_id text NOT NULL, + logical_destination_id text NOT NULL, + destination_binding_digest text NOT NULL, + package_revision text NOT NULL, + schema_fingerprint text NOT NULL, + classification_ceiling text NOT NULL, + authentication_profile text NOT NULL, + delivery_mode text NOT NULL, + attempt_timeout_ms bigint NOT NULL, + initial_backoff_ms bigint NOT NULL, + maximum_backoff_ms bigint NOT NULL, + exponential_backoff_multiplier smallint NOT NULL, + maximum_attempts smallint NOT NULL, + retry_delays_ms bigint[] NOT NULL, + maximum_payload_bytes bigint NOT NULL, + payload_digest bytea NOT NULL, + deployed_attempt_timeout_ms bigint NOT NULL, + deployed_maximum_attempts smallint NOT NULL, + dead_letter text NOT NULL, + operator_replay boolean NOT NULL, + created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (event_id, compiled_delivery_id), + FOREIGN KEY (event_id, package_revision, schema_fingerprint) + REFERENCES registry_internal.registry_outbox + (event_id, package_revision, schema_fingerprint) + ); + CREATE TABLE registry_internal.registry_webhook_delivery_state ( + event_id uuid NOT NULL, + compiled_delivery_id text NOT NULL, + generation bigint NOT NULL, + state text NOT NULL, + attempt smallint NOT NULL, + next_attempt_at timestamptz, + attempt_started_at timestamptz, + lease_expires_at timestamptz, + lease_token uuid, + delivered_at timestamptz, + dead_lettered_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (event_id, compiled_delivery_id), + FOREIGN KEY (event_id, compiled_delivery_id) + REFERENCES registry_internal.registry_webhook_deliveries + (event_id, compiled_delivery_id) + );", + ) + .await + .expect("pre-V1 webhook internal schema installs"); +} + fn compiled_registry() -> registry_server::CompiledRegistry { let project = parse_project_json( br#"{ @@ -317,7 +601,7 @@ fn compiled_registry() -> registry_server::CompiledRegistry { "kind":"RegistryProject", "registry":{"id":"webhook-outbox-registry","version":"1","defaultLanguage":"en"}, "entities":[{ - "id":"case","route":"cases","mutationMode":"create_only","classification":"restricted", + "id":"case","route":"cases","mutationMode":"mutable","classification":"restricted", "fields":[ {"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}, {"id":"label","type":"string","maxLength":64,"required":true,"classification":"internal"}, @@ -326,7 +610,7 @@ fn compiled_registry() -> registry_server::CompiledRegistry { "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"registry_principal", "requiredPurposes":["case-management"], - "operations":["create","get","list"], + "operations":["create","patch","get","list"], "readableFields":["jurisdiction","label","restricted_note"], "writableFields":["jurisdiction","label","restricted_note"], "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] @@ -334,18 +618,17 @@ fn compiled_registry() -> registry_server::CompiledRegistry { "events":[{ "id":"case-created","trigger":"created","projection":["label","restricted_note"], "webhook":{ - "destinationId":"case-operations", - "classificationCeiling":"restricted", - "authenticationProfile":"hmac_sha256_v1", - "delivery":{ - "attemptTimeoutMs":5000, - "initialBackoffMs":250, - "maximumBackoffMs":2000, - "maximumAttempts":5, - "deadLetter":"required", - "operatorReplay":true - } + "destinationId":"case-operations" } + },{ + "id":"case-label-changed","trigger":"patched","projection":["label"], + "when":{ + "kind":"fields", + "changed":["label"], + "beforeEquals":{"label":"first"}, + "afterEquals":{"restricted_note":"restricted-projection-canary"} + }, + "webhook":{"destinationId":"case-operations"} }] }] }"#, @@ -433,6 +716,37 @@ fn create_request<'a>( } } +fn patch_request<'a>( + plan: &'a MutationPlan, + claims: &'a ClaimContext, + idempotency_key: &'a str, + record_id: &'a str, + expected_etag: &'a str, + label: &str, +) -> MutationRequest<'a> { + MutationRequest { + plan, + idempotency_key, + claims, + record_id: Some(record_id), + expected_etag: Some(expected_etag), + body: MutationBody::Patch(vec![PatchOperation::Replace { + path: "/data/label".to_owned(), + value: json!(label), + }]), + response_fields: BTreeSet::from([ + "jurisdiction".to_owned(), + "label".to_owned(), + "restricted_note".to_owned(), + ]), + } +} + +fn response_etag(outcome: ®istry_server::mutation::MutationOutcome) -> String { + String::from_utf8(outcome.response().headers()[&PermittedResponseHeader::Etag].clone()) + .expect("mutation response etag is UTF-8") +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct DurableCounts { current: i64, @@ -477,6 +791,7 @@ struct CapturedDelivery { destination_binding_digest: String, package_revision: String, schema_fingerprint: String, + data_schema: String, classification_ceiling: String, authentication_profile: String, delivery_mode: String, @@ -492,6 +807,8 @@ struct CapturedDelivery { deployed_maximum_attempts: i16, dead_letter: String, operator_replay: bool, + created_at: SystemTime, + payload_expires_at: SystemTime, } impl From for CapturedDelivery { @@ -504,21 +821,24 @@ impl From for CapturedDelivery { destination_binding_digest: row.get(4), package_revision: row.get(5), schema_fingerprint: row.get(6), - classification_ceiling: row.get(7), - authentication_profile: row.get(8), - delivery_mode: row.get(9), - attempt_timeout_ms: row.get(10), - initial_backoff_ms: row.get(11), - maximum_backoff_ms: row.get(12), - exponential_backoff_multiplier: row.get(13), - maximum_attempts: row.get(14), - retry_delays_ms: row.get(15), - maximum_payload_bytes: row.get(16), - payload_digest: row.get(17), - deployed_attempt_timeout_ms: row.get(18), - deployed_maximum_attempts: row.get(19), - dead_letter: row.get(20), - operator_replay: row.get(21), + data_schema: row.get(7), + classification_ceiling: row.get(8), + authentication_profile: row.get(9), + delivery_mode: row.get(10), + attempt_timeout_ms: row.get(11), + initial_backoff_ms: row.get(12), + maximum_backoff_ms: row.get(13), + exponential_backoff_multiplier: row.get(14), + maximum_attempts: row.get(15), + retry_delays_ms: row.get(16), + maximum_payload_bytes: row.get(17), + payload_digest: row.get(18), + deployed_attempt_timeout_ms: row.get(19), + deployed_maximum_attempts: row.get(20), + dead_letter: row.get(21), + operator_replay: row.get(22), + created_at: row.get(23), + payload_expires_at: row.get(24), } } } @@ -530,7 +850,8 @@ async fn capture(database: &TestDatabase, offset: i64) -> CapturedDelivery { "SELECT outbox.event_id, outbox.payload, delivery.compiled_delivery_id, delivery.logical_destination_id, delivery.destination_binding_digest, delivery.package_revision, - delivery.schema_fingerprint, delivery.classification_ceiling, + delivery.schema_fingerprint, delivery.data_schema, + delivery.classification_ceiling, delivery.authentication_profile, delivery.delivery_mode, delivery.attempt_timeout_ms, delivery.initial_backoff_ms, delivery.maximum_backoff_ms, delivery.exponential_backoff_multiplier, @@ -538,7 +859,8 @@ async fn capture(database: &TestDatabase, offset: i64) -> CapturedDelivery { delivery.maximum_payload_bytes, delivery.payload_digest, delivery.deployed_attempt_timeout_ms, delivery.deployed_maximum_attempts, delivery.dead_letter, - delivery.operator_replay + delivery.operator_replay, outbox.created_at, + outbox.payload_expires_at FROM registry_internal.registry_outbox AS outbox JOIN registry_internal.registry_webhook_deliveries AS delivery ON delivery.event_id = outbox.event_id @@ -562,6 +884,7 @@ fn assert_capture_matches( assert_eq!(actual.destination_binding_digest, binding_digest); assert_eq!(actual.package_revision, identity.package_revision); assert_eq!(actual.schema_fingerprint, identity.schema_fingerprint); + assert_eq!(actual.data_schema, compiled.data_schema); assert_eq!(actual.classification_ceiling, "restricted"); assert_eq!(actual.authentication_profile, "hmac_sha256_v1"); assert_eq!(actual.delivery_mode, "after_commit"); @@ -606,10 +929,29 @@ fn assert_capture_matches( assert_eq!(actual.deployed_maximum_attempts, 4); assert_eq!(actual.dead_letter, "required"); assert_eq!(actual.operator_replay, compiled.operator_replay); + assert_eq!( + actual + .payload_expires_at + .duration_since(actual.created_at) + .expect("payload expiry follows capture"), + Duration::from_secs(7 * 24 * 60 * 60), + "the deployment default is captured from transaction time" + ); } -fn expected_payload() -> Vec { - format!(r#"{{"label":"first","restricted_note":"{RESTRICTED_CANARY}"}}"#).into_bytes() +fn expected_payload(record_id: &str) -> Vec { + canonicalize_json(&json!({ + "entity": "case", + "recordId": record_id, + "revision": 1, + "trigger": "created", + "packageRevision": PACKAGE_REVISION, + "values": { + "label": "first", + "restricted_note": RESTRICTED_CANARY, + }, + })) + .expect("expected event body canonicalizes") } async fn assert_delivery_is_transport_and_value_free( @@ -662,6 +1004,7 @@ async fn assert_delivery_is_transport_and_value_free( "destination_binding_digest", "package_revision", "schema_fingerprint", + "data_schema", "classification_ceiling", "authentication_profile", "delivery_mode", @@ -683,6 +1026,42 @@ async fn assert_delivery_is_transport_and_value_free( } async fn assert_capture_acl_is_insert_and_select_only(database: &TestDatabase) { + let outbox_privileges = database + .admin + .query( + "SELECT privilege_type + FROM information_schema.role_table_grants + WHERE table_schema = 'registry_internal' + AND table_name = 'registry_outbox' + AND grantee = $1 + ORDER BY privilege_type", + &[&database.runtime_role.as_str()], + ) + .await + .expect("administrator can inspect outbox table ACL") + .into_iter() + .map(|row| row.get::<_, String>(0)) + .collect::>(); + assert_eq!(outbox_privileges, ["INSERT", "SELECT"]); + let outbox_update_columns = database + .admin + .query( + "SELECT column_name + FROM information_schema.role_column_grants + WHERE table_schema = 'registry_internal' + AND table_name = 'registry_outbox' + AND grantee = $1 + AND privilege_type = 'UPDATE' + ORDER BY column_name", + &[&database.runtime_role.as_str()], + ) + .await + .expect("administrator can inspect outbox column ACL") + .into_iter() + .map(|row| row.get::<_, String>(0)) + .collect::>(); + assert_eq!(outbox_update_columns, ["payload"]); + let privileges = database .admin .query( @@ -859,6 +1238,7 @@ eventDestinations: dnsFamily: dualStackStrict allowedPrivateCidrs: [] hmacSha256KeyRef: secret:file/{SECRET_REF_CANARY} + classificationCeiling: restricted deliveryCeilings: attemptTimeoutMilliseconds: 4000 maximumAttempts: 4 diff --git a/crates/registry-server/tests/runtime_config.rs b/crates/registry-server/tests/runtime_config.rs index 66aa43533a..859940c958 100644 --- a/crates/registry-server/tests/runtime_config.rs +++ b/crates/registry-server/tests/runtime_config.rs @@ -20,8 +20,8 @@ use registry_server::compiler::{compile_project, CompileProfile}; use registry_server::contract::parse_project_json; use registry_server::event_destination::EventDestinationActivationError; use registry_server::runtime_config::{ - load_runtime_config, load_runtime_config_with_env, parse_runtime_config_with_env, - RuntimeConfigError, TrustedProxyPosture, + load_runtime_config, load_runtime_config_with_env, parse_runtime_config, + parse_runtime_config_with_env, RuntimeConfigError, TrustedProxyPosture, }; use serde_json::{json, Value}; @@ -137,6 +137,7 @@ fn event_destination_binding( dnsFamily: dualStackStrict allowedPrivateCidrs: [] hmacSha256KeyRef: {key_ref} + classificationCeiling: restricted deliveryCeilings: attemptTimeoutMilliseconds: {timeout_ms} maximumAttempts: {maximum_attempts} @@ -148,26 +149,25 @@ fn compiled_webhooks(destinations: &[(&str, u32, u8)]) -> registry_server::Compi let events = destinations .iter() .enumerate() - .map(|(index, (destination_id, timeout_ms, maximum_attempts))| { - json!({ - "id": format!("case-event-{index}"), - "trigger": if index == 0 { "created" } else { "patched" }, - "projection": ["label"], - "webhook": { - "destinationId": destination_id, - "classificationCeiling": "internal", - "authenticationProfile": "hmac_sha256_v1", - "delivery": { - "attemptTimeoutMs": timeout_ms, - "initialBackoffMs": 250, - "maximumBackoffMs": 2000, - "maximumAttempts": maximum_attempts, - "deadLetter": "required", - "operatorReplay": false + .map( + |(index, (destination_id, _timeout_ms, _maximum_attempts))| { + let mut event = json!({ + "id": format!("case-event-{index}"), + "trigger": if index == 0 { "created" } else { "patched" }, + "projection": ["label"], + "webhook": { + "destinationId": destination_id } + }); + if index == 0 { + event["when"] = json!({ + "kind": "fields", + "afterEquals": {"eligibility": "eligible"} + }); } - }) - }) + event + }, + ) .collect::>(); let project = json!({ "apiVersion": "registry.registrystack.org/v1alpha1", @@ -180,7 +180,8 @@ fn compiled_webhooks(destinations: &[(&str, u32, u8)]) -> registry_server::Compi "tombstone": true, "classification": "internal", "fields": [ - {"id": "label", "type": "string", "maxLength": 64, "classification": "internal"} + {"id": "label", "type": "string", "maxLength": 64, "classification": "internal"}, + {"id": "eligibility", "type": "string", "maxLength": 32, "classification": "restricted"} ], "events": events }] @@ -192,11 +193,14 @@ fn compiled_webhooks(destinations: &[(&str, u32, u8)]) -> registry_server::Compi fn event_headers() -> EventDeliveryHeaders<'static> { EventDeliveryHeaders { - event_id: b"event-id", + id: b"event-id", + source: b"urn:registrystack:registry:example:instance:primary", event_type: b"case.created", + time: b"2026-08-30T00:00:00Z", + dataschema: b"urn:registrystack:registry:example:event:case.created:schema:sha256:aaa", generation: b"1", attempt: b"1", - timestamp: b"2026-08-30T00:00:00Z", + delivery_time: b"2026-08-30T00:00:01Z", idempotency_key: b"delivery-key", signature: b"v1=signature", } @@ -239,6 +243,10 @@ fn strict_runtime_file_loads_and_constructs_existing_runtime_inputs() { "registry_migration" ); assert_eq!(config.package().active_sequence(), 1); + assert_eq!( + config.event_delivery().payload_retention(), + Duration::from_secs(7 * 24 * 60 * 60) + ); assert_eq!( config.package().compiler_source_revision(), "source-revision-1" @@ -284,6 +292,35 @@ fn strict_runtime_file_loads_and_constructs_existing_runtime_inputs() { .expect("cursor codec builds from protected file secret"); } +#[test] +fn webhook_payload_retention_is_deployment_selected_and_capped_at_thirty_days() { + let fixture = RuntimeFixture::new(); + let base = valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ); + for days in [1_u8, 30_u8] { + let config = parse_runtime_config(&format!( + "{base}\neventDelivery:\n payloadRetentionDays: {days}\n" + )) + .expect("bounded deployment retention parses"); + assert_eq!( + config.event_delivery().payload_retention(), + Duration::from_secs(u64::from(days) * 24 * 60 * 60) + ); + } + for days in [0_u8, 31_u8] { + assert_eq!( + parse_runtime_config(&format!( + "{base}\neventDelivery:\n payloadRetentionDays: {days}\n" + )) + .err(), + Some(RuntimeConfigError::InvalidBounds) + ); + } +} + #[test] fn local_runtime_does_not_require_or_supply_package_trust_authority() { let fixture = RuntimeFixture::new(); @@ -1354,7 +1391,7 @@ fn activation_requires_exact_compiled_and_runtime_destination_sets() { } #[test] -fn runtime_ceilings_narrow_every_subscription_sharing_a_destination() { +fn runtime_destination_classification_ceiling_cannot_widen_compiled_event_disclosure() { let fixture = RuntimeFixture::new(); fixture.write_secret("event-hmac-key", &[0x41; 32]); let compiled = compiled_webhooks(&[ @@ -1388,29 +1425,30 @@ fn runtime_ceilings_narrow_every_subscription_sharing_a_destination() { Duration::from_millis(2_500) ); - for (timeout_ms, maximum_attempts) in [(3_001, 3), (2_500, 4)] { - let widening = parse_runtime_config_with_env( - &runtime_with_event_destinations( - &fixture, - &event_destination_binding( - "shared-destination", - "https://events.example/", - "/hooks/shared", - "secret:file/event-hmac-key", - timeout_ms, - maximum_attempts, - ), - ), - env_lookup, - ) - .expect("bounded but widening binding parses"); - assert_eq!( - widening - .activate_event_destinations(&compiled) - .expect_err("runtime cannot widen one shared subscription"), - EventDestinationActivationError::DeliveryCeilingWidening - ); - } + let below_compiled_classification = runtime_with_event_destinations( + &fixture, + &event_destination_binding( + "shared-destination", + "https://events.example/", + "/hooks/shared", + "secret:file/event-hmac-key", + 2_500, + 3, + ), + ) + .replace( + "classificationCeiling: restricted", + "classificationCeiling: public", + ); + let below_compiled_classification = + parse_runtime_config_with_env(&below_compiled_classification, env_lookup) + .expect("lower classification ceiling parses"); + assert_eq!( + below_compiled_classification + .activate_event_destinations(&compiled) + .expect_err("runtime destination cannot accept a higher-classified event"), + EventDestinationActivationError::DeliveryCeilingWidening + ); } #[test] diff --git a/crates/registry-server/tests/support/pilot_acceptance_harness.rs b/crates/registry-server/tests/support/pilot_acceptance_harness.rs index 1992e6cbb9..f2f6e6c2ae 100644 --- a/crates/registry-server/tests/support/pilot_acceptance_harness.rs +++ b/crates/registry-server/tests/support/pilot_acceptance_harness.rs @@ -194,11 +194,23 @@ impl PilotHarness { } pub fn token(&self, purpose: &str, row_boundary_claims: &[(&str, Value)]) -> String { + self.token_with_scopes(purpose, &[], row_boundary_claims) + } + + pub fn token_with_scopes( + &self, + purpose: &str, + scopes: &[&str], + row_boundary_claims: &[(&str, Value)], + ) -> String { let mut claims = json!({ "aud": AUDIENCE, "registry_principal": "pilot-operator", "purpose": purpose, }); + if !scopes.is_empty() { + claims["scope"] = Value::String(scopes.join(" ")); + } for (name, value) in row_boundary_claims { claims[*name] = value.clone(); } diff --git a/crates/registry-serverctl/Cargo.toml b/crates/registry-serverctl/Cargo.toml index 724662e236..e3adee6203 100644 --- a/crates/registry-serverctl/Cargo.toml +++ b/crates/registry-serverctl/Cargo.toml @@ -30,6 +30,7 @@ serde_norway.workspace = true sha2.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["rt"] } +uuid.workspace = true zeroize.workspace = true [dev-dependencies] diff --git a/crates/registry-serverctl/README.md b/crates/registry-serverctl/README.md index de17f80ea5..5fd4ba7b82 100644 --- a/crates/registry-serverctl/README.md +++ b/crates/registry-serverctl/README.md @@ -37,6 +37,26 @@ compiler's deterministic event-delivery inventory. It contains logical destination identifiers and governed delivery policy, never deployed URLs, secret references, or secret values. +`registry-serverctl webhook sample PROJECT --event ID` compiles the authoring +project and renders a deterministic CloudEvents HTTP request with typed +synthetic projection values. The request target and signature are explicit +placeholders because this offline command does not load deployment +configuration or secrets. + +`registry-serverctl webhook list --runtime-config ABSOLUTE_FILE [--limit N]` +verifies the current package and database identity before returning bounded +pending, dead-lettered, and expired metadata. The default limit is 50 and the +product maximum is 100. Results contain delivery identity, state, attempt, +payload eligibility, and expiry only. They never contain projected values, +record identifiers, destination URLs, secret references, or keys. + +`registry-serverctl webhook replay --runtime-config ABSOLUTE_FILE --event-id +UUID --delivery-id ID --expected-generation N` delegates one optimistic replay +to Registry Server. Replay is limited to a current, replay-enabled dead letter +whose retained payload and exact destination binding are still available. +Configuration, identity, generation, eligibility, and retention refusals share +one value-free diagnostic. + `registry-serverctl doctor --runtime-config ABSOLUTE_FILE` verifies the startup dependencies opened by the current preparation path without binding a listener. It does not claim listener activation, webhook worker readiness, or diff --git a/crates/registry-serverctl/src/apply_lifecycle.rs b/crates/registry-serverctl/src/apply_lifecycle.rs index 3262a9c6ee..c641ee2978 100644 --- a/crates/registry-serverctl/src/apply_lifecycle.rs +++ b/crates/registry-serverctl/src/apply_lifecycle.rs @@ -20,6 +20,7 @@ pub(crate) enum ApplyLifecycleError { TargetPackagePath, CurrentPackage(PackageError), TargetPackage(PackageError), + EventDestinations, DatabaseConfiguration, TimeoutConfiguration, BackupArgument, @@ -97,6 +98,10 @@ pub(crate) fn run( { return Err(ApplyLifecycleError::TargetPackage(PackageError::Binding)); } + let activated_event_destinations = config + .activate_event_destinations(target.registry()) + .map_err(|_| ApplyLifecycleError::EventDestinations)?; + let event_destination_compatibility = activated_event_destinations.compatibility_inventory(); let connection = config .migration_database_connection_config() @@ -127,7 +132,8 @@ pub(crate) fn run( ), timeouts, ) - .with_destructive_backup_evidence(&backup_evidence); + .with_destructive_backup_evidence(&backup_evidence) + .with_event_destination_compatibility_inventory(&event_destination_compatibility); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() diff --git a/crates/registry-serverctl/src/lib.rs b/crates/registry-serverctl/src/lib.rs index 1d314d7ba2..a2807479a0 100644 --- a/crates/registry-serverctl/src/lib.rs +++ b/crates/registry-serverctl/src/lib.rs @@ -37,6 +37,7 @@ mod doctor; mod package_inspection; mod package_lifecycle; mod test_lifecycle; +mod webhook_lifecycle; use apply_lifecycle::{ApplyLifecycleError, ApplyLifecycleRequest}; use data_lifecycle::{ @@ -46,6 +47,9 @@ use package_inspection::{inspect_runtime_package, RuntimePackageInspectionError} use package_lifecycle::{PackageLifecycleError, PackageLifecycleState}; use registry_server::data::DataError; use test_lifecycle::{TestLifecycleError, TestLifecycleRequest}; +use webhook_lifecycle::{ + WebhookLifecycleError, WebhookListOutcome, WebhookReplayOutcome, WebhookSampleOutcome, +}; const DOMAIN_REFUSAL_EXIT: u8 = 1; const USAGE_EXIT: u8 = 2; @@ -98,6 +102,8 @@ enum Command { Migration(MigrationArgs), /// Validate, import, or export data through authenticated Registry HTTP APIs. Data(DataArgs), + /// Inspect and operate configured webhook deliveries. + Webhook(WebhookArgs), } #[derive(Debug, Args)] @@ -264,6 +270,63 @@ struct DataArgs { command: DataCommand, } +#[derive(Debug, Args)] +struct WebhookArgs { + #[command(subcommand)] + command: WebhookCommand, +} + +#[derive(Debug, Subcommand)] +enum WebhookCommand { + /// Render one deterministic exact CloudEvents request with synthetic values. + Sample(WebhookSampleArgs), + /// List bounded value-free pending, dead-lettered, and expired delivery metadata. + List(WebhookListArgs), + /// Replay one eligible retained dead-letter using optimistic generation binding. + Replay(WebhookReplayArgs), +} + +#[derive(Debug, Args)] +struct WebhookSampleArgs { + /// Registry Server authoring project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, + + /// Stable authored event identifier. + #[arg(long, value_name = "ID")] + event: String, +} + +#[derive(Debug, Args)] +struct WebhookListArgs { + /// Absolute Registry Server runtime configuration file. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Maximum number of value-free delivery rows to return. + #[arg(long, value_name = "COUNT", default_value_t = 50)] + limit: u16, +} + +#[derive(Debug, Args)] +struct WebhookReplayArgs { + /// Absolute Registry Server runtime configuration file. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Stable event UUID shown by `webhook list`. + #[arg(long, value_name = "UUID")] + event_id: String, + + /// Compiled delivery identifier shown by `webhook list`. + #[arg(long, value_name = "ID")] + delivery_id: String, + + /// Current generation shown by `webhook list`. + #[arg(long, value_name = "NUMBER")] + expected_generation: i64, +} + #[derive(Debug, Subcommand)] enum DataCommand { /// Validate a JSONL import file against one closed package plan. @@ -542,6 +605,8 @@ enum DiagnosticArtifact { DataOperation, DataCheckpoint, DataTransport, + WebhookSample, + WebhookOperations, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] @@ -578,6 +643,8 @@ enum SuggestedAction { CorrectDataInput, VerifyDataCheckpoint, VerifyDataTransport, + SelectWebhookEvent, + VerifyWebhookOperation, } #[derive(Serialize)] @@ -702,6 +769,33 @@ struct DataExportSuccessReport { complete: bool, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct WebhookSampleSuccessReport { + ok: bool, + command: &'static str, + #[serde(flatten)] + outcome: WebhookSampleOutcome, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct WebhookListSuccessReport { + ok: bool, + command: &'static str, + #[serde(flatten)] + outcome: WebhookListOutcome, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct WebhookReplaySuccessReport { + ok: bool, + command: &'static str, + #[serde(flatten)] + outcome: WebhookReplayOutcome, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] enum ApplyActivation { @@ -984,6 +1078,22 @@ where }; } }, + Command::Webhook(args) => { + return match args.command { + WebhookCommand::Sample(args) => match webhook_sample(&args) { + Ok(report) => write_webhook_sample_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }, + WebhookCommand::List(args) => match webhook_list(&args) { + Ok(report) => write_webhook_list_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }, + WebhookCommand::Replay(args) => match webhook_replay(&args) { + Ok(report) => write_webhook_replay_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + }, + }; + } }; match result { @@ -992,6 +1102,77 @@ where } } +fn webhook_sample(args: &WebhookSampleArgs) -> Result { + let compiled = compile(&args.project, ProfileArg::Authoring, "webhook sample")?; + let outcome = webhook_lifecycle::sample(&compiled, &args.event) + .map_err(|error| webhook_lifecycle_failure("webhook sample", error))?; + Ok(WebhookSampleSuccessReport { + ok: true, + command: "webhook sample", + outcome, + }) +} + +fn webhook_list(args: &WebhookListArgs) -> Result { + let outcome = webhook_lifecycle::list(&args.runtime_config, args.limit) + .map_err(|error| webhook_lifecycle_failure("webhook list", error))?; + Ok(WebhookListSuccessReport { + ok: true, + command: "webhook list", + outcome, + }) +} + +fn webhook_replay(args: &WebhookReplayArgs) -> Result { + let outcome = webhook_lifecycle::replay( + &args.runtime_config, + &args.event_id, + &args.delivery_id, + args.expected_generation, + ) + .map_err(|error| webhook_lifecycle_failure("webhook replay", error))?; + Ok(WebhookReplaySuccessReport { + ok: true, + command: "webhook replay", + outcome, + }) +} + +fn webhook_lifecycle_failure(command: &'static str, error: WebhookLifecycleError) -> FailureReport { + let (code, path, message, artifact, action) = match error { + WebhookLifecycleError::Event => ( + "webhook.sample.event_refused", + "event", + "the selected webhook event is unavailable", + DiagnosticArtifact::WebhookSample, + SuggestedAction::SelectWebhookEvent, + ), + WebhookLifecycleError::Sample => ( + "webhook.sample.render_refused", + "sample", + "the webhook sample could not be rendered", + DiagnosticArtifact::WebhookSample, + SuggestedAction::SelectWebhookEvent, + ), + WebhookLifecycleError::Operator => ( + "webhook.operation.refused", + "webhook", + "the webhook operation was refused", + DiagnosticArtifact::WebhookOperations, + SuggestedAction::VerifyWebhookOperation, + ), + }; + FailureReport { + ok: false, + command, + diagnostics: vec![tool_diagnostic( + diagnostic(code, path, message), + artifact, + action, + )], + } +} + fn data_validate(args: &DataValidateArgs) -> Result { let outcome = data_lifecycle::validate_import(DataValidateRequest { package: &args.package, @@ -1640,6 +1821,13 @@ fn apply_lifecycle_failure(error: ApplyLifecycleError) -> FailureReport { action, ) } + ApplyLifecycleError::EventDestinations => ( + "apply.event_destinations.refused", + "eventDestinations", + "the event destination bindings were refused", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), ApplyLifecycleError::DatabaseConfiguration | ApplyLifecycleError::TimeoutConfiguration => ( "apply.database_configuration.refused", "database", @@ -3204,6 +3392,76 @@ fn write_data_export_success( write_result(result, stderr) } +fn write_webhook_sample_success( + report: &WebhookSampleSuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "webhook sample succeeded").and_then(|()| { + writeln!(stdout, "event: {}", report.outcome.event_id)?; + writeln!( + stdout, + "{} {} HTTP/1.1", + report.outcome.request.method, report.outcome.request.request_target + )?; + for (name, value) in &report.outcome.request.headers { + writeln!(stdout, "{name}: {value}")?; + } + writeln!(stdout)?; + writeln!(stdout, "{}", report.outcome.request.canonical_body) + }) + }; + write_result(result, stderr) +} + +fn write_webhook_list_success( + report: &WebhookListSuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "webhook list succeeded").and_then(|()| { + for delivery in &report.outcome.deliveries { + let rendered = serde_json::to_string(delivery).map_err(io::Error::other)?; + writeln!(stdout, "delivery: {rendered}")?; + } + Ok(()) + }) + }; + write_result(result, stderr) +} + +fn write_webhook_replay_success( + report: &WebhookReplaySuccessReport, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let result = if format == OutputFormat::Json { + serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)) + } else { + writeln!(stdout, "webhook replay succeeded").and_then(|()| { + writeln!(stdout, "event id: {}", report.outcome.event_id)?; + writeln!(stdout, "delivery id: {}", report.outcome.delivery_id)?; + writeln!(stdout, "generation: {}", report.outcome.generation) + }) + }; + write_result(result, stderr) +} + fn data_operation_name(operation: DataOperationArg) -> &'static str { match operation { DataOperationArg::Create => "create", @@ -3483,7 +3741,8 @@ mod tests { "doctor", "verify", "migration", - "data" + "data", + "webhook" ] ); } diff --git a/crates/registry-serverctl/src/webhook_lifecycle.rs b/crates/registry-serverctl/src/webhook_lifecycle.rs new file mode 100644 index 0000000000..56763f0bb1 --- /dev/null +++ b/crates/registry-serverctl/src/webhook_lifecycle.rs @@ -0,0 +1,564 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Webhook developer and operator workflows. +//! +//! This module renders offline examples from compiled authority and delegates +//! live inspection and replay to Registry Server. It deliberately owns no SQL, +//! signature construction, retry policy, or replay semantics. + +use std::collections::BTreeMap; +use std::path::Path; + +use registry_platform_canonical_json::canonicalize_json; +use registry_server::contract::{Crs84BboxSource, EventTrigger, FieldTypeSource}; +use registry_server::model::CompiledRegistry; +use registry_server::webhook::{ + WebhookDeliveryStatus, WebhookDeliveryStatusKind, WebhookOperatorService, + MAX_WEBHOOK_STATUS_RESULTS, +}; +use serde::Serialize; +use serde_json::{json, Map, Number, Value}; +use uuid::Uuid; + +const SAMPLE_EVENT_ID: &str = "00000000-0000-4000-8000-000000000001"; +const SAMPLE_RECORD_ID: &str = "00000000-0000-4000-8000-000000000002"; +const SAMPLE_TIME: &str = "2026-01-01T00:00:00Z"; +const SAMPLE_PACKAGE_REVISION: &str = + "sha256:0000000000000000000000000000000000000000000000000000000000000000"; +const SAMPLE_IDEMPOTENCY_KEY: &str = + "sha256:0000000000000000000000000000000000000000000000000000000000000000"; +const SAMPLE_REQUEST_TARGET: &str = ""; +const SAMPLE_SIGNATURE: &str = "v1="; +const MAX_SCHEMA_SYNTHESIS_DEPTH: usize = 16; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WebhookLifecycleError { + Event, + Sample, + Operator, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WebhookSampleOutcome { + pub event_id: String, + pub request: WebhookSampleRequest, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WebhookSampleRequest { + pub method: &'static str, + pub request_target: &'static str, + pub headers: BTreeMap, + pub body: Value, + pub canonical_body: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WebhookListOutcome { + pub deliveries: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WebhookListItem { + pub event_id: String, + pub delivery_id: String, + pub generation: i64, + pub state: &'static str, + pub attempt: i16, + pub payload_available: bool, + pub payload_expires_at: String, + pub replay_eligible: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WebhookReplayOutcome { + pub event_id: String, + pub delivery_id: String, + pub generation: i64, +} + +pub(crate) fn sample( + registry: &CompiledRegistry, + authored_event_id: &str, +) -> Result { + let delivery = registry + .event_deliveries() + .deliveries + .iter() + .find(|delivery| delivery.event_id == authored_event_id) + .ok_or(WebhookLifecycleError::Event)?; + let entity = registry + .entities() + .get(&delivery.entity_id) + .ok_or(WebhookLifecycleError::Sample)?; + let mut values = Map::new(); + for field_id in &delivery.projection_fields { + let field = entity + .fields + .get(field_id) + .ok_or(WebhookLifecycleError::Sample)?; + let value = synthetic_field_value(&field.field_type)?; + values.insert(field_id.clone(), value); + } + let body = json!({ + "entity": delivery.entity_id, + "recordId": SAMPLE_RECORD_ID, + "revision": 1, + "trigger": trigger_name(delivery.trigger), + "packageRevision": SAMPLE_PACKAGE_REVISION, + "values": values, + }); + let canonical_body_bytes = + canonicalize_json(&body).map_err(|_| WebhookLifecycleError::Sample)?; + let canonical_body = + String::from_utf8(canonical_body_bytes).map_err(|_| WebhookLifecycleError::Sample)?; + let headers = BTreeMap::from([ + ("Accept".to_owned(), "application/json".to_owned()), + ("Content-Type".to_owned(), "application/json".to_owned()), + ( + "Idempotency-Key".to_owned(), + SAMPLE_IDEMPOTENCY_KEY.to_owned(), + ), + ("X-Registry-Delivery-Attempt".to_owned(), "1".to_owned()), + ( + "X-Registry-Delivery-Time".to_owned(), + SAMPLE_TIME.to_owned(), + ), + ("X-Registry-Event-Generation".to_owned(), "1".to_owned()), + ( + "X-Registry-Signature".to_owned(), + SAMPLE_SIGNATURE.to_owned(), + ), + ("ce-dataschema".to_owned(), delivery.data_schema.clone()), + ("ce-id".to_owned(), SAMPLE_EVENT_ID.to_owned()), + ( + "ce-source".to_owned(), + format!( + "urn:registrystack:registry:{}:instance:", + registry.registry_id() + ), + ), + ("ce-specversion".to_owned(), "1.0".to_owned()), + ("ce-time".to_owned(), SAMPLE_TIME.to_owned()), + ("ce-type".to_owned(), delivery.event_id.clone()), + ]); + Ok(WebhookSampleOutcome { + event_id: delivery.event_id.clone(), + request: WebhookSampleRequest { + method: "POST", + request_target: SAMPLE_REQUEST_TARGET, + headers, + body, + canonical_body, + }, + }) +} + +pub(crate) fn list( + runtime_config: &Path, + limit: u16, +) -> Result { + let runtime = operator_runtime()?; + list_with(runtime_config, limit, |runtime_config, limit| { + runtime.block_on(async { + let service = WebhookOperatorService::from_runtime_config(runtime_config).await?; + service.list(limit).await + }) + }) +} + +fn list_with( + runtime_config: &Path, + limit: u16, + operation: impl FnOnce(&Path, u16) -> Result, E>, +) -> Result { + if !runtime_config.is_absolute() || limit == 0 || limit > MAX_WEBHOOK_STATUS_RESULTS { + return Err(WebhookLifecycleError::Operator); + } + let deliveries = + operation(runtime_config, limit).map_err(|_| WebhookLifecycleError::Operator)?; + Ok(WebhookListOutcome { + deliveries: deliveries.into_iter().map(list_item).collect(), + }) +} + +pub(crate) fn replay( + runtime_config: &Path, + event_id: &str, + delivery_id: &str, + expected_generation: i64, +) -> Result { + let runtime = operator_runtime()?; + replay_with( + runtime_config, + event_id, + delivery_id, + expected_generation, + |runtime_config, event_id, delivery_id, expected_generation| { + runtime.block_on(async { + let service = WebhookOperatorService::from_runtime_config(runtime_config).await?; + service + .replay(event_id, delivery_id, expected_generation) + .await + }) + }, + ) +} + +fn replay_with( + runtime_config: &Path, + event_id: &str, + delivery_id: &str, + expected_generation: i64, + operation: impl FnOnce(&Path, Uuid, &str, i64) -> Result, +) -> Result { + if !runtime_config.is_absolute() + || delivery_id.is_empty() + || delivery_id.len() > 256 + || expected_generation <= 0 + { + return Err(WebhookLifecycleError::Operator); + } + let event_id = Uuid::parse_str(event_id).map_err(|_| WebhookLifecycleError::Operator)?; + let generation = operation(runtime_config, event_id, delivery_id, expected_generation) + .map_err(|_| WebhookLifecycleError::Operator)?; + Ok(WebhookReplayOutcome { + event_id: event_id.to_string(), + delivery_id: delivery_id.to_owned(), + generation, + }) +} + +fn operator_runtime() -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| WebhookLifecycleError::Operator) +} + +fn list_item(status: WebhookDeliveryStatus) -> WebhookListItem { + let (state, replay_eligible) = match status.state { + WebhookDeliveryStatusKind::Pending => ("pending", false), + WebhookDeliveryStatusKind::DeadLettered => ("dead_lettered", status.payload_available), + WebhookDeliveryStatusKind::Expired => ("expired", false), + }; + WebhookListItem { + event_id: status.event_id.to_string(), + delivery_id: status.compiled_delivery_id, + generation: status.generation, + state, + attempt: status.attempt, + payload_available: status.payload_available, + payload_expires_at: status.payload_expires_at, + replay_eligible, + } +} + +fn trigger_name(trigger: EventTrigger) -> &'static str { + match trigger { + EventTrigger::Created => "created", + EventTrigger::Patched => "patched", + EventTrigger::Tombstoned => "tombstoned", + } +} + +fn synthetic_field_value(field_type: &FieldTypeSource) -> Result { + match field_type { + FieldTypeSource::Boolean => Ok(Value::Bool(true)), + FieldTypeSource::String { + min_length, + max_length, + } => { + let length = + usize::try_from((*min_length).max(1)).map_err(|_| WebhookLifecycleError::Sample)?; + let length_u32 = u32::try_from(length).map_err(|_| WebhookLifecycleError::Sample)?; + if *max_length == 0 || length_u32 > *max_length { + return Err(WebhookLifecycleError::Sample); + } + Ok(Value::String("x".repeat(length))) + } + FieldTypeSource::Text { max_length } => { + if *max_length == 0 { + Ok(Value::String(String::new())) + } else { + Ok(Value::String( + "example".chars().take(*max_length as usize).collect(), + )) + } + } + FieldTypeSource::Int64 => Ok(json!(1)), + FieldTypeSource::Decimal { + scale, + minimum, + maximum, + .. + } => { + let zero = if *scale == 0 { + "0".to_owned() + } else { + format!("0.{}", "0".repeat(usize::from(*scale))) + }; + let value = minimum.as_ref().map_or_else( + || { + maximum + .as_ref() + .filter(|value| value.starts_with('-')) + .cloned() + .unwrap_or(zero) + }, + Clone::clone, + ); + Ok(Value::String(value)) + } + FieldTypeSource::Date => Ok(Value::String("2026-01-01".to_owned())), + FieldTypeSource::Timestamp => Ok(Value::String(SAMPLE_TIME.to_owned())), + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => { + Ok(Value::String(SAMPLE_RECORD_ID.to_owned())) + } + FieldTypeSource::VocabularyCode { values, .. } => values + .first() + .cloned() + .map(Value::String) + .ok_or(WebhookLifecycleError::Sample), + FieldTypeSource::Crs84Point { bbox, .. } => { + let (longitude, latitude) = point_in_bbox(bbox.as_ref())?; + Ok(json!({"type": "Point", "coordinates": [longitude, latitude]})) + } + FieldTypeSource::Structured { schema, .. } => synthesize_schema(schema, schema, 0), + } +} + +fn point_in_bbox( + bbox: Option<&Crs84BboxSource>, +) -> Result<(Number, Number), WebhookLifecycleError> { + let coordinate = |minimum: Option<&str>, maximum: Option<&str>| { + let minimum = minimum.and_then(|value| value.parse::().ok()); + let maximum = maximum.and_then(|value| value.parse::().ok()); + let value = match (minimum, maximum) { + (Some(minimum), Some(maximum)) if minimum <= 0.0 && maximum >= 0.0 => 0.0, + (Some(minimum), Some(_)) => minimum, + _ => 0.0, + }; + Number::from_f64(value).ok_or(WebhookLifecycleError::Sample) + }; + let longitude = coordinate( + bbox.map(|bbox| bbox.west.as_str()), + bbox.map(|bbox| bbox.east.as_str()), + )?; + let latitude = coordinate( + bbox.map(|bbox| bbox.south.as_str()), + bbox.map(|bbox| bbox.north.as_str()), + )?; + Ok((longitude, latitude)) +} + +fn synthesize_schema( + root: &Value, + schema: &Value, + depth: usize, +) -> Result { + if depth > MAX_SCHEMA_SYNTHESIS_DEPTH { + return Err(WebhookLifecycleError::Sample); + } + let object = schema.as_object().ok_or(WebhookLifecycleError::Sample)?; + for key in ["const", "default"] { + if let Some(value) = object.get(key) { + return Ok(value.clone()); + } + } + if let Some(value) = object + .get("examples") + .and_then(Value::as_array) + .and_then(|values| values.first()) + { + return Ok(value.clone()); + } + if let Some(value) = object + .get("enum") + .and_then(Value::as_array) + .and_then(|values| values.first()) + { + return Ok(value.clone()); + } + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + let pointer = reference + .strip_prefix('#') + .ok_or(WebhookLifecycleError::Sample)?; + let referred = root.pointer(pointer).ok_or(WebhookLifecycleError::Sample)?; + return synthesize_schema(root, referred, depth + 1); + } + for keyword in ["oneOf", "anyOf"] { + if let Some(first) = object + .get(keyword) + .and_then(Value::as_array) + .and_then(|values| values.first()) + { + return synthesize_schema(root, first, depth + 1); + } + } + let schema_type = match object.get("type") { + Some(Value::String(value)) => value.as_str(), + Some(Value::Array(values)) => values + .iter() + .filter_map(Value::as_str) + .find(|value| *value != "null") + .unwrap_or("null"), + None if object.contains_key("properties") => "object", + None => return Ok(Value::Object(Map::new())), + _ => return Err(WebhookLifecycleError::Sample), + }; + match schema_type { + "null" => Ok(Value::Null), + "boolean" => Ok(Value::Bool(true)), + "integer" => Ok(object + .get("minimum") + .and_then(Value::as_i64) + .map_or_else(|| json!(1), Value::from)), + "number" => Ok(object.get("minimum").cloned().unwrap_or_else(|| json!(1))), + "string" => { + let value = match object.get("format").and_then(Value::as_str) { + Some("date") => "2026-01-01".to_owned(), + Some("date-time") => SAMPLE_TIME.to_owned(), + Some("uuid") => SAMPLE_RECORD_ID.to_owned(), + _ => { + let length = object + .get("minLength") + .and_then(Value::as_u64) + .unwrap_or(1) + .max(1); + "x".repeat(usize::try_from(length).map_err(|_| WebhookLifecycleError::Sample)?) + } + }; + Ok(Value::String(value)) + } + "array" => { + let minimum = object.get("minItems").and_then(Value::as_u64).unwrap_or(0); + let prefixes = object.get("prefixItems").and_then(Value::as_array); + let item_schema = object.get("items"); + let mut result = Vec::new(); + for index in 0..minimum { + let schema = prefixes + .and_then(|values| { + usize::try_from(index) + .ok() + .and_then(|index| values.get(index)) + }) + .or(item_schema) + .ok_or(WebhookLifecycleError::Sample)?; + result.push(synthesize_schema(root, schema, depth + 1)?); + } + Ok(Value::Array(result)) + } + "object" => { + let properties = object.get("properties").and_then(Value::as_object); + let required = object + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten(); + let mut result = Map::new(); + for field in required { + let field = field.as_str().ok_or(WebhookLifecycleError::Sample)?; + let field_schema = properties + .and_then(|properties| properties.get(field)) + .ok_or(WebhookLifecycleError::Sample)?; + result.insert( + field.to_owned(), + synthesize_schema(root, field_schema, depth + 1)?, + ); + } + Ok(Value::Object(result)) + } + _ => Err(WebhookLifecycleError::Sample), + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + + #[test] + fn structured_sample_synthesis_is_deterministic_and_uses_required_typed_properties() { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "active": {"type": "boolean"}, + "code": {"type": "string", "minLength": 3}, + "ignored": {"type": "string"} + }, + "required": ["active", "code"] + }); + + assert_eq!( + synthesize_schema(&schema, &schema, 0).expect("sample synthesizes"), + json!({"active": true, "code": "xxx"}) + ); + } + + #[test] + fn sample_placeholders_never_claim_a_deployed_target_or_secret() { + assert!(SAMPLE_REQUEST_TARGET.starts_with('<')); + assert_eq!(SAMPLE_SIGNATURE, "v1="); + assert!(!SAMPLE_SIGNATURE.contains("secret")); + } + + #[test] + fn operator_list_delegates_bounded_arguments_and_returns_only_value_free_status() { + let called = Cell::new(false); + let event_id = Uuid::parse_str(SAMPLE_EVENT_ID).expect("sample event UUID parses"); + let outcome = list_with( + Path::new("/operator/runtime.yaml"), + 17, + |runtime_config, limit| { + called.set(true); + assert_eq!(runtime_config, Path::new("/operator/runtime.yaml")); + assert_eq!(limit, 17); + Ok::<_, ()>(vec![WebhookDeliveryStatus { + event_id, + compiled_delivery_id: "record.record-created-v1.webhook".to_owned(), + generation: 2, + state: WebhookDeliveryStatusKind::DeadLettered, + attempt: 3, + payload_available: true, + payload_expires_at: "2026-01-02T00:00:00Z".to_owned(), + }]) + }, + ) + .expect("delegated list succeeds"); + + assert!(called.get()); + assert_eq!(outcome.deliveries.len(), 1); + assert_eq!(outcome.deliveries[0].state, "dead_lettered"); + assert!(outcome.deliveries[0].replay_eligible); + } + + #[test] + fn operator_replay_delegates_the_exact_optimistic_identity() { + let called = Cell::new(false); + let outcome = replay_with( + Path::new("/operator/runtime.yaml"), + SAMPLE_EVENT_ID, + "record.record-created-v1.webhook", + 7, + |runtime_config, event_id, delivery_id, expected_generation| { + called.set(true); + assert_eq!(runtime_config, Path::new("/operator/runtime.yaml")); + assert_eq!(event_id.to_string(), SAMPLE_EVENT_ID); + assert_eq!(delivery_id, "record.record-created-v1.webhook"); + assert_eq!(expected_generation, 7); + Ok::<_, ()>(8) + }, + ) + .expect("delegated replay succeeds"); + + assert!(called.get()); + assert_eq!(outcome.generation, 8); + } +} diff --git a/crates/registry-serverctl/tests/cli.rs b/crates/registry-serverctl/tests/cli.rs index e91ac6ccc0..f716454ebb 100644 --- a/crates/registry-serverctl/tests/cli.rs +++ b/crates/registry-serverctl/tests/cli.rs @@ -931,15 +931,6 @@ entities: projection: [label] webhook: destinationId: case-operations - classificationCeiling: public - authenticationProfile: hmac_sha256_v1 - delivery: - attemptTimeoutMs: 5000 - initialBackoffMs: 250 - maximumBackoffMs: 2000 - maximumAttempts: 5 - deadLetter: required - operatorReplay: false "#, ); let arguments = [ @@ -977,6 +968,9 @@ entities: "attemptTimeoutMs", "authenticationProfile", "classificationCeiling", + "dataSchema", + "dataSchemaArtifactPath", + "dataSchemaFingerprint", "deadLetter", "deliveryMode", "destinationId", @@ -991,6 +985,7 @@ entities: "operatorReplay", "projectionFields", "retryDelaysMs", + "retryProfile", "trigger", ]) ); @@ -2335,14 +2330,11 @@ fn lifecycle_parser_surfaces_are_exact_and_value_free() { let help = registry_serverctl(&["--help"]); assert!(help.status.success()); let rendered = String::from_utf8(help.stdout).expect("top-level help is UTF-8"); - for available in ["package", "apply", "verify", "migration", "data"] { + for available in ["package", "apply", "verify", "migration", "data", "webhook"] { assert!(rendered .lines() .any(|line| line.trim_start().starts_with(available))); } - assert!(!rendered - .lines() - .any(|line| line.trim_start().starts_with("webhook"))); for arguments in [ vec!["verify", "--help"], diff --git a/crates/registry-serverctl/tests/webhook.rs b/crates/registry-serverctl/tests/webhook.rs new file mode 100644 index 0000000000..aa182b623d --- /dev/null +++ b/crates/registry-serverctl/tests/webhook.rs @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use registry_platform_canonical_json::canonicalize_json; +use serde_json::Value; + +static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0); +const EVENT_ID: &str = "record-created-v1"; +const EVENT_VALUE_CANARY: &str = "webhook-event-value-canary"; +const PATH_VALUE_CANARY: &str = "webhook-runtime-path-value-canary"; + +struct TestProject { + root: PathBuf, +} + +impl TestProject { + fn create() -> Self { + let root = std::env::current_dir() + .expect("current directory is available") + .join(format!( + "registry-serverctl-webhook-test-{}-{}", + std::process::id(), + TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).expect("test project directory creates"); + fs::write( + root.join("registry.yaml"), + br#"apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: webhook-sample + version: 1 + defaultLanguage: en +entities: + - id: record + route: records + mutationMode: mutable + fields: + - id: active + type: boolean + classification: internal + - id: count + type: int64 + classification: internal + - id: observed-at + type: timestamp + classification: internal + - id: status + type: vocabulary-code + vocabulary: record-status + values: [ready, closed] + classification: internal + events: + - id: record-created-v1 + trigger: created + projection: [active, count, observed-at, status] + webhook: + destinationId: sample-receiver +"#, + ) + .expect("test project writes"); + Self { root } + } + + fn path(&self) -> &Path { + &self.root + } +} + +impl Drop for TestProject { + fn drop(&mut self) { + if self.root.exists() { + fs::remove_dir_all(&self.root).expect("test project directory removes"); + } + } +} + +fn run(arguments: I) -> (u8, String, String) +where + I: IntoIterator, + T: Into + Clone, +{ + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let status = registry_serverctl::run_from(arguments, &mut stdout, &mut stderr); + ( + if status == std::process::ExitCode::SUCCESS { + 0 + } else { + 1 + }, + String::from_utf8(stdout).expect("stdout is UTF-8"), + String::from_utf8(stderr).expect("stderr is UTF-8"), + ) +} + +#[test] +fn sample_is_an_exact_deterministic_cloudevents_request_without_deployment_authority() { + let project = TestProject::create(); + let (status, first, stderr) = run([ + OsStr::new("registry-serverctl"), + OsStr::new("--format"), + OsStr::new("json"), + OsStr::new("webhook"), + OsStr::new("sample"), + project.path().as_os_str(), + OsStr::new("--event"), + OsStr::new(EVENT_ID), + ]); + let (second_status, second, second_stderr) = run([ + OsStr::new("registry-serverctl"), + OsStr::new("--format"), + OsStr::new("json"), + OsStr::new("webhook"), + OsStr::new("sample"), + project.path().as_os_str(), + OsStr::new("--event"), + OsStr::new(EVENT_ID), + ]); + + assert_eq!((status, second_status), (0, 0)); + assert!(stderr.is_empty()); + assert!(second_stderr.is_empty()); + assert_eq!(first, second); + let report: Value = serde_json::from_str(&first).expect("sample report is JSON"); + assert_eq!(report["ok"], true); + assert_eq!(report["command"], "webhook sample"); + assert_eq!(report["eventId"], EVENT_ID); + assert_eq!(report["request"]["method"], "POST"); + assert_eq!( + report["request"]["requestTarget"], + "" + ); + let headers = report["request"]["headers"] + .as_object() + .expect("headers are an object"); + assert_eq!( + headers.keys().map(String::as_str).collect::>(), + BTreeSet::from([ + "Accept", + "Content-Type", + "Idempotency-Key", + "X-Registry-Delivery-Attempt", + "X-Registry-Delivery-Time", + "X-Registry-Event-Generation", + "X-Registry-Signature", + "ce-dataschema", + "ce-id", + "ce-source", + "ce-specversion", + "ce-time", + "ce-type", + ]) + ); + assert_eq!(headers["ce-specversion"], "1.0"); + assert_eq!(headers["ce-type"], EVENT_ID); + assert_eq!( + headers["ce-source"], + "urn:registrystack:registry:webhook-sample:instance:" + ); + assert_eq!(headers["X-Registry-Signature"], "v1="); + assert!(headers["ce-dataschema"] + .as_str() + .expect("data schema is text") + .starts_with( + "urn:registry-server:event-schema:webhook-sample:record:record-created-v1:sha256:" + )); + assert_eq!( + report["request"]["body"]["values"], + serde_json::json!({ + "active": true, + "count": 1, + "observed-at": "2026-01-01T00:00:00Z", + "status": "ready" + }) + ); + let canonical = + canonicalize_json(&report["request"]["body"]).expect("sample body canonicalizes"); + assert_eq!( + report["request"]["canonicalBody"], + String::from_utf8(canonical).expect("canonical body is UTF-8") + ); + assert!(!first.contains("hmacSha256KeyRef")); + assert!(!first.contains("https://")); +} + +#[test] +fn unavailable_sample_event_is_value_free_and_field_addressed() { + let project = TestProject::create(); + let (status, stdout, stderr) = run([ + OsStr::new("registry-serverctl"), + OsStr::new("--format"), + OsStr::new("json"), + OsStr::new("webhook"), + OsStr::new("sample"), + project.path().as_os_str(), + OsStr::new("--event"), + OsStr::new(EVENT_VALUE_CANARY), + ]); + + assert_eq!(status, 1); + assert!(stderr.is_empty()); + assert!(!stdout.contains(EVENT_VALUE_CANARY)); + let report: Value = serde_json::from_str(&stdout).expect("failure is JSON"); + assert_eq!( + report["diagnostics"][0]["code"], + "webhook.sample.event_refused" + ); + assert_eq!(report["diagnostics"][0]["path"], "event"); + assert_eq!(report["diagnostics"][0]["artifact"], "webhook_sample"); + assert_eq!( + report["diagnostics"][0]["suggestedAction"], + "select_webhook_event" + ); +} + +#[test] +fn operator_commands_share_one_value_free_refusal() { + let relative = Path::new(PATH_VALUE_CANARY); + let cases = [ + vec![ + OsStr::new("registry-serverctl"), + OsStr::new("--format"), + OsStr::new("json"), + OsStr::new("webhook"), + OsStr::new("list"), + OsStr::new("--runtime-config"), + relative.as_os_str(), + ], + vec![ + OsStr::new("registry-serverctl"), + OsStr::new("--format"), + OsStr::new("json"), + OsStr::new("webhook"), + OsStr::new("replay"), + OsStr::new("--runtime-config"), + relative.as_os_str(), + OsStr::new("--event-id"), + OsStr::new("00000000-0000-4000-8000-000000000001"), + OsStr::new("--delivery-id"), + OsStr::new("record.record-created-v1.webhook"), + OsStr::new("--expected-generation"), + OsStr::new("1"), + ], + ]; + + for arguments in cases { + let (status, stdout, stderr) = run(arguments); + assert_eq!(status, 1); + assert!(stderr.is_empty()); + assert!(!stdout.contains(PATH_VALUE_CANARY)); + let report: Value = serde_json::from_str(&stdout).expect("failure is JSON"); + assert_eq!( + report["diagnostics"][0]["code"], + "webhook.operation.refused" + ); + assert_eq!(report["diagnostics"][0]["path"], "webhook"); + assert_eq!(report["diagnostics"][0]["artifact"], "webhook_operations"); + } +} + +#[test] +fn webhook_help_describes_the_bounded_operator_contract() { + let (status, stdout, stderr) = run(["registry-serverctl", "webhook", "list", "--help"]); + + assert_eq!(status, 0); + assert!(stderr.is_empty()); + assert!(stdout.contains("--runtime-config ")); + assert!(stdout.contains("--limit ")); + assert!(stdout.contains("[default: 50]")); + assert!(stdout.contains("value-free")); +} diff --git a/products/registry-server/EVENTS-AND-WEBHOOKS.md b/products/registry-server/EVENTS-AND-WEBHOOKS.md index b705b2eb7f..77ae375ba2 100644 --- a/products/registry-server/EVENTS-AND-WEBHOOKS.md +++ b/products/registry-server/EVENTS-AND-WEBHOOKS.md @@ -1,6 +1,6 @@ # Events and webhooks -**Status:** Proposed direction for the next implementation slice +**Status:** Version 1 implemented ## Goal @@ -70,9 +70,11 @@ Runtime configuration must bind the exact compiled destination set and may tighten operational ceilings, never widen delivery authority. The compiler derives the event classification from the highest-classified -projected field. The project does not restate it. Activation requires the -runtime destination to permit that classification, but the destination can -never add to the compiled projection. +field used by either the projection or a condition. The project does not +restate it. A condition can disclose information through whether an event +fires even when that field is not in the payload. Activation therefore +requires the runtime destination to permit the full derived classification, +but the destination can never add to the compiled projection. ### Event evaluation and capture @@ -88,6 +90,12 @@ destination, and delivery policy. A later package activation must not reinterpret it. Activation refuses a destination change that would strand a retained non-terminal delivery. +This contract does not reinterpret delivery history created by the earlier +experimental webhook shape. An empty pre-Version 1 internal schema upgrades +automatically. A database containing pre-Version 1 webhook history requires an +explicit operator migration before this version starts, even when those rows +are terminal; Registry Server does not invent CloudEvents metadata for them. + ### Wire format Webhooks use CloudEvents 1.0 HTTP binary mode with canonical JSON data: @@ -96,7 +104,8 @@ Webhooks use CloudEvents 1.0 HTTP binary mode with canonical JSON data: - `ce-id`: the stable event UUID - `ce-source`: a stable URN for the Registry instance - `ce-type`: the authored event id -- `ce-time`: the mutation commit time +- `ce-time`: the immutable mutation capture time recorded in the committing + transaction - `ce-dataschema`: a URN containing the Registry id, event id, and generated event-schema fingerprint @@ -122,8 +131,9 @@ Delivery is asynchronous, after commit, and at least once: - Any `2xx` response acknowledges delivery. Redirects, transport failures, timeouts, and other statuses retry within a bounded product-owned profile. - HMAC-SHA-256, dead-lettering, operator replay, a five-second attempt timeout, - and the bounded retry profile are secure defaults, not per-event authoring - choices. `registry-serverctl explain events` shows the effective values. + and five total attempts with 1, 2, 4, then 8 second delays are secure + Version 1 defaults, not per-event authoring choices. `registry-serverctl + explain events` shows the effective values. - The event id and idempotency key remain stable across automatic retries. Consumers must deduplicate by `Idempotency-Key`. - A dead-letter replay keeps the event id, increments the generation, and gets @@ -171,8 +181,9 @@ The first complete journey must be possible without reading Rust code: - `registry-serverctl webhook replay` replays one eligible dead letter using its event id, delivery id, and expected generation. - `products/registry-server/demo/run.sh --webhook` starts Mint, PostgreSQL, - Registry Server, and a local HMAC-verifying receiver. It demonstrates one - successful event and one automatic retry without printing the token or key. + Registry Server, and a local HMAC-verifying receiver. Its smoke journey + demonstrates automatic retry, dead-letter inspection, operator replay, and + eventual authenticated success without printing the token or key. ## Definition of done diff --git a/products/registry-server/acceptance/asset-site-placement/registry.yaml b/products/registry-server/acceptance/asset-site-placement/registry.yaml index e84620e1b9..4c66b177f6 100644 --- a/products/registry-server/acceptance/asset-site-placement/registry.yaml +++ b/products/registry-server/acceptance/asset-site-placement/registry.yaml @@ -68,8 +68,6 @@ entities: - {id: asset, type: reference, target: asset-item, required: true, classification: internal} - {id: observed-at, type: timestamp, required: true, classification: internal} - {id: result, type: vocabulary-code, vocabulary: inspection-result, required: true, classification: internal} - events: - - {id: inspection-created, trigger: created, projection: [asset, observed-at, result]} accessProfiles: - id: asset-operator default: true diff --git a/products/registry-server/contracts/acceptance-scenario-matrix.yaml b/products/registry-server/contracts/acceptance-scenario-matrix.yaml index db4a2eb734..06e6bd9d17 100644 --- a/products/registry-server/contracts/acceptance-scenario-matrix.yaml +++ b/products/registry-server/contracts/acceptance-scenario-matrix.yaml @@ -75,8 +75,8 @@ scenarios: evidence: [{path: crates/registry-server/tests/migration_plan.rs, name: reviewed_migration_plan_closes_ast_sql_and_bound_evidence}, {path: crates/registry-server/tests/migration_plan.rs, name: reviewed_migration_plan_rejects_uncovered_changes_forbidden_sql_and_unbound_evidence}, {path: crates/registry-server/tests/postgres_migration.rs, name: real_postgres_backfill_and_destructive_recovery_are_bounded_resumable_and_activation_closed}] - id: RS-J16 state: enforced - doneWhen: "Webhook delivery, retry, dead letter, replay, projection, and classification confinement pass together." - evidence: [{path: crates/registry-server/tests/compiler_webhook.rs, name: governed_webhook_compiles_to_deterministic_destination_neutral_inventory}, {path: crates/registry-server/tests/runtime_config.rs, name: activation_constructs_the_exact_platform_policy_template_and_signing_material}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_deterministically_identified}, {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}] + doneWhen: "A user configures a minimal conditional webhook, previews its exact CloudEvents shape, atomically captures matching mutations, operates value-free list and generation-bound replay through retry and dead letter, observes retention erasure, and upgrades without stranding compatible prior-package work." + evidence: [{path: crates/registry-server/tests/compiler_webhook.rs, name: governed_webhook_compiles_to_deterministic_destination_neutral_inventory}, {path: crates/registry-server/tests/compiler_webhook.rs, name: webhook_projection_is_closed_and_classification_is_derived}, {path: crates/registry-server/tests/compiler_webhook.rs, name: field_conditions_are_typed_nonempty_and_trigger_compatible}, {path: crates/registry-server/tests/runtime_config.rs, name: runtime_destination_classification_ceiling_cannot_widen_compiled_event_disclosure}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_deterministically_identified}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_empty_pre_v1_webhook_schema_upgrades_idempotently}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_pre_v1_webhook_history_refuses_silent_v1_reinterpretation}, {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}, {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_finishes_prior_package_work_after_compatible_upgrade}, {path: crates/registry-server/tests/postgres_package.rs, name: successor_apply_refuses_to_strand_retained_webhook_work}, {path: crates/registry-serverctl/tests/webhook.rs, name: sample_is_an_exact_deterministic_cloudevents_request_without_deployment_authority}, {path: crates/registry-serverctl/tests/webhook.rs, name: operator_commands_share_one_value_free_refusal}, {path: products/registry-server/demo/run.sh, name: run.sh}] - id: RS-J17 state: enforced doneWhen: "An external coding agent can author and check but cannot satisfy signature or migration-role authority." diff --git a/products/registry-server/contracts/definition-of-done.yaml b/products/registry-server/contracts/definition-of-done.yaml index 65020c21a6..48cf662ea4 100644 --- a/products/registry-server/contracts/definition-of-done.yaml +++ b/products/registry-server/contracts/definition-of-done.yaml @@ -58,7 +58,7 @@ requirements: - {id: RS-V1-34, phase: W5, state: enforced, doneWhen: "registry-serverctl exposes the complete authoring, package, apply, verification, doctor, and data command set.", journeys: [RS-J02, RS-J17], evidence: [{path: crates/registry-serverctl/src/lib.rs, name: public_command_surface_is_explicit}, {path: crates/registry-serverctl/tests/cli.rs, name: lifecycle_parser_surfaces_are_exact_and_value_free}, {path: crates/registry-serverctl/tests/doctor.rs, name: startup_value_disclosure_and_listener_activation_threats_are_enforced_by_prepare_negative}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}]} - {id: RS-V1-35, phase: W5, state: enforced, doneWhen: "Production tooling is distinct and callers cannot acquire signature or migration authority.", journeys: [RS-J02, RS-J17], evidence: [{path: crates/registry-serverctl/tests/cli.rs, name: test_help_requires_test_inputs_and_exposes_no_package_or_apply_authority}, {path: crates/registry-serverctl/tests/cli.rs, name: package_always_uses_production_compilation_and_never_offers_a_signing_command}, {path: crates/registry-serverctl/tests/cli.rs, name: apply_verifies_package_intent_before_database_authority_and_stays_value_free}, {path: crates/registry-serverctl/tests/diff.rs, name: production_trust_is_verified_without_opening_runtime_dependencies}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}]} - {id: RS-V1-36, phase: W5, state: enforced, doneWhen: "Resumable import and authorized export use normal mutation, audit, revision, idempotency, and outbox paths.", journeys: [RS-J05], evidence: [{path: crates/registry-server/tests/data_operations.rs, name: data_export_requires_explicit_nonanonymous_profile_permission}, {path: crates/registry-server/tests/data_operations.rs, name: data_validate_and_chunk_plan_reuse_runtime_rules_and_compiled_batch_bounds}, {path: crates/registry-server/tests/data_operations.rs, name: data_import_checkpoint_and_idempotency_are_exact_and_value_free}, {path: crates/registry-server/tests/data_operations.rs, name: data_export_checkpoint_refuses_package_profile_projection_or_prefix_substitution}, {path: crates/registry-server/tests/postgres_data_farmer.rs, name: real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race_safe}, {path: crates/registry-server/tests/postgres_data_export.rs, name: real_postgres_export_is_authenticated_projected_audited_and_resumable}]} - - {id: RS-V1-37, phase: W5, state: enforced, doneWhen: "Webhook delivery is confined, authenticated, bounded, audited, retryable, dead-lettered, and replayable.", journeys: [RS-J16], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}, {path: crates/registry-server/tests/compiler_webhook.rs, name: governed_webhook_compiles_to_deterministic_destination_neutral_inventory}, {path: crates/registry-server/tests/runtime_config.rs, name: activation_constructs_the_exact_platform_policy_template_and_signing_material}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_deterministically_identified}, {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}]} + - {id: RS-V1-37, phase: W5, state: enforced, doneWhen: "Configured conditional webhooks compile with disclosure classification derived from projected and observed fields; mutations atomically capture canonical package-bound events; exact runtime authority delivers signed CloudEvents with bounded retry, dead letter, retention erasure, audited generation-bound replay, and compatible-upgrade continuity; bounded ctl sample, list, replay, and demo journeys remain value-free outside payloads.", journeys: [RS-J16], evidence: [{path: crates/registry-server/tests/compiler_webhook.rs, name: governed_webhook_compiles_to_deterministic_destination_neutral_inventory}, {path: crates/registry-server/tests/compiler_webhook.rs, name: webhook_projection_is_closed_and_classification_is_derived}, {path: crates/registry-server/tests/compiler_webhook.rs, name: field_conditions_are_typed_nonempty_and_trigger_compatible}, {path: crates/registry-server/tests/runtime_config.rs, name: runtime_destination_classification_ceiling_cannot_widen_compiled_event_disclosure}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_deterministically_identified}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_empty_pre_v1_webhook_schema_upgrades_idempotently}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_pre_v1_webhook_history_refuses_silent_v1_reinterpretation}, {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}, {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_finishes_prior_package_work_after_compatible_upgrade}, {path: crates/registry-server/tests/postgres_package.rs, name: successor_apply_refuses_to_strand_retained_webhook_work}, {path: crates/registry-serverctl/tests/webhook.rs, name: sample_is_an_exact_deterministic_cloudevents_request_without_deployment_authority}, {path: crates/registry-serverctl/tests/webhook.rs, name: operator_commands_share_one_value_free_refusal}, {path: products/registry-server/demo/run.sh, name: run.sh}]} - {id: RS-V1-38, phase: W5, state: enforced, doneWhen: "The non-person asset, site, and placement project proves the kernel without person-related concepts.", journeys: [RS-J01, RS-J07], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} - {id: RS-V1-39, phase: W5, state: enforced, doneWhen: "The household project has no domain-specific route, query, Rust type, feature, migration, metric, or error.", journeys: [RS-J03, RS-J07], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: products/registry-server/scripts/check-source-neutrality.sh, name: check-source-neutrality.sh}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_every_domain_fixture_family_has_a_rejected_route_canary}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_bare_domain_rust_type_identifier_is_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_cargo_feature_is_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_migration_and_resource_inputs_are_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_metric_and_error_identifiers_are_rejected}]} - {id: RS-V1-40, phase: W5, state: enforced, doneWhen: "The disability project proves protected observations, certification, validity, and correction provenance.", journeys: [RS-J04], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} diff --git a/products/registry-server/contracts/security-invariant-matrix.yaml b/products/registry-server/contracts/security-invariant-matrix.yaml index 59cdcd3adc..6c20654b14 100644 --- a/products/registry-server/contracts/security-invariant-matrix.yaml +++ b/products/registry-server/contracts/security-invariant-matrix.yaml @@ -15,7 +15,7 @@ invariants: - {id: RS-SEC-12, state: enforced, targetWave: W3, threat: "An ETag or idempotency result is replayed under a different access context.", enforcementPoint: authenticated ETag and idempotency binding, refusal: "Reject a replay whose package, profile, principal, purpose, row boundary, projection, or canonical request context differs.", negativeId: RS-NEG-12, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}} - {id: RS-SEC-13, state: enforced, targetWave: W3, threat: "Problems, logs, metrics, traces, or database diagnostics disclose data, credentials, or physical structure.", enforcementPoint: closed diagnostic vocabulary and telemetry boundary, refusal: Replace unsafe detail with a stable value-free problem and suppress unsafe telemetry fields., negativeId: RS-NEG-13, negativeTest: {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}} - {id: RS-SEC-14, state: enforced, targetWave: W1, threat: Different generated surfaces or physical names diverge from the reviewed configuration., enforcementPoint: canonical compiler and artifact inventory comparison, refusal: Reject non-deterministic or inconsistent compilation before package creation., negativeId: RS-NEG-14, negativeTest: {path: crates/registry-server/tests/compiler_contract.rs, name: duplicate_routes_fail_before_artifact_generation}} - - {id: RS-SEC-15, state: enforced, targetWave: W5, threat: A webhook leaks data to an arbitrary destination or retries with a widened projection., enforcementPoint: logical destination resolver and durable delivery worker, refusal: "Refuse a destination, TLS, egress, signature, projection, or replay that is outside the compiled subscription.", negativeId: RS-NEG-15, negativeTest: {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}} + - {id: RS-SEC-15, state: enforced, targetWave: W5, threat: "A condition leaks a more-classified field through event presence, captured values reach widened or substituted egress, erased data is replayed, or an upgrade strands retained delivery work.", enforcementPoint: "compiler-derived event classification, exact activated destination authority, transactional capture, durable CloudEvents delivery and retention, and activation compatibility interlock", refusal: "Reject underclassified conditions, widened runtime ceilings, mismatched destination bindings or payloads, stale, non-dead-letter, or erased replay, and changed or removed bindings with deliverable retained work before egress or activation.", negativeId: RS-NEG-15, negativeTest: {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}} - {id: RS-SEC-16, state: enforced, targetWave: W5, threat: A domain fixture gains hidden production behavior through a hard-coded runtime concept., enforcementPoint: self-tested source-neutrality gate over production source and public kernel contracts, refusal: Reject any acceptance-fixture identifier planted in the runtime source or public kernel contract., negativeId: RS-NEG-16, negativeTest: {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_fixture_identifier_in_production_source_is_rejected}} - {id: RS-SEC-17, state: enforced, targetWave: W3, threat: An encrypted cursor is replayed under a different authorized query context., enforcementPoint: fresh HTTP authorization plus authenticated cursor opening and PostgreSQL ReadPlan binding recomputation, refusal: "Reject before SQL when package, route, operation, profile, principal, purpose, row boundary, projection, filter, sort, temporal instant, page size, or expiry differs.", negativeId: RS-NEG-17, negativeTest: {path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}} - {id: RS-SEC-18, state: enforced, targetWave: W5, threat: A bulk request bypasses per-item authority or commits a valid prefix after a later item fails., enforcementPoint: configured Batch route and single-transaction mutation coordinator, refusal: "Refuse the complete request before record I/O when its bounds, operation, profile, or mutation mode is invalid; otherwise roll back every item and release nothing when any item or terminal component fails.", negativeId: RS-NEG-18, negativeTest: {path: crates/registry-server/tests/postgres_batch.rs, name: real_postgres_batch_is_bounded_authorized_atomic_and_exactly_replayable}} diff --git a/products/registry-server/demo/README.md b/products/registry-server/demo/README.md index 24319f3069..5131135cd6 100644 --- a/products/registry-server/demo/README.md +++ b/products/registry-server/demo/README.md @@ -41,6 +41,26 @@ without waiting: products/registry-server/demo/run.sh --smoke ``` +Use `--webhook` to add a local loopback receiver and exercise the configured +event lifecycle: + +```bash +products/registry-server/demo/run.sh --webhook +products/registry-server/demo/run.sh --webhook --smoke +``` + +Webhook mode extends only the disposable project copy with a conditional +person event. It leaves the shared acceptance fixture unchanged, generates an +owner-only HMAC key, and uses Registry Server's loopback-development outbound +policy. The receiver verifies the exact CloudEvents request and HMAC contract, +then deterministically proves immediate delivery, automatic retry, +dead-letter inspection with `registry-serverctl webhook list`, and optimistic +replay with `registry-serverctl webhook replay`. + +The offline `webhook sample` report and final value-free status report are +written under `demo/.run/`. The script prints their paths, but never prints the +bearer token or HMAC key. + ## Disposable state All generated configuration, keys, tokens, logs, package artifacts, and diff --git a/products/registry-server/demo/run.sh b/products/registry-server/demo/run.sh index 501da8af08..14e83d6102 100755 --- a/products/registry-server/demo/run.sh +++ b/products/registry-server/demo/run.sh @@ -10,13 +10,30 @@ run_dir="$demo_dir/.run" mint_key_material="$repository_root/crates/registry-mint/demo/support/key_material.py" postgres_image='postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675' mode=serve +webhook=false -if [[ "${1:-}" == "--smoke" ]]; then - mode=smoke -elif [[ $# -ne 0 ]]; then - printf '%s\n' 'usage: products/registry-server/demo/run.sh [--smoke]' >&2 - exit 2 -fi +for argument in "$@"; do + case "$argument" in + --smoke) + if [[ "$mode" == smoke ]]; then + printf '%s\n' 'the --smoke option may be supplied only once.' >&2 + exit 2 + fi + mode=smoke + ;; + --webhook) + if [[ "$webhook" == true ]]; then + printf '%s\n' 'the --webhook option may be supplied only once.' >&2 + exit 2 + fi + webhook=true + ;; + *) + printf '%s\n' 'usage: products/registry-server/demo/run.sh [--smoke] [--webhook]' >&2 + exit 2 + ;; + esac +done require_command() { if ! command -v "$1" >/dev/null 2>&1; then @@ -51,6 +68,7 @@ mkdir -m 700 "$run_dir" "$run_dir/secrets" "$run_dir/keys" "$run_dir/logs" "$run mint_pid="" server_pid="" +receiver_pid="" postgres_container="registry-server-demo-${PPID}-$$" cleanup() { if [[ -n "${server_pid:-}" ]]; then @@ -61,14 +79,26 @@ cleanup() { kill "$mint_pid" >/dev/null 2>&1 || true wait "$mint_pid" >/dev/null 2>&1 || true fi + if [[ -n "${receiver_pid:-}" ]]; then + kill "$receiver_pid" >/dev/null 2>&1 || true + wait "$receiver_pid" >/dev/null 2>&1 || true + fi docker rm -f "$postgres_container" >/dev/null 2>&1 || true } trap cleanup EXIT HUP INT TERM -ports=$(python3 "$support" ports) -read -r database_port mint_port server_port <"$run_dir/secrets/database-password" chmod 600 "$run_dir/secrets/database-password" -python3 "$support" prepare \ - --root "$run_dir" \ - --fixture "$fixture" \ - --database-port "$database_port" \ - --mint-port "$mint_port" \ +prepare_arguments=( + prepare + --root "$run_dir" + --fixture "$fixture" + --database-port "$database_port" + --mint-port "$mint_port" --server-port "$server_port" +) +if [[ "$webhook" == true ]]; then + prepare_arguments+=(--webhook --receiver-port "$receiver_port") +fi +python3 "$support" "${prepare_arguments[@]}" + +if [[ "$webhook" == true ]]; then + "$registry_serverctl" --format json explain model "$run_dir/project" \ + >"$run_dir/webhook-model-report.json" + python3 "$support" bind-webhook-module \ + --root "$run_dir" \ + --report "$run_dir/webhook-model-report.json" + "$registry_serverctl" --format json webhook sample "$run_dir/project" \ + --event usual-resident-created-v1 \ + >"$run_dir/webhook-sample.json" +fi openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 2 \ -subj '/CN=Registry Server local demo CA' \ @@ -212,7 +263,11 @@ schema_fingerprint=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[ --output "$run_dir/build" \ >"$run_dir/package-report.json" package_revision=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["packageRevision"])' "$run_dir/package-report.json") -python3 "$support" render-runtime --root "$run_dir" --revision "$package_revision" +render_arguments=(render-runtime --root "$run_dir" --revision "$package_revision") +if [[ "$webhook" == true ]]; then + render_arguments+=(--webhook) +fi +python3 "$support" "${render_arguments[@]}" "$registry_serverctl" apply \ --runtime-config "$run_dir/runtime.yaml" \ @@ -225,14 +280,72 @@ REGISTRY_SERVER_LOG=error "$registry_server" --config "$run_dir/runtime.yaml" \ >"$run_dir/logs/registry-server.log" 2>&1 & server_pid=$! python3 "$support" wait-http --url "http://127.0.0.1:${server_port}/ready" --timeout 30 +if [[ "$webhook" == true ]]; then + printf '%s\n' '== Starting the local CloudEvents receiver' + python3 "$support" serve-webhook-receiver --root "$run_dir" \ + >"$run_dir/logs/webhook-receiver.log" 2>&1 & + receiver_pid=$! + python3 "$support" wait-http \ + --url "http://127.0.0.1:${receiver_port}/ready" \ + --timeout 30 +fi python3 "$support" seed --root "$run_dir" "$demo_dir/query.sh" >/dev/null +if [[ "$webhook" == true ]]; then + printf '%s\n' '== Proving webhook success, retry, dead-letter inspection, and replay' + if ! python3 "$support" wait-webhook \ + --root "$run_dir" \ + --phase dead-letter-ready \ + --timeout 30; then + "$registry_serverctl" --format json webhook list \ + --runtime-config "$run_dir/runtime.yaml" \ + >"$run_dir/webhook-timeout-list.json" 2>/dev/null || true + printf '%s\n' "Webhook progress timed out; inspect $run_dir/webhook-timeout-list.json." >&2 + exit 1 + fi + dead_letter_found=false + for _attempt in $(seq 1 100); do + "$registry_serverctl" --format json webhook list \ + --runtime-config "$run_dir/runtime.yaml" \ + >"$run_dir/webhook-list.json" + if python3 "$support" select-dead-letter \ + --report "$run_dir/webhook-list.json" \ + >"$run_dir/dead-letter-selection" 2>/dev/null; then + dead_letter_found=true + break + fi + sleep 0.1 + done + if [[ "$dead_letter_found" != true ]]; then + printf '%s\n' 'The webhook delivery did not reach the replayable dead letter state.' >&2 + exit 1 + fi + read -r dead_event_id dead_delivery_id dead_generation \ + <"$run_dir/dead-letter-selection" + "$registry_serverctl" webhook replay \ + --runtime-config "$run_dir/runtime.yaml" \ + --event-id "$dead_event_id" \ + --delivery-id "$dead_delivery_id" \ + --expected-generation "$dead_generation" \ + >/dev/null + python3 "$support" wait-webhook \ + --root "$run_dir" \ + --phase replayed \ + --timeout 30 + python3 "$support" verify-webhook --root "$run_dir" + printf '%s\n' 'Webhook delivery, retry, dead-letter inspection, and replay passed.' +fi + printf '\n%s\n' 'Registry Server household demo is ready.' printf ' Registry Server: http://127.0.0.1:%s\n' "$server_port" printf ' Registry Mint: http://127.0.0.1:%s\n' "$mint_port" printf ' Token file: %s\n' "$run_dir/secrets/operator-token" printf ' Sample queries: %s\n' "$demo_dir/query.sh" +if [[ "$webhook" == true ]]; then + printf ' Webhook sample: %s\n' "$run_dir/webhook-sample.json" + printf ' Webhook status: %s\n' "$run_dir/webhook-list.json" +fi printf ' Logs: %s\n' "$run_dir/logs" if [[ "$mode" == smoke ]]; then @@ -242,6 +355,9 @@ fi printf '\n%s\n' 'Leave this terminal running. Press Ctrl-C to stop the services.' while kill -0 "$mint_pid" >/dev/null 2>&1 && kill -0 "$server_pid" >/dev/null 2>&1; do + if [[ "$webhook" == true ]] && ! kill -0 "$receiver_pid" >/dev/null 2>&1; then + break + fi sleep 1 done printf '%s\n' "A demo service stopped unexpectedly; inspect $run_dir/logs." >&2 diff --git a/products/registry-server/demo/support/demo.py b/products/registry-server/demo/support/demo.py index bd6b6c0cb0..9c527b991b 100755 --- a/products/registry-server/demo/support/demo.py +++ b/products/registry-server/demo/support/demo.py @@ -4,6 +4,10 @@ from __future__ import annotations import argparse +import base64 +import hashlib +import hmac +import http.server import json import os import shutil @@ -14,6 +18,8 @@ import urllib.error import urllib.parse import urllib.request +import uuid +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -33,6 +39,22 @@ " instanceId: publicschema-household-acceptance": f" instanceId: {INSTANCE_ID}", " sourceRevision: publicschema-household-acceptance-0.1.0": f" sourceRevision: {SOURCE_REVISION}", } +WEBHOOK_DESTINATION_ID = "household-event-receiver" +WEBHOOK_EVENT_ID = "usual-resident-created-v1" +WEBHOOK_MODULE_ID = "publicschema-household-demographics" +WEBHOOK_MODULE_LOCK = " - id: publicschema-household-demographics\n version: 0.1.0\n" +WEBHOOK_MODULE_SOURCE = """ events: + - id: usual-resident-created-v1 + trigger: created + projection: [person-code, residency-status] + when: + kind: fields + afterEquals: {residency-status: usual-resident} + webhook: + destinationId: household-event-receiver +""" +WEBHOOK_SIGNATURE_DOMAIN = b"registry-server-webhook-signature-v1" +WEBHOOK_RECEIVER_MAX_BODY_BYTES = 1024 * 1024 class DemoError(RuntimeError): @@ -67,10 +89,12 @@ def _require_root(root: Path) -> Path: return root -def reserve_ports() -> tuple[int, int, int]: +def reserve_ports(count: int = 3) -> tuple[int, ...]: + if count not in (3, 4): + raise DemoError("the demo reserves either three or four ports") listeners: list[socket.socket] = [] try: - for _ in range(3): + for _ in range(count): listener = socket.socket() listener.bind(("127.0.0.1", 0)) listeners.append(listener) @@ -80,7 +104,7 @@ def reserve_ports() -> tuple[int, int, int]: listener.close() -def _local_project(root: Path, fixture: Path) -> None: +def _local_project(root: Path, fixture: Path, webhook: bool) -> None: target = root / "project" shutil.copytree(fixture, target, ignore=shutil.ignore_patterns(".DS_Store")) project_path = target / "registry.yaml" @@ -89,6 +113,54 @@ def _local_project(root: Path, fixture: Path) -> None: if source.count(expected) != 1: raise DemoError(f"household fixture no longer has the expected package line: {expected.strip()}") source = source.replace(expected, replacement, 1) + if webhook: + if source.count(WEBHOOK_MODULE_LOCK) != 1: + raise DemoError("household fixture no longer has the expected demographics module lock") + before_lock, after_lock = source.split(WEBHOOK_MODULE_LOCK, 1) + digest_line, separator, after_digest = after_lock.partition("\n") + digest = digest_line.removeprefix(" digest: ") + if ( + not separator + or not digest.startswith("sha256:") + or len(digest) != 71 + ): + raise DemoError( + "household fixture no longer has the expected demographics module digest" + ) + source = before_lock + WEBHOOK_MODULE_LOCK + after_digest + module_path = target / f"modules/{WEBHOOK_MODULE_ID}/module.yaml" + module_source = module_path.read_text(encoding="utf-8") + if " events:\n" in module_source or not module_source.endswith("\n"): + raise DemoError("household demographics module cannot receive the demo event") + module_path.write_text(module_source + WEBHOOK_MODULE_SOURCE, encoding="utf-8") + project_path.write_text(source, encoding="utf-8") + + +def bind_webhook_module(root: Path, explain_report: Path) -> None: + root = _require_root(root) + report = _read_json_object(explain_report) + closure = report.get("explanation", {}).get("moduleClosure") + if not isinstance(closure, list): + raise DemoError("compiled model report has no module closure") + matching = [ + entry + for entry in closure + if isinstance(entry, dict) and entry.get("id") == WEBHOOK_MODULE_ID + ] + if len(matching) != 1: + raise DemoError("compiled model report does not identify the demo webhook module") + digest = matching[0].get("digest") + if not isinstance(digest, str) or not digest.startswith("sha256:") or len(digest) != 71: + raise DemoError("compiled model report has no canonical demo webhook module digest") + project_path = root / "project/registry.yaml" + source = project_path.read_text(encoding="utf-8") + if source.count(WEBHOOK_MODULE_LOCK) != 1: + raise DemoError("demo webhook module lock is not ready for its compiled digest") + source = source.replace( + WEBHOOK_MODULE_LOCK, + WEBHOOK_MODULE_LOCK + f" digest: {digest}\n", + 1, + ) project_path.write_text(source, encoding="utf-8") @@ -107,8 +179,33 @@ def _mint_client(client_id: str, principal: str, public_key: dict[str, Any], pur ) -def _runtime_config(root: Path, package_root: Path, revision: str, bind: str) -> str: +def _runtime_config( + root: Path, + package_root: Path, + revision: str, + bind: str, + webhook: bool = False, +) -> str: secrets = root / "secrets" + if webhook: + receiver_origin = root.joinpath("receiver-origin").read_text(encoding="ascii").strip() + event_destinations = f"""eventDestinations: + {WEBHOOK_DESTINATION_ID}: + origin: {receiver_origin} + path: /events + networkProfile: loopbackDevelopmentHttp + dnsFamily: dualStackStrict + allowedPrivateCidrs: [] + hmacSha256KeyRef: secret:file/webhook-key + classificationCeiling: restricted + deliveryCeilings: + attemptTimeoutMilliseconds: 1000 + maximumAttempts: 3 +eventDelivery: + payloadRetentionDays: 1 +""" + else: + event_destinations = "eventDestinations: {}\n" return f"""listener: bind: {bind} trustedProxy: direct @@ -167,8 +264,7 @@ def _runtime_config(root: Path, package_root: Path, revision: str, bind: str) -> cursor: secretRef: secret:file/cursor-key maxAgeSeconds: 300 -eventDestinations: {{}} -operationalTimeouts: +{event_destinations}operationalTimeouts: httpRequestMilliseconds: 10000 shutdownGraceMilliseconds: 5000 recordLockMilliseconds: 5000 @@ -177,7 +273,15 @@ def _runtime_config(root: Path, package_root: Path, revision: str, bind: str) -> """ -def prepare(root: Path, fixture: Path, database_port: int, mint_port: int, server_port: int) -> None: +def prepare( + root: Path, + fixture: Path, + database_port: int, + mint_port: int, + server_port: int, + webhook: bool = False, + receiver_port: int | None = None, +) -> None: root = _require_root(root) fixture = fixture.resolve() if not (fixture / "registry.yaml").is_file(): @@ -187,7 +291,9 @@ def prepare(root: Path, fixture: Path, database_port: int, mint_port: int, serve if not password or any(character not in "0123456789abcdef" for character in password): raise DemoError("database password must be non-empty lowercase hexadecimal") - _local_project(root, fixture) + if webhook and receiver_port is None: + raise DemoError("the webhook demo requires a receiver port") + _local_project(root, fixture, webhook) mint_public = _read_json_object(root / "keys/mint-public.jwk.json") operator_public = _read_json_object(root / "keys/operator-public.jwk.json") no_purpose_public = _read_json_object(root / "keys/no-purpose-public.jwk.json") @@ -199,6 +305,8 @@ def prepare(root: Path, fixture: Path, database_port: int, mint_port: int, serve server_origin = f"http://127.0.0.1:{server_port}" _write_new(root / "mint-origin", mint_origin + "\n") _write_new(root / "server-origin", server_origin + "\n") + if webhook: + _write_new(root / "receiver-origin", f"http://127.0.0.1:{receiver_port}\n") _write_json(root / "secrets/mint-jwks", {"keys": [mint_public]}, 0o600) _write_json(root / f"mint/public-keys/{kid}.jwk.json", mint_public) _write_new( @@ -305,7 +413,13 @@ def prepare(root: Path, fixture: Path, database_port: int, mint_port: int, serve _write_new(root / "trust-anchor.json", "{}") (root / "empty-package").mkdir(mode=0o755) dummy_revision = "sha256:" + "1" * 64 - test_runtime = _runtime_config(root, root / "empty-package", dummy_revision, "127.0.0.1:0") + test_runtime = _runtime_config( + root, + root / "empty-package", + dummy_revision, + "127.0.0.1:0", + webhook, + ) test_runtime = test_runtime.replace( "secret:file/runtime-database-url", "secret:file/test-runtime-database-url" ).replace( @@ -328,12 +442,15 @@ def prepare(root: Path, fixture: Path, database_port: int, mint_port: int, serve ) -def render_runtime(root: Path, revision: str) -> None: +def render_runtime(root: Path, revision: str, webhook: bool = False) -> None: root = _require_root(root) if not revision.startswith("sha256:") or len(revision) != 71: raise DemoError("package revision must be one SHA-256 identifier") bind = urllib.parse.urlparse((root / "server-origin").read_text(encoding="ascii").strip()).netloc - _write_new(root / "runtime.yaml", _runtime_config(root, root / "build/package", revision, bind)) + _write_new( + root / "runtime.yaml", + _runtime_config(root, root / "build/package", revision, bind, webhook), + ) def _token(root: Path, name: str) -> str: @@ -358,6 +475,289 @@ def store_token(path: Path, source: bytes) -> None: _write_new(path, value, 0o600) +def _write_state(path: Path, state: dict[str, Any]) -> None: + temporary = path.with_name(path.name + ".next") + if temporary.exists(): + temporary.unlink() + _write_json(temporary, state, 0o600) + os.replace(temporary, path) + + +def _length_prefixed(value: bytes) -> bytes: + return len(value).to_bytes(8, "big") + value + + +def _expected_webhook_signature(key: bytes, headers: dict[str, str], body: bytes) -> str: + # Keep this receiver-side verifier synchronized with the versioned Registry + # Server signature contract. It has no access to runtime destination config. + signed = bytearray(WEBHOOK_SIGNATURE_DOMAIN) + for value in ( + headers["ce-specversion"].encode("ascii"), + headers["ce-id"].encode("ascii"), + headers["ce-source"].encode("ascii"), + headers["ce-type"].encode("ascii"), + headers["ce-time"].encode("ascii"), + headers["ce-dataschema"].encode("ascii"), + headers["x-registry-event-generation"].encode("ascii"), + headers["x-registry-delivery-attempt"].encode("ascii"), + headers["x-registry-delivery-time"].encode("ascii"), + b"POST", + b"/events", + b"application/json", + headers["idempotency-key"].encode("ascii"), + body, + ): + signed.extend(_length_prefixed(value)) + encoded = base64.urlsafe_b64encode(hmac.new(key, signed, hashlib.sha256).digest()).rstrip(b"=") + return "v1=" + encoded.decode("ascii") + + +def _parse_positive_header(headers: dict[str, str], name: str) -> int: + value = headers.get(name, "") + if not value.isascii() or not value.isdecimal(): + raise DemoError("the receiver refused webhook metadata") + parsed = int(value) + if parsed <= 0: + raise DemoError("the receiver refused webhook metadata") + return parsed + + +def _verify_webhook_request( + key: bytes, + path: str, + headers: dict[str, str], + body: bytes, +) -> tuple[str, int, int, str]: + required = { + "accept", + "content-type", + "ce-specversion", + "ce-id", + "ce-source", + "ce-type", + "ce-time", + "ce-dataschema", + "x-registry-event-generation", + "x-registry-delivery-attempt", + "x-registry-delivery-time", + "idempotency-key", + "x-registry-signature", + } + if path != "/events" or not required.issubset(headers): + raise DemoError("the receiver refused the webhook request shape") + if ( + headers["accept"] != "application/json" + or headers["content-type"] != "application/json" + or headers["ce-specversion"] != "1.0" + ): + raise DemoError("the receiver refused the CloudEvents profile") + event_uuid = str(uuid.UUID(headers["ce-id"])) + if event_uuid != headers["ce-id"] or headers["ce-type"] != WEBHOOK_EVENT_ID: + raise DemoError("the receiver refused the CloudEvents identity") + expected_source = f"urn:registrystack:registry:publicschema-household:instance:{INSTANCE_ID}" + if headers["ce-source"] != expected_source: + raise DemoError("the receiver refused the CloudEvents source") + expected_schema_prefix = ( + "urn:registry-server:event-schema:publicschema-household:person:" + f"{WEBHOOK_EVENT_ID}:sha256:" + ) + if not headers["ce-dataschema"].startswith(expected_schema_prefix): + raise DemoError("the receiver refused the CloudEvents data schema") + event_time = datetime.fromisoformat(headers["ce-time"].replace("Z", "+00:00")) + delivery_time = datetime.fromisoformat( + headers["x-registry-delivery-time"].replace("Z", "+00:00") + ) + if event_time.tzinfo is None or delivery_time.tzinfo is None: + raise DemoError("the receiver refused unzoned event time") + if abs((datetime.now(timezone.utc) - delivery_time).total_seconds()) > 30: + raise DemoError("the receiver refused stale delivery time") + generation = _parse_positive_header(headers, "x-registry-event-generation") + attempt = _parse_positive_header(headers, "x-registry-delivery-attempt") + idempotency_key = headers["idempotency-key"] + if not idempotency_key.startswith("sha256:") or len(idempotency_key) != 71: + raise DemoError("the receiver refused the idempotency key") + if len(body) == 0 or len(body) > WEBHOOK_RECEIVER_MAX_BODY_BYTES: + raise DemoError("the receiver refused the webhook body bounds") + document = json.loads(body) + canonical = json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8") + if canonical != body or not isinstance(document, dict): + raise DemoError("the receiver refused a non-canonical webhook body") + if set(document) != {"entity", "recordId", "revision", "trigger", "packageRevision", "values"}: + raise DemoError("the receiver refused the webhook body shape") + if ( + document["entity"] != "person" + or document["trigger"] != "created" + or not isinstance(document["revision"], int) + or document["revision"] < 1 + or not isinstance(document["packageRevision"], str) + or not document["packageRevision"].startswith("sha256:") + or str(uuid.UUID(document["recordId"])) != document["recordId"] + or not isinstance(document["values"], dict) + or set(document["values"]) != {"person-code", "residency-status"} + ): + raise DemoError("the receiver refused the event data contract") + expected_signature = _expected_webhook_signature(key, headers, body) + if not hmac.compare_digest(headers["x-registry-signature"], expected_signature): + raise DemoError("the receiver refused the webhook signature") + return event_uuid, generation, attempt, idempotency_key + + +class WebhookReceiver(http.server.BaseHTTPRequestHandler): + server_version = "RegistryDemoReceiver/1" + sys_version = "" + + def log_message(self, _format: str, *_args: Any) -> None: + return + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler callback + self.send_response(200 if self.path == "/ready" else 404) + self.end_headers() + + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler callback + state_path: Path = self.server.state_path # type: ignore[attr-defined] + key: bytes = self.server.webhook_key # type: ignore[attr-defined] + try: + raw_length = self.headers.get("Content-Length", "") + if not raw_length.isdecimal(): + raise DemoError("the receiver refused the webhook length") + length = int(raw_length) + if length <= 0 or length > WEBHOOK_RECEIVER_MAX_BODY_BYTES: + raise DemoError("the receiver refused the webhook length") + body = self.rfile.read(length) + headers = {name.lower(): value for name, value in self.headers.items()} + event_id, generation, attempt, idempotency_key = _verify_webhook_request( + key, self.path, headers, body + ) + except (DemoError, KeyError, TypeError, ValueError, UnicodeError, json.JSONDecodeError): + state = _read_json_object(state_path) + state["verificationFailures"] = int(state.get("verificationFailures", 0)) + 1 + _write_state(state_path, state) + self.send_response(400) + self.end_headers() + return + + state = _read_json_object(state_path) + events = state.setdefault("events", {}) + event = events.get(event_id) + if event is None: + event = {"slot": len(events) + 1, "idempotencyKeys": {}, "attempts": []} + events[event_id] = event + key_name = str(generation) + prior_key = event["idempotencyKeys"].get(key_name) + if prior_key is not None and prior_key != idempotency_key: + state["verificationFailures"] = int(state.get("verificationFailures", 0)) + 1 + _write_state(state_path, state) + self.send_response(400) + self.end_headers() + return + event["idempotencyKeys"][key_name] = idempotency_key + slot = int(event["slot"]) + accepted = ( + slot == 1 + or slot >= 4 + or (slot == 2 and attempt > 1) + or (slot == 3 and generation > 1) + ) + event["attempts"].append( + {"generation": generation, "attempt": attempt, "accepted": accepted} + ) + _write_state(state_path, state) + self.send_response(204 if accepted else 503) + self.end_headers() + + +def serve_webhook_receiver(root: Path) -> None: + root = _require_root(root) + origin = urllib.parse.urlparse((root / "receiver-origin").read_text(encoding="ascii").strip()) + if origin.scheme != "http" or origin.hostname != "127.0.0.1" or origin.port is None: + raise DemoError("the webhook receiver origin must be exact loopback HTTP") + key_path = root / "secrets/webhook-key" + if not key_path.is_file() or key_path.is_symlink() or key_path.stat().st_mode & 0o077: + raise DemoError("the webhook key must be an owner-only regular file") + key = key_path.read_bytes() + if len(key) < 32: + raise DemoError("the webhook key is too short") + state_path = root / "webhook-receiver-state.json" + _write_json(state_path, {"verificationFailures": 0, "events": {}}, 0o600) + server = http.server.HTTPServer(("127.0.0.1", origin.port), WebhookReceiver) + server.state_path = state_path # type: ignore[attr-defined] + server.webhook_key = key # type: ignore[attr-defined] + server.serve_forever(poll_interval=0.1) + + +def wait_webhook(root: Path, phase: str, timeout_seconds: float) -> None: + root = _require_root(root) + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + state = _read_json_object(root / "webhook-receiver-state.json") + except (OSError, json.JSONDecodeError, DemoError): + time.sleep(0.1) + continue + events = sorted(state.get("events", {}).values(), key=lambda event: event.get("slot", 0)) + if phase == "dead-letter-ready" and len(events) >= 3: + second = events[1].get("attempts", []) + third = events[2].get("attempts", []) + if any(item.get("accepted") and item.get("attempt", 0) > 1 for item in second) and sum( + item.get("generation") == 1 and not item.get("accepted") for item in third + ) >= 3: + return + if phase == "replayed" and len(events) >= 3 and any( + item.get("generation", 0) > 1 and item.get("accepted") + for item in events[2].get("attempts", []) + ): + return + time.sleep(0.1) + raise DemoError(f"the webhook receiver did not reach {phase} within {timeout_seconds:g} seconds") + + +def select_dead_letter(report_path: Path) -> tuple[str, str, int]: + report = _read_json_object(report_path) + deliveries = report.get("deliveries") + if not isinstance(deliveries, list): + raise DemoError("webhook list returned no delivery inventory") + matching = [ + delivery + for delivery in deliveries + if isinstance(delivery, dict) + and delivery.get("state") == "dead_lettered" + and delivery.get("replayEligible") is True + ] + if len(matching) != 1: + raise DemoError("webhook list did not expose exactly one replayable dead letter") + delivery = matching[0] + event_id = delivery.get("eventId") + delivery_id = delivery.get("deliveryId") + generation = delivery.get("generation") + if ( + not isinstance(event_id, str) + or not isinstance(delivery_id, str) + or not isinstance(generation, int) + ): + raise DemoError("webhook list returned invalid replay metadata") + return event_id, delivery_id, generation + + +def verify_webhook(root: Path) -> None: + root = _require_root(root) + state = _read_json_object(root / "webhook-receiver-state.json") + events = sorted(state.get("events", {}).values(), key=lambda event: event.get("slot", 0)) + if state.get("verificationFailures") != 0 or len(events) != 4: + raise DemoError("the webhook receiver did not verify exactly four matching events") + if not any(item.get("accepted") for item in events[0].get("attempts", [])): + raise DemoError("the webhook receiver did not prove immediate success") + if not any( + item.get("accepted") and item.get("generation") == 1 and item.get("attempt", 0) > 1 + for item in events[1].get("attempts", []) + ): + raise DemoError("the webhook receiver did not prove automatic retry") + if not any( + item.get("accepted") and item.get("generation", 0) > 1 + for item in events[2].get("attempts", []) + ): + raise DemoError("the webhook receiver did not prove replay success") + + def _request( root: Path, method: str, @@ -520,16 +920,23 @@ def wait_http(url: str, timeout_seconds: float) -> None: def parser() -> argparse.ArgumentParser: result = argparse.ArgumentParser() commands = result.add_subparsers(dest="command", required=True) - commands.add_parser("ports") + ports_parser = commands.add_parser("ports") + ports_parser.add_argument("--count", type=int, choices=(3, 4), default=3) prepare_parser = commands.add_parser("prepare") prepare_parser.add_argument("--root", required=True, type=Path) prepare_parser.add_argument("--fixture", required=True, type=Path) prepare_parser.add_argument("--database-port", required=True, type=int) prepare_parser.add_argument("--mint-port", required=True, type=int) prepare_parser.add_argument("--server-port", required=True, type=int) + prepare_parser.add_argument("--receiver-port", type=int) + prepare_parser.add_argument("--webhook", action="store_true") runtime_parser = commands.add_parser("render-runtime") runtime_parser.add_argument("--root", required=True, type=Path) runtime_parser.add_argument("--revision", required=True) + runtime_parser.add_argument("--webhook", action="store_true") + bind_parser = commands.add_parser("bind-webhook-module") + bind_parser.add_argument("--root", required=True, type=Path) + bind_parser.add_argument("--report", required=True, type=Path) seed_parser = commands.add_parser("seed") seed_parser.add_argument("--root", required=True, type=Path) query_parser = commands.add_parser("query") @@ -539,6 +946,18 @@ def parser() -> argparse.ArgumentParser: wait_parser.add_argument("--timeout", type=float, default=30.0) token_parser = commands.add_parser("store-token") token_parser.add_argument("--out", required=True, type=Path) + receiver_parser = commands.add_parser("serve-webhook-receiver") + receiver_parser.add_argument("--root", required=True, type=Path) + webhook_wait_parser = commands.add_parser("wait-webhook") + webhook_wait_parser.add_argument("--root", required=True, type=Path) + webhook_wait_parser.add_argument( + "--phase", required=True, choices=("dead-letter-ready", "replayed") + ) + webhook_wait_parser.add_argument("--timeout", type=float, default=30.0) + dead_letter_parser = commands.add_parser("select-dead-letter") + dead_letter_parser.add_argument("--report", required=True, type=Path) + verify_webhook_parser = commands.add_parser("verify-webhook") + verify_webhook_parser.add_argument("--root", required=True, type=Path) return result @@ -546,11 +965,21 @@ def main() -> int: args = parser().parse_args() try: if args.command == "ports": - print(*reserve_ports()) + print(*reserve_ports(args.count)) elif args.command == "prepare": - prepare(args.root, args.fixture, args.database_port, args.mint_port, args.server_port) + prepare( + args.root, + args.fixture, + args.database_port, + args.mint_port, + args.server_port, + args.webhook, + args.receiver_port, + ) elif args.command == "render-runtime": - render_runtime(args.root, args.revision) + render_runtime(args.root, args.revision, args.webhook) + elif args.command == "bind-webhook-module": + bind_webhook_module(args.root, args.report) elif args.command == "seed": seed(args.root) elif args.command == "query": @@ -559,6 +988,14 @@ def main() -> int: wait_http(args.url, args.timeout) elif args.command == "store-token": store_token(args.out, sys.stdin.buffer.read(64 * 1024 + 1)) + elif args.command == "serve-webhook-receiver": + serve_webhook_receiver(args.root) + elif args.command == "wait-webhook": + wait_webhook(args.root, args.phase, args.timeout) + elif args.command == "select-dead-letter": + print(*select_dead_letter(args.report)) + elif args.command == "verify-webhook": + verify_webhook(args.root) else: # pragma: no cover raise AssertionError(args.command) except (DemoError, OSError, ValueError, json.JSONDecodeError) as error: diff --git a/products/registry-server/demo/support/test_demo.py b/products/registry-server/demo/support/test_demo.py index 2d0aabcae7..081cc6484d 100755 --- a/products/registry-server/demo/support/test_demo.py +++ b/products/registry-server/demo/support/test_demo.py @@ -8,6 +8,8 @@ import sys import tempfile import unittest +import uuid +from datetime import datetime, timezone from pathlib import Path @@ -66,7 +68,9 @@ def test_prepare_binds_mint_authority_static_jwks_and_secret_database_urls(self) operator = (self.root / "mint/clients/household-demo.yaml").read_text(encoding="utf-8") self.assertIn("registry_principal: synthetic-household-operator", operator) self.assertIn("registry_purpose: household-administration", operator) - no_purpose = (self.root / "mint/clients/household-demo-no-purpose.yaml").read_text(encoding="utf-8") + no_purpose = (self.root / "mint/clients/household-demo-no-purpose.yaml").read_text( + encoding="utf-8" + ) self.assertIn("registry_principal: synthetic-household-operator", no_purpose) self.assertNotIn("registry_purpose", no_purpose) @@ -99,6 +103,131 @@ def test_render_runtime_selects_exact_package_and_listener(self) -> None: self.assertIn(f"root: {self.root.resolve() / 'build/package'}", runtime) self.assertIn("bind: 127.0.0.1:18080", runtime) + def test_webhook_mode_extends_only_the_disposable_module_and_binds_its_compiled_digest( + self, + ) -> None: + webhook_key = self.root / "secrets/webhook-key" + webhook_key.write_bytes(b"k" * 32) + webhook_key.chmod(0o600) + fixture_module = self.fixture / "modules/publicschema-household-demographics/module.yaml" + original_fixture_module = fixture_module.read_bytes() + + DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080, True, 18082) + + project_path = self.root / "project/registry.yaml" + project = project_path.read_text(encoding="utf-8") + module = ( + self.root / "project/modules/publicschema-household-demographics/module.yaml" + ).read_text(encoding="utf-8") + self.assertIn(DEMO.WEBHOOK_MODULE_LOCK, project) + self.assertNotIn(DEMO.WEBHOOK_MODULE_LOCK + " digest:", project) + self.assertIn("id: usual-resident-created-v1", module) + self.assertIn("afterEquals: {residency-status: usual-resident}", module) + self.assertEqual(fixture_module.read_bytes(), original_fixture_module) + + digest = "sha256:" + "3" * 64 + report = self.root / "explain.json" + report.write_text( + json.dumps( + { + "explanation": { + "moduleClosure": [ + { + "id": DEMO.WEBHOOK_MODULE_ID, + "version": "0.1.0", + "digest": digest, + } + ] + } + } + ), + encoding="utf-8", + ) + DEMO.bind_webhook_module(self.root, report) + self.assertIn(f" digest: {digest}", project_path.read_text(encoding="utf-8")) + + runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") + self.assertIn("origin: http://127.0.0.1:18082", runtime) + self.assertIn("networkProfile: loopbackDevelopmentHttp", runtime) + self.assertIn("dnsFamily: dualStackStrict", runtime) + self.assertIn("hmacSha256KeyRef: secret:file/webhook-key", runtime) + self.assertNotIn("k" * 32, runtime) + + def test_receiver_verifies_the_exact_cloudevents_and_signature_contract(self) -> None: + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + event_id = str(uuid.UUID("00000000-0000-4000-8000-000000000001")) + body = json.dumps( + { + "entity": "person", + "packageRevision": "sha256:" + "4" * 64, + "recordId": "00000000-0000-4000-8000-000000000002", + "revision": 1, + "trigger": "created", + "values": { + "person-code": "PERSON-DEMO-001", + "residency-status": "usual-resident", + }, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + headers = { + "accept": "application/json", + "content-type": "application/json", + "ce-specversion": "1.0", + "ce-id": event_id, + "ce-source": ( + "urn:registrystack:registry:publicschema-household:" + f"instance:{DEMO.INSTANCE_ID}" + ), + "ce-type": DEMO.WEBHOOK_EVENT_ID, + "ce-time": "2026-01-01T00:00:00Z", + "ce-dataschema": ( + "urn:registry-server:event-schema:publicschema-household:person:" + f"{DEMO.WEBHOOK_EVENT_ID}:sha256:" + "5" * 64 + ), + "x-registry-event-generation": "1", + "x-registry-delivery-attempt": "1", + "x-registry-delivery-time": now, + "idempotency-key": "sha256:" + "6" * 64, + } + key = b"receiver-test-key" * 4 + headers["x-registry-signature"] = DEMO._expected_webhook_signature(key, headers, body) + + self.assertEqual( + DEMO._verify_webhook_request(key, "/events", headers, body), + (event_id, 1, 1, "sha256:" + "6" * 64), + ) + tampered = dict(headers) + tampered["idempotency-key"] = "sha256:" + "7" * 64 + with self.assertRaises(DEMO.DemoError): + DEMO._verify_webhook_request(key, "/events", tampered, body) + + def test_dead_letter_selection_returns_only_replay_eligible_value_free_metadata(self) -> None: + report = self.root / "list.json" + event_id = "00000000-0000-4000-8000-000000000001" + report.write_text( + json.dumps( + { + "deliveries": [ + { + "eventId": event_id, + "deliveryId": "person.usual-resident-created-v1.webhook", + "generation": 1, + "state": "dead_lettered", + "replayEligible": True, + } + ] + } + ), + encoding="utf-8", + ) + + self.assertEqual( + DEMO.select_dead_letter(report), + (event_id, "person.usual-resident-created-v1.webhook", 1), + ) + def test_seed_is_referentially_closed_and_stable(self) -> None: people, households, memberships = DEMO.seed_spec() person_codes = {person["person-code"] for person in people} diff --git a/products/registry-server/generated/authoring/registry-project.schema.json b/products/registry-server/generated/authoring/registry-project.schema.json index 301bf94ded..a8b39588ac 100644 --- a/products/registry-server/generated/authoring/registry-project.schema.json +++ b/products/registry-server/generated/authoring/registry-project.schema.json @@ -713,6 +713,63 @@ ], "type": "object" }, + "EventConditionSource": { + "description": "Closed Version 1 event selection language.\n\nA tagged shape leaves room for a later, separately governed rule ABI\nwithout turning fields into an ad hoc expression language.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "afterEquals": { + "additionalProperties": { + "$ref": "#/$defs/EventScalarValue" + }, + "default": {}, + "type": "object" + }, + "beforeEquals": { + "additionalProperties": { + "$ref": "#/$defs/EventScalarValue" + }, + "default": {}, + "type": "object" + }, + "changed": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "kind": { + "const": "fields", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "EventScalarValue": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ], + "description": "A comparison literal in the closed field-condition language.\n\nObjects and arrays are refused during source parsing. The compiler then\nvalidates each scalar against the declared Registry field type." + }, "EventSource": { "additionalProperties": false, "properties": { @@ -738,6 +795,16 @@ "type": "null" } ] + }, + "when": { + "anyOf": [ + { + "$ref": "#/$defs/EventConditionSource" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1938,88 +2005,16 @@ ], "type": "object" }, - "WebhookAuthenticationProfile": { - "enum": [ - "hmac_sha256_v1" - ], - "type": "string" - }, - "WebhookDeadLetterMode": { - "enum": [ - "required" - ], - "type": "string" - }, - "WebhookDeliverySource": { - "additionalProperties": false, - "properties": { - "attemptTimeoutMs": { - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "deadLetter": { - "anyOf": [ - { - "$ref": "#/$defs/WebhookDeadLetterMode" - }, - { - "type": "null" - } - ], - "default": null - }, - "initialBackoffMs": { - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "maximumAttempts": { - "format": "uint8", - "maximum": 255, - "minimum": 0, - "type": "integer" - }, - "maximumBackoffMs": { - "format": "uint32", - "minimum": 0, - "type": "integer" - }, - "operatorReplay": { - "type": "boolean" - } - }, - "required": [ - "attemptTimeoutMs", - "initialBackoffMs", - "maximumBackoffMs", - "maximumAttempts", - "operatorReplay" - ], - "type": "object" - }, "WebhookSource": { "additionalProperties": false, "description": "Governed, destination-neutral webhook subscription.\n\nDeployment configuration may bind `destination_id` to transport details\nand tighten these bounds, but cannot supply or widen this authority.", "properties": { - "authenticationProfile": { - "$ref": "#/$defs/WebhookAuthenticationProfile" - }, - "classificationCeiling": { - "$ref": "#/$defs/Classification" - }, - "delivery": { - "$ref": "#/$defs/WebhookDeliverySource" - }, "destinationId": { "type": "string" } }, "required": [ - "destinationId", - "classificationCeiling", - "authenticationProfile", - "delivery" + "destinationId" ], "type": "object" } From 531c6eb234d05c962cc4be184054cc8e3dcce6c9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 30 Aug 2026 17:58:24 +0700 Subject: [PATCH 05/19] feat(server): add governed query and derived fields Signed-off-by: Jeremi Joslin --- crates/registry-server/Cargo.toml | 4 +- crates/registry-server/src/api/mod.rs | 1915 +++++++++++++---- crates/registry-server/src/api/service.rs | 236 +- crates/registry-server/src/artifacts.rs | 30 +- crates/registry-server/src/audit.rs | 2 + crates/registry-server/src/auth.rs | 229 +- crates/registry-server/src/compiler.rs | 1361 +++++++++++- crates/registry-server/src/contract.rs | 361 +++- crates/registry-server/src/cursor.rs | 187 +- crates/registry-server/src/data.rs | 27 +- crates/registry-server/src/derived_sql.rs | 220 ++ crates/registry-server/src/fixtures.rs | 340 ++- crates/registry-server/src/generated_ddl.rs | 406 +++- crates/registry-server/src/lib.rs | 5 +- crates/registry-server/src/logical_names.rs | 46 + crates/registry-server/src/model.rs | 84 + crates/registry-server/src/package.rs | 261 ++- .../registry-server/src/postgres/catalog.rs | 195 +- .../registry-server/src/postgres/context.rs | 8 + crates/registry-server/src/postgres/mod.rs | 1 + crates/registry-server/src/postgres/read.rs | 1657 ++++++++++---- crates/registry-server/src/postgres/roles.rs | 33 +- crates/registry-server/src/postgres/schema.rs | 88 +- crates/registry-server/src/query.rs | 1492 +++++++++++++ crates/registry-server/src/runtime_config.rs | 72 +- crates/registry-server/src/startup.rs | 19 +- crates/registry-server/src/tooling.rs | 7 + .../tests/compiler_contract.rs | 356 ++- crates/registry-server/tests/http_auth.rs | 96 +- .../registry-server/tests/http_read_only.rs | 724 ++++++- .../registry-server/tests/migration_plan.rs | 1 + .../tests/package_change_plan.rs | 209 +- .../tests/postgres_compiled_schema.rs | 275 ++- .../tests/postgres_data_export.rs | 15 +- .../tests/postgres_fixture_journeys.rs | 5 +- .../tests/postgres_migration.rs | 1 + .../registry-server/tests/postgres_package.rs | 161 +- crates/registry-server/tests/postgres_read.rs | 58 +- .../registry-server/tests/postgres_startup.rs | 1 + .../tests/postgres_webhook_delivery.rs | 2 - .../tests/postgres_webhook_outbox.rs | 2 - .../registry-server/tests/runtime_config.rs | 3 - .../tests/schema_fingerprint_rehearsal.rs | 1 - crates/registry-server/tests/startup_http.rs | 12 +- .../registry-server/tests/startup_ordering.rs | 1 + .../tests/support/pilot_acceptance_harness.rs | 39 +- crates/registry-serverctl/src/lib.rs | 274 ++- crates/registry-serverctl/tests/cli.rs | 105 +- crates/registry-serverctl/tests/diff.rs | 2 +- .../registry-server/ACCEPTANCE-JOURNEYS.md | 13 +- products/registry-server/DECISIONS.md | 4 + .../registry-server/DEFINITION-OF-DONE.md | 6 +- products/registry-server/IMPLEMENTATION.md | 5 +- products/registry-server/README.md | 13 +- .../publicschema-household-core/module.yaml | 8 + .../module.yaml | 13 + .../sql/household-demographics.sql | 49 + .../publicschema-household/registry.yaml | 51 +- .../tests/journeys.yaml | 212 +- .../contracts/acceptance-scenario-matrix.yaml | 2 +- .../contracts/artifact-inventory.yaml | 1 + .../contracts/definition-of-done.yaml | 2 +- .../contracts/package-layout.yaml | 1 + .../contracts/security-invariant-matrix.yaml | 2 + .../contracts/security-test-traceability.yaml | 2 + products/registry-server/demo/README.md | 39 +- products/registry-server/demo/support/demo.py | 68 +- .../registry-server/demo/support/test_demo.py | 12 +- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 31 + .../authoring/registry-project.schema.json | 539 +++++ .../generated/manifest/dcat.jsonld | 1 + .../generated/manifest/registry-manifest.json | 1 + .../generated/metadata/registry.json | 1 + .../generated/openapi.json | 1 + .../generated/postgres/schema.sql | 127 ++ .../schemas/group-membership.schema.json | 1 + .../generated/schemas/household.schema.json | 1 + .../generated/schemas/person.schema.json | 1 + .../scripts/check-generated.sh | 25 +- .../scripts/check_source_neutrality.py | 2 - .../scripts/compare-generated-tree.py | 27 +- .../scripts/test_check_source_neutrality.py | 11 + .../scripts/test_generated_gates.py | 1 + .../scripts/validate_product.py | 3 +- 85 files changed, 11240 insertions(+), 1667 deletions(-) create mode 100644 crates/registry-server/src/derived_sql.rs create mode 100644 crates/registry-server/src/logical_names.rs create mode 100644 crates/registry-server/src/query.rs create mode 100644 products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/sql/household-demographics.sql create mode 100644 products/registry-server/generated/publicschema-household/generated/manifest/dcat.jsonld create mode 100644 products/registry-server/generated/publicschema-household/generated/manifest/registry-manifest.json create mode 100644 products/registry-server/generated/publicschema-household/generated/metadata/registry.json create mode 100644 products/registry-server/generated/publicschema-household/generated/openapi.json create mode 100644 products/registry-server/generated/publicschema-household/generated/postgres/schema.sql create mode 100644 products/registry-server/generated/publicschema-household/generated/schemas/group-membership.schema.json create mode 100644 products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json create mode 100644 products/registry-server/generated/publicschema-household/generated/schemas/person.schema.json diff --git a/crates/registry-server/Cargo.toml b/crates/registry-server/Cargo.toml index 7e36a8504d..d967036bec 100644 --- a/crates/registry-server/Cargo.toml +++ b/crates/registry-server/Cargo.toml @@ -140,7 +140,7 @@ runtime = [ postgres-test = ["runtime", "dep:tower", "registry-platform-httputil/test-support"] postgres-tls-test = ["runtime"] schema = ["dep:schemars"] -tooling = ["dep:pg_query", "dep:tempfile", "dep:tower"] +tooling = ["dep:tempfile", "dep:tower"] [dependencies] axum = { workspace = true, optional = true } @@ -164,7 +164,7 @@ registry-platform-httpsec = { workspace = true, features = ["server"], optional registry-platform-httputil = { workspace = true, optional = true } registry-platform-oidc = { workspace = true, optional = true } jsonschema.workspace = true -pg_query = { workspace = true, optional = true } +pg_query.workspace = true rustls = { workspace = true, optional = true } rustix = { workspace = true, optional = true } schemars = { workspace = true, optional = true } diff --git a/crates/registry-server/src/api/mod.rs b/crates/registry-server/src/api/mod.rs index e23c0a9b08..c640156305 100644 --- a/crates/registry-server/src/api/mod.rs +++ b/crates/registry-server/src/api/mod.rs @@ -3,9 +3,11 @@ mod context; mod service; +#[allow(dead_code)] +#[path = "../query.rs"] +mod strict_query; use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::fmt; use std::sync::Arc; use axum::body::{to_bytes, Body}; @@ -25,27 +27,35 @@ pub use context::{ VerifiedRequestClaims, VerifiedRowBoundary, }; pub use service::{ - BatchMutationInput, CompiledReadQuery, ConditionalMutationInput, HeldReadResponse, HttpService, - ReadFilterClause, ReadRuntimeIdentity, ReadServiceError, ReadinessProbe, RecordReadRefusal, - RecordReadRequest, RecordReadService, RevisionReadRefusal, RevisionReadRequest, - RevisionReadService, ServiceFuture, + BatchMutationInput, CompiledLookupSelector, CompiledReadQuery, ConditionalMutationInput, + HeldReadResponse, HttpService, LookupSelectorValue, ReadFilterExpr, ReadFilterOperator, + ReadFilterPredicate, ReadLogicalOp, ReadOrderClause, ReadProjectionField, ReadRuntimeIdentity, + ReadServiceError, ReadinessProbe, RecordReadKind, RecordReadRefusal, RecordReadRequest, + RecordReadService, RevisionReadRefusal, RevisionReadRequest, RevisionReadService, + ServiceFuture, }; use crate::auth::{authenticate_request, RegistryAuthenticator}; -use crate::contract::{AccessProfileSource, BoundaryOperator, Classification, Operation}; -use crate::cursor::{now_unix_seconds, CursorBinding, CursorError}; +use crate::contract::{ + AccessProfileSource, BoundaryOperator, Classification, FieldTypeSource, LookupValueOrigin, + Operation, +}; +use crate::cursor::{ + now_unix_seconds, CursorBinding, CursorError, CursorFilterExpr, CursorFilterOperator, + CursorFilterPredicate, CursorLogicalOp, CursorOrderClause, CursorProjectionField, + CursorQueryScope, +}; use crate::idempotency::{HeldResponse, PermittedResponseHeader}; use crate::model::{ - CompiledEntity, CompiledMetadataEntity, CompiledMetadataEntry, CompiledQueryFilterOperator, - CompiledQueryKind, CompiledQueryOperation, CompiledRevisionKind, CompiledRoute, - MAX_REVISION_HISTORY_RECORDS, + CompiledEntity, CompiledMetadataEntity, CompiledMetadataEntry, CompiledQueryKind, + CompiledQueryOperation, CompiledQuerySortDirection, CompiledReadPath, CompiledRevisionKind, + CompiledRoute, MAX_REVISION_HISTORY_RECORDS, }; use crate::mutation::{parse_json_patch_document, BatchMutationItem, MutationError}; use uuid::Uuid; -const MAX_FIELDS: usize = 128; -const MAX_FIELD_BYTES: usize = 128; const MAX_MUTATION_BODY_BYTES: usize = 2 * 1024 * 1024; +const MAX_LOOKUP_BODY_BYTES: usize = 16 * 1024; const MAX_IDEMPOTENCY_KEY_BYTES: usize = 256; const MAX_RAW_QUERY_BYTES: usize = 16 * 1024; const MAX_FILTER_CLAUSES: usize = 32; @@ -74,6 +84,10 @@ fn route_set(service: Arc) -> Router { &route.path, get(read_dispatch).layer(Extension(route.clone())), ), + Operation::Lookup => app.route( + &route.path, + post(lookup_dispatch).layer(Extension(route.clone())), + ), Operation::Revisions if service.revisions.is_some() => app.route( &route.path, get(revision_dispatch).layer(Extension(route.clone())), @@ -170,7 +184,7 @@ async fn openapi( .map(|Extension(value)| value) .unwrap_or_else(VerifiedRequestClaims::anonymous); let visible = visible_surfaces(&service, &claims, &options); - if options.access_profile.is_some() && visible.is_empty() { + if options.access_profile().is_some() && visible.is_empty() { return concealed(); } @@ -189,6 +203,10 @@ async fn openapi( "x-registry-entity".to_owned(), json!(surface.route.entity_id), ), + ( + "x-registry-responseEntity".to_owned(), + json!(surface.response_entity.id), + ), ( "x-registry-operation".to_owned(), json!(operation_name(surface.route.operation)), @@ -208,6 +226,9 @@ async fn openapi( Value::String(query_kind_name(kind).to_owned()), ); operation.insert("parameters".to_owned(), query_parameters(kind)); + } else if surface.route.operation == Operation::Lookup { + operation.insert("parameters".to_owned(), lookup_parameters()); + operation.insert("requestBody".to_owned(), lookup_request_body()); } else if let Some(kind) = surface.route.revision_kind { operation.insert("parameters".to_owned(), revision_parameters(kind)); operation.insert( @@ -256,7 +277,7 @@ async fn openapi( Value::Object(operation), ); readable_by_entity - .entry(surface.route.entity_id.clone()) + .entry(surface.response_entity.id.clone()) .and_modify(|fields| { *fields = fields .intersection(&surface.readable_fields) @@ -293,7 +314,7 @@ async fn registry_metadata( .map(|Extension(value)| value) .unwrap_or_else(VerifiedRequestClaims::anonymous); let visible = visible_metadata_entries(&service, &claims, &options); - if options.access_profile.is_some() && visible.is_empty() { + if options.access_profile().is_some() && visible.is_empty() { return concealed(); } @@ -357,7 +378,9 @@ async fn entity_schema( .unwrap_or_else(VerifiedRequestClaims::anonymous); let surfaces = visible_surfaces(&service, &claims, &options) .into_iter() - .filter(|surface| surface.route.entity_id == entity_id) + .filter(|surface| { + surface.response_entity.id == entity_id || surface.route.entity_id == entity_id + }) .collect::>(); let Some(first) = surfaces.first() else { return concealed(); @@ -372,7 +395,8 @@ async fn entity_schema( .cloned() .collect() }); - match filtered_schema(&service, &entity_id, &readable) { + let schema_entity = first.response_entity.id.clone(); + match filtered_schema(&service, &schema_entity, &readable) { Some(schema) => Json(schema).into_response(), None => concealed(), } @@ -407,10 +431,10 @@ async fn read_dispatch( .await; return response; }; - let query = if route.operation == Operation::List { - match read_query(&service, &route, &surface, &options).await { - Ok(query) => query, - Err(ReadQueryError::Invalid) => { + + match route.operation { + Operation::Get => { + if surface.read_path.is_some() || options.has_non_projection_query_members() { return audited_read_refusal( &service, &route, @@ -420,41 +444,109 @@ async fn read_dispatch( ) .await; } - Err(ReadQueryError::CursorInvalid) => { + let Some(record_id) = path.get("record_id") else { + return audited_read_refusal(&service, &route, &surface, None, concealed()).await; + }; + if !valid_canonical_record_uuid(record_id) { return audited_read_refusal( &service, &route, &surface, - path.get("record_id"), - cursor_invalid(), + Some(record_id), + concealed(), ) .await; } + let readable_fields = match resolve_select( + surface.response_entity, + &surface.readable_fields, + options.select_clause(), + ) { + Ok(Some(fields)) => fields, + Ok(None) => surface.readable_fields.clone(), + Err(()) => { + return audited_read_refusal( + &service, + &route, + &surface, + Some(record_id), + concealed(), + ) + .await; + } + }; + let request = RecordReadRequest { + entity_id: route.entity_id.clone(), + operation_id: route.id.clone(), + method: route.method, + context: surface.context, + selected_fields: readable_fields, + kind: RecordReadKind::Get { + id: record_id.clone(), + }, + maximum_records: 1, + }; + match service.records.get(request).await { + Ok(Some(record)) => exact_json(record), + Ok(None) => concealed(), + Err(ReadServiceError::Unavailable) => unavailable(), + Err(ReadServiceError::CursorInvalid) => cursor_invalid(), + } } - } else { - if options.has_list_query_members() { - return audited_read_refusal( + Operation::List => { + if surface.read_path.is_some() + && !path + .get("record_id") + .is_some_and(|record_id| valid_canonical_record_uuid(record_id)) + { + return audited_read_refusal( + &service, + &route, + &surface, + path.get("record_id"), + concealed(), + ) + .await; + } + let query = match read_query( &service, &route, &surface, - path.get("record_id"), - invalid_query(), + &options, + path.get("record_id").map(String::as_str), ) - .await; - } - None - }; - let readable_fields = if let Some(query) = &query { - query - .cursor_binding - .selected_fields - .iter() - .cloned() - .collect::>() - } else { - match &options.fields { - Some(fields) if fields.is_subset(&surface.readable_fields) => fields.clone(), - Some(_) => { + .await + { + Ok(Some(query)) => query, + Ok(None) => return unavailable(), + Err(ReadQueryError::Invalid) => { + return audited_read_refusal( + &service, + &route, + &surface, + path.get("record_id"), + invalid_query(), + ) + .await; + } + Err(ReadQueryError::CursorInvalid) => { + return audited_read_refusal( + &service, + &route, + &surface, + path.get("record_id"), + cursor_invalid(), + ) + .await; + } + }; + let readable_fields = query + .cursor_binding + .selected_fields + .iter() + .cloned() + .collect::>(); + if !readable_fields.is_subset(&surface.readable_fields) { return audited_read_refusal( &service, &route, @@ -464,45 +556,123 @@ async fn read_dispatch( ) .await; } - None => surface.readable_fields.clone(), + let maximum_records = usize::from(query.page_size) + 1; + let kind = if let Some(read_path) = surface.read_path { + RecordReadKind::Relationship { + root_id: path + .get("record_id") + .expect("relationship root id was validated") + .clone(), + path_id: read_path.id.clone(), + plan: query, + } + } else { + RecordReadKind::List { plan: query } + }; + let request = RecordReadRequest { + entity_id: route.entity_id.clone(), + operation_id: route.id.clone(), + method: route.method, + context: surface.context, + selected_fields: readable_fields, + kind, + maximum_records, + }; + match service.records.list(request).await { + Ok(response) => exact_json_no_store(response), + Err(ReadServiceError::Unavailable) => unavailable(), + Err(ReadServiceError::CursorInvalid) => cursor_invalid(), + } + } + _ => concealed(), + } +} + +async fn lookup_dispatch( + State(service): State>, + Extension(route): Extension, + claims: Option>, + RawQuery(raw_query): RawQuery, + headers: HeaderMap, + body: Body, +) -> Response { + let claims = claims + .map(|Extension(value)| value) + .unwrap_or_else(VerifiedRequestClaims::anonymous); + let options = match QueryOptions::parse(raw_query.as_deref(), true) { + Ok(options) => options, + Err(QueryParseError::Invalid) => { + return audited_known_read_refusal(&service, &route, &claims, None, invalid_query()) + .await; + } + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + return audited_read_concealment(&service, &route, &options, &claims, None).await; + }; + if surface.read_path.is_some() || options.has_non_projection_query_members() { + return audited_read_refusal(&service, &route, &surface, None, invalid_query()).await; + } + let readable_fields = match resolve_select( + surface.response_entity, + &surface.readable_fields, + options.select_clause(), + ) { + Ok(Some(fields)) => fields, + Ok(None) => surface.readable_fields.clone(), + Err(()) => { + return audited_read_refusal(&service, &route, &surface, None, concealed()).await; + } + }; + if !single_content_type(&headers, "application/json") { + return audited_read_refusal(&service, &route, &surface, None, unsupported_media_type()) + .await; + } + let Ok(body) = bounded_body_to(body, MAX_LOOKUP_BODY_BYTES).await else { + return audited_read_refusal(&service, &route, &surface, None, invalid_request()).await; + }; + let body = match parse_lookup_body(&body) { + Ok(body) => body, + Err(()) => { + return audited_read_refusal(&service, &route, &surface, None, invalid_request()).await; + } + }; + let selector = match resolve_lookup_selector(&service, &route, &surface, &claims, &body) { + Ok(selector) => selector, + Err(LookupResolutionError::InvalidRequest) => { + return audited_read_refusal(&service, &route, &surface, None, invalid_request()).await; + } + Err(LookupResolutionError::Unresolved) => { + return audited_read_refusal(&service, &route, &surface, None, lookup_unresolved()) + .await; } }; if !readable_fields.is_subset(&surface.readable_fields) { - return audited_read_refusal( - &service, - &route, - &surface, - path.get("record_id"), - concealed(), - ) - .await; + return audited_read_refusal(&service, &route, &surface, None, concealed()).await; + } + let Some(operation) = + lookup_query_operation_for_selector(&service, &route, &surface, &selector.selector_id) + else { + return audited_read_refusal(&service, &route, &surface, None, lookup_unresolved()).await; + }; + if !readable_fields + .iter() + .all(|field| operation.projection_fields.contains(field)) + { + return audited_read_refusal(&service, &route, &surface, None, concealed()).await; } let request = RecordReadRequest { entity_id: route.entity_id.clone(), operation_id: route.id.clone(), method: route.method, - record_id: path.get("record_id").cloned(), context: surface.context, - selected_fields: readable_fields.clone(), - maximum_records: query - .as_ref() - .map_or(1, |query| usize::from(query.page_size) + 1), - query, + selected_fields: readable_fields, + kind: RecordReadKind::Lookup { selector }, + maximum_records: 2, }; - - match route.operation { - Operation::Get => match service.records.get(request).await { - Ok(Some(record)) => exact_json(record), - Ok(None) => concealed(), - Err(ReadServiceError::Unavailable) => unavailable(), - Err(ReadServiceError::CursorInvalid) => cursor_invalid(), - }, - Operation::List => match service.records.list(request).await { - Ok(response) => exact_json_no_store(response), - Err(ReadServiceError::Unavailable) => unavailable(), - Err(ReadServiceError::CursorInvalid) => cursor_invalid(), - }, - _ => concealed(), + match service.records.lookup(request).await { + Ok(Some(record)) => exact_json_no_store(record), + Ok(None) | Err(ReadServiceError::Unavailable) => lookup_unresolved(), + Err(ReadServiceError::CursorInvalid) => cursor_invalid(), } } @@ -673,7 +843,7 @@ async fn audited_revision_concealment( claims: &VerifiedRequestClaims, target_record: Option<&String>, ) -> Response { - let selected_access_profile = options.access_profile.as_ref().and_then(|profile| { + let selected_access_profile = options.access_profile().and_then(|profile| { route .access_profiles .iter() @@ -751,7 +921,7 @@ async fn audited_read_concealment( claims: &VerifiedRequestClaims, target_record: Option<&String>, ) -> Response { - let selected_access_profile = options.access_profile.as_ref().and_then(|profile| { + let selected_access_profile = options.access_profile().and_then(|profile| { route .access_profiles .iter() @@ -1256,8 +1426,8 @@ async fn audited_mutation_concealment( target_record: Option<&str>, ) -> Response { let selected_profile = options - .access_profile - .as_deref() + .access_profile() + .map(String::as_str) .or(Some(route.default_access_profile.as_str())); match mutations .record_refusal( @@ -1282,8 +1452,10 @@ async fn not_found() -> Response { struct AuthorizedSurface<'a> { route: &'a CompiledRoute, entity: &'a CompiledEntity, + response_entity: &'a CompiledEntity, context: AuthorizedRequestContext, readable_fields: BTreeSet, + read_path: Option<&'a CompiledReadPath>, } fn visible_surfaces<'a>( @@ -1336,13 +1508,29 @@ fn authorize_route<'a>( claims: &VerifiedRequestClaims, options: &QueryOptions, ) -> Option> { - let access = - service.registry.access().entries.iter().find(|entry| { - entry.entity_id == route.entity_id && entry.operation == route.operation - })?; + if let Some((read_path, response_entity)) = read_path_for_route(service, route) { + return authorize_read_path_route( + service, + route, + read_path, + response_entity, + claims, + options, + ); + } + authorize_direct_route(service, route, claims, options) +} + +fn authorize_direct_route<'a>( + service: &'a HttpService, + route: &'a CompiledRoute, + claims: &VerifiedRequestClaims, + options: &QueryOptions, +) -> Option> { + let access = access_entry_for_route(service, route)?; let selected_profile = options - .access_profile - .as_deref() + .access_profile() + .map(String::as_str) .unwrap_or(&access.default_profile_id); if !access.profile_ids.contains(selected_profile) || !route @@ -1407,6 +1595,91 @@ fn authorize_route<'a>( Some(AuthorizedSurface { route, entity, + response_entity: entity, + context: AuthorizedRequestContext::new( + claims.principal().map(str::to_owned), + claims.purpose().map(str::to_owned), + selected_profile.to_owned(), + row_boundaries, + ), + readable_fields, + read_path: None, + }) +} + +fn authorize_read_path_route<'a>( + service: &'a HttpService, + route: &'a CompiledRoute, + read_path: &'a CompiledReadPath, + response_entity: &'a CompiledEntity, + claims: &VerifiedRequestClaims, + options: &QueryOptions, +) -> Option> { + let access = access_entry_for_route(service, route)?; + let selected_profile = options + .access_profile() + .map(String::as_str) + .unwrap_or(&access.default_profile_id); + if !access.profile_ids.contains(selected_profile) + || !route + .access_profiles + .iter() + .any(|id| id == selected_profile) + { + return None; + } + let entity = service.registry.entities().get(&route.entity_id)?; + let profile = entity.access_profiles.get(selected_profile)?; + let grant = profile + .read_paths + .iter() + .find(|grant| grant.path == read_path.id)?; + if route.operation != Operation::List || route.query_kind != Some(CompiledQueryKind::List) { + return None; + } + if profile.anonymous { + if entity.classification != Classification::Public + || response_entity.classification != Classification::Public + { + return None; + } + } else { + let expected_claim = profile.principal_claim.as_deref()?; + if claims.principal_claim() != Some(expected_claim) || claims.principal().is_none() { + return None; + } + } + if !profile + .required_scopes + .iter() + .all(|scope| claims.has_scope(scope)) + { + return None; + } + if !profile.required_purposes.is_empty() + && !claims + .purpose() + .is_some_and(|purpose| profile.required_purposes.contains(purpose)) + { + return None; + } + let row_boundaries = verified_row_boundaries(profile, claims)?; + let readable_fields = grant + .readable_fields + .iter() + .filter(|field| { + !profile.anonymous + || response_entity + .fields + .get(*field) + .is_some_and(|field| field.classification == Classification::Public) + }) + .cloned() + .collect(); + Some(AuthorizedSurface { + route, + entity, + response_entity, context: AuthorizedRequestContext::new( claims.principal().map(str::to_owned), claims.purpose().map(str::to_owned), @@ -1414,12 +1687,47 @@ fn authorize_route<'a>( row_boundaries, ), readable_fields, + read_path: Some(read_path), }) } +fn access_entry_for_route<'a>( + service: &'a HttpService, + route: &CompiledRoute, +) -> Option<&'a crate::model::CompiledAccessEntry> { + service + .registry + .access() + .entries + .iter() + .find(|entry| entry.route_id == route.id && entry.operation == route.operation) + .or_else(|| { + if route.id.contains(".path.") { + return None; + } + service.registry.access().entries.iter().find(|entry| { + entry.entity_id == route.entity_id && entry.operation == route.operation + }) + }) +} + +fn read_path_for_route<'a>( + service: &'a HttpService, + route: &CompiledRoute, +) -> Option<(&'a CompiledReadPath, &'a CompiledEntity)> { + let entity = service.registry.entities().get(&route.entity_id)?; + let path = entity.read_paths.values().find(|path| { + route.id == format!("records.{}.path.{}", entity.id, path.id) + && route.path == format!("/v1/records/{}/{{record_id}}/{}", entity.route, path.route) + })?; + let response_entity = service.registry.entities().get(&path.to)?; + Some((path, response_entity)) +} + fn served_operation(service: &HttpService, route: &CompiledRoute) -> bool { match route.operation { Operation::Get | Operation::List => true, + Operation::Lookup => true, Operation::Create => service.mutations.is_some(), Operation::Batch => { service.mutations.is_some() @@ -1482,6 +1790,7 @@ async fn read_query( route: &CompiledRoute, surface: &AuthorizedSurface<'_>, options: &QueryOptions, + root_id: Option<&str>, ) -> Result, ReadQueryError> { let Some(kind) = route.query_kind else { return Ok(None); @@ -1489,7 +1798,8 @@ async fn read_query( let Some(operation) = query_operation_for_route(service, route, surface, kind) else { return Err(ReadQueryError::Invalid); }; - if let Some(token) = &options.cursor { + let scope = cursor_scope(surface, root_id)?; + if let Some(token) = options.skiptoken() { let payload = service .cursors .open_after_authorization(token, now_unix_seconds(), |payload| { @@ -1497,6 +1807,10 @@ async fn read_query( || payload.binding.query_operation_id != operation.id || payload.binding.query_kind != kind || payload.binding.selected_profile != surface.context.selected_profile() + || payload.binding.include_count != payload.query.include_count + || payload.binding.page_size != payload.query.page_size + || payload.binding.temporal_instant != payload.query.temporal_instant + || payload.query.scope != scope { return Err(CursorError::Mismatch); } @@ -1506,13 +1820,34 @@ async fn read_query( .iter() .cloned() .collect::>(); - let filters = cursor_filters_to_read_filters(&payload.query.filters) + let projection = read_projection_from_cursor( + surface.response_entity, + operation, + &fields, + &payload.query.projection, + ) + .map_err(|_| CursorError::Mismatch)?; + let filter = payload + .query + .filter + .as_ref() + .map(|filter| read_filter_expr_from_cursor(surface.response_entity, filter)) + .transpose() + .map_err(|_| CursorError::Mismatch)?; + let order = payload + .query + .order + .as_ref() + .map(|order| { + read_order_clause_from_cursor(surface.response_entity, operation, order) + }) + .transpose() .map_err(|_| CursorError::Mismatch)?; validate_query_shape( - surface.entity, + surface.response_entity, operation, - &filters, - payload.query.sort.as_deref(), + filter.as_ref(), + order.as_ref(), payload.binding.page_size, ) .map_err(|_| CursorError::Mismatch)?; @@ -1523,10 +1858,13 @@ async fn read_query( operation, CursorBindingQuery { selected_fields: &fields, - filters: &filters, - sort: payload.query.sort.as_deref(), + projection: &projection, + filter: filter.as_ref(), + order: order.as_ref(), + include_count: payload.binding.include_count, page_size: payload.binding.page_size, temporal_instant: payload.binding.temporal_instant.as_deref(), + scope: &scope, }, ) }) @@ -1540,37 +1878,92 @@ async fn read_query( if fields.is_empty() || !fields.is_subset(&surface.readable_fields) { return Err(ReadQueryError::CursorInvalid); } - let filters = cursor_filters_to_read_filters(&payload.query.filters)?; + let projection = read_projection_from_cursor( + surface.response_entity, + operation, + &fields, + &payload.query.projection, + )?; + let filter = payload + .query + .filter + .as_ref() + .map(|filter| read_filter_expr_from_cursor(surface.response_entity, filter)) + .transpose()?; + let order = payload + .query + .order + .as_ref() + .map(|order| read_order_clause_from_cursor(surface.response_entity, operation, order)) + .transpose()?; return Ok(Some(CompiledReadQuery { route_id: route.id.clone(), query_operation_id: operation.id.clone(), kind, cursor_binding: payload.binding.clone(), cursor_query: payload.query.clone(), - filters, - sort: payload.query.sort, + projection, + filter, + order, + include_count: payload.binding.include_count, page_size: payload.binding.page_size, temporal_instant: payload.binding.temporal_instant, continuation: Some(payload.continuation), })); } - let fields = match &options.fields { - Some(fields) if fields.is_subset(&surface.readable_fields) => fields.clone(), - Some(_) => return Err(ReadQueryError::Invalid), - None => operation.projection_fields.iter().cloned().collect(), + let query_options = options.query_options().ok_or(ReadQueryError::Invalid)?; + let fields = match resolve_select( + surface.response_entity, + &surface.readable_fields, + query_options.select.as_ref(), + ) { + Ok(Some(fields)) => fields, + Ok(None) => operation.projection_fields.iter().cloned().collect(), + Err(()) => return Err(ReadQueryError::Invalid), + }; + if fields.is_empty() + || !fields.is_subset(&surface.readable_fields) + || !fields + .iter() + .all(|field| operation.projection_fields.contains(field)) + { + return Err(ReadQueryError::Invalid); + } + let projection = projection_plan(surface.response_entity, &fields)?; + let filter = first_page_filter_expr( + surface.response_entity, + operation, + query_options.filter.as_ref(), + )?; + let order = match &query_options.orderby { + Some(orderby) => { + if orderby.direction != strict_query::OrderDirection::Asc { + return Err(ReadQueryError::Invalid); + } + Some(resolve_order_clause( + surface.response_entity, + operation, + orderby, + )?) + } + None => None, }; - if fields.is_empty() || !fields.is_subset(&surface.readable_fields) { + let page_size = query_options + .top + .map(u16::try_from) + .transpose() + .map_err(|_| ReadQueryError::Invalid)? + .unwrap_or(operation.max_page_size); + let include_count = query_options.count.unwrap_or(false); + if include_count && !operation.allow_count { return Err(ReadQueryError::Invalid); } - let filters = first_page_filters(operation, &options.filters)?; - let sort = options.sort.clone(); - let page_size = options.page_size.unwrap_or(operation.max_page_size); validate_query_shape( - surface.entity, + surface.response_entity, operation, - &filters, - sort.as_deref(), + filter.as_ref(), + order.as_ref(), page_size, )?; let temporal_instant = temporal_instant_for(kind, options)?; @@ -1581,24 +1974,35 @@ async fn read_query( operation, CursorBindingQuery { selected_fields: &fields, - filters: &filters, - sort: sort.as_deref(), + projection: &projection, + filter: filter.as_ref(), + order: order.as_ref(), + include_count, page_size, temporal_instant: temporal_instant.as_deref(), + scope: &scope, }, ) .map_err(|_| ReadQueryError::Invalid)?; + let cursor_query = cursor_query_from_plan( + &projection, + filter.as_ref(), + order.as_ref(), + include_count, + page_size, + temporal_instant.clone(), + scope, + ); Ok(Some(CompiledReadQuery { route_id: route.id.clone(), query_operation_id: operation.id.clone(), kind, cursor_binding: binding, - cursor_query: crate::cursor::CursorQuery { - filters: cursor_filters(&filters), - sort: sort.clone(), - }, - filters, - sort, + cursor_query, + projection, + filter, + order, + include_count, page_size, temporal_instant, continuation: None, @@ -1618,181 +2022,408 @@ fn query_operation_for_route<'a>( .iter() .find(|operation| { operation.route_id == route.id - && operation.entity_id == route.entity_id + && operation.entity_id == surface.response_entity.id && operation.profile_id == surface.context.selected_profile() && operation.kind == kind }) } -fn first_page_filters( - operation: &CompiledQueryOperation, - filters: &[RawFilterClause], -) -> Result, ReadQueryError> { - let mut result = Vec::new(); - let mut in_values: BTreeMap> = BTreeMap::new(); - let mut non_in_fields = BTreeSet::new(); - for filter in filters { - let field = operation - .filter_fields - .iter() - .find(|field| field.field == filter.field) - .ok_or(ReadQueryError::Invalid)?; - if !field.operators.contains(&filter.operator) { - return Err(ReadQueryError::Invalid); - } - let values = match filter.operator { - CompiledQueryFilterOperator::Equals | CompiledQueryFilterOperator::In => { - vec![filter.value.clone()] - } - CompiledQueryFilterOperator::Prefix => vec![filter.value.clone()], - CompiledQueryFilterOperator::Range => { - let (lower, upper) = filter - .value - .split_once("..") - .ok_or(ReadQueryError::Invalid)?; - if lower.is_empty() || upper.is_empty() { - return Err(ReadQueryError::Invalid); - } - vec![lower.to_owned(), upper.to_owned()] - } - CompiledQueryFilterOperator::IsNull | CompiledQueryFilterOperator::IsNotNull => { - if filter.value != "true" { - return Err(ReadQueryError::Invalid); - } - vec!["true".to_owned()] - } - }; - if filter.operator == CompiledQueryFilterOperator::In { - if non_in_fields.contains(&filter.field) { +fn lookup_query_operation_for_selector<'a>( + service: &'a HttpService, + route: &CompiledRoute, + surface: &AuthorizedSurface<'_>, + selector_id: &str, +) -> Option<&'a CompiledQueryOperation> { + let selector = surface.entity.selector_profiles.get(selector_id)?; + service + .registry + .queries() + .operations + .iter() + .find(|operation| { + operation.route_id == route.id + && operation.entity_id == surface.entity.id + && operation.profile_id == surface.context.selected_profile() + && operation.kind == CompiledQueryKind::List + && operation.read_path.is_none() + && operation.selector_fields == selector.fields + }) +} + +fn cursor_scope( + surface: &AuthorizedSurface<'_>, + root_id: Option<&str>, +) -> Result { + match surface.read_path { + Some(read_path) => { + let root_id = root_id.ok_or(ReadQueryError::Invalid)?; + if !valid_canonical_record_uuid(root_id) { return Err(ReadQueryError::Invalid); } - in_values - .entry(filter.field.clone()) - .or_default() - .insert(values[0].clone()); - continue; + Ok(CursorQueryScope::Relationship { + path_id: read_path.id.clone(), + root_id: root_id.to_owned(), + }) } - if in_values.contains_key(&filter.field) { - return Err(ReadQueryError::Invalid); + None => Ok(CursorQueryScope::Collection {}), + } +} + +fn resolve_select( + entity: &CompiledEntity, + readable_fields: &BTreeSet, + select: Option<&strict_query::SelectClause>, +) -> Result>, ()> { + let Some(select) = select else { + return Ok(None); + }; + let mut fields = BTreeSet::new(); + for field in select.fields() { + match field.as_str() { + "id" | "revision" => continue, + api_name => { + let field_id = resolve_data_field_id(entity, api_name).ok_or(())?; + if !readable_fields.contains(field_id) { + return Err(()); + } + fields.insert(field_id.to_owned()); + } } - non_in_fields.insert(filter.field.clone()); - result.push(ReadFilterClause { - field: filter.field.clone(), - operator: filter.operator, - values, - }); } - for (field, values) in in_values { - result.push(ReadFilterClause { - field, - operator: CompiledQueryFilterOperator::In, - values: values.into_iter().collect(), - }); + if fields.is_empty() { + Ok(None) + } else { + Ok(Some(fields)) } - result.sort_by(|left, right| (&left.field, left.operator).cmp(&(&right.field, right.operator))); - Ok(result) } -fn cursor_filters_to_read_filters( - filters: &[crate::cursor::CursorFilter], -) -> Result, ReadQueryError> { - filters +fn resolve_data_field_id<'a>(entity: &'a CompiledEntity, api_name: &str) -> Option<&'a str> { + entity + .stored_fields .iter() - .map(|filter| { - let operator = match filter.operator.as_str() { - "equals" => CompiledQueryFilterOperator::Equals, - "in" => CompiledQueryFilterOperator::In, - "range" => CompiledQueryFilterOperator::Range, - "is_null" => CompiledQueryFilterOperator::IsNull, - "is_not_null" => CompiledQueryFilterOperator::IsNotNull, - "prefix" => CompiledQueryFilterOperator::Prefix, - _ => return Err(ReadQueryError::CursorInvalid), - }; - Ok(ReadFilterClause { - field: filter.field.clone(), - operator, - values: filter.values.clone(), + .map(|field| &field.logical) + .chain(entity.derived_fields.values().map(|field| &field.logical)) + .find(|field| field.api_name == api_name) + .map(|field| field.id.as_str()) + .or_else(|| { + entity + .fields + .get_key_value(api_name) + .map(|(field_id, _)| field_id.as_str()) + }) +} + +fn projection_plan( + entity: &CompiledEntity, + selected_fields: &BTreeSet, +) -> Result, ReadQueryError> { + selected_fields + .iter() + .map(|field_id| { + let field = entity.fields.get(field_id).ok_or(ReadQueryError::Invalid)?; + Ok(ReadProjectionField { + field_id: field_id.clone(), + field_type: field.field_type.clone(), }) }) .collect() } +fn first_page_filter_expr( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + filter: Option<&strict_query::FilterExpr>, +) -> Result, ReadQueryError> { + filter + .map(|filter| read_filter_expr(entity, operation, filter)) + .transpose() +} + +fn read_filter_expr( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + filter: &strict_query::FilterExpr, +) -> Result { + match filter { + strict_query::FilterExpr::Binary { op, left, right } => Ok(ReadFilterExpr::Binary { + op: match op { + strict_query::LogicalOp::And => ReadLogicalOp::And, + strict_query::LogicalOp::Or => ReadLogicalOp::Or, + }, + left: Box::new(read_filter_expr(entity, operation, left)?), + right: Box::new(read_filter_expr(entity, operation, right)?), + }), + strict_query::FilterExpr::Not(expr) => Ok(ReadFilterExpr::Not(Box::new(read_filter_expr( + entity, operation, expr, + )?))), + strict_query::FilterExpr::Group(expr) => Ok(ReadFilterExpr::Group(Box::new( + read_filter_expr(entity, operation, expr)?, + ))), + strict_query::FilterExpr::Predicate(predicate) => Ok(ReadFilterExpr::Predicate( + read_filter_predicate(entity, operation, predicate)?, + )), + } +} + +fn read_filter_predicate( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + predicate: &strict_query::FilterPredicate, +) -> Result { + let (api_field, operator, literals) = match predicate { + strict_query::FilterPredicate::Compare { field, op, literal } => match (op, literal) { + (strict_query::ComparisonOp::Eq, strict_query::Literal::Null) => { + (field.as_str(), ReadFilterOperator::IsNull, Vec::new()) + } + (strict_query::ComparisonOp::Ne, strict_query::Literal::Null) => { + (field.as_str(), ReadFilterOperator::IsNotNull, Vec::new()) + } + (strict_query::ComparisonOp::Eq, literal) => { + (field.as_str(), ReadFilterOperator::Eq, vec![literal]) + } + (strict_query::ComparisonOp::Ne, literal) => { + (field.as_str(), ReadFilterOperator::Ne, vec![literal]) + } + (strict_query::ComparisonOp::Lt, literal) => { + (field.as_str(), ReadFilterOperator::Lt, vec![literal]) + } + (strict_query::ComparisonOp::Le, literal) => { + (field.as_str(), ReadFilterOperator::Le, vec![literal]) + } + (strict_query::ComparisonOp::Gt, literal) => { + (field.as_str(), ReadFilterOperator::Gt, vec![literal]) + } + (strict_query::ComparisonOp::Ge, literal) => { + (field.as_str(), ReadFilterOperator::Ge, vec![literal]) + } + }, + strict_query::FilterPredicate::In { field, values } => ( + field.as_str(), + ReadFilterOperator::In, + values.iter().collect::>(), + ), + strict_query::FilterPredicate::Function { + function, + field, + literal, + } => { + let operator = match function { + strict_query::StringFunction::StartsWith => ReadFilterOperator::StartsWith, + strict_query::StringFunction::Contains => ReadFilterOperator::Contains, + }; + (field.as_str(), operator, vec![literal]) + } + }; + let field_id = resolve_data_field_id(entity, api_field).ok_or(ReadQueryError::Invalid)?; + let field = entity.fields.get(field_id).ok_or(ReadQueryError::Invalid)?; + let capability = operation + .filter_fields + .iter() + .find(|candidate| candidate.field == field_id) + .ok_or(ReadQueryError::Invalid)?; + if !capability + .operators + .contains(&operator.compiled_capability()) + { + return Err(ReadQueryError::Invalid); + } + let mut values = if matches!( + operator, + ReadFilterOperator::IsNull | ReadFilterOperator::IsNotNull + ) { + vec!["true".to_owned()] + } else { + literals + .into_iter() + .map(|literal| literal_to_field_value(literal, &field.field_type)) + .collect::, _>>()? + }; + if operator == ReadFilterOperator::In { + let unique = values.iter().collect::>(); + if values.is_empty() || values.len() > MAX_IN_VALUES || unique.len() != values.len() { + return Err(ReadQueryError::Invalid); + } + values.sort(); + } + Ok(ReadFilterPredicate { + field_id: field_id.to_owned(), + field_type: field.field_type.clone(), + operator, + values, + }) +} + +fn literal_to_field_value( + literal: &strict_query::Literal, + field_type: &FieldTypeSource, +) -> Result { + let value = match literal { + strict_query::Literal::String(value) + | strict_query::Literal::Integer(value) + | strict_query::Literal::Decimal(value) => value.clone(), + strict_query::Literal::Boolean(value) => value.to_string(), + strict_query::Literal::Null => return Err(ReadQueryError::Invalid), + }; + crate::postgres::validate_field_value(&value, field_type) + .map_err(|_| ReadQueryError::Invalid)?; + Ok(value) +} + +fn resolve_order_clause( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + orderby: &strict_query::OrderByClause, +) -> Result { + read_order_clause(entity, operation, Some(orderby.field.as_str()))? + .ok_or(ReadQueryError::Invalid) +} + +fn read_order_clause( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + api_or_field: Option<&str>, +) -> Result, ReadQueryError> { + let Some(api_or_field) = api_or_field else { + return Ok(None); + }; + let field_id = resolve_data_field_id(entity, api_or_field).ok_or(ReadQueryError::Invalid)?; + let field = entity.fields.get(field_id).ok_or(ReadQueryError::Invalid)?; + let sortable = operation.sort_fields.iter().any(|candidate| { + candidate.field == field_id + && candidate + .directions + .contains(&CompiledQuerySortDirection::Asc) + }); + if !sortable { + return Err(ReadQueryError::Invalid); + } + Ok(Some(ReadOrderClause { + field_id: field_id.to_owned(), + field_type: field.field_type.clone(), + direction: CompiledQuerySortDirection::Asc, + })) +} + fn validate_query_shape( entity: &CompiledEntity, operation: &CompiledQueryOperation, - filters: &[ReadFilterClause], - sort: Option<&str>, + filter: Option<&ReadFilterExpr>, + order: Option<&ReadOrderClause>, page_size: u16, ) -> Result<(), ReadQueryError> { - if page_size == 0 || page_size > operation.max_page_size || filters.len() > MAX_FILTER_CLAUSES { + if page_size == 0 || page_size > operation.max_page_size { return Err(ReadQueryError::Invalid); } - let mut in_values = 0_usize; - for filter in filters { - let field = operation - .filter_fields - .iter() - .find(|field| field.field == filter.field) - .ok_or(ReadQueryError::Invalid)?; - if !field.operators.contains(&filter.operator) { + let mut stats = QueryShapeStats::default(); + if let Some(filter) = filter { + validate_filter_shape(entity, operation, filter, &mut stats)?; + if stats.predicates > MAX_FILTER_CLAUSES || stats.in_values > MAX_IN_VALUES { return Err(ReadQueryError::Invalid); } - let compiled_field_type = entity - .fields - .get(&filter.field) - .map(|field| &field.field_type) - .ok_or(ReadQueryError::Invalid)?; - match filter.operator { - CompiledQueryFilterOperator::Equals | CompiledQueryFilterOperator::Prefix => { - if filter.values.len() != 1 { - return Err(ReadQueryError::Invalid); - } - crate::postgres::validate_field_value(&filter.values[0], compiled_field_type) - .map_err(|_| ReadQueryError::Invalid)?; + } + if let Some(order) = order { + let sortable = operation.sort_fields.iter().any(|field| { + field.field == order.field_id + && field.directions.contains(&CompiledQuerySortDirection::Asc) + }); + if !sortable + || operation.stable_tie_breaker != "record_id" + || order.direction != CompiledQuerySortDirection::Asc + || entity + .fields + .get(&order.field_id) + .map(|field| &field.field_type) + != Some(&order.field_type) + { + return Err(ReadQueryError::Invalid); + } + } + Ok(()) +} + +#[derive(Default)] +struct QueryShapeStats { + predicates: usize, + in_values: usize, +} + +fn validate_filter_shape( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + filter: &ReadFilterExpr, + stats: &mut QueryShapeStats, +) -> Result<(), ReadQueryError> { + match filter { + ReadFilterExpr::Binary { left, right, .. } => { + validate_filter_shape(entity, operation, left, stats)?; + validate_filter_shape(entity, operation, right, stats) + } + ReadFilterExpr::Not(expr) | ReadFilterExpr::Group(expr) => { + validate_filter_shape(entity, operation, expr, stats) + } + ReadFilterExpr::Predicate(predicate) => { + stats.predicates = stats + .predicates + .checked_add(1) + .ok_or(ReadQueryError::Invalid)?; + let field = entity + .fields + .get(&predicate.field_id) + .ok_or(ReadQueryError::Invalid)?; + let capability = operation + .filter_fields + .iter() + .find(|field| field.field == predicate.field_id) + .ok_or(ReadQueryError::Invalid)?; + if field.field_type != predicate.field_type + || !capability + .operators + .contains(&predicate.operator.compiled_capability()) + { + return Err(ReadQueryError::Invalid); } - CompiledQueryFilterOperator::In => { - if filter.values.is_empty() { - return Err(ReadQueryError::Invalid); - } - in_values += filter.values.len(); - if in_values > MAX_IN_VALUES { - return Err(ReadQueryError::Invalid); - } - let unique = filter.values.iter().collect::>(); - if unique.len() != filter.values.len() { - return Err(ReadQueryError::Invalid); - } - for value in &filter.values { - crate::postgres::validate_field_value(value, compiled_field_type) + match predicate.operator { + ReadFilterOperator::Eq + | ReadFilterOperator::Ne + | ReadFilterOperator::Lt + | ReadFilterOperator::Le + | ReadFilterOperator::Gt + | ReadFilterOperator::Ge + | ReadFilterOperator::StartsWith + | ReadFilterOperator::Contains => { + if predicate.values.len() != 1 { + return Err(ReadQueryError::Invalid); + } + crate::postgres::validate_field_value(&predicate.values[0], &field.field_type) .map_err(|_| ReadQueryError::Invalid)?; } - } - CompiledQueryFilterOperator::Range => { - if filter.values.len() != 2 { - return Err(ReadQueryError::Invalid); - } - for value in &filter.values { - crate::postgres::validate_field_value(value, compiled_field_type) - .map_err(|_| ReadQueryError::Invalid)?; + ReadFilterOperator::In => { + if predicate.values.is_empty() + || predicate + .values + .windows(2) + .any(|window| window[0] >= window[1]) + { + return Err(ReadQueryError::Invalid); + } + stats.in_values = stats + .in_values + .checked_add(predicate.values.len()) + .ok_or(ReadQueryError::Invalid)?; + for value in &predicate.values { + crate::postgres::validate_field_value(value, &field.field_type) + .map_err(|_| ReadQueryError::Invalid)?; + } } - } - CompiledQueryFilterOperator::IsNull | CompiledQueryFilterOperator::IsNotNull => { - if filter.values.as_slice() != ["true"] { - return Err(ReadQueryError::Invalid); + ReadFilterOperator::IsNull | ReadFilterOperator::IsNotNull => { + if predicate.values.as_slice() != ["true"] { + return Err(ReadQueryError::Invalid); + } } } + Ok(()) } } - if let Some(sort) = sort { - let sortable = operation - .sort_fields - .iter() - .any(|field| field.field == sort && field.directions.len() == 1); - if !sortable { - return Err(ReadQueryError::Invalid); - } - } - Ok(()) } fn temporal_instant_for( @@ -1801,13 +2432,13 @@ fn temporal_instant_for( ) -> Result, ReadQueryError> { match kind { CompiledQueryKind::List => { - if options.as_of.is_some() { + if options.parsed.as_of.is_some() { return Err(ReadQueryError::Invalid); } Ok(None) } CompiledQueryKind::Current => { - if options.as_of.is_some() { + if options.parsed.as_of.is_some() { return Err(ReadQueryError::Invalid); } OffsetDateTime::now_utc() @@ -1816,7 +2447,11 @@ fn temporal_instant_for( .map_err(|_| ReadQueryError::Invalid) } CompiledQueryKind::AsOf => { - let value = options.as_of.as_deref().ok_or(ReadQueryError::Invalid)?; + let value = options + .parsed + .as_of + .as_deref() + .ok_or(ReadQueryError::Invalid)?; parse_strict_rfc3339_utc(value).map_err(|_| ReadQueryError::Invalid)?; Ok(Some(value.to_owned())) } @@ -1846,7 +2481,7 @@ fn cursor_binding( .map(|principal| { service .cursors - .binding_digest_bytes(b"registry-server-cursor-principal-v1", principal.as_bytes()) + .binding_digest_bytes(b"registry-server-cursor-principal-v3", principal.as_bytes()) }) .transpose()?; let purpose_reference = surface @@ -1855,11 +2490,11 @@ fn cursor_binding( .map(|purpose| { service .cursors - .binding_digest_bytes(b"registry-server-cursor-purpose-v1", purpose.as_bytes()) + .binding_digest_bytes(b"registry-server-cursor-purpose-v3", purpose.as_bytes()) }) .transpose()?; let row_boundary_reference = service.cursors.binding_digest( - b"registry-server-cursor-row-boundary-v1", + b"registry-server-cursor-row-boundary-v3", &json!(surface .context .row_boundaries() @@ -1877,17 +2512,32 @@ fn cursor_binding( .collect::>()), )?; let projection_reference = service.cursors.binding_digest( - b"registry-server-cursor-projection-v1", - &json!({"selectedFields": selected_fields_vec}), + b"registry-server-cursor-projection-v3", + &json!({"projection": query.projection.iter().map(projection_field_value).collect::>()}), )?; - let cursor_filters = cursor_filters(query.filters); let query_reference = service.cursors.binding_digest( - b"registry-server-cursor-query-v1", - &json!({"filters": cursor_filters, "temporalInstant": query.temporal_instant}), + b"registry-server-cursor-query-v3", + &json!({ + "routeId": route.id, + "queryOperationId": operation.id, + "queryKind": operation.kind, + "selectedProfile": surface.context.selected_profile(), + "projection": query.projection.iter().map(projection_field_value).collect::>(), + "filter": query.filter.map(read_filter_expr_value), + "order": query.order.map(read_order_clause_value), + "pageSize": query.page_size, + "includeCount": query.include_count, + "temporalInstant": query.temporal_instant, + "scope": cursor_scope_value(query.scope), + }), )?; let sort_reference = service.cursors.binding_digest( - b"registry-server-cursor-sort-v1", - &json!({"sort": query.sort, "tieBreaker": operation.stable_tie_breaker}), + b"registry-server-cursor-sort-v3", + &json!({"order": query.order.map(read_order_clause_value), "tieBreaker": operation.stable_tie_breaker}), + )?; + let scope_reference = service.cursors.binding_digest( + b"registry-server-cursor-scope-v3", + &cursor_scope_value(query.scope), )?; Ok(CursorBinding { package_revision: service.identity.package_revision.clone(), @@ -1903,7 +2553,9 @@ fn cursor_binding( projection_reference, query_reference, sort_reference, + scope_reference, page_size: query.page_size, + include_count: query.include_count, temporal_instant: query.temporal_instant.map(str::to_owned), selected_fields: selected_fields_vec, }) @@ -1911,34 +2563,467 @@ fn cursor_binding( struct CursorBindingQuery<'a> { selected_fields: &'a BTreeSet, - filters: &'a [ReadFilterClause], - sort: Option<&'a str>, + projection: &'a [ReadProjectionField], + filter: Option<&'a ReadFilterExpr>, + order: Option<&'a ReadOrderClause>, + include_count: bool, page_size: u16, temporal_instant: Option<&'a str>, + scope: &'a CursorQueryScope, } -fn cursor_filters(filters: &[ReadFilterClause]) -> Vec { - filters +fn projection_field_value(field: &ReadProjectionField) -> Value { + json!({ + "fieldId": field.field_id, + "fieldType": field.field_type, + }) +} + +fn read_order_clause_value(order: &ReadOrderClause) -> Value { + json!({ + "fieldId": order.field_id, + "fieldType": order.field_type, + "direction": order.direction, + }) +} + +fn cursor_scope_value(scope: &CursorQueryScope) -> Value { + match scope { + CursorQueryScope::Collection {} => json!({"kind": "collection"}), + CursorQueryScope::Relationship { path_id, root_id } => json!({ + "kind": "relationship", + "pathId": path_id, + "rootId": root_id, + }), + } +} + +fn read_filter_expr_value(filter: &ReadFilterExpr) -> Value { + match filter { + ReadFilterExpr::Binary { op, left, right } => json!({ + "kind": "binary", + "op": match op { + ReadLogicalOp::And => "and", + ReadLogicalOp::Or => "or", + }, + "left": read_filter_expr_value(left), + "right": read_filter_expr_value(right), + }), + ReadFilterExpr::Not(expr) => json!({ + "kind": "not", + "op": "not", + "expr": read_filter_expr_value(expr), + }), + ReadFilterExpr::Group(expr) => json!({ + "kind": "group", + "op": "group", + "expr": read_filter_expr_value(expr), + }), + ReadFilterExpr::Predicate(predicate) => json!({ + "kind": "predicate", + "fieldId": predicate.field_id, + "fieldType": predicate.field_type, + "operator": read_filter_operator_name(predicate.operator), + "values": predicate.values, + }), + } +} + +fn read_filter_operator_name(operator: ReadFilterOperator) -> &'static str { + match operator { + ReadFilterOperator::Eq => "eq", + ReadFilterOperator::Ne => "ne", + ReadFilterOperator::Lt => "lt", + ReadFilterOperator::Le => "le", + ReadFilterOperator::Gt => "gt", + ReadFilterOperator::Ge => "ge", + ReadFilterOperator::In => "in", + ReadFilterOperator::IsNull => "is_null", + ReadFilterOperator::IsNotNull => "is_not_null", + ReadFilterOperator::StartsWith => "startswith", + ReadFilterOperator::Contains => "contains", + } +} + +fn cursor_query_from_plan( + projection: &[ReadProjectionField], + filter: Option<&ReadFilterExpr>, + order: Option<&ReadOrderClause>, + include_count: bool, + page_size: u16, + temporal_instant: Option, + scope: CursorQueryScope, +) -> crate::cursor::CursorQuery { + crate::cursor::CursorQuery { + projection: projection.iter().map(cursor_projection_from_read).collect(), + filter: filter.map(cursor_filter_expr_from_read), + order: order.map(cursor_order_from_read), + include_count, + page_size, + temporal_instant, + scope, + } +} + +fn cursor_projection_from_read(field: &ReadProjectionField) -> CursorProjectionField { + CursorProjectionField { + field_id: field.field_id.clone(), + field_type: field.field_type.clone(), + } +} + +fn cursor_order_from_read(order: &ReadOrderClause) -> CursorOrderClause { + CursorOrderClause { + field_id: order.field_id.clone(), + field_type: order.field_type.clone(), + direction: order.direction, + } +} + +fn cursor_filter_expr_from_read(filter: &ReadFilterExpr) -> CursorFilterExpr { + match filter { + ReadFilterExpr::Binary { op, left, right } => CursorFilterExpr::Binary { + op: match op { + ReadLogicalOp::And => CursorLogicalOp::And, + ReadLogicalOp::Or => CursorLogicalOp::Or, + }, + left: Box::new(cursor_filter_expr_from_read(left)), + right: Box::new(cursor_filter_expr_from_read(right)), + }, + ReadFilterExpr::Not(expr) => CursorFilterExpr::Not { + expr: Box::new(cursor_filter_expr_from_read(expr)), + }, + ReadFilterExpr::Group(expr) => CursorFilterExpr::Group { + expr: Box::new(cursor_filter_expr_from_read(expr)), + }, + ReadFilterExpr::Predicate(predicate) => CursorFilterExpr::Predicate { + predicate: CursorFilterPredicate { + field_id: predicate.field_id.clone(), + field_type: predicate.field_type.clone(), + operator: cursor_operator_from_read(predicate.operator), + values: predicate.values.clone(), + }, + }, + } +} + +fn read_projection_from_cursor( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + selected_fields: &BTreeSet, + projection: &[CursorProjectionField], +) -> Result, ReadQueryError> { + if projection.len() != selected_fields.len() { + return Err(ReadQueryError::CursorInvalid); + } + let expected = projection_plan(entity, selected_fields)?; + let actual = projection .iter() - .map(|filter| crate::cursor::CursorFilter { - field: filter.field.clone(), - operator: filter_operator_name(filter.operator).to_owned(), - values: filter.values.clone(), + .map(|field| { + if !operation.projection_fields.contains(&field.field_id) { + return Err(ReadQueryError::CursorInvalid); + } + Ok(ReadProjectionField { + field_id: field.field_id.clone(), + field_type: field.field_type.clone(), + }) }) - .collect() + .collect::, _>>()?; + if actual != expected { + return Err(ReadQueryError::CursorInvalid); + } + Ok(actual) +} + +fn read_order_clause_from_cursor( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + order: &CursorOrderClause, +) -> Result { + let field = entity + .fields + .get(&order.field_id) + .ok_or(ReadQueryError::CursorInvalid)?; + let sortable = operation.sort_fields.iter().any(|candidate| { + candidate.field == order.field_id + && candidate + .directions + .contains(&CompiledQuerySortDirection::Asc) + }); + if !sortable + || field.field_type != order.field_type + || order.direction != CompiledQuerySortDirection::Asc + { + return Err(ReadQueryError::CursorInvalid); + } + Ok(ReadOrderClause { + field_id: order.field_id.clone(), + field_type: order.field_type.clone(), + direction: order.direction, + }) +} + +fn read_filter_expr_from_cursor( + entity: &CompiledEntity, + filter: &CursorFilterExpr, +) -> Result { + match filter { + CursorFilterExpr::Binary { op, left, right } => Ok(ReadFilterExpr::Binary { + op: match op { + CursorLogicalOp::And => ReadLogicalOp::And, + CursorLogicalOp::Or => ReadLogicalOp::Or, + }, + left: Box::new(read_filter_expr_from_cursor(entity, left)?), + right: Box::new(read_filter_expr_from_cursor(entity, right)?), + }), + CursorFilterExpr::Not { expr } => Ok(ReadFilterExpr::Not(Box::new( + read_filter_expr_from_cursor(entity, expr)?, + ))), + CursorFilterExpr::Group { expr } => Ok(ReadFilterExpr::Group(Box::new( + read_filter_expr_from_cursor(entity, expr)?, + ))), + CursorFilterExpr::Predicate { predicate } => { + let field = entity + .fields + .get(&predicate.field_id) + .ok_or(ReadQueryError::CursorInvalid)?; + if field.field_type != predicate.field_type { + return Err(ReadQueryError::CursorInvalid); + } + Ok(ReadFilterExpr::Predicate(ReadFilterPredicate { + field_id: predicate.field_id.clone(), + field_type: predicate.field_type.clone(), + operator: read_operator_from_cursor(predicate.operator), + values: predicate.values.clone(), + })) + } + } +} + +fn cursor_operator_from_read(operator: ReadFilterOperator) -> CursorFilterOperator { + match operator { + ReadFilterOperator::Eq => CursorFilterOperator::Eq, + ReadFilterOperator::Ne => CursorFilterOperator::Ne, + ReadFilterOperator::Lt => CursorFilterOperator::Lt, + ReadFilterOperator::Le => CursorFilterOperator::Le, + ReadFilterOperator::Gt => CursorFilterOperator::Gt, + ReadFilterOperator::Ge => CursorFilterOperator::Ge, + ReadFilterOperator::In => CursorFilterOperator::In, + ReadFilterOperator::IsNull => CursorFilterOperator::IsNull, + ReadFilterOperator::IsNotNull => CursorFilterOperator::IsNotNull, + ReadFilterOperator::StartsWith => CursorFilterOperator::StartsWith, + ReadFilterOperator::Contains => CursorFilterOperator::Contains, + } } -fn filter_operator_name(operator: CompiledQueryFilterOperator) -> &'static str { +fn read_operator_from_cursor(operator: CursorFilterOperator) -> ReadFilterOperator { match operator { - CompiledQueryFilterOperator::Equals => "equals", - CompiledQueryFilterOperator::In => "in", - CompiledQueryFilterOperator::Range => "range", - CompiledQueryFilterOperator::IsNull => "is_null", - CompiledQueryFilterOperator::IsNotNull => "is_not_null", - CompiledQueryFilterOperator::Prefix => "prefix", + CursorFilterOperator::Eq => ReadFilterOperator::Eq, + CursorFilterOperator::Ne => ReadFilterOperator::Ne, + CursorFilterOperator::Lt => ReadFilterOperator::Lt, + CursorFilterOperator::Le => ReadFilterOperator::Le, + CursorFilterOperator::Gt => ReadFilterOperator::Gt, + CursorFilterOperator::Ge => ReadFilterOperator::Ge, + CursorFilterOperator::In => ReadFilterOperator::In, + CursorFilterOperator::IsNull => ReadFilterOperator::IsNull, + CursorFilterOperator::IsNotNull => ReadFilterOperator::IsNotNull, + CursorFilterOperator::StartsWith => ReadFilterOperator::StartsWith, + CursorFilterOperator::Contains => ReadFilterOperator::Contains, } } +struct LookupBody { + selector_id: String, + values: Option>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LookupResolutionError { + InvalidRequest, + Unresolved, +} + +fn parse_lookup_body(body: &[u8]) -> Result { + let value = parse_json_strict(body).map_err(|_| ())?; + let object = value.as_object().ok_or(())?; + let selector_id = object.get("selector").and_then(Value::as_str).ok_or(())?; + if selector_id.is_empty() { + return Err(()); + } + let values = match object.get("values") { + Some(Value::Object(values)) => Some( + values + .iter() + .map(|(field, value)| (field.clone(), value.clone())) + .collect(), + ), + Some(_) => return Err(()), + None => None, + }; + let expected_len = if values.is_some() { 2 } else { 1 }; + if object.len() != expected_len { + return Err(()); + } + Ok(LookupBody { + selector_id: selector_id.to_owned(), + values, + }) +} + +fn resolve_lookup_selector( + service: &HttpService, + route: &CompiledRoute, + surface: &AuthorizedSurface<'_>, + claims: &VerifiedRequestClaims, + body: &LookupBody, +) -> Result { + let selector = surface + .entity + .selector_profiles + .get(&body.selector_id) + .ok_or(LookupResolutionError::Unresolved)?; + let profile = surface + .entity + .access_profiles + .get(surface.context.selected_profile()) + .ok_or(LookupResolutionError::Unresolved)?; + let grant = profile + .lookups + .iter() + .find(|lookup| lookup.selector == body.selector_id) + .ok_or(LookupResolutionError::Unresolved)?; + let operation = lookup_query_operation_for_selector(service, route, surface, &body.selector_id) + .ok_or(LookupResolutionError::Unresolved)?; + let values = match grant.value_origin { + LookupValueOrigin::Request => { + let values = body + .values + .as_ref() + .ok_or(LookupResolutionError::InvalidRequest)?; + lookup_request_values(surface.entity, selector, values)? + } + LookupValueOrigin::VerifiedClaim => { + if body.values.is_some() { + return Err(LookupResolutionError::InvalidRequest); + } + lookup_verified_claim_values(surface.entity, selector, grant, claims)? + } + }; + Ok(CompiledLookupSelector { + route_id: route.id.clone(), + query_operation_id: operation.id.clone(), + selector_id: selector.id.clone(), + value_origin: grant.value_origin, + values, + }) +} + +fn lookup_request_values( + entity: &CompiledEntity, + selector: &crate::model::CompiledSelectorProfile, + values: &BTreeMap, +) -> Result, LookupResolutionError> { + let expected = selector.fields.iter().collect::>(); + let actual = values.keys().collect::>(); + if expected != actual { + return Err(LookupResolutionError::InvalidRequest); + } + selector + .fields + .iter() + .map(|field_id| { + let field = entity + .fields + .get(field_id) + .ok_or(LookupResolutionError::Unresolved)?; + let value = lookup_json_scalar( + values + .get(field_id) + .ok_or(LookupResolutionError::InvalidRequest)?, + &field.field_type, + )?; + Ok(LookupSelectorValue { + field_id: field_id.clone(), + field_type: field.field_type.clone(), + value, + }) + }) + .collect() +} + +fn lookup_verified_claim_values( + entity: &CompiledEntity, + selector: &crate::model::CompiledSelectorProfile, + grant: &crate::contract::LookupGrantSource, + claims: &VerifiedRequestClaims, +) -> Result, LookupResolutionError> { + selector + .fields + .iter() + .map(|field_id| { + let field = entity + .fields + .get(field_id) + .ok_or(LookupResolutionError::Unresolved)?; + let claim_name = grant + .claim_mapping + .get(field_id) + .ok_or(LookupResolutionError::Unresolved)?; + let claim = claims + .direct_claim(claim_name) + .ok_or(LookupResolutionError::Unresolved)?; + let values = claim.values(); + if values.len() != 1 { + return Err(LookupResolutionError::Unresolved); + } + let value = values + .into_iter() + .next() + .ok_or(LookupResolutionError::Unresolved)?; + crate::postgres::validate_field_value(&value, &field.field_type) + .map_err(|_| LookupResolutionError::Unresolved)?; + Ok(LookupSelectorValue { + field_id: field_id.clone(), + field_type: field.field_type.clone(), + value, + }) + }) + .collect() +} + +fn lookup_json_scalar( + value: &Value, + field_type: &FieldTypeSource, +) -> Result { + let value = match field_type { + FieldTypeSource::Boolean => value + .as_bool() + .map(|value| value.to_string()) + .ok_or(LookupResolutionError::InvalidRequest)?, + FieldTypeSource::Int64 => value + .as_i64() + .map(|value| value.to_string()) + .ok_or(LookupResolutionError::InvalidRequest)?, + FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::Decimal { .. } + | FieldTypeSource::Date + | FieldTypeSource::Timestamp + | FieldTypeSource::Uuid + | FieldTypeSource::Reference { .. } + | FieldTypeSource::VocabularyCode { .. } => value + .as_str() + .map(str::to_owned) + .ok_or(LookupResolutionError::InvalidRequest)?, + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + return Err(LookupResolutionError::InvalidRequest); + } + }; + crate::postgres::validate_field_value(&value, field_type) + .map_err(|_| LookupResolutionError::InvalidRequest)?; + Ok(value) +} + fn filtered_schema( service: &HttpService, entity_id: &str, @@ -1968,104 +3053,93 @@ struct MetadataEntity { schema_path: String, } -#[derive(Default)] struct QueryOptions { - access_profile: Option, - fields: Option>, - filters: Vec, - sort: Option, - page_size: Option, - as_of: Option, - cursor: Option, + parsed: strict_query::ParsedReadQuery, } impl QueryOptions { - fn parse(raw: Option<&str>, allow_fields: bool) -> Result { - let mut result = Self::default(); + fn parse(raw: Option<&str>, allow_read_query: bool) -> Result { let Some(raw) = raw else { - return Ok(result); + return Ok(Self::default()); }; if raw.is_empty() || raw.len() > MAX_RAW_QUERY_BYTES { return Err(QueryParseError::Invalid); } - let mut in_values = 0_usize; + let mut pairs = Vec::new(); for pair in raw.split('&') { let (name, value) = pair.split_once('=').ok_or(QueryParseError::Invalid)?; let name = percent_decode(name)?; let value = percent_decode(value)?; - match name.as_str() { - "accessProfile" if result.access_profile.is_none() && valid_id(&value) => { - result.access_profile = Some(value); - } - "fields" if allow_fields && result.fields.is_none() => { - result.fields = Some(parse_fields(&value)?); - } - "filter" if allow_fields => { - if result.filters.len() >= MAX_FILTER_CLAUSES { - return Err(QueryParseError::Invalid); - } - let filter = parse_raw_filter(&value)?; - if filter.operator == CompiledQueryFilterOperator::In { - in_values += 1; - if in_values > MAX_IN_VALUES { - return Err(QueryParseError::Invalid); - } - } - result.filters.push(filter); - } - "sort" if allow_fields && result.sort.is_none() && valid_id(&value) => { - result.sort = Some(value); - } - "pageSize" if allow_fields && result.page_size.is_none() => { - let size = value.parse::().map_err(|_| QueryParseError::Invalid)?; - result.page_size = Some(size); - } - "asOf" if allow_fields && result.as_of.is_none() => { - parse_strict_rfc3339_utc(&value).map_err(|_| QueryParseError::Invalid)?; - result.as_of = Some(value); - } - "cursor" if allow_fields && result.cursor.is_none() && !value.is_empty() => { - result.cursor = Some(value); - } - _ => return Err(QueryParseError::Invalid), - } + pairs.push((name, value)); } - if result.cursor.is_some() - && (result.fields.is_some() - || !result.filters.is_empty() - || result.sort.is_some() - || result.page_size.is_some() - || result.as_of.is_some()) - { + let parsed = strict_query::parse_read_query(pairs).map_err(|_| QueryParseError::Invalid)?; + let result = Self { parsed }; + if !allow_read_query && result.has_any_query_member() { return Err(QueryParseError::Invalid); } Ok(result) } - fn has_list_query_members(&self) -> bool { - self.cursor.is_some() - || !self.filters.is_empty() - || self.sort.is_some() - || self.page_size.is_some() - || self.as_of.is_some() + fn access_profile(&self) -> Option<&String> { + self.parsed.access_profile.as_ref() } -} -#[derive(Clone, Eq, PartialEq)] -struct RawFilterClause { - field: String, - operator: CompiledQueryFilterOperator, - value: String, + fn select_clause(&self) -> Option<&strict_query::SelectClause> { + match &self.parsed.mode { + strict_query::ParsedReadQueryMode::Query(options) => options.select.as_ref(), + strict_query::ParsedReadQueryMode::SkipToken { .. } => None, + } + } + + fn query_options(&self) -> Option<&strict_query::ReadQueryOptions> { + match &self.parsed.mode { + strict_query::ParsedReadQueryMode::Query(options) => Some(options), + strict_query::ParsedReadQueryMode::SkipToken { .. } => None, + } + } + + fn skiptoken(&self) -> Option<&str> { + match &self.parsed.mode { + strict_query::ParsedReadQueryMode::SkipToken { token } => Some(token), + strict_query::ParsedReadQueryMode::Query(_) => None, + } + } + + fn has_non_projection_query_members(&self) -> bool { + self.parsed.as_of.is_some() + || self.skiptoken().is_some() + || self.query_options().is_some_and(|options| { + options.filter.is_some() + || options.orderby.is_some() + || options.top.is_some() + || options.count.is_some() + }) + } + + fn has_any_query_member(&self) -> bool { + self.parsed.as_of.is_some() + || self.skiptoken().is_some() + || self.query_options().is_some_and(|options| { + options.select.is_some() + || options.filter.is_some() + || options.orderby.is_some() + || options.top.is_some() + || options.count.is_some() + }) + } } -impl fmt::Debug for RawFilterClause { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("RawFilterClause") - .field("field", &self.field) - .field("operator", &self.operator) - .field("value", &"") - .finish() +impl Default for QueryOptions { + fn default() -> Self { + Self { + parsed: strict_query::ParsedReadQuery { + access_profile: None, + as_of: None, + mode: strict_query::ParsedReadQueryMode::Query( + strict_query::ReadQueryOptions::default(), + ), + }, + } } } @@ -2115,58 +3189,11 @@ fn hex(value: u8) -> Option { } } -fn valid_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= 128 - && value.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') - }) -} - -fn parse_fields(value: &str) -> Result, QueryParseError> { - if value.is_empty() { - return Err(QueryParseError::Invalid); - } - let mut fields = BTreeSet::new(); - for field in value.split(',') { - if fields.len() >= MAX_FIELDS - || field.is_empty() - || field.len() > MAX_FIELD_BYTES - || !valid_id(field) - || !fields.insert(field.to_owned()) - { - return Err(QueryParseError::Invalid); - } - } - Ok(fields) -} - -fn parse_raw_filter(value: &str) -> Result { - let (field, rest) = value.split_once(':').ok_or(QueryParseError::Invalid)?; - let (operator, value) = rest.split_once(':').ok_or(QueryParseError::Invalid)?; - if field.is_empty() || value.is_empty() || !valid_id(field) { - return Err(QueryParseError::Invalid); - } - let operator = match operator { - "equals" => CompiledQueryFilterOperator::Equals, - "in" => CompiledQueryFilterOperator::In, - "range" => CompiledQueryFilterOperator::Range, - "is_null" => CompiledQueryFilterOperator::IsNull, - "is_not_null" => CompiledQueryFilterOperator::IsNotNull, - "prefix" => CompiledQueryFilterOperator::Prefix, - _ => return Err(QueryParseError::Invalid), - }; - Ok(RawFilterClause { - field: field.to_owned(), - operator, - value: value.to_owned(), - }) -} - fn operation_name(operation: Operation) -> &'static str { match operation { Operation::Get => "get", Operation::List => "list", + Operation::Lookup => "lookup", Operation::Create => "create", Operation::Patch => "patch", Operation::Tombstone => "tombstone", @@ -2193,35 +3220,42 @@ fn query_parameters(kind: CompiledQueryKind) -> Value { "Select one compiled access profile.", ), query_parameter( - "fields", + "$select", false, false, json!({"type": "string"}), - "Comma-separated subset of readable fields.", + "Comma-separated subset of readable API property names.", ), query_parameter( - "filter", + "$filter", + false, false, - true, json!({"type": "string"}), - "Repeatable field:operator:value filter clause.", + "Strict Registry read filter expression over compiled filterable properties.", ), query_parameter( - "sort", + "$orderby", false, false, json!({"type": "string"}), - "One compiled sortable field, ascending only.", + "One compiled sortable property, ascending only.", + ), + query_parameter( + "$top", + false, + false, + json!({"type": "integer", "minimum": 1, "maximum": strict_query::MAX_TOP}), + "Bounded page size.", ), query_parameter( - "pageSize", + "$count", false, false, - json!({"type": "integer", "minimum": 1}), - "Bounded page size within the compiled maximum.", + json!({"type": "boolean"}), + "Request a total count when the compiled operation allows it.", ), query_parameter( - "cursor", + "$skiptoken", false, false, json!({"type": "string"}), @@ -2240,6 +3274,49 @@ fn query_parameters(kind: CompiledQueryKind) -> Value { Value::Array(parameters) } +fn lookup_parameters() -> Value { + Value::Array(vec![ + query_parameter( + "accessProfile", + false, + false, + json!({"type": "string"}), + "Select one compiled access profile.", + ), + query_parameter( + "$select", + false, + false, + json!({"type": "string"}), + "Comma-separated subset of readable API property names.", + ), + ]) +} + +fn lookup_request_body() -> Value { + json!({ + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["selector"], + "properties": { + "selector": {"type": "string"}, + "values": { + "type": "object", + "additionalProperties": { + "type": ["string", "integer", "boolean"] + } + } + } + } + } + } + }) +} + fn revision_parameters(kind: CompiledRevisionKind) -> Value { let mut parameters = vec![query_parameter( "accessProfile", @@ -2341,6 +3418,14 @@ fn cursor_invalid() -> Response { ) } +fn lookup_unresolved() -> Response { + fixed_problem( + StatusCode::NOT_FOUND, + "lookup.unresolved", + "The lookup did not resolve exactly one record.", + ) +} + fn exact_json(response: HeldReadResponse) -> Response { let mut builder = Response::builder() .status(StatusCode::OK) @@ -2610,7 +3695,7 @@ fn invalid_request() -> Response { fixed_problem( StatusCode::BAD_REQUEST, "request.invalid", - "The mutation request is invalid.", + "The request is invalid.", ) } diff --git a/crates/registry-server/src/api/service.rs b/crates/registry-server/src/api/service.rs index 5b53c819dc..c1a7c054ab 100644 --- a/crates/registry-server/src/api/service.rs +++ b/crates/registry-server/src/api/service.rs @@ -9,8 +9,12 @@ use std::sync::Arc; use serde_json::Value; use super::context::AuthorizedRequestContext; +use crate::contract::FieldTypeSource; use crate::cursor::{CursorBinding, CursorCodec, CursorContinuation, CursorQuery}; -use crate::model::{CompiledQueryFilterOperator, CompiledQueryKind, CompiledRegistry, HttpMethod}; +use crate::model::{ + CompiledQueryFilterOperator, CompiledQueryKind, CompiledQuerySortDirection, CompiledRegistry, + HttpMethod, +}; use crate::mutation::BatchMutationItem; use crate::postgres::{PostgresRecordMutationService, PostgresRevisionReadService}; @@ -75,14 +79,13 @@ pub struct RecordReadRequest { pub entity_id: String, pub operation_id: String, pub method: HttpMethod, - pub record_id: Option, pub context: AuthorizedRequestContext, /// Exact response fields authorized for this operation. Source plans must /// select and process only this set, plus compiler-owned row-boundary /// fields from `context`; they must never fetch the profile's wider field /// set and rely on response filtering. pub selected_fields: BTreeSet, - pub query: Option, + pub kind: RecordReadKind, /// Hard source-execution result bound. Implementations must apply it in /// the database plan before rows are materialized. pub maximum_records: usize, @@ -95,15 +98,101 @@ impl fmt::Debug for RecordReadRequest { .field("entity_id", &self.entity_id) .field("operation_id", &self.operation_id) .field("method", &self.method) - .field("record_id", &self.record_id.as_ref().map(|_| "")) .field("context", &"") .field("selected_fields", &self.selected_fields) - .field("query", &self.query.as_ref().map(|_| "")) + .field("kind", &self.kind) .field("maximum_records", &self.maximum_records) .finish() } } +#[derive(Clone, Eq, PartialEq)] +pub enum RecordReadKind { + Get { + id: String, + }, + List { + plan: CompiledReadQuery, + }, + Lookup { + selector: CompiledLookupSelector, + }, + Relationship { + root_id: String, + path_id: String, + plan: CompiledReadQuery, + }, +} + +impl fmt::Debug for RecordReadKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Get { id: _ } => formatter + .debug_struct("Get") + .field("id", &"") + .finish(), + Self::List { plan } => formatter.debug_struct("List").field("plan", plan).finish(), + Self::Lookup { selector } => formatter + .debug_struct("Lookup") + .field("selector", selector) + .finish(), + Self::Relationship { + root_id: _, + path_id, + plan, + } => formatter + .debug_struct("Relationship") + .field("root_id", &"") + .field("path_id", path_id) + .field("plan", plan) + .finish(), + } + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct CompiledLookupSelector { + pub route_id: String, + pub query_operation_id: String, + pub selector_id: String, + pub value_origin: crate::contract::LookupValueOrigin, + pub values: Vec, +} + +impl fmt::Debug for CompiledLookupSelector { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CompiledLookupSelector") + .field("route_id", &self.route_id) + .field("query_operation_id", &self.query_operation_id) + .field("selector_id", &self.selector_id) + .field("value_origin", &self.value_origin) + .field( + "values", + &self.values.iter().map(|_| "").collect::>(), + ) + .finish() + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct LookupSelectorValue { + pub field_id: String, + pub field_type: FieldTypeSource, + pub value: String, +} + +impl fmt::Debug for LookupSelectorValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LookupSelectorValue") + .field("field_id", &self.field_id) + .field("field_type", &self.field_type) + .field("value", &"") + .finish() + } +} + #[derive(Clone, Eq, PartialEq)] pub struct CompiledReadQuery { pub route_id: String, @@ -111,8 +200,10 @@ pub struct CompiledReadQuery { pub kind: CompiledQueryKind, pub cursor_binding: CursorBinding, pub cursor_query: CursorQuery, - pub filters: Vec, - pub sort: Option, + pub projection: Vec, + pub filter: Option, + pub order: Option, + pub include_count: bool, pub page_size: u16, pub temporal_instant: Option, pub continuation: Option, @@ -127,15 +218,10 @@ impl fmt::Debug for CompiledReadQuery { .field("kind", &self.kind) .field("cursor_binding", &self.cursor_binding) .field("cursor_query", &"") - .field( - "filters", - &self - .filters - .iter() - .map(|_| "") - .collect::>(), - ) - .field("sort", &self.sort) + .field("projection", &self.projection) + .field("filter", &self.filter) + .field("order", &self.order) + .field("include_count", &self.include_count) .field("page_size", &self.page_size) .field( "temporal_instant", @@ -150,23 +236,124 @@ impl fmt::Debug for CompiledReadQuery { } #[derive(Clone, Eq, PartialEq)] -pub struct ReadFilterClause { - pub field: String, - pub operator: CompiledQueryFilterOperator, +pub struct ReadProjectionField { + pub field_id: String, + pub field_type: FieldTypeSource, +} + +impl fmt::Debug for ReadProjectionField { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReadProjectionField") + .field("field_id", &self.field_id) + .field("field_type", &self.field_type) + .finish() + } +} + +#[derive(Clone, Eq, PartialEq)] +pub enum ReadFilterExpr { + Binary { + op: ReadLogicalOp, + left: Box, + right: Box, + }, + Not(Box), + Group(Box), + Predicate(ReadFilterPredicate), +} + +impl fmt::Debug for ReadFilterExpr { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReadFilterExpr::Binary { op, left, right } => formatter + .debug_struct("Binary") + .field("op", op) + .field("left", left) + .field("right", right) + .finish(), + ReadFilterExpr::Not(expr) => formatter.debug_tuple("Not").field(expr).finish(), + ReadFilterExpr::Group(expr) => formatter.debug_tuple("Group").field(expr).finish(), + ReadFilterExpr::Predicate(predicate) => { + formatter.debug_tuple("Predicate").field(predicate).finish() + } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReadLogicalOp { + And, + Or, +} + +#[derive(Clone, Eq, PartialEq)] +pub struct ReadFilterPredicate { + pub field_id: String, + pub field_type: FieldTypeSource, + pub operator: ReadFilterOperator, pub values: Vec, } -impl fmt::Debug for ReadFilterClause { +impl fmt::Debug for ReadFilterPredicate { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter - .debug_struct("ReadFilterClause") - .field("field", &self.field) + .debug_struct("ReadFilterPredicate") + .field("field_id", &self.field_id) + .field("field_type", &self.field_type) .field("operator", &self.operator) .field("values", &"") .finish() } } +#[derive(Clone, Eq, PartialEq)] +pub struct ReadOrderClause { + pub field_id: String, + pub field_type: FieldTypeSource, + pub direction: CompiledQuerySortDirection, +} + +impl fmt::Debug for ReadOrderClause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReadOrderClause") + .field("field_id", &self.field_id) + .field("field_type", &self.field_type) + .field("direction", &self.direction) + .finish() + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum ReadFilterOperator { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + In, + IsNull, + IsNotNull, + StartsWith, + Contains, +} + +impl ReadFilterOperator { + #[must_use] + pub fn compiled_capability(self) -> CompiledQueryFilterOperator { + match self { + Self::Eq | Self::Ne => CompiledQueryFilterOperator::Equals, + Self::Lt | Self::Le | Self::Gt | Self::Ge => CompiledQueryFilterOperator::Range, + Self::In => CompiledQueryFilterOperator::In, + Self::IsNull => CompiledQueryFilterOperator::IsNull, + Self::IsNotNull => CompiledQueryFilterOperator::IsNotNull, + Self::StartsWith | Self::Contains => CompiledQueryFilterOperator::Prefix, + } + } +} + #[derive(Clone)] pub struct RecordReadRefusal { pub method: HttpMethod, @@ -276,6 +463,11 @@ pub trait RecordReadService: Send + Sync { request: RecordReadRequest, ) -> ServiceFuture<'_, Result>; + fn lookup( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>>; + fn refusal( &self, _request: RecordReadRefusal, diff --git a/crates/registry-server/src/artifacts.rs b/crates/registry-server/src/artifacts.rs index 36e12bf482..9a56b1d434 100644 --- a/crates/registry-server/src/artifacts.rs +++ b/crates/registry-server/src/artifacts.rs @@ -541,35 +541,42 @@ fn query_parameters(kind: CompiledQueryKind) -> Value { "Select one compiled access profile.", ), query_parameter( - "fields", + "$select", false, false, json!({"type": "string"}), - "Comma-separated subset of readable fields.", + "Comma-separated subset of readable API property names.", ), query_parameter( - "filter", + "$filter", + false, false, - true, json!({"type": "string"}), - "Repeatable field:operator:value filter clause.", + "Strict Registry read filter expression over compiled filterable properties.", ), query_parameter( - "sort", + "$orderby", false, false, json!({"type": "string"}), - "One compiled sortable field, ascending only.", + "One compiled sortable property, ascending only.", + ), + query_parameter( + "$top", + false, + false, + json!({"type": "integer", "minimum": 1, "maximum": 100}), + "Bounded page size.", ), query_parameter( - "pageSize", + "$count", false, false, - json!({"type": "integer", "minimum": 1}), - "Bounded page size within the compiled maximum.", + json!({"type": "boolean"}), + "Request a total count when the compiled operation allows it.", ), query_parameter( - "cursor", + "$skiptoken", false, false, json!({"type": "string"}), @@ -627,6 +634,7 @@ fn operation_name(operation: Operation) -> &'static str { match operation { Operation::Create => "create", Operation::Get => "get", + Operation::Lookup => "lookup", Operation::List => "list", Operation::Patch => "patch", Operation::Tombstone => "tombstone", diff --git a/crates/registry-server/src/audit.rs b/crates/registry-server/src/audit.rs index 82aed08182..a80dffa293 100644 --- a/crates/registry-server/src/audit.rs +++ b/crates/registry-server/src/audit.rs @@ -67,6 +67,7 @@ pub(crate) enum TerminalAuditOutcome { Replayed, Returned, Empty, + Unresolved, Refused, } @@ -454,6 +455,7 @@ fn terminal_record(terminal: TerminalAudit) -> serde_json::Map { TerminalAuditOutcome::Replayed => "replayed", TerminalAuditOutcome::Returned => "returned", TerminalAuditOutcome::Empty => "empty", + TerminalAuditOutcome::Unresolved => "unresolved", TerminalAuditOutcome::Refused => "refused", } .to_owned(), diff --git a/crates/registry-server/src/auth.rs b/crates/registry-server/src/auth.rs index 1762419467..949768dbf8 100644 --- a/crates/registry-server/src/auth.rs +++ b/crates/registry-server/src/auth.rs @@ -18,7 +18,7 @@ use serde_json::Value; use thiserror::Error; use crate::api::{VerifiedClaimValue, VerifiedRequestClaims}; -use crate::contract::BoundaryOperator; +use crate::contract::{BoundaryOperator, FieldTypeSource, LookupValueOrigin}; use crate::model::CompiledRegistry; const MAX_CLAIM_NAME_BYTES: usize = 128; @@ -38,49 +38,19 @@ const REGISTERED_CLAIMS: &[&str] = &[ "cnf", ]; -/// The one bounded JSON shape accepted for a compiled row-boundary claim. -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub enum RowBoundaryClaimType { - DirectString, - DirectStringSet, -} - -/// One operator-configured row-boundary claim mapping. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RowBoundaryClaimMapping { - name: String, - value_type: RowBoundaryClaimType, -} - -impl RowBoundaryClaimMapping { - #[must_use] - pub fn new(name: impl Into, value_type: RowBoundaryClaimType) -> Self { - Self { - name: name.into(), - value_type, - } - } -} - /// Direct claims that may become Registry authority after OIDC verification. #[derive(Clone, Eq, PartialEq)] pub struct AuthorityClaimConfig { principal_claim: String, purpose_claim: Option, - row_boundary_claims: Vec, } impl AuthorityClaimConfig { #[must_use] - pub fn new( - principal_claim: impl Into, - purpose_claim: Option, - row_boundary_claims: Vec, - ) -> Self { + pub fn new(principal_claim: impl Into, purpose_claim: Option) -> Self { Self { principal_claim: principal_claim.into(), purpose_claim, - row_boundary_claims, } } } @@ -91,7 +61,25 @@ impl fmt::Debug for AuthorityClaimConfig { .debug_struct("AuthorityClaimConfig") .field("principal_claim", &self.principal_claim) .field("purpose_claim", &self.purpose_claim) - .field("row_boundary_claim_count", &self.row_boundary_claims.len()) + .finish() + } +} + +/// One compiled direct-claim expectation derived from row boundaries and +/// lookup selector claim mappings. Values remain direct verified scalars; set +/// shape is only available for row-boundary `in` operators. +#[derive(Clone, Eq, PartialEq)] +struct DirectClaimExpectation { + field_type: FieldTypeSource, + multi_value: bool, +} + +impl fmt::Debug for DirectClaimExpectation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DirectClaimExpectation") + .field("field_type", &self.field_type) + .field("multi_value", &self.multi_value) .finish() } } @@ -127,7 +115,7 @@ pub struct RegistryAuthenticator { audience: String, principal_claim: String, purpose_claim: Option, - row_boundary_claims: BTreeMap, + direct_claims: BTreeMap, } impl RegistryAuthenticator { @@ -140,14 +128,14 @@ impl RegistryAuthenticator { claims: AuthorityClaimConfig, ) -> Result { validate_verifier_profile(&verifier_config)?; - let row_boundary_claims = validate_claim_mapping(registry, &verifier_config, &claims)?; + let direct_claims = validate_claim_mapping(registry, &verifier_config, &claims)?; let audience = verifier_config.audiences[0].clone(); Ok(Self { verifier: TokenVerifier::new(verifier_config, key_source), audience, principal_claim: claims.principal_claim, purpose_claim: claims.purpose_claim, - row_boundary_claims, + direct_claims, }) } @@ -187,11 +175,11 @@ impl RegistryAuthenticator { .map(validate_scope) .collect::, _>>()?; let direct_claims = self - .row_boundary_claims + .direct_claims .iter() - .filter_map(|(name, value_type)| { + .filter_map(|(name, expectation)| { claims.get(name).map(|value| { - mapped_claim(value, *value_type).map(|value| (name.clone(), value)) + mapped_claim(value, expectation).map(|value| (name.clone(), value)) }) }) .collect::, _>>()?; @@ -215,7 +203,7 @@ impl fmt::Debug for RegistryAuthenticator { .field("audience", &"") .field("principal_claim", &self.principal_claim) .field("purpose_claim", &self.purpose_claim) - .field("row_boundary_claims", &self.row_boundary_claims.keys()) + .field("direct_claims", &self.direct_claims.keys()) .finish() } } @@ -294,7 +282,7 @@ fn validate_claim_mapping( registry: &CompiledRegistry, verifier: &TokenVerifierConfig, claims: &AuthorityClaimConfig, -) -> Result, AuthenticationConfigError> { +) -> Result, AuthenticationConfigError> { let mut configured_names = BTreeSet::new(); if !valid_authority_claim_name(&claims.principal_claim) || !configured_names.insert(claims.principal_claim.as_str()) @@ -310,20 +298,8 @@ fn validate_claim_mapping( return Err(AuthenticationConfigError::InvalidClaimMapping); } } - let mut configured_rows = BTreeMap::new(); - for mapping in &claims.row_boundary_claims { - if !valid_authority_claim_name(&mapping.name) - || mapping.name == verifier.scope_claim - || !configured_names.insert(mapping.name.as_str()) - || configured_rows - .insert(mapping.name.clone(), mapping.value_type) - .is_some() - { - return Err(AuthenticationConfigError::InvalidClaimMapping); - } - } - let mut expected_rows = BTreeMap::new(); + let mut expected_direct_claims = BTreeMap::new(); let mut purpose_required = false; for entity in registry.entities().values() { for profile in entity.access_profiles.values() { @@ -342,23 +318,82 @@ fn validate_claim_mapping( } purpose_required |= !profile.required_purposes.is_empty(); for boundary in &profile.row_boundaries { - let value_type = match boundary.operator { - BoundaryOperator::Equals => RowBoundaryClaimType::DirectString, - BoundaryOperator::In => RowBoundaryClaimType::DirectStringSet, + let field_type = entity + .fields + .get(&boundary.field) + .ok_or(AuthenticationConfigError::CompiledAuthorityMismatch)? + .field_type + .clone(); + let expectation = DirectClaimExpectation { + field_type, + multi_value: boundary.operator == BoundaryOperator::In, }; - if expected_rows - .insert(boundary.claim.clone(), value_type) - .is_some_and(|prior| prior != value_type) - { - return Err(AuthenticationConfigError::CompiledAuthorityMismatch); + insert_direct_claim_expectation( + &mut expected_direct_claims, + &boundary.claim, + expectation, + )?; + } + for lookup in profile + .lookups + .iter() + .filter(|lookup| lookup.value_origin == LookupValueOrigin::VerifiedClaim) + { + let selector = entity + .selector_profiles + .get(&lookup.selector) + .ok_or(AuthenticationConfigError::CompiledAuthorityMismatch)?; + for field_id in &selector.fields { + let claim = lookup + .claim_mapping + .get(field_id) + .ok_or(AuthenticationConfigError::CompiledAuthorityMismatch)?; + let field_type = entity + .fields + .get(field_id) + .ok_or(AuthenticationConfigError::CompiledAuthorityMismatch)? + .field_type + .clone(); + insert_direct_claim_expectation( + &mut expected_direct_claims, + claim, + DirectClaimExpectation { + field_type, + multi_value: false, + }, + )?; } } } } - if purpose_required != claims.purpose_claim.is_some() || expected_rows != configured_rows { + for name in expected_direct_claims.keys() { + if !valid_authority_claim_name(name) + || name == &verifier.scope_claim + || !configured_names.insert(name.as_str()) + { + return Err(AuthenticationConfigError::InvalidClaimMapping); + } + } + if purpose_required != claims.purpose_claim.is_some() { return Err(AuthenticationConfigError::CompiledAuthorityMismatch); } - Ok(configured_rows) + Ok(expected_direct_claims) +} + +fn insert_direct_claim_expectation( + claims: &mut BTreeMap, + name: &str, + expectation: DirectClaimExpectation, +) -> Result<(), AuthenticationConfigError> { + if !valid_authority_claim_name(name) { + return Err(AuthenticationConfigError::InvalidClaimMapping); + } + match claims.insert(name.to_owned(), expectation.clone()) { + Some(prior) if prior != expectation => { + Err(AuthenticationConfigError::CompiledAuthorityMismatch) + } + _ => Ok(()), + } } fn valid_claim_name(value: &str) -> bool { @@ -414,31 +449,55 @@ fn optional_direct_string(value: Option<&Value>) -> Result, Authe fn mapped_claim( value: &Value, - value_type: RowBoundaryClaimType, + expectation: &DirectClaimExpectation, ) -> Result { - match value_type { - RowBoundaryClaimType::DirectString => { - let value = value.as_str().ok_or(AuthenticationError::InvalidClaims)?; - VerifiedClaimValue::direct_string(value.to_owned()) - .map_err(|_| AuthenticationError::InvalidClaims) - } - RowBoundaryClaimType::DirectStringSet => { - let values = value.as_array().ok_or(AuthenticationError::InvalidClaims)?; - let values = values - .iter() - .map(|value| { - value - .as_str() - .map(str::to_owned) - .ok_or(AuthenticationError::InvalidClaims) - }) - .collect::, _>>()?; - VerifiedClaimValue::direct_string_set(values) - .map_err(|_| AuthenticationError::InvalidClaims) - } + if expectation.multi_value { + let values = value.as_array().ok_or(AuthenticationError::InvalidClaims)?; + let values = values + .iter() + .map(|value| mapped_scalar_claim(value, &expectation.field_type)) + .collect::, _>>()?; + VerifiedClaimValue::direct_string_set(values) + .map_err(|_| AuthenticationError::InvalidClaims) + } else { + VerifiedClaimValue::direct_string(mapped_scalar_claim(value, &expectation.field_type)?) + .map_err(|_| AuthenticationError::InvalidClaims) } } +fn mapped_scalar_claim( + value: &Value, + field_type: &FieldTypeSource, +) -> Result { + let value = match field_type { + FieldTypeSource::Boolean => value + .as_bool() + .map(|value| value.to_string()) + .ok_or(AuthenticationError::InvalidClaims)?, + FieldTypeSource::Int64 => value + .as_i64() + .map(|value| value.to_string()) + .ok_or(AuthenticationError::InvalidClaims)?, + FieldTypeSource::String { .. } + | FieldTypeSource::Text { .. } + | FieldTypeSource::Decimal { .. } + | FieldTypeSource::Date + | FieldTypeSource::Timestamp + | FieldTypeSource::Uuid + | FieldTypeSource::Reference { .. } + | FieldTypeSource::VocabularyCode { .. } => value + .as_str() + .map(str::to_owned) + .ok_or(AuthenticationError::InvalidClaims)?, + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + return Err(AuthenticationError::InvalidClaims); + } + }; + crate::postgres::validate_field_value(&value, field_type) + .map_err(|_| AuthenticationError::InvalidClaims)?; + Ok(value) +} + fn authentication_refused() -> Response { Problem::new( "urn:registry-server:problem:authentication.refused", diff --git a/crates/registry-server/src/compiler.rs b/crates/registry-server/src/compiler.rs index 2fe881519e..63319a4161 100644 --- a/crates/registry-server/src/compiler.rs +++ b/crates/registry-server/src/compiler.rs @@ -12,22 +12,28 @@ use uuid::Uuid; use crate::artifacts::generate_artifacts; use crate::contract::{ parsed_bbox, valid_decimal_bounds, valid_structured_schema, AccessProfileSource, - Classification, ConstraintSource, EntityExtensionSource, EntitySource, EventTrigger, - FieldSource, FieldTypeSource, ManifestProjectionTextSource, MutationMode, Operation, - RegistryModule, RegistryProject, UniqueWhenPredicate, ValidTimeRole, WebhookDeadLetterMode, - MAX_STRUCTURED_VALUE_BYTES, + Classification, ConstraintSource, DerivedExecutionSource, DerivedFieldSource, + EntityExtensionSource, EntitySource, EventTrigger, FieldSource, FieldTypeSource, + LookupValueOrigin, ManifestProjectionTextSource, ModuleAssetSource, MutationMode, Operation, + ReadPathGrantSource, RegistryModule, RegistryProject, UniqueWhenPredicate, ValidTimeRole, + WebhookDeadLetterMode, MAX_STRUCTURED_VALUE_BYTES, }; +use crate::derived_sql::{validate_derived_sql, MAX_DERIVED_SQL_BYTES}; use crate::diagnostics::{CompileFailure, Diagnostic}; use crate::generated_ddl::generate_ddl; +use crate::logical_names::{ + default_api_name, default_sql_name, reserved_logical_name, valid_api_name, +}; use crate::model::{ - CompiledAccessEntry, CompiledAccessInventory, CompiledEntity, CompiledEventDelivery, - CompiledEventDeliveryInventory, CompiledField, CompiledMetadataEntity, CompiledMetadataEntry, - CompiledMetadataInventory, CompiledModuleIdentity, CompiledQueryFilterField, - CompiledQueryFilterOperator, CompiledQueryInventory, CompiledQueryKind, CompiledQueryOperation, - CompiledQuerySortDirection, CompiledQuerySortField, CompiledQueryTemporalBinding, - CompiledQueryTemporalSemantics, CompiledRegistry, CompiledRevisionKind, CompiledRoute, - CompiledRouteInventory, CompiledTemporal, CompiledWebhookDeliveryMode, HttpMethod, - MAX_REVISION_HISTORY_RECORDS, + CompiledAccessEntry, CompiledAccessInventory, CompiledDerivedField, CompiledDerivedRelation, + CompiledEntity, CompiledEventDelivery, CompiledEventDeliveryInventory, CompiledField, + CompiledLogicalField, CompiledMetadataEntity, CompiledMetadataEntry, CompiledMetadataInventory, + CompiledModuleIdentity, CompiledQueryFilterField, CompiledQueryFilterOperator, + CompiledQueryInventory, CompiledQueryKind, CompiledQueryOperation, CompiledQuerySortDirection, + CompiledQuerySortField, CompiledQueryTemporalBinding, CompiledQueryTemporalSemantics, + CompiledReadPath, CompiledRegistry, CompiledRevisionKind, CompiledRoute, + CompiledRouteInventory, CompiledSelectorProfile, CompiledSourceRelation, CompiledStoredField, + CompiledTemporal, CompiledWebhookDeliveryMode, HttpMethod, MAX_REVISION_HISTORY_RECORDS, }; use crate::physical_names::{ hex_prefix, EntityPhysicalNames, PhysicalNameBuilder, PhysicalNameInventory, @@ -65,24 +71,48 @@ pub fn compile_project( project: &RegistryProject, modules: &[RegistryModule], profile: CompileProfile, +) -> Result { + compile_project_with_assets(project, modules, &[], profile) +} + +/// Compile governed source and caller-supplied module assets without opening files. +pub fn compile_project_with_assets( + project: &RegistryProject, + modules: &[RegistryModule], + assets: &[ModuleAssetSource], + profile: CompileProfile, ) -> Result { let mut diagnostics = Vec::new(); let mut findings = Vec::new(); validate_project_header(project, profile, &mut diagnostics, &mut findings); - let module_closure = - validate_module_locks(project, modules, profile, &mut diagnostics, &mut findings); + let module_closure = validate_module_locks( + project, + modules, + assets, + profile, + &mut diagnostics, + &mut findings, + ); let (module_order, module_map) = order_modules(project, modules, &mut diagnostics); - let mut sources = collect_entities(project, &module_order, &module_map, &mut diagnostics); + let (mut sources, mut derived_origins) = + collect_entities(project, &module_order, &module_map, &mut diagnostics); apply_temporal_roles(&mut sources, &mut diagnostics); - apply_extensions(&mut sources, &module_order, &module_map, &mut diagnostics); + apply_extensions( + &mut sources, + &mut derived_origins, + &module_order, + &module_map, + &mut diagnostics, + ); expand_project_access(project, &mut sources, &mut diagnostics); resolve_vocabularies(project, &mut sources, &mut diagnostics); validate_entities(&sources, &mut diagnostics); + validate_derived_assets(&sources, &derived_origins, assets, &mut diagnostics); if !diagnostics.is_empty() { return Err(CompileFailure::from_errors(diagnostics)); } - let (entities, physical_names) = compile_entities(&sources)?; + let (entities, physical_names) = compile_entities(&sources, &derived_origins, assets)?; let (route_inventory, access_inventory) = compile_routes_and_access(&entities)?; let metadata_inventory = compile_metadata_inventory( &project.registry.id, @@ -612,6 +642,7 @@ fn order_modules( fn validate_module_locks( project: &RegistryProject, modules: &[RegistryModule], + assets: &[ModuleAssetSource], profile: CompileProfile, errors: &mut Vec, findings: &mut Vec, @@ -661,7 +692,7 @@ fn validate_module_locks( "an authored module does not match its locked version", )); } - let actual = module_digest(module); + let actual = module_digest_with_assets(module, assets); if let Some(expected) = &lock.digest { if expected != &actual { errors.push(Diagnostic::error( @@ -709,9 +740,32 @@ fn validate_module_locks( } pub fn module_digest(module: &RegistryModule) -> String { + module_digest_with_assets(module, &[]) +} + +pub fn module_digest_with_assets(module: &RegistryModule, assets: &[ModuleAssetSource]) -> String { let value = serde_json::to_value(module).expect("module serializes"); let bytes = canonicalize_json(&value).expect("module canonicalizes"); - let digest = Sha256::digest(bytes); + let mut module_assets = assets + .iter() + .filter(|asset| asset.module.as_deref() == Some(module.id.as_str())) + .collect::>(); + if module_assets.is_empty() { + let digest = Sha256::digest(bytes); + return format!("sha256:{}", hex_prefix(&digest, digest.len())); + } + let mut digest = Sha256::new(); + digest.update(b"registry-server-module-v2\0"); + digest.update((bytes.len() as u64).to_be_bytes()); + digest.update(bytes); + module_assets.sort_by(|left, right| left.path.cmp(&right.path)); + for asset in module_assets { + digest.update((asset.path.len() as u64).to_be_bytes()); + digest.update(asset.path.as_bytes()); + digest.update((asset.bytes.len() as u64).to_be_bytes()); + digest.update(&asset.bytes); + } + let digest = digest.finalize(); format!("sha256:{}", hex_prefix(&digest, digest.len())) } @@ -720,24 +774,44 @@ fn collect_entities( module_order: &[String], modules: &BTreeMap, errors: &mut Vec, -) -> BTreeMap { +) -> ( + BTreeMap, + BTreeMap<(String, String), Option>, +) { let mut entities = BTreeMap::new(); + let mut derived_origins = BTreeMap::new(); for entity in &project.entities { - insert_entity(&mut entities, entity, "project.entities[].id", errors); + insert_entity( + &mut entities, + &mut derived_origins, + entity, + None, + "project.entities[].id", + errors, + ); } for module_id in module_order { if let Some(module) = modules.get(module_id) { for entity in &module.entities { - insert_entity(&mut entities, entity, "modules[].entities[].id", errors); + insert_entity( + &mut entities, + &mut derived_origins, + entity, + Some(module.id.clone()), + "modules[].entities[].id", + errors, + ); } } } - entities + (entities, derived_origins) } fn insert_entity( entities: &mut BTreeMap, + derived_origins: &mut BTreeMap<(String, String), Option>, entity: &EntitySource, + module: Option, path: &str, errors: &mut Vec, ) { @@ -747,6 +821,10 @@ fn insert_entity( path, "an entity identifier is contributed more than once", )); + return; + } + for derived in &entity.derived { + derived_origins.insert((entity.id.clone(), derived.id.clone()), module.clone()); } } @@ -788,6 +866,7 @@ fn apply_temporal_roles( fn apply_extensions( entities: &mut BTreeMap, + derived_origins: &mut BTreeMap<(String, String), Option>, module_order: &[String], modules: &BTreeMap, errors: &mut Vec, @@ -807,7 +886,13 @@ fn apply_extensions( )); continue; }; - merge_extension(entity, extension, errors); + merge_extension( + entity, + extension, + Some(module.id.clone()), + derived_origins, + errors, + ); } } } @@ -815,6 +900,8 @@ fn apply_extensions( fn merge_extension( entity: &mut EntitySource, extension: &EntityExtensionSource, + module: Option, + derived_origins: &mut BTreeMap<(String, String), Option>, errors: &mut Vec, ) { merge_by_id( @@ -826,6 +913,19 @@ fn merge_extension( "a field identifier is contributed more than once", errors, ); + let existing_derived = entity.derived.len(); + merge_by_id( + &mut entity.derived, + &extension.derived, + |value| value.id.as_str(), + "extension.derived.duplicate", + "modules[].extendEntities[].derived[].id", + "a derived relation identifier is contributed more than once", + errors, + ); + for derived in entity.derived.iter().skip(existing_derived) { + derived_origins.insert((entity.id.clone(), derived.id.clone()), module.clone()); + } merge_by_id( &mut entity.indexes, &extension.indexes, @@ -853,6 +953,24 @@ fn merge_extension( "an event identifier is contributed more than once", errors, ); + merge_by_id( + &mut entity.selector_profiles, + &extension.selector_profiles, + |value| value.id.as_str(), + "extension.selector_profile.duplicate", + "modules[].extendEntities[].selectorProfiles[].id", + "a selector profile identifier is contributed more than once", + errors, + ); + merge_by_id( + &mut entity.read_paths, + &extension.read_paths, + |value| value.id.as_str(), + "extension.read_path.duplicate", + "modules[].extendEntities[].readPaths[].id", + "a read path identifier is contributed more than once", + errors, + ); let mut known: BTreeSet = entity .constraints @@ -956,6 +1074,9 @@ fn expand_project_access( filterable_fields: grant.filterable_fields.clone(), sortable_fields: grant.sortable_fields.clone(), row_boundaries: grant.row_boundaries.clone(), + lookups: grant.lookups.clone(), + read_paths: grant.read_paths.clone(), + allow_count: grant.allow_count, revision_access: grant.revision_access, allow_data_export: grant.allow_data_export, }); @@ -1055,11 +1176,16 @@ fn validate_entities(entities: &BTreeMap, errors: &mut Vec _ => {} } validate_entity_fields(entity, entities, errors); + validate_derived(entity, errors); + validate_logical_names(entity, errors); validate_constraints(entity, errors); validate_indexes(entity, errors); - validate_profiles(entity, errors); + validate_selector_profiles(entity, errors); + validate_read_paths(entity, entities, errors); + validate_profiles(entity, entities, errors); validate_events(entity, errors); } + validate_read_path_cycles(entities, errors); } fn validate_entity_fields( @@ -1071,6 +1197,13 @@ fn validate_entity_fields( let mut roles = BTreeMap::new(); for field in &entity.fields { validate_id(&field.id, "entities[].fields[].id", errors); + if reserved_logical_name(&field.id) { + errors.push(Diagnostic::error( + "field.id.reserved", + "entities[].fields[].id", + "a field identifier collides with a reserved Registry field", + )); + } if !fields.insert(field.id.as_str()) { errors.push(Diagnostic::error( "field.id.duplicate", @@ -1206,6 +1339,193 @@ fn validate_entity_fields( } } +fn validate_derived(entity: &EntitySource, errors: &mut Vec) { + let stored = stored_field_map(entity); + let mut ids = BTreeSet::new(); + let mut field_ids = BTreeSet::new(); + field_ids.extend(entity.fields.iter().map(|field| field.id.clone())); + for derived in &entity.derived { + validate_id(&derived.id, "entities[].derived[].id", errors); + if !ids.insert(derived.id.as_str()) { + errors.push(Diagnostic::error( + "derived.id.duplicate", + "entities[].derived[].id", + "a derived relation identifier is duplicated", + )); + } + if !valid_relative_sql_path(&derived.sql) { + errors.push(Diagnostic::error( + "derived.sql_path.invalid", + "entities[].derived[].sql", + "derived SQL must be a module-relative .sql path", + )); + } + if derived.key != "id" || stored.contains_key(derived.key.as_str()) { + errors.push(Diagnostic::error( + "derived.key.invalid", + "entities[].derived[].key", + "derived SQL must declare the canonical id key", + )); + } + if derived.execution != DerivedExecutionSource::Live { + errors.push(Diagnostic::error( + "derived.execution.unsupported", + "entities[].derived[].execution", + "derived SQL currently supports only live execution", + )); + } + if derived.fields.is_empty() { + errors.push(Diagnostic::error( + "derived.fields.empty", + "entities[].derived[].fields", + "derived SQL must declare at least one output field", + )); + } + for field in &derived.fields { + validate_derived_field(field, &mut field_ids, errors); + } + } +} + +fn validate_derived_field( + field: &DerivedFieldSource, + field_ids: &mut BTreeSet, + errors: &mut Vec, +) { + validate_id(&field.id, "entities[].derived[].fields[].id", errors); + if reserved_logical_name(&field.id) { + errors.push(Diagnostic::error( + "field.id.reserved", + "entities[].derived[].fields[].id", + "a field identifier collides with a reserved Registry field", + )); + } + if !field_ids.insert(field.id.clone()) { + errors.push(Diagnostic::error( + "field.id.duplicate", + "entities[].derived[].fields[].id", + "a stored or derived field identifier is duplicated", + )); + } + validate_field_type_bounds(&field.field_type, "entities[].derived[].fields[]", errors); +} + +fn validate_logical_names(entity: &EntitySource, errors: &mut Vec) { + let mut api_names = BTreeSet::from(["id".to_owned()]); + let mut sql_names = BTreeSet::from(["id".to_owned()]); + for field in entity + .fields + .iter() + .map(|field| (&field.id, field.api_name.as_deref())) + .chain(entity.derived.iter().flat_map(|derived| { + derived + .fields + .iter() + .map(|field| (&field.id, field.api_name.as_deref())) + })) + { + let api_name = field + .1 + .map(str::to_owned) + .unwrap_or_else(|| default_api_name(field.0)); + if !valid_api_name(&api_name) || reserved_logical_name(&api_name) { + errors.push(Diagnostic::error( + "field.api_name.invalid", + "entities[].fields[].apiName", + "a field API name must be a non-reserved lower camelCase identifier", + )); + } + if !api_names.insert(api_name) { + errors.push(Diagnostic::error( + "field.api_name.duplicate", + "entities[].fields[].apiName", + "field API names must be unique within an entity", + )); + } + let sql_name = default_sql_name(field.0); + if reserved_logical_name(&sql_name) || !sql_names.insert(sql_name) { + errors.push(Diagnostic::error( + "field.sql_name.duplicate", + "entities[].fields[].id", + "field SQL names must be non-reserved and unique within an entity", + )); + } + } +} + +fn validate_field_type_bounds( + field_type: &FieldTypeSource, + path: &str, + errors: &mut Vec, +) { + match field_type { + FieldTypeSource::String { + min_length, + max_length, + } if *max_length == 0 || *max_length > 1_000_000 || min_length > max_length => { + errors.push(Diagnostic::error( + "field.string.bounds_invalid", + path, + "string length bounds are invalid", + )) + } + FieldTypeSource::Text { max_length } if *max_length == 0 || *max_length > 10_000_000 => { + errors.push(Diagnostic::error( + "field.text.bound_invalid", + path, + "text length bound must be positive", + )); + } + FieldTypeSource::VocabularyCode { values, .. } + if values.is_empty() + || has_duplicates(values) + || values.iter().any(|value| !valid_code(value)) => + { + errors.push(Diagnostic::error( + "field.vocabulary.values_invalid", + path, + "a vocabulary field requires a non-empty duplicate-free value set", + )); + } + FieldTypeSource::Decimal { + precision, + scale, + minimum, + maximum, + } if !valid_decimal_bounds(*precision, *scale, minimum.as_deref(), maximum.as_deref()) => { + errors.push(Diagnostic::error( + "field.decimal.bounds_invalid", + path, + "decimal precision, scale, or canonical bounds are invalid", + )); + } + FieldTypeSource::Crs84Point { precision, bbox } + if *precision > 9 + || bbox + .as_ref() + .is_some_and(|bbox| parsed_bbox(bbox, *precision).is_none()) => + { + errors.push(Diagnostic::error( + "field.crs84_point.bounds_invalid", + path, + "CRS84 point precision or CRS84 bounding box is invalid", + )); + } + FieldTypeSource::Structured { max_bytes, schema } + if *max_bytes == 0 + || *max_bytes > MAX_STRUCTURED_VALUE_BYTES + || !valid_structured_schema(schema) => + { + errors.push(Diagnostic::error( + "field.structured.schema_invalid", + path, + "structured field schema or byte bound is invalid", + )); + } + _ => {} + } +} + fn validate_constraints(entity: &EntitySource, errors: &mut Vec) { let fields: BTreeMap<&str, &FieldSource> = entity .fields @@ -1475,6 +1795,86 @@ fn supports_temporal_non_overlap_scope(field_type: &FieldTypeSource) -> bool { ) } +fn stored_field_map(entity: &EntitySource) -> BTreeMap<&str, &FieldSource> { + entity + .fields + .iter() + .map(|field| (field.id.as_str(), field)) + .collect() +} + +fn derived_field_map(entity: &EntitySource) -> BTreeMap<&str, &DerivedFieldSource> { + entity + .derived + .iter() + .flat_map(|derived| { + derived + .fields + .iter() + .map(|field| (field.id.as_str(), field)) + }) + .collect() +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FieldStorageKind { + Stored, + Derived, + Pseudo, +} + +fn selector_field_supported(field_type: &FieldTypeSource) -> bool { + !matches!( + field_type, + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } + ) +} + +fn infer_read_path_refs( + source: &EntitySource, + through: &EntitySource, + target: &str, +) -> Option<(String, String)> { + let source_refs = through + .fields + .iter() + .filter_map(|field| match &field.field_type { + FieldTypeSource::Reference { target, .. } if target == &source.id => { + Some(field.id.clone()) + } + _ => None, + }) + .collect::>(); + let target_refs = through + .fields + .iter() + .filter_map(|field| match &field.field_type { + FieldTypeSource::Reference { + target: field_target, + .. + } if field_target == target => Some(field.id.clone()), + _ => None, + }) + .collect::>(); + match (source_refs.as_slice(), target_refs.as_slice()) { + ([source_ref], [target_ref]) if source_ref != target_ref => { + Some((source_ref.clone(), target_ref.clone())) + } + _ => None, + } +} + +fn valid_relative_sql_path(path: &str) -> bool { + !path.is_empty() + && path.len() <= 256 + && path.ends_with(".sql") + && !path.starts_with('/') + && !path.contains('\\') + && path + .split('/') + .all(|part| !part.is_empty() && part != "." && part != "..") +} + fn validate_indexes(entity: &EntitySource, errors: &mut Vec) { let fields: BTreeSet<&str> = entity .fields @@ -1507,12 +1907,154 @@ fn validate_indexes(entity: &EntitySource, errors: &mut Vec) { } } -fn validate_profiles(entity: &EntitySource, errors: &mut Vec) { - let fields: BTreeMap<&str, &FieldSource> = entity - .fields +fn validate_selector_profiles(entity: &EntitySource, errors: &mut Vec) { + let fields = stored_field_map(entity); + let mut ids = BTreeSet::new(); + for selector in &entity.selector_profiles { + validate_id(&selector.id, "entities[].selectorProfiles[].id", errors); + if !ids.insert(selector.id.as_str()) { + errors.push(Diagnostic::error( + "selector_profile.id.duplicate", + "entities[].selectorProfiles[].id", + "a selector profile identifier is duplicated", + )); + } + if selector.fields.is_empty() + || selector.fields.len() > 16 + || has_duplicates(&selector.fields) + || selector + .fields + .iter() + .any(|field| !fields.contains_key(field.as_str())) + { + errors.push(Diagnostic::error( + "selector_profile.fields.invalid", + "entities[].selectorProfiles[].fields", + "a selector profile must name one to sixteen stored fields", + )); + continue; + } + if selector.fields.iter().any(|field| { + fields + .get(field.as_str()) + .is_some_and(|field| !selector_field_supported(&field.field_type)) + }) { + errors.push(Diagnostic::error( + "selector_profile.field_type_unsupported", + "entities[].selectorProfiles[].fields", + "selector profile fields must use supported scalar stored types", + )); + } + } +} + +fn validate_read_paths( + entity: &EntitySource, + entities: &BTreeMap, + errors: &mut Vec, +) { + let mut ids = BTreeSet::new(); + let mut routes = BTreeSet::new(); + for path in &entity.read_paths { + validate_id(&path.id, "entities[].readPaths[].id", errors); + validate_id(&path.route, "entities[].readPaths[].route", errors); + if !ids.insert(path.id.as_str()) { + errors.push(Diagnostic::error( + "read_path.id.duplicate", + "entities[].readPaths[].id", + "a read path identifier is duplicated", + )); + } + if !routes.insert(path.route.as_str()) { + errors.push(Diagnostic::error( + "read_path.route.duplicate", + "entities[].readPaths[].route", + "a read path route is duplicated for an entity", + )); + } + if path.to == entity.id { + errors.push(Diagnostic::error( + "read_path.target.self", + "entities[].readPaths[].to", + "a read path target must differ from its source entity", + )); + } + let Some(through) = entities.get(&path.through) else { + errors.push(Diagnostic::error( + "read_path.through.unknown", + "entities[].readPaths[].through", + "a read path association entity does not resolve", + )); + continue; + }; + if !entities.contains_key(&path.to) { + errors.push(Diagnostic::error( + "read_path.target.unknown", + "entities[].readPaths[].to", + "a read path target entity does not resolve", + )); + continue; + } + if infer_read_path_refs(entity, through, &path.to).is_none() { + errors.push(Diagnostic::error( + "read_path.references.ambiguous", + "entities[].readPaths[]", + "a read path must have exactly one source reference and one target reference", + )); + } + } +} + +fn validate_read_path_cycles( + entities: &BTreeMap, + errors: &mut Vec, +) { + let edges = entities + .values() + .flat_map(|entity| { + entity + .read_paths + .iter() + .map(|path| (entity.id.as_str(), path.to.as_str())) + }) + .collect::>(); + for (source, target) in &edges { + if reaches(*target, *source, &edges, &mut BTreeSet::new()) { + errors.push(Diagnostic::error( + "read_path.cycle", + "entities[].readPaths[]", + "read paths must not form a traversal cycle", + )); + return; + } + } +} + +fn reaches<'a>( + current: &'a str, + target: &str, + edges: &[(&'a str, &'a str)], + visited: &mut BTreeSet<&'a str>, +) -> bool { + if current == target { + return true; + } + if !visited.insert(current) { + return false; + } + edges .iter() - .map(|field| (field.id.as_str(), field)) - .collect(); + .filter(|(source, _)| *source == current) + .any(|(_, next)| reaches(next, target, edges, visited)) +} + +fn validate_profiles( + entity: &EntitySource, + entities: &BTreeMap, + errors: &mut Vec, +) { + let fields = stored_field_map(entity); + let derived = derived_field_map(entity); let mut ids = BTreeSet::new(); for access in &entity.access_profiles { validate_id(&access.id, "entities[].accessProfiles[].id", errors); @@ -1598,19 +2140,21 @@ fn validate_profiles(entity: &EntitySource, errors: &mut Vec) { "bulk data export requires an authenticated list profile with a readable projection", )); } - let mut processed = access.readable_fields.clone(); - processed.extend(access.writable_fields.iter().cloned()); - processed.extend(access.filterable_fields.iter().cloned()); - processed.extend(access.sortable_fields.iter().cloned()); - processed.extend( + let mut read_processed = access.readable_fields.clone(); + read_processed.extend(access.filterable_fields.iter().cloned()); + read_processed.extend(access.sortable_fields.iter().cloned()); + let mut stored_processed = access.writable_fields.clone(); + stored_processed.extend( access .row_boundaries .iter() .map(|boundary| boundary.field.clone()), ); - if processed + if read_processed.iter().any(|field| { + !fields.contains_key(field.as_str()) && !derived.contains_key(field.as_str()) + }) || stored_processed .iter() - .any(|field| !fields.contains_key(field.as_str())) + .any(|field| field != "id" && !fields.contains_key(field.as_str())) { errors.push(Diagnostic::error( "access_profile.field.unknown", @@ -1629,10 +2173,17 @@ fn validate_profiles(entity: &EntitySource, errors: &mut Vec) { } if access.anonymous && (entity.classification != Classification::Public - || processed.iter().any(|field| { + || read_processed.iter().any(|field| { fields .get(field.as_str()) .is_some_and(|field| field.classification != Classification::Public) + || derived.contains_key(field.as_str()) + }) + || stored_processed.iter().any(|field| { + field != "id" + && fields + .get(field.as_str()) + .is_some_and(|field| field.classification != Classification::Public) })) { errors.push(Diagnostic::error( @@ -1643,12 +2194,14 @@ fn validate_profiles(entity: &EntitySource, errors: &mut Vec) { } let mut boundaries = BTreeSet::new(); for boundary in &access.row_boundaries { - if fields.get(boundary.field.as_str()).is_some_and(|field| { - matches!( - field.field_type, - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } - ) - }) { + if boundary.field != "id" + && fields.get(boundary.field.as_str()).is_some_and(|field| { + matches!( + field.field_type, + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } + ) + }) + { errors.push(Diagnostic::error( "access_profile.row_boundary.type_unsupported", "entities[].accessProfiles[].rowBoundaries", @@ -1669,6 +2222,15 @@ fn validate_profiles(entity: &EntitySource, errors: &mut Vec) { )); } } + validate_lookup_grants(access, entity, &fields, errors); + validate_read_path_grants(access, entity, entities, errors); + if access.allow_count && !access.operations.contains(&Operation::List) { + errors.push(Diagnostic::error( + "access_profile.count.unavailable", + "entities[].accessProfiles[].allowCount", + "direct count access requires an explicit list grant", + )); + } } for operation in all_operations() { let profiles: Vec<&AccessProfileSource> = entity @@ -1690,6 +2252,241 @@ fn validate_profiles(entity: &EntitySource, errors: &mut Vec) { )); } } + for path in &entity.read_paths { + let profiles: Vec<&AccessProfileSource> = entity + .access_profiles + .iter() + .filter(|access| access.read_paths.iter().any(|grant| grant.path == path.id)) + .collect(); + if profiles.is_empty() { + continue; + } + let explicit_defaults = profiles.iter().filter(|access| access.default).count(); + if profiles.len() > 1 && explicit_defaults != 1 + || profiles.len() == 1 && explicit_defaults > 1 + { + errors.push(Diagnostic::error( + "access_profile.default.invalid", + "entities[].accessProfiles[].default", + "each exposed read-path route requires exactly one default profile", + )); + } + } +} + +fn validate_lookup_grants( + access: &AccessProfileSource, + entity: &EntitySource, + fields: &BTreeMap<&str, &FieldSource>, + errors: &mut Vec, +) { + if access.lookups.is_empty() { + return; + } + if !access.operations.contains(&Operation::Lookup) { + errors.push(Diagnostic::error( + "access_profile.lookup.operation_required", + "entities[].accessProfiles[].lookups", + "lookup grants require the lookup operation", + )); + } + let selectors = entity + .selector_profiles + .iter() + .map(|selector| (selector.id.as_str(), selector)) + .collect::>(); + let mut granted = BTreeSet::new(); + for lookup in &access.lookups { + if !granted.insert(lookup.selector.as_str()) { + errors.push(Diagnostic::error( + "access_profile.lookup.duplicate", + "entities[].accessProfiles[].lookups", + "lookup selector grants must be unique", + )); + } + let Some(selector) = selectors.get(lookup.selector.as_str()) else { + errors.push(Diagnostic::error( + "access_profile.lookup.selector_unknown", + "entities[].accessProfiles[].lookups[].selector", + "a lookup grant refers to an unknown selector profile", + )); + continue; + }; + if access.anonymous + && selector.fields.iter().any(|field| { + fields + .get(field.as_str()) + .is_some_and(|field| field.classification != Classification::Public) + }) + { + errors.push(Diagnostic::error( + "access_profile.public.processing_non_public", + "entities[].accessProfiles[].lookups", + "an anonymous lookup may process only public selector fields", + )); + } + match lookup.value_origin { + LookupValueOrigin::Request if !lookup.claim_mapping.is_empty() => { + errors.push(Diagnostic::error( + "access_profile.lookup.claim_mapping_unavailable", + "entities[].accessProfiles[].lookups[].claimMapping", + "request-origin lookups must not declare claim mappings", + )); + } + LookupValueOrigin::VerifiedClaim => { + let expected = selector.fields.iter().cloned().collect::>(); + let actual = lookup + .claim_mapping + .keys() + .cloned() + .collect::>(); + if actual != expected || lookup.claim_mapping.values().any(|claim| claim.is_empty()) + { + errors.push(Diagnostic::error( + "access_profile.lookup.claim_mapping_invalid", + "entities[].accessProfiles[].lookups[].claimMapping", + "claim-origin lookups must map every selector field to one direct claim", + )); + } + } + LookupValueOrigin::Request => {} + } + } +} + +fn validate_read_path_grants( + access: &AccessProfileSource, + entity: &EntitySource, + entities: &BTreeMap, + errors: &mut Vec, +) { + let paths = entity + .read_paths + .iter() + .map(|path| (path.id.as_str(), path)) + .collect::>(); + let mut granted = BTreeSet::new(); + for grant in &access.read_paths { + if !granted.insert(grant.path.as_str()) { + errors.push(Diagnostic::error( + "access_profile.read_path.duplicate", + "entities[].accessProfiles[].readPaths", + "read-path grants must be unique", + )); + } + let Some(path) = paths.get(grant.path.as_str()) else { + errors.push(Diagnostic::error( + "access_profile.read_path.unknown", + "entities[].accessProfiles[].readPaths[].path", + "a read-path grant refers to an unknown path", + )); + continue; + }; + validate_read_path_grant_fields(access, entity, entities, path, grant, errors); + } +} + +fn validate_read_path_grant_fields( + access: &AccessProfileSource, + source: &EntitySource, + entities: &BTreeMap, + path: &crate::contract::ReadPathSource, + grant: &ReadPathGrantSource, + errors: &mut Vec, +) { + let Some(target) = entities.get(&path.to) else { + return; + }; + let Some(through) = entities.get(&path.through) else { + return; + }; + let target_stored = stored_field_map(target); + let target_derived = derived_field_map(target); + if grant.readable_fields.is_empty() { + errors.push(Diagnostic::error( + "access_profile.read_path.readable_fields_empty", + "entities[].accessProfiles[].readPaths[].readableFields", + "a read-path grant must declare readable fields", + )); + } + if !grant.filterable_fields.is_subset(&grant.readable_fields) + || !grant.sortable_fields.is_subset(&grant.readable_fields) + { + errors.push(Diagnostic::error( + "access_profile.read_path.processing.wider_than_read", + "entities[].accessProfiles[].readPaths[]", + "read-path filterable and sortable fields must be readable", + )); + } + if access.anonymous && source.classification != Classification::Public { + errors.push(Diagnostic::error( + "access_profile.public.processing_non_public", + "entities[].accessProfiles[].readPaths", + "an anonymous read path may process only public source and join fields", + )); + } + if access.anonymous { + if let Some((source_ref, target_ref)) = infer_read_path_refs(source, through, &path.to) { + let through_fields = stored_field_map(through); + if [source_ref, target_ref].iter().any(|field| { + through_fields + .get(field.as_str()) + .is_some_and(|field| field.classification != Classification::Public) + }) { + errors.push(Diagnostic::error( + "access_profile.public.processing_non_public", + "entities[].accessProfiles[].readPaths", + "an anonymous read path may process only public join fields", + )); + } + } + } + let processed = grant + .readable_fields + .iter() + .chain(&grant.filterable_fields) + .chain(&grant.sortable_fields) + .collect::>(); + if processed.iter().any(|field| { + field.as_str() != "id" + && !target_stored.contains_key(field.as_str()) + && !target_derived.contains_key(field.as_str()) + }) { + errors.push(Diagnostic::error( + "access_profile.read_path.field_unknown", + "entities[].accessProfiles[].readPaths[]", + "a read-path grant refers to an unknown target field", + )); + } + if access.anonymous + && processed.iter().any(|field| { + target_derived.contains_key(field.as_str()) + || (field.as_str() != "id" + && target_stored + .get(field.as_str()) + .is_some_and(|field| field.classification != Classification::Public)) + }) + { + errors.push(Diagnostic::error( + "access_profile.public.processing_non_public", + "entities[].accessProfiles[].readPaths", + "an anonymous read path may process only public target fields and no derived fields", + )); + } + if processed.is_empty() && grant.allow_count { + errors.push(Diagnostic::error( + "access_profile.read_path.count_without_fields", + "entities[].accessProfiles[].readPaths[].allowCount", + "read-path count access requires explicit path field capabilities", + )); + } + if path.to == source.id { + errors.push(Diagnostic::error( + "access_profile.read_path.self_target", + "entities[].accessProfiles[].readPaths[].path", + "a read-path grant cannot target the source entity", + )); + } } fn validate_events(entity: &EntitySource, errors: &mut Vec) { @@ -1943,6 +2740,76 @@ fn compile_event_delivery_inventory( CompiledEventDeliveryInventory { deliveries } } +fn validate_derived_assets( + sources: &BTreeMap, + origins: &BTreeMap<(String, String), Option>, + assets: &[ModuleAssetSource], + errors: &mut Vec, +) { + let known_relations = sources + .values() + .map(|entity| default_sql_name(&entity.id)) + .collect::>(); + let known_relations = known_relations.iter().map(String::as_str).collect(); + let assets = asset_map(assets, errors); + for entity in sources.values() { + for derived in &entity.derived { + let path = format!("entities[{}].derived[{}].sql", entity.id, derived.id); + let owner = origins + .get(&(entity.id.clone(), derived.id.clone())) + .cloned() + .flatten(); + let Some(sql) = assets.get(&(owner.clone(), derived.sql.clone())) else { + errors.push(Diagnostic::error( + "derived.sql.asset_missing", + path, + "derived SQL must be supplied as a compilation asset", + )); + continue; + }; + validate_derived_sql(derived, sql, &known_relations, &path, errors); + } + } +} + +fn asset_map<'a>( + assets: &'a [ModuleAssetSource], + errors: &mut Vec, +) -> BTreeMap<(Option, String), &'a [u8]> { + let mut map = BTreeMap::new(); + for asset in assets { + if !asset + .module + .as_deref() + .is_none_or(|module| !module.is_empty()) + || !valid_relative_sql_path(&asset.path) + || asset.bytes.is_empty() + || asset.bytes.len() > MAX_DERIVED_SQL_BYTES + { + errors.push(Diagnostic::error( + "module.asset.invalid", + "modules[].assets[]", + "module assets must be bounded module-relative SQL files", + )); + continue; + } + if map + .insert( + (asset.module.clone(), asset.path.clone()), + asset.bytes.as_slice(), + ) + .is_some() + { + errors.push(Diagnostic::error( + "module.asset.duplicate", + "modules[].assets[]", + "module assets must be unique by module and path", + )); + } + } + map +} + fn webhook_retry_delays(initial_ms: u32, maximum_ms: u32, maximum_attempts: u8) -> Vec { let mut delay = initial_ms; (1..maximum_attempts) @@ -1958,19 +2825,24 @@ fn webhook_retry_delays(initial_ms: u32, maximum_ms: u32, maximum_attempts: u8) fn compile_entities( sources: &BTreeMap, + origins: &BTreeMap<(String, String), Option>, + assets: &[ModuleAssetSource], ) -> Result<(BTreeMap, PhysicalNameInventory), CompileFailure> { let mut builder = PhysicalNameBuilder::new(); let mut entities = BTreeMap::new(); let mut inventory = BTreeMap::new(); + let asset_lookup = assets + .iter() + .map(|asset| ((asset.module.clone(), asset.path.clone()), asset)) + .collect::>(); for source in sources.values() { let table = builder .derive("e", &source.id, "entities[].id") .map_err(CompileFailure::from_one)?; let mut field_names = BTreeMap::new(); let mut fields = BTreeMap::new(); - let mut sorted_fields = source.fields.clone(); - sorted_fields.sort_by(|left, right| left.id.cmp(&right.id)); - for field in sorted_fields { + let mut stored_fields = Vec::new(); + for field in source.fields.clone() { let physical = builder .derive( "f", @@ -1979,6 +2851,18 @@ fn compile_entities( ) .map_err(CompileFailure::from_one)?; field_names.insert(field.id.clone(), physical.clone()); + let logical = logical_field( + &field.id, + field.api_name.as_deref(), + field.field_type.clone(), + field.classification, + ); + stored_fields.push(CompiledStoredField { + logical: logical.clone(), + required: field.required, + valid_time_role: field.valid_time_role, + physical_name: physical.clone(), + }); fields.insert( field.id.clone(), CompiledField { @@ -1991,6 +2875,93 @@ fn compile_entities( }, ); } + let mut derived_fields = BTreeMap::new(); + let mut derived_relations = BTreeMap::new(); + for derived in &source.derived { + let owner = origins + .get(&(source.id.clone(), derived.id.clone())) + .cloned() + .flatten(); + let asset = asset_lookup + .get(&(owner, derived.sql.clone())) + .expect("derived SQL asset was validated"); + let mut field_ids = Vec::new(); + for field in &derived.fields { + let logical = logical_field( + &field.id, + field.api_name.as_deref(), + field.field_type.clone(), + field.classification, + ); + field_ids.push(field.id.clone()); + derived_fields.insert( + field.id.clone(), + CompiledDerivedField { + logical, + derivation_id: derived.id.clone(), + }, + ); + } + derived_relations.insert( + derived.id.clone(), + CompiledDerivedRelation { + id: derived.id.clone(), + sql_path: derived.sql.clone(), + key_field: derived.key.clone(), + execution: derived.execution, + sql_sha256: sha256_hex(&asset.bytes), + sql_bytes: asset.bytes.clone(), + fields: field_ids, + }, + ); + } + let canonical_id = logical_field( + "id", + Some("id"), + FieldTypeSource::Uuid, + Classification::Internal, + ); + let source_relation = CompiledSourceRelation { + entity_id: source.id.clone(), + sql_name: default_sql_name(&source.id), + stored_fields: stored_fields + .iter() + .map(|field| field.logical.id.clone()) + .collect(), + }; + let selector_profiles = source + .selector_profiles + .iter() + .map(|selector| { + ( + selector.id.clone(), + CompiledSelectorProfile { + id: selector.id.clone(), + fields: selector.fields.clone(), + }, + ) + }) + .collect(); + let read_paths = source + .read_paths + .iter() + .map(|path| { + let through = &sources[&path.through]; + let (source_ref, target_ref) = infer_read_path_refs(source, through, &path.to) + .expect("read-path refs were validated"); + ( + path.id.clone(), + CompiledReadPath { + id: path.id.clone(), + through: path.through.clone(), + to: path.to.clone(), + route: path.route.clone(), + source_ref, + target_ref, + }, + ) + }) + .collect(); let mut constraints = BTreeMap::new(); let mut constraint_names = BTreeMap::new(); for constraint in &source.constraints { @@ -2070,6 +3041,13 @@ fn compile_entities( classification: source.classification, physical_table: table, temporal: source.temporal.clone().map(CompiledTemporal::from), + canonical_id, + stored_fields, + derived_fields, + derived_relations, + source_relation, + selector_profiles, + read_paths, fields, constraints, indexes, @@ -2166,12 +3144,62 @@ fn compile_routes_and_access( } } entries.push(CompiledAccessEntry { + route_id: format!("records.{}.{}", entity.id, operation_id(operation)), entity_id: entity.id.clone(), operation, profile_ids, default_profile_id: default.id.clone(), }); } + for read_path in entity.read_paths.values() { + let profiles: Vec<&AccessProfileSource> = entity + .access_profiles + .values() + .filter(|profile| { + profile + .read_paths + .iter() + .any(|grant| grant.path == read_path.id) + }) + .collect(); + if profiles.is_empty() { + continue; + } + let default = if profiles.len() == 1 { + profiles[0] + } else { + profiles + .iter() + .copied() + .find(|profile| profile.default) + .expect("default profile was validated") + }; + let profile_ids: BTreeSet = + profiles.iter().map(|profile| profile.id.clone()).collect(); + let route_id = format!("records.{}.path.{}", entity.id, read_path.id); + routes.push(CompiledRoute { + id: route_id.clone(), + entity_id: entity.id.clone(), + method: HttpMethod::Get, + path: format!( + "/v1/records/{}/{{record_id}}/{}", + entity.route, read_path.route + ), + operation: Operation::List, + query_kind: Some(CompiledQueryKind::List), + revision_kind: None, + maximum_records: None, + access_profiles: profile_ids.iter().cloned().collect(), + default_access_profile: default.id.clone(), + }); + entries.push(CompiledAccessEntry { + route_id, + entity_id: entity.id.clone(), + operation: Operation::List, + profile_ids, + default_profile_id: default.id.clone(), + }); + } } routes.sort_by(|left, right| { (&left.path, left.method, &left.id).cmp(&(&right.path, right.method, &right.id)) @@ -2192,6 +3220,12 @@ fn compile_metadata_inventory( routes: &CompiledRouteInventory, access: &CompiledAccessInventory, ) -> Result { + let access_by_route = access + .entries + .iter() + .filter(|entry| !entry.route_id.is_empty()) + .map(|entry| ((entry.route_id.as_str(), entry.operation), entry)) + .collect::>(); let access_by_operation = access .entries .iter() @@ -2202,8 +3236,9 @@ fn compile_metadata_inventory( let Some(entity) = entities.get(&route.entity_id) else { return Err(inconsistent_metadata_inventory()); }; - let Some(access_entry) = - access_by_operation.get(&(route.entity_id.as_str(), route.operation)) + let Some(access_entry) = access_by_route + .get(&(route.id.as_str(), route.operation)) + .or_else(|| access_by_operation.get(&(route.entity_id.as_str(), route.operation))) else { return Err(inconsistent_metadata_inventory()); }; @@ -2294,39 +3329,81 @@ fn compile_query_inventory( .collect::>(); for entity in entities.values() { for profile in entity.access_profiles.values() { - if !profile.operations.contains(&Operation::List) { - continue; - } - if let Some(operation) = query_operation( - entity, - profile, - &route_ids[&(entity.id.clone(), CompiledQueryKind::List)], - CompiledQueryKind::List, - None, - errors, - ) { - operations.push(operation); - } - if let Some(temporal) = &entity.temporal { - let binding = temporal_binding(temporal); + if profile.operations.contains(&Operation::List) { if let Some(operation) = query_operation( entity, profile, - &route_ids[&(entity.id.clone(), CompiledQueryKind::Current)], - CompiledQueryKind::Current, - Some(binding.clone()), + &route_ids[&(entity.id.clone(), CompiledQueryKind::List)], + CompiledQueryKind::List, + None, + profile.allow_count, + Vec::new(), + None, errors, ) { operations.push(operation); } - if let Some(operation) = query_operation( - entity, - profile, - &route_ids[&(entity.id.clone(), CompiledQueryKind::AsOf)], - CompiledQueryKind::AsOf, - Some(binding), - errors, - ) { + if let Some(temporal) = &entity.temporal { + let binding = temporal_binding(temporal); + if let Some(operation) = query_operation( + entity, + profile, + &route_ids[&(entity.id.clone(), CompiledQueryKind::Current)], + CompiledQueryKind::Current, + Some(binding.clone()), + profile.allow_count, + Vec::new(), + None, + errors, + ) { + operations.push(operation); + } + if let Some(operation) = query_operation( + entity, + profile, + &route_ids[&(entity.id.clone(), CompiledQueryKind::AsOf)], + CompiledQueryKind::AsOf, + Some(binding), + profile.allow_count, + Vec::new(), + None, + errors, + ) { + operations.push(operation); + } + } + } + if profile.operations.contains(&Operation::Lookup) { + for lookup in &profile.lookups { + if let Some(selector) = entity.selector_profiles.get(&lookup.selector) { + let route_id = format!("records.{}.lookup", entity.id); + if let Some(operation) = query_operation( + entity, + profile, + &route_id, + CompiledQueryKind::List, + None, + false, + selector.fields.clone(), + None, + errors, + ) { + operations.push(operation); + } + } + } + } + for grant in &profile.read_paths { + let Some(path) = entity.read_paths.get(&grant.path) else { + continue; + }; + let Some(target) = entities.get(&path.to) else { + continue; + }; + let route_id = format!("records.{}.path.{}", entity.id, path.id); + if let Some(operation) = + read_path_query_operation(entity, target, profile, grant, &route_id, errors) + { operations.push(operation); } } @@ -2336,12 +3413,68 @@ fn compile_query_inventory( CompiledQueryInventory { operations } } +fn read_path_query_operation( + source: &CompiledEntity, + target: &CompiledEntity, + profile: &AccessProfileSource, + grant: &ReadPathGrantSource, + route_id: &str, + errors: &mut Vec, +) -> Option { + let readable_fields = grant.readable_fields.clone(); + let filterable_fields = grant.filterable_fields.clone(); + let sortable_fields = grant.sortable_fields.clone(); + let mut projection_fields = readable_fields.iter().cloned().collect::>(); + projection_fields.sort(); + let filter_fields = filterable_fields + .iter() + .filter_map(|field| { + let (field_type, _) = compiled_field_type(target, field)?; + query_filter_field(field_type, field, errors) + }) + .collect::>(); + let sort_fields = sortable_fields + .iter() + .filter_map(|field| { + let (field_type, _) = compiled_field_type(target, field)?; + query_sort_field(field_type, field, errors) + }) + .collect::>(); + let mut processing_fields = readable_fields; + processing_fields.extend(filterable_fields); + processing_fields.extend(sortable_fields); + if let Some(path) = source.read_paths.get(&grant.path) { + processing_fields.insert(path.source_ref.clone()); + processing_fields.insert(path.target_ref.clone()); + } + Some(CompiledQueryOperation { + id: format!("records.{}.{}.path.{}", source.id, profile.id, grant.path), + route_id: route_id.to_owned(), + entity_id: target.id.clone(), + profile_id: profile.id.clone(), + kind: CompiledQueryKind::List, + max_page_size: 100, + projection_fields, + filter_fields, + sort_fields, + allow_count: grant.allow_count, + selector_fields: Vec::new(), + read_path: Some(grant.path.clone()), + processing_fields: processing_fields.into_iter().collect(), + stable_tie_breaker: "record_id".to_owned(), + temporal: None, + }) +} + fn query_operation( entity: &CompiledEntity, profile: &AccessProfileSource, route_id: &str, kind: CompiledQueryKind, temporal: Option, + allow_count: bool, + selector_fields: Vec, + read_path: Option, errors: &mut Vec, ) -> Option { if let Some(binding) = &temporal { @@ -2384,26 +3517,41 @@ fn query_operation( .filterable_fields .iter() .filter_map(|field| { - let compiled = entity.fields.get(field)?; - query_filter_field(&compiled.field_type, field, errors) + let (field_type, _) = compiled_field_type(entity, field)?; + query_filter_field(field_type, field, errors) }) .collect::>(); let sort_fields = profile .sortable_fields .iter() .filter_map(|field| { - let compiled = entity.fields.get(field)?; - query_sort_field(&compiled.field_type, field, errors) + let (field_type, _) = compiled_field_type(entity, field)?; + query_sort_field(field_type, field, errors) }) .collect::>(); - - Some(CompiledQueryOperation { - id: format!( + let mut processing_fields = profile.readable_fields.clone(); + processing_fields.extend(profile.filterable_fields.iter().cloned()); + processing_fields.extend(profile.sortable_fields.iter().cloned()); + processing_fields.extend(selector_fields.iter().cloned()); + processing_fields.extend( + profile + .row_boundaries + .iter() + .map(|boundary| boundary.field.clone()), + ); + let id = if !selector_fields.is_empty() { + format!("records.{}.{}.lookup", entity.id, profile.id) + } else { + format!( "records.{}.{}.{}", entity.id, profile.id, query_kind_id(kind) - ), + ) + }; + + Some(CompiledQueryOperation { + id, route_id: route_id.to_owned(), entity_id: entity.id.clone(), profile_id: profile.id.clone(), @@ -2412,6 +3560,10 @@ fn query_operation( projection_fields, filter_fields, sort_fields, + allow_count, + selector_fields, + read_path, + processing_fields: processing_fields.into_iter().collect(), stable_tie_breaker: "record_id".to_owned(), temporal, }) @@ -2457,6 +3609,44 @@ fn query_filter_field( }) } +fn compiled_field_type<'a>( + entity: &'a CompiledEntity, + field: &str, +) -> Option<(&'a FieldTypeSource, FieldStorageKind)> { + if field == "id" { + return Some((&entity.canonical_id.field_type, FieldStorageKind::Pseudo)); + } + if let Some(stored) = entity.fields.get(field) { + return Some((&stored.field_type, FieldStorageKind::Stored)); + } + entity + .derived_fields + .get(field) + .map(|derived| (&derived.logical.field_type, FieldStorageKind::Derived)) +} + +fn logical_field( + id: &str, + api_name: Option<&str>, + field_type: FieldTypeSource, + classification: Classification, +) -> CompiledLogicalField { + CompiledLogicalField { + id: id.to_owned(), + api_name: api_name + .map(str::to_owned) + .unwrap_or_else(|| default_api_name(id)), + sql_name: default_sql_name(id), + field_type, + classification, + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + format!("sha256:{}", hex_prefix(&digest, digest.len())) +} + fn query_sort_field( field_type: &FieldTypeSource, field: &str, @@ -2501,6 +3691,7 @@ fn route_shape(entity: &CompiledEntity, operation: Operation) -> (HttpMethod, St match operation { Operation::Create => (HttpMethod::Post, base), Operation::Get => (HttpMethod::Get, format!("{base}/{{record_id}}")), + Operation::Lookup => (HttpMethod::Post, format!("{base}:lookup")), Operation::List => (HttpMethod::Get, base), Operation::Patch => (HttpMethod::Patch, format!("{base}/{{record_id}}")), Operation::Tombstone => (HttpMethod::Delete, format!("{base}/{{record_id}}")), @@ -2509,10 +3700,11 @@ fn route_shape(entity: &CompiledEntity, operation: Operation) -> (HttpMethod, St } } -fn all_operations() -> [Operation; 7] { +fn all_operations() -> [Operation; 8] { [ Operation::Create, Operation::Get, + Operation::Lookup, Operation::List, Operation::Patch, Operation::Tombstone, @@ -2525,6 +3717,7 @@ fn operation_id(operation: Operation) -> &'static str { match operation { Operation::Create => "create", Operation::Get => "get", + Operation::Lookup => "lookup", Operation::List => "list", Operation::Patch => "patch", Operation::Tombstone => "tombstone", diff --git a/crates/registry-server/src/contract.rs b/crates/registry-server/src/contract.rs index 1e3cf13157..cb65184fce 100644 --- a/crates/registry-server/src/contract.rs +++ b/crates/registry-server/src/contract.rs @@ -266,6 +266,13 @@ pub struct RegistryModule { pub extend_entities: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ModuleAssetSource { + pub module: Option, + pub path: String, + pub bytes: Vec, +} + #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -291,6 +298,12 @@ pub struct EntitySource { pub events: Vec, #[serde(default)] pub temporal: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub derived: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selector_profiles: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub read_paths: Vec, } #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -308,6 +321,8 @@ pub struct EntityExtensionSource { pub entity: String, #[serde(default)] pub fields: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub derived: Vec, #[serde(default)] pub constraints: Vec, #[serde(default)] @@ -316,6 +331,10 @@ pub struct EntityExtensionSource { pub access_profiles: Vec, #[serde(default)] pub events: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selector_profiles: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub read_paths: Vec, } #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -339,10 +358,16 @@ fn default_classification() -> Classification { Classification::Internal } +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct FieldSource { pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_name: Option, #[serde(flatten)] pub field_type: FieldTypeSource, #[serde(default)] @@ -392,6 +417,8 @@ enum FieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct BooleanFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: BooleanFieldKindSchema, #[serde(default)] @@ -407,6 +434,8 @@ struct BooleanFieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct StringFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: StringFieldKindSchema, #[serde(default)] @@ -425,6 +454,8 @@ struct StringFieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct TextFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: TextFieldKindSchema, #[serde(default)] @@ -441,6 +472,8 @@ struct TextFieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct Int64FieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: Int64FieldKindSchema, #[serde(default)] @@ -456,6 +489,8 @@ struct Int64FieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct DecimalFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: DecimalFieldKindSchema, #[serde(default)] @@ -477,6 +512,8 @@ struct DecimalFieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct DateFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: DateFieldKindSchema, #[serde(default)] @@ -492,6 +529,8 @@ struct DateFieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct TimestampFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: TimestampFieldKindSchema, #[serde(default)] @@ -507,6 +546,8 @@ struct TimestampFieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct UuidFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: UuidFieldKindSchema, #[serde(default)] @@ -522,6 +563,8 @@ struct UuidFieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct VocabularyCodeFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: VocabularyCodeFieldKindSchema, #[serde(default)] @@ -540,6 +583,8 @@ struct VocabularyCodeFieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct ReferenceFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: ReferenceFieldKindSchema, #[serde(default)] @@ -558,6 +603,8 @@ struct ReferenceFieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct Crs84PointFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: Crs84PointFieldKindSchema, #[serde(default)] @@ -576,6 +623,8 @@ struct Crs84PointFieldSourceSchema { #[serde(deny_unknown_fields, rename_all = "camelCase")] struct StructuredFieldSourceSchema { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] field_type: StructuredFieldKindSchema, #[serde(default)] @@ -689,101 +738,10 @@ impl<'de> Deserialize<'de> for FieldSource { D: Deserializer<'de>, { let raw = RawFieldSource::deserialize(deserializer)?; - let field_type = match raw.kind { - RawFieldKind::Boolean => { - reject_type_options::(&raw, TypeOptionAllowances::NONE)?; - FieldTypeSource::Boolean - } - RawFieldKind::String => { - reject_type_options::(&raw, TypeOptionAllowances::STRING)?; - FieldTypeSource::String { - min_length: raw.min_length.unwrap_or_default(), - max_length: raw - .max_length - .ok_or_else(|| D::Error::custom("string maxLength is required"))?, - } - } - RawFieldKind::Text => { - reject_type_options::(&raw, TypeOptionAllowances::TEXT)?; - FieldTypeSource::Text { - max_length: raw - .max_length - .ok_or_else(|| D::Error::custom("text maxLength is required"))?, - } - } - RawFieldKind::Int64 => { - reject_type_options::(&raw, TypeOptionAllowances::NONE)?; - FieldTypeSource::Int64 - } - RawFieldKind::Decimal => { - reject_type_options::(&raw, TypeOptionAllowances::DECIMAL)?; - FieldTypeSource::Decimal { - precision: raw - .precision - .ok_or_else(|| D::Error::custom("decimal precision is required"))?, - scale: raw - .scale - .ok_or_else(|| D::Error::custom("decimal scale is required"))?, - minimum: raw.minimum.clone(), - maximum: raw.maximum.clone(), - } - } - RawFieldKind::Date => { - reject_type_options::(&raw, TypeOptionAllowances::NONE)?; - FieldTypeSource::Date - } - RawFieldKind::Timestamp => { - reject_type_options::(&raw, TypeOptionAllowances::NONE)?; - FieldTypeSource::Timestamp - } - RawFieldKind::Uuid => { - reject_type_options::(&raw, TypeOptionAllowances::NONE)?; - FieldTypeSource::Uuid - } - RawFieldKind::VocabularyCode => { - reject_type_options::(&raw, TypeOptionAllowances::VOCABULARY)?; - FieldTypeSource::VocabularyCode { - vocabulary: raw - .vocabulary - .clone() - .ok_or_else(|| D::Error::custom("vocabulary is required"))?, - values: raw.values.clone(), - } - } - RawFieldKind::Reference => { - reject_type_options::(&raw, TypeOptionAllowances::REFERENCE)?; - FieldTypeSource::Reference { - target: raw - .target - .clone() - .ok_or_else(|| D::Error::custom("reference target is required"))?, - on_delete: raw.on_delete.clone().unwrap_or_default(), - } - } - RawFieldKind::Crs84Point => { - reject_type_options::(&raw, TypeOptionAllowances::CRS84_POINT)?; - FieldTypeSource::Crs84Point { - precision: raw - .precision - .ok_or_else(|| D::Error::custom("point precision is required"))?, - bbox: raw.bbox.clone(), - } - } - RawFieldKind::Structured => { - reject_type_options::(&raw, TypeOptionAllowances::STRUCTURED)?; - FieldTypeSource::Structured { - max_bytes: raw - .max_bytes - .ok_or_else(|| D::Error::custom("structured maxBytes is required"))?, - schema: raw - .schema - .clone() - .ok_or_else(|| D::Error::custom("structured schema is required"))?, - } - } - }; + let field_type = parse_field_type::(&raw)?; Ok(Self { id: raw.id, + api_name: raw.api_name, field_type, required: raw.required, classification: raw.classification, @@ -792,10 +750,142 @@ impl<'de> Deserialize<'de> for FieldSource { } } +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DerivedFieldSource { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_name: Option, + #[serde(flatten)] + pub field_type: FieldTypeSource, + pub classification: Classification, +} + +impl<'de> Deserialize<'de> for DerivedFieldSource { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RawFieldSource::deserialize(deserializer)?; + if raw.required || raw.valid_time_role.is_some() { + return Err(D::Error::custom( + "derived fields cannot declare required or validTimeRole", + )); + } + let field_type = parse_field_type::(&raw)?; + Ok(Self { + id: raw.id, + api_name: raw.api_name, + field_type, + classification: raw.classification, + }) + } +} + +fn parse_field_type(raw: &RawFieldSource) -> Result { + let field_type = match raw.kind { + RawFieldKind::Boolean => { + reject_type_options::(raw, TypeOptionAllowances::NONE)?; + FieldTypeSource::Boolean + } + RawFieldKind::String => { + reject_type_options::(raw, TypeOptionAllowances::STRING)?; + FieldTypeSource::String { + min_length: raw.min_length.unwrap_or_default(), + max_length: raw + .max_length + .ok_or_else(|| E::custom("string maxLength is required"))?, + } + } + RawFieldKind::Text => { + reject_type_options::(raw, TypeOptionAllowances::TEXT)?; + FieldTypeSource::Text { + max_length: raw + .max_length + .ok_or_else(|| E::custom("text maxLength is required"))?, + } + } + RawFieldKind::Int64 => { + reject_type_options::(raw, TypeOptionAllowances::NONE)?; + FieldTypeSource::Int64 + } + RawFieldKind::Decimal => { + reject_type_options::(raw, TypeOptionAllowances::DECIMAL)?; + FieldTypeSource::Decimal { + precision: raw + .precision + .ok_or_else(|| E::custom("decimal precision is required"))?, + scale: raw + .scale + .ok_or_else(|| E::custom("decimal scale is required"))?, + minimum: raw.minimum.clone(), + maximum: raw.maximum.clone(), + } + } + RawFieldKind::Date => { + reject_type_options::(raw, TypeOptionAllowances::NONE)?; + FieldTypeSource::Date + } + RawFieldKind::Timestamp => { + reject_type_options::(raw, TypeOptionAllowances::NONE)?; + FieldTypeSource::Timestamp + } + RawFieldKind::Uuid => { + reject_type_options::(raw, TypeOptionAllowances::NONE)?; + FieldTypeSource::Uuid + } + RawFieldKind::VocabularyCode => { + reject_type_options::(raw, TypeOptionAllowances::VOCABULARY)?; + FieldTypeSource::VocabularyCode { + vocabulary: raw + .vocabulary + .clone() + .ok_or_else(|| E::custom("vocabulary is required"))?, + values: raw.values.clone(), + } + } + RawFieldKind::Reference => { + reject_type_options::(raw, TypeOptionAllowances::REFERENCE)?; + FieldTypeSource::Reference { + target: raw + .target + .clone() + .ok_or_else(|| E::custom("reference target is required"))?, + on_delete: raw.on_delete.clone().unwrap_or_default(), + } + } + RawFieldKind::Crs84Point => { + reject_type_options::(raw, TypeOptionAllowances::CRS84_POINT)?; + FieldTypeSource::Crs84Point { + precision: raw + .precision + .ok_or_else(|| E::custom("point precision is required"))?, + bbox: raw.bbox.clone(), + } + } + RawFieldKind::Structured => { + reject_type_options::(raw, TypeOptionAllowances::STRUCTURED)?; + FieldTypeSource::Structured { + max_bytes: raw + .max_bytes + .ok_or_else(|| E::custom("structured maxBytes is required"))?, + schema: raw + .schema + .clone() + .ok_or_else(|| E::custom("structured schema is required"))?, + } + } + }; + Ok(field_type) +} + #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct RawFieldSource { id: String, + #[serde(default)] + api_name: Option, #[serde(rename = "type")] kind: RawFieldKind, #[serde(default)] @@ -831,6 +921,45 @@ struct RawFieldSource { on_delete: Option, } +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DerivedSource { + pub id: String, + pub sql: String, + pub key: String, + #[serde(default)] + pub execution: DerivedExecutionSource, + #[serde(default)] + pub fields: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DerivedExecutionSource { + #[default] + Live, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SelectorProfileSource { + pub id: String, + pub fields: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReadPathSource { + pub id: String, + pub through: String, + pub to: String, + pub route: String, +} + #[derive(Deserialize)] #[serde(rename_all = "snake_case")] enum RawFieldKind { @@ -1387,6 +1516,12 @@ pub struct AccessProfileSource { pub sortable_fields: BTreeSet, #[serde(default)] pub row_boundaries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lookups: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub read_paths: Vec, + #[serde(default, skip_serializing_if = "is_false")] + pub allow_count: bool, #[serde(default)] pub revision_access: bool, #[serde(default)] @@ -1399,6 +1534,7 @@ pub struct AccessProfileSource { pub enum Operation { Create, Get, + Lookup, List, Patch, Tombstone, @@ -1507,12 +1643,51 @@ pub struct AccessGrantSource { pub sortable_fields: BTreeSet, #[serde(default)] pub row_boundaries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lookups: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub read_paths: Vec, + #[serde(default, skip_serializing_if = "is_false")] + pub allow_count: bool, #[serde(default)] pub revision_access: bool, #[serde(default)] pub allow_data_export: bool, } +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct LookupGrantSource { + pub selector: String, + pub value_origin: LookupValueOrigin, + #[serde(default)] + pub claim_mapping: BTreeMap, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LookupValueOrigin { + Request, + VerifiedClaim, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ReadPathGrantSource { + pub path: String, + #[serde(default)] + pub readable_fields: BTreeSet, + #[serde(default)] + pub filterable_fields: BTreeSet, + #[serde(default)] + pub sortable_fields: BTreeSet, + #[serde(default)] + pub allow_count: bool, +} + #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] diff --git a/crates/registry-server/src/cursor.rs b/crates/registry-server/src/cursor.rs index 2aae4fc95f..ecb0f2b1b8 100644 --- a/crates/registry-server/src/cursor.rs +++ b/crates/registry-server/src/cursor.rs @@ -15,9 +15,10 @@ use serde_json::Value; use sha2::Sha256; use zeroize::Zeroizing; -use crate::model::CompiledQueryKind; +use crate::contract::FieldTypeSource; +use crate::model::{CompiledQueryKind, CompiledQuerySortDirection}; -const WIRE_VERSION: u8 = 1; +const WIRE_VERSION: u8 = 3; const ROOT_SECRET_MIN_BYTES: usize = 32; const KEY_BYTES: usize = 32; const NONCE_BYTES: usize = 24; @@ -25,9 +26,9 @@ const TAG_BYTES: usize = 16; const MAX_PAYLOAD_BYTES: usize = 8 * 1024; const MAX_TOKEN_BYTES: usize = (1 + NONCE_BYTES + MAX_PAYLOAD_BYTES + TAG_BYTES) * 2; const MAX_AGE_SECONDS: u64 = 86_400; -const CURSOR_AAD: &[u8] = b"registry-server-cursor-v1"; -const AEAD_LABEL: &[u8] = b"registry-server-cursor-aead-key-v1"; -const BINDING_LABEL: &[u8] = b"registry-server-cursor-binding-key-v1"; +const CURSOR_AAD: &[u8] = b"registry-server-cursor-v3"; +const AEAD_LABEL: &[u8] = b"registry-server-cursor-aead-key-v3"; +const BINDING_LABEL: &[u8] = b"registry-server-cursor-binding-key-v3"; type HmacSha256 = Hmac; @@ -248,7 +249,10 @@ pub struct CursorBinding { pub(crate) projection_reference: String, pub(crate) query_reference: String, pub(crate) sort_reference: String, + pub(crate) scope_reference: String, pub(crate) page_size: u16, + #[serde(default)] + pub(crate) include_count: bool, pub(crate) temporal_instant: Option, pub(crate) selected_fields: Vec, } @@ -276,7 +280,9 @@ impl fmt::Debug for CursorBinding { .field("projection_reference", &"") .field("query_reference", &"") .field("sort_reference", &"") + .field("scope_reference", &"") .field("page_size", &self.page_size) + .field("include_count", &self.include_count) .field( "temporal_instant", &self.temporal_instant.as_ref().map(|_| ""), @@ -289,8 +295,13 @@ impl fmt::Debug for CursorBinding { #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CursorQuery { - pub(crate) filters: Vec, - pub(crate) sort: Option, + pub(crate) projection: Vec, + pub(crate) filter: Option, + pub(crate) order: Option, + pub(crate) include_count: bool, + pub(crate) page_size: u16, + pub(crate) temporal_instant: Option, + pub(crate) scope: CursorQueryScope, } impl fmt::Debug for CursorQuery { @@ -301,23 +312,132 @@ impl fmt::Debug for CursorQuery { #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct CursorFilter { - pub(crate) field: String, - pub(crate) operator: String, +pub struct CursorProjectionField { + pub(crate) field_id: String, + pub(crate) field_type: FieldTypeSource, +} + +impl fmt::Debug for CursorProjectionField { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CursorProjectionField") + .field("field_id", &self.field_id) + .field("field_type", &self.field_type) + .finish() + } +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind", deny_unknown_fields)] +pub enum CursorFilterExpr { + Binary { + op: CursorLogicalOp, + left: Box, + right: Box, + }, + Not { + expr: Box, + }, + Group { + expr: Box, + }, + Predicate { + predicate: CursorFilterPredicate, + }, +} + +impl fmt::Debug for CursorFilterExpr { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CursorFilterExpr()") + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CursorLogicalOp { + And, + Or, +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CursorFilterPredicate { + pub(crate) field_id: String, + pub(crate) field_type: FieldTypeSource, + pub(crate) operator: CursorFilterOperator, pub(crate) values: Vec, } -impl fmt::Debug for CursorFilter { +impl fmt::Debug for CursorFilterPredicate { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter - .debug_struct("CursorFilter") - .field("field", &self.field) + .debug_struct("CursorFilterPredicate") + .field("field_id", &self.field_id) + .field("field_type", &self.field_type) .field("operator", &self.operator) .field("values", &"") .finish() } } +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CursorFilterOperator { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + In, + IsNull, + IsNotNull, + StartsWith, + Contains, +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CursorOrderClause { + pub(crate) field_id: String, + pub(crate) field_type: FieldTypeSource, + pub(crate) direction: CompiledQuerySortDirection, +} + +impl fmt::Debug for CursorOrderClause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CursorOrderClause") + .field("field_id", &self.field_id) + .field("field_type", &self.field_type) + .field("direction", &self.direction) + .finish() + } +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind", deny_unknown_fields)] +pub enum CursorQueryScope { + Collection {}, + Relationship { path_id: String, root_id: String }, +} + +impl fmt::Debug for CursorQueryScope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Collection {} => formatter.write_str("Collection"), + Self::Relationship { + path_id, + root_id: _, + } => formatter + .debug_struct("Relationship") + .field("path_id", path_id) + .field("root_id", &"") + .finish(), + } + } +} + #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CursorContinuation { @@ -382,7 +502,9 @@ mod tests { projection_reference: "hmac-sha256:projection".to_owned(), query_reference: "hmac-sha256:query".to_owned(), sort_reference: "hmac-sha256:sort".to_owned(), + scope_reference: "hmac-sha256:scope".to_owned(), page_size: 50, + include_count: false, temporal_instant: Some("2026-01-01T00:00:00Z".to_owned()), selected_fields: vec!["label".to_owned()], } @@ -395,12 +517,36 @@ mod tests { expires_at_unix_seconds: 1_060, binding: binding(), query: CursorQuery { - filters: vec![CursorFilter { - field: "label".to_owned(), - operator: "prefix".to_owned(), - values: vec!["al".to_owned()], + projection: vec![CursorProjectionField { + field_id: "label".to_owned(), + field_type: FieldTypeSource::String { + min_length: 0, + max_length: 80, + }, }], - sort: Some("label".to_owned()), + filter: Some(CursorFilterExpr::Predicate { + predicate: CursorFilterPredicate { + field_id: "label".to_owned(), + field_type: FieldTypeSource::String { + min_length: 0, + max_length: 80, + }, + operator: CursorFilterOperator::StartsWith, + values: vec!["al".to_owned()], + }, + }), + order: Some(CursorOrderClause { + field_id: "label".to_owned(), + field_type: FieldTypeSource::String { + min_length: 0, + max_length: 80, + }, + direction: CompiledQuerySortDirection::Asc, + }), + include_count: false, + page_size: 50, + temporal_instant: Some("2026-01-01T00:00:00Z".to_owned()), + scope: CursorQueryScope::Collection {}, }, continuation: CursorContinuation { last_record_id: "00000000-0000-4000-8000-000000000001".to_owned(), @@ -528,6 +674,11 @@ mod tests { value.sort_reference = "hmac-sha256:other-sort".to_owned(); value }, + { + let mut value = expected.clone(); + value.scope_reference = "hmac-sha256:other-scope".to_owned(); + value + }, { let mut value = expected.clone(); value.page_size = 51; diff --git a/crates/registry-server/src/data.rs b/crates/registry-server/src/data.rs index c482aa5dd9..3407fb4bd1 100644 --- a/crates/registry-server/src/data.rs +++ b/crates/registry-server/src/data.rs @@ -1574,7 +1574,7 @@ where &plan.route_path, &[ ("accessProfile", plan.profile_id.as_str()), - ("cursor", cursor), + ("$skiptoken", cursor), ], ) } else { @@ -1582,8 +1582,8 @@ where &plan.route_path, &[ ("accessProfile", plan.profile_id.as_str()), - ("fields", fields.as_str()), - ("pageSize", page_size.as_str()), + ("$select", fields.as_str()), + ("$top", page_size.as_str()), ], ) }; @@ -1693,18 +1693,27 @@ fn validate_export_response( require_success_json(response)?; let value = parse_canonical_response(&response.body)?; let object = value.as_object().ok_or(DataError::InvalidResponse)?; - require_exact_keys(object, &["items", "pageInfo"]).map_err(|_| DataError::InvalidResponse)?; + if !(object.len() == 2 || object.len() == 3) + || !object.contains_key("items") + || !object.contains_key("nextCursor") + || (object.len() == 3 && !object.contains_key("count")) + { + return Err(DataError::InvalidResponse); + } let items = object["items"] .as_array() .ok_or(DataError::InvalidResponse)?; if items.len() > usize::from(plan.maximum_page_size) { return Err(DataError::InvalidResponse); } - let page_info = object["pageInfo"] - .as_object() - .ok_or(DataError::InvalidResponse)?; - require_exact_keys(page_info, &["nextCursor"]).map_err(|_| DataError::InvalidResponse)?; - let next_cursor = match &page_info["nextCursor"] { + if object.get("count").is_some_and(|count| { + !count + .as_u64() + .is_some_and(|count| count >= items.len() as u64) + }) { + return Err(DataError::InvalidResponse); + } + let next_cursor = match &object["nextCursor"] { Value::Null => None, Value::String(value) if !invalid_cursor(Some(value)) => Some(value.clone()), _ => return Err(DataError::InvalidResponse), diff --git a/crates/registry-server/src/derived_sql.rs b/crates/registry-server/src/derived_sql.rs new file mode 100644 index 0000000000..a314c7481d --- /dev/null +++ b/crates/registry-server/src/derived_sql.rs @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; + +use pg_query::protobuf::{ + node::Node as PgNode, AExpr, Node as PgNodeWrapper, SelectStmt, SetOperation, +}; +use pg_query::NodeRef; + +use crate::contract::DerivedSource; +use crate::diagnostics::Diagnostic; +use crate::logical_names::default_sql_name; + +pub(crate) const MAX_DERIVED_SQL_BYTES: usize = 256 * 1024; + +pub(crate) fn validate_derived_sql( + derived: &DerivedSource, + sql: &[u8], + known_relations: &BTreeSet<&str>, + path: &str, + errors: &mut Vec, +) { + let Some(text) = std::str::from_utf8(sql).ok() else { + errors.push(sql_error(path)); + return; + }; + if text.is_empty() || text.len() > MAX_DERIVED_SQL_BYTES || text.as_bytes().contains(&0) { + errors.push(sql_error(path)); + return; + } + let Ok(parsed) = pg_query::parse(text) else { + errors.push(sql_error(path)); + return; + }; + if parsed.protobuf.stmts.len() != 1 || !parsed.warnings.is_empty() { + errors.push(sql_error(path)); + return; + } + let Some(PgNode::SelectStmt(select)) = root_node(&parsed) else { + errors.push(sql_error(path)); + return; + }; + if !valid_select_shape(select) || !declared_output_aliases(select, derived) { + errors.push(sql_error(path)); + return; + } + if !valid_ast(&parsed, known_relations) { + errors.push(sql_error(path)); + } +} + +fn root_node(parsed: &pg_query::ParseResult) -> Option<&PgNode> { + parsed + .protobuf + .stmts + .first() + .and_then(|statement| statement.stmt.as_deref()) + .and_then(|statement| statement.node.as_ref()) +} + +fn valid_select_shape(select: &SelectStmt) -> bool { + select.into_clause.is_none() + && select.with_clause.as_ref().is_none_or(|with| { + !with.recursive + && with.ctes.iter().all(|cte| { + cte.node.as_ref().is_some_and(|node| { + matches!( + node, + PgNode::CommonTableExpr(cte) + if cte.ctequery.as_deref().and_then(|query| query.node.as_ref()).is_some_and(|node| matches!(node, PgNode::SelectStmt(select) if valid_select_shape(select))) + ) + }) + }) + }) + && select.locking_clause.is_empty() + && SetOperation::try_from(select.op).ok() == Some(SetOperation::SetopNone) +} + +fn declared_output_aliases(select: &SelectStmt, derived: &DerivedSource) -> bool { + let expected = std::iter::once(derived.key.as_str()) + .map(str::to_owned) + .chain( + derived + .fields + .iter() + .map(|field| default_sql_name(&field.id)), + ) + .collect::>(); + if select.target_list.len() != expected.len() { + return false; + } + select + .target_list + .iter() + .zip(expected) + .all(|(node, expected)| { + let Some(PgNode::ResTarget(target)) = node.node.as_ref() else { + return false; + }; + target.name == expected && target.val.as_deref().is_some_and(no_wildcard) + }) +} + +fn no_wildcard(node: &PgNodeWrapper) -> bool { + !matches!(node.node.as_ref(), Some(PgNode::AStar(_))) +} + +fn valid_ast(parsed: &pg_query::ParseResult, known_relations: &BTreeSet<&str>) -> bool { + let mut statement_nodes = 0_usize; + for (node, _, _, _) in parsed.protobuf.nodes() { + match node { + NodeRef::RangeVar(range) => { + if !range.catalogname.is_empty() + || range.schemaname != "registry_source" + || !known_relations.contains(range.relname.as_str()) + || (!range.relpersistence.is_empty() && range.relpersistence != "p") + { + return false; + } + } + NodeRef::SelectStmt(_) => statement_nodes += 1, + NodeRef::FuncCall(function) if !safe_function(function) => return false, + NodeRef::AExpr(expression) if unsafe_schema_operator(expression) => return false, + node if forbidden_node(node) => return false, + _ => {} + } + } + statement_nodes >= 1 +} + +fn unsafe_schema_operator(expression: &AExpr) -> bool { + node_strings(&expression.name).map_or(true, |names| { + names.len() != 1 + || !matches!( + names[0].as_str(), + "=" | "<>" | "<" | ">" | "<=" | ">=" | "+" | "-" | "*" | "/" + ) + }) +} + +fn safe_function(function: &pg_query::protobuf::FuncCall) -> bool { + let Some(name) = node_strings(&function.funcname) else { + return false; + }; + if function.over.is_some() || function.agg_within_group || function.func_variadic { + return false; + } + matches!( + name.as_slice(), + [function] if matches!(function.as_str(), "count" | "bool_and" | "every") + ) || matches!( + name.as_slice(), + [schema, function] + if schema == "pg_catalog" + && matches!(function.as_str(), "count" | "bool_and" | "every") + ) || matches!( + name.as_slice(), + [schema, function] if schema == "registry_context" && function == "evaluation_date" + ) +} + +fn node_strings(nodes: &[pg_query::protobuf::Node]) -> Option> { + nodes + .iter() + .map(|node| match node.node.as_ref() { + Some(PgNode::String(value)) => Some(value.sval.clone()), + _ => None, + }) + .collect() +} + +#[allow(clippy::match_same_arms)] +fn forbidden_node(node: NodeRef<'_>) -> bool { + matches!( + node, + NodeRef::InsertStmt(_) + | NodeRef::UpdateStmt(_) + | NodeRef::DeleteStmt(_) + | NodeRef::MergeStmt(_) + | NodeRef::CreateTableAsStmt(_) + | NodeRef::IntoClause(_) + | NodeRef::CopyStmt(_) + | NodeRef::LockStmt(_) + | NodeRef::CallStmt(_) + | NodeRef::DoStmt(_) + | NodeRef::CreateStmt(_) + | NodeRef::ViewStmt(_) + | NodeRef::CreateFunctionStmt(_) + | NodeRef::AlterFunctionStmt(_) + | NodeRef::CreateSchemaStmt(_) + | NodeRef::AlterObjectSchemaStmt(_) + | NodeRef::CreateExtensionStmt(_) + | NodeRef::AlterExtensionStmt(_) + | NodeRef::DropStmt(_) + | NodeRef::GrantStmt(_) + | NodeRef::GrantRoleStmt(_) + | NodeRef::TransactionStmt(_) + | NodeRef::VariableSetStmt(_) + | NodeRef::VariableShowStmt(_) + | NodeRef::PrepareStmt(_) + | NodeRef::ExecuteStmt(_) + | NodeRef::DeallocateStmt(_) + | NodeRef::DeclareCursorStmt(_) + | NodeRef::RefreshMatViewStmt(_) + | NodeRef::ReindexStmt(_) + | NodeRef::ClusterStmt(_) + | NodeRef::LoadStmt(_) + | NodeRef::TableFunc(_) + | NodeRef::RangeFunction(_) + | NodeRef::SqlvalueFunction(_) + ) +} + +fn sql_error(path: &str) -> Diagnostic { + Diagnostic::error( + "derived.sql.invalid", + path, + "derived SQL must be one bounded read-only SELECT with declared output aliases over registry_source relations", + ) +} diff --git a/crates/registry-server/src/fixtures.rs b/crates/registry-server/src/fixtures.rs index 4f451089ce..6a99875fa8 100644 --- a/crates/registry-server/src/fixtures.rs +++ b/crates/registry-server/src/fixtures.rs @@ -27,9 +27,10 @@ use zeroize::Zeroizing; use crate::api::{HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture}; use crate::api::{VerifiedClaimValue, VerifiedRequestClaims}; use crate::auth::RegistryAuthenticator; -use crate::compiler::{compile_project, module_digest, CompileProfile}; -use crate::contract::{parse_module_yaml, parse_project_yaml}; +use crate::compiler::{compile_project_with_assets, module_digest_with_assets, CompileProfile}; +use crate::contract::{parse_module_yaml, parse_project_yaml, ModuleAssetSource}; use crate::contract::{AccessProfileSource, Operation}; +use crate::derived_sql::MAX_DERIVED_SQL_BYTES; use crate::model::CompiledRoute; use crate::model::{CompiledRegistry, HttpMethod}; #[cfg(any(test, feature = "postgres-test"))] @@ -150,6 +151,27 @@ enum ActionSource { record_ref: String, }, List, + Query { + #[serde(default)] + select: BTreeSet, + #[serde(default)] + top: Option, + #[serde(default)] + count: bool, + }, + Lookup { + selector: String, + value: Value, + }, + ReadPath { + path: String, + #[serde(default)] + select: BTreeSet, + #[serde(default)] + top: Option, + #[serde(default)] + count: bool, + }, Patch { record_ref: String, etag_ref: String, @@ -165,7 +187,8 @@ impl ActionSource { match self { Self::Create { .. } => Operation::Create, Self::Get { .. } => Operation::Get, - Self::List => Operation::List, + Self::List | Self::Query { .. } | Self::ReadPath { .. } => Operation::List, + Self::Lookup { .. } => Operation::Lookup, Self::Patch { .. } => Operation::Patch, Self::Batch { .. } => Operation::Batch, } @@ -402,7 +425,12 @@ fn validate_action_references( etag_ref, .. } => &[record_ref, etag_ref], - ActionSource::Create { .. } | ActionSource::List | ActionSource::Batch { .. } => &[], + ActionSource::Create { .. } + | ActionSource::List + | ActionSource::Query { .. } + | ActionSource::Lookup { .. } + | ActionSource::ReadPath { .. } + | ActionSource::Batch { .. } => &[], }; if references .iter() @@ -486,6 +514,29 @@ fn validate_action_fields( match action { ActionSource::Create { data } => validate_data(data), ActionSource::Get { .. } | ActionSource::List => Ok(()), + ActionSource::Query { select, top, count } => { + validate_structured_query(entity, profile, None, select, *top, *count) + } + ActionSource::Lookup { selector, value } => { + if selector.is_empty() + || selector.len() > MAX_IDENTIFIER_BYTES + || !entity.selector_profiles.contains_key(selector) + || !profile + .lookups + .iter() + .any(|lookup| lookup.selector == *selector) + || canonical_size(value)? > MAX_BINDING_BYTES + { + return Err(FixtureError::LogicalReferenceRefused); + } + Ok(()) + } + ActionSource::ReadPath { + path, + select, + top, + count, + } => validate_structured_query(entity, profile, Some(path), select, *top, *count), ActionSource::Patch { changes, .. } => { if changes.is_empty() || changes.len() > entity.fields.len() { return Err(FixtureError::JourneyBoundsRefused); @@ -543,6 +594,38 @@ fn validate_action_fields( } } +fn validate_structured_query( + entity: &crate::model::CompiledEntity, + profile: &AccessProfileSource, + read_path: Option<&str>, + select: &BTreeSet, + top: Option, + count: bool, +) -> Result<(), FixtureError> { + if top.is_some_and(|top| top == 0 || top > 100) || (count && !profile.allow_count) { + return Err(FixtureError::JourneyBoundsRefused); + } + if select + .iter() + .any(|field| !profile.readable_fields.contains(field) || !entity.fields.contains_key(field)) + { + return Err(FixtureError::LogicalReferenceRefused); + } + if let Some(path) = read_path { + if path.is_empty() + || path.len() > MAX_IDENTIFIER_BYTES + || !entity.read_paths.contains_key(path) + || !profile + .read_paths + .iter() + .any(|grant| grant.path == path && select.is_subset(&grant.readable_fields)) + { + return Err(FixtureError::LogicalReferenceRefused); + } + } + Ok(()) +} + fn validate_expectation( expectation: &ExpectationSource, operation: Operation, @@ -561,7 +644,11 @@ fn validate_expectation( ExpectedOutcome::Success => { let expected = match operation { Operation::Create => 201, - Operation::Get | Operation::List | Operation::Patch | Operation::Batch => 200, + Operation::Get + | Operation::List + | Operation::Lookup + | Operation::Patch + | Operation::Batch => 200, Operation::Tombstone | Operation::Revisions => { return Err(FixtureError::LogicalReferenceRefused) } @@ -1307,6 +1394,7 @@ fn fixture_request( let mut body = Body::empty(); let mut content_type = None; let mut if_match = None; + let mut extra_query_options = Vec::new(); match &step.action { ActionSource::Create { data } => { method = Method::POST; @@ -1320,6 +1408,19 @@ fn fixture_request( path = path.replace("{record_id}", &observed.record_id); } ActionSource::List => {} + ActionSource::Query { select, top, count } => { + extra_query_options = fixture_query_options(step, None, select, *top, *count)?; + } + ActionSource::Lookup { .. } => return Err(FixtureError::ExecutionRefused), + ActionSource::ReadPath { + path: read_path, + select, + top, + count, + } => { + extra_query_options = + fixture_query_options(step, Some(read_path), select, *top, *count)?; + } ActionSource::Patch { record_ref, etag_ref, @@ -1355,6 +1456,12 @@ fn fixture_request( } path.push_str("?accessProfile="); path.push_str(&step.access_profile); + for (name, value) in extra_query_options { + path.push('&'); + path.push_str(name); + path.push('='); + path.push_str(&value); + } let mut request = Request::builder() .method(method) .uri(path) @@ -1400,6 +1507,35 @@ fn fixture_request( Ok(request) } +fn fixture_query_options( + step: &ValidatedStep, + read_path: Option<&str>, + select: &BTreeSet, + top: Option, + count: bool, +) -> Result, FixtureError> { + let mut parameters = Vec::new(); + if !select.is_empty() { + parameters.push(( + "$select", + select.iter().cloned().collect::>().join(","), + )); + } + if let Some(top) = top { + parameters.push(("$top", top.to_string())); + } + if count { + parameters.push(("$count", "true".to_owned())); + } + if let Some(read_path) = read_path { + parameters.push(("readPath", read_path.to_owned())); + } + if step.access_profile.is_empty() { + return Err(FixtureError::RequestConstructionRefused); + } + Ok(parameters) +} + fn json_body(value: &Value) -> Result { let bytes = canonicalize_json(value).map_err(|_| FixtureError::RequestConstructionRefused)?; if bytes.len() > MAX_BODY_BYTES { @@ -1515,19 +1651,13 @@ fn assert_response( } } ExpectedOutcome::Success => match step.action { - ActionSource::List => { - let object = exact_object(document, &["items", "pageInfo"])?; + ActionSource::List | ActionSource::Query { .. } | ActionSource::ReadPath { .. } => { + let object = exact_object(document, &["items", "nextCursor"])?; let items = object .get("items") .and_then(Value::as_array) .ok_or(FixtureError::ResponseShapeRefused)?; - let page_info = exact_object( - object - .get("pageInfo") - .ok_or(FixtureError::ResponseShapeRefused)?, - &["nextCursor"], - )?; - if !page_info.get("nextCursor").is_some_and(|cursor| { + if !object.get("nextCursor").is_some_and(|cursor| { cursor.is_null() || cursor.as_str().is_some_and(|value| { !value.is_empty() && value.len() <= MAX_BINDING_BYTES @@ -1567,7 +1697,10 @@ fn assert_response( assert_record_members(object, &step.profile.readable_fields, &Map::new())?; } } - ActionSource::Create { .. } | ActionSource::Get { .. } | ActionSource::Patch { .. } => { + ActionSource::Create { .. } + | ActionSource::Get { .. } + | ActionSource::Lookup { .. } + | ActionSource::Patch { .. } => { assert_record_shape(document, &step.profile.readable_fields, &step.expect.fields)?; } }, @@ -1680,6 +1813,14 @@ pub struct FixtureModuleSource<'a> { pub id: &'a str, pub path: &'a str, pub bytes: &'a [u8], + pub assets: &'a [FixtureModuleAssetSource<'a>], +} + +/// One exact module asset source in deterministic module-relative order. +#[cfg(any(test, feature = "postgres-test"))] +pub struct FixtureModuleAssetSource<'a> { + pub path: &'a str, + pub bytes: &'a [u8], } /// Source-only candidate input. Deployment identity, compiler revision, @@ -1965,7 +2106,9 @@ fn validate_schema_test_candidate( { return Err(FixtureError::CandidateBindingRefused); } + validate_manifest_source_asset_inventory(manifest)?; let mut modules = Vec::with_capacity(sources.modules.len()); + let mut module_assets = Vec::new(); for ((captured, locked), source) in manifest .sources .modules @@ -1989,7 +2132,8 @@ fn validate_schema_test_candidate( } let module = parse_module_yaml(source.bytes).map_err(|_| FixtureError::CandidateBindingRefused)?; - let digest = module_digest(&module); + let assets = validate_source_module_assets(manifest, captured, source)?; + let digest = module_digest_with_assets(&module, &assets); if module.id != source.id || module.version != locked.version || locked.digest.as_deref() != Some(digest.as_str()) @@ -1997,10 +2141,16 @@ fn validate_schema_test_candidate( return Err(FixtureError::CandidateBindingRefused); } modules.push(module); + module_assets.extend(assets); } - let compiled = compile_project(&project, &modules, CompileProfile::Production) - .map_err(|_| FixtureError::CandidateBindingRefused)?; + let compiled = compile_project_with_assets( + &project, + &modules, + &module_assets, + CompileProfile::Production, + ) + .map_err(|_| FixtureError::CandidateBindingRefused)?; if compiled != *package.registry() { return Err(FixtureError::CandidateBindingRefused); } @@ -2106,7 +2256,9 @@ fn derive_prepared_schema_test_candidate( if project.modules.len() != manifest.sources.modules.len() { return Err(FixtureError::CandidateBindingRefused); } + validate_manifest_source_asset_inventory(manifest)?; let mut modules = Vec::with_capacity(manifest.sources.modules.len()); + let mut module_assets = Vec::new(); for (locked, captured) in project.modules.iter().zip(&manifest.sources.modules) { if locked.id != captured.id { return Err(FixtureError::CandidateBindingRefused); @@ -2117,18 +2269,26 @@ fn derive_prepared_schema_test_candidate( if module_bytes.is_empty() || module_bytes.len() > MAX_SOURCE_BYTES { return Err(FixtureError::CandidateBindingRefused); } + let assets = prepared_module_assets(captured, files)?; let module = parse_module_yaml(module_bytes).map_err(|_| FixtureError::CandidateBindingRefused)?; if module.id != captured.id || module.version != locked.version - || locked.digest.as_deref() != Some(module_digest(&module).as_str()) + || locked.digest.as_deref() + != Some(module_digest_with_assets(&module, &assets).as_str()) { return Err(FixtureError::CandidateBindingRefused); } modules.push(module); + module_assets.extend(assets); } - let compiled = compile_project(&project, &modules, CompileProfile::Production) - .map_err(|_| FixtureError::CandidateBindingRefused)?; + let compiled = compile_project_with_assets( + &project, + &modules, + &module_assets, + CompileProfile::Production, + ) + .map_err(|_| FixtureError::CandidateBindingRefused)?; let project_identity = compiled .package() .ok_or(FixtureError::CandidateBindingRefused)?; @@ -2201,6 +2361,119 @@ fn prepared_files_match_manifest(package: &PreparedPackage) -> bool { }) } +fn validate_manifest_source_asset_inventory( + manifest: &crate::package::PackageManifest, +) -> Result<(), FixtureError> { + let mut declared_paths = BTreeSet::new(); + for module in &manifest.sources.modules { + let mut prior_asset = None; + for asset in &module.assets { + let package_path = source_module_asset_package_path(&module.id, asset)?; + if prior_asset.is_some_and(|prior: &str| prior >= asset.as_str()) + || !declared_paths.insert(package_path) + { + return Err(FixtureError::CandidateBindingRefused); + } + prior_asset = Some(asset.as_str()); + } + } + let file_paths = manifest + .files + .iter() + .filter(|file| file.role == PackageFileRole::SourceModuleAsset) + .map(|file| file.path.clone()) + .collect::>(); + if declared_paths != file_paths { + return Err(FixtureError::CandidateBindingRefused); + } + Ok(()) +} + +#[cfg(any(test, feature = "postgres-test"))] +fn validate_source_module_assets( + manifest: &crate::package::PackageManifest, + captured: &crate::package::CapturedModule, + source: &FixtureModuleSource<'_>, +) -> Result, FixtureError> { + if source.assets.len() != captured.assets.len() { + return Err(FixtureError::CandidateBindingRefused); + } + let mut assets = Vec::with_capacity(source.assets.len()); + let mut seen = BTreeSet::new(); + for (expected, asset) in captured.assets.iter().zip(source.assets) { + let package_path = source_module_asset_package_path(&captured.id, asset.path)?; + if asset.path != expected + || asset.bytes.is_empty() + || asset.bytes.len() > MAX_DERIVED_SQL_BYTES + || !seen.insert(asset.path) + || !manifest_file_matches( + manifest, + PackageFileRole::SourceModuleAsset, + &package_path, + asset.bytes, + ) + { + return Err(FixtureError::CandidateBindingRefused); + } + assets.push(ModuleAssetSource { + module: Some(source.id.to_owned()), + path: asset.path.to_owned(), + bytes: asset.bytes.to_vec(), + }); + } + Ok(assets) +} + +fn prepared_module_assets( + captured: &crate::package::CapturedModule, + files: &BTreeMap>, +) -> Result, FixtureError> { + let mut assets = Vec::with_capacity(captured.assets.len()); + for asset in &captured.assets { + let package_path = source_module_asset_package_path(&captured.id, asset)?; + let bytes = files + .get(&package_path) + .ok_or(FixtureError::CandidateBindingRefused)?; + if bytes.is_empty() || bytes.len() > MAX_DERIVED_SQL_BYTES { + return Err(FixtureError::CandidateBindingRefused); + } + assets.push(ModuleAssetSource { + module: Some(captured.id.clone()), + path: asset.clone(), + bytes: bytes.clone(), + }); + } + Ok(assets) +} + +fn source_module_asset_package_path( + module_id: &str, + asset_path: &str, +) -> Result { + if !valid_stable_id(module_id) + || asset_path.is_empty() + || asset_path.len() > 256 + || asset_path.contains('\\') + || asset_path.starts_with('/') + || asset_path.ends_with('/') + || !asset_path.ends_with(".sql") + || asset_path == "module.yaml" + { + return Err(FixtureError::CandidateBindingRefused); + } + let mut components = 0usize; + for component in asset_path.split('/') { + components += 1; + if component.is_empty() || component == "." || component == ".." { + return Err(FixtureError::CandidateBindingRefused); + } + } + if components > 12 { + return Err(FixtureError::CandidateBindingRefused); + } + Ok(format!("source/modules/{module_id}/{asset_path}")) +} + fn manifest_file_matches( manifest: &crate::package::PackageManifest, role: PackageFileRole, @@ -2230,6 +2503,10 @@ fn source_closure_sha256( for source in sources.modules { digest_part(&mut digest, source.id.as_bytes(), source.path.as_bytes()); digest_part(&mut digest, source.path.as_bytes(), source.bytes); + for asset in source.assets { + let path = format!("source/modules/{}/{}", source.id, asset.path); + digest_part(&mut digest, path.as_bytes(), asset.bytes); + } } digest_part( &mut digest, @@ -2258,6 +2535,13 @@ fn source_closure_sha256_from_package(package: &PreparedPackage) -> Result bool { fn operation_method(operation: Operation) -> HttpMethod { match operation { Operation::Create | Operation::Batch => HttpMethod::Post, - Operation::Get | Operation::List | Operation::Revisions => HttpMethod::Get, + Operation::Get | Operation::List | Operation::Lookup | Operation::Revisions => { + HttpMethod::Get + } Operation::Patch => HttpMethod::Patch, Operation::Tombstone => HttpMethod::Delete, } @@ -2339,6 +2625,7 @@ fn encoded_sha256(bytes: &[u8]) -> String { mod tests { use super::*; + use crate::compiler::module_digest; use crate::package::{ load_package, prepare_package, PackageBuildRequest, PackageIntent, PackageLoadContext, PackageMigrationPlanInput, PackageModuleSource, PackageSourceFile, SignaturePolicy, @@ -2625,6 +2912,7 @@ mod tests { id: "fixture-core", path: "sources/modules/fixture-core.yaml", bytes: &fixture.module, + assets: &[], }]; let changed_project_sources = SchemaTestSources { project: FixtureSourceFile { @@ -2652,6 +2940,7 @@ mod tests { id: "fixture-core", path: "sources/modules/fixture-core.yaml", bytes: &changed_module, + assets: &[], }]; let changed_module_sources = SchemaTestSources { project: FixtureSourceFile { @@ -2829,7 +3118,7 @@ mod tests { let malformed_list = json!({ "items": [{"id": identifier, "revision": 1, "data": {"record_id": "canary"}}], - "pageInfo": {"nextCursor": null} + "nextCursor": null }); assert!( assert_response(&suite.journeys[0].steps[2], StatusCode::OK, &malformed_list,).is_err() @@ -2877,6 +3166,7 @@ mod tests { id: "fixture-core", path: "sources/modules/fixture-core.yaml", bytes: &fixture.module, + assets: &[], }]; validate_schema_test_candidate( &fixture.package, @@ -2929,6 +3219,7 @@ mod tests { id: "fixture-core".to_owned(), path: "sources/modules/fixture-core.yaml".to_owned(), bytes: MODULE_SOURCE.to_vec(), + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: FIXTURE_JOURNEYS_PATH.to_owned(), @@ -3000,6 +3291,7 @@ mod tests { id: "fixture-core".to_owned(), path: "sources/modules/fixture-core.yaml".to_owned(), bytes: MODULE_SOURCE.to_vec(), + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: FIXTURE_JOURNEYS_PATH.to_owned(), @@ -3104,7 +3396,7 @@ mod tests { ), 2 => ( 200, - json!({"items":[{"id":id,"revision":1,"data":{"jurisdiction":"zone-a","label":"first","note":"initial","quantity":1}}],"pageInfo":{"nextCursor":null}}), + json!({"items":[{"id":id,"revision":1,"data":{"jurisdiction":"zone-a","label":"first","note":"initial","quantity":1}}],"nextCursor":null}), None, ), 3 => ( diff --git a/crates/registry-server/src/generated_ddl.rs b/crates/registry-server/src/generated_ddl.rs index 27ec1c79dd..eb9d02bace 100644 --- a/crates/registry-server/src/generated_ddl.rs +++ b/crates/registry-server/src/generated_ddl.rs @@ -19,11 +19,14 @@ pub enum DdlStatementKind { Schema, Table, Column, + View, + Function, Reference, Constraint, Index, RowSecurity, Policy, + Grant, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] @@ -83,6 +86,25 @@ pub struct DdlTable { pub policies: Vec, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DdlView { + pub id: String, + pub schema: String, + pub name: String, + pub runtime_privileges: BTreeSet, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct DdlFunction { + pub id: String, + pub schema: String, + pub name: String, + pub arguments: String, + pub runtime_execute: bool, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct DdlStatement { @@ -97,6 +119,10 @@ pub struct DdlInventory { pub requires_btree_gist: bool, pub statements: Vec, pub tables: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub views: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub functions: Vec, } impl DdlInventory { @@ -119,6 +145,26 @@ pub(crate) fn generate_ddl( kind: DdlStatementKind::Schema, sql: "CREATE SCHEMA IF NOT EXISTS registry_data".to_owned(), }]; + for schema in ["registry_source", "registry_derived", "registry_context"] { + statements.push(DdlStatement { + id: format!("schema.{schema}"), + kind: DdlStatementKind::Schema, + sql: format!("CREATE SCHEMA IF NOT EXISTS {schema}"), + }); + } + statements.push(DdlStatement { + id: "function.registry_context.evaluation_date".to_owned(), + kind: DdlStatementKind::Function, + sql: "CREATE OR REPLACE FUNCTION registry_context.evaluation_date() + RETURNS date + LANGUAGE sql + STABLE + SECURITY INVOKER + AS $registry_server_function$ + SELECT NULLIF(current_setting('registry.evaluation_date', true), '')::date + $registry_server_function$" + .to_owned(), + }); for entity in entities.values() { let mut columns = vec![ @@ -202,9 +248,17 @@ pub(crate) fn generate_ddl( } let mut tables = Vec::new(); + let mut views = Vec::new(); + let functions = vec![DdlFunction { + id: "registry_context.evaluation_date".to_owned(), + schema: "registry_context".to_owned(), + name: "evaluation_date".to_owned(), + arguments: String::new(), + runtime_execute: true, + }]; for entity in entities.values() { - let runtime_privileges = runtime_privileges(entity); - let policies = policies(entity); + let runtime_privileges = runtime_privileges(entity, entities); + let policies = policies(entity, entities); let table = quote_identifier(&entity.physical_table); statements.push(DdlStatement { id: format!("entity.{}.rls.enable", entity.id), @@ -218,12 +272,7 @@ pub(crate) fn generate_ddl( }); for policy in &policies { statements.push(DdlStatement { - id: format!( - "entity.{}.policy.{}.{}", - entity.id, - policy.access_profile, - policy.command.as_sql().to_ascii_lowercase() - ), + id: format!("entity.{}.policy.{}", entity.id, policy.name), kind: DdlStatementKind::Policy, sql: policy_sql(&table, policy), }); @@ -234,6 +283,85 @@ pub(crate) fn generate_ddl( runtime_privileges, policies, }); + + let source_view = quote_identifier(&entity.source_relation.sql_name); + let mut source_columns = vec!["record_id AS id".to_owned()]; + for field_id in &entity.source_relation.stored_fields { + let field = entity + .stored_fields + .iter() + .find(|field| field.logical.id == *field_id) + .expect("compiled source relation names only stored fields"); + source_columns.push(format!( + "{} AS {}", + quote_identifier(&field.physical_name), + quote_identifier(&field.logical.sql_name) + )); + } + statements.push(DdlStatement { + id: format!("entity.{}.source-view", entity.id), + kind: DdlStatementKind::View, + sql: format!( + "CREATE VIEW registry_source.{source_view} + WITH (security_invoker=true, security_barrier=true) + AS SELECT {} + FROM registry_data.{} + WHERE record_lifecycle = 'active'", + source_columns.join(", "), + quote_identifier(&entity.physical_table), + ), + }); + views.push(DdlView { + id: format!("entity.{}.source", entity.id), + schema: "registry_source".to_owned(), + name: entity.source_relation.sql_name.clone(), + runtime_privileges: BTreeSet::from([TablePrivilege::Select]), + }); + + for relation in entity.derived_relations.values() { + let derived_view_name = + derived_view_name(&entity.source_relation.sql_name, &relation.id); + let view = quote_identifier(&derived_view_name); + let sql = std::str::from_utf8(&relation.sql_bytes) + .expect("derived SQL asset was UTF-8 validated") + .trim() + .trim_end_matches(';'); + let mut columns = vec![format!( + "{}::{} AS {}", + quote_identifier(&relation.key_field.replace('-', "_")), + sql_type(&entity.canonical_id.field_type), + quote_identifier(&entity.canonical_id.sql_name) + )]; + for field_id in &relation.fields { + let field = entity + .derived_fields + .get(field_id) + .expect("compiled relation names only derived fields"); + columns.push(format!( + "{}::{} AS {}", + quote_identifier(&field.logical.sql_name), + sql_type(&field.logical.field_type), + quote_identifier(&field.logical.sql_name) + )); + } + statements.push(DdlStatement { + id: format!("entity.{}.derived.{}.view", entity.id, relation.id), + kind: DdlStatementKind::View, + sql: format!( + "CREATE VIEW registry_derived.{view} + WITH (security_invoker=true, security_barrier=true) + AS SELECT {} + FROM ({sql}) AS trusted_derived", + columns.join(", "), + ), + }); + views.push(DdlView { + id: format!("entity.{}.derived.{}", entity.id, relation.id), + schema: "registry_derived".to_owned(), + name: derived_view_name, + runtime_privileges: BTreeSet::from([TablePrivilege::Select]), + }); + } } DdlInventory { @@ -245,7 +373,19 @@ pub(crate) fn generate_ddl( }), statements, tables, + views, + functions, + } +} + +pub(crate) fn derived_view_name(source_relation: &str, derived_relation: &str) -> String { + let slug = derived_relation.replace('-', "_"); + let candidate = format!("{source_relation}__{slug}"); + if candidate.len() <= 63 { + return candidate; } + let digest = Sha256::digest(format!("registry-server/derived-view/{candidate}").as_bytes()); + format!("{}_{}", &candidate[..46], hex_prefix(&digest, 8)) } #[cfg(feature = "runtime")] @@ -278,7 +418,10 @@ fn column_definition(field: &crate::model::CompiledField) -> String { column } -fn runtime_privileges(entity: &CompiledEntity) -> BTreeSet { +fn runtime_privileges( + entity: &CompiledEntity, + entities: &BTreeMap, +) -> BTreeSet { let operations = entity .access_profiles .values() @@ -288,9 +431,14 @@ fn runtime_privileges(entity: &CompiledEntity) -> BTreeSet { if operations.iter().any(|operation| { matches!( operation, - Operation::Get | Operation::List | Operation::Batch | Operation::Revisions + Operation::Get + | Operation::Lookup + | Operation::List + | Operation::Batch + | Operation::Revisions ) - }) { + }) || path_select_entities(entities).contains(&entity.id) + { privileges.insert(TablePrivilege::Select); } if operations.contains(&Operation::Create) { @@ -305,7 +453,10 @@ fn runtime_privileges(entity: &CompiledEntity) -> BTreeSet { privileges } -fn policies(entity: &CompiledEntity) -> Vec { +fn policies( + entity: &CompiledEntity, + entities: &BTreeMap, +) -> Vec { let mut policies = Vec::new(); for profile in entity.access_profiles.values() { for command in [ @@ -347,6 +498,7 @@ fn policies(entity: &CompiledEntity) -> Vec { }); } } + policies.extend(read_path_policies_for_table(entity, entities)); policies } @@ -355,7 +507,11 @@ fn profile_supports_command(operations: &BTreeSet, command: PolicyCom PolicyCommand::Select => operations.iter().any(|operation| { matches!( operation, - Operation::Get | Operation::List | Operation::Batch | Operation::Revisions + Operation::Get + | Operation::Lookup + | Operation::List + | Operation::Batch + | Operation::Revisions ) }), PolicyCommand::Insert => operations.contains(&Operation::Create), @@ -365,9 +521,167 @@ fn profile_supports_command(operations: &BTreeSet, command: PolicyCom } } +fn path_select_entities(entities: &BTreeMap) -> BTreeSet { + let mut selected = BTreeSet::new(); + for source in entities.values() { + for profile in source.access_profiles.values() { + for grant in &profile.read_paths { + let Some(path) = source.read_paths.get(&grant.path) else { + continue; + }; + selected.insert(source.id.clone()); + selected.insert(path.through.clone()); + selected.insert(path.to.clone()); + } + } + } + selected +} + +fn read_path_policies_for_table( + table_entity: &CompiledEntity, + entities: &BTreeMap, +) -> Vec { + let mut policies = Vec::new(); + for source in entities.values() { + for profile in source.access_profiles.values() { + for grant in &profile.read_paths { + let Some(path) = source.read_paths.get(&grant.path) else { + continue; + }; + if table_entity.id == source.id { + policies.push(read_path_source_policy(table_entity, profile, path)); + } else if table_entity.id == path.through { + policies.push(read_path_through_policy( + table_entity, + source, + profile, + path, + )); + } else if table_entity.id == path.to { + let Some(through) = entities.get(&path.through) else { + continue; + }; + policies.push(read_path_target_policy( + table_entity, + through, + source, + profile, + path, + )); + } + } + } + } + policies +} + +fn read_path_source_policy( + source: &CompiledEntity, + profile: &crate::contract::AccessProfileSource, + path: &crate::model::CompiledReadPath, +) -> DdlPolicy { + let root_id = "NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid"; + DdlPolicy { + name: read_path_policy_name(&source.id, &source.id, &profile.id, &path.id, "source"), + command: PolicyCommand::Select, + access_profile: profile.id.clone(), + using_expression: Some(format!( + "({}) AND {} AND record_id = {root_id} AND record_lifecycle = 'active'", + policy_authority_expression(source, profile), + read_path_setting_expression(path), + )), + check_expression: None, + } +} + +fn read_path_through_policy( + through: &CompiledEntity, + source: &CompiledEntity, + profile: &crate::contract::AccessProfileSource, + path: &crate::model::CompiledReadPath, +) -> DdlPolicy { + let source_ref = field_name(through, &path.source_ref); + let root_id = "NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid"; + let source_authority = + policy_authority_expression_for_alias(source, profile, Some("path_source")); + DdlPolicy { + name: read_path_policy_name(&through.id, &source.id, &profile.id, &path.id, "through"), + command: PolicyCommand::Select, + access_profile: profile.id.clone(), + using_expression: Some(format!( + "({}) AND {} AND record_lifecycle = 'active' AND {source_ref} = {root_id} + AND EXISTS ( + SELECT 1 + FROM registry_data.{} AS path_source + WHERE path_source.record_id = {source_ref} + AND path_source.record_lifecycle = 'active' + AND ({source_authority}) + )", + session_authority_expression(profile), + read_path_setting_expression(path), + quote_identifier(&source.physical_table), + )), + check_expression: None, + } +} + +fn read_path_target_policy( + target: &CompiledEntity, + through: &CompiledEntity, + source: &CompiledEntity, + profile: &crate::contract::AccessProfileSource, + path: &crate::model::CompiledReadPath, +) -> DdlPolicy { + let through_source_ref = format!("path_edge.{}", field_name(through, &path.source_ref)); + let through_target_ref = format!("path_edge.{}", field_name(through, &path.target_ref)); + let root_id = "NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid"; + let source_authority = + policy_authority_expression_for_alias(source, profile, Some("path_source")); + DdlPolicy { + name: read_path_policy_name(&target.id, &source.id, &profile.id, &path.id, "target"), + command: PolicyCommand::Select, + access_profile: profile.id.clone(), + using_expression: Some(format!( + "({}) AND {} AND record_lifecycle = 'active' + AND EXISTS ( + SELECT 1 + FROM registry_data.{} AS path_edge + JOIN registry_data.{} AS path_source + ON path_source.record_id = {through_source_ref} + WHERE {through_target_ref} = record_id + AND {through_source_ref} = {root_id} + AND path_edge.record_lifecycle = 'active' + AND path_source.record_lifecycle = 'active' + AND ({source_authority}) + )", + session_authority_expression(profile), + read_path_setting_expression(path), + quote_identifier(&through.physical_table), + quote_identifier(&source.physical_table), + )), + check_expression: None, + } +} + +fn read_path_setting_expression(path: &crate::model::CompiledReadPath) -> String { + format!( + "NULLIF(current_setting('registry.read_path_id', true), '') = {}", + quote_literal(&path.id) + ) +} + fn policy_authority_expression( entity: &CompiledEntity, profile: &crate::contract::AccessProfileSource, +) -> String { + policy_authority_expression_for_alias(entity, profile, None) +} + +fn policy_authority_expression_for_alias( + entity: &CompiledEntity, + profile: &crate::contract::AccessProfileSource, + alias: Option<&str>, ) -> String { let mut predicates = vec![format!( "NULLIF(current_setting('registry.access_profile', true), '') = {}", @@ -414,8 +728,8 @@ fn policy_authority_expression( }) )); predicates.push(format!("jsonb_typeof({values}) = 'array'")); - let column = field_name(entity, &boundary.field); - let value_type = policy_value_type(&entity.fields[&boundary.field].field_type); + let column = field_name_with_alias(entity, &boundary.field, alias); + let value_type = policy_value_type(&logical_field_type(entity, &boundary.field)); match boundary.operator { BoundaryOperator::Equals => { predicates.push(format!("jsonb_array_length({values}) = 1")); @@ -432,6 +746,29 @@ fn policy_authority_expression( predicates.join(" AND ") } +fn session_authority_expression(profile: &crate::contract::AccessProfileSource) -> String { + let mut predicates = vec![format!( + "NULLIF(current_setting('registry.access_profile', true), '') = {}", + quote_literal(&profile.id) + )]; + if !profile.anonymous { + predicates + .push("NULLIF(current_setting('registry.principal', true), '') IS NOT NULL".to_owned()); + } + if !profile.required_purposes.is_empty() { + let purposes = profile + .required_purposes + .iter() + .map(|purpose| quote_literal(purpose)) + .collect::>() + .join(", "); + predicates.push(format!( + "NULLIF(current_setting('registry.purpose', true), '') IN ({purposes})" + )); + } + predicates.join(" AND ") +} + fn policy_value_type(field_type: &FieldTypeSource) -> &'static str { match field_type { FieldTypeSource::Boolean => "boolean", @@ -467,6 +804,27 @@ fn policy_name(entity_id: &str, profile_id: &str, command: PolicyCommand) -> Str ) } +fn read_path_policy_name( + table_entity_id: &str, + source_entity_id: &str, + profile_id: &str, + path_id: &str, + role: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"registry-server/read-path-rls-policy/v1"); + for value in [table_entity_id, source_entity_id, profile_id, path_id, role] { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value.as_bytes()); + } + let digest = hasher.finalize(); + let suffix = digest[..12] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("registry_path_rls_select_{suffix}") +} + fn policy_sql(table: &str, policy: &DdlPolicy) -> String { let mut sql = format!( "CREATE POLICY {} ON registry_data.{table} FOR {}", @@ -813,9 +1171,27 @@ fn field_list(entity: &CompiledEntity, fields: &[String]) -> String { } fn field_name(entity: &CompiledEntity, field: &str) -> String { + if field == "id" { + return quote_identifier(&entity.canonical_id.sql_name); + } quote_identifier(&entity.fields[field].physical_name) } +fn logical_field_type(entity: &CompiledEntity, field: &str) -> FieldTypeSource { + if field == "id" { + return entity.canonical_id.field_type.clone(); + } + entity.fields[field].field_type.clone() +} + +fn field_name_with_alias(entity: &CompiledEntity, field: &str, alias: Option<&str>) -> String { + let field = field_name(entity, field); + match alias { + Some(alias) => format!("{alias}.{field}"), + None => field, + } +} + fn comparison_operator(operator: ComparisonOperator) -> &'static str { match operator { ComparisonOperator::LessThan => "<", diff --git a/crates/registry-server/src/lib.rs b/crates/registry-server/src/lib.rs index 097484ada7..49b4a64093 100644 --- a/crates/registry-server/src/lib.rs +++ b/crates/registry-server/src/lib.rs @@ -14,6 +14,7 @@ pub mod contract; #[cfg(feature = "runtime")] pub mod cursor; pub mod data; +pub mod derived_sql; pub mod diagnostics; #[cfg(feature = "runtime")] pub mod event_destination; @@ -22,6 +23,7 @@ pub mod fixtures; pub mod generated_ddl; #[cfg(feature = "runtime")] pub mod idempotency; +pub mod logical_names; pub mod manifest_adapter; #[cfg(feature = "runtime")] pub mod migration; @@ -37,6 +39,7 @@ pub mod package; pub mod physical_names; #[cfg(feature = "runtime")] pub mod postgres; +pub mod query; #[cfg(feature = "runtime")] pub mod revision; #[cfg(feature = "runtime")] @@ -51,7 +54,7 @@ pub mod tooling; pub mod webhook; pub use artifacts::{GeneratedArtifact, GeneratedArtifacts}; -pub use compiler::{compile_project, CompileProfile}; +pub use compiler::{compile_project, compile_project_with_assets, CompileProfile}; pub use contract::{ parse_module_json, parse_module_yaml, parse_project_json, parse_project_yaml, RegistryModule, RegistryProject, diff --git a/crates/registry-server/src/logical_names.rs b/crates/registry-server/src/logical_names.rs new file mode 100644 index 0000000000..191b993c7e --- /dev/null +++ b/crates/registry-server/src/logical_names.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) fn default_api_name(id: &str) -> String { + let mut result = String::new(); + let mut upper_next = false; + for byte in id.bytes() { + match byte { + b'-' | b'_' => upper_next = true, + byte if upper_next => { + result.push(char::from(byte).to_ascii_uppercase()); + upper_next = false; + } + byte => result.push(char::from(byte)), + } + } + result +} + +pub(crate) fn default_sql_name(id: &str) -> String { + id.replace('-', "_") +} + +pub(crate) fn valid_api_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} + +pub(crate) fn reserved_logical_name(value: &str) -> bool { + matches!( + value, + "id" | "record_id" + | "recordId" + | "revision" + | "created_at" + | "createdAt" + | "updated_at" + | "updatedAt" + | "deleted_at" + | "deletedAt" + ) +} diff --git a/crates/registry-server/src/model.rs b/crates/registry-server/src/model.rs index 09d478b8a7..0e526db13c 100644 --- a/crates/registry-server/src/model.rs +++ b/crates/registry-server/src/model.rs @@ -26,6 +26,73 @@ pub struct CompiledField { pub physical_name: String, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledLogicalField { + pub id: String, + pub api_name: String, + pub sql_name: String, + pub field_type: FieldTypeSource, + pub classification: Classification, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledStoredField { + #[serde(flatten)] + pub logical: CompiledLogicalField, + pub required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub valid_time_role: Option, + pub physical_name: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledDerivedField { + #[serde(flatten)] + pub logical: CompiledLogicalField, + pub derivation_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledDerivedRelation { + pub id: String, + pub sql_path: String, + pub key_field: String, + pub execution: crate::contract::DerivedExecutionSource, + pub sql_sha256: String, + pub sql_bytes: Vec, + pub fields: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledSourceRelation { + pub entity_id: String, + pub sql_name: String, + pub stored_fields: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledSelectorProfile { + pub id: String, + pub fields: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledReadPath { + pub id: String, + pub through: String, + pub to: String, + pub route: String, + pub source_ref: String, + pub target_ref: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct CompiledEntity { @@ -39,6 +106,13 @@ pub struct CompiledEntity { pub physical_table: String, #[serde(skip_serializing_if = "Option::is_none")] pub temporal: Option, + pub canonical_id: CompiledLogicalField, + pub stored_fields: Vec, + pub derived_fields: BTreeMap, + pub derived_relations: BTreeMap, + pub source_relation: CompiledSourceRelation, + pub selector_profiles: BTreeMap, + pub read_paths: BTreeMap, pub fields: BTreeMap, pub constraints: BTreeMap, pub indexes: BTreeMap>, @@ -148,6 +222,8 @@ pub enum CompiledRevisionKind { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct CompiledAccessEntry { + #[serde(default)] + pub route_id: String, pub entity_id: String, pub operation: Operation, pub profile_ids: BTreeSet, @@ -252,6 +328,14 @@ pub struct CompiledQueryOperation { pub projection_fields: Vec, pub filter_fields: Vec, pub sort_fields: Vec, + #[serde(default)] + pub allow_count: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selector_fields: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_path: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub processing_fields: Vec, pub stable_tie_breaker: String, #[serde(skip_serializing_if = "Option::is_none")] pub temporal: Option, diff --git a/crates/registry-server/src/package.rs b/crates/registry-server/src/package.rs index 67207d9934..998e7fbf07 100644 --- a/crates/registry-server/src/package.rs +++ b/crates/registry-server/src/package.rs @@ -14,11 +14,13 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use crate::artifacts::REGISTRY_METADATA_ARTIFACT_PATH; -use crate::compiler::{compile_project, CompileProfile}; +use crate::compiler::{compile_project_with_assets, CompileProfile}; use crate::contract::{ - parse_module_yaml, parse_project_yaml, FieldTypeSource, RegistryModule, RegistryProject, + parse_module_yaml, parse_project_yaml, FieldTypeSource, ModuleAssetSource, RegistryModule, + RegistryProject, }; -use crate::generated_ddl::{add_column_statement, DdlStatement}; +use crate::derived_sql::MAX_DERIVED_SQL_BYTES; +use crate::generated_ddl::{add_column_statement, DdlStatement, DdlStatementKind}; #[cfg(feature = "tooling")] use crate::migration_plan::{ prepare_reviewed_migration_plan, validate_reviewed_migration_plan, @@ -118,6 +120,8 @@ pub struct CapturedSources { pub struct CapturedModule { pub id: String, pub path: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assets: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -134,6 +138,7 @@ pub struct PackageFile { pub enum PackageFileRole { SourceProject, SourceModule, + SourceModuleAsset, FixtureJourneys, GovernedModel, PhysicalNameInventory, @@ -248,6 +253,9 @@ pub enum CompiledRegistryChangeCode { FieldRequirednessChanged, FieldClassificationChanged, FieldTemporalRoleChanged, + DerivedRelationAdded, + DerivedRelationRemoved, + DerivedRelationChanged, ReferenceTargetChanged, ConstraintAdded, ConstraintRemoved, @@ -283,6 +291,7 @@ pub enum CompiledRegistryChangeTargetKind { Registry, Entity, Field, + DerivedRelation, Constraint, Index, AccessProfile, @@ -689,6 +698,7 @@ pub struct PackageModuleSource { pub id: String, pub path: String, pub bytes: Vec, + pub assets: Vec, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -981,6 +991,7 @@ fn compare_entities( ); } compare_fields(entity_id, previous_entity, candidate_entity, changes); + compare_derived_relations(entity_id, previous_entity, candidate_entity, changes); compare_map( entity_id, &previous_entity.constraints, @@ -1051,6 +1062,64 @@ fn compare_entities( } } +fn compare_derived_relations( + entity_id: &str, + previous: &CompiledEntity, + candidate: &CompiledEntity, + changes: &mut Vec, +) { + for (relation_id, previous_relation) in &previous.derived_relations { + match candidate.derived_relations.get(relation_id) { + Some(candidate_relation) if previous_relation == candidate_relation => {} + Some(candidate_relation) => { + let class = if previous_relation.sql_path == candidate_relation.sql_path + && previous_relation.key_field == candidate_relation.key_field + && previous_relation.execution == candidate_relation.execution + && previous_relation.fields == candidate_relation.fields + { + CompiledRegistryChangeClass::CompatibleAdditive + } else { + CompiledRegistryChangeClass::DestructiveOrIrreversible + }; + push_change( + changes, + class, + CompiledRegistryChangeCode::DerivedRelationChanged, + target( + CompiledRegistryChangeTargetKind::DerivedRelation, + Some(entity_id), + Some(relation_id.as_str()), + ), + ); + } + None => push_change( + changes, + CompiledRegistryChangeClass::DestructiveOrIrreversible, + CompiledRegistryChangeCode::DerivedRelationRemoved, + target( + CompiledRegistryChangeTargetKind::DerivedRelation, + Some(entity_id), + Some(relation_id.as_str()), + ), + ), + } + } + for relation_id in candidate.derived_relations.keys() { + if !previous.derived_relations.contains_key(relation_id) { + push_change( + changes, + CompiledRegistryChangeClass::CompatibleAdditive, + CompiledRegistryChangeCode::DerivedRelationAdded, + target( + CompiledRegistryChangeTargetKind::DerivedRelation, + Some(entity_id), + Some(relation_id.as_str()), + ), + ); + } + } +} + fn compare_fields( entity_id: &str, previous: &CompiledEntity, @@ -1372,6 +1441,7 @@ fn additive_migration_plan( changes: Vec, ) -> MigrationPlan { let mut new_statement_ids = BTreeSet::::new(); + let mut replacement_view_statement_ids = BTreeSet::::new(); let mut added_columns = BTreeMap::>::new(); for (entity_id, candidate_entity) in candidate.entities() { @@ -1398,6 +1468,29 @@ fn additive_migration_plan( new_statement_ids .insert(format!("entity.{entity_id}.field.{field_id}.reference")); } + let source_view_id = format!("entity.{entity_id}.source-view"); + replacement_view_statement_ids.insert(source_view_id.clone()); + new_statement_ids.insert(source_view_id); + } + } + for (relation_id, relation) in &candidate_entity.derived_relations { + match previous_entity.derived_relations.get(relation_id) { + Some(previous) + if previous.sql_path == relation.sql_path + && previous.key_field == relation.key_field + && previous.execution == relation.execution + && previous.fields == relation.fields + && previous != relation => + { + let derived_view_id = format!("entity.{entity_id}.derived.{relation_id}.view"); + replacement_view_statement_ids.insert(derived_view_id.clone()); + new_statement_ids.insert(derived_view_id); + } + None => { + new_statement_ids + .insert(format!("entity.{entity_id}.derived.{relation_id}.view")); + } + _ => {} } } for constraint_id in candidate_entity.constraints.keys() { @@ -1420,7 +1513,13 @@ fn additive_migration_plan( } } if new_statement_ids.contains(statement.id.as_str()) { - statements.push(statement.clone()); + statements.push( + if replacement_view_statement_ids.contains(statement.id.as_str()) { + replacement_statement(statement) + } else { + statement.clone() + }, + ); } } MigrationPlan { @@ -1433,6 +1532,20 @@ fn additive_migration_plan( } } +fn replacement_statement(statement: &DdlStatement) -> DdlStatement { + if statement.kind != DdlStatementKind::View { + return statement.clone(); + } + DdlStatement { + id: statement.id.clone(), + kind: statement.kind, + sql: statement.sql.strip_prefix("CREATE VIEW ").map_or_else( + || statement.sql.clone(), + |suffix| format!("CREATE OR REPLACE VIEW {suffix}"), + ), + } +} + fn initial_migration_plan(compiled: &CompiledRegistry) -> MigrationPlan { MigrationPlan { from_revision: None, @@ -1521,7 +1634,15 @@ fn target( } } -pub fn prepare_package(request: PackageBuildRequest) -> Result { +pub fn prepare_package(mut request: PackageBuildRequest) -> Result { + request + .modules + .sort_by(|left, right| left.id.cmp(&right.id)); + for module in &mut request.modules { + module + .assets + .sort_by(|left, right| left.path.cmp(&right.path)); + } validate_build_identity(&request)?; validate_relative(&request.project.path)?; if request.fixture_journeys.path != FIXTURE_JOURNEYS_PATH @@ -1547,8 +1668,14 @@ pub fn prepare_package(request: PackageBuildRequest) -> Result Ok(module) }) .collect::>>()?; - let compiled = compile_project(&project, &modules, CompileProfile::Production) - .map_err(|_| PackageError::Derivation)?; + let module_assets = package_module_assets(&request.modules)?; + let compiled = compile_project_with_assets( + &project, + &modules, + &module_assets, + CompileProfile::Production, + ) + .map_err(|_| PackageError::Derivation)?; validate_build_bindings(&request, &project, &compiled)?; let (migration_plan, reviewed_files): (MigrationPlan, BTreeMap>) = match request @@ -1636,6 +1763,12 @@ pub fn prepare_package(request: PackageBuildRequest) -> Result { return Err(PackageError::Closure); } + for asset in &module.assets { + let path = package_module_asset_path(&module.id, &asset.path)?; + if files.insert(path, asset.bytes.clone()).is_some() { + return Err(PackageError::Closure); + } + } } if files .insert( @@ -1666,6 +1799,14 @@ pub fn prepare_package(request: PackageBuildRequest) -> Result PackageFileRole::SourceModule, &module.bytes, )?); + for asset in &module.assets { + let path = package_module_asset_path(&module.id, &asset.path)?; + entries.push(file_entry( + &path, + PackageFileRole::SourceModuleAsset, + &asset.bytes, + )?); + } } entries.push(file_entry( &request.fixture_journeys.path, @@ -1676,6 +1817,12 @@ pub fn prepare_package(request: PackageBuildRequest) -> Result if path == &request.project.path || path == &request.fixture_journeys.path || request.modules.iter().any(|module| module.path == *path) + || request.modules.iter().any(|module| { + module.assets.iter().any(|asset| { + package_module_asset_path(&module.id, &asset.path) + .is_ok_and(|asset_path| asset_path == *path) + }) + }) { continue; } @@ -1705,6 +1852,7 @@ pub fn prepare_package(request: PackageBuildRequest) -> Result .modules .into_iter() .map(|module| CapturedModule { + assets: module.assets.into_iter().map(|asset| asset.path).collect(), id: module.id, path: module.path, }) @@ -1726,6 +1874,42 @@ pub fn prepare_package(request: PackageBuildRequest) -> Result }) } +fn package_module_assets(modules: &[PackageModuleSource]) -> Result> { + let mut assets = Vec::new(); + let mut paths = BTreeSet::new(); + for module in modules { + validate_relative(&module.id)?; + for asset in &module.assets { + validate_relative(&asset.path)?; + if !asset.path.ends_with(".sql") + || asset.path == "module.yaml" + || asset.bytes.is_empty() + || asset.bytes.len() > MAX_DERIVED_SQL_BYTES + || !paths.insert((module.id.as_str(), asset.path.as_str())) + { + return Err(PackageError::Derivation); + } + assets.push(ModuleAssetSource { + module: Some(module.id.clone()), + path: asset.path.clone(), + bytes: asset.bytes.clone(), + }); + } + } + Ok(assets) +} + +fn package_module_asset_path(module_id: &str, asset_path: &str) -> Result { + validate_relative(module_id)?; + validate_relative(asset_path)?; + if !asset_path.ends_with(".sql") || asset_path == "module.yaml" { + return Err(PackageError::Derivation); + } + let path = format!("source/modules/{module_id}/{asset_path}"); + validate_relative(&path)?; + Ok(path) +} + /// Return the exact canonical bytes signed by every package signer. pub fn canonical_signed_bytes(manifest: &PackageManifest) -> Result> { canonicalize_json(&serde_json::to_value(manifest).map_err(|_| PackageError::CanonicalJson)?) @@ -1864,6 +2048,12 @@ fn package_role_for_path(path: &str) -> Result { } Ok(match path { FIXTURE_JOURNEYS_PATH => PackageFileRole::FixtureJourneys, + path if path.starts_with("source/modules/") + && path.ends_with(".sql") + && !path.ends_with("/module.yaml") => + { + PackageFileRole::SourceModuleAsset + } "effective-model.json" => PackageFileRole::GovernedModel, "inventories/physical-names.json" => PackageFileRole::PhysicalNameInventory, "inventories/routes.json" => PackageFileRole::RouteInventory, @@ -2531,9 +2721,15 @@ fn rederive( .and_then(|bytes| parse_module_yaml(bytes).map_err(|_| PackageError::Derivation)) }) .collect::>>()?; + let module_assets = captured_module_assets(manifest, loaded)?; validate_captured_bindings(manifest, &project, &modules)?; - let compiled = compile_project(&project, &modules, CompileProfile::Production) - .map_err(|_| PackageError::Derivation)?; + let compiled = compile_project_with_assets( + &project, + &modules, + &module_assets, + CompileProfile::Production, + ) + .map_err(|_| PackageError::Derivation)?; if compiled.registry_id() != manifest.package_id { return Err(PackageError::Derivation); } @@ -2547,6 +2743,7 @@ fn rederive( entry.role, PackageFileRole::SourceProject | PackageFileRole::SourceModule + | PackageFileRole::SourceModuleAsset | PackageFileRole::FixtureJourneys ) && !reviewed_package_role(entry.role) }) @@ -2570,6 +2767,31 @@ fn rederive( Ok((compiled, reviewed_migration_plan)) } +fn captured_module_assets( + manifest: &PackageManifest, + loaded: &BTreeMap>, +) -> Result> { + let mut assets = Vec::new(); + for module in &manifest.sources.modules { + for asset_path in &module.assets { + let package_path = package_module_asset_path(&module.id, asset_path)?; + let bytes = loaded + .get(&package_path) + .ok_or(PackageError::Derivation)? + .clone(); + if bytes.is_empty() || bytes.len() > MAX_DERIVED_SQL_BYTES { + return Err(PackageError::Derivation); + } + assets.push(ModuleAssetSource { + module: Some(module.id.clone()), + path: asset_path.clone(), + bytes, + }); + } + } + Ok(assets) +} + fn reviewed_artifact_files( manifest: &PackageManifest, loaded: &BTreeMap>, @@ -2679,6 +2901,7 @@ fn validate_source_inventory(manifest: &PackageManifest) -> Result<()> { } let mut prior_id = None; let mut module_paths = BTreeSet::new(); + let mut asset_paths = BTreeSet::new(); for module in &manifest.sources.modules { validate_relative(&module.path)?; if module.id.is_empty() @@ -2687,6 +2910,16 @@ fn validate_source_inventory(manifest: &PackageManifest) -> Result<()> { { return Err(PackageError::Derivation); } + let mut prior_asset = None; + for asset in &module.assets { + let package_path = package_module_asset_path(&module.id, asset)?; + if prior_asset.is_some_and(|prior: &str| prior >= asset.as_str()) + || !asset_paths.insert(package_path) + { + return Err(PackageError::Derivation); + } + prior_asset = Some(asset.as_str()); + } prior_id = Some(module.id.as_str()); } let declared_paths = manifest @@ -2704,11 +2937,21 @@ fn validate_source_inventory(manifest: &PackageManifest) -> Result<()> { if declared_paths != file_paths { return Err(PackageError::Derivation); } + let file_asset_paths = manifest + .files + .iter() + .filter(|entry| entry.role == PackageFileRole::SourceModuleAsset) + .map(|entry| entry.path.clone()) + .collect::>(); + if asset_paths != file_asset_paths { + return Err(PackageError::Derivation); + } for entry in &manifest.files { if matches!( entry.role, PackageFileRole::SourceProject | PackageFileRole::SourceModule + | PackageFileRole::SourceModuleAsset | PackageFileRole::FixtureJourneys ) { continue; diff --git a/crates/registry-server/src/postgres/catalog.rs b/crates/registry-server/src/postgres/catalog.rs index f683a5e7cb..90c37b399c 100644 --- a/crates/registry-server/src/postgres/catalog.rs +++ b/crates/registry-server/src/postgres/catalog.rs @@ -13,6 +13,13 @@ use super::{ SqlIdentifier, }; +const MANAGED_SCHEMAS: &[&str] = &[ + "registry_internal", + "registry_data", + "registry_source", + "registry_derived", + "registry_context", +]; const TABLE_OWNER_PRIVILEGES: &[&str] = &[ "DELETE", "INSERT", @@ -24,12 +31,15 @@ const TABLE_OWNER_PRIVILEGES: &[&str] = &[ "UPDATE", ]; const SEQUENCE_OWNER_PRIVILEGES: &[&str] = &["SELECT", "UPDATE", "USAGE"]; +const FUNCTION_OWNER_PRIVILEGES: &[&str] = &["EXECUTE"]; #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] enum ManagedObjectKind { Schema, Table, + View, Sequence, + Function, } impl ManagedObjectKind { @@ -37,7 +47,9 @@ impl ManagedObjectKind { match self { Self::Schema => "schema", Self::Table => "table", + Self::View => "view", Self::Sequence => "sequence", + Self::Function => "function", } } } @@ -152,6 +164,24 @@ impl ExpectedManagedCatalog { }); } } + for view in ®istry.ddl().views { + catalog.view( + &format!("{}.{}", view.schema, view.name), + view.runtime_privileges + .iter() + .copied() + .map(TablePrivilege::as_sql), + ); + } + for function in ®istry.ddl().functions { + catalog.function( + &format!( + "{}.{}({})", + function.schema, function.name, function.arguments + ), + function.runtime_execute.then_some("EXECUTE"), + ); + } catalog } @@ -160,8 +190,9 @@ impl ExpectedManagedCatalog { objects: BTreeSet::new(), policies: BTreeSet::new(), }; - catalog.schema("registry_data"); - catalog.schema("registry_internal"); + for schema in MANAGED_SCHEMAS { + catalog.schema(schema); + } catalog.table( "registry_internal.registry_state", ["SELECT"], @@ -211,6 +242,24 @@ impl ExpectedManagedCatalog { row_security: None, }); } + + fn view(&mut self, name: &str, privileges: impl IntoIterator) { + self.objects.insert(ManagedObject { + kind: ManagedObjectKind::View, + name: name.to_owned(), + runtime_privileges: privileges.into_iter().map(str::to_owned).collect(), + row_security: None, + }); + } + + fn function(&mut self, name: &str, privilege: Option<&'static str>) { + self.objects.insert(ManagedObject { + kind: ManagedObjectKind::Function, + name: name.to_owned(), + runtime_privileges: privilege.into_iter().map(str::to_owned).collect(), + row_security: None, + }); + } } fn policy_command_code(command: PolicyCommand) -> &'static str { @@ -418,8 +467,8 @@ pub(crate) async fn install_registry_state_schema( install_migration_ledger(migration, runtime_role).await?; migration .batch_execute(&format!( - "REVOKE ALL ON SCHEMA registry_internal, registry_data FROM PUBLIC, {};\n\ - GRANT USAGE ON SCHEMA registry_internal, registry_data TO {};\n\ + "REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context FROM PUBLIC, {};\n\ + GRANT USAGE ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context TO {};\n\ REVOKE ALL ON TABLE registry_internal.registry_state FROM {};\n\ GRANT SELECT ON TABLE registry_internal.registry_state TO {};", runtime_role.quoted(), @@ -609,15 +658,15 @@ async fn verify_closed_ambient_catalog(client: &impl GenericClient) -> Result<() SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') - AND c.relkind NOT IN ('r', 'S', 'i') + WHERE n.nspname = ANY($1::text[]) + AND c.relkind NOT IN ('r', 'S', 'i', 'v') ), EXISTS ( SELECT 1 FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname = ANY($1::text[]) AND NOT t.tgisinternal ), EXISTS ( @@ -625,30 +674,35 @@ async fn verify_closed_ambient_catalog(client: &impl GenericClient) -> Result<() FROM pg_catalog.pg_rewrite w JOIN pg_catalog.pg_class c ON c.oid = w.ev_class JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname = ANY($1::text[]) AND c.relkind = 'r' ), EXISTS ( SELECT 1 FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname = ANY($1::text[]) + AND NOT ( + n.nspname = 'registry_context' + AND p.proname = 'evaluation_date' + AND pg_catalog.pg_get_function_identity_arguments(p.oid) = '' + ) ), EXISTS ( SELECT 1 FROM pg_catalog.pg_publication_rel pr JOIN pg_catalog.pg_class c ON c.oid = pr.prrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname = ANY($1::text[]) ) OR EXISTS ( SELECT 1 FROM pg_catalog.pg_publication_namespace pn JOIN pg_catalog.pg_namespace n ON n.oid = pn.pnnspid - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname = ANY($1::text[]) ) OR EXISTS ( SELECT 1 FROM pg_catalog.pg_publication WHERE puballtables )", - &[], + &[&MANAGED_SCHEMAS], ) .await?; if (0..5).any(|index| row.get::<_, bool>(index)) { @@ -669,17 +723,25 @@ async fn verify_managed_owners_for_catalog( "SELECT 'schema', n.nspname, r.rolname FROM pg_catalog.pg_namespace n JOIN pg_catalog.pg_roles r ON r.oid = n.nspowner - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname = ANY($1::text[]) UNION ALL - SELECT CASE c.relkind WHEN 'r' THEN 'table' ELSE 'sequence' END, + SELECT CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' ELSE 'sequence' END, n.nspname || '.' || c.relname, r.rolname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_roles r ON r.oid = c.relowner - WHERE n.nspname IN ('registry_internal', 'registry_data') - AND c.relkind IN ('r', 'S')", - &[], + WHERE n.nspname = ANY($1::text[]) + AND c.relkind IN ('r', 'v', 'S') + UNION ALL + SELECT 'function', + n.nspname || '.' || p.proname || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')', + r.rolname + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_roles r ON r.oid = p.proowner + WHERE n.nspname = ANY($1::text[])", + &[&MANAGED_SCHEMAS], ) .await?; let actual: BTreeSet<(String, String, String)> = rows @@ -715,8 +777,8 @@ async fn verify_row_security( "SELECT n.nspname || '.' || c.relname, c.relrowsecurity, c.relforcerowsecurity FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') AND c.relkind = 'r'", - &[], + WHERE n.nspname = ANY($1::text[]) AND c.relkind = 'r'", + &[&MANAGED_SCHEMAS], ) .await?; let actual: BTreeSet<(String, bool, bool)> = rows @@ -756,8 +818,8 @@ async fn verify_policies( FROM pg_catalog.pg_policy p JOIN pg_catalog.pg_class c ON c.oid = p.polrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data')", - &[], + WHERE n.nspname = ANY($1::text[])", + &[&MANAGED_SCHEMAS], ) .await?; let actual: BTreeSet = rows @@ -797,22 +859,31 @@ async fn query_categorized_acl( n.nspowner, COALESCE(n.nspacl, pg_catalog.acldefault('n', n.nspowner)) FROM pg_catalog.pg_namespace n - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname = ANY($2::text[]) UNION ALL - SELECT CASE c.relkind WHEN 'r' THEN 'table' ELSE 'sequence' END, + SELECT CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' ELSE 'sequence' END, n.nspname || '.' || c.relname, c.relowner, COALESCE( c.relacl, CASE c.relkind WHEN 'r' THEN pg_catalog.acldefault('r', c.relowner) + WHEN 'v' THEN pg_catalog.acldefault('r', c.relowner) ELSE pg_catalog.acldefault('S', c.relowner) END ) FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') - AND c.relkind IN ('r', 'S') + WHERE n.nspname = ANY($2::text[]) + AND c.relkind IN ('r', 'v', 'S') + UNION ALL + SELECT 'function'::text, + n.nspname || '.' || p.proname || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')', + p.proowner, + COALESCE(p.proacl, pg_catalog.acldefault('f', p.proowner)) + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = ANY($2::text[]) ), runtime AS ( SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $1 ) @@ -830,7 +901,7 @@ async fn query_categorized_acl( CROSS JOIN runtime CROSS JOIN LATERAL pg_catalog.aclexplode(o.acl) a ORDER BY 1, 2, 3, 4, 5", - &[&runtime_role.as_str()], + &[&runtime_role.as_str(), &MANAGED_SCHEMAS], ) .await?) } @@ -851,7 +922,9 @@ async fn verify_exact_acl( let owner_privileges = match object.kind { ManagedObjectKind::Schema => &["CREATE", "USAGE"][..], ManagedObjectKind::Table => TABLE_OWNER_PRIVILEGES, + ManagedObjectKind::View => TABLE_OWNER_PRIVILEGES, ManagedObjectKind::Sequence => SEQUENCE_OWNER_PRIVILEGES, + ManagedObjectKind::Function => FUNCTION_OWNER_PRIVILEGES, }; for privilege in owner_privileges { expected.insert(( @@ -927,10 +1000,10 @@ async fn fingerprint_catalog( ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum - WHERE n.nspname IN ('registry_internal', 'registry_data') - AND c.relkind IN ('r', 'S') + WHERE n.nspname = ANY($1::text[]) + AND c.relkind IN ('r', 'v', 'S') ORDER BY n.nspname, c.relname, a.attnum", - &[], + &[&MANAGED_SCHEMAS], ) .await?; let constraint_rows = client @@ -947,9 +1020,9 @@ async fn fingerprint_catalog( CROSS JOIN pg_catalog.pg_constraint x JOIN pg_catalog.pg_class c ON c.oid = x.conrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname = ANY($1::text[]) ORDER BY n.nspname, c.relname, x.conname", - &[], + &[&MANAGED_SCHEMAS], ) .await?; let index_rows = client @@ -966,9 +1039,9 @@ async fn fingerprint_catalog( JOIN pg_catalog.pg_class table_class ON table_class.oid = x.indrelid JOIN pg_catalog.pg_class index_class ON index_class.oid = x.indexrelid JOIN pg_catalog.pg_namespace n ON n.oid = table_class.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname = ANY($1::text[]) ORDER BY n.nspname, table_class.relname, index_class.relname", - &[], + &[&MANAGED_SCHEMAS], ) .await?; let policy_rows = client @@ -988,9 +1061,48 @@ async fn fingerprint_catalog( CROSS JOIN pg_catalog.pg_policy p JOIN pg_catalog.pg_class c ON c.oid = p.polrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname = ANY($1::text[]) ORDER BY n.nspname, c.relname, p.polname", - &[], + &[&MANAGED_SCHEMAS], + ) + .await?; + let view_rows = client + .query( + "WITH deparse_context AS MATERIALIZED ( + SELECT pg_catalog.set_config('search_path', 'pg_catalog', true) + ) + SELECT n.nspname, + c.relname, + pg_catalog.pg_get_viewdef(c.oid, false), + COALESCE(array_to_string(c.reloptions, ','), '') + FROM deparse_context + CROSS JOIN pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ANY($1::text[]) + AND c.relkind = 'v' + ORDER BY n.nspname, c.relname", + &[&MANAGED_SCHEMAS], + ) + .await?; + let function_rows = client + .query( + "WITH deparse_context AS MATERIALIZED ( + SELECT pg_catalog.set_config('search_path', 'pg_catalog', true) + ) + SELECT n.nspname, + p.proname, + pg_catalog.pg_get_function_identity_arguments(p.oid), + pg_catalog.format_type(p.prorettype, NULL), + p.provolatile::text, + p.prosecdef, + p.prokind::text, + pg_catalog.pg_get_functiondef(p.oid) + FROM deparse_context + CROSS JOIN pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = ANY($1::text[]) + ORDER BY n.nspname, p.proname, pg_catalog.pg_get_function_identity_arguments(p.oid)", + &[&MANAGED_SCHEMAS], ) .await?; let acl_rows = query_categorized_acl(client, runtime_role).await?; @@ -1031,6 +1143,19 @@ async fn fingerprint_catalog( hash_bool(&mut hasher, row.get(4)); hash_bool(&mut hasher, row.get(5)); } + hasher.update(b"registry-server/catalog/v4/views"); + for row in view_rows { + for index in 0..4 { + hash_text(&mut hasher, &row.get::<_, String>(index)); + } + } + hasher.update(b"registry-server/catalog/v4/functions"); + for row in function_rows { + for index in [0, 1, 2, 3, 4, 6, 7] { + hash_text(&mut hasher, &row.get::<_, String>(index)); + } + hash_bool(&mut hasher, row.get(5)); + } hasher.update(b"registry-server/catalog/v3/acl"); for row in acl_rows { for index in 0..4 { diff --git a/crates/registry-server/src/postgres/context.rs b/crates/registry-server/src/postgres/context.rs index 2ca1c7d903..1927001e00 100644 --- a/crates/registry-server/src/postgres/context.rs +++ b/crates/registry-server/src/postgres/context.rs @@ -660,9 +660,13 @@ mod tests { tombstone: false, batch: None, classification: Classification::Internal, + derived: Vec::new(), + selector_profiles: Vec::new(), + read_paths: Vec::new(), fields: vec![ FieldSource { id: "tenant".to_owned(), + api_name: None, field_type: FieldTypeSource::String { min_length: 1, max_length: 64, @@ -673,6 +677,7 @@ mod tests { }, FieldSource { id: "region".to_owned(), + api_name: None, field_type: FieldTypeSource::String { min_length: 1, max_length: 64, @@ -711,6 +716,9 @@ mod tests { ], revision_access: false, allow_data_export: false, + lookups: Vec::new(), + read_paths: Vec::new(), + allow_count: false, }], events: Vec::new(), }], diff --git a/crates/registry-server/src/postgres/mod.rs b/crates/registry-server/src/postgres/mod.rs index 6319b71422..bc42a87d30 100644 --- a/crates/registry-server/src/postgres/mod.rs +++ b/crates/registry-server/src/postgres/mod.rs @@ -55,6 +55,7 @@ pub use roles::{ pub use schema::install_compiled_schema; #[cfg(all(feature = "runtime", feature = "tooling"))] pub(crate) use schema::rehearse_schema_fingerprint_with_connection; +pub(crate) use schema::verify_postgres_15_or_newer; #[cfg(all(feature = "runtime", feature = "tooling"))] pub(crate) use schema::PreparedSchemaTestCatalogVerifier; #[cfg(all(feature = "runtime", feature = "tooling"))] diff --git a/crates/registry-server/src/postgres/read.rs b/crates/registry-server/src/postgres/read.rs index 6105a36bc5..5a7cf2815a 100644 --- a/crates/registry-server/src/postgres/read.rs +++ b/crates/registry-server/src/postgres/read.rs @@ -14,18 +14,23 @@ use tokio_postgres::types::ToSql; use uuid::Uuid; use crate::api::{ - AuthorizedRequestContext, HeldReadResponse, ReadServiceError, RecordReadRequest, - RecordReadService, RowBoundaryOperator as ApiRowBoundaryOperator, ServiceFuture, + AuthorizedRequestContext, HeldReadResponse, LookupSelectorValue, ReadFilterExpr, + ReadFilterOperator, ReadFilterPredicate, ReadLogicalOp, ReadOrderClause, ReadProjectionField, + ReadServiceError, RecordReadKind, RecordReadRequest, RecordReadService, + RowBoundaryOperator as ApiRowBoundaryOperator, ServiceFuture, }; use crate::audit::{ append_read_terminal_audit, profile_is_keyed, record_pre_io_audit, PreIoAudit, PreIoAuditKind, ReadTerminalAudit, TerminalAudit, TerminalAuditOutcome, }; use crate::contract::{FieldTypeSource, Operation}; -use crate::cursor::{now_unix_seconds, CursorCodec, CursorContinuation}; +use crate::cursor::{ + now_unix_seconds, CursorCodec, CursorContinuation, CursorFilterExpr, CursorFilterOperator, + CursorLogicalOp, CursorOrderClause, CursorProjectionField, CursorQueryScope, +}; use crate::model::{ - CompiledEntity, CompiledQueryFilterOperator, CompiledQueryKind, CompiledQueryOperation, - CompiledQuerySortDirection, CompiledRegistry, + CompiledEntity, CompiledQueryKind, CompiledQueryOperation, CompiledQuerySortDirection, + CompiledReadPath, CompiledRegistry, }; use crate::mutation::strong_record_etag; @@ -80,14 +85,12 @@ impl PostgresRecordReadService { self } - async fn execute( - &self, - request: RecordReadRequest, - operation: Operation, - ) -> Result { + async fn execute(&self, request: RecordReadRequest) -> Result { if !profile_is_keyed(&self.audit_profile) { return Err(ReadServiceError::Unavailable); } + let operation = request_operation(&request.kind); + let target_record = target_record(&request.kind); let mut client = self .pool .get() @@ -99,7 +102,6 @@ impl PostgresRecordReadService { &self.expected, self.cursors.as_ref(), &request, - operation, ) { Ok(plan) => plan, Err(()) => { @@ -114,7 +116,7 @@ impl PostgresRecordReadService { kind: PreIoAuditKind::Refusal, method: request.method, operation_id: &request.operation_id, - target_record: request.record_id.as_deref(), + target_record, }, ) .await @@ -122,12 +124,7 @@ impl PostgresRecordReadService { return Ok(ReadResult::empty_get()); } }; - if operation == Operation::Get - && !request - .record_id - .as_deref() - .is_some_and(valid_canonical_uuid) - { + if operation == Operation::Get && !target_record.is_some_and(valid_canonical_uuid) { record_pre_io_audit( &mut client, self.lock_key, @@ -139,7 +136,7 @@ impl PostgresRecordReadService { kind: PreIoAuditKind::Refusal, method: request.method, operation_id: &request.operation_id, - target_record: request.record_id.as_deref(), + target_record, }, ) .await @@ -158,7 +155,7 @@ impl PostgresRecordReadService { kind: PreIoAuditKind::Attempt, method: request.method, operation_id: &request.operation_id, - target_record: request.record_id.as_deref(), + target_record, }, ) .await @@ -189,10 +186,7 @@ impl PostgresRecordReadService { let mut held = ReadResult::from_materialized(plan.operation, materialized)?; if plan.operation == Operation::Get && held.response.is_some() { let response = held.response.take().ok_or(ReadServiceError::Unavailable)?; - let record_id = request - .record_id - .as_deref() - .ok_or(ReadServiceError::Unavailable)?; + let record_id = target_record.ok_or(ReadServiceError::Unavailable)?; let record_revision = held.record_revision.ok_or(ReadServiceError::Unavailable)?; let etag = strong_record_etag( &self.audit_profile, @@ -206,10 +200,10 @@ impl PostgresRecordReadService { held.response = Some(response.with_strong_etag(etag)); } self.fault.fail_at(ReadFaultPoint::BeforeTerminalAudit)?; - let outcome = if held.result_count == 0 { - TerminalAuditOutcome::Empty - } else { - TerminalAuditOutcome::Returned + let outcome = match (plan.operation, held.result_count) { + (Operation::Lookup, 0) => TerminalAuditOutcome::Unresolved, + (_, 0) => TerminalAuditOutcome::Empty, + _ => TerminalAuditOutcome::Returned, }; self.record_read_terminal_audit( &mut client, @@ -250,13 +244,9 @@ impl PostgresRecordReadService { &self.audit_profile, ReadTerminalAudit { terminal, - query_reference: request - .query - .as_ref() + query_reference: request_query(&request.kind) .map(|query| query.cursor_binding.query_reference.clone()), - row_boundary_reference: request - .query - .as_ref() + row_boundary_reference: request_query(&request.kind) .map(|query| query.cursor_binding.row_boundary_reference.clone()), }, ) @@ -283,31 +273,53 @@ impl PostgresRecordReadService { ) .await .map_err(|_| ReadServiceError::Unavailable)?; - let selected_fields = request.selected_fields.iter().cloned().collect::>(); - let projection = projection( - &plan.entity, - &selected_fields, - request - .query + let query = request_query(&request.kind); + install_evaluation_date(transaction.transaction(), query).await?; + if let RecordReadKind::Relationship { + root_id, path_id, .. + } = &request.kind + { + install_read_path_context(transaction.transaction(), path_id, root_id).await?; + } + let selected_fields = if let Some(query) = query { + query + .projection + .iter() + .map(|field| field.field_id.clone()) + .collect::>() + } else { + request.selected_fields.iter().cloned().collect::>() + }; + let relations = if let Some(path) = &plan.read_path { + let root_id = match &request.kind { + RecordReadKind::Relationship { + root_id, path_id, .. + } if path_id == &path.id => root_id.as_str(), + _ => return Err(ReadServiceError::Unavailable), + }; + let through = plan + .through_entity .as_ref() - .and_then(|query| query.sort.as_deref()), - )?; - let table = quote_identifier(&plan.entity.physical_table); + .ok_or(ReadServiceError::Unavailable)?; + ReadRelations::relationship(&plan.source_entity, through, &plan.entity, path, root_id)? + } else { + ReadRelations::collection(&plan.entity)? + }; + let projection = projection(&plan.entity, &relations, &selected_fields, query)?; let limit = i64::try_from(request.maximum_records).map_err(|_| ReadServiceError::Unavailable)?; + let mut total_count = None; let rows = match plan.operation { Operation::Get => { let sql = format!( "SELECT {projection} - FROM registry_data.{table} - WHERE record_id = $1::text::uuid - AND record_lifecycle = 'active' - LIMIT 1" + FROM {} + WHERE {} = $1::text::uuid + LIMIT 1", + relations.from_sql, relations.id_expression ); - let record_id = request - .record_id - .as_deref() - .ok_or(ReadServiceError::Unavailable)?; + let record_id = + target_record(&request.kind).ok_or(ReadServiceError::Unavailable)?; transaction .transaction() .query(&sql, &[&record_id]) @@ -315,15 +327,48 @@ impl PostgresRecordReadService { .map_err(|_| ReadServiceError::Unavailable)? } Operation::List => { - let query = request - .query - .as_ref() - .ok_or(ReadServiceError::Unavailable)?; + let query = query.ok_or(ReadServiceError::Unavailable)?; let _compiled_query = plan .query_operation .as_ref() .ok_or(ReadServiceError::Unavailable)?; - let (sql, values) = list_sql(&plan.entity, query, &projection, &table)?; + let (sql, count_sql, values) = + list_sql(&plan.entity, &relations, query, &projection)?; + if query.include_count { + let refs = values + .iter() + .map(|value| value as &(dyn ToSql + Sync)) + .collect::>(); + total_count = Some( + transaction + .transaction() + .query_one(&count_sql, &refs) + .await + .map_err(|_| ReadServiceError::Unavailable)? + .get::<_, i64>(0), + ); + } + let mut params = values + .into_iter() + .map(|value| Box::new(value) as Box) + .collect::>(); + params.push(Box::new(limit)); + let refs = params + .iter() + .map(|value| &**value as &(dyn ToSql + Sync)) + .collect::>(); + transaction + .transaction() + .query(&sql, &refs) + .await + .map_err(|_| ReadServiceError::Unavailable)? + } + Operation::Lookup => { + let values = match &request.kind { + RecordReadKind::Lookup { selector } => &selector.values, + _ => return Err(ReadServiceError::Unavailable), + }; + let (sql, values) = lookup_sql(&plan.entity, &relations, values, &projection)?; let mut params = values .into_iter() .map(|value| Box::new(value) as Box) @@ -341,12 +386,9 @@ impl PostgresRecordReadService { } _ => return Err(ReadServiceError::Unavailable), }; - let page_size = request - .query - .as_ref() - .map_or(request.maximum_records, |query| { - usize::from(query.page_size) - }); + let page_size = query.map_or(request.maximum_records, |query| { + usize::from(query.page_size) + }); let has_more = plan.operation == Operation::List && rows.len() > page_size; let rows = if has_more { &rows[..page_size] @@ -354,10 +396,7 @@ impl PostgresRecordReadService { rows.as_slice() }; let next_cursor = if has_more { - let query = request - .query - .as_ref() - .ok_or(ReadServiceError::Unavailable)?; + let query = query.ok_or(ReadServiceError::Unavailable)?; rows.last() .map(|row| self.next_cursor(row, &selected_fields, query)) .transpose()? @@ -372,7 +411,11 @@ impl PostgresRecordReadService { .commit() .await .map_err(|_| ReadServiceError::Unavailable)?; - Ok(MaterializedRead { rows, next_cursor }) + Ok(MaterializedRead { + rows, + next_cursor, + total_count, + }) } fn next_cursor( @@ -384,7 +427,7 @@ impl PostgresRecordReadService { let last_record_id = row .try_get::<_, String>(0) .map_err(|_| ReadServiceError::Unavailable)?; - let sort_value = if query.sort.is_some() { + let sort_value = if query.order.is_some() { row.try_get::<_, Option>(selected_fields.len() + 2) .map_err(|_| ReadServiceError::Unavailable)? .and_then(cursor_sort_value) @@ -429,9 +472,7 @@ impl PostgresRecordReadService { }) .transpose() .map_err(|_| ReadServiceError::Unavailable)?; - let record_reference = request - .record_id - .as_deref() + let record_reference = target_record(&request.kind) .map(|record_id| { key_hasher.audit_reference_hash( "registry-server-record-v1", @@ -457,7 +498,7 @@ impl PostgresRecordReadService { principal_reference, record_reference, record_revision, - result_count: Some(result_count), + result_count: (outcome != TerminalAuditOutcome::Unresolved).then_some(result_count), field_set_reference: Some(field_set_reference), }) } @@ -469,7 +510,7 @@ impl RecordReadService for PostgresRecordReadService { request: RecordReadRequest, ) -> ServiceFuture<'_, Result, ReadServiceError>> { Box::pin(async move { - let result = self.execute(request, Operation::Get).await?; + let result = self.execute(request).await?; Ok(result.response) }) } @@ -479,13 +520,23 @@ impl RecordReadService for PostgresRecordReadService { request: RecordReadRequest, ) -> ServiceFuture<'_, Result> { Box::pin(async move { - self.execute(request, Operation::List) + self.execute(request) .await? .response .ok_or(ReadServiceError::Unavailable) }) } + fn lookup( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(async move { + let result = self.execute(request).await?; + Ok(result.response) + }) + } + fn refusal( &self, request: crate::api::RecordReadRefusal, @@ -552,16 +603,37 @@ impl ReadResult { } Operation::List => { let result_count = materialized.rows.len(); - let response = HeldReadResponse::from_json(&json!({ + let mut body = json!({ "items": materialized.rows, "pageInfo": {"nextCursor": materialized.next_cursor}, - }))?; + }); + if let Some(count) = materialized.total_count { + body["count"] = json!(count); + } + let response = HeldReadResponse::from_json(&body)?; Ok(Self { response: Some(response), result_count, record_revision: None, }) } + Operation::Lookup => { + let mut rows = materialized.rows.into_iter(); + let Some(record) = rows.next() else { + return Ok(Self::empty_get()); + }; + if rows.next().is_some() { + return Ok(Self::empty_get()); + } + let revision = + i64::try_from(record.revision).map_err(|_| ReadServiceError::Unavailable)?; + let response = HeldReadResponse::from_json(&json!(record))?; + Ok(Self { + response: Some(response), + result_count: 1, + record_revision: Some(revision), + }) + } _ => Err(ReadServiceError::Unavailable), } } @@ -570,6 +642,7 @@ impl ReadResult { struct MaterializedRead { rows: Vec, next_cursor: Option, + total_count: Option, } #[derive(Serialize)] @@ -580,10 +653,56 @@ struct RecordEnvelope { data: Map, } +fn request_operation(kind: &RecordReadKind) -> Operation { + match kind { + RecordReadKind::Get { .. } => Operation::Get, + RecordReadKind::List { .. } | RecordReadKind::Relationship { .. } => Operation::List, + RecordReadKind::Lookup { .. } => Operation::Lookup, + } +} + +fn request_query(kind: &RecordReadKind) -> Option<&crate::api::CompiledReadQuery> { + match kind { + RecordReadKind::List { plan } | RecordReadKind::Relationship { plan, .. } => Some(plan), + RecordReadKind::Get { .. } | RecordReadKind::Lookup { .. } => None, + } +} + +fn target_record(kind: &RecordReadKind) -> Option<&str> { + match kind { + RecordReadKind::Get { id } | RecordReadKind::Relationship { root_id: id, .. } => { + Some(id.as_str()) + } + RecordReadKind::List { .. } | RecordReadKind::Lookup { .. } => None, + } +} + struct ReadPlan { operation: Operation, + source_entity: CompiledEntity, entity: CompiledEntity, query_operation: Option, + read_path: Option, + through_entity: Option, +} + +fn validate_lookup_values( + entity: &CompiledEntity, + selector: &crate::model::CompiledSelectorProfile, + values: &[LookupSelectorValue], +) -> Result<(), ()> { + if selector.fields.len() != values.len() { + return Err(()); + } + for (expected_field, value) in selector.fields.iter().zip(values) { + if expected_field != &value.field_id + || compiled_field_type(entity, &value.field_id) != Some(&value.field_type) + || validate_field_value(&value.value, &value.field_type).is_err() + { + return Err(()); + } + } + Ok(()) } impl ReadPlan { @@ -592,49 +711,148 @@ impl ReadPlan { expected: &ExpectedRegistryIdentity, cursors: &CursorCodec, request: &RecordReadRequest, - operation: Operation, ) -> Result { + let operation = request_operation(&request.kind); let route = registry .routes() .routes .iter() .find(|route| route.id == request.operation_id) .ok_or(())?; - let entity = registry.entities().get(&request.entity_id).ok_or(())?; - let profile = entity + let source_entity = registry.entities().get(&request.entity_id).ok_or(())?; + let profile = source_entity .access_profiles .get(request.context.selected_profile()) .ok_or(())?; - let inventory = registry - .physical_names() - .entities - .get(&request.entity_id) - .ok_or(())?; - let query_operation = if operation == Operation::List { - let Some(query) = request.query.as_ref() else { - return Err(()); - }; - if route.query_kind != Some(query.kind) || query.route_id != route.id { - return Err(()); + let mut entity = source_entity; + let mut read_path = None; + let mut through_entity = None; + let query_operation = match &request.kind { + RecordReadKind::Get { id } => { + if operation != Operation::Get || !valid_canonical_uuid(id) { + return Err(()); + } + None } - let Some(operation) = registry.queries().operations.iter().find(|operation| { - operation.id == query.query_operation_id - && operation.route_id == route.id - && operation.entity_id == request.entity_id - && operation.profile_id == request.context.selected_profile() - && operation.kind == query.kind - }) else { - return Err(()); - }; - validate_compiled_query_request( - registry, expected, cursors, entity, operation, request, query, - )?; - Some(operation.clone()) - } else { - if request.query.is_some() { - return Err(()); + RecordReadKind::List { plan } => { + if operation != Operation::List + || route.query_kind != Some(plan.kind) + || plan.route_id != route.id + { + return Err(()); + } + let compiled_query = registry + .queries() + .operations + .iter() + .find(|operation| { + operation.id == plan.query_operation_id + && operation.route_id == route.id + && operation.entity_id == source_entity.id + && operation.profile_id == request.context.selected_profile() + && operation.kind == plan.kind + && operation.read_path.is_none() + && operation.selector_fields.is_empty() + }) + .ok_or(())?; + validate_compiled_query_request( + registry, + expected, + cursors, + source_entity, + compiled_query, + request, + plan, + )?; + Some(compiled_query.clone()) + } + RecordReadKind::Lookup { selector } => { + if operation != Operation::Lookup || route.id != selector.route_id { + return Err(()); + } + let selector_profile = source_entity + .selector_profiles + .get(&selector.selector_id) + .ok_or(())?; + let grant = profile + .lookups + .iter() + .find(|lookup| lookup.selector == selector.selector_id) + .ok_or(())?; + if grant.value_origin != selector.value_origin { + return Err(()); + } + validate_lookup_values(source_entity, selector_profile, &selector.values)?; + let compiled_query = registry + .queries() + .operations + .iter() + .find(|operation| { + operation.id == selector.query_operation_id + && operation.route_id == route.id + && operation.entity_id == source_entity.id + && operation.profile_id == request.context.selected_profile() + && operation.kind == CompiledQueryKind::List + && operation.read_path.is_none() + && operation.selector_fields == selector_profile.fields + }) + .ok_or(())?; + Some(compiled_query.clone()) + } + RecordReadKind::Relationship { + root_id, + path_id, + plan, + } => { + if operation != Operation::List + || !valid_canonical_uuid(root_id) + || route.query_kind != Some(plan.kind) + || plan.route_id != route.id + { + return Err(()); + } + let path = source_entity.read_paths.get(path_id).ok_or(())?; + if route.id != format!("records.{}.path.{}", source_entity.id, path.id) { + return Err(()); + } + let through = registry.entities().get(&path.through).ok_or(())?; + let target_entity = registry.entities().get(&path.to).ok_or(())?; + let grant = profile + .read_paths + .iter() + .find(|grant| grant.path == path.id) + .ok_or(())?; + if !request.selected_fields.is_subset(&grant.readable_fields) { + return Err(()); + } + let compiled_query = registry + .queries() + .operations + .iter() + .find(|operation| { + operation.id == plan.query_operation_id + && operation.route_id == route.id + && operation.entity_id == target_entity.id + && operation.profile_id == request.context.selected_profile() + && operation.kind == plan.kind + && operation.read_path.as_deref() == Some(path.id.as_str()) + && operation.selector_fields.is_empty() + }) + .ok_or(())?; + validate_compiled_query_request( + registry, + expected, + cursors, + target_entity, + compiled_query, + request, + plan, + )?; + entity = target_entity; + read_path = Some(path.clone()); + through_entity = Some(through.clone()); + Some(compiled_query.clone()) } - None }; if route.operation != operation || route.method != request.method @@ -643,32 +861,48 @@ impl ReadPlan { .access_profiles .iter() .any(|profile| profile == request.context.selected_profile()) - || !profile.operations.contains(&operation) + || (read_path.is_none() && !profile.operations.contains(&operation)) || request.maximum_records == 0 || request.maximum_records > MAX_SQL_LIMIT || operation == Operation::Get && request.maximum_records != 1 - || inventory.table != entity.physical_table - || !valid_physical_identifier(&entity.physical_table) - || entity.fields.iter().any(|(id, field)| { - inventory.fields.get(id) != Some(&field.physical_name) - || !valid_physical_identifier(&field.physical_name) - }) - || !request.selected_fields.is_subset(&profile.readable_fields) + || operation == Operation::Lookup && request.maximum_records != 2 + || operation == Operation::List + && request_query(&request.kind) + .and_then(|query| usize::from(query.page_size).checked_add(1)) + != Some(request.maximum_records) + || !valid_entity_inventory(registry, source_entity) + || !valid_entity_inventory(registry, entity) + || (read_path.is_none() && !request.selected_fields.is_subset(&profile.readable_fields)) || request .selected_fields .iter() - .any(|field| !entity.fields.contains_key(field)) + .any(|field| compiled_field_type(entity, field).is_none()) { return Err(()); } Ok(Self { operation, + source_entity: source_entity.clone(), entity: entity.clone(), query_operation, + read_path, + through_entity, }) } } +fn valid_entity_inventory(registry: &CompiledRegistry, entity: &CompiledEntity) -> bool { + let Some(inventory) = registry.physical_names().entities.get(&entity.id) else { + return false; + }; + inventory.table == entity.physical_table + && valid_physical_identifier(&entity.physical_table) + && entity.fields.iter().all(|(id, field)| { + inventory.fields.get(id) == Some(&field.physical_name) + && valid_physical_identifier(&field.physical_name) + }) +} + fn validate_compiled_query_request( registry: &CompiledRegistry, expected: &ExpectedRegistryIdentity, @@ -692,6 +926,7 @@ fn validate_compiled_query_request( || query.cursor_binding.query_kind != query.kind || query.cursor_binding.selected_profile != request.context.selected_profile() || query.cursor_binding.page_size != query.page_size + || query.cursor_binding.include_count != query.include_count || query.cursor_binding.temporal_instant != query.temporal_instant || query.cursor_binding.selected_fields != selected_fields || !valid_optional_cursor_reference(query.cursor_binding.principal_reference.as_deref()) @@ -700,6 +935,7 @@ fn validate_compiled_query_request( || !valid_cursor_reference(&query.cursor_binding.projection_reference) || !valid_cursor_reference(&query.cursor_binding.query_reference) || !valid_cursor_reference(&query.cursor_binding.sort_reference) + || !valid_cursor_reference(&query.cursor_binding.scope_reference) || !request .selected_fields .iter() @@ -707,7 +943,7 @@ fn validate_compiled_query_request( || !operation .projection_fields .iter() - .all(|field| entity.fields.contains_key(field)) + .all(|field| compiled_field_type(entity, field).is_some()) { return Err(()); } @@ -729,79 +965,27 @@ fn validate_compiled_query_request( } } } - if query.filters.len() > 32 { - return Err(()); - } - let mut total_in_values = 0_usize; - for filter in &query.filters { - let Some(compiled_filter) = operation - .filter_fields - .iter() - .find(|candidate| candidate.field == filter.field) - else { - return Err(()); - }; - if !compiled_filter.operators.contains(&filter.operator) - || !entity.fields.contains_key(&filter.field) - { + validate_projection(entity, operation, &query.projection)?; + if let Some(filter) = &query.filter { + let mut stats = FilterStats::default(); + validate_filter_expr(entity, operation, filter, &mut stats)?; + if stats.predicates > 32 || stats.in_values > 100 { return Err(()); } - let field_type = &entity.fields[&filter.field].field_type; - match filter.operator { - CompiledQueryFilterOperator::Equals | CompiledQueryFilterOperator::Prefix => { - if filter.values.len() != 1 - || validate_field_value(&filter.values[0], field_type).is_err() - { - return Err(()); - } - } - CompiledQueryFilterOperator::In => { - if filter.values.is_empty() { - return Err(()); - } - total_in_values = total_in_values.checked_add(filter.values.len()).ok_or(())?; - if total_in_values > 100 - || filter - .values - .windows(2) - .any(|window| window[0] >= window[1]) - || filter - .values - .iter() - .any(|value| validate_field_value(value, field_type).is_err()) - { - return Err(()); - } - } - CompiledQueryFilterOperator::Range => { - if filter.values.len() != 2 - || filter - .values - .iter() - .any(|value| validate_field_value(value, field_type).is_err()) - { - return Err(()); - } - } - CompiledQueryFilterOperator::IsNull | CompiledQueryFilterOperator::IsNotNull => { - if filter.values.len() != 1 || filter.values[0] != "true" { - return Err(()); - } - } - } } - if let Some(sort) = &query.sort { + if let Some(order) = &query.order { let Some(compiled_sort) = operation .sort_fields .iter() - .find(|candidate| candidate.field == *sort) + .find(|candidate| candidate.field == order.field_id) else { return Err(()); }; if !compiled_sort .directions .contains(&CompiledQuerySortDirection::Asc) - || !entity.fields.contains_key(sort) + || compiled_field_type(entity, &order.field_id) != Some(&order.field_type) + || order.direction != CompiledQuerySortDirection::Asc { return Err(()); } @@ -810,12 +994,9 @@ fn validate_compiled_query_request( if !valid_canonical_uuid(&continuation.last_record_id) { return Err(()); } - match (&query.sort, &continuation.sort_value) { - (Some(sort), Some(value)) => { - let Some(field) = entity.fields.get(sort) else { - return Err(()); - }; - if validate_field_value(value, &field.field_type).is_err() { + match (&query.order, &continuation.sort_value) { + (Some(order), Some(value)) => { + if validate_field_value(value, &order.field_type).is_err() { return Err(()); } } @@ -823,16 +1004,7 @@ fn validate_compiled_query_request( (None, Some(_)) => return Err(()), } } - let expected_filters = query - .filters - .iter() - .map(|filter| crate::cursor::CursorFilter { - field: filter.field.clone(), - operator: query_filter_operator_name(filter.operator).to_owned(), - values: filter.values.clone(), - }) - .collect::>(); - if query.cursor_query.filters != expected_filters || query.cursor_query.sort != query.sort { + if !cursor_query_matches_request(query, request)? { return Err(()); } let references = cursor_binding_references(cursors, request, operation, query)?; @@ -842,6 +1014,7 @@ fn validate_compiled_query_request( || query.cursor_binding.projection_reference != references.projection || query.cursor_binding.query_reference != references.query || query.cursor_binding.sort_reference != references.sort + || query.cursor_binding.scope_reference != references.scope { return Err(()); } @@ -855,6 +1028,7 @@ struct CursorBindingReferences { projection: String, query: String, sort: String, + scope: String, } fn cursor_binding_references( @@ -867,7 +1041,7 @@ fn cursor_binding_references( .context .principal() .map(|value| { - cursors.binding_digest_bytes(b"registry-server-cursor-principal-v1", value.as_bytes()) + cursors.binding_digest_bytes(b"registry-server-cursor-principal-v3", value.as_bytes()) }) .transpose() .map_err(|_| ())?; @@ -875,13 +1049,13 @@ fn cursor_binding_references( .context .purpose() .map(|value| { - cursors.binding_digest_bytes(b"registry-server-cursor-purpose-v1", value.as_bytes()) + cursors.binding_digest_bytes(b"registry-server-cursor-purpose-v3", value.as_bytes()) }) .transpose() .map_err(|_| ())?; let row_boundary = cursors .binding_digest( - b"registry-server-cursor-row-boundary-v1", + b"registry-server-cursor-row-boundary-v3", &json!(request .context .row_boundaries() @@ -899,29 +1073,40 @@ fn cursor_binding_references( .collect::>()), ) .map_err(|_| ())?; - let selected_fields = request.selected_fields.iter().cloned().collect::>(); let projection = cursors .binding_digest( - b"registry-server-cursor-projection-v1", - &json!({"selectedFields": selected_fields}), + b"registry-server-cursor-projection-v3", + &json!({"projection": query.projection.iter().map(projection_field_value).collect::>()}), ) .map_err(|_| ())?; let query_reference = cursors .binding_digest( - b"registry-server-cursor-query-v1", + b"registry-server-cursor-query-v3", &json!({ - "filters": query.cursor_query.filters, + "routeId": query.route_id, + "queryOperationId": operation.id, + "queryKind": operation.kind, + "selectedProfile": request.context.selected_profile(), + "projection": query.projection.iter().map(projection_field_value).collect::>(), + "filter": query.filter.as_ref().map(read_filter_expr_value), + "order": query.order.as_ref().map(read_order_clause_value), + "pageSize": query.page_size, + "includeCount": query.include_count, "temporalInstant": query.temporal_instant, + "scope": cursor_scope_value(&query.cursor_query.scope), }), ) .map_err(|_| ())?; let sort = cursors .binding_digest( - b"registry-server-cursor-sort-v1", - &json!({ - "sort": query.sort, - "tieBreaker": operation.stable_tie_breaker, - }), + b"registry-server-cursor-sort-v3", + &json!({"order": query.order.as_ref().map(read_order_clause_value), "tieBreaker": operation.stable_tie_breaker}), + ) + .map_err(|_| ())?; + let scope = cursors + .binding_digest( + b"registry-server-cursor-scope-v3", + &cursor_scope_value(&query.cursor_query.scope), ) .map_err(|_| ())?; Ok(CursorBindingReferences { @@ -931,17 +1116,100 @@ fn cursor_binding_references( projection, query: query_reference, sort, + scope, }) } -fn query_filter_operator_name(operator: CompiledQueryFilterOperator) -> &'static str { - match operator { - CompiledQueryFilterOperator::Equals => "equals", - CompiledQueryFilterOperator::In => "in", - CompiledQueryFilterOperator::Range => "range", - CompiledQueryFilterOperator::IsNull => "is_null", - CompiledQueryFilterOperator::IsNotNull => "is_not_null", - CompiledQueryFilterOperator::Prefix => "prefix", +fn cursor_query_matches_request( + query: &crate::api::CompiledReadQuery, + request: &RecordReadRequest, +) -> Result { + let expected_scope = match &request.kind { + RecordReadKind::List { .. } => CursorQueryScope::Collection {}, + RecordReadKind::Relationship { + root_id, path_id, .. + } => CursorQueryScope::Relationship { + path_id: path_id.clone(), + root_id: root_id.clone(), + }, + RecordReadKind::Get { .. } | RecordReadKind::Lookup { .. } => return Ok(false), + }; + Ok( + query.cursor_query.projection == cursor_projection_from_read(&query.projection) + && query.cursor_query.filter == query.filter.as_ref().map(cursor_filter_expr_from_read) + && query.cursor_query.order == query.order.as_ref().map(cursor_order_from_read) + && query.cursor_query.include_count == query.include_count + && query.cursor_query.page_size == query.page_size + && query.cursor_query.temporal_instant == query.temporal_instant + && query.cursor_query.scope == expected_scope, + ) +} + +fn cursor_projection_from_read(projection: &[ReadProjectionField]) -> Vec { + projection + .iter() + .map(|field| CursorProjectionField { + field_id: field.field_id.clone(), + field_type: field.field_type.clone(), + }) + .collect() +} + +fn cursor_order_from_read(order: &ReadOrderClause) -> CursorOrderClause { + CursorOrderClause { + field_id: order.field_id.clone(), + field_type: order.field_type.clone(), + direction: order.direction, + } +} + +fn cursor_filter_expr_from_read(filter: &ReadFilterExpr) -> CursorFilterExpr { + match filter { + ReadFilterExpr::Binary { op, left, right } => CursorFilterExpr::Binary { + op: match op { + ReadLogicalOp::And => CursorLogicalOp::And, + ReadLogicalOp::Or => CursorLogicalOp::Or, + }, + left: Box::new(cursor_filter_expr_from_read(left)), + right: Box::new(cursor_filter_expr_from_read(right)), + }, + ReadFilterExpr::Not(expr) => CursorFilterExpr::Not { + expr: Box::new(cursor_filter_expr_from_read(expr)), + }, + ReadFilterExpr::Group(expr) => CursorFilterExpr::Group { + expr: Box::new(cursor_filter_expr_from_read(expr)), + }, + ReadFilterExpr::Predicate(predicate) => CursorFilterExpr::Predicate { + predicate: crate::cursor::CursorFilterPredicate { + field_id: predicate.field_id.clone(), + field_type: predicate.field_type.clone(), + operator: match predicate.operator { + ReadFilterOperator::Eq => CursorFilterOperator::Eq, + ReadFilterOperator::Ne => CursorFilterOperator::Ne, + ReadFilterOperator::Lt => CursorFilterOperator::Lt, + ReadFilterOperator::Le => CursorFilterOperator::Le, + ReadFilterOperator::Gt => CursorFilterOperator::Gt, + ReadFilterOperator::Ge => CursorFilterOperator::Ge, + ReadFilterOperator::In => CursorFilterOperator::In, + ReadFilterOperator::IsNull => CursorFilterOperator::IsNull, + ReadFilterOperator::IsNotNull => CursorFilterOperator::IsNotNull, + ReadFilterOperator::StartsWith => CursorFilterOperator::StartsWith, + ReadFilterOperator::Contains => CursorFilterOperator::Contains, + }, + values: predicate.values.clone(), + }, + }, + } +} + +fn cursor_scope_value(scope: &CursorQueryScope) -> Value { + match scope { + CursorQueryScope::Collection {} => json!({"kind": "collection"}), + CursorQueryScope::Relationship { path_id, root_id } => json!({ + "kind": "relationship", + "pathId": path_id, + "rootId": root_id, + }), } } @@ -996,101 +1264,480 @@ fn strict_claim_context( .map_err(|_| ReadServiceError::Unavailable) } -fn projection( - entity: &CompiledEntity, - selected_fields: &[String], - sort: Option<&str>, -) -> Result { - let mut expressions = vec!["record_id::text".to_owned(), "record_revision".to_owned()]; - for field in selected_fields { - let Some(compiled_field) = entity.fields.get(field) else { +async fn install_evaluation_date( + transaction: &tokio_postgres::Transaction<'_>, + query: Option<&crate::api::CompiledReadQuery>, +) -> Result<(), ReadServiceError> { + let instant = query + .and_then(|query| query.temporal_instant.as_deref()) + .map(parse_rfc3339_utc) + .transpose()? + .unwrap_or_else(time::OffsetDateTime::now_utc); + let evaluation_date = instant.date().to_string(); + transaction + .execute( + "SELECT set_config('registry.evaluation_date', $1, true)", + &[&evaluation_date], + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + Ok(()) +} + +async fn install_read_path_context( + transaction: &tokio_postgres::Transaction<'_>, + path_id: &str, + root_id: &str, +) -> Result<(), ReadServiceError> { + if path_id.is_empty() || path_id.len() > 256 || !valid_canonical_uuid(root_id) { + return Err(ReadServiceError::Unavailable); + } + transaction + .execute( + "SELECT set_config('registry.read_path_id', $1, true), + set_config('registry.read_path_root_id', $2, true)", + &[&path_id, &root_id], + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + Ok(()) +} + +fn parse_rfc3339_utc(value: &str) -> Result { + let parsed = time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) + .map_err(|_| ReadServiceError::Unavailable)?; + if parsed.offset() != time::UtcOffset::UTC { + return Err(ReadServiceError::Unavailable); + } + Ok(parsed) +} + +struct ReadRelations { + base_alias: &'static str, + source_alias: &'static str, + id_expression: String, + from_sql: String, + base_predicates: Vec, + derived_aliases: BTreeMap, +} + +impl ReadRelations { + fn collection(entity: &CompiledEntity) -> Result { + let base_alias = "base_record"; + let source_alias = "source_record"; + if !valid_physical_identifier(&entity.physical_table) + || !valid_physical_identifier(&entity.source_relation.sql_name) + { return Err(ReadServiceError::Unavailable); - }; - let column = quote_identifier(&compiled_field.physical_name); - if matches!(compiled_field.field_type, FieldTypeSource::Decimal { .. }) { - expressions.push(format!("to_jsonb({column}::text)")); - } else { - expressions.push(format!("to_jsonb({column})")); } + let mut derived_aliases = BTreeMap::new(); + let mut from_sql = format!( + "registry_source.{} AS {source_alias} + JOIN registry_data.{} AS {base_alias} + ON {base_alias}.record_id = {source_alias}.id", + quote_identifier(&entity.source_relation.sql_name), + quote_identifier(&entity.physical_table), + ); + for (index, relation) in entity.derived_relations.values().enumerate() { + let alias = format!("derived_{index}"); + let view_name = crate::generated_ddl::derived_view_name( + &entity.source_relation.sql_name, + &relation.id, + ); + if !valid_physical_identifier(&view_name) { + return Err(ReadServiceError::Unavailable); + } + from_sql.push_str(&format!( + " LEFT JOIN registry_derived.{} AS {alias} + ON {alias}.{} = {source_alias}.id", + quote_identifier(&view_name), + quote_identifier(&entity.canonical_id.sql_name), + )); + derived_aliases.insert(relation.id.clone(), alias); + } + Ok(Self { + base_alias, + source_alias, + id_expression: format!( + "{source_alias}.{}", + quote_identifier(&entity.canonical_id.sql_name) + ), + from_sql, + base_predicates: Vec::new(), + derived_aliases, + }) } - if let Some(sort) = sort { - let Some(compiled_field) = entity.fields.get(sort) else { + + fn relationship( + source: &CompiledEntity, + through: &CompiledEntity, + target: &CompiledEntity, + path: &CompiledReadPath, + root_id: &str, + ) -> Result { + if !valid_canonical_uuid(root_id) { return Err(ReadServiceError::Unavailable); - }; - let column = quote_identifier(&compiled_field.physical_name); - if matches!(compiled_field.field_type, FieldTypeSource::Decimal { .. }) { - expressions.push(format!("to_jsonb({column}::text)")); - } else { - expressions.push(format!("to_jsonb({column})")); } + for entity in [source, through, target] { + if !valid_physical_identifier(&entity.physical_table) + || !valid_physical_identifier(&entity.source_relation.sql_name) + { + return Err(ReadServiceError::Unavailable); + } + } + let base_alias = "base_record"; + let source_alias = "target_source_record"; + let path_source_alias = "path_source_record"; + let path_through_alias = "path_through_record"; + let source_id = format!( + "{path_source_alias}.{}", + quote_identifier(&source.canonical_id.sql_name) + ); + let target_id = format!( + "{source_alias}.{}", + quote_identifier(&target.canonical_id.sql_name) + ); + let through_source_ref = + source_view_field_expression(through, path_through_alias, &path.source_ref)?; + let through_target_ref = + source_view_field_expression(through, path_through_alias, &path.target_ref)?; + let mut from_sql = format!( + "registry_source.{} AS {path_source_alias} + JOIN registry_source.{} AS {path_through_alias} + ON {through_source_ref} = {source_id} + JOIN registry_source.{} AS {source_alias} + ON {target_id} = {through_target_ref} + JOIN registry_data.{} AS {base_alias} + ON {base_alias}.record_id = {target_id}", + quote_identifier(&source.source_relation.sql_name), + quote_identifier(&through.source_relation.sql_name), + quote_identifier(&target.source_relation.sql_name), + quote_identifier(&target.physical_table), + ); + let mut derived_aliases = BTreeMap::new(); + for (index, relation) in target.derived_relations.values().enumerate() { + let alias = format!("derived_{index}"); + let view_name = crate::generated_ddl::derived_view_name( + &target.source_relation.sql_name, + &relation.id, + ); + if !valid_physical_identifier(&view_name) { + return Err(ReadServiceError::Unavailable); + } + from_sql.push_str(&format!( + " LEFT JOIN registry_derived.{} AS {alias} + ON {alias}.{} = {target_id}", + quote_identifier(&view_name), + quote_identifier(&target.canonical_id.sql_name), + )); + derived_aliases.insert(relation.id.clone(), alias); + } + Ok(Self { + base_alias, + source_alias, + id_expression: target_id, + from_sql, + base_predicates: vec![ + format!("{source_id} = NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid"), + format!( + "NULLIF(current_setting('registry.read_path_id', true), '') = {}", + sql_quote_literal(&path.id) + ), + ], + derived_aliases, + }) + } + + fn field_expression( + &self, + entity: &CompiledEntity, + field_id: &str, + ) -> Result { + if field_id == "id" { + return Ok(FieldExpression { + sql: self.id_expression.clone(), + field_type: entity.canonical_id.field_type.clone(), + }); + } + if let Some(field) = entity + .stored_fields + .iter() + .find(|field| field.logical.id == field_id) + { + return Ok(FieldExpression { + sql: format!( + "{}.{}", + self.source_alias, + quote_identifier(&field.logical.sql_name) + ), + field_type: field.logical.field_type.clone(), + }); + } + if let Some(field) = entity.derived_fields.get(field_id) { + let alias = self + .derived_aliases + .get(&field.derivation_id) + .ok_or(ReadServiceError::Unavailable)?; + return Ok(FieldExpression { + sql: format!("{alias}.{}", quote_identifier(&field.logical.sql_name)), + field_type: field.logical.field_type.clone(), + }); + } + Err(ReadServiceError::Unavailable) } - Ok(expressions.join(", ")) } -fn list_sql( +fn source_view_field_expression( entity: &CompiledEntity, - query: &crate::api::CompiledReadQuery, - projection: &str, - table: &str, -) -> Result<(String, Vec), ReadServiceError> { - let mut values = Vec::new(); - let mut predicates = vec!["record_lifecycle = 'active'".to_owned()]; - let mut grouped_in: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); - for filter in &query.filters { - let Some(compiled_field) = entity.fields.get(&filter.field) else { - return Err(ReadServiceError::Unavailable); - }; - let column = quote_identifier(&compiled_field.physical_name); - let cast = postgres_cast(&compiled_field.field_type); - match filter.operator { - CompiledQueryFilterOperator::Equals => { - let parameter = push_value(&mut values, &filter.values[0]); - predicates.push(format!("{column} = ${parameter}::text::{cast}")); - } - CompiledQueryFilterOperator::In => { - if filter.values.is_empty() { - return Err(ReadServiceError::Unavailable); - } - grouped_in - .entry(&filter.field) - .or_default() - .extend(filter.values.iter().map(String::as_str)); + alias: &str, + field_id: &str, +) -> Result { + let field = entity + .stored_fields + .iter() + .find(|field| field.logical.id == field_id) + .ok_or(ReadServiceError::Unavailable)?; + if !valid_physical_identifier(&field.logical.sql_name) { + return Err(ReadServiceError::Unavailable); + } + Ok(format!( + "{alias}.{}", + quote_identifier(&field.logical.sql_name) + )) +} + +struct FieldExpression { + sql: String, + field_type: FieldTypeSource, +} + +fn compiled_field_type<'a>( + entity: &'a CompiledEntity, + field_id: &str, +) -> Option<&'a FieldTypeSource> { + if field_id == "id" { + return Some(&entity.canonical_id.field_type); + } + entity + .stored_fields + .iter() + .find(|field| field.logical.id == field_id) + .map(|field| &field.logical.field_type) + .or_else(|| { + entity + .derived_fields + .get(field_id) + .map(|field| &field.logical.field_type) + }) +} + +fn validate_projection( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + projection: &[ReadProjectionField], +) -> Result<(), ()> { + if projection.is_empty() || projection.len() > operation.projection_fields.len() { + return Err(()); + } + let mut seen = BTreeSet::new(); + for field in projection { + if !seen.insert(field.field_id.as_str()) + || !operation.projection_fields.contains(&field.field_id) + || compiled_field_type(entity, &field.field_id) != Some(&field.field_type) + { + return Err(()); + } + } + Ok(()) +} + +#[derive(Default)] +struct FilterStats { + predicates: usize, + in_values: usize, +} + +fn validate_filter_expr( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + filter: &ReadFilterExpr, + stats: &mut FilterStats, +) -> Result<(), ()> { + match filter { + ReadFilterExpr::Binary { left, right, .. } => { + validate_filter_expr(entity, operation, left, stats)?; + validate_filter_expr(entity, operation, right, stats) + } + ReadFilterExpr::Not(expr) | ReadFilterExpr::Group(expr) => { + validate_filter_expr(entity, operation, expr, stats) + } + ReadFilterExpr::Predicate(predicate) => { + validate_filter_predicate(entity, operation, predicate, stats) + } + } +} + +fn validate_filter_predicate( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + predicate: &ReadFilterPredicate, + stats: &mut FilterStats, +) -> Result<(), ()> { + stats.predicates = stats.predicates.checked_add(1).ok_or(())?; + let Some(field_type) = compiled_field_type(entity, &predicate.field_id) else { + return Err(()); + }; + let Some(compiled_filter) = operation + .filter_fields + .iter() + .find(|candidate| candidate.field == predicate.field_id) + else { + return Err(()); + }; + if !compiled_filter + .operators + .contains(&predicate.operator.compiled_capability()) + || field_type != &predicate.field_type + { + return Err(()); + } + match predicate.operator { + ReadFilterOperator::Eq + | ReadFilterOperator::Ne + | ReadFilterOperator::Lt + | ReadFilterOperator::Le + | ReadFilterOperator::Gt + | ReadFilterOperator::Ge + | ReadFilterOperator::StartsWith + | ReadFilterOperator::Contains => { + if predicate.values.len() != 1 + || validate_field_value(&predicate.values[0], field_type).is_err() + { + return Err(()); } - CompiledQueryFilterOperator::Range => { - let lower = push_value(&mut values, &filter.values[0]); - let upper = push_value(&mut values, &filter.values[1]); - predicates.push(format!( - "{column} >= ${lower}::text::{cast} AND {column} <= ${upper}::text::{cast}" - )); + } + ReadFilterOperator::In => { + if predicate.values.is_empty() { + return Err(()); } - CompiledQueryFilterOperator::IsNull => predicates.push(format!("{column} IS NULL")), - CompiledQueryFilterOperator::IsNotNull => { - predicates.push(format!("{column} IS NOT NULL")); + stats.in_values = stats + .in_values + .checked_add(predicate.values.len()) + .ok_or(())?; + if predicate + .values + .windows(2) + .any(|window| window[0] >= window[1]) + || predicate + .values + .iter() + .any(|value| validate_field_value(value, field_type).is_err()) + { + return Err(()); } - CompiledQueryFilterOperator::Prefix => { - let parameter = - push_value(&mut values, &format!("{}%", escape_like(&filter.values[0]))); - predicates.push(format!("{column} LIKE ${parameter}::text ESCAPE '\\'")); + } + ReadFilterOperator::IsNull | ReadFilterOperator::IsNotNull => { + if predicate.values.as_slice() != ["true"] { + return Err(()); } } } - for (field, finite_values) in grouped_in { - if finite_values.is_empty() { - return Err(ReadServiceError::Unavailable); - } - let Some(compiled_field) = entity.fields.get(field) else { - return Err(ReadServiceError::Unavailable); - }; - let column = quote_identifier(&compiled_field.physical_name); - let cast = postgres_cast(&compiled_field.field_type); - let placeholders = finite_values - .iter() - .map(|value| { - let parameter = push_value(&mut values, value); - format!("${parameter}::text::{cast}") - }) - .collect::>(); - predicates.push(format!("{column} IN ({})", placeholders.join(", "))); + Ok(()) +} + +fn projection_field_value(field: &ReadProjectionField) -> Value { + json!({ + "fieldId": field.field_id, + "fieldType": field.field_type, + }) +} + +fn read_order_clause_value(order: &ReadOrderClause) -> Value { + json!({ + "fieldId": order.field_id, + "fieldType": order.field_type, + "direction": order.direction, + }) +} + +fn read_filter_expr_value(filter: &ReadFilterExpr) -> Value { + match filter { + ReadFilterExpr::Binary { op, left, right } => json!({ + "kind": "binary", + "op": match op { + ReadLogicalOp::And => "and", + ReadLogicalOp::Or => "or", + }, + "left": read_filter_expr_value(left), + "right": read_filter_expr_value(right), + }), + ReadFilterExpr::Not(expr) => json!({ + "kind": "not", + "op": "not", + "expr": read_filter_expr_value(expr), + }), + ReadFilterExpr::Group(expr) => json!({ + "kind": "group", + "op": "group", + "expr": read_filter_expr_value(expr), + }), + ReadFilterExpr::Predicate(predicate) => json!({ + "kind": "predicate", + "fieldId": predicate.field_id, + "fieldType": predicate.field_type, + "operator": read_filter_operator_name(predicate.operator), + "values": predicate.values, + }), + } +} + +fn read_filter_operator_name(operator: ReadFilterOperator) -> &'static str { + match operator { + ReadFilterOperator::Eq => "eq", + ReadFilterOperator::Ne => "ne", + ReadFilterOperator::Lt => "lt", + ReadFilterOperator::Le => "le", + ReadFilterOperator::Gt => "gt", + ReadFilterOperator::Ge => "ge", + ReadFilterOperator::In => "in", + ReadFilterOperator::IsNull => "is_null", + ReadFilterOperator::IsNotNull => "is_not_null", + ReadFilterOperator::StartsWith => "startswith", + ReadFilterOperator::Contains => "contains", + } +} + +fn projection( + entity: &CompiledEntity, + relations: &ReadRelations, + selected_fields: &[String], + query: Option<&crate::api::CompiledReadQuery>, +) -> Result { + let mut expressions = vec![ + format!("{}::text", relations.id_expression), + format!("{}.record_revision", relations.base_alias), + ]; + for field in selected_fields { + let expression = relations.field_expression(entity, field)?; + expressions.push(json_expression(&expression.sql, &expression.field_type)); + } + if let Some(order) = query.and_then(|query| query.order.as_ref()) { + let expression = relations.field_expression(entity, &order.field_id)?; + expressions.push(json_expression(&expression.sql, &expression.field_type)); + } + Ok(expressions.join(", ")) +} + +fn list_sql( + entity: &CompiledEntity, + relations: &ReadRelations, + query: &crate::api::CompiledReadQuery, + projection: &str, +) -> Result<(String, String, Vec), ReadServiceError> { + let mut values = Vec::new(); + let mut predicates = relations.base_predicates.clone(); + if let Some(filter) = &query.filter { + predicates.push(filter_sql(entity, relations, filter, &mut values)?); } if let Some(instant) = &query.temporal_instant { let temporal = entity @@ -1105,8 +1752,10 @@ fn list_sql( .fields .get(&temporal.end_field) .ok_or(ReadServiceError::Unavailable)?; - let start = quote_identifier(&start_field.physical_name); - let end = quote_identifier(&end_field.physical_name); + let start = relations + .field_expression(entity, &temporal.start_field)? + .sql; + let end = relations.field_expression(entity, &temporal.end_field)?.sql; let parameter = push_value(&mut values, instant); let instant_expression = temporal_instant_expression(&start_field.field_type, &end_field.field_type, parameter)?; @@ -1124,55 +1773,208 @@ fn list_sql( return Err(ReadServiceError::CursorInvalid); } let record_parameter = push_value(&mut values, &continuation.last_record_id); - if let Some(sort) = &query.sort { - let Some(compiled_field) = entity.fields.get(sort) else { - return Err(ReadServiceError::Unavailable); - }; - let column = quote_identifier(&compiled_field.physical_name); - let cast = postgres_cast(&compiled_field.field_type); + if let Some(order) = &query.order { + let field = relations.field_expression(entity, &order.field_id)?; + let cast = postgres_cast(&field.field_type); match &continuation.sort_value { Some(value) => { - validate_field_value(value, &compiled_field.field_type) + validate_field_value(value, &field.field_type) .map_err(|_| ReadServiceError::CursorInvalid)?; let sort_parameter = push_value(&mut values, value); predicates.push(format!( - "({column} > ${sort_parameter}::text::{cast} OR {column} IS NULL OR ({column} = ${sort_parameter}::text::{cast} AND record_id > ${record_parameter}::text::uuid))" + "({column} > ${sort_parameter}::text::{cast} OR {column} IS NULL OR ({column} = ${sort_parameter}::text::{cast} AND {id} > ${record_parameter}::text::uuid))", + column = field.sql, + id = relations.id_expression )); } None => predicates.push(format!( - "({column} IS NULL AND record_id > ${record_parameter}::text::uuid)" + "({column} IS NULL AND {id} > ${record_parameter}::text::uuid)", + column = field.sql, + id = relations.id_expression )), } } else { - predicates.push(format!("record_id > ${record_parameter}::text::uuid")); + predicates.push(format!( + "{} > ${record_parameter}::text::uuid", + relations.id_expression + )); } } - let order = if let Some(sort) = &query.sort { - let column = quote_identifier( - &entity - .fields - .get(sort) - .ok_or(ReadServiceError::Unavailable)? - .physical_name, - ); - format!("{column} ASC NULLS LAST, record_id ASC") + let where_sql = if predicates.is_empty() { + "TRUE".to_owned() + } else { + predicates.join(" AND ") + }; + let order = if let Some(order) = &query.order { + let field = relations.field_expression(entity, &order.field_id)?; + format!( + "{} ASC NULLS LAST, {} ASC", + field.sql, relations.id_expression + ) } else { - "record_id ASC".to_owned() + format!("{} ASC", relations.id_expression) }; let limit_parameter = values.len() + 1; Ok(( format!( "SELECT {projection} - FROM registry_data.{table} - WHERE {} + FROM {} + WHERE {where_sql} ORDER BY {order} LIMIT ${limit_parameter}::bigint", - predicates.join(" AND ") + relations.from_sql + ), + format!( + "SELECT count(*)::bigint + FROM {} + WHERE {where_sql}", + relations.from_sql ), values, )) } +fn lookup_sql( + entity: &CompiledEntity, + relations: &ReadRelations, + selector_values: &[LookupSelectorValue], + projection: &str, +) -> Result<(String, Vec), ReadServiceError> { + if selector_values.is_empty() { + return Err(ReadServiceError::Unavailable); + } + let mut values = Vec::new(); + let mut predicates = relations.base_predicates.clone(); + for selector in selector_values { + let field = relations.field_expression(entity, &selector.field_id)?; + if field.field_type != selector.field_type { + return Err(ReadServiceError::Unavailable); + } + validate_field_value(&selector.value, &field.field_type) + .map_err(|_| ReadServiceError::Unavailable)?; + let parameter = push_value(&mut values, &selector.value); + predicates.push(format!( + "{} = ${parameter}::text::{}", + field.sql, + postgres_cast(&field.field_type) + )); + } + let where_sql = predicates.join(" AND "); + let limit_parameter = values.len() + 1; + Ok(( + format!( + "SELECT {projection} + FROM {} + WHERE {where_sql} + ORDER BY {} + LIMIT ${limit_parameter}::bigint", + relations.from_sql, relations.id_expression, + ), + values, + )) +} + +fn json_expression(expression: &str, field_type: &FieldTypeSource) -> String { + if matches!(field_type, FieldTypeSource::Decimal { .. }) { + format!("to_jsonb({expression}::text)") + } else { + format!("to_jsonb({expression})") + } +} + +fn filter_sql( + entity: &CompiledEntity, + relations: &ReadRelations, + filter: &ReadFilterExpr, + values: &mut Vec, +) -> Result { + match filter { + ReadFilterExpr::Binary { op, left, right } => { + let operator = match op { + ReadLogicalOp::And => "AND", + ReadLogicalOp::Or => "OR", + }; + Ok(format!( + "({} {operator} {})", + filter_sql(entity, relations, left, values)?, + filter_sql(entity, relations, right, values)? + )) + } + ReadFilterExpr::Not(expr) => Ok(format!( + "(NOT {})", + filter_sql(entity, relations, expr, values)? + )), + ReadFilterExpr::Group(expr) => Ok(format!( + "({})", + filter_sql(entity, relations, expr, values)? + )), + ReadFilterExpr::Predicate(predicate) => predicate_sql(entity, relations, predicate, values), + } +} + +fn predicate_sql( + entity: &CompiledEntity, + relations: &ReadRelations, + predicate: &ReadFilterPredicate, + values: &mut Vec, +) -> Result { + let field = relations.field_expression(entity, &predicate.field_id)?; + if field.field_type != predicate.field_type { + return Err(ReadServiceError::Unavailable); + } + let cast = postgres_cast(&field.field_type); + match predicate.operator { + ReadFilterOperator::Eq => { + let parameter = push_value(values, &predicate.values[0]); + Ok(format!("{} = ${parameter}::text::{cast}", field.sql)) + } + ReadFilterOperator::Ne => { + let parameter = push_value(values, &predicate.values[0]); + Ok(format!("{} <> ${parameter}::text::{cast}", field.sql)) + } + ReadFilterOperator::Lt => { + let parameter = push_value(values, &predicate.values[0]); + Ok(format!("{} < ${parameter}::text::{cast}", field.sql)) + } + ReadFilterOperator::Le => { + let parameter = push_value(values, &predicate.values[0]); + Ok(format!("{} <= ${parameter}::text::{cast}", field.sql)) + } + ReadFilterOperator::Gt => { + let parameter = push_value(values, &predicate.values[0]); + Ok(format!("{} > ${parameter}::text::{cast}", field.sql)) + } + ReadFilterOperator::Ge => { + let parameter = push_value(values, &predicate.values[0]); + Ok(format!("{} >= ${parameter}::text::{cast}", field.sql)) + } + ReadFilterOperator::In => { + if predicate.values.is_empty() { + return Err(ReadServiceError::Unavailable); + } + let placeholders = predicate + .values + .iter() + .map(|value| { + let parameter = push_value(values, value); + format!("${parameter}::text::{cast}") + }) + .collect::>(); + Ok(format!("{} IN ({})", field.sql, placeholders.join(", "))) + } + ReadFilterOperator::IsNull => Ok(format!("{} IS NULL", field.sql)), + ReadFilterOperator::IsNotNull => Ok(format!("{} IS NOT NULL", field.sql)), + ReadFilterOperator::StartsWith => { + let parameter = push_value(values, &format!("{}%", escape_like(&predicate.values[0]))); + Ok(format!("{} LIKE ${parameter}::text ESCAPE '\\'", field.sql)) + } + ReadFilterOperator::Contains => { + let parameter = push_value(values, &format!("%{}%", escape_like(&predicate.values[0]))); + Ok(format!("{} LIKE ${parameter}::text ESCAPE '\\'", field.sql)) + } + } +} + fn temporal_instant_expression( start_type: &FieldTypeSource, end_type: &FieldTypeSource, @@ -1247,7 +2049,7 @@ fn row_to_record( let revision = u64::try_from(revision).map_err(|_| ReadServiceError::Unavailable)?; let mut data = Map::new(); for (index, field) in selected_fields.iter().enumerate() { - if !entity.fields.contains_key(field) { + if compiled_field_type(entity, field).is_none() { return Err(ReadServiceError::Unavailable); } let value = row @@ -1293,6 +2095,10 @@ fn quote_identifier(value: &str) -> String { format!("\"{value}\"") } +fn sql_quote_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + fn valid_canonical_uuid(value: &str) -> bool { value.len() == 36 && value.bytes().enumerate().all(|(index, byte)| match index { @@ -1308,12 +2114,18 @@ mod tests { use std::time::Duration; use crate::api::{ - AuthorizedRequestContext, CompiledReadQuery, ReadFilterClause, RecordReadRequest, + AuthorizedRequestContext, CompiledReadQuery, ReadFilterExpr, ReadFilterOperator, + ReadFilterPredicate, ReadOrderClause, ReadProjectionField, RecordReadKind, + RecordReadRequest, }; use crate::compiler::{compile_project, CompileProfile}; - use crate::contract::{parse_project_json, FieldTypeSource, Operation}; - use crate::cursor::{CursorBinding, CursorCodec, CursorContinuation, CursorQuery}; - use crate::model::{CompiledQueryFilterOperator, CompiledQueryKind, HttpMethod}; + use crate::contract::{parse_project_json, FieldTypeSource}; + use crate::cursor::{ + CursorBinding, CursorCodec, CursorContinuation, CursorFilterExpr, CursorFilterOperator, + CursorFilterPredicate, CursorOrderClause, CursorProjectionField, CursorQuery, + CursorQueryScope, + }; + use crate::model::{CompiledQueryKind, CompiledQuerySortDirection, HttpMethod}; use zeroize::Zeroizing; use super::{ @@ -1391,115 +2203,156 @@ mod tests { entity_id: "case".to_owned(), operation_id: "records.case.list".to_owned(), method: HttpMethod::Get, - record_id: None, context: AuthorizedRequestContext::new(None, None, "public".to_owned(), Vec::new()), selected_fields: BTreeSet::from(["label".to_owned()]), - query: Some(CompiledReadQuery { - route_id: operation.route_id.clone(), - query_operation_id: operation.id.clone(), - kind: CompiledQueryKind::List, - cursor_binding: CursorBinding { - package_revision: expected.package_revision.clone(), - schema_fingerprint: expected.schema_fingerprint.clone(), - registry_revision: registry.revision().to_owned(), + kind: RecordReadKind::List { + plan: CompiledReadQuery { route_id: operation.route_id.clone(), query_operation_id: operation.id.clone(), - query_kind: CompiledQueryKind::List, - selected_profile: "public".to_owned(), - principal_reference: None, - purpose_reference: None, - row_boundary_reference: digest(), - projection_reference: digest(), - query_reference: digest(), - sort_reference: digest(), + kind: CompiledQueryKind::List, + cursor_binding: CursorBinding { + package_revision: expected.package_revision.clone(), + schema_fingerprint: expected.schema_fingerprint.clone(), + registry_revision: registry.revision().to_owned(), + route_id: operation.route_id.clone(), + query_operation_id: operation.id.clone(), + query_kind: CompiledQueryKind::List, + selected_profile: "public".to_owned(), + principal_reference: None, + purpose_reference: None, + row_boundary_reference: digest(), + projection_reference: digest(), + query_reference: digest(), + sort_reference: digest(), + scope_reference: digest(), + page_size: 10, + include_count: false, + temporal_instant: None, + selected_fields: vec!["label".to_owned()], + }, + cursor_query: CursorQuery { + projection: vec![CursorProjectionField { + field_id: "label".to_owned(), + field_type: FieldTypeSource::String { + min_length: 0, + max_length: 32, + }, + }], + filter: Some(CursorFilterExpr::Predicate { + predicate: CursorFilterPredicate { + field_id: "secret".to_owned(), + field_type: FieldTypeSource::String { + min_length: 0, + max_length: 32, + }, + operator: CursorFilterOperator::Eq, + values: vec!["hidden".to_owned()], + }, + }), + order: None, + include_count: false, + page_size: 10, + temporal_instant: None, + scope: CursorQueryScope::Collection {}, + }, + projection: vec![ReadProjectionField { + field_id: "label".to_owned(), + field_type: FieldTypeSource::String { + min_length: 0, + max_length: 32, + }, + }], + filter: Some(ReadFilterExpr::Predicate(ReadFilterPredicate { + field_id: "secret".to_owned(), + field_type: FieldTypeSource::String { + min_length: 0, + max_length: 32, + }, + operator: ReadFilterOperator::Eq, + values: vec!["hidden".to_owned()], + })), + order: None, + include_count: false, page_size: 10, temporal_instant: None, - selected_fields: vec!["label".to_owned()], + continuation: None, }, - cursor_query: CursorQuery { - filters: Vec::new(), - sort: None, - }, - filters: vec![ReadFilterClause { - field: "secret".to_owned(), - operator: CompiledQueryFilterOperator::Equals, - values: vec!["hidden".to_owned()], - }], - sort: None, - page_size: 10, - temporal_instant: None, - continuation: None, - }), + }, maximum_records: 11, }; - assert!( - ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) - .is_err() - ); + assert!(ReadPlan::from_request(®istry, &expected, &cursors, &request).is_err()); - let query = request.query.as_mut().expect("query present"); - query.filters.clear(); - query.sort = Some("secret".to_owned()); - query.cursor_query.sort = Some("secret".to_owned()); - assert!( - ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) - .is_err() - ); + let query = query_mut(&mut request); + query.filter = None; + query.cursor_query.filter = None; + query.order = Some(ReadOrderClause { + field_id: "secret".to_owned(), + field_type: FieldTypeSource::String { + min_length: 0, + max_length: 32, + }, + direction: CompiledQuerySortDirection::Asc, + }); + query.cursor_query.order = Some(CursorOrderClause { + field_id: "secret".to_owned(), + field_type: FieldTypeSource::String { + min_length: 0, + max_length: 32, + }, + direction: CompiledQuerySortDirection::Asc, + }); + assert!(ReadPlan::from_request(®istry, &expected, &cursors, &request).is_err()); - let query = request.query.as_mut().expect("query present"); - query.sort = None; - query.cursor_query.sort = None; + let query = query_mut(&mut request); + query.order = None; + query.cursor_query.order = None; query.cursor_binding.query_reference = "hidden-raw-query-value".to_owned(); - assert!( - ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) - .is_err() - ); + assert!(ReadPlan::from_request(®istry, &expected, &cursors, &request).is_err()); - let query = request.query.as_mut().expect("query present"); + let query = query_mut(&mut request); query.cursor_binding.query_reference = digest(); query.continuation = Some(CursorContinuation { last_record_id: "not-a-canonical-uuid".to_owned(), sort_value: None, }); - assert!( - ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) - .is_err() - ); + assert!(ReadPlan::from_request(®istry, &expected, &cursors, &request).is_err()); - let query = request.query.as_mut().expect("query present"); + let query = query_mut(&mut request); query.continuation = None; - let references = cursor_binding_references( - &cursors, - &request, - operation, - request.query.as_ref().expect("query present"), - ) - .expect("bounded request context has cursor references"); - let query = request.query.as_mut().expect("query present"); + let references = + cursor_binding_references(&cursors, &request, operation, query_ref(&request)) + .expect("bounded request context has cursor references"); + let query = query_mut(&mut request); query.cursor_binding.principal_reference = references.principal; query.cursor_binding.purpose_reference = references.purpose; query.cursor_binding.row_boundary_reference = references.row_boundary; query.cursor_binding.projection_reference = references.projection; query.cursor_binding.query_reference = references.query; query.cursor_binding.sort_reference = references.sort; - assert!( - ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) - .is_ok() - ); + query.cursor_binding.scope_reference = references.scope; + assert!(ReadPlan::from_request(®istry, &expected, &cursors, &request).is_ok()); - request - .query - .as_mut() - .expect("query present") - .cursor_binding - .query_reference = digest(); + query_mut(&mut request).cursor_binding.query_reference = digest(); assert!( - ReadPlan::from_request(®istry, &expected, &cursors, &request, Operation::List) - .is_err(), + ReadPlan::from_request(®istry, &expected, &cursors, &request).is_err(), "a well-shaped forged binding digest fails before SQL construction" ); } + fn query_ref(request: &RecordReadRequest) -> &CompiledReadQuery { + match &request.kind { + RecordReadKind::List { plan } => plan, + _ => unreachable!("test request is a list"), + } + } + + fn query_mut(request: &mut RecordReadRequest) -> &mut CompiledReadQuery { + match &mut request.kind { + RecordReadKind::List { plan } => plan, + _ => unreachable!("test request is a list"), + } + } + fn digest() -> String { format!("hmac-sha256:{}", "0".repeat(64)) } diff --git a/crates/registry-server/src/postgres/roles.rs b/crates/registry-server/src/postgres/roles.rs index f96ec40025..93fd2a0587 100644 --- a/crates/registry-server/src/postgres/roles.rs +++ b/crates/registry-server/src/postgres/roles.rs @@ -44,12 +44,18 @@ impl fmt::Display for QuotedIdentifier<'_> { } } -/// Admin-only provisioning of the two managed schemas. +/// Admin-only provisioning of the managed schemas. pub async fn provision_managed_schemas( admin: &impl GenericClient, migration_role: &SqlIdentifier, ) -> Result<()> { - for schema in ["registry_internal", "registry_data"] { + for schema in [ + "registry_internal", + "registry_data", + "registry_source", + "registry_derived", + "registry_context", + ] { admin .batch_execute(&format!( "CREATE SCHEMA {schema} AUTHORIZATION {};\n\ @@ -125,11 +131,20 @@ pub async fn verify_runtime_role( has_database_privilege(current_user, current_database(), 'CREATE'), has_schema_privilege(current_user, 'registry_internal', 'CREATE'), has_schema_privilege(current_user, 'registry_data', 'CREATE'), + has_schema_privilege(current_user, 'registry_source', 'CREATE'), + has_schema_privilege(current_user, 'registry_derived', 'CREATE'), + has_schema_privilege(current_user, 'registry_context', 'CREATE'), EXISTS ( SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname IN ( + 'registry_internal', + 'registry_data', + 'registry_source', + 'registry_derived', + 'registry_context' + ) AND c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user) ) FROM pg_catalog.pg_roles @@ -137,7 +152,7 @@ pub async fn verify_runtime_role( &[&migration_role.as_str()], ) .await?; - let forbidden = (1..=10).any(|index| row.get::<_, bool>(index)); + let forbidden = (1..=13).any(|index| row.get::<_, bool>(index)); if forbidden { return Err(PostgresKernelError::RoleInvariant( "runtime role has ownership, bypass, or DDL authority", @@ -178,7 +193,13 @@ async fn verify_schema_owner( client: &impl GenericClient, expected_role: &SqlIdentifier, ) -> Result<()> { - let managed_schemas: &[&str] = &["registry_data", "registry_internal"]; + let managed_schemas: &[&str] = &[ + "registry_data", + "registry_derived", + "registry_context", + "registry_internal", + "registry_source", + ]; let rows = client .query( "SELECT n.nspname, r.rolname @@ -189,7 +210,7 @@ async fn verify_schema_owner( &[&managed_schemas], ) .await?; - if rows.len() != 2 + if rows.len() != managed_schemas.len() || rows .iter() .any(|row| row.get::<_, String>(1) != expected_role.as_str()) diff --git a/crates/registry-server/src/postgres/schema.rs b/crates/registry-server/src/postgres/schema.rs index 892e97d007..d3bfe4ac64 100644 --- a/crates/registry-server/src/postgres/schema.rs +++ b/crates/registry-server/src/postgres/schema.rs @@ -30,12 +30,13 @@ use super::{ /// Installs one exact compiled Registry data inventory and its closed runtime /// privilege set. The caller must already be the verified migration role and -/// must own both managed schemas. +/// must own every managed schema. pub async fn install_compiled_schema( migration: &impl GenericClient, registry: &CompiledRegistry, runtime_role: &SqlIdentifier, ) -> Result<()> { + verify_postgres_15_or_newer(migration).await?; if registry.ddl().requires_btree_gist { verify_btree_gist(migration).await?; } @@ -62,8 +63,8 @@ pub(crate) async fn reconcile_compiled_runtime_acl( ) -> Result<()> { client .batch_execute(&format!( - "REVOKE ALL ON SCHEMA registry_data FROM PUBLIC, {}; - GRANT USAGE ON SCHEMA registry_data TO {};", + "REVOKE ALL ON SCHEMA registry_data, registry_source, registry_derived, registry_context FROM PUBLIC, {}; + GRANT USAGE ON SCHEMA registry_data, registry_source, registry_derived, registry_context TO {};", runtime_role.quoted(), runtime_role.quoted(), )) @@ -92,6 +93,66 @@ pub(crate) async fn reconcile_compiled_runtime_acl( .await?; } } + for view in ®istry.ddl().views { + let schema = quote_compiled_identifier(&view.schema); + let view_name = quote_compiled_identifier(&view.name); + client + .batch_execute(&format!( + "REVOKE ALL ON TABLE {schema}.{view_name} FROM PUBLIC, {};", + runtime_role.quoted(), + )) + .await?; + if !view.runtime_privileges.is_empty() { + let privileges = view + .runtime_privileges + .iter() + .map(|privilege| privilege.as_sql()) + .collect::>() + .join(", "); + client + .batch_execute(&format!( + "GRANT {privileges} ON TABLE {schema}.{view_name} TO {};", + runtime_role.quoted(), + )) + .await?; + } + } + for function in ®istry.ddl().functions { + let schema = quote_compiled_identifier(&function.schema); + let name = quote_compiled_identifier(&function.name); + client + .batch_execute(&format!( + "REVOKE ALL ON FUNCTION {schema}.{name}({}) FROM PUBLIC, {};", + function.arguments, + runtime_role.quoted(), + )) + .await?; + if function.runtime_execute { + client + .batch_execute(&format!( + "GRANT EXECUTE ON FUNCTION {schema}.{name}({}) TO {};", + function.arguments, + runtime_role.quoted(), + )) + .await?; + } + } + Ok(()) +} + +pub(crate) async fn verify_postgres_15_or_newer(client: &impl GenericClient) -> Result<()> { + let version_num: String = client + .query_one("SELECT current_setting('server_version_num')", &[]) + .await? + .get(0); + let version_num = version_num + .parse::() + .map_err(|_| PostgresKernelError::Configuration("PostgreSQL 15 or newer is required"))?; + if version_num < 150_000 { + return Err(PostgresKernelError::Configuration( + "PostgreSQL 15 or newer is required", + )); + } Ok(()) } @@ -341,7 +402,13 @@ async fn refuse_existing_managed_objects(client: &impl GenericClient) -> Result< .batch_execute("SAVEPOINT registry_empty_schema_probe") .await?; let empty = client - .batch_execute("DROP SCHEMA registry_internal RESTRICT; DROP SCHEMA registry_data RESTRICT") + .batch_execute( + "DROP SCHEMA registry_internal RESTRICT; + DROP SCHEMA registry_data RESTRICT; + DROP SCHEMA registry_source RESTRICT; + DROP SCHEMA registry_derived RESTRICT; + DROP SCHEMA registry_context RESTRICT", + ) .await .is_ok(); client @@ -376,11 +443,20 @@ async fn verify_schema_test_runtime_role( has_database_privilege(current_user, current_database(), 'CREATE'), has_schema_privilege(current_user, 'registry_internal', 'CREATE'), has_schema_privilege(current_user, 'registry_data', 'CREATE'), + has_schema_privilege(current_user, 'registry_source', 'CREATE'), + has_schema_privilege(current_user, 'registry_derived', 'CREATE'), + has_schema_privilege(current_user, 'registry_context', 'CREATE'), EXISTS ( SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname IN ( + 'registry_internal', + 'registry_data', + 'registry_source', + 'registry_derived', + 'registry_context' + ) AND c.relowner = ( SELECT oid FROM pg_catalog.pg_roles @@ -393,7 +469,7 @@ async fn verify_schema_test_runtime_role( ) .await?; if row.get::<_, String>(0) != runtime_role.as_str() - || (1..=10).any(|index| row.get::<_, bool>(index)) + || (1..=13).any(|index| row.get::<_, bool>(index)) { return Err(PostgresKernelError::RoleInvariant( "schema-test runtime connection uses unexpected authority", diff --git a/crates/registry-server/src/query.rs b/crates/registry-server/src/query.rs new file mode 100644 index 0000000000..01abd2eb51 --- /dev/null +++ b/crates/registry-server/src/query.rs @@ -0,0 +1,1492 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Strict syntactic parsing for the Registry REST read query profile. +//! +//! This module deliberately stops at syntax. It preserves caller-facing field +//! identifiers and literal token kinds for the later authorization and planning +//! stages, but it does not resolve fields, infer types, or render SQL. + +use std::collections::BTreeSet; +use std::fmt; + +pub const MAX_QUERY_PAYLOAD_BYTES: usize = 16 * 1024; +pub const MAX_FILTER_DEPTH: usize = 16; +pub const MAX_FILTER_NODES: usize = 128; +pub const MAX_FILTER_PREDICATES: usize = 32; +pub const MAX_IN_VALUES: usize = 100; +pub const MAX_SELECTED_FIELDS: usize = 128; +pub const MAX_IDENTIFIER_BYTES: usize = 128; +pub const MAX_LITERAL_BYTES: usize = 1024; +pub const MAX_OPAQUE_VALUE_BYTES: usize = 4096; +pub const MAX_TOP: u32 = 100; + +#[derive(Clone, Eq, PartialEq)] +pub struct ParsedReadQuery { + pub access_profile: Option, + pub as_of: Option, + pub mode: ParsedReadQueryMode, +} + +#[derive(Clone, Eq, PartialEq)] +pub enum ParsedReadQueryMode { + Query(ReadQueryOptions), + SkipToken { token: String }, +} + +#[derive(Clone, Default, Eq, PartialEq)] +pub struct ReadQueryOptions { + pub select: Option, + pub filter: Option, + pub orderby: Option, + pub top: Option, + pub count: Option, +} + +#[derive(Clone, Eq, PartialEq)] +pub struct SelectClause { + fields: Vec, +} + +#[derive(Clone, Eq, PartialEq)] +pub struct OrderByClause { + pub field: ApiIdentifier, + pub direction: OrderDirection, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OrderDirection { + Asc, + Desc, +} + +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct ApiIdentifier(String); + +#[derive(Clone, Eq, PartialEq)] +pub enum FilterExpr { + Binary { + op: LogicalOp, + left: Box, + right: Box, + }, + Not(Box), + Group(Box), + Predicate(FilterPredicate), +} + +#[derive(Clone, Eq, PartialEq)] +pub enum FilterPredicate { + Compare { + field: ApiIdentifier, + op: ComparisonOp, + literal: Literal, + }, + In { + field: ApiIdentifier, + values: Vec, + }, + Function { + function: StringFunction, + field: ApiIdentifier, + literal: Literal, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LogicalOp { + And, + Or, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ComparisonOp { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StringFunction { + StartsWith, + Contains, +} + +#[derive(Clone, Eq, PartialEq)] +pub enum Literal { + String(String), + Integer(String), + Decimal(String), + Boolean(bool), + Null, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum QueryParseError { + PayloadTooLarge, + UnknownOption, + DisallowedOption, + DuplicateOption, + ConflictingOptions, + InvalidValue, + InvalidFilterSyntax, + QueryTooComplex, +} + +impl fmt::Display for QueryParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + QueryParseError::PayloadTooLarge => "query payload is too large", + QueryParseError::UnknownOption => "query option is not recognized", + QueryParseError::DisallowedOption => "query option is not allowed", + QueryParseError::DuplicateOption => "query option is duplicated", + QueryParseError::ConflictingOptions => "query options conflict", + QueryParseError::InvalidValue => "query option value is invalid", + QueryParseError::InvalidFilterSyntax => "query filter syntax is invalid", + QueryParseError::QueryTooComplex => "query is too complex", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for QueryParseError {} + +impl fmt::Debug for ParsedReadQuery { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ParsedReadQuery") + .field( + "access_profile", + &self.access_profile.as_ref().map(|_| ""), + ) + .field("as_of", &self.as_of.as_ref().map(|_| "")) + .field("mode", &self.mode) + .finish() + } +} + +impl fmt::Debug for ParsedReadQueryMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParsedReadQueryMode::Query(options) => { + formatter.debug_tuple("Query").field(options).finish() + } + ParsedReadQueryMode::SkipToken { token: _ } => formatter + .debug_struct("SkipToken") + .field("token", &"") + .finish(), + } + } +} + +impl fmt::Debug for ReadQueryOptions { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReadQueryOptions") + .field("select", &self.select) + .field("filter", &self.filter) + .field("orderby", &self.orderby) + .field("top", &self.top) + .field("count", &self.count) + .finish() + } +} + +impl fmt::Debug for SelectClause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SelectClause") + .field("field_count", &self.fields.len()) + .finish() + } +} + +impl fmt::Debug for OrderByClause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OrderByClause") + .field("field", &"") + .field("direction", &self.direction) + .finish() + } +} + +impl fmt::Debug for ApiIdentifier { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ApiIdentifier()") + } +} + +impl fmt::Debug for FilterExpr { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FilterExpr::Binary { op, left, right } => formatter + .debug_struct("Binary") + .field("op", op) + .field("left", left) + .field("right", right) + .finish(), + FilterExpr::Not(expr) => formatter.debug_tuple("Not").field(expr).finish(), + FilterExpr::Group(expr) => formatter.debug_tuple("Group").field(expr).finish(), + FilterExpr::Predicate(predicate) => { + formatter.debug_tuple("Predicate").field(predicate).finish() + } + } + } +} + +impl fmt::Debug for FilterPredicate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FilterPredicate::Compare { field, op, literal } => formatter + .debug_struct("Compare") + .field("field", field) + .field("op", op) + .field("literal", literal) + .finish(), + FilterPredicate::In { field, values } => formatter + .debug_struct("In") + .field("field", field) + .field("values", values) + .finish(), + FilterPredicate::Function { + function, + field, + literal, + } => formatter + .debug_struct("Function") + .field("function", function) + .field("field", field) + .field("literal", literal) + .finish(), + } + } +} + +impl fmt::Debug for Literal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Literal::String(_) => formatter.write_str("String()"), + Literal::Integer(_) => formatter.write_str("Integer()"), + Literal::Decimal(_) => formatter.write_str("Decimal()"), + Literal::Boolean(_) => formatter.write_str("Boolean()"), + Literal::Null => formatter.write_str("Null"), + } + } +} + +impl ParsedReadQuery { + pub fn canonical(&self) -> String { + let mut output = String::from("registry-read-query:v2;"); + push_optional_atom(&mut output, "accessProfile", self.access_profile.as_deref()); + output.push(';'); + push_optional_atom(&mut output, "asOf", self.as_of.as_deref()); + output.push(';'); + match &self.mode { + ParsedReadQueryMode::SkipToken { token } => { + output.push_str("mode=skiptoken;"); + push_atom(&mut output, "token", token); + } + ParsedReadQueryMode::Query(options) => { + output.push_str("mode=query;"); + options.push_canonical(&mut output); + } + } + output + } +} + +impl ReadQueryOptions { + fn has_any_option(&self) -> bool { + self.select.is_some() + || self.filter.is_some() + || self.orderby.is_some() + || self.top.is_some() + || self.count.is_some() + } + + fn push_canonical(&self, output: &mut String) { + output.push_str("select="); + match &self.select { + Some(select) => select.push_canonical(output), + None => output.push_str("none"), + } + output.push_str(";filter="); + match &self.filter { + Some(filter) => filter.push_canonical(output), + None => output.push_str("none"), + } + output.push_str(";orderby="); + match &self.orderby { + Some(orderby) => orderby.push_canonical(output), + None => output.push_str("none"), + } + output.push_str(";top="); + match self.top { + Some(top) => output.push_str(&top.to_string()), + None => output.push_str("none"), + } + output.push_str(";count="); + match self.count { + Some(count) => output.push_str(if count { "true" } else { "false" }), + None => output.push_str("none"), + } + } +} + +impl SelectClause { + pub fn fields(&self) -> &[ApiIdentifier] { + &self.fields + } + + fn push_canonical(&self, output: &mut String) { + output.push('['); + for (index, field) in self.fields.iter().enumerate() { + if index > 0 { + output.push(','); + } + field.push_canonical(output); + } + output.push(']'); + } +} + +impl ApiIdentifier { + pub fn as_str(&self) -> &str { + &self.0 + } + + fn parse(value: &str) -> Result { + if valid_identifier(value) { + Ok(Self(value.to_owned())) + } else { + Err(QueryParseError::InvalidValue) + } + } + + fn push_canonical(&self, output: &mut String) { + push_atom(output, "id", &self.0); + } +} + +impl OrderByClause { + fn push_canonical(&self, output: &mut String) { + output.push('('); + self.field.push_canonical(output); + output.push(','); + output.push_str(match self.direction { + OrderDirection::Asc => "asc", + OrderDirection::Desc => "desc", + }); + output.push(')'); + } +} + +impl FilterExpr { + fn push_canonical(&self, output: &mut String) { + match self { + FilterExpr::Binary { op, left, right } => { + output.push_str(match op { + LogicalOp::And => "and(", + LogicalOp::Or => "or(", + }); + left.push_canonical(output); + output.push(','); + right.push_canonical(output); + output.push(')'); + } + FilterExpr::Not(expr) => { + output.push_str("not("); + expr.push_canonical(output); + output.push(')'); + } + FilterExpr::Group(expr) => { + output.push_str("group("); + expr.push_canonical(output); + output.push(')'); + } + FilterExpr::Predicate(predicate) => predicate.push_canonical(output), + } + } +} + +impl FilterPredicate { + fn push_canonical(&self, output: &mut String) { + match self { + FilterPredicate::Compare { field, op, literal } => { + output.push_str(match op { + ComparisonOp::Eq => "eq(", + ComparisonOp::Ne => "ne(", + ComparisonOp::Lt => "lt(", + ComparisonOp::Le => "le(", + ComparisonOp::Gt => "gt(", + ComparisonOp::Ge => "ge(", + }); + field.push_canonical(output); + output.push(','); + literal.push_canonical(output); + output.push(')'); + } + FilterPredicate::In { field, values } => { + output.push_str("in("); + field.push_canonical(output); + output.push_str(",["); + for (index, value) in values.iter().enumerate() { + if index > 0 { + output.push(','); + } + value.push_canonical(output); + } + output.push_str("])"); + } + FilterPredicate::Function { + function, + field, + literal, + } => { + output.push_str(match function { + StringFunction::StartsWith => "startswith(", + StringFunction::Contains => "contains(", + }); + field.push_canonical(output); + output.push(','); + literal.push_canonical(output); + output.push(')'); + } + } + } +} + +impl Literal { + fn push_canonical(&self, output: &mut String) { + match self { + Literal::String(value) => push_atom(output, "str", value), + Literal::Integer(value) => push_atom(output, "int", value), + Literal::Decimal(value) => push_atom(output, "dec", value), + Literal::Boolean(true) => output.push_str("bool(true)"), + Literal::Boolean(false) => output.push_str("bool(false)"), + Literal::Null => output.push_str("null"), + } + } +} + +pub fn parse_read_query(pairs: I) -> Result +where + I: IntoIterator, + K: AsRef, + V: AsRef, +{ + let mut builder = QueryBuilder::default(); + let mut payload_bytes = 0_usize; + + for (key, value) in pairs { + let key = key.as_ref(); + let value = value.as_ref(); + payload_bytes = payload_bytes + .checked_add(key.len()) + .and_then(|bytes| bytes.checked_add(value.len())) + .and_then(|bytes| bytes.checked_add(2)) + .ok_or(QueryParseError::PayloadTooLarge)?; + if payload_bytes > MAX_QUERY_PAYLOAD_BYTES { + return Err(QueryParseError::PayloadTooLarge); + } + builder.apply(key, value)?; + } + + builder.finish() +} + +pub fn parse_filter(value: &str) -> Result { + if value.is_empty() || value.len() > MAX_QUERY_PAYLOAD_BYTES { + return Err(QueryParseError::InvalidFilterSyntax); + } + let tokens = Lexer::new(value).lex()?; + let mut parser = FilterParser::new(tokens); + let filter = parser.parse_or(0)?; + parser.expect_end()?; + validate_filter_depth(&filter, 1)?; + Ok(filter) +} + +#[derive(Default)] +struct QueryBuilder { + access_profile: Option, + as_of: Option, + select: Option, + filter: Option, + orderby: Option, + top: Option, + count: Option, + skiptoken: Option, +} + +impl QueryBuilder { + fn apply(&mut self, key: &str, value: &str) -> Result<(), QueryParseError> { + match key { + "accessProfile" => { + ensure_absent(self.access_profile.is_none())?; + self.access_profile = Some(ApiIdentifier::parse(value)?.0); + } + "asOf" => { + ensure_absent(self.as_of.is_none())?; + self.as_of = Some(parse_bounded_scalar(value)?); + } + "$select" => { + ensure_absent(self.select.is_none())?; + self.select = Some(parse_select(value)?); + } + "$filter" => { + ensure_absent(self.filter.is_none())?; + self.filter = Some(parse_filter(value)?); + } + "$orderby" => { + ensure_absent(self.orderby.is_none())?; + self.orderby = Some(parse_orderby(value)?); + } + "$top" => { + ensure_absent(self.top.is_none())?; + self.top = Some(parse_top(value)?); + } + "$count" => { + ensure_absent(self.count.is_none())?; + self.count = Some(parse_count(value)?); + } + "$skiptoken" => { + ensure_absent(self.skiptoken.is_none())?; + self.skiptoken = Some(parse_opaque_value(value)?); + } + "fields" | "filter" | "sort" | "pageSize" | "cursor" | "$skip" | "$apply" + | "$expand" | "$batch" => return Err(QueryParseError::DisallowedOption), + "$query" | "query" | "sql" | "statement" => { + return Err(QueryParseError::DisallowedOption); + } + _ => return Err(QueryParseError::UnknownOption), + } + Ok(()) + } + + fn finish(self) -> Result { + let options = ReadQueryOptions { + select: self.select, + filter: self.filter, + orderby: self.orderby, + top: self.top, + count: self.count, + }; + let mode = match self.skiptoken { + Some(token) => { + if self.as_of.is_some() || options.has_any_option() { + return Err(QueryParseError::ConflictingOptions); + } + ParsedReadQueryMode::SkipToken { token } + } + None => ParsedReadQueryMode::Query(options), + }; + Ok(ParsedReadQuery { + access_profile: self.access_profile, + as_of: self.as_of, + mode, + }) + } +} + +fn ensure_absent(absent: bool) -> Result<(), QueryParseError> { + if absent { + Ok(()) + } else { + Err(QueryParseError::DuplicateOption) + } +} + +fn parse_select(value: &str) -> Result { + if value.is_empty() { + return Err(QueryParseError::InvalidValue); + } + let mut fields = Vec::new(); + let mut seen = BTreeSet::new(); + for field in value.split(',') { + if fields.len() >= MAX_SELECTED_FIELDS { + return Err(QueryParseError::QueryTooComplex); + } + let field = ApiIdentifier::parse(field)?; + if !seen.insert(field.clone()) { + return Err(QueryParseError::DuplicateOption); + } + fields.push(field); + } + Ok(SelectClause { fields }) +} + +fn parse_orderby(value: &str) -> Result { + if value.contains(',') { + return Err(QueryParseError::InvalidValue); + } + let mut pieces = value.split_ascii_whitespace(); + let field = pieces.next().ok_or(QueryParseError::InvalidValue)?; + let direction = match pieces.next() { + None => OrderDirection::Asc, + Some("asc") => OrderDirection::Asc, + Some("desc") => OrderDirection::Desc, + Some(_) => return Err(QueryParseError::InvalidValue), + }; + if pieces.next().is_some() { + return Err(QueryParseError::InvalidValue); + } + Ok(OrderByClause { + field: ApiIdentifier::parse(field)?, + direction, + }) +} + +fn parse_top(value: &str) -> Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(QueryParseError::InvalidValue); + } + let top = value + .parse::() + .map_err(|_| QueryParseError::InvalidValue)?; + if top == 0 || top > MAX_TOP { + return Err(QueryParseError::InvalidValue); + } + Ok(top) +} + +fn parse_count(value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(QueryParseError::InvalidValue), + } +} + +fn parse_bounded_scalar(value: &str) -> Result { + if value.is_empty() + || value.len() > MAX_LITERAL_BYTES + || value.bytes().any(|byte| byte.is_ascii_control()) + { + return Err(QueryParseError::InvalidValue); + } + Ok(value.to_owned()) +} + +fn parse_opaque_value(value: &str) -> Result { + if value.is_empty() + || value.len() > MAX_OPAQUE_VALUE_BYTES + || value.bytes().any(|byte| byte.is_ascii_control()) + { + return Err(QueryParseError::InvalidValue); + } + Ok(value.to_owned()) +} + +fn valid_identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + value.len() <= MAX_IDENTIFIER_BYTES + && (first.is_ascii_lowercase() || first == b'_') + && bytes.all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') + }) +} + +fn push_optional_atom(output: &mut String, name: &str, value: Option<&str>) { + output.push_str(name); + output.push('='); + match value { + Some(value) => push_atom(output, "s", value), + None => output.push_str("none"), + } +} + +fn push_atom(output: &mut String, kind: &str, value: &str) { + output.push_str(kind); + output.push('('); + output.push_str(&value.len().to_string()); + output.push(':'); + output.push_str(value); + output.push(')'); +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum Token { + Ident(String), + String(String), + Integer(String), + Decimal(String), + Boolean(bool), + Null, + LParen, + RParen, + Comma, + End, +} + +struct Lexer<'a> { + input: &'a str, + index: usize, + tokens: Vec, +} + +impl<'a> Lexer<'a> { + fn new(input: &'a str) -> Self { + Self { + input, + index: 0, + tokens: Vec::new(), + } + } + + fn lex(mut self) -> Result, QueryParseError> { + while self.index < self.input.len() { + let byte = self.input.as_bytes()[self.index]; + match byte { + b' ' | b'\t' | b'\r' | b'\n' => self.index += 1, + b'(' => { + self.tokens.push(Token::LParen); + self.index += 1; + } + b')' => { + self.tokens.push(Token::RParen); + self.index += 1; + } + b',' => { + self.tokens.push(Token::Comma); + self.index += 1; + } + b'\'' => self.lex_string()?, + b'-' | b'0'..=b'9' => self.lex_number()?, + b'a'..=b'z' | b'_' => self.lex_identifier()?, + _ => return Err(QueryParseError::InvalidFilterSyntax), + } + } + self.tokens.push(Token::End); + Ok(self.tokens) + } + + fn lex_string(&mut self) -> Result<(), QueryParseError> { + self.index += 1; + let mut value = String::new(); + while self.index < self.input.len() { + let byte = self.input.as_bytes()[self.index]; + if byte == b'\'' { + if self.index + 1 < self.input.len() + && self.input.as_bytes()[self.index + 1] == b'\'' + { + if value.len() + 1 > MAX_LITERAL_BYTES { + return Err(QueryParseError::InvalidValue); + } + value.push('\''); + self.index += 2; + continue; + } + self.index += 1; + self.tokens.push(Token::String(value)); + return Ok(()); + } + let ch = self.input[self.index..] + .chars() + .next() + .ok_or(QueryParseError::InvalidFilterSyntax)?; + if ch.is_control() { + return Err(QueryParseError::InvalidValue); + } + if value.len() + ch.len_utf8() > MAX_LITERAL_BYTES { + return Err(QueryParseError::InvalidValue); + } + value.push(ch); + self.index += ch.len_utf8(); + } + Err(QueryParseError::InvalidFilterSyntax) + } + + fn lex_number(&mut self) -> Result<(), QueryParseError> { + let start = self.index; + if self.input.as_bytes()[self.index] == b'-' { + self.index += 1; + if self.index >= self.input.len() || !self.input.as_bytes()[self.index].is_ascii_digit() + { + return Err(QueryParseError::InvalidFilterSyntax); + } + } + self.consume_digits(); + let mut decimal = false; + if self.index < self.input.len() && self.input.as_bytes()[self.index] == b'.' { + decimal = true; + self.index += 1; + if self.index >= self.input.len() || !self.input.as_bytes()[self.index].is_ascii_digit() + { + return Err(QueryParseError::InvalidFilterSyntax); + } + self.consume_digits(); + } + let value = &self.input[start..self.index]; + if value.len() > MAX_LITERAL_BYTES { + return Err(QueryParseError::InvalidValue); + } + if decimal { + self.tokens.push(Token::Decimal(value.to_owned())); + } else { + self.tokens.push(Token::Integer(value.to_owned())); + } + Ok(()) + } + + fn consume_digits(&mut self) { + while self.index < self.input.len() && self.input.as_bytes()[self.index].is_ascii_digit() { + self.index += 1; + } + } + + fn lex_identifier(&mut self) -> Result<(), QueryParseError> { + let start = self.index; + self.index += 1; + while self.index < self.input.len() { + let byte = self.input.as_bytes()[self.index]; + if byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'-' | b'_' | b'.') + { + self.index += 1; + } else { + break; + } + } + let value = &self.input[start..self.index]; + if value.len() > MAX_IDENTIFIER_BYTES { + return Err(QueryParseError::InvalidValue); + } + match value { + "true" => self.tokens.push(Token::Boolean(true)), + "false" => self.tokens.push(Token::Boolean(false)), + "null" => self.tokens.push(Token::Null), + _ => self.tokens.push(Token::Ident(value.to_owned())), + } + Ok(()) + } +} + +#[derive(Default)] +struct FilterBudget { + nodes: usize, + predicates: usize, + in_values: usize, +} + +impl FilterBudget { + fn node(&mut self) -> Result<(), QueryParseError> { + self.nodes += 1; + if self.nodes > MAX_FILTER_NODES { + return Err(QueryParseError::QueryTooComplex); + } + Ok(()) + } + + fn predicate(&mut self) -> Result<(), QueryParseError> { + self.predicates += 1; + if self.predicates > MAX_FILTER_PREDICATES { + return Err(QueryParseError::QueryTooComplex); + } + Ok(()) + } + + fn in_value(&mut self) -> Result<(), QueryParseError> { + self.in_values += 1; + if self.in_values > MAX_IN_VALUES { + return Err(QueryParseError::QueryTooComplex); + } + Ok(()) + } +} + +struct FilterParser { + tokens: Vec, + index: usize, + budget: FilterBudget, +} + +impl FilterParser { + fn new(tokens: Vec) -> Self { + Self { + tokens, + index: 0, + budget: FilterBudget::default(), + } + } + + fn parse_or(&mut self, group_depth: usize) -> Result { + let mut expr = self.parse_and(group_depth)?; + while self.consume_keyword("or") { + let right = self.parse_and(group_depth)?; + self.budget.node()?; + expr = FilterExpr::Binary { + op: LogicalOp::Or, + left: Box::new(expr), + right: Box::new(right), + }; + } + Ok(expr) + } + + fn parse_and(&mut self, group_depth: usize) -> Result { + let mut expr = self.parse_unary(group_depth)?; + while self.consume_keyword("and") { + let right = self.parse_unary(group_depth)?; + self.budget.node()?; + expr = FilterExpr::Binary { + op: LogicalOp::And, + left: Box::new(expr), + right: Box::new(right), + }; + } + Ok(expr) + } + + fn parse_unary(&mut self, group_depth: usize) -> Result { + if self.consume_keyword("not") { + let expr = self.parse_unary(group_depth)?; + self.budget.node()?; + return Ok(FilterExpr::Not(Box::new(expr))); + } + self.parse_primary(group_depth) + } + + fn parse_primary(&mut self, group_depth: usize) -> Result { + if self.consume_lparen() { + if group_depth >= MAX_FILTER_DEPTH { + return Err(QueryParseError::QueryTooComplex); + } + let expr = self.parse_or(group_depth + 1)?; + self.expect_rparen()?; + self.budget.node()?; + return Ok(FilterExpr::Group(Box::new(expr))); + } + + let field_or_function = self.expect_identifier()?; + match self.peek() { + Token::LParen => self.parse_function(field_or_function), + Token::Ident(operator) if is_comparison_operator(operator) || operator == "in" => { + self.parse_field_predicate(field_or_function) + } + _ => Err(QueryParseError::InvalidFilterSyntax), + } + } + + fn parse_function(&mut self, name: ApiIdentifier) -> Result { + let function = match name.as_str() { + "startswith" => StringFunction::StartsWith, + "contains" => StringFunction::Contains, + _ => return Err(QueryParseError::InvalidFilterSyntax), + }; + self.expect_lparen()?; + let field = self.expect_identifier()?; + self.expect_comma()?; + let literal = self.expect_literal(false)?; + self.expect_rparen()?; + self.budget.predicate()?; + self.budget.node()?; + Ok(FilterExpr::Predicate(FilterPredicate::Function { + function, + field, + literal, + })) + } + + fn parse_field_predicate( + &mut self, + field: ApiIdentifier, + ) -> Result { + let operator = self.expect_identifier()?; + let predicate = if operator.as_str() == "in" { + self.parse_in_predicate(field)? + } else { + let op = comparison_operator(operator.as_str())?; + let literal = self.expect_literal(true)?; + if matches!(literal, Literal::Null) + && !matches!(op, ComparisonOp::Eq | ComparisonOp::Ne) + { + return Err(QueryParseError::InvalidFilterSyntax); + } + FilterPredicate::Compare { field, op, literal } + }; + self.budget.predicate()?; + self.budget.node()?; + Ok(FilterExpr::Predicate(predicate)) + } + + fn parse_in_predicate( + &mut self, + field: ApiIdentifier, + ) -> Result { + self.expect_lparen()?; + let mut values = Vec::new(); + loop { + values.push(self.expect_literal(false)?); + self.budget.in_value()?; + if self.consume_comma() { + continue; + } + self.expect_rparen()?; + break; + } + Ok(FilterPredicate::In { field, values }) + } + + fn expect_literal(&mut self, allow_null: bool) -> Result { + let literal = match self.next() { + Token::String(value) => Literal::String(value), + Token::Integer(value) => Literal::Integer(value), + Token::Decimal(value) => Literal::Decimal(value), + Token::Boolean(value) => Literal::Boolean(value), + Token::Null if allow_null => Literal::Null, + _ => return Err(QueryParseError::InvalidFilterSyntax), + }; + Ok(literal) + } + + fn expect_identifier(&mut self) -> Result { + match self.next() { + Token::Ident(value) => Ok(ApiIdentifier(value)), + _ => Err(QueryParseError::InvalidFilterSyntax), + } + } + + fn consume_keyword(&mut self, keyword: &str) -> bool { + match self.peek() { + Token::Ident(value) if value == keyword => { + self.index += 1; + true + } + _ => false, + } + } + + fn consume_lparen(&mut self) -> bool { + if matches!(self.peek(), Token::LParen) { + self.index += 1; + true + } else { + false + } + } + + fn consume_comma(&mut self) -> bool { + if matches!(self.peek(), Token::Comma) { + self.index += 1; + true + } else { + false + } + } + + fn expect_lparen(&mut self) -> Result<(), QueryParseError> { + match self.next() { + Token::LParen => Ok(()), + _ => Err(QueryParseError::InvalidFilterSyntax), + } + } + + fn expect_rparen(&mut self) -> Result<(), QueryParseError> { + match self.next() { + Token::RParen => Ok(()), + _ => Err(QueryParseError::InvalidFilterSyntax), + } + } + + fn expect_comma(&mut self) -> Result<(), QueryParseError> { + match self.next() { + Token::Comma => Ok(()), + _ => Err(QueryParseError::InvalidFilterSyntax), + } + } + + fn expect_end(&mut self) -> Result<(), QueryParseError> { + match self.next() { + Token::End => Ok(()), + _ => Err(QueryParseError::InvalidFilterSyntax), + } + } + + fn peek(&self) -> &Token { + self.tokens.get(self.index).unwrap_or(&Token::End) + } + + fn next(&mut self) -> Token { + let token = self.tokens.get(self.index).cloned().unwrap_or(Token::End); + self.index += 1; + token + } +} + +fn is_comparison_operator(value: &str) -> bool { + matches!(value, "eq" | "ne" | "lt" | "le" | "gt" | "ge") +} + +fn comparison_operator(value: &str) -> Result { + match value { + "eq" => Ok(ComparisonOp::Eq), + "ne" => Ok(ComparisonOp::Ne), + "lt" => Ok(ComparisonOp::Lt), + "le" => Ok(ComparisonOp::Le), + "gt" => Ok(ComparisonOp::Gt), + "ge" => Ok(ComparisonOp::Ge), + _ => Err(QueryParseError::InvalidFilterSyntax), + } +} + +fn validate_filter_depth(expr: &FilterExpr, depth: usize) -> Result<(), QueryParseError> { + if depth > MAX_FILTER_DEPTH { + return Err(QueryParseError::QueryTooComplex); + } + match expr { + FilterExpr::Binary { left, right, .. } => { + validate_filter_depth(left, depth + 1)?; + validate_filter_depth(right, depth + 1) + } + FilterExpr::Not(expr) | FilterExpr::Group(expr) => validate_filter_depth(expr, depth + 1), + FilterExpr::Predicate(_) => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse( + pairs: [(&'static str, &'static str); N], + ) -> Result { + parse_read_query(pairs) + } + + fn filter(value: &str) -> FilterExpr { + parse_filter(value).expect("filter parses") + } + + #[test] + fn parses_native_read_query_surface() { + let query = parse([ + ("accessProfile", "caseworker"), + ("asOf", "2026-08-30T00:00:00Z"), + ("$select", "case-code,status"), + ("$filter", "status eq 'open'"), + ("$orderby", "opened-on desc"), + ("$top", "50"), + ("$count", "false"), + ]) + .expect("query parses"); + + assert_eq!(query.access_profile.as_deref(), Some("caseworker")); + assert_eq!(query.as_of.as_deref(), Some("2026-08-30T00:00:00Z")); + let ParsedReadQueryMode::Query(options) = query.mode else { + panic!("expected query mode"); + }; + assert_eq!(options.select.unwrap().fields()[0].as_str(), "case-code"); + assert_eq!(options.orderby.unwrap().direction, OrderDirection::Desc); + assert_eq!(options.top, Some(50)); + assert_eq!(options.count, Some(false)); + } + + #[test] + fn rejects_unknown_disallowed_and_duplicate_options() { + assert_eq!( + parse([("limit", "10")]), + Err(QueryParseError::UnknownOption) + ); + for key in [ + "fields", "filter", "sort", "pageSize", "cursor", "$skip", "$apply", "$expand", + "$batch", "sql", + ] { + assert_eq!( + parse([(key, "value")]), + Err(QueryParseError::DisallowedOption) + ); + } + assert_eq!( + parse([("$top", "10"), ("$top", "11")]), + Err(QueryParseError::DuplicateOption) + ); + } + + #[test] + fn skiptoken_only_allows_access_profile() { + let query = parse([("accessProfile", "reader"), ("$skiptoken", "opaque-token")]) + .expect("skiptoken query parses"); + assert!(matches!(query.mode, ParsedReadQueryMode::SkipToken { .. })); + + for pair in [ + ("asOf", "2026-08-30T00:00:00Z"), + ("$select", "field"), + ("$filter", "field eq 'value'"), + ("$orderby", "field"), + ("$top", "10"), + ("$count", "true"), + ] { + assert_eq!( + parse([("$skiptoken", "opaque-token"), pair]), + Err(QueryParseError::ConflictingOptions) + ); + } + } + + #[test] + fn top_and_count_are_strict() { + assert_eq!( + parse([("$top", "1")]) + .unwrap() + .canonical() + .contains("top=1"), + true + ); + assert_eq!( + parse([("$top", "100")]) + .unwrap() + .canonical() + .contains("top=100"), + true + ); + for value in ["", "0", "101", "-1", "1.0", "+1", "true"] { + assert_eq!(parse([("$top", value)]), Err(QueryParseError::InvalidValue)); + } + assert_eq!( + parse([("$count", "true")]) + .unwrap() + .canonical() + .contains("count=true"), + true + ); + assert_eq!( + parse([("$count", "false")]) + .unwrap() + .canonical() + .contains("count=false"), + true + ); + for value in ["", "True", "1", "yes"] { + assert_eq!( + parse([("$count", value)]), + Err(QueryParseError::InvalidValue) + ); + } + } + + #[test] + fn orderby_accepts_one_field_and_optional_direction() { + let asc = parse([("$orderby", "opened-on")]).expect("orderby parses"); + assert!(asc.canonical().contains("orderby=(id(9:opened-on),asc)")); + let desc = parse([("$orderby", "opened-on desc")]).expect("desc parses"); + assert!(desc.canonical().contains("orderby=(id(9:opened-on),desc)")); + for value in ["opened-on DESC", "opened-on asc extra", "one,two", ""] { + assert_eq!( + parse([("$orderby", value)]), + Err(QueryParseError::InvalidValue) + ); + } + } + + #[test] + fn select_is_bounded_and_duplicate_free() { + assert_eq!( + parse([("$select", "case-code,case-code")]), + Err(QueryParseError::DuplicateOption) + ); + assert_eq!(parse([("$select", "")]), Err(QueryParseError::InvalidValue)); + assert_eq!( + parse([("$select", "case-code/person")]), + Err(QueryParseError::InvalidValue) + ); + + let many = (0..=MAX_SELECTED_FIELDS) + .map(|index| format!("field-{index}")) + .collect::>() + .join(","); + assert_eq!( + parse_read_query([("$select", many.as_str())]), + Err(QueryParseError::QueryTooComplex) + ); + } + + #[test] + fn filter_precedence_and_grouping_are_preserved() { + let parsed = filter("a eq 1 or b eq 2 and not c eq 3"); + assert_eq!( + parsed_canonical(&parsed), + "or(eq(id(1:a),int(1:1)),and(eq(id(1:b),int(1:2)),not(eq(id(1:c),int(1:3)))))" + ); + + let grouped = filter("(a eq 1 or b eq 2) and c eq 3"); + assert_eq!( + parsed_canonical(&grouped), + "and(group(or(eq(id(1:a),int(1:1)),eq(id(1:b),int(1:2)))),eq(id(1:c),int(1:3)))" + ); + } + + #[test] + fn filter_comparison_operators_parse() { + for (source, canonical) in [ + ("a eq 1", "eq(id(1:a),int(1:1))"), + ("a ne 1", "ne(id(1:a),int(1:1))"), + ("a lt 1", "lt(id(1:a),int(1:1))"), + ("a le 1", "le(id(1:a),int(1:1))"), + ("a gt 1", "gt(id(1:a),int(1:1))"), + ("a ge 1", "ge(id(1:a),int(1:1))"), + ] { + assert_eq!(parsed_canonical(&filter(source)), canonical); + } + } + + #[test] + fn filter_literals_and_null_tests_parse() { + assert_eq!( + parsed_canonical(&filter("name eq 'O''Brien'")), + "eq(id(4:name),str(7:O'Brien))" + ); + assert_eq!( + parsed_canonical(&filter("score eq -10.5")), + "eq(id(5:score),dec(5:-10.5))" + ); + assert_eq!( + parsed_canonical(&filter("active eq true")), + "eq(id(6:active),bool(true))" + ); + assert_eq!( + parsed_canonical(&filter("closed-on eq null")), + "eq(id(9:closed-on),null)" + ); + assert_eq!( + parsed_canonical(&filter("closed-on ne null")), + "ne(id(9:closed-on),null)" + ); + assert_eq!( + parse_filter("closed-on lt null"), + Err(QueryParseError::InvalidFilterSyntax) + ); + } + + #[test] + fn filter_in_and_functions_parse() { + assert_eq!( + parsed_canonical(&filter("status in ('open','held','closed')")), + "in(id(6:status),[str(4:open),str(4:held),str(6:closed)])" + ); + assert_eq!( + parsed_canonical(&filter("startswith(name,'Jo')")), + "startswith(id(4:name),str(2:Jo))" + ); + assert_eq!( + parsed_canonical(&filter("contains(note,'review')")), + "contains(id(4:note),str(6:review))" + ); + assert_eq!( + parse_filter("endswith(name,'n')"), + Err(QueryParseError::InvalidFilterSyntax) + ); + assert_eq!( + parse_filter("startswith(parent/name,'Jo')"), + Err(QueryParseError::InvalidFilterSyntax) + ); + } + + #[test] + fn filter_bounds_are_enforced() { + let too_many_in_values = format!( + "status in ({})", + (0..=MAX_IN_VALUES) + .map(|index| format!("'{index}'")) + .collect::>() + .join(",") + ); + assert_eq!( + parse_filter(&too_many_in_values), + Err(QueryParseError::QueryTooComplex) + ); + + let too_many_predicates = (0..=MAX_FILTER_PREDICATES) + .map(|index| format!("f{index} eq {index}")) + .collect::>() + .join(" and "); + assert_eq!( + parse_filter(&too_many_predicates), + Err(QueryParseError::QueryTooComplex) + ); + + let too_deep = format!( + "{}a eq 1{}", + "(".repeat(MAX_FILTER_DEPTH), + ")".repeat(MAX_FILTER_DEPTH) + ); + assert_eq!( + parse_filter(&too_deep), + Err(QueryParseError::QueryTooComplex) + ); + + let too_long_literal = format!("name eq '{}'", "a".repeat(MAX_LITERAL_BYTES + 1)); + assert_eq!( + parse_filter(&too_long_literal), + Err(QueryParseError::InvalidValue) + ); + } + + #[test] + fn payload_and_opaque_values_are_bounded() { + let large = "a".repeat(MAX_QUERY_PAYLOAD_BYTES + 1); + assert_eq!( + parse_read_query([("accessProfile", large.as_str())]), + Err(QueryParseError::PayloadTooLarge) + ); + + let large_token = "a".repeat(MAX_OPAQUE_VALUE_BYTES + 1); + assert_eq!( + parse_read_query([("$skiptoken", large_token.as_str())]), + Err(QueryParseError::InvalidValue) + ); + } + + #[test] + fn canonicalization_is_stable_and_preserves_grouping() { + let ungrouped = parse([("$filter", "a eq 1 and b eq 2")]) + .expect("query parses") + .canonical(); + let grouped = parse([("$filter", "(a eq 1) and b eq 2")]) + .expect("query parses") + .canonical(); + + assert_ne!(ungrouped, grouped); + assert!(ungrouped.contains("filter=and(eq(id(1:a),int(1:1)),eq(id(1:b),int(1:2)))")); + assert!(grouped.contains("filter=and(group(eq(id(1:a),int(1:1))),eq(id(1:b),int(1:2)))")); + } + + #[test] + fn errors_are_value_free_in_debug_and_display() { + let debug = format!("{:?}", QueryParseError::InvalidFilterSyntax); + let display = QueryParseError::InvalidFilterSyntax.to_string(); + assert_eq!(debug, "InvalidFilterSyntax"); + assert_eq!(display, "query filter syntax is invalid"); + assert!(!debug.contains("secret")); + assert!(!display.contains("secret")); + } + + #[test] + fn parsed_query_debug_redacts_identifiers_literals_and_tokens() { + let query = parse([ + ("accessProfile", "caseworker-canary"), + ("$filter", "secret eq 'literal-canary'"), + ]) + .expect("query parses"); + let debug = format!("{query:?}"); + assert!(!debug.contains("caseworker-canary")); + assert!(!debug.contains("secret")); + assert!(!debug.contains("literal-canary")); + + let cursor = parse([("$skiptoken", "cursor-canary")]).expect("cursor query parses"); + assert!(!format!("{cursor:?}").contains("cursor-canary")); + } + + fn parsed_canonical(filter: &FilterExpr) -> String { + let mut output = String::new(); + filter.push_canonical(&mut output); + output + } +} diff --git a/crates/registry-server/src/runtime_config.rs b/crates/registry-server/src/runtime_config.rs index 2b26f28c1c..29a22d1c1e 100644 --- a/crates/registry-server/src/runtime_config.rs +++ b/crates/registry-server/src/runtime_config.rs @@ -28,7 +28,7 @@ use thiserror::Error; use zeroize::Zeroizing; use crate::{ - auth::{AuthorityClaimConfig, RowBoundaryClaimMapping, RowBoundaryClaimType}, + auth::AuthorityClaimConfig, cursor::CursorCodec, event_destination::{ ActivatedEventDestinationRegistry, EventDestinationConfigs, RawEventDestinationConfigs, @@ -1161,7 +1161,6 @@ impl fmt::Debug for JwksCacheConfig { pub struct AuthorityClaimsConfig { principal: String, purpose: Option, - row_boundary_claims: Vec, } impl AuthorityClaimsConfig { @@ -1170,9 +1169,6 @@ impl AuthorityClaimsConfig { if let Some(purpose) = &raw.purpose { validate_authority_claim_name(purpose)?; } - if raw.row_boundary_claims.len() > MAX_LIST_ITEMS { - return Err(RuntimeConfigError::InvalidOidc); - } let mut names = HashSet::new(); names.insert(raw.principal.as_str()); if let Some(purpose) = &raw.purpose { @@ -1180,32 +1176,14 @@ impl AuthorityClaimsConfig { return Err(RuntimeConfigError::InvalidOidc); } } - for mapping in &raw.row_boundary_claims { - validate_authority_claim_name(&mapping.name)?; - if !names.insert(mapping.name.as_str()) { - return Err(RuntimeConfigError::InvalidOidc); - } - } Ok(Self { principal: raw.principal, purpose: raw.purpose, - row_boundary_claims: raw - .row_boundary_claims - .into_iter() - .map(RowBoundaryClaimConfig::from_raw) - .collect(), }) } fn to_platform_config(&self) -> AuthorityClaimConfig { - AuthorityClaimConfig::new( - self.principal.clone(), - self.purpose.clone(), - self.row_boundary_claims - .iter() - .map(RowBoundaryClaimConfig::to_platform_mapping) - .collect(), - ) + AuthorityClaimConfig::new(self.principal.clone(), self.purpose.clone()) } } @@ -1215,46 +1193,10 @@ impl fmt::Debug for AuthorityClaimsConfig { .debug_struct("AuthorityClaimsConfig") .field("principal", &"") .field("purpose", &self.purpose.as_ref().map(|_| "")) - .field("row_boundary_claim_count", &self.row_boundary_claims.len()) .finish() } } -#[derive(Clone)] -struct RowBoundaryClaimConfig { - name: String, - value_type: RowBoundaryClaimConfigType, -} - -impl RowBoundaryClaimConfig { - fn from_raw(raw: RawRowBoundaryClaimConfig) -> Self { - Self { - name: raw.name, - value_type: raw.value_type, - } - } - - fn to_platform_mapping(&self) -> RowBoundaryClaimMapping { - RowBoundaryClaimMapping::new(self.name.clone(), self.value_type.to_platform_type()) - } -} - -#[derive(Clone, Copy, Deserialize)] -#[serde(rename_all = "camelCase")] -enum RowBoundaryClaimConfigType { - DirectString, - DirectStringSet, -} - -impl RowBoundaryClaimConfigType { - fn to_platform_type(self) -> RowBoundaryClaimType { - match self { - Self::DirectString => RowBoundaryClaimType::DirectString, - Self::DirectStringSet => RowBoundaryClaimType::DirectStringSet, - } - } -} - #[derive(Clone)] pub struct AuditConfig { hash_key_ref: SecretReference, @@ -1528,16 +1470,6 @@ struct RawAuthorityClaimsConfig { principal: String, #[serde(default)] purpose: Option, - #[serde(default)] - row_boundary_claims: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RawRowBoundaryClaimConfig { - name: String, - #[serde(rename = "type")] - value_type: RowBoundaryClaimConfigType, } #[derive(Deserialize)] diff --git a/crates/registry-server/src/startup.rs b/crates/registry-server/src/startup.rs index 9a8c32960a..50b857b9bf 100644 --- a/crates/registry-server/src/startup.rs +++ b/crates/registry-server/src/startup.rs @@ -915,6 +915,9 @@ async fn verify_opened_startup( .batch_execute("SET LOCAL lock_timeout = '5s'") .await .map_err(|_| StartupError::DatabaseUnready)?; + crate::postgres::verify_postgres_15_or_newer(&transaction) + .await + .map_err(|_| StartupError::DatabaseUnready)?; transaction .execute( "SELECT pg_advisory_xact_lock_shared($1)", @@ -1019,6 +1022,9 @@ impl DynamicRuntimeReadiness { .batch_execute("SET LOCAL lock_timeout = '5s'") .await .map_err(|_| StartupError::DatabaseUnready)?; + crate::postgres::verify_postgres_15_or_newer(&*transaction) + .await + .map_err(|_| StartupError::DatabaseUnready)?; transaction .execute( "SELECT pg_advisory_xact_lock_shared($1)", @@ -1087,11 +1093,20 @@ async fn verify_configured_runtime_role( has_database_privilege(current_user, current_database(), 'CREATE'), has_schema_privilege(current_user, 'registry_internal', 'CREATE'), has_schema_privilege(current_user, 'registry_data', 'CREATE'), + has_schema_privilege(current_user, 'registry_source', 'CREATE'), + has_schema_privilege(current_user, 'registry_derived', 'CREATE'), + has_schema_privilege(current_user, 'registry_context', 'CREATE'), EXISTS ( SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname IN ('registry_internal', 'registry_data') + WHERE n.nspname IN ( + 'registry_internal', + 'registry_data', + 'registry_source', + 'registry_derived', + 'registry_context' + ) AND c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user) ) FROM pg_catalog.pg_roles @@ -1104,7 +1119,7 @@ async fn verify_configured_runtime_role( if actual_role != runtime_role.as_str() { return Err(StartupError::DatabaseUnready); } - if (1..=10).any(|index| row.get::<_, bool>(index)) { + if (1..=13).any(|index| row.get::<_, bool>(index)) { return Err(StartupError::DatabaseUnready); } Ok(()) diff --git a/crates/registry-server/src/tooling.rs b/crates/registry-server/src/tooling.rs index 8f22c4372a..8142ab41ed 100644 --- a/crates/registry-server/src/tooling.rs +++ b/crates/registry-server/src/tooling.rs @@ -75,6 +75,13 @@ fn classify_change( match change.code { Code::ConstraintAdded | Code::IndexAdded => DiffClassification::LockOrRewriteRisk, + Code::DerivedRelationChanged if change.class == BaseClass::CompatibleAdditive => { + DiffClassification::CompatibleAdditive + } + Code::DerivedRelationAdded => DiffClassification::CompatibleAdditive, + Code::DerivedRelationRemoved | Code::DerivedRelationChanged => { + DiffClassification::DestructiveOrIrreversible + } Code::EntityClassificationChanged | Code::FieldClassificationChanged => { classification_direction(baseline, candidate, change) } diff --git a/crates/registry-server/tests/compiler_contract.rs b/crates/registry-server/tests/compiler_contract.rs index e4fa5525c9..952466d04e 100644 --- a/crates/registry-server/tests/compiler_contract.rs +++ b/crates/registry-server/tests/compiler_contract.rs @@ -7,12 +7,15 @@ use std::path::PathBuf; use registry_manifest_core::{compile_manifest, AccessRights, FieldType, MetadataManifest}; use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; use registry_server::artifacts::REGISTRY_METADATA_ARTIFACT_PATH; -use registry_server::compiler::{compile_project, module_digest, CompileProfile}; +use registry_server::compiler::{ + compile_project, compile_project_with_assets, module_digest, module_digest_with_assets, + CompileProfile, +}; use registry_server::contract::{ parse_module_json, parse_module_yaml, parse_project_json, parse_project_yaml, AccessProfileSource, BoundaryOperator, Classification, ComparisonOperator, ConstraintSource, - FieldTypeSource, Operation, PackageIdentitySource, ReferenceDelete, RegistryModule, - RowBoundarySource, UniqueWhenPredicate, + FieldTypeSource, ModuleAssetSource, Operation, PackageIdentitySource, ReferenceDelete, + RegistryModule, RowBoundarySource, UniqueWhenPredicate, }; use registry_server::diagnostics::CompileFailure; use registry_server::generated_ddl::DdlStatementKind; @@ -49,6 +52,276 @@ fn compile_json(source: &[u8]) -> Result, +) -> Result { + let project = parse_project_json(source).expect("source shape parses"); + compile_project_with_assets(&project, &[], &assets, CompileProfile::Authoring) +} + +fn derived_sql_asset(path: &str, sql: &str) -> ModuleAssetSource { + ModuleAssetSource { + module: None, + path: path.to_owned(), + bytes: sql.as_bytes().to_vec(), + } +} + +#[test] +fn derived_fields_selectors_and_read_paths_compile_to_route_specific_inventories() { + let project = br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"household-demo","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"household","route":"households","mutationMode":"mutable", + "fields":[ + {"id":"household-code","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"administrative-area","type":"string","maxLength":32,"required":true,"classification":"internal"}, + {"id":"local-household-number","type":"string","maxLength":32,"required":true,"classification":"internal"} + ], + "derived":[{ + "id":"demographics","sql":"sql/household-demographics.sql","key":"id","execution":"live", + "fields":[ + {"id":"child-count","type":"int64","classification":"restricted"}, + {"id":"single-headed","type":"boolean","classification":"restricted"} + ] + }], + "selectorProfiles":[ + {"id":"by-local-reference","fields":["administrative-area","local-household-number"]} + ], + "readPaths":[{"id":"people","through":"group-membership","to":"person","route":"people"}], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"sub","operations":["get","lookup","list"], + "readableFields":["household-code","child-count","single-headed"], + "filterableFields":["child-count","single-headed"], + "sortableFields":["child-count"], + "allowCount":true, + "lookups":[{"selector":"by-local-reference","valueOrigin":"request"}], + "readPaths":[{ + "path":"people", + "readableFields":["legal-name","date-of-birth"], + "filterableFields":["date-of-birth"], + "sortableFields":["date-of-birth"], + "allowCount":true + }] + }] + },{ + "id":"person","route":"people","mutationMode":"mutable", + "fields":[ + {"id":"legal-name","type":"string","maxLength":80,"classification":"internal"}, + {"id":"date-of-birth","type":"date","classification":"internal"} + ] + },{ + "id":"group-membership","route":"memberships","mutationMode":"mutable", + "fields":[ + {"id":"household","type":"reference","target":"household","classification":"internal"}, + {"id":"person","type":"reference","target":"person","classification":"internal"} + ] + }] + }"#; + let sql = "SELECT h.id AS id, 0::bigint AS child_count, false AS single_headed FROM registry_source.household h"; + let compiled = compile_json_with_assets( + project, + vec![derived_sql_asset("sql/household-demographics.sql", sql)], + ) + .expect("derived fields and relationship reads compile"); + + let household = &compiled.entities()["household"]; + assert_eq!( + household + .stored_fields + .iter() + .map(|field| field.logical.id.as_str()) + .collect::>(), + vec![ + "household-code", + "administrative-area", + "local-household-number" + ], + "stored field authoring order is preserved for DDL/query workers" + ); + assert_eq!(household.canonical_id.id, "id"); + assert!(!household.fields.contains_key("id")); + let child_count = &household.derived_fields["child-count"].logical; + assert_eq!(child_count.api_name, "childCount"); + assert_eq!(child_count.sql_name, "child_count"); + assert_eq!( + household.derived_relations["demographics"].sql_bytes, + sql.as_bytes() + ); + assert!(compiled + .routes() + .routes + .iter() + .any(|route| route.id == "records.household.lookup" + && route.path == "/v1/records/households:lookup")); + assert!(compiled + .routes() + .routes + .iter() + .any(|route| route.id == "records.household.path.people" + && route.path == "/v1/records/households/{record_id}/people")); + assert!(compiled + .access() + .entries + .iter() + .any(|entry| entry.route_id == "records.household.path.people" + && entry.profile_ids.contains("operator"))); + let lookup = compiled + .queries() + .operations + .iter() + .find(|operation| operation.id == "records.household.operator.lookup") + .expect("lookup selector operation is compiled"); + assert_eq!( + lookup.selector_fields, + vec!["administrative-area", "local-household-number"] + ); + let path = compiled + .queries() + .operations + .iter() + .find(|operation| operation.id == "records.household.operator.path.people") + .expect("read-path operation is compiled"); + assert_eq!(path.read_path.as_deref(), Some("people")); + assert!(path.allow_count); + assert!(path.processing_fields.contains(&"household".to_owned())); + assert!(path.processing_fields.contains(&"person".to_owned())); +} + +#[test] +fn derived_sql_is_asset_backed_value_free_and_validates_output_aliases() { + let project = br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"derived-demo","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"household","route":"households","mutationMode":"mutable", + "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}], + "derived":[{ + "id":"demographics","sql":"sql/demographics.sql","key":"id", + "fields":[{"id":"child-count","type":"int64","classification":"internal"}] + }] + }] + }"#; + + let missing = compile_json_with_assets(project, vec![]) + .expect_err("derived SQL must be supplied as an explicit asset"); + assert!(missing + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "derived.sql.asset_missing" + && !diagnostic.message.contains("SELECT"))); + + let wrong_alias = compile_json_with_assets( + project, + vec![derived_sql_asset( + "sql/demographics.sql", + "SELECT h.id AS id, 0::bigint AS childCount FROM registry_source.household h", + )], + ) + .expect_err("SQL output aliases must use stable SQL field names"); + assert!(wrong_alias + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "derived.sql.invalid" + && diagnostic.path == "entities[household].derived[demographics].sql" + && !diagnostic.message.contains("childCount"))); + + let wildcard = compile_json_with_assets( + project, + vec![derived_sql_asset( + "sql/demographics.sql", + "SELECT * FROM registry_source.household", + )], + ) + .expect_err("derived SQL cannot use wildcard projection"); + assert!(wildcard + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "derived.sql.invalid")); +} + +#[test] +fn anonymous_access_cannot_process_selector_path_or_derived_private_fields() { + let source = |extra: &str| { + format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"public-demo","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"household","route":"households","mutationMode":"mutable","classification":"public", + "fields":[{{"id":"public-code","type":"string","maxLength":32,"classification":"public"}}, + {{"id":"private-code","type":"string","maxLength":32,"classification":"restricted"}}], + "derived":[{{"id":"flags","sql":"sql/flags.sql","key":"id","fields":[{{"id":"risk-flag","type":"boolean","classification":"public"}}]}}], + "selectorProfiles":[{{"id":"by-private-code","fields":["private-code"]}}], + "accessProfiles":[{{"id":"anon","anonymous":true,"operations":["lookup"],{extra}}}] + }}] + }}"# + ) + }; + + let selector = compile_json_with_assets( + source(r#""readableFields":["public-code"],"lookups":[{"selector":"by-private-code","valueOrigin":"request"}]"#).as_bytes(), + vec![derived_sql_asset( + "sql/flags.sql", + "SELECT h.id AS id, false AS risk_flag FROM registry_source.household h", + )], + ) + .expect_err("anonymous lookup cannot process restricted selector fields"); + assert!(selector + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "access_profile.public.processing_non_public")); + + let derived = compile_json_with_assets( + source(r#""readableFields":["risk-flag"],"filterableFields":["risk-flag"]"#).as_bytes(), + vec![derived_sql_asset( + "sql/flags.sql", + "SELECT h.id AS id, false AS risk_flag FROM registry_source.household h", + )], + ) + .expect_err("anonymous access cannot process derived fields until lineage exists"); + assert!(derived + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "access_profile.public.processing_non_public")); +} + +#[test] +fn module_digest_can_bind_explicit_sql_assets() { + let module = parse_module_json( + br#"{"id":"core","version":"1","entities":[{"id":"record","route":"records","mutationMode":"mutable","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}]}]}"#, + ) + .expect("module parses"); + let yaml_only = module_digest(&module); + let with_asset = module_digest_with_assets( + &module, + &[ModuleAssetSource { + module: Some("core".to_owned()), + path: "sql/derived.sql".to_owned(), + bytes: b"SELECT r.id AS id FROM registry_source.record r".to_vec(), + }], + ); + assert_ne!(yaml_only, with_asset); + assert_eq!(yaml_only, module_digest_with_assets(&module, &[])); + assert_eq!( + yaml_only, + module_digest_with_assets( + &module, + &[ModuleAssetSource { + module: Some("another-module".to_owned()), + path: "sql/derived.sql".to_owned(), + bytes: b"SELECT 1".to_vec(), + }], + ), + "assets owned by another module do not change this module's lock digest" + ); +} + #[test] fn batch_route_requires_explicit_bounds_and_compiles_bounded_openapi() { let source = |batch: &str, operations: &str| { @@ -341,16 +614,39 @@ fn all_acceptance_fixtures_compile_manifest_projection_under_production() { source_revision: "acceptance-fixture-source".to_owned(), }); let mut modules = Vec::new(); + let mut assets = Vec::new(); for lock in &mut project.modules { - let module_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + let module_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../products/registry-server/acceptance") .join(domain) .join("modules") - .join(&lock.id) - .join("module.yaml"); + .join(&lock.id); + let module_path = module_root.join("module.yaml"); let module = if module_path.is_file() { let bytes = fs::read(module_path).expect("locked acceptance module is readable"); - parse_module_yaml(&bytes).expect("locked acceptance module parses") + let module = parse_module_yaml(&bytes).expect("locked acceptance module parses"); + let mut module_assets = Vec::new(); + for derived in module + .entities + .iter() + .flat_map(|entity| &entity.derived) + .chain( + module + .extend_entities + .iter() + .flat_map(|extension| &extension.derived), + ) + { + module_assets.push(ModuleAssetSource { + module: Some(module.id.clone()), + path: derived.sql.clone(), + bytes: fs::read(module_root.join(&derived.sql)) + .expect("locked derived SQL asset is readable"), + }); + } + lock.digest = Some(module_digest_with_assets(&module, &module_assets)); + assets.extend(module_assets); + module } else { let module = RegistryModule { id: lock.id.clone(), @@ -365,8 +661,11 @@ fn all_acceptance_fixtures_compile_manifest_projection_under_production() { modules.push(module); } - let compiled = compile_project(&project, &modules, CompileProfile::Production) - .unwrap_or_else(|failure| panic!("{domain} production compile failed: {failure:?}")); + let compiled = + compile_project_with_assets(&project, &modules, &assets, CompileProfile::Production) + .unwrap_or_else(|failure| { + panic!("{domain} production compile failed: {failure:?}") + }); let artifact = compiled .artifacts() .get("generated/manifest/registry-manifest.json") @@ -2045,6 +2344,9 @@ fn public_profile_cannot_process_an_internal_field() { claim: "asset_code".to_owned(), operator: BoundaryOperator::Equals, }], + lookups: Vec::new(), + read_paths: Vec::new(), + allow_count: false, allow_data_export: false, revision_access: false, }); @@ -2461,12 +2763,13 @@ fn compiled_query_inventory_is_profile_scoped_bounded_and_temporal() { assert_eq!( list_parameter_names, [ + "$count", + "$filter", + "$orderby", + "$select", + "$skiptoken", + "$top", "accessProfile", - "cursor", - "fields", - "filter", - "pageSize", - "sort" ] ); let as_of_parameter_names = query_parameter_names( @@ -2475,13 +2778,14 @@ fn compiled_query_inventory_is_profile_scoped_bounded_and_temporal() { assert_eq!( as_of_parameter_names, [ + "$count", + "$filter", + "$orderby", + "$select", + "$skiptoken", + "$top", "accessProfile", "asOf", - "cursor", - "fields", - "filter", - "pageSize", - "sort" ] ); let as_of_parameters = openapi["paths"]["/v1/records/placements:as-of"]["get"]["parameters"] @@ -2498,11 +2802,11 @@ fn compiled_query_inventory_is_profile_scoped_bounded_and_temporal() { ); let page_size = as_of_parameters .iter() - .find(|parameter| parameter["name"] == "pageSize") - .expect("pageSize parameter is rendered"); + .find(|parameter| parameter["name"] == "$top") + .expect("$top parameter is rendered"); assert_eq!( page_size["schema"], - json!({"type": "integer", "minimum": 1}) + json!({"type": "integer", "minimum": 1, "maximum": 100}) ); assert!(compiled.ddl().statements.iter().any(|statement| { statement.id == "entity.asset-placement.constraint.temporal-order" @@ -2588,7 +2892,7 @@ fn temporal_queries_require_profile_readable_boundary_fields() { } #[test] -fn reordered_equivalent_query_authoring_has_the_same_revision() { +fn reordered_stored_field_authoring_changes_revision_but_not_query_inventory() { let left = parse_project_json( br#"{ "apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject", @@ -2631,7 +2935,11 @@ fn reordered_equivalent_query_authoring_has_the_same_revision() { let right = compile_project(&right, &[], CompileProfile::Authoring) .expect("right query source compiles"); assert_eq!(left.queries(), right.queries()); - assert_eq!(left.revision(), right.revision()); + assert_ne!( + left.revision(), + right.revision(), + "stored field authoring order is now part of the compiled model" + ); } #[test] diff --git a/crates/registry-server/tests/http_auth.rs b/crates/registry-server/tests/http_auth.rs index 6afeb6a70c..c880c7341e 100644 --- a/crates/registry-server/tests/http_auth.rs +++ b/crates/registry-server/tests/http_auth.rs @@ -23,7 +23,6 @@ use registry_server::api::{ }; use registry_server::auth::{ AuthenticationConfigError, AuthenticationError, AuthorityClaimConfig, RegistryAuthenticator, - RowBoundaryClaimMapping, RowBoundaryClaimType, }; use registry_server::cursor::CursorCodec; use registry_server::{compile_project, parse_project_yaml, CompileProfile, CompiledRegistry}; @@ -36,6 +35,7 @@ const PRINCIPAL: &str = "principal-value-never-rendered"; const PURPOSE: &str = "case-management-never-rendered"; const JURISDICTION: &str = "area-a-never-rendered"; const TENANT: &str = "tenant-a-never-rendered"; +const RECORD_ID: &str = "00000000-0000-4000-8000-000000000001"; const PROJECT: &str = r#" apiVersion: registry.registrystack.org/v1alpha1 @@ -89,7 +89,7 @@ impl RecordReadService for RecordingReadService { Box::pin(async move { Ok(Some(held(project_fixture( json!({ - "id": "case-1", + "id": RECORD_ID, "revision": 1, "data": { "label": "Visible", @@ -108,6 +108,14 @@ impl RecordReadService for RecordingReadService { self.calls.fetch_add(1, Ordering::SeqCst); Box::pin(async { Ok(held(json!({"items": []}))) }) } + + fn lookup( + &self, + _request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(None) }) + } } fn held(value: Value) -> HeldReadResponse { @@ -236,7 +244,7 @@ async fn verified_direct_authority_reaches_the_protected_record_service() { let token = harness.valid_token(); let response = harness .send( - "/v1/records/cases/case-1?accessProfile=caseworker", + "/v1/records/cases/00000000-0000-4000-8000-000000000001?accessProfile=caseworker", &[bearer(&token)], None, ) @@ -402,7 +410,11 @@ async fn malformed_or_duplicate_bearer_never_downgrades_to_anonymous() { ] { let before = harness.records.calls.load(Ordering::SeqCst); let response = harness - .send("/v1/records/cases/case-1", &values, None) + .send( + "/v1/records/cases/00000000-0000-4000-8000-000000000001", + &values, + None, + ) .await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(body_json(response).await["code"], "authentication.refused"); @@ -413,14 +425,20 @@ async fn malformed_or_duplicate_bearer_never_downgrades_to_anonymous() { #[tokio::test] async fn anonymous_without_a_token_succeeds_but_injected_authority_is_removed() { let harness = Harness::new().await; - let public = harness.send("/v1/records/cases/case-1", &[], None).await; + let public = harness + .send( + "/v1/records/cases/00000000-0000-4000-8000-000000000001", + &[], + None, + ) + .await; assert_eq!(public.status(), StatusCode::OK); assert_eq!(body_json(public).await["data"], json!({"label": "Visible"})); let before = harness.records.calls.load(Ordering::SeqCst); let missing = harness .send( - "/v1/records/cases/case-1?accessProfile=caseworker", + "/v1/records/cases/00000000-0000-4000-8000-000000000001?accessProfile=caseworker", &[], None, ) @@ -439,7 +457,7 @@ async fn anonymous_without_a_token_succeeds_but_injected_authority_is_removed() let before = harness.records.calls.load(Ordering::SeqCst); let response = harness .send( - "/v1/records/cases/case-1?accessProfile=caseworker", + "/v1/records/cases/00000000-0000-4000-8000-000000000001?accessProfile=caseworker", &[], Some(injected), ) @@ -466,7 +484,11 @@ async fn refusals_and_debug_output_are_value_free() { let before = harness.records.calls.load(Ordering::SeqCst); let response = harness - .send("/v1/records/cases/case-1", &[bearer(&token)], None) + .send( + "/v1/records/cases/00000000-0000-4000-8000-000000000001", + &[bearer(&token)], + None, + ) .await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); let body = body_json(response).await.to_string(); @@ -497,27 +519,9 @@ async fn refusals_and_debug_output_are_value_free() { async fn constructor_rejects_empty_duplicate_reserved_and_incomplete_mappings() { let harness = Harness::new().await; let invalid = [ - AuthorityClaimConfig::new("", Some("purpose".to_owned()), row_claims()), - AuthorityClaimConfig::new("sub", Some("purpose".to_owned()), row_claims()), - AuthorityClaimConfig::new( - "registry_principal", - Some("registry_principal".to_owned()), - row_claims(), - ), - AuthorityClaimConfig::new( - "registry_principal", - Some("purpose".to_owned()), - vec![ - RowBoundaryClaimMapping::new( - "jurisdictions", - RowBoundaryClaimType::DirectStringSet, - ), - RowBoundaryClaimMapping::new( - "jurisdictions", - RowBoundaryClaimType::DirectStringSet, - ), - ], - ), + AuthorityClaimConfig::new("", Some("purpose".to_owned())), + AuthorityClaimConfig::new("sub", Some("purpose".to_owned())), + AuthorityClaimConfig::new("registry_principal", Some("registry_principal".to_owned())), ]; for claims in invalid { let error = authenticator(&harness.registry, &harness.idp, claims) @@ -526,23 +530,8 @@ async fn constructor_rejects_empty_duplicate_reserved_and_incomplete_mappings() } for claims in [ - AuthorityClaimConfig::new("registry_principal", None, row_claims()), - AuthorityClaimConfig::new( - "registry_principal", - Some("purpose".to_owned()), - vec![RowBoundaryClaimMapping::new( - "jurisdictions", - RowBoundaryClaimType::DirectStringSet, - )], - ), - AuthorityClaimConfig::new( - "registry_principal", - Some("purpose".to_owned()), - vec![ - RowBoundaryClaimMapping::new("jurisdictions", RowBoundaryClaimType::DirectString), - RowBoundaryClaimMapping::new("tenant", RowBoundaryClaimType::DirectString), - ], - ), + AuthorityClaimConfig::new("registry_principal", None), + AuthorityClaimConfig::new("wrong_principal", Some("purpose".to_owned())), ] { let error = authenticator(&harness.registry, &harness.idp, claims) .expect_err("incomplete compiled authority mapping is refused"); @@ -588,7 +577,7 @@ async fn assert_refused_without_record_call(harness: &Harness, token: &str) { let before = harness.records.calls.load(Ordering::SeqCst); let response = harness .send( - "/v1/records/cases/case-1?accessProfile=caseworker", + "/v1/records/cases/00000000-0000-4000-8000-000000000001?accessProfile=caseworker", &[bearer(token)], None, ) @@ -650,18 +639,7 @@ fn verifier_config(idp: &MockIdp) -> TokenVerifierConfig { } fn authority_claims() -> AuthorityClaimConfig { - AuthorityClaimConfig::new( - "registry_principal", - Some("purpose".to_owned()), - row_claims(), - ) -} - -fn row_claims() -> Vec { - vec![ - RowBoundaryClaimMapping::new("jurisdictions", RowBoundaryClaimType::DirectStringSet), - RowBoundaryClaimMapping::new("tenant", RowBoundaryClaimType::DirectString), - ] + AuthorityClaimConfig::new("registry_principal", Some("purpose".to_owned())) } fn valid_claims() -> Value { diff --git a/crates/registry-server/tests/http_read_only.rs b/crates/registry-server/tests/http_read_only.rs index 1497ee4679..c32d4f0d79 100644 --- a/crates/registry-server/tests/http_read_only.rs +++ b/crates/registry-server/tests/http_read_only.rs @@ -7,11 +7,11 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use axum::body::{to_bytes, Body}; -use axum::http::{Method, Request, StatusCode}; +use axum::http::{header::CONTENT_TYPE, Method, Request, StatusCode}; use registry_platform_canonical_json::parse_json_strict; use registry_server::api::{ router, HeldReadResponse, HttpService, ReadRuntimeIdentity, ReadServiceError, ReadinessProbe, - RecordReadRequest, RecordReadService, RevisionReadRefusal, RevisionReadRequest, + RecordReadKind, RecordReadRequest, RecordReadService, RevisionReadRefusal, RevisionReadRequest, RevisionReadService, ServiceFuture, VerifiedClaimValue, VerifiedRequestClaims, }; use registry_server::artifacts::REGISTRY_METADATA_ARTIFACT_PATH; @@ -22,6 +22,9 @@ use serde_json::{json, Value}; use tower::Service as _; use zeroize::Zeroizing; +#[path = "../src/query.rs"] +mod strict_query; + const PROJECT: &str = r#" apiVersion: registry.registrystack.org/v1alpha1 kind: RegistryProject @@ -53,6 +56,7 @@ entities: requiredScopes: [registry.read] requiredPurposes: [case-management] operations: [create, get, list, patch, tombstone, batch, revisions] + allowCount: true readableFields: [label, secret, jurisdiction] writableFields: [label, secret, jurisdiction] filterableFields: [label, jurisdiction] @@ -75,6 +79,87 @@ entities: writableFields: [text] "#; +const LOOKUP_PATH_PROJECT: &str = r#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: lookup-path-surface + version: 0.1.0 + defaultLanguage: en +entities: + - id: household + route: households + mutationMode: mutable + tombstone: true + classification: restricted + fields: + - {id: household-code, type: string, required: true, maxLength: 64, classification: restricted} + - {id: administrative-area, type: string, required: true, maxLength: 64, classification: restricted} + - {id: local-household-number, type: int64, required: true, classification: restricted} + - {id: private-note, type: string, required: false, maxLength: 64, classification: restricted} + selectorProfiles: + - {id: by-household-code, fields: [household-code]} + - {id: by-local-reference, fields: [administrative-area, local-household-number]} + - {id: by-private-note, fields: [private-note]} + readPaths: + - {id: people, through: membership, to: person, route: people} + accessProfiles: + - id: operator + default: true + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + operations: [get, lookup, list] + readableFields: [household-code, administrative-area, local-household-number] + filterableFields: [household-code, administrative-area, local-household-number] + sortableFields: [household-code] + lookups: + - {selector: by-household-code, valueOrigin: request} + - {selector: by-local-reference, valueOrigin: request} + readPaths: + - path: people + readableFields: [person-code] + filterableFields: [person-code] + sortableFields: [person-code] + allowCount: true + - id: viewer + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + operations: [get, lookup] + readableFields: [household-code] + rowBoundaries: + - {field: id, claim: household_id, operator: equals} + lookups: + - selector: by-household-code + valueOrigin: verified_claim + claimMapping: {household-code: household_code} + - id: membership + route: memberships + mutationMode: mutable + classification: restricted + fields: + - {id: household, type: reference, target: household, required: true, classification: restricted} + - {id: person, type: reference, target: person, required: true, classification: restricted} + - id: person + route: people + mutationMode: mutable + classification: restricted + fields: + - {id: person-code, type: string, required: true, maxLength: 64, classification: restricted} + - {id: sensitive-note, type: string, required: false, maxLength: 64, classification: restricted} + accessProfiles: + - id: operator + default: true + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + operations: [get, list] + readableFields: [sensitive-note] + filterableFields: [sensitive-note] + sortableFields: [sensitive-note] +"#; + const DISCOVERY_MATRIX_PROJECT: &str = r#" apiVersion: registry.registrystack.org/v1alpha1 kind: RegistryProject @@ -212,31 +297,34 @@ async fn closed_query_grammar_reaches_record_service_as_compiled_query() { let accepted = harness .send( Method::GET, - "/v1/records/cases?fields=label&filter=label:prefix:Visible&sort=label&pageSize=25", + "/v1/records/cases?$select=label&$filter=startswith(label,'Visible')&$orderby=label&$top=25", None, ) .await; assert_eq!(accepted.status(), StatusCode::OK); assert_eq!(accepted.headers()["cache-control"], "no-store"); let request = harness.records.last_request(); - let query = request.query.expect("list request carries compiled query"); + let query = request_query(&request); assert_eq!(query.route_id, "records.case.list"); assert_eq!(query.query_operation_id, "records.case.public.list"); assert_eq!(query.page_size, 25); assert_eq!(request.maximum_records, 26); - assert_eq!(query.sort.as_deref(), Some("label")); - assert_eq!(query.filters.len(), 1); - assert_eq!(query.filters[0].field, "label"); assert_eq!( - query.filters[0].operator, - registry_server::model::CompiledQueryFilterOperator::Prefix + query.order.as_ref().map(|order| order.field_id.as_str()), + Some("label") + ); + let predicate = single_filter_predicate(query); + assert_eq!(predicate.field_id, "label"); + assert_eq!( + predicate.operator, + registry_server::api::ReadFilterOperator::StartsWith ); let before = harness.records.calls(); let bad_operator = harness .send( Method::GET, - "/v1/records/cases?filter=label:range:a..z", + "/v1/records/cases?$filter=label%20approximately%20'a'", None, ) .await; @@ -246,32 +334,29 @@ async fn closed_query_grammar_reaches_record_service_as_compiled_query() { } #[tokio::test] -async fn repeated_in_filters_are_one_deterministic_finite_set() { +async fn in_filter_values_are_one_deterministic_finite_set() { let harness = Harness::new(true); let accepted = harness .send( Method::GET, - "/v1/records/cases?accessProfile=caseworker&filter=jurisdiction:in:area-b&filter=jurisdiction:in:area-a", + "/v1/records/cases?accessProfile=caseworker&$filter=jurisdiction%20in%20('area-b','area-a')", Some(caseworker_claims("case-management")), ) .await; assert_eq!(accepted.status(), StatusCode::OK); - let query = harness - .records - .last_request() - .query - .expect("list request carries compiled query"); - assert_eq!(query.filters.len(), 1); - assert_eq!(query.filters[0].field, "jurisdiction"); + let last = harness.records.last_request(); + let query = request_query(&last); + let predicate = single_filter_predicate(query); + assert_eq!(predicate.field_id, "jurisdiction"); assert_eq!( - query.filters[0].values, + predicate.values, vec!["area-a".to_owned(), "area-b".to_owned()] ); let mixed = harness .send( Method::GET, - "/v1/records/cases?accessProfile=caseworker&filter=jurisdiction:in:area-a&filter=jurisdiction:equals:area-a", + "/v1/records/cases?accessProfile=caseworker&$filter=secret%20eq%20'DO-NOT-LEAK'", Some(caseworker_claims("case-management")), ) .await; @@ -279,6 +364,289 @@ async fn repeated_in_filters_are_one_deterministic_finite_set() { assert_eq!(body_json(mixed).await["code"], "query.invalid"); } +#[tokio::test] +async fn lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value_free() { + let harness = Harness::from_project(LOOKUP_PATH_PROJECT, true); + let operator_claims = Some(caseworker_claims("case-management")); + let accepted = harness + .send_json( + Method::POST, + "/v1/records/households:lookup?accessProfile=operator&$select=household-code", + operator_claims.clone(), + json!({ + "selector": "by-local-reference", + "values": { + "administrative-area": "area-a", + "local-household-number": 7 + } + }), + ) + .await; + assert_eq!(accepted.status(), StatusCode::OK); + let request = harness.records.last_request(); + assert_eq!(request.maximum_records, 2); + assert_eq!( + request.selected_fields, + BTreeSet::from(["household-code".to_owned()]) + ); + let RecordReadKind::Lookup { selector } = request.kind else { + panic!("lookup route must reach the service as a lookup request") + }; + assert_eq!(selector.selector_id, "by-local-reference"); + assert_eq!( + selector.query_operation_id, + "records.household.operator.lookup" + ); + assert_eq!( + selector + .values + .iter() + .map(|value| (value.field_id.as_str(), value.value.as_str())) + .collect::>(), + vec![ + ("administrative-area", "area-a"), + ("local-household-number", "7") + ] + ); + + for body in [ + json!({"selector": "by-local-reference"}), + json!({"selector": "by-local-reference", "values": {"administrative-area": "area-a", "local-household-number": "7"}}), + json!({"selector": "by-local-reference", "values": {"administrative-area": "area-a", "local-household-number": 7, "private-note": "DO-NOT-LEAK"}}), + json!({"selector": "by-local-reference", "values": {"administrative-area": "area-a", "local-household-number": 7}, "extra": "DO-NOT-LEAK"}), + ] { + let before = harness.records.calls(); + let response = harness + .send_json( + Method::POST, + "/v1/records/households:lookup?accessProfile=operator", + operator_claims.clone(), + body, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_json(response).await; + assert_eq!(body["code"], "request.invalid"); + assert!(!body.to_string().contains("DO-NOT-LEAK")); + assert_eq!(harness.records.calls(), before); + } + + let oversized_body = format!( + r#"{{"selector":"by-household-code","values":{{"household-code":"{}"}}}}"#, + "x".repeat(17 * 1024) + ); + let before = harness.records.calls(); + let oversized = harness + .send_body( + Method::POST, + "/v1/records/households:lookup?accessProfile=operator", + operator_claims.clone(), + Some("application/json"), + Body::from(oversized_body), + ) + .await; + assert_eq!(oversized.status(), StatusCode::BAD_REQUEST); + assert_eq!(body_json(oversized).await["code"], "request.invalid"); + assert_eq!(harness.records.calls(), before); + + let unknown = harness + .send_json( + Method::POST, + "/v1/records/households:lookup?accessProfile=operator", + operator_claims.clone(), + json!({"selector": "missing-canary", "values": {"household-code": "DO-NOT-LEAK"}}), + ) + .await; + let unknown_body = body_bytes(unknown).await; + let ungranted = harness + .send_json( + Method::POST, + "/v1/records/households:lookup?accessProfile=operator", + operator_claims.clone(), + json!({"selector": "by-private-note", "values": {"private-note": "DO-NOT-LEAK"}}), + ) + .await; + let ungranted_body = body_bytes(ungranted).await; + assert_eq!(unknown_body, ungranted_body); + assert!(!String::from_utf8_lossy(&unknown_body).contains("DO-NOT-LEAK")); + assert_eq!( + serde_json::from_slice::(&unknown_body).expect("unresolved response is JSON") + ["code"], + "lookup.unresolved" + ); + + let claim_origin_claims = Some(caseworker_claims_with_direct( + "case-management", + [ + ( + "household_code", + VerifiedClaimValue::direct_string("hh-001").expect("claim value"), + ), + ( + "household_id", + VerifiedClaimValue::direct_string("00000000-0000-4000-8000-000000000001") + .expect("claim value"), + ), + ], + )); + let claim_origin = harness + .send_json( + Method::POST, + "/v1/records/households:lookup?accessProfile=viewer", + claim_origin_claims, + json!({"selector": "by-household-code"}), + ) + .await; + assert_eq!(claim_origin.status(), StatusCode::OK); + let request = harness.records.last_request(); + let RecordReadKind::Lookup { selector } = request.kind else { + panic!("claim-origin route must reach the service as a lookup request") + }; + assert_eq!(selector.selector_id, "by-household-code"); + assert_eq!( + selector.value_origin, + registry_server::contract::LookupValueOrigin::VerifiedClaim + ); + assert_eq!(selector.values[0].value, "hh-001"); + + let claim_values_body = harness + .send_json( + Method::POST, + "/v1/records/households:lookup?accessProfile=viewer", + Some(caseworker_claims_with_direct( + "case-management", + [( + "household_id", + VerifiedClaimValue::direct_string("00000000-0000-4000-8000-000000000001") + .expect("claim value"), + )], + )), + json!({"selector": "by-household-code", "values": {"household-code": "DO-NOT-LEAK"}}), + ) + .await; + assert_eq!(claim_values_body.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body_json(claim_values_body).await["code"], + "request.invalid" + ); + + let missing_claim = harness + .send_json( + Method::POST, + "/v1/records/households:lookup?accessProfile=viewer", + Some(caseworker_claims_with_direct( + "case-management", + [( + "household_id", + VerifiedClaimValue::direct_string("00000000-0000-4000-8000-000000000001") + .expect("claim value"), + )], + )), + json!({"selector": "by-household-code"}), + ) + .await; + assert_eq!(body_bytes(missing_claim).await, unknown_body); +} + +#[tokio::test] +async fn relationship_route_uses_path_grant_not_direct_target_rights() { + let harness = Harness::from_project(LOOKUP_PATH_PROJECT, true); + let root = "00000000-0000-4000-8000-000000000001"; + let accepted = harness + .send( + Method::GET, + &format!( + "/v1/records/households/{root}/people?accessProfile=operator&$select=person-code&$filter=startswith(person-code,'P-')&$orderby=person-code&$top=5&$count=true" + ), + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(accepted.status(), StatusCode::OK); + let request = harness.records.last_request(); + assert_eq!(request.entity_id, "household"); + assert_eq!(request.operation_id, "records.household.path.people"); + assert_eq!( + request.selected_fields, + BTreeSet::from(["person-code".to_owned()]) + ); + assert_eq!(request.maximum_records, 6); + let RecordReadKind::Relationship { + root_id, + path_id, + plan, + } = request.kind + else { + panic!("read-path route must reach the service as a relationship request") + }; + assert_eq!(root_id, root); + assert_eq!(path_id, "people"); + assert_eq!(plan.route_id, "records.household.path.people"); + assert_eq!( + plan.query_operation_id, + "records.household.operator.path.people" + ); + assert_eq!(plan.page_size, 5); + assert!(plan.include_count); + assert_eq!( + single_filter_predicate(&plan).field_id, + "person-code", + "path filters are target-field filters from the path grant" + ); + assert_eq!( + plan.order.as_ref().map(|order| order.field_id.as_str()), + Some("person-code") + ); + + let before = harness.records.calls(); + let widened = harness + .send( + Method::GET, + &format!( + "/v1/records/households/{root}/people?accessProfile=operator&$select=sensitive-note" + ), + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(widened.status(), StatusCode::BAD_REQUEST); + let widened_body = body_json(widened).await; + assert_eq!(widened_body["code"], "query.invalid"); + assert!(!widened_body.to_string().contains("sensitive-note")); + assert_eq!(harness.records.calls(), before); + + let unknown_path = harness + .send( + Method::GET, + &format!("/v1/records/households/{root}/unknown-path?accessProfile=viewer"), + Some(caseworker_claims_with_direct( + "case-management", + [( + "household_id", + VerifiedClaimValue::direct_string(root).expect("claim value"), + )], + )), + ) + .await; + assert_eq!(unknown_path.status(), StatusCode::NOT_FOUND); + let unknown_path_body = body_json(unknown_path).await; + + let ungranted = harness + .send( + Method::GET, + &format!("/v1/records/households/{root}/people?accessProfile=viewer"), + Some(caseworker_claims_with_direct( + "case-management", + [( + "household_id", + VerifiedClaimValue::direct_string(root).expect("claim value"), + )], + )), + ) + .await; + assert_eq!(ungranted.status(), StatusCode::NOT_FOUND); + assert_eq!(body_json(ungranted).await, unknown_path_body); + assert_eq!(harness.records.calls(), before); +} + #[tokio::test] async fn continuation_requests_refuse_query_overrides_before_record_io() { let harness = Harness::new(true); @@ -286,7 +654,7 @@ async fn continuation_requests_refuse_query_overrides_before_record_io() { let response = harness .send( Method::GET, - "/v1/records/cases?cursor=opaque-token&fields=label", + "/v1/records/cases?$skiptoken=opaque-token&$select=label", None, ) .await; @@ -300,7 +668,7 @@ async fn known_route_malformed_query_is_refusal_audited_before_response() { let harness = Harness::new(true); harness.records.refusal_fails.store(true, Ordering::SeqCst); let response = harness - .send(Method::GET, "/v1/records/cases?filter=label:equals", None) + .send(Method::GET, "/v1/records/cases?$filter=label%20eq", None) .await; assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!(body_json(response).await["code"], "source.unavailable"); @@ -308,6 +676,118 @@ async fn known_route_malformed_query_is_refusal_audited_before_response() { assert_eq!(harness.records.refusal_calls(), 1); } +#[tokio::test] +async fn legacy_query_keys_are_not_accepted() { + let harness = Harness::new(true); + for uri in [ + "/v1/records/cases?fields=label", + "/v1/records/cases?filter=label:equals:Visible", + "/v1/records/cases?sort=label", + "/v1/records/cases?pageSize=25", + "/v1/records/cases?cursor=opaque-token", + ] { + let response = harness.send(Method::GET, uri, None).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}"); + assert_eq!(body_json(response).await["code"], "query.invalid", "{uri}"); + } + assert_eq!(harness.records.calls(), 0); +} + +#[tokio::test] +async fn count_requires_compiled_permission_and_top_is_bounded() { + let harness = Harness::new(true); + let public_count = harness + .send(Method::GET, "/v1/records/cases?$count=true", None) + .await; + assert_eq!(public_count.status(), StatusCode::BAD_REQUEST); + assert_eq!(body_json(public_count).await["code"], "query.invalid"); + + let caseworker_count = harness + .send( + Method::GET, + "/v1/records/cases?accessProfile=caseworker&$count=true&$top=1", + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(caseworker_count.status(), StatusCode::OK); + let body = body_json(caseworker_count).await; + assert_eq!(body["count"], 1); + assert_eq!(body["pageInfo"]["nextCursor"], Value::Null); + let request = harness.records.last_request(); + let query = request_query(&request); + assert!(query.include_count); + assert_eq!(query.page_size, 1); + assert_eq!(request.maximum_records, 2); +} + +#[tokio::test] +async fn desc_ordering_and_field_capability_failures_are_value_free() { + let harness = Harness::new(true); + for uri in [ + "/v1/records/cases?$orderby=label%20desc", + "/v1/records/cases?$filter=secret%20eq%20'DO-NOT-LEAK'", + "/v1/records/cases?$filter=missing%20eq%20'DO-NOT-LEAK'", + ] { + let response = harness.send(Method::GET, uri, None).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}"); + let body = body_json(response).await; + assert_eq!(body["code"], "query.invalid", "{uri}"); + let rendered = body.to_string(); + assert!(!rendered.contains("secret")); + assert!(!rendered.contains("missing")); + assert!(!rendered.contains("DO-NOT-LEAK")); + } + assert_eq!(harness.records.calls(), 0); +} + +#[tokio::test] +async fn select_id_is_a_noop_and_filter_grouping_reaches_the_plan() { + let harness = Harness::new(true); + let selected_id = harness + .send(Method::GET, "/v1/records/cases?$select=id", None) + .await; + assert_eq!(selected_id.status(), StatusCode::OK); + assert_eq!( + harness.records.last_request().selected_fields, + BTreeSet::from(["label".to_owned()]) + ); + + let grouped = harness + .send( + Method::GET, + "/v1/records/cases?accessProfile=caseworker&$filter=(label%20eq%20'Visible'%20or%20jurisdiction%20eq%20'area-a')%20and%20not%20jurisdiction%20eq%20'area-b'", + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(grouped.status(), StatusCode::OK); + let last = harness.records.last_request(); + let query = request_query(&last); + assert!(matches!( + &query.filter, + Some(registry_server::api::ReadFilterExpr::Binary { + op: registry_server::api::ReadLogicalOp::And, + .. + }) + )); +} + +#[test] +fn strict_query_debug_output_redacts_identifiers_literals_and_tokens() { + let query = strict_query::parse_read_query([ + ("accessProfile", "caseworker-canary"), + ("$filter", "secret eq 'literal-canary'"), + ]) + .expect("query parses"); + let rendered = format!("{query:?}"); + assert!(!rendered.contains("caseworker-canary")); + assert!(!rendered.contains("secret")); + assert!(!rendered.contains("literal-canary")); + + let token_query = strict_query::parse_read_query([("$skiptoken", "cursor-canary")]) + .expect("cursor query parses"); + assert!(!format!("{token_query:?}").contains("cursor-canary")); +} + impl RecordingReadService { fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) @@ -342,7 +822,7 @@ impl RecordReadService for RecordingReadService { Box::pin(async move { Ok(Some(held(project_fixture( json!({ - "id": "record-1", + "id": "00000000-0000-4000-8000-000000000001", "revision": 1, "data": { "label": "Visible label", @@ -361,11 +841,17 @@ impl RecordReadService for RecordingReadService { ) -> ServiceFuture<'_, Result> { let selected_fields = request.selected_fields.clone(); let maximum_records = request.maximum_records; + let include_count = match &request.kind { + RecordReadKind::List { plan } | RecordReadKind::Relationship { plan, .. } => { + plan.include_count + } + RecordReadKind::Get { .. } | RecordReadKind::Lookup { .. } => false, + }; self.record(request); Box::pin(async move { let mut records = vec![project_fixture( json!({ - "id": "record-1", + "id": "00000000-0000-4000-8000-000000000001", "revision": 1, "data": { "label": "Visible label", @@ -376,7 +862,33 @@ impl RecordReadService for RecordingReadService { &selected_fields, )]; records.truncate(maximum_records); - Ok(held(json!({"items": records}))) + let mut response = json!({"items": records, "pageInfo": {"nextCursor": null}}); + if include_count { + response["count"] = json!(1); + } + Ok(held(response)) + }) + } + + fn lookup( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + let selected_fields = request.selected_fields.clone(); + self.record(request); + Box::pin(async move { + Ok(Some(held(project_fixture( + json!({ + "id": "00000000-0000-4000-8000-000000000001", + "revision": 1, + "data": { + "label": "Visible label", + "secret": "DO-NOT-LEAK", + "jurisdiction": "area-a" + } + }), + &selected_fields, + )))) }) } @@ -413,7 +925,11 @@ struct Harness { impl Harness { fn new(ready: bool) -> Self { - let project = parse_project_yaml(PROJECT.as_bytes()).expect("project parses"); + Self::from_project(PROJECT, ready) + } + + fn from_project(source: &str, ready: bool) -> Self { + let project = parse_project_yaml(source.as_bytes()).expect("project parses"); let registry = Arc::new( compile_project(&project, &[], CompileProfile::Authoring).expect("project compiles"), ); @@ -450,6 +966,43 @@ impl Harness { let mut app = self.app.clone(); app.call(request).await.expect("response") } + + async fn send_json( + &self, + method: Method, + uri: &str, + claims: Option, + body: Value, + ) -> axum::response::Response { + self.send_body( + method, + uri, + claims, + Some("application/json"), + Body::from(serde_json::to_vec(&body).expect("JSON body serializes")), + ) + .await + } + + async fn send_body( + &self, + method: Method, + uri: &str, + claims: Option, + content_type: Option<&str>, + body: Body, + ) -> axum::response::Response { + let mut request = Request::builder().method(method).uri(uri); + if let Some(content_type) = content_type { + request = request.header(CONTENT_TYPE, content_type); + } + let mut request = request.body(body).expect("request"); + if let Some(claims) = claims { + request.extensions_mut().insert(claims); + } + let mut app = self.app.clone(); + app.call(request).await.expect("response") + } } fn revision_harness() -> (axum::Router, Arc) { @@ -546,21 +1099,21 @@ async fn profile_and_resource_concealment_complete_before_record_io() { let unauthorized = harness .send( Method::GET, - "/v1/records/cases/record-1?accessProfile=caseworker", + "/v1/records/cases/00000000-0000-4000-8000-000000000001?accessProfile=caseworker", Some(wrong_purpose), ) .await; let unknown_profile = harness .send( Method::GET, - "/v1/records/cases/record-1?accessProfile=missing", + "/v1/records/cases/00000000-0000-4000-8000-000000000001?accessProfile=missing", Some(caseworker_claims("case-management")), ) .await; let unknown_resource = harness .send( Method::GET, - "/v1/records/unknown/record-1?accessProfile=caseworker", + "/v1/records/unknown/00000000-0000-4000-8000-000000000001?accessProfile=caseworker", Some(caseworker_claims("case-management")), ) .await; @@ -587,7 +1140,7 @@ async fn profile_and_resource_concealment_complete_before_record_io() { let fallback = harness .send( Method::GET, - "/v1/records/cases/record-1?accessProfile=caseworker", + "/v1/records/cases/00000000-0000-4000-8000-000000000001?accessProfile=caseworker", Some(fallback_claim), ) .await; @@ -599,7 +1152,11 @@ async fn profile_and_resource_concealment_complete_before_record_io() { async fn projection_can_only_reduce_the_authorized_profile() { let harness = Harness::new(true); let public = harness - .send(Method::GET, "/v1/records/cases/record-1?fields=label", None) + .send( + Method::GET, + "/v1/records/cases/00000000-0000-4000-8000-000000000001?$select=label", + None, + ) .await; assert_eq!(public.status(), StatusCode::OK); let public = body_json(public).await; @@ -607,7 +1164,7 @@ async fn projection_can_only_reduce_the_authorized_profile() { assert!(!public.to_string().contains("DO-NOT-LEAK")); let public_list = harness - .send(Method::GET, "/v1/records/cases?fields=label", None) + .send(Method::GET, "/v1/records/cases?$select=label", None) .await; assert_eq!(public_list.status(), StatusCode::OK); let public_list = body_json(public_list).await; @@ -625,7 +1182,7 @@ async fn projection_can_only_reduce_the_authorized_profile() { let before = harness.records.calls(); let caller_limit = harness - .send(Method::GET, "/v1/records/cases?pageSize=101", None) + .send(Method::GET, "/v1/records/cases?$top=101", None) .await; assert_eq!(caller_limit.status(), StatusCode::BAD_REQUEST); assert_eq!(harness.records.calls(), before); @@ -633,7 +1190,7 @@ async fn projection_can_only_reduce_the_authorized_profile() { let widening = harness .send( Method::GET, - "/v1/records/cases/record-1?fields=secret", + "/v1/records/cases/00000000-0000-4000-8000-000000000001?$select=secret", None, ) .await; @@ -643,7 +1200,7 @@ async fn projection_can_only_reduce_the_authorized_profile() { let protected = harness .send( Method::GET, - "/v1/records/cases/record-1?accessProfile=caseworker&fields=label,secret", + "/v1/records/cases/00000000-0000-4000-8000-000000000001?accessProfile=caseworker&$select=label,secret", Some(caseworker_claims("case-management")), ) .await; @@ -681,24 +1238,25 @@ async fn discovery_surfaces_share_caller_filtered_routes_and_fields() { assert_eq!( query_parameter_names(&public_openapi["paths"]["/v1/records/cases"]["get"]["parameters"]), [ + "$count", + "$filter", + "$orderby", + "$select", + "$skiptoken", + "$top", "accessProfile", - "cursor", - "fields", - "filter", - "pageSize", - "sort" ] ); let page_size = public_openapi["paths"]["/v1/records/cases"]["get"]["parameters"] .as_array() .expect("query parameters are rendered") .iter() - .find(|parameter| parameter["name"] == "pageSize") - .expect("pageSize parameter is rendered"); + .find(|parameter| parameter["name"] == "$top") + .expect("$top parameter is rendered"); assert_eq!(page_size["required"], false); assert_eq!( page_size["schema"], - json!({"type": "integer", "minimum": 1}) + json!({"type": "integer", "minimum": 1, "maximum": 100}) ); assert_eq!( public_openapi["components"]["schemas"]["case"]["properties"], @@ -1057,7 +1615,7 @@ async fn real_router_serves_only_authorized_explicit_revision_routes() { let extra_query = send_to( &app, Method::GET, - &format!("{list_path}?accessProfile=caseworker&pageSize=1"), + &format!("{list_path}?accessProfile=caseworker&$top=1"), Some(caseworker_claims("case-management")), ) .await; @@ -1141,7 +1699,7 @@ async fn real_router_serves_only_authorized_explicit_revision_routes() { let audit_failure = send_to( &app, Method::GET, - &format!("{list_path}?pageSize=2"), + &format!("{list_path}?$top=2"), Some(caseworker_claims("case-management")), ) .await; @@ -1209,6 +1767,7 @@ fn operation_name(operation: Operation) -> &'static str { match operation { Operation::Get => "get", Operation::List => "list", + Operation::Lookup => "lookup", Operation::Create => "create", Operation::Patch => "patch", Operation::Tombstone => "tombstone", @@ -1223,13 +1782,28 @@ async fn every_compiled_mutation_route_is_absent_from_the_served_router() { let claims = Some(caseworker_claims("case-management")); for (method, uri) in [ (Method::POST, "/v1/records/cases"), - (Method::PATCH, "/v1/records/cases/record-1"), - (Method::DELETE, "/v1/records/cases/record-1"), + ( + Method::PATCH, + "/v1/records/cases/00000000-0000-4000-8000-000000000001", + ), + ( + Method::DELETE, + "/v1/records/cases/00000000-0000-4000-8000-000000000001", + ), (Method::POST, "/v1/records/cases:batch"), - (Method::GET, "/v1/records/cases/record-1/revisions"), + ( + Method::GET, + "/v1/records/cases/00000000-0000-4000-8000-000000000001/revisions", + ), (Method::POST, "/v1/records/notes"), - (Method::PATCH, "/v1/records/notes/record-1"), - (Method::DELETE, "/v1/records/notes/record-1"), + ( + Method::PATCH, + "/v1/records/notes/00000000-0000-4000-8000-000000000001", + ), + ( + Method::DELETE, + "/v1/records/notes/00000000-0000-4000-8000-000000000001", + ), ] { let response = harness.send(method, uri, claims.clone()).await; assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); @@ -1239,19 +1813,51 @@ async fn every_compiled_mutation_route_is_absent_from_the_served_router() { } fn caseworker_claims(purpose: &str) -> VerifiedRequestClaims { + caseworker_claims_with_direct( + purpose, + std::iter::empty::<(&'static str, VerifiedClaimValue)>(), + ) +} + +fn caseworker_claims_with_direct(purpose: &str, direct_claims: I) -> VerifiedRequestClaims +where + I: IntoIterator, +{ + let mut direct = BTreeMap::from([( + "jurisdictions".to_owned(), + VerifiedClaimValue::direct_string_set(["area-a", "area-b"]).expect("direct claims"), + )]); + for (name, value) in direct_claims { + direct.insert(name.to_owned(), value); + } VerifiedRequestClaims::authenticated( "registry_principal", "principal-value-never-rendered", BTreeSet::from(["registry.read".to_owned()]), Some(purpose.to_owned()), - BTreeMap::from([( - "jurisdictions".to_owned(), - VerifiedClaimValue::direct_string_set(["area-a", "area-b"]).expect("direct claims"), - )]), + direct, ) .expect("verified context") } +fn request_query(request: &RecordReadRequest) -> ®istry_server::api::CompiledReadQuery { + match &request.kind { + RecordReadKind::List { plan } | RecordReadKind::Relationship { plan, .. } => plan, + RecordReadKind::Get { .. } | RecordReadKind::Lookup { .. } => { + panic!("request did not carry a list query plan") + } + } +} + +fn single_filter_predicate( + query: ®istry_server::api::CompiledReadQuery, +) -> ®istry_server::api::ReadFilterPredicate { + match query.filter.as_ref().expect("query has a filter") { + registry_server::api::ReadFilterExpr::Predicate(predicate) => predicate, + other => panic!("expected one predicate filter, got {other:?}"), + } +} + fn project_fixture(mut record: Value, selected_fields: &BTreeSet) -> Value { record["data"] .as_object_mut() @@ -1264,11 +1870,15 @@ fn held(value: Value) -> HeldReadResponse { HeldReadResponse::from_json(&value).expect("fake read response serializes") } -async fn body_json(response: axum::response::Response) -> Value { +async fn body_bytes(response: axum::response::Response) -> Vec { let bytes = to_bytes(response.into_body(), 1024 * 1024) .await .expect("response body"); - serde_json::from_slice(&bytes).expect("JSON response") + bytes.to_vec() +} + +async fn body_json(response: axum::response::Response) -> Value { + serde_json::from_slice(&body_bytes(response).await).expect("JSON response") } fn assert_no_mutation_methods(document: &Value) { diff --git a/crates/registry-server/tests/migration_plan.rs b/crates/registry-server/tests/migration_plan.rs index 5cf78f7c73..f01bb330d7 100644 --- a/crates/registry-server/tests/migration_plan.rs +++ b/crates/registry-server/tests/migration_plan.rs @@ -791,6 +791,7 @@ fn prepare_reviewed_package( id: "core".to_owned(), path: "source/modules/core/module.yaml".to_owned(), bytes: source.module_bytes, + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: "tests/journeys.yaml".to_owned(), diff --git a/crates/registry-server/tests/package_change_plan.rs b/crates/registry-server/tests/package_change_plan.rs index 91d6a6b096..1408e80c48 100644 --- a/crates/registry-server/tests/package_change_plan.rs +++ b/crates/registry-server/tests/package_change_plan.rs @@ -6,8 +6,11 @@ use std::fs; use registry_platform_canonical_json::canonicalize_json; -use registry_server::compiler::{compile_project, module_digest, CompileProfile}; -use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::compiler::{ + compile_project, compile_project_with_assets, module_digest, module_digest_with_assets, + CompileProfile, +}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml, ModuleAssetSource}; #[cfg(feature = "tooling")] use registry_server::migration_plan::{ ArtifactDigestBinding, ChunkCursorProtocol, ExternalBackupBinding, MigrationRehearsalReceipt, @@ -80,7 +83,7 @@ fn new_optional_scalar_field_emits_only_closed_add_column() { Some(PRIOR_REVISION) ); assert_eq!(plan.changes, change_set.changes); - assert_eq!(plan.statements.len(), 1); + assert_eq!(plan.statements.len(), 2); assert_eq!(plan.statements[0].id, "entity.asset.field.color.column"); assert!(plan.statements[0] .sql @@ -88,6 +91,10 @@ fn new_optional_scalar_field_emits_only_closed_add_column() { assert!(plan.statements[0].sql.contains(" ADD COLUMN ")); assert!(plan.statements[0].sql.contains("varchar(16)")); assert!(!plan.statements[0].sql.contains("CREATE TABLE")); + assert_eq!(plan.statements[1].id, "entity.asset.source-view"); + assert!(plan.statements[1] + .sql + .starts_with("CREATE OR REPLACE VIEW ")); let rendered_changes = serde_json::to_string(&change_set.changes).expect("changes serialize"); for forbidden in [ @@ -172,6 +179,7 @@ fn new_reference_constraint_and_index_are_supported_additive_statements() { "entity.asset.field.site.reference", "entity.asset.constraint.code-unique", "entity.asset.index.code-idx", + "entity.asset.source-view", ] ); } @@ -308,19 +316,37 @@ fn complete_extension_surface_modules_are_order_independent() { } #[test] -fn equivalent_reordered_inputs_produce_byte_stable_change_sets() { +fn equivalent_reordered_inputs_produce_stable_change_and_statement_inventory() { let previous = compile_variant(Variant::Base, 1); let candidate = compile_variant(Variant::ReferenceConstraintIndex, 2); let reordered = compile_variant(Variant::ReferenceConstraintIndexReordered, 2); let first = compiled_registry_change_set(&previous, &candidate, PRIOR_REVISION); let second = compiled_registry_change_set(&previous, &reordered, PRIOR_REVISION); let first_bytes = - canonicalize_json(&serde_json::to_value(&first).expect("first change set serializes")) - .expect("first change set canonicalizes"); - let second_bytes = - canonicalize_json(&serde_json::to_value(&second).expect("second change set serializes")) - .expect("second change set canonicalizes"); + canonicalize_json(&serde_json::to_value(&first.changes).expect("first changes serialize")) + .expect("first changes canonicalize"); + let second_bytes = canonicalize_json( + &serde_json::to_value(&second.changes).expect("second changes serialize"), + ) + .expect("second changes canonicalize"); assert_eq!(first_bytes, second_bytes); + let first_statement_ids = first + .migration_plan + .as_ref() + .expect("first additive migration plan exists") + .statements + .iter() + .map(|statement| statement.id.as_str()) + .collect::>(); + let second_statement_ids = second + .migration_plan + .as_ref() + .expect("second additive migration plan exists") + .statements + .iter() + .map(|statement| statement.id.as_str()) + .collect::>(); + assert_eq!(first_statement_ids, second_statement_ids); } #[test] @@ -360,6 +386,101 @@ fn generated_successor_plan_passes_package_validation_with_prior_revision() { assert_eq!(successor.manifest().migration_plan, expected_plan); } +#[test] +fn derived_sql_asset_bytes_change_revisions_and_emit_generated_view_replacement() { + let previous_source = derived_source_for_sql( + 1, + b"SELECT a.id AS id, a.code AS summary FROM registry_source.asset a", + ); + let previous = compile_derived_source(&previous_source); + let previous_package = registry_server::package::prepare_package(derived_build_request( + &previous_source, + 1, + None, + None, + )) + .expect("initial derived package prepares"); + + let candidate_source = derived_source_for_sql( + 2, + b"SELECT a.id AS id, (a.code) AS summary FROM registry_source.asset a", + ); + let candidate = compile_derived_source(&candidate_source); + let successor = registry_server::package::prepare_package(derived_build_request( + &candidate_source, + 2, + Some(&previous), + Some(previous_package.package_revision()), + )) + .expect("successor derived package prepares"); + + assert_ne!( + previous.module_closure()[0].digest, + candidate.module_closure()[0].digest + ); + assert_ne!(previous.revision(), candidate.revision()); + assert_ne!( + previous_package.package_revision(), + successor.package_revision() + ); + assert!(successor + .file_bytes() + .contains_key("source/modules/core/sql/summary.sql")); + assert!(successor.manifest().files.iter().any(|entry| { + entry.path == "source/modules/core/sql/summary.sql" + && entry.role == registry_server::package::PackageFileRole::SourceModuleAsset + })); + + let change_set = + compiled_registry_change_set(&previous, &candidate, previous_package.package_revision()); + assert_change( + &change_set, + CompiledRegistryChangeClass::CompatibleAdditive, + CompiledRegistryChangeCode::DerivedRelationChanged, + ); + let plan = change_set_to_applicable_migration_plan(&change_set) + .expect("same-contract SQL replacement is generated"); + assert!(plan.statements.iter().any(|statement| { + statement.id == "entity.asset.derived.summary.view" + && statement.sql.starts_with("CREATE OR REPLACE VIEW ") + })); + + let source_asset_entry = successor + .manifest() + .files + .iter() + .find(|entry| entry.path == "source/modules/core/sql/summary.sql") + .expect("asset file entry exists"); + assert!(source_asset_entry.sha256.starts_with("sha256:")); +} + +#[test] +fn oversized_derived_sql_asset_is_refused_before_compilation() { + let mut source = derived_source_for_sql( + 1, + b"SELECT a.id AS id, a.code AS summary FROM registry_source.asset a", + ); + source.sql = vec![b'x'; 256 * 1024 + 1]; + let module = parse_module_yaml(&source.module_bytes).expect("derived module parses"); + source.project_bytes = project_bytes( + 1, + &module_digest_with_assets( + &module, + &[ModuleAssetSource { + module: Some("core".to_owned()), + path: "sql/summary.sql".to_owned(), + bytes: source.sql.clone(), + }], + ), + ); + + assert_eq!( + registry_server::package::prepare_package(derived_build_request(&source, 1, None, None)) + .err(), + Some(registry_server::package::PackageError::Derivation) + ); +} + #[cfg(feature = "tooling")] #[test] fn metadata_only_reviewed_migration_covers_non_sql_surface_without_dummy_sql() { @@ -512,7 +633,7 @@ fn inspected_migration_summaries_are_exact_deterministic_and_value_free() { "destructiveOrIrreversible": 0, "unsupported": 0, }, - "generatedStatementCount": 1, + "generatedStatementCount": 2, "reviewedMigrations": [], }) ); @@ -532,7 +653,7 @@ fn inspected_migration_summaries_are_exact_deterministic_and_value_free() { "destructiveOrIrreversible": 0, "unsupported": 0, }, - "generatedStatementCount": 1, + "generatedStatementCount": 2, "reviewedMigrations": [{ "changeClass": "data_backfill_required", "recovery": "exact_target_resume", @@ -672,6 +793,12 @@ struct SourceFixture { module_bytes: Vec, } +struct DerivedSourceFixture { + project_bytes: Vec, + module_bytes: Vec, + sql: Vec, +} + fn compile_variant(variant: Variant, sequence: u64) -> CompiledRegistry { let source = source_for_variant(variant, sequence); let module = parse_module_yaml(&source.module_bytes).expect("fixture module parses"); @@ -697,6 +824,65 @@ fn project_bytes(sequence: u64, module_digest: &str) -> Vec { .into_bytes() } +fn derived_source_for_sql(sequence: u64, sql: &[u8]) -> DerivedSourceFixture { + let module_bytes = br#"{"id":"core","version":"1","entities":[{"id":"asset","route":"assets","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}],"derived":[{"id":"summary","sql":"sql/summary.sql","key":"id","fields":[{"id":"summary","type":"string","maxLength":16,"classification":"internal"}]}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get","list"],"readableFields":["code","summary"]}]}]}"#.to_vec(); + let module = parse_module_yaml(&module_bytes).expect("derived module parses"); + let digest = module_digest_with_assets( + &module, + &[ModuleAssetSource { + module: Some("core".to_owned()), + path: "sql/summary.sql".to_owned(), + bytes: sql.to_vec(), + }], + ); + DerivedSourceFixture { + project_bytes: project_bytes(sequence, &digest), + module_bytes, + sql: sql.to_vec(), + } +} + +fn compile_derived_source(source: &DerivedSourceFixture) -> CompiledRegistry { + let project = parse_project_yaml(&source.project_bytes).expect("derived project parses"); + let module = parse_module_yaml(&source.module_bytes).expect("derived module parses"); + compile_project_with_assets( + &project, + &[module], + &[ModuleAssetSource { + module: Some("core".to_owned()), + path: "sql/summary.sql".to_owned(), + bytes: source.sql.clone(), + }], + CompileProfile::Production, + ) + .expect("derived fixture compiles") +} + +fn derived_build_request( + source: &DerivedSourceFixture, + sequence: u64, + prior_registry: Option<&CompiledRegistry>, + prior_revision: Option<&str>, +) -> PackageBuildRequest { + let mut request = build_request( + sequence, + prior_revision, + source.project_bytes.clone(), + source.module_bytes.clone(), + match prior_registry { + Some(registry) => PackageMigrationPlanInput::Successor { + prior_registry: Box::new(registry.clone()), + }, + None => PackageMigrationPlanInput::InitialCompiledDdl, + }, + ); + request.modules[0].assets = vec![PackageSourceFile { + path: "sql/summary.sql".to_owned(), + bytes: source.sql.clone(), + }]; + request +} + fn module_bytes(variant: Variant) -> Vec { let asset = match variant { Variant::OptionalField => asset_entity( @@ -967,6 +1153,7 @@ fn build_request( id: "core".to_owned(), path: "source/modules/core/module.yaml".to_owned(), bytes: module_bytes, + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: "tests/journeys.yaml".to_owned(), diff --git a/crates/registry-server/tests/postgres_compiled_schema.rs b/crates/registry-server/tests/postgres_compiled_schema.rs index ddcfcf0960..b2a534453a 100644 --- a/crates/registry-server/tests/postgres_compiled_schema.rs +++ b/crates/registry-server/tests/postgres_compiled_schema.rs @@ -9,8 +9,8 @@ mod postgres_harness; use std::time::Duration; use postgres_harness::TestDatabase; -use registry_server::compiler::{compile_project, CompileProfile}; -use registry_server::contract::{parse_project_json, parse_project_yaml}; +use registry_server::compiler::{compile_project, compile_project_with_assets, CompileProfile}; +use registry_server::contract::{parse_project_json, parse_project_yaml, ModuleAssetSource}; use registry_server::postgres::{ begin_record_transaction, initialize_registry_state_for_catalog_test, install_compiled_schema, verify_catalog_identity_for_catalog, ClaimContext, ExpectedManagedCatalog, RegistryLockKey, @@ -78,6 +78,18 @@ async fn compiled_postgres_schema_enforces_context_rls_and_exact_catalog() { .get_for_test() .await .expect("runtime connection is available"); + for schema in ["registry_source", "registry_derived", "registry_context"] { + let usage: bool = runtime + .query_one( + "SELECT has_schema_privilege(current_user, $1, 'USAGE') + AND NOT has_schema_privilege(current_user, $1, 'CREATE')", + &[&schema], + ) + .await + .expect("runtime schema privilege probe succeeds") + .get(0); + assert!(usage, "runtime has only USAGE on {schema}"); + } let missing: i64 = runtime .query_one(&format!("SELECT count(*) FROM registry_data.{table}"), &[]) .await @@ -300,9 +312,223 @@ async fn compiled_postgres_schema_enforces_context_rls_and_exact_catalog() { assert_catalog_drift_is_rejected(&database, &catalog, &identity, &table).await; database.cleanup().await; + install_derived_view_fixture().await; install_asset_fixture().await; } +async fn install_derived_view_fixture() { + let registry = derived_registry(); + let database = TestDatabase::create(1).await; + let (migration, migration_task) = database.connect_migration().await; + install_compiled_schema(&migration, ®istry, &database.runtime_role) + .await + .expect("derived PostgreSQL schema installs"); + let catalog = ExpectedManagedCatalog::compiled(®istry); + let identity = initialize_registry_state_for_catalog_test( + &migration, + &database.runtime_role, + &catalog, + RegistryStateTestIdentity { + package_id: PACKAGE_ID, + environment: "local", + instance_id: INSTANCE_ID, + database_id: DATABASE_ID, + package_revision: "derived-package-1", + package_sequence: 1, + }, + ) + .await + .expect("derived catalog binds active Registry identity"); + verify_catalog_identity_for_catalog( + &migration, + &identity, + &catalog, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("derived catalog passes exact verification"); + + let entity = ®istry.entities()["household"]; + let table = quote_identifier(&entity.physical_table); + let tenant = quote_identifier(&entity.fields["tenant"].physical_name); + let size = quote_identifier(&entity.fields["size"].physical_name); + let source_view = quote_identifier(&entity.source_relation.sql_name); + let derived_view_name = registry + .ddl() + .views + .iter() + .find(|view| view.id == "entity.household.derived.facts") + .expect("derived view is in compiled DDL inventory") + .name + .clone(); + let derived_view = quote_identifier(&derived_view_name); + + let pool = database + .runtime_config + .build_pool() + .expect("bounded runtime pool builds"); + let lock_key = RegistryLockKey::derive("derived-schema-test").expect("lock key is bounded"); + let claims = ClaimContext::for_compiled( + ®istry, + "household", + Some("principal".to_owned()), + "operator", + None, + vec![RowBoundaryContext::Equals { + field: "tenant".to_owned(), + value: "north".to_owned(), + }], + ) + .expect("derived claims match profile"); + let mut client = pool + .get_for_test() + .await + .expect("runtime connection is available"); + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &claims, + ) + .await + .expect("runtime transaction starts"); + transaction + .transaction_for_test() + .execute( + &format!( + "INSERT INTO registry_data.{table} (record_id, {tenant}, {size}) + VALUES ('00000000-0000-4000-8000-000000000301', 'north', 3)" + ), + &[], + ) + .await + .expect("matching derived seed row is accepted"); + transaction.commit().await.expect("seed commits"); + + let transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &claims, + ) + .await + .expect("read transaction starts"); + transaction + .transaction_for_test() + .execute( + "SELECT set_config('registry.evaluation_date', '2026-08-30', true)", + &[], + ) + .await + .expect("test installs explicit evaluation date"); + let options: String = transaction + .transaction_for_test() + .query_one( + "SELECT COALESCE(array_to_string(reloptions, ','), '') + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'registry_derived' AND c.relname = $1", + &[&derived_view_name], + ) + .await + .expect("derived view options are inspectable") + .get(0); + assert!(options.contains("security_invoker=true")); + assert!(options.contains("security_barrier=true")); + let row = transaction + .transaction_for_test() + .query_one( + &format!( + "SELECT pg_typeof(child_count)::text, child_count, observed_on::text + FROM registry_derived.{derived_view}" + ), + &[], + ) + .await + .expect("derived wrapper casts declared output types"); + assert_eq!(row.get::<_, String>(0), "bigint"); + assert_eq!(row.get::<_, i64>(1), 3); + assert_eq!(row.get::<_, String>(2), "2026-08-30"); + let source_visible: i64 = transaction + .transaction_for_test() + .query_one( + &format!("SELECT count(*) FROM registry_source.{source_view}"), + &[], + ) + .await + .expect("source view remains RLS confined") + .get(0); + assert_eq!(source_visible, 1); + assert!(transaction + .transaction_for_test() + .execute( + &format!("UPDATE registry_derived.{derived_view} SET child_count = 5"), + &[], + ) + .await + .is_err()); + transaction.rollback().await.expect("proof rolls back"); + + let denied_claims = ClaimContext::for_compiled( + ®istry, + "household", + Some("principal".to_owned()), + "operator", + None, + vec![RowBoundaryContext::Equals { + field: "tenant".to_owned(), + value: "south".to_owned(), + }], + ) + .expect("denied derived claims match profile shape"); + let denied_transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &denied_claims, + ) + .await + .expect("denied read transaction starts"); + denied_transaction + .transaction_for_test() + .execute( + "SELECT set_config('registry.evaluation_date', '2026-08-30', true)", + &[], + ) + .await + .expect("test installs explicit evaluation date for denied read"); + let source_denied: i64 = denied_transaction + .transaction_for_test() + .query_one( + &format!("SELECT count(*) FROM registry_source.{source_view}"), + &[], + ) + .await + .expect("source view can be queried through denied RLS") + .get(0); + let derived_denied: i64 = denied_transaction + .transaction_for_test() + .query_one( + &format!("SELECT count(*) FROM registry_derived.{derived_view}"), + &[], + ) + .await + .expect("derived view can be queried through denied RLS") + .get(0); + assert_eq!(source_denied, 0); + assert_eq!(derived_denied, 0); + denied_transaction + .rollback() + .await + .expect("denied proof rolls back"); + migration_task.abort(); + database.cleanup().await; +} + async fn insert_row( client: &mut deadpool_postgres::Client, lock_key: RegistryLockKey, @@ -676,3 +902,48 @@ fn compiled_registry() -> registry_server::CompiledRegistry { compile_project(&project, &[], CompileProfile::Authoring) .expect("compiled PostgreSQL fixture compiles") } + +fn derived_registry() -> registry_server::CompiledRegistry { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"derived-postgres","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"household","route":"households","mutationMode":"mutable", + "fields":[ + {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, + {"id":"size","type":"int64","required":true,"classification":"internal"} + ], + "derived":[{ + "id":"facts","sql":"sql/facts.sql","key":"id","execution":"live", + "fields":[ + {"id":"child-count","type":"int64","classification":"internal"}, + {"id":"observed-on","type":"date","classification":"internal"} + ] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "operations":["create","get","list"], + "readableFields":["tenant","size","child-count","observed-on"], + "writableFields":["tenant","size"], + "filterableFields":["child-count"], + "sortableFields":["child-count"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}] + }] + }] + }"#, + ) + .expect("derived PostgreSQL fixture parses"); + compile_project_with_assets( + &project, + &[], + &[ModuleAssetSource { + module: None, + path: "sql/facts.sql".to_owned(), + bytes: b"SELECT h.id AS id, h.size AS child_count, registry_context.evaluation_date() AS observed_on FROM registry_source.household h".to_vec(), + }], + CompileProfile::Authoring, + ) + .expect("derived PostgreSQL fixture compiles") +} diff --git a/crates/registry-server/tests/postgres_data_export.rs b/crates/registry-server/tests/postgres_data_export.rs index 3febc1df28..814059d7a2 100644 --- a/crates/registry-server/tests/postgres_data_export.rs +++ b/crates/registry-server/tests/postgres_data_export.rs @@ -21,9 +21,7 @@ use registry_platform_testing::{oidc_verifier_config, MockIdp}; use registry_server::api::{ authenticated_router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, }; -use registry_server::auth::{ - AuthorityClaimConfig, RegistryAuthenticator, RowBoundaryClaimMapping, RowBoundaryClaimType, -}; +use registry_server::auth::{AuthorityClaimConfig, RegistryAuthenticator}; use registry_server::compiler::{compile_project, CompileProfile}; use registry_server::contract::parse_project_json; use registry_server::cursor::CursorCodec; @@ -299,7 +297,7 @@ async fn real_postgres_export_is_authenticated_projected_audited_and_resumable() let widened_body = canonicalize_json(&json!({ "items":[{"id":"00000000-0000-4000-8000-000000000001","revision":1, "data":{"code":"ROW-000","secret":SECRET_CANARY}}], - "pageInfo":{"nextCursor":null} + "nextCursor":null })) .unwrap(); let widened = execute_export_page( @@ -434,14 +432,7 @@ fn authenticated_app( ®istry, oidc_verifier_config(idp.issuer(), vec![AUDIENCE.to_owned()]), key_source, - AuthorityClaimConfig::new( - "registry_principal", - Some("purpose".to_owned()), - vec![RowBoundaryClaimMapping::new( - "jurisdictions", - RowBoundaryClaimType::DirectStringSet, - )], - ), + AuthorityClaimConfig::new("registry_principal", Some("purpose".to_owned())), ) .expect("OIDC authority matches the compiled Registry"), ); diff --git a/crates/registry-server/tests/postgres_fixture_journeys.rs b/crates/registry-server/tests/postgres_fixture_journeys.rs index c054091b08..9beb32d58a 100644 --- a/crates/registry-server/tests/postgres_fixture_journeys.rs +++ b/crates/registry-server/tests/postgres_fixture_journeys.rs @@ -362,6 +362,7 @@ async fn prepare_runner( id: "fixture-core", path: "sources/modules/fixture-core.yaml", bytes: MODULE_SOURCE, + assets: &[], }]; PostgresFixtureTestRunner::prepare( &package.package, @@ -574,6 +575,7 @@ fn package_fixture_with_journeys( id: "fixture-core".to_owned(), path: "sources/modules/fixture-core.yaml".to_owned(), bytes: MODULE_SOURCE.to_vec(), + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: FIXTURE_JOURNEYS_PATH.to_owned(), @@ -716,9 +718,6 @@ authentication: authorityClaims: principal: registry_principal purpose: purpose - rowBoundaryClaims: - - name: jurisdiction - type: directString audit: hashKeyRef: secret:file/audit-key cursor: diff --git a/crates/registry-server/tests/postgres_migration.rs b/crates/registry-server/tests/postgres_migration.rs index a382851325..3d79cff558 100644 --- a/crates/registry-server/tests/postgres_migration.rs +++ b/crates/registry-server/tests/postgres_migration.rs @@ -814,6 +814,7 @@ fn build_request( id: "core".to_owned(), path: "source/modules/core/module.yaml".to_owned(), bytes: module_bytes, + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: "tests/journeys.yaml".to_owned(), diff --git a/crates/registry-server/tests/postgres_package.rs b/crates/registry-server/tests/postgres_package.rs index e904938590..2e9f9ab24b 100644 --- a/crates/registry-server/tests/postgres_package.rs +++ b/crates/registry-server/tests/postgres_package.rs @@ -14,8 +14,10 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use postgres_harness::TestDatabase; use registry_platform_canonical_json::canonicalize_json; use registry_platform_crypto::{generate_private_jwk, sign, GeneratedKeyAlgorithm, PrivateJwk}; -use registry_server::compiler::{compile_project, module_digest, CompileProfile}; -use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::compiler::{ + compile_project, module_digest, module_digest_with_assets, CompileProfile, +}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml, ModuleAssetSource}; use registry_server::migration::{ apply_verified_package, ApplyPrecondition, ApplyRoles, ApplyTimeouts, ApplyVerifiedPackageRequest, MigrationError, @@ -267,6 +269,125 @@ fn fixture_journeys_are_required_at_the_fixed_path_and_change_the_package_revisi assert_eq!(first.registry(), second.registry()); } +#[test] +fn derived_sql_assets_are_captured_and_bound_to_revisions() { + let first = derived_asset_request( + b"SELECT r.id AS id, r.code AS summary FROM registry_source.neutral_record r", + ); + let second = derived_asset_request( + b"SELECT r.id AS id, (r.code) AS summary FROM registry_source.neutral_record r", + ); + let first = prepare_package(first).expect("first derived package prepares"); + let second = prepare_package(second).expect("second derived package prepares"); + + assert!(first + .file_bytes() + .contains_key("source/modules/core/sql/summary.sql")); + assert!(first.manifest().files.iter().any(|entry| { + entry.path == "source/modules/core/sql/summary.sql" + && entry.role == PackageFileRole::SourceModuleAsset + })); + assert_eq!( + first.manifest().sources.modules[0].assets, + vec!["sql/summary.sql".to_owned()] + ); + assert_ne!( + first.registry().module_closure(), + second.registry().module_closure() + ); + assert_ne!(first.registry().revision(), second.registry().revision()); + assert_ne!(first.package_revision(), second.package_revision()); +} + +#[test] +fn derived_sql_asset_tampering_is_refused_before_activation() { + let prepared = prepare_package(derived_asset_request( + b"SELECT r.id AS id, r.code AS summary FROM registry_source.neutral_record r", + )) + .expect("derived package prepares"); + let root = TempRoot::create(); + prepared + .publish_to_directory(root.path(), Vec::new()) + .expect("package publishes"); + let context = local_context(PackageIntent::InitialActivation); + load_package(root.path(), &context).expect("untampered asset package loads"); + + let asset_path = root.path().join("source/modules/core/sql/summary.sql"); + let original = fs::read(&asset_path).expect("asset reads"); + fs::write( + &asset_path, + b"SELECT r.id AS id, (r.code) AS summary FROM registry_source.neutral_record r", + ) + .expect("asset tamper writes"); + assert_eq!(load_error(root.path(), &context), PackageError::Integrity); + fs::write(&asset_path, original).expect("asset restores"); + + fs::remove_file(&asset_path).expect("asset removes"); + assert!(matches!( + load_error(root.path(), &context), + PackageError::Read | PackageError::Closure + )); +} + +#[test] +fn derived_sql_asset_extra_path_swap_and_size_are_refused() { + let prepared = prepare_package(derived_asset_request( + b"SELECT r.id AS id, r.code AS summary FROM registry_source.neutral_record r", + )) + .expect("derived package prepares"); + let root = TempRoot::create(); + prepared + .publish_to_directory(root.path(), Vec::new()) + .expect("package publishes"); + let context = local_context(PackageIntent::InitialActivation); + + fs::write( + root.path().join("source/modules/core/sql/unlisted.sql"), + b"SELECT r.id AS id, r.code AS summary FROM registry_source.neutral_record r", + ) + .expect("extra asset writes"); + assert_eq!(load_error(root.path(), &context), PackageError::Closure); + fs::remove_file(root.path().join("source/modules/core/sql/unlisted.sql")) + .expect("extra asset removes"); + + let original_path = root.path().join("source/modules/core/sql/summary.sql"); + let swapped_path = root.path().join("source/modules/core/sql/swapped.sql"); + fs::rename(&original_path, &swapped_path).expect("asset path swaps"); + rewrite_unsigned(root.path(), |manifest| { + manifest.sources.modules[0].assets = vec!["sql/swapped.sql".to_owned()]; + let entry = manifest + .files + .iter_mut() + .find(|entry| entry.path == "source/modules/core/sql/summary.sql") + .expect("asset entry exists"); + entry.path = "source/modules/core/sql/swapped.sql".to_owned(); + }); + assert_eq!(load_error(root.path(), &context), PackageError::Derivation); + + let mut oversized = derived_asset_request( + b"SELECT r.id AS id, r.code AS summary FROM registry_source.neutral_record r", + ); + let bytes = vec![b'x'; 256 * 1024 + 1]; + let module = parse_module_yaml(&oversized.modules[0].bytes).expect("module parses"); + oversized.modules[0].assets[0].bytes = bytes.clone(); + oversized.project.bytes = project_bytes( + "local", + 1, + &module_digest_with_assets( + &module, + &[ModuleAssetSource { + module: Some("core".to_owned()), + path: "sql/summary.sql".to_owned(), + bytes, + }], + ), + ); + assert_eq!( + prepare_package(oversized).err(), + Some(PackageError::Derivation) + ); +} + #[test] fn signed_package_refuses_missing_or_rehashed_substituted_fixture_journeys() { let signing = @@ -2061,6 +2182,41 @@ fn module_bytes(plan: PlanChoice) -> Vec { .into_bytes() } +fn derived_module_bytes() -> Vec { + br#"{"id":"core","version":"1","entities":[{"id":"neutral-record","route":"neutral-records","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}],"derived":[{"id":"summary","sql":"sql/summary.sql","key":"id","fields":[{"id":"summary","type":"string","maxLength":64,"classification":"internal"}]}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get","list"],"readableFields":["code","summary"]}]}]}"#.to_vec() +} + +fn derived_asset_request(sql: &[u8]) -> PackageBuildRequest { + let module_bytes = derived_module_bytes(); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); + let digest = module_digest_with_assets( + &module, + &[ModuleAssetSource { + module: Some("core".to_owned()), + path: "sql/summary.sql".to_owned(), + bytes: sql.to_vec(), + }], + ); + let mut request = build_request(BuildRequestParts { + environment: "local", + sequence: 1, + prior_revision: None, + schema_fingerprint: fingerprint(1), + project_bytes: project_bytes("local", 1, &digest), + module_bytes, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + }); + request.modules[0].assets = vec![PackageSourceFile { + path: "sql/summary.sql".to_owned(), + bytes: sql.to_vec(), + }]; + request +} + struct BuildRequestParts<'a> { environment: &'a str, sequence: u64, @@ -2090,6 +2246,7 @@ fn build_request(parts: BuildRequestParts<'_>) -> PackageBuildRequest { id: "core".to_owned(), path: "source/modules/core/module.yaml".to_owned(), bytes: parts.module_bytes, + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: "tests/journeys.yaml".to_owned(), diff --git a/crates/registry-server/tests/postgres_read.rs b/crates/registry-server/tests/postgres_read.rs index 5b77be7f85..5c9cad5521 100644 --- a/crates/registry-server/tests/postgres_read.rs +++ b/crates/registry-server/tests/postgres_read.rs @@ -101,7 +101,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { let get = send( &app, - &format!("/v1/records/widgets/{VISIBLE_RECORD}?fields=label"), + &format!("/v1/records/widgets/{VISIBLE_RECORD}?$select=label"), Some(read_claims(["zone-a"])), ) .await; @@ -122,7 +122,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { let decimal = send( &app, - &format!("/v1/records/widgets/{VISIBLE_RECORD}?fields=amount"), + &format!("/v1/records/widgets/{VISIBLE_RECORD}?$select=amount"), Some(read_claims(["zone-a"])), ) .await; @@ -138,7 +138,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { let list = send( &app, - "/v1/records/widgets?fields=label", + "/v1/records/widgets?$select=label", Some(read_claims(["zone-a"])), ) .await; @@ -167,7 +167,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { let repeated_in = send( &app, - "/v1/records/widgets?fields=label&filter=jurisdiction:in:zone-b&filter=jurisdiction:in:zone-a&pageSize=2", + "/v1/records/widgets?$select=label&$filter=jurisdiction%20in%20('zone-b','zone-a')&$top=2", Some(read_claims(["zone-a"])), ) .await; @@ -190,7 +190,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { let prefix = send( &app, - "/v1/records/widgets?fields=label&filter=label:prefix:literal%25_%5C", + "/v1/records/widgets?$select=label&$filter=startswith(label,'literal%25_%5C')", Some(read_claims(["zone-a"])), ) .await; @@ -203,7 +203,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { let continuation = send( &app, - &format!("/v1/records/widgets?cursor={next_cursor}"), + &format!("/v1/records/widgets?$skiptoken={next_cursor}"), Some(read_claims(["zone-a"])), ) .await; @@ -231,7 +231,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { let tampered = String::from_utf8(tampered).expect("cursor remains UTF-8"); let refused_cursor = send( &app, - &format!("/v1/records/widgets?cursor={tampered}"), + &format!("/v1/records/widgets?$skiptoken={tampered}"), Some(read_claims(["zone-a"])), ) .await; @@ -243,7 +243,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { let concealed = send( &app, - &format!("/v1/records/widgets/{MISMATCH_RECORD}?fields=label"), + &format!("/v1/records/widgets/{MISMATCH_RECORD}?$select=label"), Some(read_claims(["zone-a"])), ) .await; @@ -255,7 +255,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { let tombstoned = send( &app, - &format!("/v1/records/widgets/{TOMBSTONED_RECORD}?fields=label"), + &format!("/v1/records/widgets/{TOMBSTONED_RECORD}?$select=label"), Some(read_claims(["zone-a"])), ) .await; @@ -268,7 +268,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { let uppercase = send( &app, &format!( - "/v1/records/widgets/{}?fields=label", + "/v1/records/widgets/{}?$select=label", ALPHA_RECORD.to_ascii_uppercase() ), Some(read_claims(["zone-a"])), @@ -288,7 +288,7 @@ async fn real_postgres_read_is_authorized_bounded_minimized_and_audit_gated() { ); let faulted = send( &faulting_app, - &format!("/v1/records/widgets/{VISIBLE_RECORD}?fields=label"), + &format!("/v1/records/widgets/{VISIBLE_RECORD}?$select=label"), Some(read_claims(["zone-a"])), ) .await; @@ -359,7 +359,7 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { body_json( send( &app, - "/v1/records/assignments:as-of?fields=label&asOf=2020-05-31T23:59:59Z", + "/v1/records/assignments:as-of?$select=label&asOf=2020-05-31T23:59:59Z", Some(read_claims(["zone-a"])), ) .await, @@ -371,7 +371,7 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { body_json( send( &app, - "/v1/records/assignments:as-of?fields=label&asOf=2020-06-01T00:00:00Z", + "/v1/records/assignments:as-of?$select=label&asOf=2020-06-01T00:00:00Z", Some(read_claims(["zone-a"])), ) .await, @@ -381,7 +381,7 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { ); let current = send( &app, - "/v1/records/assignments:current?fields=label,valid-from,valid-to", + "/v1/records/assignments:current?$select=label,valid-from,valid-to", Some(read_claims(["zone-a"])), ) .await; @@ -395,7 +395,7 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { let sorted_first = send( &app, - "/v1/records/widgets?fields=label,rank&filter=label:prefix:sort-key-&sort=rank&pageSize=2", + "/v1/records/widgets?$select=label,rank&$filter=startswith(label,'sort-key-')&$orderby=rank&$top=2", Some(read_claims(["zone-a"])), ) .await; @@ -411,7 +411,7 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { .to_owned(); let sorted_second = send( &app, - &format!("/v1/records/widgets?cursor={sorted_second_cursor}"), + &format!("/v1/records/widgets?$skiptoken={sorted_second_cursor}"), Some(read_claims(["zone-a"])), ) .await; @@ -428,7 +428,7 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { .to_owned(); let sorted_third = send( &app, - &format!("/v1/records/widgets?cursor={sorted_third_cursor}"), + &format!("/v1/records/widgets?$skiptoken={sorted_third_cursor}"), Some(read_claims(["zone-a"])), ) .await; @@ -439,29 +439,29 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { let replay_cursor = next_cursor( &app, - "/v1/records/widgets?fields=label,rank&filter=label:prefix:sort-key-&sort=rank&pageSize=2", + "/v1/records/widgets?$select=label,rank&$filter=startswith(label,'sort-key-')&$orderby=rank&$top=2", Some(read_claims(["zone-a"])), ) .await; for (uri, claims) in [ ( - format!("/v1/records/widgets?cursor={replay_cursor}"), + format!("/v1/records/widgets?$skiptoken={replay_cursor}"), read_claims_with(PRINCIPAL_CANARY, "audit-review", ["zone-a"]), ), ( - format!("/v1/records/widgets?cursor={replay_cursor}"), + format!("/v1/records/widgets?$skiptoken={replay_cursor}"), read_claims_with("other-principal-value", "case-management", ["zone-a"]), ), ( - format!("/v1/records/widgets?cursor={replay_cursor}"), + format!("/v1/records/widgets?$skiptoken={replay_cursor}"), read_claims_with(PRINCIPAL_CANARY, "case-management", ["zone-b"]), ), ( - format!("/v1/records/widgets?accessProfile=auditor&cursor={replay_cursor}"), + format!("/v1/records/widgets?accessProfile=auditor&$skiptoken={replay_cursor}"), read_claims(["zone-a"]), ), ( - format!("/v1/records/assignments?cursor={replay_cursor}"), + format!("/v1/records/assignments?$skiptoken={replay_cursor}"), read_claims(["zone-a"]), ), ] { @@ -483,14 +483,14 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { ); assert_cursor_invalid( &package_changed_app, - &format!("/v1/records/widgets?cursor={replay_cursor}"), + &format!("/v1/records/widgets?$skiptoken={replay_cursor}"), Some(read_claims(["zone-a"])), ) .await; let projection_cursor = next_cursor( &app, - "/v1/records/widgets?fields=label,amount&filter=label:prefix:label-&sort=ordinal&pageSize=2", + "/v1/records/widgets?$select=label,amount&$filter=startswith(label,'label-')&$orderby=ordinal&$top=2", Some(read_claims(["zone-a"])), ) .await; @@ -506,7 +506,7 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { ); assert_cursor_invalid( &projection_changed_app, - &format!("/v1/records/widgets?cursor={projection_cursor}"), + &format!("/v1/records/widgets?$skiptoken={projection_cursor}"), Some(read_claims(["zone-a"])), ) .await; @@ -527,7 +527,7 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { ); assert_cursor_invalid( &changed_app, - &format!("/v1/records/widgets?cursor={replay_cursor}"), + &format!("/v1/records/widgets?$skiptoken={replay_cursor}"), Some(read_claims(["zone-a"])), ) .await; @@ -545,13 +545,13 @@ async fn real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced() { ); let expired_cursor = next_cursor( &expiring_app, - "/v1/records/widgets?fields=label&sort=ordinal&pageSize=1", + "/v1/records/widgets?$select=label&$orderby=ordinal&$top=1", Some(read_claims(["zone-a"])), ) .await; assert_cursor_invalid( &expiring_app, - &format!("/v1/records/widgets?cursor={expired_cursor}"), + &format!("/v1/records/widgets?$skiptoken={expired_cursor}"), Some(read_claims(["zone-a"])), ) .await; diff --git a/crates/registry-server/tests/postgres_startup.rs b/crates/registry-server/tests/postgres_startup.rs index 0843ee0724..6458bca676 100644 --- a/crates/registry-server/tests/postgres_startup.rs +++ b/crates/registry-server/tests/postgres_startup.rs @@ -1161,6 +1161,7 @@ impl PackageFixture { id: "core".to_owned(), path: "source/modules/core/module.yaml".to_owned(), bytes: module_source, + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: "tests/journeys.yaml".to_owned(), diff --git a/crates/registry-server/tests/postgres_webhook_delivery.rs b/crates/registry-server/tests/postgres_webhook_delivery.rs index 6eda17dda5..920c570d7c 100644 --- a/crates/registry-server/tests/postgres_webhook_delivery.rs +++ b/crates/registry-server/tests/postgres_webhook_delivery.rs @@ -1174,8 +1174,6 @@ authentication: authorityClaims: principal: registry_principal purpose: registry_purpose - rowBoundaryClaims: - - {{name: jurisdiction, type: directString}} audit: hashKeyRef: secret:file/audit-key cursor: diff --git a/crates/registry-server/tests/postgres_webhook_outbox.rs b/crates/registry-server/tests/postgres_webhook_outbox.rs index 820f9de106..20dff2e941 100644 --- a/crates/registry-server/tests/postgres_webhook_outbox.rs +++ b/crates/registry-server/tests/postgres_webhook_outbox.rs @@ -844,8 +844,6 @@ authentication: authorityClaims: principal: registry_principal purpose: registry_purpose - rowBoundaryClaims: - - {{name: jurisdiction, type: directString}} audit: hashKeyRef: secret:file/audit-key cursor: diff --git a/crates/registry-server/tests/runtime_config.rs b/crates/registry-server/tests/runtime_config.rs index 66aa43533a..97abdce59b 100644 --- a/crates/registry-server/tests/runtime_config.rs +++ b/crates/registry-server/tests/runtime_config.rs @@ -87,9 +87,6 @@ authentication: authorityClaims: principal: registry_principal purpose: registry_purpose - rowBoundaryClaims: - - {{name: jurisdiction, type: directStringSet}} - - {{name: tenant, type: directString}} audit: hashKeyRef: secret:file/audit-key cursor: diff --git a/crates/registry-server/tests/schema_fingerprint_rehearsal.rs b/crates/registry-server/tests/schema_fingerprint_rehearsal.rs index 054e3fab4b..3f9e7ce506 100644 --- a/crates/registry-server/tests/schema_fingerprint_rehearsal.rs +++ b/crates/registry-server/tests/schema_fingerprint_rehearsal.rs @@ -316,7 +316,6 @@ authentication: authorityClaims: principal: registry_principal purpose: registry_purpose - rowBoundaryClaims: [] audit: hashKeyRef: secret:env/REGISTRY_SERVER_REHEARSAL_AUDIT_KEY cursor: diff --git a/crates/registry-server/tests/startup_http.rs b/crates/registry-server/tests/startup_http.rs index 9180054564..2fc14b35fb 100644 --- a/crates/registry-server/tests/startup_http.rs +++ b/crates/registry-server/tests/startup_http.rs @@ -87,6 +87,13 @@ impl RecordReadService for NoopRecords { .map_err(|_| ReadServiceError::Unavailable) }) } + + fn lookup( + &self, + _request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(async { Ok(None) }) + } } struct SlowReadiness; @@ -404,7 +411,7 @@ async fn provenance_operational_logs_metrics_and_traces_are_separate_closed_and_ JwkSet { keys: Vec::new() }, JwksFetcherConfig::defaults(), )), - AuthorityClaimConfig::new("registry_principal", None, Vec::new()), + AuthorityClaimConfig::new("registry_principal", None), ) .expect("anonymous Registry has a valid production authenticator"), ); @@ -729,9 +736,6 @@ authentication: authorityClaims: principal: registry_principal purpose: registry_purpose - rowBoundaryClaims: - - {{name: jurisdiction, type: directStringSet}} - - {{name: tenant, type: directString}} audit: hashKeyRef: secret:file/audit-key cursor: diff --git a/crates/registry-server/tests/startup_ordering.rs b/crates/registry-server/tests/startup_ordering.rs index efff37bd20..3cec1cebe3 100644 --- a/crates/registry-server/tests/startup_ordering.rs +++ b/crates/registry-server/tests/startup_ordering.rs @@ -204,6 +204,7 @@ impl PackageFixture { id: "core".to_owned(), path: "source/modules/core/module.yaml".to_owned(), bytes: module_bytes, + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: "tests/journeys.yaml".to_owned(), diff --git a/crates/registry-server/tests/support/pilot_acceptance_harness.rs b/crates/registry-server/tests/support/pilot_acceptance_harness.rs index 1992e6cbb9..c1638b87a5 100644 --- a/crates/registry-server/tests/support/pilot_acceptance_harness.rs +++ b/crates/registry-server/tests/support/pilot_acceptance_harness.rs @@ -1,6 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -17,7 +16,7 @@ use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; use registry_platform_testing::MockIdp; use registry_server::compiler::{compile_project, CompileProfile}; use registry_server::contract::{ - parse_module_yaml, parse_project_yaml, BoundaryOperator, Operation, RegistryProject, + parse_module_yaml, parse_project_yaml, Operation, RegistryProject, }; use registry_server::package::{ load_package, PackageBuildRequest, PackageIntent, PackageLoadContext, @@ -374,6 +373,7 @@ impl PublishedPackage { id: id.clone(), path: format!("source/modules/{id}/module.yaml"), bytes: bytes.clone(), + assets: Vec::new(), }) .collect(), fixture_journeys: PackageSourceFile { @@ -512,7 +512,7 @@ fn write_runtime_config( database_id: &str, database: &TestDatabase, idp: &MockIdp, - registry: &CompiledRegistry, + _registry: &CompiledRegistry, ) -> PathBuf { let secrets = root.join("secrets"); fs::create_dir(&secrets).expect("pilot secret root creates"); @@ -522,7 +522,6 @@ fn write_runtime_config( ); write_secret(&secrets.join("audit-key"), &[0x6b; 32]); write_secret(&secrets.join("cursor-key"), &[0x43; 32]); - let row_boundary_claims = runtime_row_boundary_claims(registry); let path = root.join("runtime.yaml"); fs::write( &path, @@ -574,7 +573,7 @@ authentication: outageToleranceSeconds: 0 authorityClaims: principal: registry_principal - purpose: purpose{row_boundary_claims} + purpose: purpose audit: hashKeyRef: secret:file/audit-key cursor: @@ -606,36 +605,6 @@ operationalTimeouts: path } -fn runtime_row_boundary_claims(registry: &CompiledRegistry) -> String { - let mut claims = BTreeMap::new(); - for entity in registry.entities().values() { - for profile in entity.access_profiles.values() { - for boundary in &profile.row_boundaries { - let value_type = match boundary.operator { - BoundaryOperator::Equals => "directString", - BoundaryOperator::In => "directStringSet", - }; - if let Some(previous) = claims.insert(boundary.claim.as_str(), value_type) { - assert_eq!( - previous, value_type, - "one verified authority claim cannot have conflicting compiled types" - ); - } - } - } - } - if claims.is_empty() { - return String::new(); - } - let mut yaml = String::from("\n rowBoundaryClaims:"); - for (name, value_type) in claims { - yaml.push_str(&format!( - "\n - name: {name}\n type: {value_type}" - )); - } - yaml -} - fn write_secret(path: &Path, bytes: &[u8]) { fs::write(path, bytes).expect("pilot secret writes"); set_private_permissions(path); diff --git a/crates/registry-serverctl/src/lib.rs b/crates/registry-serverctl/src/lib.rs index 1d314d7ba2..de195a83c8 100644 --- a/crates/registry-serverctl/src/lib.rs +++ b/crates/registry-serverctl/src/lib.rs @@ -5,7 +5,7 @@ //! parsing, validation, compilation, and artifact generation remain in //! `registry-server`. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; use std::fs::{self, File}; use std::io::{self, Read, Write}; @@ -14,6 +14,7 @@ use std::process::ExitCode; use std::sync::atomic::{AtomicU64, Ordering}; use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use registry_server::contract::ModuleAssetSource; use registry_server::migration_plan::ReviewedMigrationRecovery; use registry_server::package::{ inspect_package_integrity, CompiledRegistryChangeClass, MigrationInspectionPlanKind, @@ -24,9 +25,9 @@ use registry_server::package::{ use registry_server::runtime_config::RuntimeConfigError; use registry_server::tooling::{classify_registry_diff, CompiledRegistryDiff, DiffClassification}; use registry_server::{ - compile_project, parse_module_yaml, parse_project_yaml, CompileFailure, CompileProfile, - CompiledRegistry, Diagnostic, DiagnosticSeverity, GeneratedArtifact, GeneratedArtifacts, - RegistryModule, RegistryProject, + compile_project_with_assets, parse_module_yaml, parse_project_yaml, CompileFailure, + CompileProfile, CompiledRegistry, Diagnostic, DiagnosticSeverity, GeneratedArtifact, + GeneratedArtifacts, RegistryModule, RegistryProject, }; use serde::Serialize; use serde_json::{json, Value}; @@ -55,6 +56,7 @@ const OPERATIONAL_FAILURE_EXIT: u8 = 3; // before runtime secret resolution or database rehearsal. Broader package-file // limits still apply to fixture journeys and generated package artifacts. const AUTHORED_SOURCE_REDERIVATION_MAX_BYTES: u64 = 1024 * 1024; +const MAX_DERIVED_SQL_ASSET_BYTES: u64 = 256 * 1024; static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Parser)] @@ -449,6 +451,7 @@ enum ExplainSubject { Model, Access, Routes, + Queries, Events, } @@ -761,6 +764,13 @@ struct CapturedModuleSource { id: String, module: RegistryModule, bytes: Vec, + assets: Vec, +} + +#[derive(Debug)] +struct CapturedModuleAssetSource { + path: String, + bytes: Vec, } #[derive(Clone, Debug)] @@ -1364,6 +1374,14 @@ fn capture_candidate( path: format!("source/modules/{}/module.yaml", module.id), id: module.id, bytes: module.bytes, + assets: module + .assets + .into_iter() + .map(|asset| PackageSourceFile { + path: asset.path, + bytes: asset.bytes, + }) + .collect(), }) .collect(); let fixture_journey_bytes = read_bounded_regular_file( @@ -2085,6 +2103,7 @@ fn explain( ExplainSubject::Model => explain_model(&compiled), ExplainSubject::Access => serde_json::to_value(compiled.access()), ExplainSubject::Routes => serde_json::to_value(compiled.routes()), + ExplainSubject::Queries => explain_queries(&compiled), ExplainSubject::Events => serde_json::to_value(compiled.event_deliveries()), } .map_err(|_| FailureReport { @@ -2137,22 +2156,35 @@ fn compile_captured_project( .iter() .map(|module| module.module.clone()) .collect::>(); - compile_project(&source.project, &modules, profile.into()).map_err(|failure| FailureReport { - ok: false, - command, - diagnostics: failure - .diagnostics() - .iter() - .cloned() - .map(|diagnostic| { - tool_diagnostic( - diagnostic, - DiagnosticArtifact::RegistryProject, - SuggestedAction::CorrectAuthoringSource, - ) + let assets = source + .modules + .iter() + .flat_map(|module| { + module.assets.iter().map(|asset| ModuleAssetSource { + module: Some(module.id.clone()), + path: asset.path.clone(), + bytes: asset.bytes.clone(), }) - .collect(), - }) + }) + .collect::>(); + compile_project_with_assets(&source.project, &modules, &assets, profile.into()).map_err( + |failure| FailureReport { + ok: false, + command, + diagnostics: failure + .diagnostics() + .iter() + .cloned() + .map(|diagnostic| { + tool_diagnostic( + remap_derived_diagnostic_path(diagnostic, source), + DiagnosticArtifact::RegistryProject, + SuggestedAction::CorrectAuthoringSource, + ) + }) + .collect(), + }, + ) } fn compiler_findings(compiled: &CompiledRegistry) -> Vec { @@ -2195,7 +2227,13 @@ fn capture_project_source(project_path: &Path) -> Result, Diagnostic>>()?; Ok(CapturedProjectSource { @@ -2291,6 +2329,96 @@ fn load_module_files( .collect() } +fn load_module_asset_files( + project_path: &Path, + module_id: &str, + module: &RegistryModule, +) -> Result, Diagnostic> { + let mut paths = BTreeSet::new(); + for entity in &module.entities { + for derived in &entity.derived { + validate_module_sql_asset_path(module_id, &derived.sql)?; + if !paths.insert(derived.sql.clone()) { + return Err(diagnostic( + "source.module_asset.duplicate", + &format!("modules/{module_id}/module.yaml"), + "derived SQL assets must be unique within a module", + )); + } + } + } + for extension in &module.extend_entities { + for derived in &extension.derived { + validate_module_sql_asset_path(module_id, &derived.sql)?; + if !paths.insert(derived.sql.clone()) { + return Err(diagnostic( + "source.module_asset.duplicate", + &format!("modules/{module_id}/module.yaml"), + "derived SQL assets must be unique within a module", + )); + } + } + } + paths + .into_iter() + .map(|path| { + let bytes = read_bounded_regular_file( + &project_path.join("modules").join(module_id).join(&path), + "source.module_asset.missing", + MAX_DERIVED_SQL_ASSET_BYTES, + )?; + if bytes.is_empty() { + return Err(diagnostic( + "source.module_asset.bounds", + &format!("modules/{module_id}/{path}"), + "derived SQL assets must be non-empty bounded regular files", + )); + } + Ok(CapturedModuleAssetSource { path, bytes }) + }) + .collect() +} + +fn validate_module_sql_asset_path(module_id: &str, asset_path: &str) -> Result<(), Diagnostic> { + if asset_path.is_empty() + || asset_path.len() > 512 + || asset_path.contains('\\') + || asset_path.ends_with('/') + || !asset_path.ends_with(".sql") + { + return Err(module_asset_path_diagnostic(module_id)); + } + let path = Path::new(asset_path); + let components = path.components().collect::>(); + if path.is_absolute() + || components.len() > 12 + || components + .iter() + .any(|component| !matches!(component, Component::Normal(_))) + || path.to_str() != Some(asset_path) + || components + .iter() + .filter_map(|component| match component { + Component::Normal(component) => component.to_str(), + _ => None, + }) + .collect::>() + .join("/") + != asset_path + { + return Err(module_asset_path_diagnostic(module_id)); + } + Ok(()) +} + +fn module_asset_path_diagnostic(module_id: &str) -> Diagnostic { + diagnostic( + "source.module_asset.path_unsafe", + &format!("modules/{module_id}/module.yaml"), + "derived SQL assets must be bounded module-relative .sql paths", + ) +} + fn init_files() -> BTreeMap> { BTreeMap::from([ ( @@ -2455,6 +2583,67 @@ fn explain_model(compiled: &CompiledRegistry) -> serde_json::Result { })) } +fn explain_queries(compiled: &CompiledRegistry) -> serde_json::Result { + let operations = compiled + .queries() + .operations + .iter() + .map(|operation| { + let entity = compiled.entities().get(&operation.entity_id); + let api_fields = entity + .map(|entity| { + operation + .projection_fields + .iter() + .filter_map(|field_id| { + query_field_summary( + field_id, + entity + .stored_fields + .iter() + .find(|field| field.logical.id == *field_id) + .map(|field| (&field.logical.api_name, "stored")) + .or_else(|| { + entity + .derived_fields + .get(field_id) + .map(|field| (&field.logical.api_name, "derived")) + }), + ) + }) + .collect::>() + }) + .unwrap_or_default(); + json!({ + "id": operation.id, + "routeId": operation.route_id, + "profile": operation.profile_id, + "entity": operation.entity_id, + "kind": operation.kind, + "apiFields": api_fields, + "filterable": operation.filter_fields, + "sortable": operation.sort_fields, + "allowCount": operation.allow_count, + "selectors": operation.selector_fields, + "readPath": operation.read_path, + "bounds": { + "maxPageSize": operation.max_page_size + } + }) + }) + .collect::>(); + serde_json::to_value(json!({ "operations": operations })) +} + +fn query_field_summary(field_id: &str, resolved: Option<(&String, &str)>) -> Option { + let (api_name, source_kind) = resolved?; + Some(json!({ + "field": field_id, + "apiName": api_name, + "sourceKind": source_kind, + })) +} + fn validate_project_directory(project_path: &Path) -> Result<(), Diagnostic> { if project_path.as_os_str().is_empty() || has_parent_component(project_path) { return Err(diagnostic( @@ -2835,6 +3024,49 @@ fn first_diagnostic(failure: CompileFailure) -> Diagnostic { }) } +fn remap_derived_diagnostic_path( + mut diagnostic: Diagnostic, + source: &CapturedProjectSource, +) -> Diagnostic { + if !diagnostic.code.starts_with("derived.sql.") { + return diagnostic; + } + diagnostic.message = + "derived SQL asset failed value-minimized validation against its module config".to_owned(); + if let Some(path) = derived_source_path(source, &diagnostic.path) { + diagnostic.path = path; + } + diagnostic +} + +fn derived_source_path(source: &CapturedProjectSource, diagnostic_path: &str) -> Option { + for module in &source.modules { + for entity in &module.module.entities { + for derived in &entity.derived { + let path = format!("entities[{}].derived[{}].sql", entity.id, derived.id); + if path == diagnostic_path { + return Some(format!( + "modules/{}/module.yaml:{}", + module.id, diagnostic_path + )); + } + } + } + for extension in &module.module.extend_entities { + for derived in &extension.derived { + let path = format!("entities[{}].derived[{}].sql", extension.entity, derived.id); + if path == diagnostic_path { + return Some(format!( + "modules/{}/module.yaml:{}", + module.id, diagnostic_path + )); + } + } + } + } + None +} + fn tool_diagnostic( diagnostic: Diagnostic, artifact: DiagnosticArtifact, @@ -3437,6 +3669,8 @@ fn write_failure( mod tests { use super::*; + use registry_server::compile_project; + struct TestDirectory { path: PathBuf, } diff --git a/crates/registry-serverctl/tests/cli.rs b/crates/registry-serverctl/tests/cli.rs index e91ac6ccc0..0fb37b8db8 100644 --- a/crates/registry-serverctl/tests/cli.rs +++ b/crates/registry-serverctl/tests/cli.rs @@ -127,6 +127,7 @@ impl RuntimePackageFixture { id: "core".to_owned(), path: "source/modules/core/module.yaml".to_owned(), bytes: module_bytes, + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: "tests/journeys.yaml".to_owned(), @@ -260,6 +261,7 @@ fn prepare_packaging_candidate( id: "core".to_owned(), path: "source/modules/core/module.yaml".to_owned(), bytes: module_bytes, + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: FIXTURE_JOURNEYS_PATH.to_owned(), @@ -846,10 +848,18 @@ fn explain_reports_are_derived_from_compiled_inventories() { "model", project.path().to_str().expect("path is UTF-8"), ]); + let queries = registry_serverctl(&[ + "--format", + "json", + "explain", + "queries", + project.path().to_str().expect("path is UTF-8"), + ]); assert!(routes.status.success(), "{routes:?}"); assert!(access.status.success(), "{access:?}"); assert!(model.status.success(), "{model:?}"); + assert!(queries.status.success(), "{queries:?}"); assert_eq!( json_stdout(&routes)["explanation"]["routes"][0]["entityId"], "asset-item" @@ -862,6 +872,99 @@ fn explain_reports_are_derived_from_compiled_inventories() { json_stdout(&model)["explanation"]["registryId"], "asset-site-placement" ); + let queries_json = json_stdout(&queries); + let operations = queries_json["explanation"]["operations"] + .as_array() + .expect("query operations are an array"); + assert!(operations + .windows(2) + .all(|window| window[0]["id"].as_str().expect("left id") + <= window[1]["id"].as_str().expect("right id"))); + let planner_list = operations + .iter() + .find(|operation| operation["id"] == "records.asset-item.site-planner.list") + .expect("site planner list query is explained"); + assert_eq!(planner_list["profile"], "site-planner"); + assert_eq!(planner_list["routeId"], "records.asset-item.list"); + assert_eq!(planner_list["apiFields"][0]["apiName"], "assetCode"); + assert_eq!(planner_list["apiFields"][0]["sourceKind"], "stored"); + assert_eq!( + planner_list["filterable"][0]["operators"], + json!(["equals", "in", "is_null", "is_not_null", "prefix"]) + ); + assert_eq!(planner_list["bounds"]["maxPageSize"], 100); + assert!(!String::from_utf8(queries.stdout) + .expect("queries JSON is UTF-8") + .contains("registry_data")); +} + +#[test] +fn check_reports_derived_sql_module_path_without_sql_values() { + let project = TestProject::from_registry_source( + br#"apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: derived-diagnostic-fixture + version: 1 + defaultLanguage: en +modules: + - id: core + version: 1 +"#, + ); + let module_dir = project.path().join("modules/core"); + fs::create_dir_all(module_dir.join("sql")).expect("module SQL directory creates"); + fs::write( + module_dir.join("module.yaml"), + br#"id: core +version: 1 +entities: + - id: record + route: records + mutationMode: create_only + fields: + - id: code + type: string + maxLength: 16 + classification: internal + derived: + - id: summary + sql: sql/summary.sql + key: id + fields: + - id: summary + type: string + maxLength: 16 + classification: internal + accessProfiles: + - id: reader + principalClaim: principal + operations: [list] + readableFields: [code, summary] +"#, + ) + .expect("module fixture writes"); + fs::write( + module_dir.join("sql/summary.sql"), + b"SELECT SQL_VALUE_CANARY FROM", + ) + .expect("SQL fixture writes"); + + let check = registry_serverctl(&["--format", "json", "check", path(project.path())]); + + assert_eq!(check.status.code(), Some(1), "{check:?}"); + let rendered = String::from_utf8_lossy(&check.stdout); + assert!(!rendered.contains("SQL_VALUE_CANARY")); + assert!(!rendered.contains("SELECT")); + let report = json_stdout(&check); + let diagnostic = &report["diagnostics"][0]; + assert_eq!(diagnostic["code"], "derived.sql.invalid"); + assert_eq!( + diagnostic["path"], + "modules/core/module.yaml:entities[record].derived[summary].sql" + ); + assert_eq!(diagnostic["artifact"], "registry_project"); + assert_eq!(diagnostic["suggestedAction"], "correct_authoring_source"); } #[test] @@ -2742,6 +2845,7 @@ fn data_package_fixture() -> (TestProject, PathBuf) { id: "core".to_owned(), path: "source/modules/core/module.yaml".to_owned(), bytes: module_bytes, + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: "tests/journeys.yaml".to_owned(), @@ -2846,7 +2950,6 @@ authentication: authorityClaims: principal: registry_principal purpose: registry_purpose - rowBoundaryClaims: [] audit: hashKeyRef: secret:file/{PACKAGE_VALUE_CANARY} cursor: diff --git a/crates/registry-serverctl/tests/diff.rs b/crates/registry-serverctl/tests/diff.rs index 312a1dba98..edeb8f5c53 100644 --- a/crates/registry-serverctl/tests/diff.rs +++ b/crates/registry-serverctl/tests/diff.rs @@ -485,6 +485,7 @@ fn publish_package( id: "core".to_owned(), path: "source/modules/core/module.yaml".to_owned(), bytes: module_bytes, + assets: Vec::new(), }], fixture_journeys: PackageSourceFile { path: "tests/journeys.yaml".to_owned(), @@ -635,7 +636,6 @@ authentication: authorityClaims: principal: registry_principal purpose: registry_purpose - rowBoundaryClaims: [] audit: hashKeyRef: secret:file/{VALUE_CANARY} cursor: diff --git a/products/registry-server/ACCEPTANCE-JOURNEYS.md b/products/registry-server/ACCEPTANCE-JOURNEYS.md index 764939631b..36e0a1e701 100644 --- a/products/registry-server/ACCEPTANCE-JOURNEYS.md +++ b/products/registry-server/ACCEPTANCE-JOURNEYS.md @@ -6,8 +6,17 @@ disability, farmer, and business registries. Their contract identifiers and delivery state are in `contracts/acceptance-scenario-matrix.yaml`. All five configuration projects pass the Production compiler and the same -real-PostgreSQL pilot test. That test loads their locked module digests and -executes configured behavior without domain-specific runtime concepts. +real-PostgreSQL pilot test. That test loads their module source, rederives the +package, and executes configured behavior without domain-specific runtime +concepts. + +The PublicSchema household project is intentionally richer than a single +stored-record smoke test. Its authored configuration adds person sex and a +local household number, declares selector profiles, exposes household-to-person +reads through group membership, and derives live demographic facts from a +reviewed module-relative SQL file. The demo seed includes a single-headed +household with a child under five, a woman-headed household with a child and +elderly member, and a separate control household to prove row/path isolation. The non-person asset project also passes the public-binary adopter workflow. That workflow builds the two executables once, tests a candidate in an isolated diff --git a/products/registry-server/DECISIONS.md b/products/registry-server/DECISIONS.md index 5a2ecfff2f..d2dd546520 100644 --- a/products/registry-server/DECISIONS.md +++ b/products/registry-server/DECISIONS.md @@ -13,6 +13,10 @@ An overlay may add localized labels, concept URIs, identifiers, relationship roles, and codelist metadata only for entities and fields visible through its selected access profile and classification ceiling. +- Selector profiles and relationship read paths are governed model entries, + not runtime concepts. Selector values are exact inputs for a compiled lookup + and never grant authority. A read-path grant is confined to one configured + source, association entity, target, and target field capability set. - Registry Manifest remains the owner of standards-oriented metadata and DCAT rendering. Registry Server emits a one-way, lossy Manifest source plus its DCAT JSON-LD projection in the governed package. It does not maintain a diff --git a/products/registry-server/DEFINITION-OF-DONE.md b/products/registry-server/DEFINITION-OF-DONE.md index fcd3351b76..29e6a86f08 100644 --- a/products/registry-server/DEFINITION-OF-DONE.md +++ b/products/registry-server/DEFINITION-OF-DONE.md @@ -25,5 +25,7 @@ real-PostgreSQL five-domain acceptance test with a clean public-binary lifecycle that exercises production checking, isolated schema tests, external signing, operator apply, authenticated serving, compatible additive upgrade, durable failed maintenance, exact fix-forward recovery, and restart with unchanged -server bytes. This is a pilot exit claim, not a claim that a release has been -published. +server bytes. The household pilot fixture also exercises configured selector, +derived-field, and relationship read-path surfaces without making household or +person a runtime type. This is a pilot exit claim, not a claim that a release +has been published. diff --git a/products/registry-server/IMPLEMENTATION.md b/products/registry-server/IMPLEMENTATION.md index 0f1dc38661..12af7a23f4 100644 --- a/products/registry-server/IMPLEMENTATION.md +++ b/products/registry-server/IMPLEMENTATION.md @@ -12,8 +12,9 @@ is `contracts/implementation-schedule.yaml`. 4. **W3:** add the real REST router, request authorization, revisions, idempotency, audit ordering, and transactional outbox as one path. 5. **W4:** add verified packages, migrations, activation, and recovery. -6. **W5:** complete the pilot tooling, bounded data operations, webhooks, and - five coequal adopter journeys. +6. **W5:** complete the pilot tooling, bounded data operations, webhooks, + generated baselines, selector/read-path samples, and five coequal adopter + journeys. No wave creates a generic storage framework, package framework, plugin runtime, workflow engine, or second database client abstraction merely in anticipation diff --git a/products/registry-server/README.md b/products/registry-server/README.md index cae68692e4..bec463ed77 100644 --- a/products/registry-server/README.md +++ b/products/registry-server/README.md @@ -137,7 +137,11 @@ selected access profile and classification ceiling. It does not infer or hardcode a domain model. The PublicSchema-shaped household fixture demonstrates Person, Household, and -GroupMembership alignment entirely in configuration: +GroupMembership alignment entirely in configuration. It also declares +module-owned selector profiles, a household-to-people read path through group +membership, and a reviewed live SQL asset that contributes derived household +facts such as head count, child count, under-five child count, elderly count, +single-headed, and woman-headed: ```bash registry-serverctl generate manifest \ @@ -149,6 +153,13 @@ This produces the canonical Registry Manifest source and a DCAT JSON-LD catalogue. Registry Manifest owns the standards rendering, so Registry Server does not carry a second DCAT implementation. +The REST query profile uses the native `$select`, `$filter`, `$orderby`, +`$top`, `$count`, and `$skiptoken` keys. Selector values are exact lookup +inputs only; they do not create authority. Relationship read paths are +configured routes such as `/v1/records/households/{record_id}/people`, and the +path grant explicitly limits the target fields, filters, ordering, and count +support available through that traversal. + Evidence can consume an authenticated Registry Server REST route through its existing bounded `http-json` source and an explicitly reviewed adapter. Relay remains a separate publication boundary. A direct Relay source adapter should diff --git a/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-core/module.yaml b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-core/module.yaml index 2cd03586dd..e52c8b303a 100644 --- a/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-core/module.yaml +++ b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-core/module.yaml @@ -10,6 +10,7 @@ entities: - {id: legal-name, type: string, required: true, maxLength: 160, classification: restricted} - {id: family-name, type: string, required: false, maxLength: 120, classification: restricted} - {id: date-of-birth, type: date, required: false, classification: restricted} + - {id: person-sex, type: vocabulary-code, vocabulary: person-sex, required: true, classification: restricted} constraints: - {kind: unique, fields: [person-code]} - id: household @@ -18,11 +19,18 @@ entities: classification: restricted fields: - {id: household-code, type: string, required: true, maxLength: 64, classification: restricted} + - {id: local-household-number, type: int64, required: true, classification: restricted} - {id: household-name, type: string, required: true, maxLength: 160, classification: restricted} - {id: administrative-area, type: string, required: true, maxLength: 80, classification: restricted} - {id: household-type, type: vocabulary-code, vocabulary: household-type, required: true, classification: restricted} constraints: - {kind: unique, fields: [household-code]} + - {kind: unique, fields: [administrative-area, local-household-number]} + selectorProfiles: + - {id: by-local-reference, fields: [administrative-area, local-household-number]} + - {id: by-household-code, fields: [household-code]} + readPaths: + - {id: people, through: group-membership, to: person, route: people} - id: group-membership route: group-memberships mutationMode: mutable diff --git a/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/module.yaml b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/module.yaml index 05bc3e3697..28d92fc27b 100644 --- a/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/module.yaml +++ b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/module.yaml @@ -6,3 +6,16 @@ extendEntities: fields: - {id: residency-status, type: vocabulary-code, vocabulary: residency-status, required: true, classification: restricted} - {id: preferred-language, type: vocabulary-code, vocabulary: preferred-language, required: false, classification: restricted} + - entity: household + derived: + - id: household-demographics + sql: sql/household-demographics.sql + key: id + execution: live + fields: + - {id: head-count, type: int64, classification: restricted} + - {id: child-count, type: int64, classification: restricted} + - {id: child-under-5-count, type: int64, classification: restricted} + - {id: elderly-count, type: int64, classification: restricted} + - {id: single-headed, type: boolean, classification: restricted} + - {id: woman-headed, type: boolean, classification: restricted} diff --git a/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/sql/household-demographics.sql b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/sql/household-demographics.sql new file mode 100644 index 0000000000..60a25ee86b --- /dev/null +++ b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/sql/household-demographics.sql @@ -0,0 +1,49 @@ +SELECT + h.id AS id, + count(*) FILTER ( + WHERE gm.relationship = 'head' + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + )::bigint AS head_count, + count(*) FILTER ( + WHERE gm.relationship = 'child' + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + )::bigint AS child_count, + count(*) FILTER ( + WHERE gm.relationship = 'child' + AND p.date_of_birth > (registry_context.evaluation_date() - 5 * INTERVAL '1 year')::date + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + )::bigint AS child_under_5_count, + count(*) FILTER ( + WHERE p.date_of_birth <= (registry_context.evaluation_date() - 65 * INTERVAL '1 year')::date + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + )::bigint AS elderly_count, + ( + count(*) FILTER ( + WHERE gm.relationship = 'head' + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + ) = 1 + AND count(*) FILTER ( + WHERE gm.relationship = 'spouse' + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + ) = 0 + ) AS single_headed, + ( + count(*) FILTER ( + WHERE gm.relationship = 'head' + AND p.person_sex = 'female' + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + ) > 0 + ) AS woman_headed +FROM registry_source.household h +LEFT JOIN registry_source.group_membership gm + ON gm.household = h.id +LEFT JOIN registry_source.person p + ON p.id = gm.person +GROUP BY h.id diff --git a/products/registry-server/acceptance/publicschema-household/registry.yaml b/products/registry-server/acceptance/publicschema-household/registry.yaml index 743cfbdd46..27e2c92d70 100644 --- a/products/registry-server/acceptance/publicschema-household/registry.yaml +++ b/products/registry-server/acceptance/publicschema-household/registry.yaml @@ -60,6 +60,7 @@ manifestProjection: - {id: legal-name, concepts: [https://publicschema.org/name]} - {id: family-name, concepts: [https://publicschema.org/family_name]} - {id: date-of-birth, concepts: [https://publicschema.org/date_of_birth]} + - {id: person-sex, concepts: [https://publicschema.org/sex]} - id: household title: {en: Household, fr: Ménage} description: @@ -70,6 +71,7 @@ manifestProjection: - {field: household-code, kind: local} fields: - {id: household-code, concepts: [https://publicschema.org/identifier]} + - {id: local-household-number, concepts: [https://publicschema.org/local_identifier]} - {id: household-name, concepts: [https://publicschema.org/name]} - id: group-membership title: {en: Group membership, fr: Appartenance à un groupe} @@ -105,24 +107,63 @@ manifestProjection: - id: preferred-language schemeIri: https://id.loc.gov/vocabulary/iso639-1 externalRef: https://id.loc.gov/vocabulary/iso639-1.html + - id: person-sex + schemeIri: https://publicschema.org/Sex + version: "0.3.0" + externalRef: https://publicschema.org/Sex + concepts: + - {code: female, iri: https://publicschema.org/Sex/female, label: {en: Female, fr: Féminin}} + - {code: male, iri: https://publicschema.org/Sex/male, label: {en: Male, fr: Masculin}} + - {code: unknown, iri: https://publicschema.org/Sex/unknown, label: {en: Unknown, fr: Inconnu}} modules: - id: publicschema-household-core version: 0.1.0 - digest: sha256:9e681a1d27a3677aa8cd319df83ab70d794263a93a560dd65672319e1d89b231 + digest: sha256:b13240f2ce7d17b18e8cde04289a341a6517d7952f64adaf7a5341eca4aa340b - id: publicschema-household-demographics version: 0.1.0 - digest: sha256:aba9eeebcbf420de79306fb8f499dc52cce8ad838953d5b1fcaf1a878e5f046c + digest: sha256:cc40de1b9ede8cb727980022f966da5ced9a26e56853ca3a5b3355ce8aff37d5 accessProfiles: - id: household-operator + default: true principalClaim: registry_principal requiredScopes: [registry:household:operate] purposes: [household-administration] grants: - - {entity: person, actions: [create, get, list, patch], readableFields: [person-code, legal-name, family-name, date-of-birth, residency-status, preferred-language], writableFields: [person-code, legal-name, family-name, date-of-birth, residency-status, preferred-language], filterableFields: [person-code, residency-status]} - - {entity: household, actions: [create, get, list, patch], readableFields: [household-code, household-name, administrative-area, household-type], writableFields: [household-code, household-name, administrative-area, household-type], filterableFields: [household-code, administrative-area, household-type]} - - {entity: group-membership, actions: [create, get, list, patch], readableFields: [person, household, relationship, valid-from, valid-to], writableFields: [person, household, relationship, valid-from, valid-to], filterableFields: [person, household, valid-from]} + - {entity: person, actions: [create, get, list, patch], readableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status, preferred-language], writableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status, preferred-language], filterableFields: [person-code, person-sex, residency-status], sortableFields: [person-code]} + - entity: household + actions: [create, get, lookup, list, patch] + readableFields: [household-code, local-household-number, household-name, administrative-area, household-type, head-count, child-count, child-under-5-count, elderly-count, single-headed, woman-headed] + writableFields: [household-code, local-household-number, household-name, administrative-area, household-type] + filterableFields: [household-code, local-household-number, administrative-area, household-type, head-count, child-count, child-under-5-count, elderly-count, single-headed, woman-headed] + sortableFields: [local-household-number, household-code, child-count] + allowCount: true + lookups: + - {selector: by-local-reference, valueOrigin: request} + - {selector: by-household-code, valueOrigin: request} + readPaths: + - path: people + readableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status] + filterableFields: [person-sex, residency-status] + sortableFields: [person-code] + allowCount: true + - {entity: group-membership, actions: [create, get, list, patch], readableFields: [person, household, relationship, valid-from, valid-to], writableFields: [person, household, relationship, valid-from, valid-to], filterableFields: [person, household, relationship, valid-from]} + - id: household-viewer + principalClaim: registry_principal + requiredScopes: [registry:household:view] + purposes: [household-view] + grants: + - entity: household + actions: [get, lookup] + readableFields: [household-code, local-household-number, household-name, administrative-area, household-type] + rowBoundaries: + - {field: id, claim: household_id, operator: equals} + lookups: + - selector: by-household-code + valueOrigin: verified_claim + claimMapping: {household-code: household_code} vocabularies: - {id: household-relationship, values: [head, spouse, child, dependent, other]} - {id: household-type, values: [private, collective, institutional]} - {id: residency-status, values: [usual-resident, temporary-resident, departed]} - {id: preferred-language, values: [en, es, fr]} + - {id: person-sex, values: [female, male, unknown]} diff --git a/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml b/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml index d65b78501f..9c3e401c5b 100644 --- a/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml +++ b/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml @@ -2,7 +2,7 @@ apiVersion: registry.registrystack.org/server-journeys/v1 journeys: - id: household-person-lifecycle steps: - - id: create-person + - id: create-single-headed-head entity: person accessProfile: household-operator claims: &household_operator_claims @@ -13,9 +13,10 @@ journeys: operation: create data: person-code: PERSON-SYNTH-001 - legal-name: Synthetic Person One + legal-name: Omar Example family-name: Example - date-of-birth: 1990-01-15 + date-of-birth: 1986-02-22 + person-sex: male residency-status: usual-resident preferred-language: en expect: @@ -23,39 +24,144 @@ journeys: status: 201 fields: person-code: PERSON-SYNTH-001 - legal-name: Synthetic Person One + legal-name: Omar Example + person-sex: male + residency-status: usual-resident + capture: single-headed-head + - id: create-under-five-child + entity: person + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: create + data: + person-code: PERSON-SYNTH-002 + legal-name: Lina Example family-name: Example - date-of-birth: 1990-01-15 + date-of-birth: 2023-03-14 + person-sex: female residency-status: usual-resident preferred-language: en - capture: first-person - - id: get-person + expect: + outcome: success + status: 201 + fields: {person-code: PERSON-SYNTH-002, person-sex: female, residency-status: usual-resident} + capture: under-five-child + - id: create-woman-headed-head entity: person accessProfile: household-operator claims: *household_operator_claims - request: {operation: get, recordRef: first-person} + request: + operation: create + data: + person-code: PERSON-SYNTH-003 + legal-name: Sofia Sample + family-name: Sample + date-of-birth: 1980-11-02 + person-sex: female + residency-status: usual-resident + preferred-language: es expect: outcome: success - status: 200 - fields: {person-code: PERSON-SYNTH-001, residency-status: usual-resident} - - id: update-person-residency + status: 201 + fields: {person-code: PERSON-SYNTH-003, person-sex: female} + capture: woman-headed-head + - id: create-woman-headed-child entity: person accessProfile: household-operator claims: *household_operator_claims request: - operation: patch - recordRef: first-person - etagRef: first-person - changes: - - {field: residency-status, value: temporary-resident} + operation: create + data: + person-code: PERSON-SYNTH-004 + legal-name: Diego Sample + family-name: Sample + date-of-birth: 2016-06-17 + person-sex: male + residency-status: usual-resident + preferred-language: es expect: outcome: success - status: 200 - fields: - person-code: PERSON-SYNTH-001 - residency-status: temporary-resident - capture: updated-person - - id: create-household + status: 201 + fields: {person-code: PERSON-SYNTH-004, person-sex: male} + capture: woman-headed-child + - id: create-woman-headed-elder + entity: person + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: create + data: + person-code: PERSON-SYNTH-005 + legal-name: Rosa Sample + family-name: Sample + date-of-birth: 1940-08-20 + person-sex: female + residency-status: usual-resident + preferred-language: es + expect: + outcome: success + status: 201 + fields: {person-code: PERSON-SYNTH-005, person-sex: female} + capture: woman-headed-elder + - id: create-isolation-head + entity: person + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: create + data: + person-code: PERSON-SYNTH-006 + legal-name: Karim Control + family-name: Control + date-of-birth: 1975-01-09 + person-sex: male + residency-status: usual-resident + preferred-language: fr + expect: + outcome: success + status: 201 + fields: {person-code: PERSON-SYNTH-006, person-sex: male} + capture: isolation-head + - id: create-isolation-spouse + entity: person + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: create + data: + person-code: PERSON-SYNTH-007 + legal-name: Hana Control + family-name: Control + date-of-birth: 1977-09-23 + person-sex: female + residency-status: usual-resident + preferred-language: fr + expect: + outcome: success + status: 201 + fields: {person-code: PERSON-SYNTH-007, person-sex: female} + capture: isolation-spouse + - id: create-isolation-child + entity: person + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: create + data: + person-code: PERSON-SYNTH-008 + legal-name: Noor Control + family-name: Control + date-of-birth: 2018-05-06 + person-sex: female + residency-status: usual-resident + preferred-language: fr + expect: + outcome: success + status: 201 + fields: {person-code: PERSON-SYNTH-008, person-sex: female} + capture: isolation-child + - id: create-single-headed-household entity: household accessProfile: household-operator claims: *household_operator_claims @@ -63,24 +169,66 @@ journeys: operation: create data: household-code: HOUSEHOLD-SYNTH-001 - household-name: Synthetic Example Household - administrative-area: demonstration-area + local-household-number: 1001 + household-name: Single Headed Under Five Household + administrative-area: demonstration-north household-type: private expect: outcome: success status: 201 fields: household-code: HOUSEHOLD-SYNTH-001 - household-name: Synthetic Example Household - administrative-area: demonstration-area + local-household-number: 1001 + household-name: Single Headed Under Five Household + capture: single-headed-household + - id: create-woman-headed-household + entity: household + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: create + data: + household-code: HOUSEHOLD-SYNTH-002 + local-household-number: 1002 + household-name: Woman Headed Child Elderly Household + administrative-area: demonstration-central household-type: private - capture: first-household - - id: list-people - entity: person + expect: + outcome: success + status: 201 + fields: + household-code: HOUSEHOLD-SYNTH-002 + local-household-number: 1002 + capture: woman-headed-household + - id: create-isolation-household + entity: household accessProfile: household-operator claims: *household_operator_claims - request: {operation: list} - expect: {outcome: success, status: 200, count: 1} + request: + operation: create + data: + household-code: HOUSEHOLD-SYNTH-003 + local-household-number: 1003 + household-name: Isolation Control Household + administrative-area: demonstration-south + household-type: private + expect: + outcome: success + status: 201 + fields: + household-code: HOUSEHOLD-SYNTH-003 + local-household-number: 1003 + capture: isolation-household + - id: query-household-demographics + entity: household + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: query + select: [household-code, local-household-number, child-count, child-under-5-count, elderly-count, single-headed, woman-headed] + top: 3 + count: true + expect: {outcome: success, status: 200, count: 3} - id: refuse-incomplete-membership entity: group-membership accessProfile: household-operator @@ -98,7 +246,7 @@ journeys: claims: principal: synthetic-household-operator scopes: [registry:household:operate] - request: {operation: get, recordRef: updated-person} + request: {operation: get, recordRef: single-headed-head} expect: outcome: refusal status: 404 diff --git a/products/registry-server/contracts/acceptance-scenario-matrix.yaml b/products/registry-server/contracts/acceptance-scenario-matrix.yaml index db4a2eb734..c9dd1ebca4 100644 --- a/products/registry-server/contracts/acceptance-scenario-matrix.yaml +++ b/products/registry-server/contracts/acceptance-scenario-matrix.yaml @@ -17,7 +17,7 @@ scenarios: state: enforced domain: household fixture: acceptance/publicschema-household - doneWhen: "Household, two persons, and current and historical membership run without privileged runtime concepts." + doneWhen: "Household, person, membership, selector, derived demographic, and read-path surfaces run without privileged runtime concepts." evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}] - id: RS-J04 state: enforced diff --git a/products/registry-server/contracts/artifact-inventory.yaml b/products/registry-server/contracts/artifact-inventory.yaml index 652e1b9b76..490120d00b 100644 --- a/products/registry-server/contracts/artifact-inventory.yaml +++ b/products/registry-server/contracts/artifact-inventory.yaml @@ -21,6 +21,7 @@ artifacts: - {path: demo, kind: local-mint-server-demo, state: authored} - {path: generated/authoring/registry-project.schema.json, kind: generated-authoring-schema, state: authored} - {path: generated/asset-site-placement, kind: generated-baseline, state: authored} + - {path: generated/publicschema-household, kind: generated-baseline, state: authored} - {path: scripts/check-generated.sh, kind: generated-artifact-gate, state: authored} - {path: scripts/compare-generated-tree.py, kind: generated-tree-comparator, state: authored} - {path: scripts/test-postgres.sh, kind: real-postgresql-entrypoint, state: authored} diff --git a/products/registry-server/contracts/definition-of-done.yaml b/products/registry-server/contracts/definition-of-done.yaml index 65020c21a6..17b596d6d0 100644 --- a/products/registry-server/contracts/definition-of-done.yaml +++ b/products/registry-server/contracts/definition-of-done.yaml @@ -60,7 +60,7 @@ requirements: - {id: RS-V1-36, phase: W5, state: enforced, doneWhen: "Resumable import and authorized export use normal mutation, audit, revision, idempotency, and outbox paths.", journeys: [RS-J05], evidence: [{path: crates/registry-server/tests/data_operations.rs, name: data_export_requires_explicit_nonanonymous_profile_permission}, {path: crates/registry-server/tests/data_operations.rs, name: data_validate_and_chunk_plan_reuse_runtime_rules_and_compiled_batch_bounds}, {path: crates/registry-server/tests/data_operations.rs, name: data_import_checkpoint_and_idempotency_are_exact_and_value_free}, {path: crates/registry-server/tests/data_operations.rs, name: data_export_checkpoint_refuses_package_profile_projection_or_prefix_substitution}, {path: crates/registry-server/tests/postgres_data_farmer.rs, name: real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race_safe}, {path: crates/registry-server/tests/postgres_data_export.rs, name: real_postgres_export_is_authenticated_projected_audited_and_resumable}]} - {id: RS-V1-37, phase: W5, state: enforced, doneWhen: "Webhook delivery is confined, authenticated, bounded, audited, retryable, dead-lettered, and replayable.", journeys: [RS-J16], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}, {path: crates/registry-server/tests/compiler_webhook.rs, name: governed_webhook_compiles_to_deterministic_destination_neutral_inventory}, {path: crates/registry-server/tests/runtime_config.rs, name: activation_constructs_the_exact_platform_policy_template_and_signing_material}, {path: crates/registry-server/tests/postgres_webhook_outbox.rs, name: real_postgres_webhook_outbox_capture_is_atomic_package_bound_and_deterministically_identified}, {path: crates/registry-server/tests/postgres_webhook_delivery.rs, name: real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_bound_audited_and_confined}]} - {id: RS-V1-38, phase: W5, state: enforced, doneWhen: "The non-person asset, site, and placement project proves the kernel without person-related concepts.", journeys: [RS-J01, RS-J07], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} - - {id: RS-V1-39, phase: W5, state: enforced, doneWhen: "The household project has no domain-specific route, query, Rust type, feature, migration, metric, or error.", journeys: [RS-J03, RS-J07], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: products/registry-server/scripts/check-source-neutrality.sh, name: check-source-neutrality.sh}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_every_domain_fixture_family_has_a_rejected_route_canary}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_bare_domain_rust_type_identifier_is_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_cargo_feature_is_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_migration_and_resource_inputs_are_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_metric_and_error_identifiers_are_rejected}]} + - {id: RS-V1-39, phase: W5, state: enforced, doneWhen: "The household project configures selectors, relationship read paths, and derived demographics without a domain-specific route, query, Rust type, feature, migration, metric, or error.", journeys: [RS-J03, RS-J07], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: products/registry-server/scripts/check-source-neutrality.sh, name: check-source-neutrality.sh}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_every_domain_fixture_family_has_a_rejected_route_canary}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_bare_domain_rust_type_identifier_is_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_cargo_feature_is_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_migration_and_resource_inputs_are_rejected}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_domain_metric_and_error_identifiers_are_rejected}]} - {id: RS-V1-40, phase: W5, state: enforced, doneWhen: "The disability project proves protected observations, certification, validity, and correction provenance.", journeys: [RS-J04], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} - {id: RS-V1-41, phase: W5, state: enforced, doneWhen: "The farmer project proves bounded CRS84, units, temporal tenure or activity, resumable import, and finite boundaries without PostGIS or domain runtime code.", journeys: [RS-J05], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: crates/registry-server/tests/postgres_data_farmer.rs, name: real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race_safe}]} - {id: RS-V1-42, phase: W5, state: enforced, doneWhen: "The business project proves identifiers, filings, appointments, temporal constraints, and public/protected processing.", journeys: [RS-J06], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} diff --git a/products/registry-server/contracts/package-layout.yaml b/products/registry-server/contracts/package-layout.yaml index ebb292ded9..5d016321a4 100644 --- a/products/registry-server/contracts/package-layout.yaml +++ b/products/registry-server/contracts/package-layout.yaml @@ -16,6 +16,7 @@ entries: - {path: schemas, role: entity-json-schemas, required: true} - {path: manifest/registry-manifest.json, role: lossy-manifest-projection, required: true} - {path: manifest/dcat.jsonld, role: dcat-catalog-projection, required: true} + - {path: source/modules//, role: source-module-asset, required: false} - {path: tests/journeys.yaml, role: fixture-journeys, required: true} - {path: signatures, role: package-signatures, required: false} diff --git a/products/registry-server/contracts/security-invariant-matrix.yaml b/products/registry-server/contracts/security-invariant-matrix.yaml index 59cdcd3adc..fd1304bb2e 100644 --- a/products/registry-server/contracts/security-invariant-matrix.yaml +++ b/products/registry-server/contracts/security-invariant-matrix.yaml @@ -20,3 +20,5 @@ invariants: - {id: RS-SEC-17, state: enforced, targetWave: W3, threat: An encrypted cursor is replayed under a different authorized query context., enforcementPoint: fresh HTTP authorization plus authenticated cursor opening and PostgreSQL ReadPlan binding recomputation, refusal: "Reject before SQL when package, route, operation, profile, principal, purpose, row boundary, projection, filter, sort, temporal instant, page size, or expiry differs.", negativeId: RS-NEG-17, negativeTest: {path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}} - {id: RS-SEC-18, state: enforced, targetWave: W5, threat: A bulk request bypasses per-item authority or commits a valid prefix after a later item fails., enforcementPoint: configured Batch route and single-transaction mutation coordinator, refusal: "Refuse the complete request before record I/O when its bounds, operation, profile, or mutation mode is invalid; otherwise roll back every item and release nothing when any item or terminal component fails.", negativeId: RS-NEG-18, negativeTest: {path: crates/registry-server/tests/postgres_batch.rs, name: real_postgres_batch_is_bounded_authorized_atomic_and_exactly_replayable}} - {id: RS-SEC-19, state: enforced, targetWave: W5, threat: "A malformed, private, ambiguous, denied, or wrong-algorithm operator-pinned JWKS selects unintended verification material or leaks key metadata.", enforcementPoint: static JWKS validation before verifier construction in production startup and schema-test execution, refusal: "Reject the complete bounded document unless it is a strict duplicate-free set of valid public keys exactly bound to the configured algorithm, signature use, verification operation, and kid policy.", negativeId: RS-NEG-19, negativeTest: {path: crates/registry-server/tests/runtime_config.rs, name: static_jwks_validation_refuses_unsafe_documents_value_free}} + - {id: RS-SEC-20, state: enforced, targetWave: W3, threat: "A lookup selector accepts partial, extra, mistyped, caller-supplied claim values, or otherwise becomes an enumeration oracle.", enforcementPoint: compiled selector grant and bounded lookup request parser, refusal: "Require the selector's exact configured field set and origin, then collapse unknown, ungranted, missing-claim, zero-match, and multiple-match outcomes to one value-free unresolved response.", negativeId: RS-NEG-20, negativeTest: {path: crates/registry-server/tests/http_read_only.rs, name: lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value_free}} + - {id: RS-SEC-21, state: enforced, targetWave: W3, threat: "A relationship traversal inherits direct target rights or widens its configured target projection, filtering, ordering, or count authority.", enforcementPoint: compiled named read-path grant and route-specific query plan, refusal: "Require an independently granted path and enforce only that path's target fields and query capabilities; conceal unknown and ungranted paths identically before record I/O.", negativeId: RS-NEG-21, negativeTest: {path: crates/registry-server/tests/http_read_only.rs, name: relationship_route_uses_path_grant_not_direct_target_rights}} diff --git a/products/registry-server/contracts/security-test-traceability.yaml b/products/registry-server/contracts/security-test-traceability.yaml index 8850fa82ae..6ac031bea9 100644 --- a/products/registry-server/contracts/security-test-traceability.yaml +++ b/products/registry-server/contracts/security-test-traceability.yaml @@ -20,3 +20,5 @@ traceability: - {id: RS-SEC-17, state: enforced, negativeId: RS-NEG-17, negativeTest: {path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}} - {id: RS-SEC-18, state: enforced, negativeId: RS-NEG-18, negativeTest: {path: crates/registry-server/tests/postgres_batch.rs, name: real_postgres_batch_is_bounded_authorized_atomic_and_exactly_replayable}} - {id: RS-SEC-19, state: enforced, negativeId: RS-NEG-19, negativeTest: {path: crates/registry-server/tests/runtime_config.rs, name: static_jwks_validation_refuses_unsafe_documents_value_free}} + - {id: RS-SEC-20, state: enforced, negativeId: RS-NEG-20, negativeTest: {path: crates/registry-server/tests/http_read_only.rs, name: lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value_free}} + - {id: RS-SEC-21, state: enforced, negativeId: RS-NEG-21, negativeTest: {path: crates/registry-server/tests/http_read_only.rs, name: relationship_route_uses_path_grant_not_direct_target_rights}} diff --git a/products/registry-server/demo/README.md b/products/registry-server/demo/README.md index 24319f3069..f38da33dd7 100644 --- a/products/registry-server/demo/README.md +++ b/products/registry-server/demo/README.md @@ -10,7 +10,7 @@ This local demo starts four real components: verification. It then asks Mint for short-lived operator and negative-test tokens and creates -five synthetic people, two households, and five effective-dated memberships +eight synthetic people, three households, and eight effective-dated memberships through Registry Server's ordinary authenticated REST API. ## Run it @@ -41,6 +41,43 @@ without waiting: products/registry-server/demo/run.sh --smoke ``` +## Copyable requests + +The query helper runs the GET shapes against the local server. The examples +below also show the selector lookup and viewer-denial requests with synthetic +bearer placeholders and deterministic logical IDs; when using the demo +directly, read the generated household UUID from +`demo/.run/seed-record-ids.json`. + +```bash +curl -sS -H 'Authorization: Bearer ' \ + 'http://127.0.0.1:18080/v1/records/households//people?accessProfile=household-operator&$select=person-code,legal-name,person-sex,residency-status&$orderby=person-code&$top=20&$count=true' + +curl -sS -H 'Authorization: Bearer ' \ + 'http://127.0.0.1:18080/v1/records/households?accessProfile=household-operator&$select=household-code,administrative-area,local-household-number,child-count&$filter=administrative-area%20eq%20%27north-demo%27%20and%20child-count%20eq%201&$orderby=local-household-number&$top=20&$count=true' + +curl -sS -H 'Authorization: Bearer ' \ + 'http://127.0.0.1:18080/v1/records/households?accessProfile=household-operator&$select=household-code,child-under-5-count,single-headed&$filter=single-headed%20eq%20true%20and%20child-under-5-count%20eq%201&$top=20&$count=true' + +curl -sS -H 'Authorization: Bearer ' \ + 'http://127.0.0.1:18080/v1/records/households?accessProfile=household-operator&$select=household-code,woman-headed,child-count,elderly-count&$filter=woman-headed%20eq%20true%20and%20child-count%20eq%201%20and%20elderly-count%20eq%201&$top=20&$count=true' + +curl -sS -X POST -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + --data '{"selector":"by-local-reference","value":{"administrative-area":"north-demo","local-household-number":1001}}' \ + 'http://127.0.0.1:18080/v1/records/households:lookup?accessProfile=household-operator' + +curl -sS -H 'Authorization: Bearer ' \ + 'http://127.0.0.1:18080/v1/records/households?accessProfile=household-viewer' + +curl -sS -H 'Authorization: Bearer ' \ + 'http://127.0.0.1:18080/v1/records/households?accessProfile=household-operator&$skiptoken=' +``` + +The `household-viewer` profile is intentionally get and lookup only. A list +attempt is expected to return the same concealed absence class as an +unauthorized resource. + ## Disposable state All generated configuration, keys, tokens, logs, package artifacts, and diff --git a/products/registry-server/demo/support/demo.py b/products/registry-server/demo/support/demo.py index bd6b6c0cb0..a539d4bd58 100755 --- a/products/registry-server/demo/support/demo.py +++ b/products/registry-server/demo/support/demo.py @@ -317,11 +317,18 @@ def prepare(root: Path, fixture: Path, database_port: int, mint_port: int, serve f"""apiVersion: registry.registrystack.org/server-schema-test-credentials/v1 kind: SchemaTestCredentials bindings: - - {{journeyId: household-person-lifecycle, stepId: create-person, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: get-person, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: update-person-residency, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: list-people, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-single-headed-head, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-under-five-child, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-woman-headed-head, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-woman-headed-child, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-woman-headed-elder, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-isolation-head, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-isolation-spouse, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-isolation-child, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-single-headed-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-woman-headed-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: create-isolation-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: query-household-demographics, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - {{journeyId: household-person-lifecycle, stepId: refuse-incomplete-membership, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - {{journeyId: household-person-lifecycle, stepId: operator-without-purpose-is-concealed, credential: {{type: bearer, tokenRef: secret:file/no-purpose-token}}}} """, @@ -411,22 +418,29 @@ def _create(root: Path, route: str, logical_key: str, data: dict[str, Any]) -> s def seed_spec() -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: people = [ - {"person-code": "PERSON-DEMO-001", "legal-name": "Amina Example", "family-name": "Example", "date-of-birth": "1988-02-22", "residency-status": "usual-resident", "preferred-language": "en"}, - {"person-code": "PERSON-DEMO-002", "legal-name": "Karim Example", "family-name": "Example", "date-of-birth": "2014-06-17", "residency-status": "usual-resident", "preferred-language": "fr"}, - {"person-code": "PERSON-DEMO-003", "legal-name": "Elena Sample", "family-name": "Sample", "date-of-birth": "1992-11-02", "residency-status": "usual-resident", "preferred-language": "es"}, - {"person-code": "PERSON-DEMO-004", "legal-name": "Mateo Sample", "family-name": "Sample", "date-of-birth": "2022-03-14", "residency-status": "usual-resident", "preferred-language": "es"}, - {"person-code": "PERSON-DEMO-005", "legal-name": "Luis Sample", "family-name": "Sample", "date-of-birth": "1989-08-20", "residency-status": "temporary-resident", "preferred-language": "en"}, + {"person-code": "PERSON-DEMO-001", "legal-name": "Omar Example", "family-name": "Example", "date-of-birth": "1986-02-22", "person-sex": "male", "residency-status": "usual-resident", "preferred-language": "en"}, + {"person-code": "PERSON-DEMO-002", "legal-name": "Lina Example", "family-name": "Example", "date-of-birth": "2023-03-14", "person-sex": "female", "residency-status": "usual-resident", "preferred-language": "en"}, + {"person-code": "PERSON-DEMO-003", "legal-name": "Sofia Sample", "family-name": "Sample", "date-of-birth": "1980-11-02", "person-sex": "female", "residency-status": "usual-resident", "preferred-language": "es"}, + {"person-code": "PERSON-DEMO-004", "legal-name": "Diego Sample", "family-name": "Sample", "date-of-birth": "2016-06-17", "person-sex": "male", "residency-status": "usual-resident", "preferred-language": "es"}, + {"person-code": "PERSON-DEMO-005", "legal-name": "Rosa Sample", "family-name": "Sample", "date-of-birth": "1940-08-20", "person-sex": "female", "residency-status": "usual-resident", "preferred-language": "es"}, + {"person-code": "PERSON-DEMO-006", "legal-name": "Karim Control", "family-name": "Control", "date-of-birth": "1975-01-09", "person-sex": "male", "residency-status": "usual-resident", "preferred-language": "fr"}, + {"person-code": "PERSON-DEMO-007", "legal-name": "Hana Control", "family-name": "Control", "date-of-birth": "1977-09-23", "person-sex": "female", "residency-status": "usual-resident", "preferred-language": "fr"}, + {"person-code": "PERSON-DEMO-008", "legal-name": "Noor Control", "family-name": "Control", "date-of-birth": "2018-05-06", "person-sex": "female", "residency-status": "usual-resident", "preferred-language": "fr"}, ] households = [ - {"household-code": "HOUSEHOLD-DEMO-001", "household-name": "Northern Demo Household", "administrative-area": "north-demo", "household-type": "private"}, - {"household-code": "HOUSEHOLD-DEMO-002", "household-name": "Central Demo Household", "administrative-area": "central-demo", "household-type": "private"}, + {"household-code": "HOUSEHOLD-DEMO-001", "local-household-number": 1001, "household-name": "Single Headed Under Five Household", "administrative-area": "north-demo", "household-type": "private"}, + {"household-code": "HOUSEHOLD-DEMO-002", "local-household-number": 1002, "household-name": "Woman Headed Child Elderly Household", "administrative-area": "central-demo", "household-type": "private"}, + {"household-code": "HOUSEHOLD-DEMO-003", "local-household-number": 1003, "household-name": "Isolation Control Household", "administrative-area": "south-demo", "household-type": "private"}, ] memberships = [ {"person-code": "PERSON-DEMO-001", "household-code": "HOUSEHOLD-DEMO-001", "relationship": "head", "valid-from": "2026-01-01"}, {"person-code": "PERSON-DEMO-002", "household-code": "HOUSEHOLD-DEMO-001", "relationship": "child", "valid-from": "2026-01-01"}, - {"person-code": "PERSON-DEMO-005", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "head", "valid-from": "2026-01-01"}, - {"person-code": "PERSON-DEMO-003", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "spouse", "valid-from": "2026-01-01"}, + {"person-code": "PERSON-DEMO-003", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "head", "valid-from": "2026-01-01"}, {"person-code": "PERSON-DEMO-004", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "child", "valid-from": "2026-01-01"}, + {"person-code": "PERSON-DEMO-005", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "dependent", "valid-from": "2026-01-01"}, + {"person-code": "PERSON-DEMO-006", "household-code": "HOUSEHOLD-DEMO-003", "relationship": "head", "valid-from": "2026-01-01"}, + {"person-code": "PERSON-DEMO-007", "household-code": "HOUSEHOLD-DEMO-003", "relationship": "spouse", "valid-from": "2026-01-01"}, + {"person-code": "PERSON-DEMO-008", "household-code": "HOUSEHOLD-DEMO-003", "relationship": "child", "valid-from": "2026-01-01"}, ] return people, households, memberships @@ -456,26 +470,27 @@ def seed(root: Path) -> None: "valid-from": membership["valid-from"], }, ) + _write_json(root / "seed-record-ids.json", {"people": person_ids, "households": household_ids}) people_response, _ = _request( root, "GET", - "/v1/records/persons?accessProfile=household-operator&pageSize=20", + "/v1/records/persons?accessProfile=household-operator&$top=20", "operator-token", ) household_response, _ = _request( root, "GET", - "/v1/records/households?accessProfile=household-operator&pageSize=20", + "/v1/records/households?accessProfile=household-operator&$top=20", "operator-token", ) membership_response, _ = _request( root, "GET", - "/v1/records/group-memberships:current?accessProfile=household-operator&pageSize=20", + "/v1/records/group-memberships:current?accessProfile=household-operator&$top=20", "operator-token", ) - if [len(response.get("items", [])) for response in (people_response, household_response, membership_response)] != [5, 2, 5]: - raise DemoError("seeded list counts did not match the expected 5 people, 2 households, and 5 memberships") + if [len(response.get("items", [])) for response in (people_response, household_response, membership_response)] != [8, 3, 8]: + raise DemoError("seeded list counts did not match the expected 8 people, 3 households, and 8 memberships") _request( root, "GET", @@ -483,15 +498,22 @@ def seed(root: Path) -> None: "no-purpose-token", expected=404, ) - print("Seeded 5 synthetic people, 2 households, and 5 current memberships.") + print("Seeded 8 synthetic people, 3 households, and 8 current memberships.") def query(root: Path) -> None: root = _require_root(root) + seed_ids = _read_json_object(root / "seed-record-ids.json") + households = seed_ids.get("households") + if not isinstance(households, dict) or not isinstance(households.get("HOUSEHOLD-DEMO-001"), str): + raise DemoError("seed record identifiers are missing; run the demo seed first") + first_household_id = urllib.parse.quote(households["HOUSEHOLD-DEMO-001"], safe="") queries = [ - ("Usual residents", "/v1/records/persons?accessProfile=household-operator&fields=person-code,legal-name,residency-status&filter=residency-status:equals:usual-resident&pageSize=20"), - ("Households", "/v1/records/households?accessProfile=household-operator&fields=household-code,household-name,administrative-area&pageSize=20"), - ("Current memberships", "/v1/records/group-memberships:current?accessProfile=household-operator&pageSize=20"), + ("People from one household", f"/v1/records/households/{first_household_id}/people?accessProfile=household-operator&$select=person-code,legal-name,person-sex,residency-status&$orderby=person-code&$top=20&$count=true"), + ("Derived stored and computed filter", "/v1/records/households?accessProfile=household-operator&$select=household-code,administrative-area,local-household-number,child-count&$filter=administrative-area%20eq%20%27north-demo%27%20and%20child-count%20eq%201&$orderby=local-household-number&$top=20&$count=true"), + ("Single headed with child under five", "/v1/records/households?accessProfile=household-operator&$select=household-code,child-under-5-count,single-headed&$filter=single-headed%20eq%20true%20and%20child-under-5-count%20eq%201&$top=20&$count=true"), + ("Woman headed with child and elderly", "/v1/records/households?accessProfile=household-operator&$select=household-code,woman-headed,child-count,elderly-count&$filter=woman-headed%20eq%20true%20and%20child-count%20eq%201%20and%20elderly-count%20eq%201&$top=20&$count=true"), + ("Selector lookup input shape", "/v1/records/households?accessProfile=household-operator&$select=household-code,local-household-number&$filter=household-code%20eq%20%27HOUSEHOLD-DEMO-001%27&$top=1"), ] for label, path in queries: response, _ = _request(root, "GET", path, "operator-token") diff --git a/products/registry-server/demo/support/test_demo.py b/products/registry-server/demo/support/test_demo.py index 2d0aabcae7..f65b853fec 100755 --- a/products/registry-server/demo/support/test_demo.py +++ b/products/registry-server/demo/support/test_demo.py @@ -103,14 +103,22 @@ def test_seed_is_referentially_closed_and_stable(self) -> None: people, households, memberships = DEMO.seed_spec() person_codes = {person["person-code"] for person in people} household_codes = {household["household-code"] for household in households} - self.assertEqual((len(people), len(households), len(memberships)), (5, 2, 5)) + self.assertEqual((len(people), len(households), len(memberships)), (8, 3, 8)) self.assertEqual(len(person_codes), len(people)) self.assertEqual(len(household_codes), len(households)) + self.assertEqual( + [household["local-household-number"] for household in households], + [1001, 1002, 1003], + ) self.assertTrue(all(row["person-code"] in person_codes for row in memberships)) self.assertTrue(all(row["household-code"] in household_codes for row in memberships)) + self.assertEqual( + {person["person-sex"] for person in people}, + {"female", "male"}, + ) self.assertEqual( sum(person["residency-status"] == "usual-resident" for person in people), - 4, + 8, ) def test_prepare_refuses_a_fixture_without_the_expected_localization_boundary(self) -> None: diff --git a/products/registry-server/generated/asset-site-placement/generated/openapi.json b/products/registry-server/generated/asset-site-placement/generated/openapi.json index 935f0d7f00..8db7c7fb04 100644 --- a/products/registry-server/generated/asset-site-placement/generated/openapi.json +++ b/products/registry-server/generated/asset-site-placement/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"asset-item":{"$id":"urn:registry-server:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset-class":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"asset-code":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["asset-class","asset-code","label"],"type":"object","x-registry-mutationMode":"mutable"},"asset-placement":{"$id":"urn:registry-server:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["asset","site","valid-from"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site":{"$id":"urn:registry-server:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"site-code":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label","site-code"],"type":"object","x-registry-mutationMode":"mutable"},"inspection-event":{"$id":"urn:registry-server:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observed-at":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observed-at","result"],"type":"object","x-registry-mutationMode":"create_only"}}},"info":{"title":"asset-site-placement","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/assets":{"get":{"operationId":"records.asset-item.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-item.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"create"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset-item.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"get"},"patch":{"operationId":"records.asset-item.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"patch"}},"/v1/records/assets:batch":{"post":{"operationId":"records.asset-item.batch","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"items":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"operation":{"const":"create"}},"required":["operation","data"],"type":"object"},{"additionalProperties":false,"properties":{"ifMatch":{"type":"string"},"operation":{"const":"patch"},"patch":{"maxItems":128,"minItems":1,"type":"array"},"recordId":{"format":"uuid","type":"string"}},"required":["operation","recordId","ifMatch","patch"],"type":"object"}]},"maxItems":4,"minItems":1,"type":"array"}},"required":["items"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"results":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"etag":{"type":"string"},"id":{"format":"uuid","type":"string"},"operation":{"enum":["create","patch"]},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["operation","id","revision","etag","data"],"type":"object"},"maxItems":4,"minItems":1,"type":"array"}},"required":["results"],"type":"object"}}},"description":"Atomic batch committed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-maximumBytes":16384,"x-registry-maximumItems":4,"x-registry-operation":"batch"}},"/v1/records/inspections":{"get":{"operationId":"records.inspection-event.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.inspection-event.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"create"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.inspection-event.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"get"}},"/v1/records/placements":{"get":{"operationId":"records.asset-placement.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-placement.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"create"}},"/v1/records/placements/{record_id}":{"get":{"operationId":"records.asset-placement.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"get"},"patch":{"operationId":"records.asset-placement.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"patch"}},"/v1/records/placements:as-of":{"get":{"operationId":"records.asset-placement.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/placements:current":{"get":{"operationId":"records.asset-placement.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/sites":{"get":{"operationId":"records.asset-site.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable fields.","explode":false,"in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Repeatable field:operator:value filter clause.","explode":true,"in":"query","name":"filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable field, ascending only.","explode":false,"in":"query","name":"sort","required":false,"schema":{"type":"string"}},{"description":"Bounded page size within the compiled maximum.","explode":false,"in":"query","name":"pageSize","required":false,"schema":{"minimum":1,"type":"integer"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-site.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"create"}},"/v1/records/sites/{record_id}":{"get":{"operationId":"records.asset-site.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"get"},"patch":{"operationId":"records.asset-site.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"patch"}}}} \ No newline at end of file +{"components":{"schemas":{"asset-item":{"$id":"urn:registry-server:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset-class":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"asset-code":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["asset-class","asset-code","label"],"type":"object","x-registry-mutationMode":"mutable"},"asset-placement":{"$id":"urn:registry-server:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["asset","site","valid-from"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site":{"$id":"urn:registry-server:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"site-code":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label","site-code"],"type":"object","x-registry-mutationMode":"mutable"},"inspection-event":{"$id":"urn:registry-server:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observed-at":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observed-at","result"],"type":"object","x-registry-mutationMode":"create_only"}}},"info":{"title":"asset-site-placement","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/assets":{"get":{"operationId":"records.asset-item.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-item.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"create"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset-item.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"get"},"patch":{"operationId":"records.asset-item.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"patch"}},"/v1/records/assets:batch":{"post":{"operationId":"records.asset-item.batch","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"items":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"operation":{"const":"create"}},"required":["operation","data"],"type":"object"},{"additionalProperties":false,"properties":{"ifMatch":{"type":"string"},"operation":{"const":"patch"},"patch":{"maxItems":128,"minItems":1,"type":"array"},"recordId":{"format":"uuid","type":"string"}},"required":["operation","recordId","ifMatch","patch"],"type":"object"}]},"maxItems":4,"minItems":1,"type":"array"}},"required":["items"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"results":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"etag":{"type":"string"},"id":{"format":"uuid","type":"string"},"operation":{"enum":["create","patch"]},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["operation","id","revision","etag","data"],"type":"object"},"maxItems":4,"minItems":1,"type":"array"}},"required":["results"],"type":"object"}}},"description":"Atomic batch committed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-maximumBytes":16384,"x-registry-maximumItems":4,"x-registry-operation":"batch"}},"/v1/records/inspections":{"get":{"operationId":"records.inspection-event.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.inspection-event.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"create"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.inspection-event.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"get"}},"/v1/records/placements":{"get":{"operationId":"records.asset-placement.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-placement.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"create"}},"/v1/records/placements/{record_id}":{"get":{"operationId":"records.asset-placement.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"get"},"patch":{"operationId":"records.asset-placement.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"patch"}},"/v1/records/placements:as-of":{"get":{"operationId":"records.asset-placement.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/placements:current":{"get":{"operationId":"records.asset-placement.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/sites":{"get":{"operationId":"records.asset-site.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-site.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"create"}},"/v1/records/sites/{record_id}":{"get":{"operationId":"records.asset-site.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"get"},"patch":{"operationId":"records.asset-site.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"patch"}}}} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql b/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql index 991c134399..046fd7dc6c 100644 --- a/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql +++ b/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql @@ -1,4 +1,15 @@ CREATE SCHEMA IF NOT EXISTS registry_data; +CREATE SCHEMA IF NOT EXISTS registry_source; +CREATE SCHEMA IF NOT EXISTS registry_derived; +CREATE SCHEMA IF NOT EXISTS registry_context; +CREATE OR REPLACE FUNCTION registry_context.evaluation_date() + RETURNS date + LANGUAGE sql + STABLE + SECURITY INVOKER + AS $registry_server_function$ + SELECT NULLIF(current_setting('registry.evaluation_date', true), '')::date + $registry_server_function$; CREATE TABLE registry_data."rs_e_asset_item_847d26c3e6e68a51" (record_id uuid NOT NULL, record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0), record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned')), created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> ''), PRIMARY KEY (record_id), "rs_f_asset_item_asset_class_d600d2cfe0601df0" text NOT NULL CHECK ("rs_f_asset_item_asset_class_d600d2cfe0601df0" IN ('equipment', 'vehicle', 'furniture')), "rs_f_asset_item_asset_code_3dcfb11c8485c27d" varchar(64) NOT NULL, "rs_f_asset_item_label_07f15f9906c86214" varchar(200) NOT NULL); CREATE TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" (record_id uuid NOT NULL, record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0), record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned')), created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> ''), PRIMARY KEY (record_id), "rs_f_asset_placement_asset_c9ca09383d36692d" uuid NOT NULL, "rs_f_asset_placement_site_1f363a0accf66d99" uuid NOT NULL, "rs_f_asset_placement_valid_from_26d05bd0c44857c7" date NOT NULL, "rs_f_asset_placement_valid_to_f90aaf0250c93a70" date); CREATE TABLE registry_data."rs_e_asset_site_db7008b8eaed2382" (record_id uuid NOT NULL, record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0), record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned')), created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> ''), PRIMARY KEY (record_id), "rs_f_asset_site_label_12f77c179e4d46c0" varchar(200) NOT NULL, "rs_f_asset_site_site_code_078b8d51a606a531" varchar(64) NOT NULL); @@ -16,6 +27,11 @@ CREATE POLICY "registry_rls_select_ae0796eafa1e9eac571bb87c" ON registry_data."r CREATE POLICY "registry_rls_insert_d025d90a72995a769e8a6173" ON registry_data."rs_e_asset_item_847d26c3e6e68a51" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_update_705b1fb4f79ed895a0e92256" ON registry_data."rs_e_asset_item_847d26c3e6e68a51" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_select_ef9fd8aeff50702410afaaaa" ON registry_data."rs_e_asset_item_847d26c3e6e68a51" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE VIEW registry_source."asset_item" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS id, "rs_f_asset_item_asset_code_3dcfb11c8485c27d" AS "asset_code", "rs_f_asset_item_label_07f15f9906c86214" AS "label", "rs_f_asset_item_asset_class_d600d2cfe0601df0" AS "asset_class" + FROM registry_data."rs_e_asset_item_847d26c3e6e68a51" + WHERE record_lifecycle = 'active'; ALTER TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_607646a772003fc998702c5d" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); @@ -24,13 +40,28 @@ CREATE POLICY "registry_rls_update_3954a4ba2983e7cdfcde8af4" ON registry_data."r CREATE POLICY "registry_rls_select_975f856168c7c15a912dda52" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_insert_bc00fa6b634ab2ca59bb7efd" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_update_240cb40a7ff28a3eda3e35fa" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE VIEW registry_source."asset_placement" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS id, "rs_f_asset_placement_asset_c9ca09383d36692d" AS "asset", "rs_f_asset_placement_site_1f363a0accf66d99" AS "site", "rs_f_asset_placement_valid_from_26d05bd0c44857c7" AS "valid_from", "rs_f_asset_placement_valid_to_f90aaf0250c93a70" AS "valid_to" + FROM registry_data."rs_e_asset_placement_36f204044c8d76ff" + WHERE record_lifecycle = 'active'; ALTER TABLE registry_data."rs_e_asset_site_db7008b8eaed2382" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."rs_e_asset_site_db7008b8eaed2382" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_34fa8a622e702a16f5b0b398" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_insert_e160cd033236bc16b7069084" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_update_50997e5bd81b338659ce5217" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_select_1077759afe589ed883cbf7e8" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE VIEW registry_source."asset_site" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS id, "rs_f_asset_site_site_code_078b8d51a606a531" AS "site_code", "rs_f_asset_site_label_12f77c179e4d46c0" AS "label" + FROM registry_data."rs_e_asset_site_db7008b8eaed2382" + WHERE record_lifecycle = 'active'; ALTER TABLE registry_data."rs_e_inspection_event_8d78d2871d86ffa3" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."rs_e_inspection_event_8d78d2871d86ffa3" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_160ed0ec696ef3506f21244c" ON registry_data."rs_e_inspection_event_8d78d2871d86ffa3" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_insert_e57571ab1a9e5b1bd4f66b59" ON registry_data."rs_e_inspection_event_8d78d2871d86ffa3" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE VIEW registry_source."inspection_event" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS id, "rs_f_inspection_event_asset_3a5ad890cb504dce" AS "asset", "rs_f_inspection_event_observed_at_5ae9cec8794e85a2" AS "observed_at", "rs_f_inspection_event_result_f0d09fc75deb558a" AS "result" + FROM registry_data."rs_e_inspection_event_8d78d2871d86ffa3" + WHERE record_lifecycle = 'active'; diff --git a/products/registry-server/generated/authoring/registry-project.schema.json b/products/registry-server/generated/authoring/registry-project.schema.json index 301bf94ded..c502f9feeb 100644 --- a/products/registry-server/generated/authoring/registry-project.schema.json +++ b/products/registry-server/generated/authoring/registry-project.schema.json @@ -10,6 +10,9 @@ "type": "array", "uniqueItems": true }, + "allowCount": { + "type": "boolean" + }, "allowDataExport": { "default": false, "type": "boolean" @@ -25,6 +28,18 @@ "type": "array", "uniqueItems": true }, + "lookups": { + "items": { + "$ref": "#/$defs/LookupGrantSource" + }, + "type": "array" + }, + "readPaths": { + "items": { + "$ref": "#/$defs/ReadPathGrantSource" + }, + "type": "array" + }, "readableFields": { "default": [], "items": { @@ -70,6 +85,9 @@ "AccessProfileSource": { "additionalProperties": false, "properties": { + "allowCount": { + "type": "boolean" + }, "allowDataExport": { "default": false, "type": "boolean" @@ -93,6 +111,12 @@ "id": { "type": "string" }, + "lookups": { + "items": { + "$ref": "#/$defs/LookupGrantSource" + }, + "type": "array" + }, "operations": { "items": { "$ref": "#/$defs/Operation" @@ -107,6 +131,12 @@ "null" ] }, + "readPaths": { + "items": { + "$ref": "#/$defs/ReadPathGrantSource" + }, + "type": "array" + }, "readableFields": { "default": [], "items": { @@ -195,6 +225,13 @@ "BooleanFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, @@ -468,6 +505,13 @@ "Crs84PointFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "bbox": { "anyOf": [ { @@ -527,6 +571,13 @@ "DateFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, @@ -568,6 +619,13 @@ "DecimalFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, @@ -628,6 +686,299 @@ ], "type": "object" }, + "DerivedExecutionSource": { + "enum": [ + "live" + ], + "type": "string" + }, + "DerivedFieldSource": { + "oneOf": [ + { + "properties": { + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "maxLength": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "minLength": { + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type", + "maxLength" + ], + "type": "object" + }, + { + "properties": { + "maxLength": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "type", + "maxLength" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "int64", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "maximum": { + "type": [ + "string", + "null" + ] + }, + "minimum": { + "type": [ + "string", + "null" + ] + }, + "precision": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "scale": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "decimal", + "type": "string" + } + }, + "required": [ + "type", + "precision", + "scale" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "date", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "timestamp", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "uuid", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "vocabulary-code", + "type": "string" + }, + "values": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "vocabulary": { + "type": "string" + } + }, + "required": [ + "type", + "vocabulary" + ], + "type": "object" + }, + { + "properties": { + "onDelete": { + "$ref": "#/$defs/ReferenceDelete", + "default": "restrict" + }, + "target": { + "type": "string" + }, + "type": { + "const": "reference", + "type": "string" + } + }, + "required": [ + "type", + "target" + ], + "type": "object" + }, + { + "properties": { + "bbox": { + "anyOf": [ + { + "$ref": "#/$defs/Crs84BboxSource" + }, + { + "type": "null" + } + ] + }, + "precision": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "crs84-point", + "type": "string" + } + }, + "required": [ + "type", + "precision" + ], + "type": "object" + }, + { + "properties": { + "maxBytes": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "schema": true, + "type": { + "const": "structured", + "type": "string" + } + }, + "required": [ + "type", + "maxBytes", + "schema" + ], + "type": "object" + } + ], + "properties": { + "apiName": { + "type": [ + "string", + "null" + ] + }, + "classification": { + "$ref": "#/$defs/Classification" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "classification" + ], + "type": "object", + "unevaluatedProperties": false + }, + "DerivedSource": { + "additionalProperties": false, + "properties": { + "execution": { + "$ref": "#/$defs/DerivedExecutionSource", + "default": "live" + }, + "fields": { + "default": [], + "items": { + "$ref": "#/$defs/DerivedFieldSource" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "sql": { + "type": "string" + } + }, + "required": [ + "id", + "sql", + "key" + ], + "type": "object" + }, "EntitySource": { "additionalProperties": false, "properties": { @@ -660,6 +1011,12 @@ }, "type": "array" }, + "derived": { + "items": { + "$ref": "#/$defs/DerivedSource" + }, + "type": "array" + }, "events": { "default": [], "items": { @@ -687,9 +1044,21 @@ "mutationMode": { "$ref": "#/$defs/MutationMode" }, + "readPaths": { + "items": { + "$ref": "#/$defs/ReadPathSource" + }, + "type": "array" + }, "route": { "type": "string" }, + "selectorProfiles": { + "items": { + "$ref": "#/$defs/SelectorProfileSource" + }, + "type": "array" + }, "temporal": { "anyOf": [ { @@ -823,6 +1192,13 @@ "Int64FieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, @@ -855,6 +1231,36 @@ ], "type": "object" }, + "LookupGrantSource": { + "additionalProperties": false, + "properties": { + "claimMapping": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "type": "object" + }, + "selector": { + "type": "string" + }, + "valueOrigin": { + "$ref": "#/$defs/LookupValueOrigin" + } + }, + "required": [ + "selector", + "valueOrigin" + ], + "type": "object" + }, + "LookupValueOrigin": { + "enum": [ + "request", + "verified_claim" + ], + "type": "string" + }, "ManifestProjectionApplicationProfileSource": { "additionalProperties": false, "properties": { @@ -1364,6 +1770,7 @@ "enum": [ "create", "get", + "lookup", "list", "patch", "tombstone", @@ -1441,6 +1848,70 @@ ], "type": "object" }, + "ReadPathGrantSource": { + "additionalProperties": false, + "properties": { + "allowCount": { + "default": false, + "type": "boolean" + }, + "filterableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "path": { + "type": "string" + }, + "readableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "sortableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "ReadPathSource": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "route": { + "type": "string" + }, + "through": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "id", + "through", + "to", + "route" + ], + "type": "object" + }, "ReferenceDelete": { "enum": [ "restrict" @@ -1456,6 +1927,13 @@ "ReferenceFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, @@ -1536,6 +2014,25 @@ ], "type": "object" }, + "SelectorProfileSource": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "fields" + ], + "type": "object" + }, "StringFieldKindSchema": { "enum": [ "string" @@ -1545,6 +2042,13 @@ "StringFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, @@ -1598,6 +2102,13 @@ "StructuredFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, @@ -1670,6 +2181,13 @@ "TextFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, @@ -1717,6 +2235,13 @@ "TimestampFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, @@ -1828,6 +2353,13 @@ "UuidFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, @@ -1876,6 +2408,13 @@ "VocabularyCodeFieldSourceSchema": { "additionalProperties": false, "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, "classification": { "$ref": "#/$defs/Classification" }, diff --git a/products/registry-server/generated/publicschema-household/generated/manifest/dcat.jsonld b/products/registry-server/generated/publicschema-household/generated/manifest/dcat.jsonld new file mode 100644 index 0000000000..8a95beaae5 --- /dev/null +++ b/products/registry-server/generated/publicschema-household/generated/manifest/dcat.jsonld @@ -0,0 +1 @@ +{"@context":{"adms":"http://www.w3.org/ns/adms#","adms:status":{"@type":"@id"},"dcat":"http://www.w3.org/ns/dcat#","dcat:accessService":{"@type":"@id"},"dcat:accessURL":{"@type":"@id"},"dcat:dataset":{"@type":"@id"},"dcat:distribution":{"@type":"@id"},"dcat:endpointDescription":{"@type":"@id"},"dcat:endpointURL":{"@type":"@id"},"dcat:landingPage":{"@type":"@id"},"dcat:mediaType":{"@type":"@id"},"dcat:servesDataset":{"@type":"@id"},"dcat:theme":{"@type":"@id"},"dcat:themeTaxonomy":{"@type":"@id"},"dcterms":"http://purl.org/dc/terms/","dcterms:accessRights":{"@type":"@id"},"dcterms:accrualPeriodicity":{"@type":"@id"},"dcterms:conformsTo":{"@type":"@id"},"dcterms:format":{"@type":"@id"},"dcterms:isPartOf":{"@type":"@id"},"dcterms:spatial":{"@type":"@id"},"dcterms:type":{"@type":"@id"},"foaf":"http://xmlns.com/foaf/0.1/","odrl":"http://www.w3.org/ns/odrl/2/","odrl:action":{"@type":"@id"},"odrl:assignee":{"@type":"@id"},"odrl:assigner":{"@type":"@id"},"odrl:hasPolicy":{"@type":"@id"},"odrl:leftOperand":{"@type":"@id"},"odrl:operator":{"@type":"@id"},"odrl:profile":{"@type":"@id"},"odrl:target":{"@type":"@id"},"odrl:uid":{"@type":"@id"},"odrl:unit":{"@type":"@id"},"rdfs":"http://www.w3.org/2000/01/rdf-schema#","rdfs:seeAlso":{"@type":"@id"},"registry_manifest":"https://id.registrystack.org/ns/registry-manifest/v1#","sh":"http://www.w3.org/ns/shacl#","sh:class":{"@type":"@id"},"sh:datatype":{"@type":"@id"},"sh:nodeKind":{"@type":"@id"},"sh:path":{"@type":"@id"},"sh:targetClass":{"@type":"@id"},"skos":"http://www.w3.org/2004/02/skos/core#","skos:hasTopConcept":{"@type":"@id"},"skos:inScheme":{"@type":"@id"},"xsd":"http://www.w3.org/2001/XMLSchema#"},"@id":"https://publicschema-household.example.gov/metadata/dcat.jsonld","@included":[{"@id":"https://www.w3.org/TR/vocab-dcat-3/","@type":"dcterms:Standard"},{"@id":"#dataset-household-registry","@type":"foaf:Document"},{"@id":"http://eurovoc.europa.eu/100141","@type":"skos:ConceptScheme","dcterms:title":"100141","skos:prefLabel":"100141"},{"@id":"http://publications.europa.eu/resource/authority/data-theme","@type":"skos:ConceptScheme","dcterms:title":"data theme","skos:prefLabel":"data theme"},{"@id":"https://publicschema-household.example.gov","@type":"foaf:Document"},{"@id":"https://spec.openapis.org/oas/v3.1.0","@type":"dcterms:Standard"}],"@type":"dcat:Catalog","dcat:dataset":[{"@id":"#dataset-household-registry","@type":"dcat:Dataset","dcat:landingPage":"#dataset-household-registry","dcterms:conformsTo":[],"dcterms:description":"Household, person, and time-bounded group membership metadata.","dcterms:identifier":"household-registry","dcterms:title":"PublicSchema Household Registry","odrl:hasPolicy":{"@id":"#policy-household-registry-offer","@type":"odrl:Offer","odrl:assigner":{"@id":"https://publicschema-household.example.gov/authority"},"odrl:permission":[{"odrl:action":{"@id":"odrl:use"},"odrl:assigner":{"@id":"https://publicschema-household.example.gov/authority"},"odrl:target":{"@id":"#dataset-household-registry"}}],"odrl:uid":"#policy-household-registry-offer"}}],"dcat:landingPage":"https://publicschema-household.example.gov","dcat:service":[{"@id":"https://publicschema-household.example.gov/services/registry-api","@type":"dcat:DataService","dcat:endpointDescription":"https://publicschema-household.example.gov/openapi.json","dcat:endpointURL":"https://publicschema-household.example.gov/v1","dcat:servesDataset":[{"@id":"#dataset-household-registry"}],"dcterms:conformsTo":"https://spec.openapis.org/oas/v3.1.0","dcterms:description":"","dcterms:identifier":"household-registry-api","dcterms:title":"Household Registry REST API"}],"dcat:themeTaxonomy":["http://publications.europa.eu/resource/authority/data-theme","http://eurovoc.europa.eu/100141"],"dcterms:conformsTo":["https://www.w3.org/TR/vocab-dcat-3/"],"dcterms:description":"Portable metadata for household, person, and group membership records.","dcterms:identifier":"publicschema-household","dcterms:publisher":{"@id":"https://publicschema-household.example.gov/authority","@type":"foaf:Agent","foaf:name":"PublicSchema Household Authority"},"dcterms:title":"PublicSchema Household Registry Catalog"} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/manifest/registry-manifest.json b/products/registry-server/generated/publicschema-household/generated/manifest/registry-manifest.json new file mode 100644 index 0000000000..8f6f179908 --- /dev/null +++ b/products/registry-server/generated/publicschema-household/generated/manifest/registry-manifest.json @@ -0,0 +1 @@ +{"authorities":[],"catalog":{"application_profiles":[],"base_url":"https://publicschema-household.example.gov","conforms_to":["https://www.w3.org/TR/vocab-dcat-3/"],"description":{"en":"Portable metadata for household, person, and group membership records.","fr":"Métadonnées portables pour les personnes, les ménages et leur appartenance."},"id":"publicschema-household","publisher":{"iri":"https://publicschema-household.example.gov/authority","name":"PublicSchema Household Authority"},"standards":{"dcat":"3.0","json_schema":"2020-12"},"title":{"en":"PublicSchema Household Registry Catalog","fr":"Catalogue du registre des ménages PublicSchema"}},"codelists":[{"concepts":[{"code":"head","iri":"https://publicschema.org/GroupRole/head","label":{"en":"Head","fr":"Chef"}},{"code":"spouse","iri":"https://publicschema.org/GroupRole/spouse","label":{"en":"Spouse","fr":"Conjoint"}},{"code":"child","iri":"https://publicschema.org/GroupRole/child","label":{"en":"Child","fr":"Enfant"}},{"code":"dependent","iri":"https://publicschema.org/GroupRole/dependent","label":{"en":"Dependent","fr":"Personne à charge"}},{"code":"other","iri":"https://publicschema.org/GroupRole/other","label":{"en":"Other","fr":"Autre"}}],"external_ref":"https://publicschema.org/GroupRole","id":"household-relationship","scheme_iri":"https://publicschema.org/GroupRole","version":"0.3.0"},{"concepts":[{"code":"private"},{"code":"collective"},{"code":"institutional"}],"id":"household-type","scheme_iri":"https://publicschema-household.example.gov/vocab/household-type"},{"concepts":[{"code":"usual-resident"},{"code":"temporary-resident"},{"code":"departed"}],"id":"residency-status","scheme_iri":"https://publicschema-household.example.gov/vocab/residency-status"},{"concepts":[{"code":"en"},{"code":"es"},{"code":"fr"}],"external_ref":"https://id.loc.gov/vocabulary/iso639-1.html","id":"preferred-language","scheme_iri":"https://id.loc.gov/vocabulary/iso639-1"},{"concepts":[{"code":"female","iri":"https://publicschema.org/Sex/female","label":{"en":"Female","fr":"Féminin"}},{"code":"male","iri":"https://publicschema.org/Sex/male","label":{"en":"Male","fr":"Masculin"}},{"code":"unknown","iri":"https://publicschema.org/Sex/unknown","label":{"en":"Unknown","fr":"Inconnu"}}],"external_ref":"https://publicschema.org/Sex","id":"person-sex","scheme_iri":"https://publicschema.org/Sex","version":"0.3.0"}],"data_services":[{"conforms_to":"https://spec.openapis.org/oas/v3.1.0","endpoint_description":"https://publicschema-household.example.gov/openapi.json","endpoint_url":"https://publicschema-household.example.gov/v1","id":"household-registry-api","iri":"https://publicschema-household.example.gov/services/registry-api","serves_datasets":["household-registry"],"title":{"en":"Household Registry REST API","fr":"API REST du registre des ménages"}}],"datasets":[{"access_rights":"restricted","applicable_legislation":[],"conforms_to":[],"description":{"en":"Household, person, and time-bounded group membership metadata.","fr":"Métadonnées sur les ménages, les personnes et les appartenances limitées dans le temps."},"entities":[{"concept_uri":"https://publicschema.org/GroupMembership","description":{"en":"A time-bounded link between a person and a household.","fr":"Un lien limité dans le temps entre une personne et un ménage."},"fields":[{"codelist":"household-relationship","concepts":["https://publicschema.org/role"],"constraints":{"in":["head","spouse","child","dependent","other"]},"name":"relationship","required":true,"type":"code"},{"concepts":["https://publicschema.org/start_date"],"constraints":{"in":[]},"name":"valid-from","required":true,"type":"date"},{"concepts":["https://publicschema.org/end_date"],"constraints":{"in":[]},"name":"valid-to","required":false,"type":"date"}],"identifiers":[],"name":"group-membership","relationships":[{"cardinality":"one","concept_uri":"https://publicschema.org/group","name":"household","role":"group","target_entity":"household"},{"cardinality":"one","concept_uri":"https://publicschema.org/person","name":"person","role":"member","target_entity":"person"}],"title":{"en":"Group membership","fr":"Appartenance à un groupe"}},{"concept_uri":"https://publicschema.org/Household","description":{"en":"A social and economic unit represented as a configured registry entity.","fr":"Une unité sociale et économique représentée comme une entité configurée du registre."},"fields":[{"concepts":[],"constraints":{"in":[],"max_length":80,"min_length":0},"name":"administrative-area","required":true,"type":"string"},{"concepts":["https://publicschema.org/identifier"],"constraints":{"in":[],"max_length":64,"min_length":0},"name":"household-code","required":true,"type":"string"},{"concepts":["https://publicschema.org/name"],"constraints":{"in":[],"max_length":160,"min_length":0},"name":"household-name","required":true,"type":"string"},{"codelist":"household-type","concepts":[],"constraints":{"in":["private","collective","institutional"]},"name":"household-type","required":true,"type":"code"},{"concepts":["https://publicschema.org/local_identifier"],"constraints":{"in":[]},"name":"local-household-number","required":true,"type":"integer"}],"identifiers":[{"kind":"local","name":"household-code"}],"name":"household","relationships":[],"title":{"en":"Household","fr":"Ménage"}},{"concept_uri":"https://publicschema.org/Person","description":{"en":"A person recorded by the household registry.","fr":"Une personne enregistrée dans le registre des ménages."},"fields":[{"concepts":["https://publicschema.org/date_of_birth"],"constraints":{"in":[]},"name":"date-of-birth","required":false,"type":"date"},{"concepts":["https://publicschema.org/family_name"],"constraints":{"in":[],"max_length":120,"min_length":0},"name":"family-name","required":false,"type":"string"},{"concepts":["https://publicschema.org/name"],"constraints":{"in":[],"max_length":160,"min_length":0},"name":"legal-name","required":true,"type":"string"},{"concepts":["https://publicschema.org/identifier"],"constraints":{"in":[],"max_length":64,"min_length":0},"name":"person-code","required":true,"type":"string"},{"codelist":"person-sex","concepts":["https://publicschema.org/sex"],"constraints":{"in":["female","male","unknown"]},"name":"person-sex","required":true,"type":"code"},{"codelist":"preferred-language","concepts":[],"constraints":{"in":["en","es","fr"]},"name":"preferred-language","required":false,"type":"code"},{"codelist":"residency-status","concepts":[],"constraints":{"in":["usual-resident","temporary-resident","departed"]},"name":"residency-status","required":true,"type":"code"}],"identifiers":[{"kind":"local","name":"person-code"}],"name":"person","relationships":[],"title":{"en":"Person","fr":"Personne"}}],"evidence_offerings":[],"id":"household-registry","owner":"PublicSchema Household Authority","public_services":[],"sensitivity":"confidential","status":"active","title":{"en":"PublicSchema Household Registry","fr":"Registre des ménages PublicSchema"},"update_frequency":"unknown"}],"ecosystem_bindings":[],"evaluation_profiles":[],"evidence_types":[],"forms":[],"profiles":[],"public_services":[],"requirements":[],"schema_version":"registry-manifest/v1","vocabularies":{}} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/metadata/registry.json b/products/registry-server/generated/publicschema-household/generated/metadata/registry.json new file mode 100644 index 0000000000..3c0fdf27ba --- /dev/null +++ b/products/registry-server/generated/publicschema-household/generated/metadata/registry.json @@ -0,0 +1 @@ +{"entities":[{"entries":[{"accessProfile":"household-operator","operation":"list","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.as-of"},{"accessProfile":"household-operator","operation":"create","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.create"},{"accessProfile":"household-operator","operation":"list","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.current"},{"accessProfile":"household-operator","operation":"get","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.list"},{"accessProfile":"household-operator","operation":"patch","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.patch"}],"id":"group-membership","route":"group-memberships","schemaPath":"/v1/schemas/group-membership"},{"entries":[{"accessProfile":"household-operator","operation":"create","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.create"},{"accessProfile":"household-operator","operation":"get","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.get"},{"accessProfile":"household-viewer","operation":"get","readableFields":["administrative-area","household-code","household-name","household-type","local-household-number"],"routeId":"records.household.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.list"},{"accessProfile":"household-operator","operation":"lookup","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.lookup"},{"accessProfile":"household-viewer","operation":"lookup","readableFields":["administrative-area","household-code","household-name","household-type","local-household-number"],"routeId":"records.household.lookup"},{"accessProfile":"household-operator","operation":"patch","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.patch"},{"accessProfile":"household-operator","operation":"list","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.path.people"}],"id":"household","route":"households","schemaPath":"/v1/schemas/household"},{"entries":[{"accessProfile":"household-operator","operation":"create","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"routeId":"records.person.create"},{"accessProfile":"household-operator","operation":"get","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"routeId":"records.person.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"routeId":"records.person.list"},{"accessProfile":"household-operator","operation":"patch","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"routeId":"records.person.patch"}],"id":"person","route":"persons","schemaPath":"/v1/schemas/person"}],"registryId":"publicschema-household","version":"0.1.0"} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/openapi.json b/products/registry-server/generated/publicschema-household/generated/openapi.json new file mode 100644 index 0000000000..6eb2545097 --- /dev/null +++ b/products/registry-server/generated/publicschema-household/generated/openapi.json @@ -0,0 +1 @@ +{"components":{"schemas":{"group-membership":{"$id":"urn:registry-server:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["household","person","relationship","valid-from"],"type":"object","x-registry-mutationMode":"mutable"},"household":{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrative-area":{"maxLength":80,"minLength":0,"type":"string"},"household-code":{"maxLength":64,"minLength":0,"type":"string"},"household-name":{"maxLength":160,"minLength":0,"type":"string"},"household-type":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"local-household-number":{"format":"int64","type":"integer"}},"required":["administrative-area","household-code","household-name","household-type","local-household-number"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:registry-server:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"date-of-birth":{"format":"date","type":"string"},"family-name":{"maxLength":120,"minLength":0,"type":"string"},"legal-name":{"maxLength":160,"minLength":0,"type":"string"},"person-code":{"maxLength":64,"minLength":0,"type":"string"},"person-sex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferred-language":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residency-status":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["legal-name","person-code","person-sex","residency-status"],"type":"object","x-registry-mutationMode":"mutable"}}},"info":{"title":"publicschema-household","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.group-membership.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"create"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get"},"patch":{"operationId":"records.group-membership.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"patch"}},"/v1/records/group-memberships:as-of":{"get":{"operationId":"records.group-membership.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/group-memberships:current":{"get":{"operationId":"records.group-membership.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/households":{"get":{"operationId":"records.household.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.household.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"get"},"patch":{"operationId":"records.household.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"patch"}},"/v1/records/households/{record_id}/people":{"get":{"operationId":"records.household.path.people","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"}},"/v1/records/households:lookup":{"post":{"operationId":"records.household.lookup","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"lookup"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.person.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"create"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get"},"patch":{"operationId":"records.person.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"patch"}}}} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql new file mode 100644 index 0000000000..eb20ea5886 --- /dev/null +++ b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql @@ -0,0 +1,127 @@ +CREATE SCHEMA IF NOT EXISTS registry_data; +CREATE SCHEMA IF NOT EXISTS registry_source; +CREATE SCHEMA IF NOT EXISTS registry_derived; +CREATE SCHEMA IF NOT EXISTS registry_context; +CREATE OR REPLACE FUNCTION registry_context.evaluation_date() + RETURNS date + LANGUAGE sql + STABLE + SECURITY INVOKER + AS $registry_server_function$ + SELECT NULLIF(current_setting('registry.evaluation_date', true), '')::date + $registry_server_function$; +CREATE TABLE registry_data."rs_e_group_membership_6b97f4204f141f28" (record_id uuid NOT NULL, record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0), record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned')), created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> ''), PRIMARY KEY (record_id), "rs_f_group_membership_household_9ce011eef65483bd" uuid NOT NULL, "rs_f_group_membership_person_f16f370962050e27" uuid NOT NULL, "rs_f_group_membership_relationship_4da0b16845ccd25c" text NOT NULL CHECK ("rs_f_group_membership_relationship_4da0b16845ccd25c" IN ('head', 'spouse', 'child', 'dependent', 'other')), "rs_f_group_membership_valid_from_9982e6778a7c4410" date NOT NULL, "rs_f_group_membership_valid_to_6eb49ef9d6a65085" date); +CREATE TABLE registry_data."rs_e_household_45e8576d356a1f75" (record_id uuid NOT NULL, record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0), record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned')), created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> ''), PRIMARY KEY (record_id), "rs_f_household_administrative_area_1946b433a9241a87" varchar(80) NOT NULL, "rs_f_household_household_code_44029c0143d71ab3" varchar(64) NOT NULL, "rs_f_household_household_name_aeac0ac6071a6b3d" varchar(160) NOT NULL, "rs_f_household_household_type_87fa3a1f7183bbe0" text NOT NULL CHECK ("rs_f_household_household_type_87fa3a1f7183bbe0" IN ('private', 'collective', 'institutional')), "rs_f_household_local_household_number_040305e8ef37727d" bigint NOT NULL); +CREATE TABLE registry_data."rs_e_person_a28225974420754a" (record_id uuid NOT NULL, record_revision bigint NOT NULL DEFAULT 1 CHECK (record_revision > 0), record_lifecycle text NOT NULL DEFAULT 'active' CHECK (record_lifecycle IN ('active', 'tombstoned')), created_at timestamptz NOT NULL DEFAULT transaction_timestamp(), updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(), active_package_revision text NOT NULL DEFAULT NULLIF(current_setting('registry.active_package_revision', true), '') CHECK (active_package_revision <> ''), PRIMARY KEY (record_id), "rs_f_person_date_of_birth_d4d8fa151f4a4285" date, "rs_f_person_family_name_1a6b1252713201d7" varchar(120), "rs_f_person_legal_name_142f648a19dcd2a4" varchar(160) NOT NULL, "rs_f_person_person_code_7514464caf72c5a7" varchar(64) NOT NULL, "rs_f_person_person_sex_01e02174128c75d2" text NOT NULL CHECK ("rs_f_person_person_sex_01e02174128c75d2" IN ('female', 'male', 'unknown')), "rs_f_person_preferred_language_d36dc5f1bd7bec3c" text CHECK ("rs_f_person_preferred_language_d36dc5f1bd7bec3c" IN ('en', 'es', 'fr')), "rs_f_person_residency_status_19ed35302430c5ac" text NOT NULL CHECK ("rs_f_person_residency_status_19ed35302430c5ac" IN ('usual-resident', 'temporary-resident', 'departed'))); +ALTER TABLE registry_data."rs_e_group_membership_6b97f4204f141f28" ADD CONSTRAINT "registry_temporal_order_60d3dc851ed107ae8e3358d4" CHECK ("rs_f_group_membership_valid_to_6eb49ef9d6a65085" IS NULL OR "rs_f_group_membership_valid_from_9982e6778a7c4410" < "rs_f_group_membership_valid_to_6eb49ef9d6a65085"); +ALTER TABLE registry_data."rs_e_group_membership_6b97f4204f141f28" ADD CONSTRAINT "rs_r_group_membership_household_b380b31f7172d457" FOREIGN KEY ("rs_f_group_membership_household_9ce011eef65483bd") REFERENCES registry_data."rs_e_household_45e8576d356a1f75" (record_id) ON DELETE RESTRICT; +ALTER TABLE registry_data."rs_e_group_membership_6b97f4204f141f28" ADD CONSTRAINT "rs_r_group_membership_person_a7a0a93318d692c5" FOREIGN KEY ("rs_f_group_membership_person_f16f370962050e27") REFERENCES registry_data."rs_e_person_a28225974420754a" (record_id) ON DELETE RESTRICT; +ALTER TABLE registry_data."rs_e_group_membership_6b97f4204f141f28" ADD CONSTRAINT "rs_c_group_membership_temporal_non_overlap_4a7_a431d790c9c7107c" EXCLUDE USING gist ("rs_f_group_membership_person_f16f370962050e27" WITH =, daterange("rs_f_group_membership_valid_from_9982e6778a7c4410", "rs_f_group_membership_valid_to_6eb49ef9d6a65085", '[)') WITH &&); +ALTER TABLE registry_data."rs_e_group_membership_6b97f4204f141f28" ADD CONSTRAINT "rs_c_group_membership_unique_29ca03124bd627de_1e94e199498d973c" UNIQUE ("rs_f_group_membership_person_f16f370962050e27", "rs_f_group_membership_household_9ce011eef65483bd", "rs_f_group_membership_valid_from_9982e6778a7c4410"); +ALTER TABLE registry_data."rs_e_household_45e8576d356a1f75" ADD CONSTRAINT "rs_c_household_unique_35644d63b5c27a2d_29e5106744bb8f85" UNIQUE ("rs_f_household_administrative_area_1946b433a9241a87", "rs_f_household_local_household_number_040305e8ef37727d"); +ALTER TABLE registry_data."rs_e_household_45e8576d356a1f75" ADD CONSTRAINT "rs_c_household_unique_5c243bbc691c96ec_a2783944072ade3e" UNIQUE ("rs_f_household_household_code_44029c0143d71ab3"); +ALTER TABLE registry_data."rs_e_person_a28225974420754a" ADD CONSTRAINT "rs_c_person_unique_36996ff8fe2e1319_14d44b100fef1335" UNIQUE ("rs_f_person_person_code_7514464caf72c5a7"); +ALTER TABLE registry_data."rs_e_group_membership_6b97f4204f141f28" ENABLE ROW LEVEL SECURITY; +ALTER TABLE registry_data."rs_e_group_membership_6b97f4204f141f28" FORCE ROW LEVEL SECURITY; +CREATE POLICY "registry_rls_select_e4c68d24a3cc83c24851f3eb" ON registry_data."rs_e_group_membership_6b97f4204f141f28" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_insert_95851ffed84c5ef8be616113" ON registry_data."rs_e_group_membership_6b97f4204f141f28" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_update_8ebb61cfa44b068b3de15a37" ON registry_data."rs_e_group_membership_6b97f4204f141f28" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_path_rls_select_d40194fdac27176f50e7bab9" ON registry_data."rs_e_group_membership_6b97f4204f141f28" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration')) AND NULLIF(current_setting('registry.read_path_id', true), '') = 'people' AND record_lifecycle = 'active' AND "rs_f_group_membership_household_9ce011eef65483bd" = NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid + AND EXISTS ( + SELECT 1 + FROM registry_data."rs_e_household_45e8576d356a1f75" AS path_source + WHERE path_source.record_id = "rs_f_group_membership_household_9ce011eef65483bd" + AND path_source.record_lifecycle = 'active' + AND (NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) + )); +CREATE VIEW registry_source."group_membership" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS id, "rs_f_group_membership_person_f16f370962050e27" AS "person", "rs_f_group_membership_household_9ce011eef65483bd" AS "household", "rs_f_group_membership_relationship_4da0b16845ccd25c" AS "relationship", "rs_f_group_membership_valid_from_9982e6778a7c4410" AS "valid_from", "rs_f_group_membership_valid_to_6eb49ef9d6a65085" AS "valid_to" + FROM registry_data."rs_e_group_membership_6b97f4204f141f28" + WHERE record_lifecycle = 'active'; +ALTER TABLE registry_data."rs_e_household_45e8576d356a1f75" ENABLE ROW LEVEL SECURITY; +ALTER TABLE registry_data."rs_e_household_45e8576d356a1f75" FORCE ROW LEVEL SECURITY; +CREATE POLICY "registry_rls_select_f2348f1ea686c085c254174f" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_insert_a49ea44589b3bfd7550c1880" ON registry_data."rs_e_household_45e8576d356a1f75" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_update_b573105c01cf4eb57ca69c80" ON registry_data."rs_e_household_45e8576d356a1f75" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_select_bf0afc959f020c9105952b7d" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-viewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-view') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'id' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "id" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::uuid) AND record_lifecycle = 'active'); +CREATE POLICY "registry_path_rls_select_c8cb6ccd10712973c3e46aaf" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND NULLIF(current_setting('registry.read_path_id', true), '') = 'people' AND record_id = NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid AND record_lifecycle = 'active'); +CREATE VIEW registry_source."household" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS id, "rs_f_household_household_code_44029c0143d71ab3" AS "household_code", "rs_f_household_local_household_number_040305e8ef37727d" AS "local_household_number", "rs_f_household_household_name_aeac0ac6071a6b3d" AS "household_name", "rs_f_household_administrative_area_1946b433a9241a87" AS "administrative_area", "rs_f_household_household_type_87fa3a1f7183bbe0" AS "household_type" + FROM registry_data."rs_e_household_45e8576d356a1f75" + WHERE record_lifecycle = 'active'; +CREATE VIEW registry_derived."household__household_demographics" + WITH (security_invoker=true, security_barrier=true) + AS SELECT "id"::uuid AS "id", "head_count"::bigint AS "head_count", "child_count"::bigint AS "child_count", "child_under_5_count"::bigint AS "child_under_5_count", "elderly_count"::bigint AS "elderly_count", "single_headed"::boolean AS "single_headed", "woman_headed"::boolean AS "woman_headed" + FROM (SELECT + h.id AS id, + count(*) FILTER ( + WHERE gm.relationship = 'head' + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + )::bigint AS head_count, + count(*) FILTER ( + WHERE gm.relationship = 'child' + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + )::bigint AS child_count, + count(*) FILTER ( + WHERE gm.relationship = 'child' + AND p.date_of_birth > (registry_context.evaluation_date() - 5 * INTERVAL '1 year')::date + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + )::bigint AS child_under_5_count, + count(*) FILTER ( + WHERE p.date_of_birth <= (registry_context.evaluation_date() - 65 * INTERVAL '1 year')::date + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + )::bigint AS elderly_count, + ( + count(*) FILTER ( + WHERE gm.relationship = 'head' + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + ) = 1 + AND count(*) FILTER ( + WHERE gm.relationship = 'spouse' + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + ) = 0 + ) AS single_headed, + ( + count(*) FILTER ( + WHERE gm.relationship = 'head' + AND p.person_sex = 'female' + AND gm.valid_from <= registry_context.evaluation_date() + AND (gm.valid_to IS NULL OR registry_context.evaluation_date() < gm.valid_to) + ) > 0 + ) AS woman_headed +FROM registry_source.household h +LEFT JOIN registry_source.group_membership gm + ON gm.household = h.id +LEFT JOIN registry_source.person p + ON p.id = gm.person +GROUP BY h.id) AS trusted_derived; +ALTER TABLE registry_data."rs_e_person_a28225974420754a" ENABLE ROW LEVEL SECURITY; +ALTER TABLE registry_data."rs_e_person_a28225974420754a" FORCE ROW LEVEL SECURITY; +CREATE POLICY "registry_rls_select_799283181617a6fa58c49141" ON registry_data."rs_e_person_a28225974420754a" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_insert_4439eb1ffa28a9c2175ef14d" ON registry_data."rs_e_person_a28225974420754a" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_update_1c8bc48adff601b70fae3086" ON registry_data."rs_e_person_a28225974420754a" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_path_rls_select_b32737d82dfcb2dfac05ef5c" ON registry_data."rs_e_person_a28225974420754a" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration')) AND NULLIF(current_setting('registry.read_path_id', true), '') = 'people' AND record_lifecycle = 'active' + AND EXISTS ( + SELECT 1 + FROM registry_data."rs_e_group_membership_6b97f4204f141f28" AS path_edge + JOIN registry_data."rs_e_household_45e8576d356a1f75" AS path_source + ON path_source.record_id = path_edge."rs_f_group_membership_household_9ce011eef65483bd" + WHERE path_edge."rs_f_group_membership_person_f16f370962050e27" = record_id + AND path_edge."rs_f_group_membership_household_9ce011eef65483bd" = NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid + AND path_edge.record_lifecycle = 'active' + AND path_source.record_lifecycle = 'active' + AND (NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) + )); +CREATE VIEW registry_source."person" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS id, "rs_f_person_person_code_7514464caf72c5a7" AS "person_code", "rs_f_person_legal_name_142f648a19dcd2a4" AS "legal_name", "rs_f_person_family_name_1a6b1252713201d7" AS "family_name", "rs_f_person_date_of_birth_d4d8fa151f4a4285" AS "date_of_birth", "rs_f_person_person_sex_01e02174128c75d2" AS "person_sex", "rs_f_person_residency_status_19ed35302430c5ac" AS "residency_status", "rs_f_person_preferred_language_d36dc5f1bd7bec3c" AS "preferred_language" + FROM registry_data."rs_e_person_a28225974420754a" + WHERE record_lifecycle = 'active'; diff --git a/products/registry-server/generated/publicschema-household/generated/schemas/group-membership.schema.json b/products/registry-server/generated/publicschema-household/generated/schemas/group-membership.schema.json new file mode 100644 index 0000000000..bfd3c061cd --- /dev/null +++ b/products/registry-server/generated/publicschema-household/generated/schemas/group-membership.schema.json @@ -0,0 +1 @@ +{"$id":"urn:registry-server:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["household","person","relationship","valid-from"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json b/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json new file mode 100644 index 0000000000..e017b738d6 --- /dev/null +++ b/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json @@ -0,0 +1 @@ +{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrative-area":{"maxLength":80,"minLength":0,"type":"string"},"household-code":{"maxLength":64,"minLength":0,"type":"string"},"household-name":{"maxLength":160,"minLength":0,"type":"string"},"household-type":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"local-household-number":{"format":"int64","type":"integer"}},"required":["administrative-area","household-code","household-name","household-type","local-household-number"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/schemas/person.schema.json b/products/registry-server/generated/publicschema-household/generated/schemas/person.schema.json new file mode 100644 index 0000000000..a9412445ed --- /dev/null +++ b/products/registry-server/generated/publicschema-household/generated/schemas/person.schema.json @@ -0,0 +1 @@ +{"$id":"urn:registry-server:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"date-of-birth":{"format":"date","type":"string"},"family-name":{"maxLength":120,"minLength":0,"type":"string"},"legal-name":{"maxLength":160,"minLength":0,"type":"string"},"person-code":{"maxLength":64,"minLength":0,"type":"string"},"person-sex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferred-language":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residency-status":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["legal-name","person-code","person-sex","residency-status"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/scripts/check-generated.sh b/products/registry-server/scripts/check-generated.sh index 5d09cc5685..88cd1dbe0c 100755 --- a/products/registry-server/scripts/check-generated.sh +++ b/products/registry-server/scripts/check-generated.sh @@ -3,8 +3,7 @@ set -euo pipefail script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) repository_root=$(cd -- "$script_dir/../../.." && pwd) -fixture="$repository_root/products/registry-server/acceptance/asset-site-placement" -baseline="$repository_root/products/registry-server/generated/asset-site-placement" +fixtures=(asset-site-placement publicschema-household) authoring_baseline="$repository_root/products/registry-server/generated/authoring" temporary_root="" @@ -30,8 +29,6 @@ export CARGO_PROFILE_DEV_DEBUG=0 export CARGO_PROFILE_TEST_DEBUG=0 export RUSTC_WRAPPER="${RUSTC_WRAPPER-}" -candidate="$temporary_root/generated" -mkdir "$candidate" authoring_candidate="$temporary_root/authoring" mkdir "$authoring_candidate" ( @@ -39,10 +36,16 @@ mkdir "$authoring_candidate" cargo run --manifest-path "$repository_root/Cargo.toml" --locked --quiet \ -p registry-server --features schema --example authoring-schema -- \ --output "$authoring_candidate" - for selector in openapi schemas manifest metadata sql; do - cargo run --manifest-path "$repository_root/Cargo.toml" --locked -p registry-serverctl -- \ - generate "$selector" "$fixture" --output "./$selector" - cp -R "./$selector/." "$candidate" + for fixture_name in "${fixtures[@]}"; do + candidate="$temporary_root/$fixture_name" + mkdir "$candidate" + fixture="$repository_root/products/registry-server/acceptance/$fixture_name" + for selector in openapi schemas manifest metadata sql; do + selector_candidate="$temporary_root/selector-$fixture_name-$selector" + cargo run --manifest-path "$repository_root/Cargo.toml" --locked -p registry-serverctl -- \ + generate "$selector" "$fixture" --output "$selector_candidate" + cp -R "$selector_candidate/." "$candidate" + done done ) @@ -53,4 +56,8 @@ if ! diff -ru "$authoring_baseline" "$authoring_candidate"; then exit 1 fi -python3 "$script_dir/compare-generated-tree.py" "$baseline" "$candidate" +for fixture_name in "${fixtures[@]}"; do + python3 "$script_dir/compare-generated-tree.py" \ + "$repository_root/products/registry-server/generated/$fixture_name" \ + "$temporary_root/$fixture_name" +done diff --git a/products/registry-server/scripts/check_source_neutrality.py b/products/registry-server/scripts/check_source_neutrality.py index 5b6fcda93c..cb1960986d 100644 --- a/products/registry-server/scripts/check_source_neutrality.py +++ b/products/registry-server/scripts/check_source_neutrality.py @@ -65,8 +65,6 @@ "OfficerAppointment", ) FORBIDDEN_DOMAIN_COMPONENTS = ( - "asset", - "assets", "site", "sites", "placement", diff --git a/products/registry-server/scripts/compare-generated-tree.py b/products/registry-server/scripts/compare-generated-tree.py index 1b9d7df631..9b7fbad64b 100755 --- a/products/registry-server/scripts/compare-generated-tree.py +++ b/products/registry-server/scripts/compare-generated-tree.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Compare the frozen Registry Server asset baseline without traversing links.""" +"""Compare frozen Registry Server generated baselines without traversing links.""" from __future__ import annotations @@ -8,7 +8,7 @@ from pathlib import Path -EXPECTED_PATHS = ( +ASSET_SITE_PLACEMENT_PATHS = ( "generated/manifest/registry-manifest.json", "generated/manifest/dcat.jsonld", "generated/metadata/registry.json", @@ -19,6 +19,21 @@ "generated/schemas/asset-site.schema.json", "generated/schemas/inspection-event.schema.json", ) +PUBLICSCHEMA_HOUSEHOLD_PATHS = ( + "generated/manifest/registry-manifest.json", + "generated/manifest/dcat.jsonld", + "generated/metadata/registry.json", + "generated/openapi.json", + "generated/postgres/schema.sql", + "generated/schemas/group-membership.schema.json", + "generated/schemas/household.schema.json", + "generated/schemas/person.schema.json", +) +EXPECTED_PATHS_BY_BASELINE = { + "asset-site-placement": ASSET_SITE_PLACEMENT_PATHS, + "publicschema-household": PUBLICSCHEMA_HOUSEHOLD_PATHS, +} +EXPECTED_PATHS = ASSET_SITE_PLACEMENT_PATHS def regular_tree(root: Path) -> dict[str, bytes]: @@ -50,7 +65,9 @@ def regular_tree(root: Path) -> dict[str, bytes]: def compare(baseline: Path, candidate: Path) -> list[str]: baseline_tree = regular_tree(baseline) candidate_tree = regular_tree(candidate) - expected = set(EXPECTED_PATHS) + expected = set( + EXPECTED_PATHS_BY_BASELINE.get(baseline.name, ASSET_SITE_PLACEMENT_PATHS) + ) errors: list[str] = [] for label, tree in (("baseline", baseline_tree), ("candidate", candidate_tree)): paths = set(tree) @@ -61,7 +78,7 @@ def compare(baseline: Path, candidate: Path) -> list[str]: errors.append(f"{label} is missing expected artifacts: {', '.join(missing)}") if unexpected: errors.append(f"{label} has unexpected artifacts: {', '.join(unexpected)}") - for path in EXPECTED_PATHS: + for path in expected: if path in baseline_tree and path in candidate_tree and baseline_tree[path] != candidate_tree[path]: errors.append(f"generated bytes differ: {path}") return errors @@ -81,7 +98,7 @@ def main(arguments: list[str]) -> int: for error in errors: print(f"- {error}", file=sys.stderr) return 1 - print("generated tree matches the committed asset baseline") + print(f"generated tree matches the committed {Path(arguments[0]).name} baseline") return 0 diff --git a/products/registry-server/scripts/test_check_source_neutrality.py b/products/registry-server/scripts/test_check_source_neutrality.py index 98347adba1..be502c13ae 100644 --- a/products/registry-server/scripts/test_check_source_neutrality.py +++ b/products/registry-server/scripts/test_check_source_neutrality.py @@ -63,6 +63,17 @@ def test_generic_source_is_allowed(self) -> None: source.write_text("pub struct CompiledRegistry;\n", encoding="utf-8") self.assertEqual([], CHECKER.find_violations(root)) + def test_generic_module_asset_vocabulary_is_allowed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "crates/registry-server/src/lib.rs" + source.parent.mkdir(parents=True) + source.write_text( + 'pub struct ModuleAssetSource;\nconst ERROR: &str = "module.asset.refused";\n', + encoding="utf-8", + ) + self.assertEqual([], CHECKER.find_violations(root)) + def test_fixture_identifier_in_test_code_is_allowed(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/products/registry-server/scripts/test_generated_gates.py b/products/registry-server/scripts/test_generated_gates.py index ece149bcb8..eabb460a75 100755 --- a/products/registry-server/scripts/test_generated_gates.py +++ b/products/registry-server/scripts/test_generated_gates.py @@ -35,6 +35,7 @@ def test_comparator_rejects_a_missing_committed_artifact(self) -> None: def test_generated_gate_script_keeps_a_bounded_database_free_cli_journey(self) -> None: generated_gate = (SCRIPT_DIR / "check-generated.sh").read_text(encoding="utf-8") self.assertIn("mktemp -d", generated_gate) + self.assertIn("publicschema-household", generated_gate) self.assertIn('export RUSTC_WRAPPER="${RUSTC_WRAPPER-}"', generated_gate) self.assertIn("authoring_baseline", generated_gate) self.assertIn("--features schema --example authoring-schema", generated_gate) diff --git a/products/registry-server/scripts/validate_product.py b/products/registry-server/scripts/validate_product.py index 077717cde8..7735139990 100644 --- a/products/registry-server/scripts/validate_product.py +++ b/products/registry-server/scripts/validate_product.py @@ -56,6 +56,7 @@ ("schemas", "entity-json-schemas", True), ("manifest/registry-manifest.json", "lossy-manifest-projection", True), ("manifest/dcat.jsonld", "dcat-catalog-projection", True), + ("source/modules//", "source-module-asset", False), ("tests/journeys.yaml", "fixture-journeys", True), ("signatures", "package-signatures", False), } @@ -514,7 +515,7 @@ def validate_security(waves: set[str], errors: list[str]) -> None: invariants = as_list(matrix.get("invariants"), "security matrix.invariants", errors) rows = {row.get("id"): row for row in invariants if isinstance(row, dict) and isinstance(row.get("id"), str)} unique_ids(invariants, "security matrix.invariants", errors) - if set(rows) != {f"RS-SEC-{index:02d}" for index in range(1, 20)}: + if set(rows) != {f"RS-SEC-{index:02d}" for index in range(1, 22)}: errors.append("security matrix: must contain the complete closed product invariant identifiers") negatives: set[str] = set() for index, raw in enumerate(invariants): From 6a20add5c58892350ed43fa6b866f09c2b95b325 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 30 Aug 2026 19:08:21 +0700 Subject: [PATCH 06/19] fix(server): close registry query gaps Signed-off-by: Jeremi Joslin --- crates/registry-server/Cargo.toml | 5 + crates/registry-server/src/api/mod.rs | 109 ++++----- crates/registry-server/src/api/service.rs | 3 +- crates/registry-server/src/artifacts.rs | 8 + crates/registry-server/src/auth.rs | 22 +- crates/registry-server/src/compiler.rs | 154 ++++++++----- crates/registry-server/src/data.rs | 8 +- crates/registry-server/src/derived_sql.rs | 25 ++- crates/registry-server/src/fixtures.rs | 193 ++++++++++++---- crates/registry-server/src/generated_ddl.rs | 10 +- crates/registry-server/src/migration.rs | 13 +- crates/registry-server/src/migration_plan.rs | 2 +- crates/registry-server/src/model.rs | 2 + crates/registry-server/src/package.rs | 30 ++- .../registry-server/src/postgres/context.rs | 121 +++++++--- .../registry-server/src/postgres/interlock.rs | 81 ++++++- crates/registry-server/src/query.rs | 68 +++--- .../tests/compiler_contract.rs | 25 +++ crates/registry-server/tests/http_auth.rs | 14 ++ .../registry-server/tests/http_read_only.rs | 210 +++++++++++++++++- .../tests/package_change_plan.rs | 2 +- .../tests/pilot_acceptance_fixtures.rs | 93 ++++++-- .../tests/postgres_data_export.rs | 2 +- .../tests/postgres_migration.rs | 86 +++++++ .../registry-server/tests/postgres_package.rs | 43 ++++ .../tests/postgres_pilot_acceptance.rs | 46 +++- .../registry-server/tests/postgres_startup.rs | 26 +++ .../tests/support/pilot_acceptance_harness.rs | 86 +++++-- crates/registry-serverctl/tests/cli.rs | 9 +- .../tests/journeys.yaml | 24 ++ products/registry-server/demo/README.md | 70 +++--- products/registry-server/demo/query.sh | 8 +- products/registry-server/demo/run.sh | 31 ++- products/registry-server/demo/support/demo.py | 183 ++++++++++++--- .../registry-server/demo/support/test_demo.py | 165 +++++++++++++- .../generated/metadata/registry.json | 2 +- .../generated/metadata/registry.json | 2 +- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 46 ++-- .../generated/schemas/household.schema.json | 2 +- 40 files changed, 1633 insertions(+), 398 deletions(-) diff --git a/crates/registry-server/Cargo.toml b/crates/registry-server/Cargo.toml index d967036bec..6a9ceba508 100644 --- a/crates/registry-server/Cargo.toml +++ b/crates/registry-server/Cargo.toml @@ -99,6 +99,11 @@ name = "fixture_tooling" path = "tests/fixture_tooling.rs" required-features = ["runtime", "tooling"] +[[test]] +name = "pilot_acceptance_fixtures" +path = "tests/pilot_acceptance_fixtures.rs" +required-features = ["runtime", "tooling"] + [[test]] name = "postgres_fixture_journeys" path = "tests/postgres_fixture_journeys.rs" diff --git a/crates/registry-server/src/api/mod.rs b/crates/registry-server/src/api/mod.rs index c640156305..be0d900ad2 100644 --- a/crates/registry-server/src/api/mod.rs +++ b/crates/registry-server/src/api/mod.rs @@ -3,9 +3,6 @@ mod context; mod service; -#[allow(dead_code)] -#[path = "../query.rs"] -mod strict_query; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; @@ -52,6 +49,7 @@ use crate::model::{ CompiledRoute, MAX_REVISION_HISTORY_RECORDS, }; use crate::mutation::{parse_json_patch_document, BatchMutationItem, MutationError}; +use crate::query as strict_query; use uuid::Uuid; const MAX_MUTATION_BODY_BYTES: usize = 2 * 1024 * 1024; @@ -279,10 +277,7 @@ async fn openapi( readable_by_entity .entry(surface.response_entity.id.clone()) .and_modify(|fields| { - *fields = fields - .intersection(&surface.readable_fields) - .cloned() - .collect(); + fields.extend(surface.readable_fields.iter().cloned()); }) .or_insert_with(|| surface.readable_fields.clone()); } @@ -319,25 +314,27 @@ async fn registry_metadata( } let mut entities: BTreeMap = BTreeMap::new(); - for (metadata_entity, entry) in visible { + for (_, entry) in visible { + let Some(response_entity) = service.registry.entities().get(&entry.response_entity_id) + else { + return concealed(); + }; entities - .entry(metadata_entity.id.clone()) + .entry(response_entity.id.clone()) .and_modify(|metadata| { metadata .operations .insert(entry.operation, entry.access_profile.clone()); - metadata.readable_fields = metadata + metadata .readable_fields - .intersection(&entry.readable_fields) - .cloned() - .collect(); + .extend(entry.readable_fields.iter().cloned()); }) .or_insert_with(|| MetadataEntity { - id: metadata_entity.id.clone(), - route: metadata_entity.route.clone(), + id: response_entity.id.clone(), + route: response_entity.route.clone(), operations: BTreeMap::from([(entry.operation, entry.access_profile.clone())]), readable_fields: entry.readable_fields.clone(), - schema_path: metadata_entity.schema_path.clone(), + schema_path: format!("/v1/schemas/{}", response_entity.id), }); } let entities = entities @@ -378,9 +375,7 @@ async fn entity_schema( .unwrap_or_else(VerifiedRequestClaims::anonymous); let surfaces = visible_surfaces(&service, &claims, &options) .into_iter() - .filter(|surface| { - surface.response_entity.id == entity_id || surface.route.entity_id == entity_id - }) + .filter(|surface| surface.response_entity.id == entity_id) .collect::>(); let Some(first) = surfaces.first() else { return concealed(); @@ -389,14 +384,11 @@ async fn entity_schema( surfaces .iter() .skip(1) - .fold(first.readable_fields.clone(), |fields, surface| { + .fold(first.readable_fields.clone(), |mut fields, surface| { + fields.extend(surface.readable_fields.iter().cloned()); fields - .intersection(&surface.readable_fields) - .cloned() - .collect() }); - let schema_entity = first.response_entity.id.clone(); - match filtered_schema(&service, &schema_entity, &readable) { + match filtered_schema(&service, &entity_id, &readable) { Some(schema) => Json(schema).into_response(), None => concealed(), } @@ -1498,6 +1490,8 @@ fn metadata_entry_for_surface<'a>( entry.route_id == surface.route.id && entry.operation == surface.route.operation && entry.access_profile == surface.context.selected_profile() + && entry.response_entity_id == surface.response_entity.id + && entry.readable_fields == surface.readable_fields })?; Some((entity, entry)) } @@ -2111,6 +2105,12 @@ fn resolve_data_field_id<'a>(entity: &'a CompiledEntity, api_name: &str) -> Opti .get_key_value(api_name) .map(|(field_id, _)| field_id.as_str()) }) + .or_else(|| { + entity + .derived_fields + .get_key_value(api_name) + .map(|(field_id, _)| field_id.as_str()) + }) } fn projection_plan( @@ -2120,15 +2120,28 @@ fn projection_plan( selected_fields .iter() .map(|field_id| { - let field = entity.fields.get(field_id).ok_or(ReadQueryError::Invalid)?; + let field_type = data_field_type(entity, field_id).ok_or(ReadQueryError::Invalid)?; Ok(ReadProjectionField { field_id: field_id.clone(), - field_type: field.field_type.clone(), + field_type: field_type.clone(), }) }) .collect() } +fn data_field_type<'a>(entity: &'a CompiledEntity, field_id: &str) -> Option<&'a FieldTypeSource> { + entity + .fields + .get(field_id) + .map(|field| &field.field_type) + .or_else(|| { + entity + .derived_fields + .get(field_id) + .map(|field| &field.logical.field_type) + }) +} + fn first_page_filter_expr( entity: &CompiledEntity, operation: &CompiledQueryOperation, @@ -2215,7 +2228,7 @@ fn read_filter_predicate( } }; let field_id = resolve_data_field_id(entity, api_field).ok_or(ReadQueryError::Invalid)?; - let field = entity.fields.get(field_id).ok_or(ReadQueryError::Invalid)?; + let field_type = data_field_type(entity, field_id).ok_or(ReadQueryError::Invalid)?; let capability = operation .filter_fields .iter() @@ -2235,7 +2248,7 @@ fn read_filter_predicate( } else { literals .into_iter() - .map(|literal| literal_to_field_value(literal, &field.field_type)) + .map(|literal| literal_to_field_value(literal, field_type)) .collect::, _>>()? }; if operator == ReadFilterOperator::In { @@ -2247,7 +2260,7 @@ fn read_filter_predicate( } Ok(ReadFilterPredicate { field_id: field_id.to_owned(), - field_type: field.field_type.clone(), + field_type: field_type.clone(), operator, values, }) @@ -2287,7 +2300,7 @@ fn read_order_clause( return Ok(None); }; let field_id = resolve_data_field_id(entity, api_or_field).ok_or(ReadQueryError::Invalid)?; - let field = entity.fields.get(field_id).ok_or(ReadQueryError::Invalid)?; + let field_type = data_field_type(entity, field_id).ok_or(ReadQueryError::Invalid)?; let sortable = operation.sort_fields.iter().any(|candidate| { candidate.field == field_id && candidate @@ -2299,7 +2312,7 @@ fn read_order_clause( } Ok(Some(ReadOrderClause { field_id: field_id.to_owned(), - field_type: field.field_type.clone(), + field_type: field_type.clone(), direction: CompiledQuerySortDirection::Asc, })) } @@ -2329,11 +2342,7 @@ fn validate_query_shape( if !sortable || operation.stable_tie_breaker != "record_id" || order.direction != CompiledQuerySortDirection::Asc - || entity - .fields - .get(&order.field_id) - .map(|field| &field.field_type) - != Some(&order.field_type) + || data_field_type(entity, &order.field_id) != Some(&order.field_type) { return Err(ReadQueryError::Invalid); } @@ -2366,16 +2375,14 @@ fn validate_filter_shape( .predicates .checked_add(1) .ok_or(ReadQueryError::Invalid)?; - let field = entity - .fields - .get(&predicate.field_id) - .ok_or(ReadQueryError::Invalid)?; + let field_type = + data_field_type(entity, &predicate.field_id).ok_or(ReadQueryError::Invalid)?; let capability = operation .filter_fields .iter() .find(|field| field.field == predicate.field_id) .ok_or(ReadQueryError::Invalid)?; - if field.field_type != predicate.field_type + if field_type != &predicate.field_type || !capability .operators .contains(&predicate.operator.compiled_capability()) @@ -2394,7 +2401,7 @@ fn validate_filter_shape( if predicate.values.len() != 1 { return Err(ReadQueryError::Invalid); } - crate::postgres::validate_field_value(&predicate.values[0], &field.field_type) + crate::postgres::validate_field_value(&predicate.values[0], field_type) .map_err(|_| ReadQueryError::Invalid)?; } ReadFilterOperator::In => { @@ -2411,7 +2418,7 @@ fn validate_filter_shape( .checked_add(predicate.values.len()) .ok_or(ReadQueryError::Invalid)?; for value in &predicate.values { - crate::postgres::validate_field_value(value, &field.field_type) + crate::postgres::validate_field_value(value, field_type) .map_err(|_| ReadQueryError::Invalid)?; } } @@ -2740,10 +2747,8 @@ fn read_order_clause_from_cursor( operation: &CompiledQueryOperation, order: &CursorOrderClause, ) -> Result { - let field = entity - .fields - .get(&order.field_id) - .ok_or(ReadQueryError::CursorInvalid)?; + let field_type = + data_field_type(entity, &order.field_id).ok_or(ReadQueryError::CursorInvalid)?; let sortable = operation.sort_fields.iter().any(|candidate| { candidate.field == order.field_id && candidate @@ -2751,7 +2756,7 @@ fn read_order_clause_from_cursor( .contains(&CompiledQuerySortDirection::Asc) }); if !sortable - || field.field_type != order.field_type + || field_type != &order.field_type || order.direction != CompiledQuerySortDirection::Asc { return Err(ReadQueryError::CursorInvalid); @@ -2783,11 +2788,9 @@ fn read_filter_expr_from_cursor( read_filter_expr_from_cursor(entity, expr)?, ))), CursorFilterExpr::Predicate { predicate } => { - let field = entity - .fields - .get(&predicate.field_id) + let field_type = data_field_type(entity, &predicate.field_id) .ok_or(ReadQueryError::CursorInvalid)?; - if field.field_type != predicate.field_type { + if field_type != &predicate.field_type { return Err(ReadQueryError::CursorInvalid); } Ok(ReadFilterExpr::Predicate(ReadFilterPredicate { diff --git a/crates/registry-server/src/api/service.rs b/crates/registry-server/src/api/service.rs index c1a7c054ab..d07dede68b 100644 --- a/crates/registry-server/src/api/service.rs +++ b/crates/registry-server/src/api/service.rs @@ -349,7 +349,8 @@ impl ReadFilterOperator { Self::In => CompiledQueryFilterOperator::In, Self::IsNull => CompiledQueryFilterOperator::IsNull, Self::IsNotNull => CompiledQueryFilterOperator::IsNotNull, - Self::StartsWith | Self::Contains => CompiledQueryFilterOperator::Prefix, + Self::StartsWith => CompiledQueryFilterOperator::Prefix, + Self::Contains => CompiledQueryFilterOperator::Contains, } } } diff --git a/crates/registry-server/src/artifacts.rs b/crates/registry-server/src/artifacts.rs index 9a56b1d434..6f188413a7 100644 --- a/crates/registry-server/src/artifacts.rs +++ b/crates/registry-server/src/artifacts.rs @@ -162,6 +162,14 @@ fn entity_schema(entity: &CompiledEntity) -> Value { required.push(Value::String(field.id.clone())); } } + for field in entity.derived_fields.values() { + let mut schema = field_schema(&field.logical.field_type); + schema + .as_object_mut() + .expect("field schemas are objects") + .insert("readOnly".to_owned(), Value::Bool(true)); + properties.insert(field.logical.id.clone(), schema); + } json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": format!("urn:registry-server:entity:{}", entity.id), diff --git a/crates/registry-server/src/auth.rs b/crates/registry-server/src/auth.rs index 949768dbf8..65580106df 100644 --- a/crates/registry-server/src/auth.rs +++ b/crates/registry-server/src/auth.rs @@ -318,12 +318,8 @@ fn validate_claim_mapping( } purpose_required |= !profile.required_purposes.is_empty(); for boundary in &profile.row_boundaries { - let field_type = entity - .fields - .get(&boundary.field) - .ok_or(AuthenticationConfigError::CompiledAuthorityMismatch)? - .field_type - .clone(); + let field_type = compiled_authority_field_type(entity, &boundary.field) + .ok_or(AuthenticationConfigError::CompiledAuthorityMismatch)?; let expectation = DirectClaimExpectation { field_type, multi_value: boundary.operator == BoundaryOperator::In, @@ -380,6 +376,20 @@ fn validate_claim_mapping( Ok(expected_direct_claims) } +fn compiled_authority_field_type( + entity: &crate::model::CompiledEntity, + field_id: &str, +) -> Option { + if field_id == entity.canonical_id.id { + Some(entity.canonical_id.field_type.clone()) + } else { + entity + .fields + .get(field_id) + .map(|field| field.field_type.clone()) + } +} + fn insert_direct_claim_expectation( claims: &mut BTreeMap, name: &str, diff --git a/crates/registry-server/src/compiler.rs b/crates/registry-server/src/compiler.rs index 63319a4161..9506bb2781 100644 --- a/crates/registry-server/src/compiler.rs +++ b/crates/registry-server/src/compiler.rs @@ -769,15 +769,14 @@ pub fn module_digest_with_assets(module: &RegistryModule, assets: &[ModuleAssetS format!("sha256:{}", hex_prefix(&digest, digest.len())) } +type DerivedOriginMap = BTreeMap<(String, String), Option>; + fn collect_entities( project: &RegistryProject, module_order: &[String], modules: &BTreeMap, errors: &mut Vec, -) -> ( - BTreeMap, - BTreeMap<(String, String), Option>, -) { +) -> (BTreeMap, DerivedOriginMap) { let mut entities = BTreeMap::new(); let mut derived_origins = BTreeMap::new(); for entity in &project.entities { @@ -2019,7 +2018,7 @@ fn validate_read_path_cycles( }) .collect::>(); for (source, target) in &edges { - if reaches(*target, *source, &edges, &mut BTreeSet::new()) { + if reaches(target, source, &edges, &mut BTreeSet::new()) { errors.push(Diagnostic::error( "read_path.cycle", "entities[].readPaths[]", @@ -3249,18 +3248,9 @@ fn compile_metadata_inventory( let Some(profile) = entity.access_profiles.get(profile_id) else { return Err(inconsistent_metadata_inventory()); }; - let readable_fields = profile - .readable_fields - .iter() - .filter(|field| { - !profile.anonymous - || entity - .fields - .get(*field) - .is_some_and(|field| field.classification == Classification::Public) - }) - .cloned() - .collect(); + let (response_entity, readable_fields) = + metadata_response_surface(entity, profile, route, entities) + .ok_or_else(inconsistent_metadata_inventory)?; entries_by_entity .entry(entity.id.clone()) .or_default() @@ -3268,6 +3258,7 @@ fn compile_metadata_inventory( route_id: route.id.clone(), operation: route.operation, access_profile: profile_id.clone(), + response_entity_id: response_entity.id.clone(), readable_fields, }); } @@ -3298,6 +3289,40 @@ fn compile_metadata_inventory( }) } +fn metadata_response_surface<'a>( + entity: &'a CompiledEntity, + profile: &AccessProfileSource, + route: &CompiledRoute, + entities: &'a BTreeMap, +) -> Option<(&'a CompiledEntity, BTreeSet)> { + let read_path = entity.read_paths.values().find(|path| { + route.id == format!("records.{}.path.{}", entity.id, path.id) + && route.path == format!("/v1/records/{}/{{record_id}}/{}", entity.route, path.route) + }); + let (response_entity, configured_fields) = match read_path { + Some(path) => { + let grant = profile + .read_paths + .iter() + .find(|grant| grant.path == path.id)?; + (entities.get(&path.to)?, &grant.readable_fields) + } + None => (entity, &profile.readable_fields), + }; + let readable_fields = configured_fields + .iter() + .filter(|field| { + !profile.anonymous + || response_entity + .fields + .get(*field) + .is_some_and(|field| field.classification == Classification::Public) + }) + .cloned() + .collect(); + Some((response_entity, readable_fields)) +} + fn inconsistent_metadata_inventory() -> Diagnostic { Diagnostic::error( "metadata_inventory.inconsistent", @@ -3331,14 +3356,16 @@ fn compile_query_inventory( for profile in entity.access_profiles.values() { if profile.operations.contains(&Operation::List) { if let Some(operation) = query_operation( - entity, - profile, - &route_ids[&(entity.id.clone(), CompiledQueryKind::List)], - CompiledQueryKind::List, - None, - profile.allow_count, - Vec::new(), - None, + QueryOperationInput { + entity, + profile, + route_id: &route_ids[&(entity.id.clone(), CompiledQueryKind::List)], + kind: CompiledQueryKind::List, + temporal: None, + allow_count: profile.allow_count, + selector_fields: Vec::new(), + read_path: None, + }, errors, ) { operations.push(operation); @@ -3346,27 +3373,31 @@ fn compile_query_inventory( if let Some(temporal) = &entity.temporal { let binding = temporal_binding(temporal); if let Some(operation) = query_operation( - entity, - profile, - &route_ids[&(entity.id.clone(), CompiledQueryKind::Current)], - CompiledQueryKind::Current, - Some(binding.clone()), - profile.allow_count, - Vec::new(), - None, + QueryOperationInput { + entity, + profile, + route_id: &route_ids[&(entity.id.clone(), CompiledQueryKind::Current)], + kind: CompiledQueryKind::Current, + temporal: Some(binding.clone()), + allow_count: profile.allow_count, + selector_fields: Vec::new(), + read_path: None, + }, errors, ) { operations.push(operation); } if let Some(operation) = query_operation( - entity, - profile, - &route_ids[&(entity.id.clone(), CompiledQueryKind::AsOf)], - CompiledQueryKind::AsOf, - Some(binding), - profile.allow_count, - Vec::new(), - None, + QueryOperationInput { + entity, + profile, + route_id: &route_ids[&(entity.id.clone(), CompiledQueryKind::AsOf)], + kind: CompiledQueryKind::AsOf, + temporal: Some(binding), + allow_count: profile.allow_count, + selector_fields: Vec::new(), + read_path: None, + }, errors, ) { operations.push(operation); @@ -3378,14 +3409,16 @@ fn compile_query_inventory( if let Some(selector) = entity.selector_profiles.get(&lookup.selector) { let route_id = format!("records.{}.lookup", entity.id); if let Some(operation) = query_operation( - entity, - profile, - &route_id, - CompiledQueryKind::List, - None, - false, - selector.fields.clone(), - None, + QueryOperationInput { + entity, + profile, + route_id: &route_id, + kind: CompiledQueryKind::List, + temporal: None, + allow_count: false, + selector_fields: selector.fields.clone(), + read_path: None, + }, errors, ) { operations.push(operation); @@ -3466,17 +3499,31 @@ fn read_path_query_operation( }) } -fn query_operation( - entity: &CompiledEntity, - profile: &AccessProfileSource, - route_id: &str, +struct QueryOperationInput<'a> { + entity: &'a CompiledEntity, + profile: &'a AccessProfileSource, + route_id: &'a str, kind: CompiledQueryKind, temporal: Option, allow_count: bool, selector_fields: Vec, read_path: Option, +} + +fn query_operation( + input: QueryOperationInput<'_>, errors: &mut Vec, ) -> Option { + let QueryOperationInput { + entity, + profile, + route_id, + kind, + temporal, + allow_count, + selector_fields, + read_path, + } = input; if let Some(binding) = &temporal { let temporal_fields = [&binding.start_field, &binding.end_field]; if temporal_fields @@ -3586,6 +3633,7 @@ fn query_filter_field( | FieldTypeSource::Text { .. } | FieldTypeSource::VocabularyCode { .. } => { operators.push(CompiledQueryFilterOperator::Prefix); + operators.push(CompiledQueryFilterOperator::Contains); } FieldTypeSource::Int64 | FieldTypeSource::Decimal { .. } diff --git a/crates/registry-server/src/data.rs b/crates/registry-server/src/data.rs index 3407fb4bd1..a66d63bda9 100644 --- a/crates/registry-server/src/data.rs +++ b/crates/registry-server/src/data.rs @@ -1695,7 +1695,7 @@ fn validate_export_response( let object = value.as_object().ok_or(DataError::InvalidResponse)?; if !(object.len() == 2 || object.len() == 3) || !object.contains_key("items") - || !object.contains_key("nextCursor") + || !object.contains_key("pageInfo") || (object.len() == 3 && !object.contains_key("count")) { return Err(DataError::InvalidResponse); @@ -1713,7 +1713,11 @@ fn validate_export_response( }) { return Err(DataError::InvalidResponse); } - let next_cursor = match &object["nextCursor"] { + let page_info = object["pageInfo"] + .as_object() + .ok_or(DataError::InvalidResponse)?; + require_exact_keys(page_info, &["nextCursor"]).map_err(|_| DataError::InvalidResponse)?; + let next_cursor = match &page_info["nextCursor"] { Value::Null => None, Value::String(value) if !invalid_cursor(Some(value)) => Some(value.clone()), _ => return Err(DataError::InvalidResponse), diff --git a/crates/registry-server/src/derived_sql.rs b/crates/registry-server/src/derived_sql.rs index a314c7481d..6bd3c49490 100644 --- a/crates/registry-server/src/derived_sql.rs +++ b/crates/registry-server/src/derived_sql.rs @@ -106,13 +106,30 @@ fn no_wildcard(node: &PgNodeWrapper) -> bool { } fn valid_ast(parsed: &pg_query::ParseResult, known_relations: &BTreeSet<&str>) -> bool { + let mut cte_names = BTreeSet::new(); + for (node, _, _, _) in parsed.protobuf.nodes() { + if let NodeRef::CommonTableExpr(cte) = node { + if cte.ctename.is_empty() + || cte.cterecursive + || cte.search_clause.is_some() + || cte.cycle_clause.is_some() + || !cte_names.insert(cte.ctename.as_str()) + { + return false; + } + } + } let mut statement_nodes = 0_usize; for (node, _, _, _) in parsed.protobuf.nodes() { match node { NodeRef::RangeVar(range) => { - if !range.catalogname.is_empty() - || range.schemaname != "registry_source" - || !known_relations.contains(range.relname.as_str()) + let source_relation = range.catalogname.is_empty() + && range.schemaname == "registry_source" + && known_relations.contains(range.relname.as_str()); + let cte_relation = range.catalogname.is_empty() + && range.schemaname.is_empty() + && cte_names.contains(range.relname.as_str()); + if (!source_relation && !cte_relation) || (!range.relpersistence.is_empty() && range.relpersistence != "p") { return false; @@ -129,7 +146,7 @@ fn valid_ast(parsed: &pg_query::ParseResult, known_relations: &BTreeSet<&str>) - } fn unsafe_schema_operator(expression: &AExpr) -> bool { - node_strings(&expression.name).map_or(true, |names| { + node_strings(&expression.name).is_none_or(|names| { names.len() != 1 || !matches!( names[0].as_str(), diff --git a/crates/registry-server/src/fixtures.rs b/crates/registry-server/src/fixtures.rs index 6a99875fa8..a2b88db50c 100644 --- a/crates/registry-server/src/fixtures.rs +++ b/crates/registry-server/src/fixtures.rs @@ -29,7 +29,7 @@ use crate::api::{VerifiedClaimValue, VerifiedRequestClaims}; use crate::auth::RegistryAuthenticator; use crate::compiler::{compile_project_with_assets, module_digest_with_assets, CompileProfile}; use crate::contract::{parse_module_yaml, parse_project_yaml, ModuleAssetSource}; -use crate::contract::{AccessProfileSource, Operation}; +use crate::contract::{AccessProfileSource, LookupValueOrigin, Operation}; use crate::derived_sql::MAX_DERIVED_SQL_BYTES; use crate::model::CompiledRoute; use crate::model::{CompiledRegistry, HttpMethod}; @@ -60,7 +60,7 @@ const MAX_SOURCE_BYTES: usize = 1024 * 1024; const MAX_IDENTIFIER_BYTES: usize = 64; const MAX_BINDING_BYTES: usize = 256; const MAX_BEARER_TOKEN_BYTES: usize = 32 * 1024; -const MIN_SUPPORTED_POSTGRES_MAJOR: u16 = 13; +const MIN_SUPPORTED_POSTGRES_MAJOR: u16 = 15; const MAX_SUPPORTED_POSTGRES_MAJOR: u16 = 18; type CredentialMap = BTreeMap<(String, String), Option>>; @@ -161,10 +161,12 @@ enum ActionSource { }, Lookup { selector: String, - value: Value, + #[serde(default)] + values: Map, }, ReadPath { path: String, + record_ref: String, #[serde(default)] select: BTreeSet, #[serde(default)] @@ -193,6 +195,19 @@ impl ActionSource { Self::Batch { .. } => Operation::Batch, } } + + fn route_id(&self, entity_id: &str) -> String { + let suffix = match self { + Self::Create { .. } => "create".to_owned(), + Self::Get { .. } => "get".to_owned(), + Self::List | Self::Query { .. } => "list".to_owned(), + Self::Lookup { .. } => "lookup".to_owned(), + Self::ReadPath { path, .. } => format!("path.{path}"), + Self::Patch { .. } => "patch".to_owned(), + Self::Batch { .. } => "batch".to_owned(), + }; + format!("records.{entity_id}.{suffix}") + } } #[derive(Clone, Deserialize)] @@ -296,6 +311,7 @@ struct ValidatedStep { claims: ClaimsSource, route: CompiledRoute, profile: AccessProfileSource, + response_readable_fields: BTreeSet, action: ActionSource, expect: ExpectationSource, capture: Option, @@ -371,15 +387,19 @@ pub fn validate_fixture_journeys( .get(&step.access_profile) .ok_or(FixtureError::LogicalReferenceRefused)?; let operation = step.request.operation(); - if !profile.operations.contains(&operation) { + if !matches!(step.request, ActionSource::ReadPath { .. }) + && !profile.operations.contains(&operation) + { return Err(FixtureError::LogicalReferenceRefused); } + let expected_route_id = step.request.route_id(&step.entity); let route = registry .routes() .routes .iter() .find(|route| { route.entity_id == step.entity + && route.id == expected_route_id && route.operation == operation && route.method == operation_method(operation) && route.access_profiles.contains(&step.access_profile) @@ -387,8 +407,17 @@ pub fn validate_fixture_journeys( .cloned() .ok_or(FixtureError::LogicalReferenceRefused)?; validate_claims(&step.claims, profile, step.expect.outcome)?; - validate_action_fields(&step.request, entity, profile)?; + validate_action_fields(&step.request, registry, entity, profile)?; validate_expectation(&step.expect, operation, profile, capture.is_some())?; + let response_readable_fields = match &step.request { + ActionSource::ReadPath { path, .. } => profile + .read_paths + .iter() + .find(|grant| grant.path == *path) + .map(|grant| grant.readable_fields.clone()) + .ok_or(FixtureError::LogicalReferenceRefused)?, + _ => profile.readable_fields.clone(), + }; steps.push(ValidatedStep { id: step.id, entity: step.entity, @@ -396,6 +425,7 @@ pub fn validate_fixture_journeys( claims: step.claims, route, profile: profile.clone(), + response_readable_fields, action: step.request, expect: step.expect, capture, @@ -420,6 +450,7 @@ fn validate_action_references( ) -> Result<(), FixtureError> { let references: &[&str] = match action { ActionSource::Get { record_ref } => &[record_ref], + ActionSource::ReadPath { record_ref, .. } => &[record_ref], ActionSource::Patch { record_ref, etag_ref, @@ -429,7 +460,6 @@ fn validate_action_references( | ActionSource::List | ActionSource::Query { .. } | ActionSource::Lookup { .. } - | ActionSource::ReadPath { .. } | ActionSource::Batch { .. } => &[], }; if references @@ -497,6 +527,7 @@ fn validate_claims( fn validate_action_fields( action: &ActionSource, + registry: &CompiledRegistry, entity: &crate::model::CompiledEntity, profile: &AccessProfileSource, ) -> Result<(), FixtureError> { @@ -515,17 +546,28 @@ fn validate_action_fields( ActionSource::Create { data } => validate_data(data), ActionSource::Get { .. } | ActionSource::List => Ok(()), ActionSource::Query { select, top, count } => { - validate_structured_query(entity, profile, None, select, *top, *count) + validate_structured_query(registry, entity, profile, None, select, *top, *count) } - ActionSource::Lookup { selector, value } => { + ActionSource::Lookup { selector, values } => { + let selector_profile = entity + .selector_profiles + .get(selector) + .ok_or(FixtureError::LogicalReferenceRefused)?; + let lookup = profile + .lookups + .iter() + .find(|lookup| lookup.selector == *selector) + .ok_or(FixtureError::LogicalReferenceRefused)?; + let exact_fields = selector_profile.fields.iter().collect::>(); + let supplied_fields = values.keys().collect::>(); + let origin_shape_is_valid = match lookup.value_origin { + LookupValueOrigin::Request => supplied_fields == exact_fields, + LookupValueOrigin::VerifiedClaim => values.is_empty(), + }; if selector.is_empty() || selector.len() > MAX_IDENTIFIER_BYTES - || !entity.selector_profiles.contains_key(selector) - || !profile - .lookups - .iter() - .any(|lookup| lookup.selector == *selector) - || canonical_size(value)? > MAX_BINDING_BYTES + || !origin_shape_is_valid + || canonical_size(&Value::Object(values.clone()))? > MAX_BODY_BYTES { return Err(FixtureError::LogicalReferenceRefused); } @@ -536,7 +578,8 @@ fn validate_action_fields( select, top, count, - } => validate_structured_query(entity, profile, Some(path), select, *top, *count), + .. + } => validate_structured_query(registry, entity, profile, Some(path), select, *top, *count), ActionSource::Patch { changes, .. } => { if changes.is_empty() || changes.len() > entity.fields.len() { return Err(FixtureError::JourneyBoundsRefused); @@ -595,6 +638,7 @@ fn validate_action_fields( } fn validate_structured_query( + registry: &CompiledRegistry, entity: &crate::model::CompiledEntity, profile: &AccessProfileSource, read_path: Option<&str>, @@ -602,30 +646,50 @@ fn validate_structured_query( top: Option, count: bool, ) -> Result<(), FixtureError> { - if top.is_some_and(|top| top == 0 || top > 100) || (count && !profile.allow_count) { + if top.is_some_and(|top| top == 0 || top > 100) { return Err(FixtureError::JourneyBoundsRefused); } - if select - .iter() - .any(|field| !profile.readable_fields.contains(field) || !entity.fields.contains_key(field)) - { - return Err(FixtureError::LogicalReferenceRefused); - } if let Some(path) = read_path { + let compiled_path = entity + .read_paths + .get(path) + .ok_or(FixtureError::LogicalReferenceRefused)?; + let grant = profile + .read_paths + .iter() + .find(|grant| grant.path == path) + .ok_or(FixtureError::LogicalReferenceRefused)?; + let target = registry + .entities() + .get(&compiled_path.to) + .ok_or(FixtureError::LogicalReferenceRefused)?; if path.is_empty() || path.len() > MAX_IDENTIFIER_BYTES - || !entity.read_paths.contains_key(path) - || !profile - .read_paths + || (count && !grant.allow_count) + || !select.is_subset(&grant.readable_fields) + || select .iter() - .any(|grant| grant.path == path && select.is_subset(&grant.readable_fields)) + .any(|field| !compiled_field_exists(target, field)) { return Err(FixtureError::LogicalReferenceRefused); } + } else if (count && !profile.allow_count) + || select.iter().any(|field| { + field != "id" + && field != "revision" + && (!profile.readable_fields.contains(field) + || !compiled_field_exists(entity, field)) + }) + { + return Err(FixtureError::LogicalReferenceRefused); } Ok(()) } +fn compiled_field_exists(entity: &crate::model::CompiledEntity, field: &str) -> bool { + entity.fields.contains_key(field) || entity.derived_fields.contains_key(field) +} + fn validate_expectation( expectation: &ExpectationSource, operation: Operation, @@ -1411,13 +1475,27 @@ fn fixture_request( ActionSource::Query { select, top, count } => { extra_query_options = fixture_query_options(step, None, select, *top, *count)?; } - ActionSource::Lookup { .. } => return Err(FixtureError::ExecutionRefused), + ActionSource::Lookup { selector, values } => { + method = Method::POST; + let document = if values.is_empty() { + json!({"selector": selector}) + } else { + json!({"selector": selector, "values": values}) + }; + body = json_body(&document)?; + content_type = Some("application/json"); + } ActionSource::ReadPath { path: read_path, + record_ref, select, top, count, } => { + let observed = observations + .get(record_ref) + .ok_or(FixtureError::RequestConstructionRefused)?; + path = path.replace("{record_id}", &observed.record_id); extra_query_options = fixture_query_options(step, Some(read_path), select, *top, *count)?; } @@ -1509,7 +1587,7 @@ fn fixture_request( fn fixture_query_options( step: &ValidatedStep, - read_path: Option<&str>, + _read_path: Option<&str>, select: &BTreeSet, top: Option, count: bool, @@ -1527,9 +1605,6 @@ fn fixture_query_options( if count { parameters.push(("$count", "true".to_owned())); } - if let Some(read_path) = read_path { - parameters.push(("readPath", read_path.to_owned())); - } if step.access_profile.is_empty() { return Err(FixtureError::RequestConstructionRefused); } @@ -1652,22 +1727,44 @@ fn assert_response( } ExpectedOutcome::Success => match step.action { ActionSource::List | ActionSource::Query { .. } | ActionSource::ReadPath { .. } => { - let object = exact_object(document, &["items", "nextCursor"])?; + let include_count = matches!( + step.action, + ActionSource::Query { count: true, .. } + | ActionSource::ReadPath { count: true, .. } + ); + let expected_keys: &[&str] = if include_count { + &["items", "pageInfo", "count"] + } else { + &["items", "pageInfo"] + }; + let object = exact_object(document, expected_keys)?; let items = object .get("items") .and_then(Value::as_array) .ok_or(FixtureError::ResponseShapeRefused)?; - if !object.get("nextCursor").is_some_and(|cursor| { - cursor.is_null() - || cursor.as_str().is_some_and(|value| { - !value.is_empty() && value.len() <= MAX_BINDING_BYTES - }) - }) || Some(items.len()) != step.expect.count + let page_info = object + .get("pageInfo") + .and_then(Value::as_object) + .ok_or(FixtureError::ResponseShapeRefused)?; + if page_info.len() != 1 + || !page_info.get("nextCursor").is_some_and(|cursor| { + cursor.is_null() + || cursor.as_str().is_some_and(|value| { + !value.is_empty() && value.len() <= MAX_BINDING_BYTES + }) + }) + || Some(items.len()) != step.expect.count + || (include_count + && object.get("count").and_then(Value::as_u64) + != step + .expect + .count + .and_then(|count| u64::try_from(count).ok())) { return Err(FixtureError::ExpectationMismatch); } for item in items { - assert_record_shape(item, &step.profile.readable_fields, &Map::new())?; + assert_record_shape(item, &step.response_readable_fields, &Map::new())?; } } ActionSource::Batch { .. } => { @@ -1694,14 +1791,18 @@ fn assert_response( { return Err(FixtureError::ResponseShapeRefused); } - assert_record_members(object, &step.profile.readable_fields, &Map::new())?; + assert_record_members(object, &step.response_readable_fields, &Map::new())?; } } ActionSource::Create { .. } | ActionSource::Get { .. } | ActionSource::Lookup { .. } | ActionSource::Patch { .. } => { - assert_record_shape(document, &step.profile.readable_fields, &step.expect.fields)?; + assert_record_shape( + document, + &step.response_readable_fields, + &step.expect.fields, + )?; } }, } @@ -1766,7 +1867,7 @@ fn assert_record_members( fn problem_contract(status: u16, code: Option<&str>) -> Option<(&'static str, &'static str)> { match (status, code?) { (400, "query.invalid") => Some(("Bad Request", "The query request is invalid.")), - (400, "request.invalid") => Some(("Bad Request", "The mutation request is invalid.")), + (400, "request.invalid") => Some(("Bad Request", "The request is invalid.")), (404, "resource.not_found") => Some(("Not Found", "The requested resource was not found.")), (409, "mutation.conflict") => { Some(("Conflict", "The mutation conflicts with current state.")) @@ -2597,10 +2698,8 @@ fn valid_stable_id(value: &str) -> bool { fn operation_method(operation: Operation) -> HttpMethod { match operation { - Operation::Create | Operation::Batch => HttpMethod::Post, - Operation::Get | Operation::List | Operation::Lookup | Operation::Revisions => { - HttpMethod::Get - } + Operation::Create | Operation::Lookup | Operation::Batch => HttpMethod::Post, + Operation::Get | Operation::List | Operation::Revisions => HttpMethod::Get, Operation::Patch => HttpMethod::Patch, Operation::Tombstone => HttpMethod::Delete, } @@ -3396,7 +3495,7 @@ mod tests { ), 2 => ( 200, - json!({"items":[{"id":id,"revision":1,"data":{"jurisdiction":"zone-a","label":"first","note":"initial","quantity":1}}],"nextCursor":null}), + json!({"items":[{"id":id,"revision":1,"data":{"jurisdiction":"zone-a","label":"first","note":"initial","quantity":1}}],"pageInfo":{"nextCursor":null}}), None, ), 3 => ( diff --git a/crates/registry-server/src/generated_ddl.rs b/crates/registry-server/src/generated_ddl.rs index eb9d02bace..bafa86568d 100644 --- a/crates/registry-server/src/generated_ddl.rs +++ b/crates/registry-server/src/generated_ddl.rs @@ -317,7 +317,12 @@ pub(crate) fn generate_ddl( name: entity.source_relation.sql_name.clone(), runtime_privileges: BTreeSet::from([TablePrivilege::Select]), }); + } + // Every trusted SQL fragment may join any declared source relation. Build + // the complete source layer before creating derived views so entity sort + // order cannot turn a valid cross-entity dependency into invalid DDL. + for entity in entities.values() { for relation in entity.derived_relations.values() { let derived_view_name = derived_view_name(&entity.source_relation.sql_name, &relation.id); @@ -635,6 +640,7 @@ fn read_path_target_policy( ) -> DdlPolicy { let through_source_ref = format!("path_edge.{}", field_name(through, &path.source_ref)); let through_target_ref = format!("path_edge.{}", field_name(through, &path.target_ref)); + let target_record_id = format!("{}.record_id", quote_identifier(&target.physical_table)); let root_id = "NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid"; let source_authority = policy_authority_expression_for_alias(source, profile, Some("path_source")); @@ -649,7 +655,7 @@ fn read_path_target_policy( FROM registry_data.{} AS path_edge JOIN registry_data.{} AS path_source ON path_source.record_id = {through_source_ref} - WHERE {through_target_ref} = record_id + WHERE {through_target_ref} = {target_record_id} AND {through_source_ref} = {root_id} AND path_edge.record_lifecycle = 'active' AND path_source.record_lifecycle = 'active' @@ -1172,7 +1178,7 @@ fn field_list(entity: &CompiledEntity, fields: &[String]) -> String { fn field_name(entity: &CompiledEntity, field: &str) -> String { if field == "id" { - return quote_identifier(&entity.canonical_id.sql_name); + return "record_id".to_owned(); } quote_identifier(&entity.fields[field].physical_name) } diff --git a/crates/registry-server/src/migration.rs b/crates/registry-server/src/migration.rs index 8859f7b7b6..2d927688d2 100644 --- a/crates/registry-server/src/migration.rs +++ b/crates/registry-server/src/migration.rs @@ -260,11 +260,16 @@ pub async fn apply_verified_package( .statements .iter() .zip(&compiler_checksums) - .map(|(statement, checksum)| PackageDdlStatement { - sql: &statement.sql, - checksum, + .enumerate() + .map(|(ordinal, (statement, checksum))| { + Ok(PackageDdlStatement { + sql: &statement.sql, + checksum, + kind: statement.kind, + ordinal: i32::try_from(ordinal).map_err(|_| MigrationError::PackageBinding)?, + }) }) - .collect::>(); + .collect::>>()?; // Threat: a path-only backup check could be swapped between validation // and the maintenance transition. The library opens with NOFOLLOW, checks diff --git a/crates/registry-server/src/migration_plan.rs b/crates/registry-server/src/migration_plan.rs index 37ff584229..02242af0d8 100644 --- a/crates/registry-server/src/migration_plan.rs +++ b/crates/registry-server/src/migration_plan.rs @@ -842,7 +842,7 @@ fn validate_receipt( || receipt.plan_sha256 != digest(descriptor_bytes) || receipt.sql_sha256 != expected_sql || receipt.assertion_sha256 != expected_assertions - || !(13..=18).contains(&receipt.postgres_major) + || !(15..=18).contains(&receipt.postgres_major) || (receipt.fixture_inventory.is_empty() && !metadata_only) || !strictly_sorted( receipt diff --git a/crates/registry-server/src/model.rs b/crates/registry-server/src/model.rs index 0e526db13c..7a8629157b 100644 --- a/crates/registry-server/src/model.rs +++ b/crates/registry-server/src/model.rs @@ -242,6 +242,7 @@ pub struct CompiledMetadataEntry { pub route_id: String, pub operation: Operation, pub access_profile: String, + pub response_entity_id: String, pub readable_fields: BTreeSet, } @@ -279,6 +280,7 @@ pub enum CompiledQueryFilterOperator { IsNull, IsNotNull, Prefix, + Contains, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] diff --git a/crates/registry-server/src/package.rs b/crates/registry-server/src/package.rs index 998e7fbf07..87ef8a3215 100644 --- a/crates/registry-server/src/package.rs +++ b/crates/registry-server/src/package.rs @@ -1584,11 +1584,39 @@ fn reviewed_successor_migration_plan( &change_set.from_revision, additive_changes, ); + let refresh_views = additive + .statements + .iter() + .any(|statement| statement.kind == DdlStatementKind::View) + || change_set.changes.iter().any(|change| { + matches!( + change.code, + CompiledRegistryChangeCode::EntityRemoved + | CompiledRegistryChangeCode::FieldAddedRequired + | CompiledRegistryChangeCode::FieldRemoved + | CompiledRegistryChangeCode::FieldTypeChanged + | CompiledRegistryChangeCode::FieldPhysicalNameChanged + | CompiledRegistryChangeCode::DerivedRelationRemoved + | CompiledRegistryChangeCode::DerivedRelationChanged + ) + }); + let mut statements = additive.statements; + if refresh_views { + statements.retain(|statement| statement.kind != DdlStatementKind::View); + statements.extend( + candidate + .ddl() + .statements + .iter() + .filter(|statement| statement.kind == DdlStatementKind::View) + .cloned(), + ); + } Ok(MigrationPlan { from_revision: Some(change_set.from_revision.clone()), prior_baseline: Some(baseline.clone()), changes: change_set.changes.clone(), - statements: additive.statements, + statements, reviewed_descriptors: descriptor_paths, prior_schema_fingerprint: Some(prior_schema_fingerprint), }) diff --git a/crates/registry-server/src/postgres/context.rs b/crates/registry-server/src/postgres/context.rs index 1927001e00..beba574baa 100644 --- a/crates/registry-server/src/postgres/context.rs +++ b/crates/registry-server/src/postgres/context.rs @@ -150,12 +150,17 @@ impl ClaimContext { return Err(invalid_context()); } validate_boundary(actual)?; - let field = entity - .fields - .get(&expected.field) - .ok_or_else(invalid_context)?; + let field_type = if expected.field == entity.canonical_id.id { + &entity.canonical_id.field_type + } else { + &entity + .fields + .get(&expected.field) + .ok_or_else(invalid_context)? + .field_type + }; for value in actual.values() { - validate_field_value(value, &field.field_type)?; + validate_field_value(value, field_type)?; } } let canonical_row_boundaries = canonical_boundaries(&row_boundaries)?; @@ -554,6 +559,29 @@ mod tests { } } + #[test] + fn compiled_context_accepts_a_canonical_id_row_boundary() { + let registry = compiled_registry(); + ClaimContext::for_compiled( + ®istry, + "entry", + Some("principal".to_owned()), + "viewer", + None, + vec![equals("id", "123e4567-e89b-12d3-a456-426614174000")], + ) + .expect("the compiled canonical UUID boundary is accepted"); + assert!(ClaimContext::for_compiled( + ®istry, + "entry", + Some("principal".to_owned()), + "viewer", + None, + vec![equals("id", "not-a-uuid")], + ) + .is_err()); + } + fn equals(field: &str, value: &str) -> RowBoundaryContext { RowBoundaryContext::Equals { field: field.to_owned(), @@ -690,36 +718,61 @@ mod tests { constraints: Vec::new(), temporal: None, indexes: Vec::new(), - access_profiles: vec![AccessProfileSource { - id: "operator".to_owned(), - default: true, - anonymous: false, - principal_claim: Some("registry_principal".to_owned()), - required_scopes: BTreeSet::new(), - required_purposes: BTreeSet::from(["operations".to_owned()]), - operations, - readable_fields: BTreeSet::from(["tenant".to_owned(), "region".to_owned()]), - writable_fields: BTreeSet::new(), - filterable_fields: BTreeSet::new(), - sortable_fields: BTreeSet::new(), - row_boundaries: vec![ - RowBoundarySource { - field: "tenant".to_owned(), - claim: "tenant_claim".to_owned(), + access_profiles: vec![ + AccessProfileSource { + id: "operator".to_owned(), + default: true, + anonymous: false, + principal_claim: Some("registry_principal".to_owned()), + required_scopes: BTreeSet::new(), + required_purposes: BTreeSet::from(["operations".to_owned()]), + operations: operations.clone(), + readable_fields: BTreeSet::from(["tenant".to_owned(), "region".to_owned()]), + writable_fields: BTreeSet::new(), + filterable_fields: BTreeSet::new(), + sortable_fields: BTreeSet::new(), + row_boundaries: vec![ + RowBoundarySource { + field: "tenant".to_owned(), + claim: "tenant_claim".to_owned(), + operator: BoundaryOperator::Equals, + }, + RowBoundarySource { + field: "region".to_owned(), + claim: "region_claim".to_owned(), + operator: BoundaryOperator::In, + }, + ], + revision_access: false, + allow_data_export: false, + lookups: Vec::new(), + read_paths: Vec::new(), + allow_count: false, + }, + AccessProfileSource { + id: "viewer".to_owned(), + default: false, + anonymous: false, + principal_claim: Some("registry_principal".to_owned()), + required_scopes: BTreeSet::new(), + required_purposes: BTreeSet::new(), + operations, + readable_fields: BTreeSet::from(["tenant".to_owned()]), + writable_fields: BTreeSet::new(), + filterable_fields: BTreeSet::new(), + sortable_fields: BTreeSet::new(), + row_boundaries: vec![RowBoundarySource { + field: "id".to_owned(), + claim: "record_id_claim".to_owned(), operator: BoundaryOperator::Equals, - }, - RowBoundarySource { - field: "region".to_owned(), - claim: "region_claim".to_owned(), - operator: BoundaryOperator::In, - }, - ], - revision_access: false, - allow_data_export: false, - lookups: Vec::new(), - read_paths: Vec::new(), - allow_count: false, - }], + }], + revision_access: false, + allow_data_export: false, + lookups: Vec::new(), + read_paths: Vec::new(), + allow_count: false, + }, + ], events: Vec::new(), }], access_profiles: Vec::new(), diff --git a/crates/registry-server/src/postgres/interlock.rs b/crates/registry-server/src/postgres/interlock.rs index b5738f3af5..82da6ef004 100644 --- a/crates/registry-server/src/postgres/interlock.rs +++ b/crates/registry-server/src/postgres/interlock.rs @@ -40,6 +40,8 @@ const MAX_VERIFIED_DDL_STATEMENT_TIMEOUT: Duration = Duration::from_secs(60 * 60 pub(crate) struct PackageDdlStatement<'a> { pub sql: &'a str, pub checksum: &'a str, + pub kind: DdlStatementKind, + pub ordinal: i32, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -265,8 +267,16 @@ impl DedicatedApplyConnection { ledger, compiler_lock_timeout, compiler_statement_timeout, + false, ) .await?; + let refresh_views = compiler_statements + .iter() + .any(|statement| statement.kind == DdlStatementKind::View); + if refresh_views { + self.drop_managed_read_views(compiler_lock_timeout, compiler_statement_timeout) + .await?; + } let mut committed_chunks = 0_u64; for (migration_index, migration) in plan.migrations().iter().enumerate() { @@ -335,6 +345,16 @@ impl DedicatedApplyConnection { } } + if refresh_views { + self.execute_reviewed_compiler_steps( + compiler_statements, + ledger, + compiler_lock_timeout, + compiler_statement_timeout, + true, + ) + .await?; + } self.execute_reviewed_assertion_phase(plan, ledger, candidate_tables, true) .await?; Ok(ReviewedExecutionOutcome::Complete) @@ -404,6 +424,7 @@ impl DedicatedApplyConnection { ledger: &MigrationLedgerEntry, lock_timeout: Duration, statement_timeout: Duration, + views: bool, ) -> Result<()> { validate_timeout( lock_timeout, @@ -415,11 +436,12 @@ impl DedicatedApplyConnection { MAX_VERIFIED_DDL_STATEMENT_TIMEOUT, "compiler DDL statement timeout is outside its bound", )?; - for (index, statement) in statements.iter().enumerate() { + for statement in statements + .iter() + .filter(|statement| (statement.kind == DdlStatementKind::View) == views) + { validate_statement_checksum(statement)?; - let step_ordinal = - i32::try_from(index).map_err(|_| PostgresKernelError::RegistryUnavailable)?; - let ledger_step = ledger_step(ledger, 0, step_ordinal)?; + let ledger_step = ledger_step(ledger, 0, statement.ordinal)?; if ledger_step.kind != MigrationLedgerStepKind::CompilerDdl || ledger_step.checksum != statement.checksum { @@ -427,10 +449,10 @@ impl DedicatedApplyConnection { } let transaction = self.client.transaction().await?; set_local_duration_timeouts(&transaction, lock_timeout, statement_timeout).await?; - if step_progress(&transaction, ledger, ledger_step) + let complete = step_progress(&transaction, ledger, ledger_step) .await? - .complete - { + .complete; + if complete && !views { transaction.commit().await?; continue; } @@ -438,12 +460,55 @@ impl DedicatedApplyConnection { .batch_execute(statement.sql) .await .map_err(|_| PostgresKernelError::Connection)?; - record_step_complete(&transaction, ledger, ledger_step, 0).await?; + if !complete { + record_step_complete(&transaction, ledger, ledger_step, 0).await?; + } transaction.commit().await?; } Ok(()) } + async fn drop_managed_read_views( + &mut self, + lock_timeout: Duration, + statement_timeout: Duration, + ) -> Result<()> { + // These two schemas are a closed compiler-owned boundary. Reviewed + // column changes may require their dependent views to be removed + // first; exact package DDL recreates every candidate view afterward. + let transaction = self.client.transaction().await?; + set_local_duration_timeouts(&transaction, lock_timeout, statement_timeout).await?; + let rows = transaction + .query( + "SELECT schemaname, viewname + FROM pg_catalog.pg_views + WHERE schemaname IN ('registry_derived', 'registry_source') + ORDER BY CASE schemaname WHEN 'registry_derived' THEN 0 ELSE 1 END, + viewname", + &[], + ) + .await?; + for row in rows { + let schema = row + .try_get::<_, String>(0) + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + let view = row + .try_get::<_, String>(1) + .map_err(|_| PostgresKernelError::RegistryUnavailable)?; + if !matches!(schema.as_str(), "registry_derived" | "registry_source") { + return Err(PostgresKernelError::RegistryUnavailable); + } + let schema = SqlIdentifier::parse(&schema)?; + let view = SqlIdentifier::parse(&view)?; + transaction + .batch_execute(&format!("DROP VIEW {}.{}", schema.quoted(), view.quoted())) + .await + .map_err(|_| PostgresKernelError::Connection)?; + } + transaction.commit().await?; + Ok(()) + } + async fn execute_reviewed_transactional_step( &mut self, step: &ValidatedReviewedMigrationStep, diff --git a/crates/registry-server/src/query.rs b/crates/registry-server/src/query.rs index 01abd2eb51..22f892ecc9 100644 --- a/crates/registry-server/src/query.rs +++ b/crates/registry-server/src/query.rs @@ -527,7 +527,10 @@ impl QueryBuilder { match key { "accessProfile" => { ensure_absent(self.access_profile.is_none())?; - self.access_profile = Some(ApiIdentifier::parse(value)?.0); + if !valid_config_identifier(value) { + return Err(QueryParseError::InvalidValue); + } + self.access_profile = Some(value.to_owned()); } "asOf" => { ensure_absent(self.as_of.is_none())?; @@ -681,7 +684,7 @@ fn parse_opaque_value(value: &str) -> Result { Ok(value.to_owned()) } -fn valid_identifier(value: &str) -> bool { +fn valid_config_identifier(value: &str) -> bool { let mut bytes = value.bytes(); let Some(first) = bytes.next() else { return false; @@ -693,6 +696,16 @@ fn valid_identifier(value: &str) -> bool { }) } +fn valid_identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + value.len() <= MAX_IDENTIFIER_BYTES + && (first.is_ascii_lowercase() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + fn push_optional_atom(output: &mut String, name: &str, value: Option<&str>) { output.push_str(name); output.push('='); @@ -847,6 +860,7 @@ impl<'a> Lexer<'a> { while self.index < self.input.len() { let byte = self.input.as_bytes()[self.index]; if byte.is_ascii_lowercase() + || byte.is_ascii_uppercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') { @@ -1233,37 +1247,25 @@ mod tests { #[test] fn top_and_count_are_strict() { - assert_eq!( - parse([("$top", "1")]) - .unwrap() - .canonical() - .contains("top=1"), - true - ); - assert_eq!( - parse([("$top", "100")]) - .unwrap() - .canonical() - .contains("top=100"), - true - ); + assert!(parse([("$top", "1")]) + .unwrap() + .canonical() + .contains("top=1")); + assert!(parse([("$top", "100")]) + .unwrap() + .canonical() + .contains("top=100")); for value in ["", "0", "101", "-1", "1.0", "+1", "true"] { assert_eq!(parse([("$top", value)]), Err(QueryParseError::InvalidValue)); } - assert_eq!( - parse([("$count", "true")]) - .unwrap() - .canonical() - .contains("count=true"), - true - ); - assert_eq!( - parse([("$count", "false")]) - .unwrap() - .canonical() - .contains("count=false"), - true - ); + assert!(parse([("$count", "true")]) + .unwrap() + .canonical() + .contains("count=true")); + assert!(parse([("$count", "false")]) + .unwrap() + .canonical() + .contains("count=false")); for value in ["", "True", "1", "yes"] { assert_eq!( parse([("$count", value)]), @@ -1288,6 +1290,8 @@ mod tests { #[test] fn select_is_bounded_and_duplicate_free() { + let camel = parse([("$select", "childUnder5Count")]).expect("lower camel API name parses"); + assert!(camel.canonical().contains("id(16:childUnder5Count)")); assert_eq!( parse([("$select", "case-code,case-code")]), Err(QueryParseError::DuplicateOption) @@ -1335,6 +1339,10 @@ mod tests { ] { assert_eq!(parsed_canonical(&filter(source)), canonical); } + assert_eq!( + parsed_canonical(&filter("childUnder5Count gt 0")), + "gt(id(16:childUnder5Count),int(1:0))" + ); } #[test] diff --git a/crates/registry-server/tests/compiler_contract.rs b/crates/registry-server/tests/compiler_contract.rs index 952466d04e..d0beab6695 100644 --- a/crates/registry-server/tests/compiler_contract.rs +++ b/crates/registry-server/tests/compiler_contract.rs @@ -151,6 +151,22 @@ fn derived_fields_selectors_and_read_paths_compile_to_route_specific_inventories household.derived_relations["demographics"].sql_bytes, sql.as_bytes() ); + let last_source_view = compiled + .ddl() + .statements + .iter() + .rposition(|statement| statement.id.ends_with(".source-view")) + .expect("source views are generated"); + let first_derived_view = compiled + .ddl() + .statements + .iter() + .position(|statement| statement.id.contains(".derived.")) + .expect("derived view is generated"); + assert!( + last_source_view < first_derived_view, + "all source views must exist before cross-entity derived SQL is installed" + ); assert!(compiled .routes() .routes @@ -242,6 +258,15 @@ fn derived_sql_is_asset_backed_value_free_and_validates_output_aliases() { .diagnostics() .iter() .any(|diagnostic| diagnostic.code == "derived.sql.invalid")); + + compile_json_with_assets( + project, + vec![derived_sql_asset( + "sql/demographics.sql", + "WITH counts AS (SELECT h.id AS id, count(*) AS child_count FROM registry_source.household h GROUP BY h.id) SELECT c.id AS id, c.child_count AS child_count FROM counts c", + )], + ) + .expect("a bounded non-recursive CTE over registry_source is accepted"); } #[test] diff --git a/crates/registry-server/tests/http_auth.rs b/crates/registry-server/tests/http_auth.rs index c880c7341e..54b360173b 100644 --- a/crates/registry-server/tests/http_auth.rs +++ b/crates/registry-server/tests/http_auth.rs @@ -573,6 +573,20 @@ async fn constructor_requires_one_exact_bounded_verifier_profile() { } } +#[tokio::test] +async fn constructor_accepts_the_compiled_canonical_id_as_a_row_boundary() { + let source = PROJECT.replace( + " rowBoundaries:\n - {field: jurisdiction, claim: jurisdictions, operator: in}", + " rowBoundaries:\n - {field: id, claim: case_id, operator: equals}\n - {field: jurisdiction, claim: jurisdictions, operator: in}", + ); + let project = parse_project_yaml(source.as_bytes()).expect("canonical id project parses"); + let registry = compile_project(&project, &[], CompileProfile::Authoring) + .expect("canonical id boundary compiles"); + let idp = MockIdp::start().await; + authenticator(®istry, &idp, authority_claims()) + .expect("canonical UUID authority is derived from the compiled id field"); +} + async fn assert_refused_without_record_call(harness: &Harness, token: &str) { let before = harness.records.calls.load(Ordering::SeqCst); let response = harness diff --git a/crates/registry-server/tests/http_read_only.rs b/crates/registry-server/tests/http_read_only.rs index c32d4f0d79..5a6d73e2aa 100644 --- a/crates/registry-server/tests/http_read_only.rs +++ b/crates/registry-server/tests/http_read_only.rs @@ -15,9 +15,11 @@ use registry_server::api::{ RevisionReadService, ServiceFuture, VerifiedClaimValue, VerifiedRequestClaims, }; use registry_server::artifacts::REGISTRY_METADATA_ARTIFACT_PATH; -use registry_server::contract::Operation; +use registry_server::contract::{ModuleAssetSource, Operation}; use registry_server::cursor::CursorCodec; -use registry_server::{compile_project, parse_project_yaml, CompileProfile}; +use registry_server::{ + compile_project, compile_project_with_assets, parse_project_yaml, CompileProfile, +}; use serde_json::{json, Value}; use tower::Service as _; use zeroize::Zeroizing; @@ -160,6 +162,37 @@ entities: sortableFields: [sensitive-note] "#; +const DERIVED_DISCOVERY_PROJECT: &str = r#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: derived-discovery + version: 0.1.0 + defaultLanguage: en +entities: + - id: benefit-record + route: benefit-records + mutationMode: mutable + classification: restricted + fields: + - {id: label, type: string, required: true, maxLength: 100, classification: restricted} + derived: + - id: eligibility + sql: sql/eligibility.sql + key: id + execution: live + fields: + - {id: eligibility-score, type: int64, classification: restricted} + accessProfiles: + - id: operator + default: true + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + operations: [get, list] + readableFields: [label, eligibility-score] +"#; + const DISCOVERY_MATRIX_PROJECT: &str = r#" apiVersion: registry.registrystack.org/v1alpha1 kind: RegistryProject @@ -647,6 +680,179 @@ async fn relationship_route_uses_path_grant_not_direct_target_rights() { assert_eq!(harness.records.calls(), before); } +#[tokio::test] +async fn relationship_discovery_uses_target_entity_and_unions_authorized_operation_fields() { + let project = + parse_project_yaml(LOOKUP_PATH_PROJECT.as_bytes()).expect("relationship project parses"); + let registry = compile_project(&project, &[], CompileProfile::Authoring) + .expect("relationship project compiles"); + let path_entry = registry + .metadata() + .entities + .iter() + .find(|entity| entity.id == "household") + .and_then(|entity| { + entity + .entries + .iter() + .find(|entry| entry.route_id == "records.household.path.people") + }) + .expect("relationship metadata entry is compiled"); + assert_eq!(path_entry.response_entity_id, "person"); + assert_eq!( + path_entry.readable_fields, + BTreeSet::from(["person-code".to_owned()]) + ); + let path_filter = registry + .queries() + .operations + .iter() + .find(|operation| operation.id == "records.household.operator.path.people") + .and_then(|operation| { + operation + .filter_fields + .iter() + .find(|field| field.field == "person-code") + }) + .expect("relationship string filter capability is compiled"); + assert!(path_filter + .operators + .contains(®istry_server::model::CompiledQueryFilterOperator::Contains)); + + let harness = Harness::from_project(LOOKUP_PATH_PROJECT, true); + let claims = Some(caseworker_claims("case-management")); + let openapi = body_json( + harness + .send( + Method::GET, + "/openapi.json?accessProfile=operator", + claims.clone(), + ) + .await, + ) + .await; + assert_eq!( + openapi["paths"]["/v1/records/households/{record_id}/people"]["get"] + ["x-registry-responseEntity"], + "person" + ); + assert_eq!( + openapi["components"]["schemas"]["person"]["properties"], + json!({ + "person-code": {"type": "string", "minLength": 0, "maxLength": 64}, + "sensitive-note": {"type": "string", "minLength": 0, "maxLength": 64} + }) + ); + + let person_schema = body_json( + harness + .send( + Method::GET, + "/v1/schemas/person?accessProfile=operator", + claims.clone(), + ) + .await, + ) + .await; + assert_eq!( + person_schema["properties"], + openapi["components"]["schemas"]["person"]["properties"] + ); + let household_schema = body_json( + harness + .send( + Method::GET, + "/v1/schemas/household?accessProfile=operator", + claims.clone(), + ) + .await, + ) + .await; + assert!(household_schema["properties"] + .get("household-code") + .is_some()); + assert!(household_schema["properties"].get("person-code").is_none()); + + let metadata = body_json( + harness + .send(Method::GET, "/v1/registry?accessProfile=operator", claims) + .await, + ) + .await; + let person = metadata["entities"] + .as_array() + .expect("metadata entities") + .iter() + .find(|entity| entity["id"] == "person") + .expect("target entity is discoverable through direct and relationship reads"); + assert_eq!( + person["readableFields"], + json!(["person-code", "sensitive-note"]) + ); +} + +#[tokio::test] +async fn derived_fields_are_discoverable_as_read_only_response_properties() { + let project = parse_project_yaml(DERIVED_DISCOVERY_PROJECT.as_bytes()) + .expect("derived discovery project parses"); + let registry = Arc::new( + compile_project_with_assets( + &project, + &[], + &[ModuleAssetSource { + module: None, + path: "sql/eligibility.sql".to_owned(), + bytes: b"SELECT benefit.id AS id, 0::bigint AS eligibility_score FROM registry_source.benefit_record benefit".to_vec(), + }], + CompileProfile::Authoring, + ) + .expect("derived discovery project compiles"), + ); + assert!(!registry.entities()["benefit-record"] + .fields + .contains_key("eligibility-score")); + let records = Arc::new(RecordingReadService::default()); + let app = router(Arc::new(HttpService::new( + registry, + read_identity(), + records.clone(), + Arc::new(ControlledReadiness(AtomicBool::new(true))), + cursor_codec(), + ))); + let schema = body_json( + send_to( + &app, + Method::GET, + "/v1/schemas/benefit-record?accessProfile=operator", + Some(caseworker_claims("case-management")), + ) + .await, + ) + .await; + assert_eq!( + schema["properties"]["eligibility-score"], + json!({"type": "integer", "format": "int64", "readOnly": true}) + ); + assert_eq!( + schema["properties"]["label"], + json!({"type": "string", "minLength": 0, "maxLength": 100}) + ); + assert_eq!(schema["required"], json!(["label"])); + + let response = send_to( + &app, + Method::GET, + "/v1/records/benefit-records/00000000-0000-4000-8000-000000000001?accessProfile=operator&$select=eligibility-score", + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + records.last_request().selected_fields, + BTreeSet::from(["eligibility-score".to_owned()]) + ); +} + #[tokio::test] async fn continuation_requests_refuse_query_overrides_before_record_io() { let harness = Harness::new(true); diff --git a/crates/registry-server/tests/package_change_plan.rs b/crates/registry-server/tests/package_change_plan.rs index 1408e80c48..6cd95d18c2 100644 --- a/crates/registry-server/tests/package_change_plan.rs +++ b/crates/registry-server/tests/package_change_plan.rs @@ -653,7 +653,7 @@ fn inspected_migration_summaries_are_exact_deterministic_and_value_free() { "destructiveOrIrreversible": 0, "unsupported": 0, }, - "generatedStatementCount": 2, + "generatedStatementCount": 3, "reviewedMigrations": [{ "changeClass": "data_backfill_required", "recovery": "exact_target_resume", diff --git a/crates/registry-server/tests/pilot_acceptance_fixtures.rs b/crates/registry-server/tests/pilot_acceptance_fixtures.rs index fb9c0850ee..3f227b6586 100644 --- a/crates/registry-server/tests/pilot_acceptance_fixtures.rs +++ b/crates/registry-server/tests/pilot_acceptance_fixtures.rs @@ -1,39 +1,65 @@ // SPDX-License-Identifier: Apache-2.0 +use std::collections::BTreeSet; use std::fs; use std::path::PathBuf; -use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::compiler::{compile_project_with_assets, CompileProfile}; use registry_server::contract::{ - Classification, ConstraintSource, FieldTypeSource, MutationMode, Operation, RegistryModule, - RegistryProject, UniqueWhenPredicate, ValidTimeRole, + Classification, ConstraintSource, FieldTypeSource, ModuleAssetSource, MutationMode, Operation, + RegistryModule, RegistryProject, UniqueWhenPredicate, ValidTimeRole, }; +use registry_server::fixtures::validate_fixture_journeys; use registry_server::generated_ddl::DdlStatementKind; use registry_server::model::{CompiledEntity, CompiledRegistry}; -fn fixture_sources(name: &str) -> (RegistryProject, Vec) { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) +fn fixture_root(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../products/registry-server/acceptance") - .join(name); + .join(name) +} + +fn fixture_sources(name: &str) -> (RegistryProject, Vec, Vec) { + let root = fixture_root(name); let bytes = fs::read(root.join("registry.yaml")).expect("committed pilot fixture is readable"); let project = registry_server::contract::parse_project_yaml(&bytes) .expect("pilot fixture follows the authoring contract"); - let modules = project - .modules - .iter() - .map(|locked| { - let bytes = fs::read(root.join("modules").join(&locked.id).join("module.yaml")) - .expect("every locked pilot module source is readable"); - registry_server::contract::parse_module_yaml(&bytes) - .expect("pilot module follows the authoring contract") - }) - .collect(); - (project, modules) + let mut modules = Vec::new(); + let mut assets = Vec::new(); + for locked in &project.modules { + let module_root = root.join("modules").join(&locked.id); + let bytes = fs::read(root.join("modules").join(&locked.id).join("module.yaml")) + .expect("every locked pilot module source is readable"); + let module = registry_server::contract::parse_module_yaml(&bytes) + .expect("pilot module follows the authoring contract"); + let declared_assets = module + .entities + .iter() + .flat_map(|entity| &entity.derived) + .chain( + module + .extend_entities + .iter() + .flat_map(|extension| &extension.derived), + ) + .map(|derived| derived.sql.clone()) + .collect::>(); + for path in declared_assets { + assets.push(ModuleAssetSource { + module: Some(module.id.clone()), + bytes: fs::read(module_root.join(&path)) + .expect("every declared pilot module asset is readable"), + path, + }); + } + modules.push(module); + } + (project, modules, assets) } fn compile_fixture(name: &str) -> CompiledRegistry { - let (project, modules) = fixture_sources(name); - compile_project(&project, &modules, CompileProfile::Production) + let (project, modules, assets) = fixture_sources(name); + compile_project_with_assets(&project, &modules, &assets, CompileProfile::Production) .expect("pilot fixture compiles in production mode") } @@ -135,6 +161,35 @@ fn household_pilot_fixture_compiles_person_household_and_time_bounded_membership })); } +#[test] +fn household_pilot_fixture_journeys_preflight_against_the_exact_compiled_registry() { + let compiled = compile_fixture("publicschema-household"); + let journeys = fs::read(fixture_root("publicschema-household").join("tests/journeys.yaml")) + .expect("committed household journeys are readable"); + if let Err(error) = validate_fixture_journeys(&journeys, &compiled) { + let document: serde_json::Value = + serde_norway::from_slice(&journeys).expect("journey YAML has a generic value shape"); + let steps = document["journeys"][0]["steps"] + .as_array() + .expect("journey steps are an array"); + for length in 1..=steps.len() { + let mut prefix = document.clone(); + prefix["journeys"][0]["steps"] + .as_array_mut() + .expect("journey steps remain an array") + .truncate(length); + let source = serde_norway::to_string(&prefix).expect("journey prefix serializes"); + if let Err(prefix_error) = validate_fixture_journeys(source.as_bytes(), &compiled) { + let step = steps[length - 1]["id"].as_str().unwrap_or("unknown"); + panic!( + "household journey first fails at step {step}: {prefix_error:?}; full error: {error:?}" + ); + } + } + panic!("household journey validation failed after every prefix passed: {error:?}"); + } +} + #[test] fn disability_pilot_fixture_compiles_protected_observations_and_create_only_certification() { let compiled = compile_fixture("disability"); diff --git a/crates/registry-server/tests/postgres_data_export.rs b/crates/registry-server/tests/postgres_data_export.rs index 814059d7a2..d21cdf60fe 100644 --- a/crates/registry-server/tests/postgres_data_export.rs +++ b/crates/registry-server/tests/postgres_data_export.rs @@ -297,7 +297,7 @@ async fn real_postgres_export_is_authenticated_projected_audited_and_resumable() let widened_body = canonicalize_json(&json!({ "items":[{"id":"00000000-0000-4000-8000-000000000001","revision":1, "data":{"code":"ROW-000","secret":SECRET_CANARY}}], - "nextCursor":null + "pageInfo":{"nextCursor":null} })) .unwrap(); let widened = execute_export_page( diff --git a/crates/registry-server/tests/postgres_migration.rs b/crates/registry-server/tests/postgres_migration.rs index 3d79cff558..d51f4f1eaa 100644 --- a/crates/registry-server/tests/postgres_migration.rs +++ b/crates/registry-server/tests/postgres_migration.rs @@ -1285,12 +1285,15 @@ async fn destructive_target_fingerprint( .transaction() .await .expect("destructive fingerprint transaction starts"); + drop_managed_views_for_fingerprint(&transaction).await; transaction .batch_execute(&format!( "ALTER TABLE registry_data.{table} DROP COLUMN {legacy}" )) .await .expect("destructive target rehearses"); + create_candidate_views_for_fingerprint(&transaction, candidate, database.runtime_role.as_str()) + .await; let fingerprint = managed_schema_fingerprint( &transaction, &database.runtime_role, @@ -1306,6 +1309,69 @@ async fn destructive_target_fingerprint( fingerprint } +async fn drop_managed_views_for_fingerprint(transaction: &tokio_postgres::Transaction<'_>) { + let rows = transaction + .query( + "SELECT schemaname, viewname + FROM pg_catalog.pg_views + WHERE schemaname IN ('registry_derived', 'registry_source') + ORDER BY CASE schemaname WHEN 'registry_derived' THEN 0 ELSE 1 END, + viewname", + &[], + ) + .await + .expect("fingerprint rehearsal inventories compiler-owned views"); + for row in rows { + let schema: String = row.get(0); + let view: String = row.get(1); + transaction + .batch_execute(&format!("DROP VIEW {}.{}", quote(&schema), quote(&view))) + .await + .expect("fingerprint rehearsal drops the prior compiler-owned read view"); + } +} + +async fn create_candidate_views_for_fingerprint( + transaction: &tokio_postgres::Transaction<'_>, + candidate: &CompiledRegistry, + runtime_role: &str, +) { + for statement in candidate.ddl().statements.iter().filter(|statement| { + statement.kind == registry_server::generated_ddl::DdlStatementKind::View + }) { + transaction + .batch_execute(&statement.sql) + .await + .expect("fingerprint rehearsal creates the candidate compiler-owned read view"); + } + for view in &candidate.ddl().views { + let schema = quote(&view.schema); + let name = quote(&view.name); + transaction + .batch_execute(&format!( + "REVOKE ALL ON TABLE {schema}.{name} FROM PUBLIC, {};", + quote(runtime_role) + )) + .await + .expect("fingerprint rehearsal revokes candidate view privileges"); + if !view.runtime_privileges.is_empty() { + let privileges = view + .runtime_privileges + .iter() + .map(|privilege| privilege.as_sql()) + .collect::>() + .join(", "); + transaction + .batch_execute(&format!( + "GRANT {privileges} ON TABLE {schema}.{name} TO {};", + quote(runtime_role) + )) + .await + .expect("fingerprint rehearsal grants candidate view privileges"); + } + } +} + async fn seed_backfill_rows(database: &TestDatabase, registry: &CompiledRegistry, count: u64) { let entity = ®istry.entities()["asset"]; let table = quote(&entity.physical_table); @@ -1390,6 +1456,26 @@ fn synthetic_backup_sql( quote(runtime_role.as_str()), quote(runtime_role.as_str()) )); + for view in ®istry.ddl().views { + let schema = quote(&view.schema); + let name = quote(&view.name); + sql.push_str(&format!( + "REVOKE ALL ON TABLE {schema}.{name} FROM PUBLIC, {};\n", + quote(runtime_role.as_str()) + )); + if !view.runtime_privileges.is_empty() { + let privileges = view + .runtime_privileges + .iter() + .map(|privilege| privilege.as_sql()) + .collect::>() + .join(", "); + sql.push_str(&format!( + "GRANT {privileges} ON TABLE {schema}.{name} TO {};\n", + quote(runtime_role.as_str()) + )); + } + } sql.into_bytes() } diff --git a/crates/registry-server/tests/postgres_package.rs b/crates/registry-server/tests/postgres_package.rs index 2e9f9ab24b..ca1b3e10db 100644 --- a/crates/registry-server/tests/postgres_package.rs +++ b/crates/registry-server/tests/postgres_package.rs @@ -1419,6 +1419,12 @@ async fn real_postgres_package_startup_apply_failure_and_old_process_are_closed( )) .await .expect("target fingerprint transaction installs the exact compiled runtime ACL"); + reconcile_view_acl_for_fingerprint( + &transaction, + provisional_second.registry(), + database.runtime_role.as_str(), + ) + .await; let second_catalog = ExpectedManagedCatalog::compiled(provisional_second.registry()); let target_schema = managed_schema_fingerprint(&transaction, &database.runtime_role, &second_catalog) @@ -1824,6 +1830,12 @@ async fn real_postgres_package_startup_apply_failure_and_old_process_are_closed( .map_err(|_| ()) .expect("third exact additive plan applies in fingerprint transaction"); } + reconcile_view_acl_for_fingerprint( + &transaction, + provisional_third.registry(), + database.runtime_role.as_str(), + ) + .await; let third_catalog = ExpectedManagedCatalog::compiled(provisional_third.registry()); let third_schema = managed_schema_fingerprint(&transaction, &database.runtime_role, &third_catalog) @@ -1966,6 +1978,37 @@ async fn real_postgres_package_startup_apply_failure_and_old_process_are_closed( database.cleanup().await; } +async fn reconcile_view_acl_for_fingerprint( + client: &impl GenericClient, + registry: ®istry_server::CompiledRegistry, + runtime_role: &str, +) { + for view in ®istry.ddl().views { + let schema = quote_identifier(&view.schema); + let name = quote_identifier(&view.name); + client + .batch_execute(&format!( + "REVOKE ALL ON TABLE {schema}.{name} FROM PUBLIC, \"{runtime_role}\";" + )) + .await + .expect("fingerprint transaction reconciles compiled view revocations"); + if !view.runtime_privileges.is_empty() { + let privileges = view + .runtime_privileges + .iter() + .map(|privilege| privilege.as_sql()) + .collect::>() + .join(", "); + client + .batch_execute(&format!( + "GRANT {privileges} ON TABLE {schema}.{name} TO \"{runtime_role}\";" + )) + .await + .expect("fingerprint transaction reconciles compiled view grants"); + } + } +} + #[derive(Clone, Copy)] enum PlanChoice { Schema, diff --git a/crates/registry-server/tests/postgres_pilot_acceptance.rs b/crates/registry-server/tests/postgres_pilot_acceptance.rs index 6ae2165141..0c782f66b7 100644 --- a/crates/registry-server/tests/postgres_pilot_acceptance.rs +++ b/crates/registry-server/tests/postgres_pilot_acceptance.rs @@ -211,7 +211,11 @@ async fn asset_site_placement_journey(harness: &PilotHarness) { } async fn household_journey(harness: &PilotHarness) { - let token = harness.token("household-administration", &[]); + let token = harness.token_with_scopes( + "household-administration", + &[], + &["registry:household:operate"], + ); let openapi = assert_fixture_surface(harness, "household-operator", &token, "household").await; assert_eq!( openapi["components"]["schemas"]["person"]["properties"]["residency-status"] @@ -232,6 +236,7 @@ async fn household_journey(harness: &PilotHarness) { "legal-name":"Ada North", "family-name":"North", "date-of-birth":"1990-04-03", + "person-sex":"female", "residency-status":"usual-resident", "preferred-language":"en" }), @@ -246,6 +251,7 @@ async fn household_journey(harness: &PilotHarness) { "household-old-create", json!({ "household-code":"H-OLD", + "local-household-number":100, "household-name":"Old household", "administrative-area":"north", "household-type":"private" @@ -259,6 +265,7 @@ async fn household_journey(harness: &PilotHarness) { "household-current-create", json!({ "household-code":"H-CURRENT", + "local-household-number":101, "household-name":"Current household", "administrative-area":"north", "household-type":"private" @@ -307,6 +314,43 @@ async fn household_journey(harness: &PilotHarness) { ) .await; + let household = harness + .send( + Method::GET, + &format!( + "/v1/records/households/{}?accessProfile=household-operator&$select=household-code,head-count,single-headed,woman-headed", + current_household.id + ), + Some(&token), + &[], + Vec::new(), + ) + .await; + assert_eq!(household.status(), StatusCode::OK); + let household = response_json(household).await; + assert_eq!(household["data"]["household-code"], "H-CURRENT"); + assert_eq!(household["data"]["head-count"], 1); + assert_eq!(household["data"]["single-headed"], true); + assert_eq!(household["data"]["woman-headed"], true); + + let people = harness + .send( + Method::GET, + &format!( + "/v1/records/households/{}/people?accessProfile=household-operator&$select=person-code,person-sex&$filter=person-sex%20eq%20%27female%27&$count=true", + current_household.id + ), + Some(&token), + &[], + Vec::new(), + ) + .await; + assert_eq!(people.status(), StatusCode::OK); + let people = response_json(people).await; + assert_eq!(people["count"], 1); + assert_eq!(people["items"][0]["data"]["person-code"], "P-100"); + assert_eq!(people["items"][0]["data"]["person-sex"], "female"); + let overlap = harness .send_json( Method::POST, diff --git a/crates/registry-server/tests/postgres_startup.rs b/crates/registry-server/tests/postgres_startup.rs index 6458bca676..9b0d7eeb56 100644 --- a/crates/registry-server/tests/postgres_startup.rs +++ b/crates/registry-server/tests/postgres_startup.rs @@ -269,6 +269,32 @@ async fn live_old_server_drains_apply_and_exact_successor_restart_becomes_ready( )) .await .expect("successor rehearsal installs the compiled runtime ACL"); + for view in &verified_provisional_successor.registry().ddl().views { + let schema = quote(&view.schema); + let name = quote(&view.name); + transaction + .batch_execute(&format!( + "REVOKE ALL ON TABLE {schema}.{name} FROM PUBLIC, \"{}\";", + database.runtime_role.as_str() + )) + .await + .expect("successor rehearsal revokes compiled view privileges"); + if !view.runtime_privileges.is_empty() { + let privileges = view + .runtime_privileges + .iter() + .map(|privilege| privilege.as_sql()) + .collect::>() + .join(", "); + transaction + .batch_execute(&format!( + "GRANT {privileges} ON TABLE {schema}.{name} TO \"{}\";", + database.runtime_role.as_str() + )) + .await + .expect("successor rehearsal grants compiled view privileges"); + } + } let successor_fingerprint = managed_schema_fingerprint( &transaction, &database.runtime_role, diff --git a/crates/registry-server/tests/support/pilot_acceptance_harness.rs b/crates/registry-server/tests/support/pilot_acceptance_harness.rs index c1638b87a5..a0bf0741a4 100644 --- a/crates/registry-server/tests/support/pilot_acceptance_harness.rs +++ b/crates/registry-server/tests/support/pilot_acceptance_harness.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -14,9 +15,9 @@ use registry_platform_crypto::{generate_private_jwk, sign, GeneratedKeyAlgorithm use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; use registry_platform_testing::MockIdp; -use registry_server::compiler::{compile_project, CompileProfile}; +use registry_server::compiler::{compile_project_with_assets, CompileProfile}; use registry_server::contract::{ - parse_module_yaml, parse_project_yaml, Operation, RegistryProject, + parse_module_yaml, parse_project_yaml, ModuleAssetSource, Operation, RegistryProject, }; use registry_server::package::{ load_package, PackageBuildRequest, PackageIntent, PackageLoadContext, @@ -193,11 +194,23 @@ impl PilotHarness { } pub fn token(&self, purpose: &str, row_boundary_claims: &[(&str, Value)]) -> String { + self.token_with_scopes(purpose, row_boundary_claims, &[]) + } + + pub fn token_with_scopes( + &self, + purpose: &str, + row_boundary_claims: &[(&str, Value)], + scopes: &[&str], + ) -> String { let mut claims = json!({ "aud": AUDIENCE, "registry_principal": "pilot-operator", "purpose": purpose, }); + if !scopes.is_empty() { + claims["scope"] = Value::String(scopes.join(" ")); + } for (name, value) in row_boundary_claims { claims[*name] = value.clone(); } @@ -290,6 +303,7 @@ struct FixtureSources { project: RegistryProject, project_bytes: Vec, modules: Vec<(String, Vec)>, + module_assets: Vec, compiled: CompiledRegistry, } @@ -302,28 +316,50 @@ impl FixtureSources { .expect("committed pilot registry source is readable"); let project = parse_project_yaml(&project_bytes) .expect("committed pilot registry follows the strict authoring contract"); - let modules = project - .modules - .iter() - .map(|locked| { - let bytes = fs::read(root.join("modules").join(&locked.id).join("module.yaml")) - .expect("every exact locked module source is committed and readable"); - (locked.id.clone(), bytes) - }) - .collect::>(); - let parsed_modules = modules - .iter() - .map(|(_, bytes)| { - parse_module_yaml(bytes) - .expect("committed pilot module follows the strict contract") - }) - .collect::>(); - let compiled = compile_project(&project, &parsed_modules, CompileProfile::Production) - .expect("pilot fixture closes under the Production compiler without repair"); + let mut modules = Vec::new(); + let mut parsed_modules = Vec::new(); + let mut module_assets = Vec::new(); + for locked in &project.modules { + let module_root = root.join("modules").join(&locked.id); + let bytes = fs::read(module_root.join("module.yaml")) + .expect("every exact locked module source is committed and readable"); + let module = parse_module_yaml(&bytes) + .expect("committed pilot module follows the strict contract"); + let declared_assets = module + .entities + .iter() + .flat_map(|entity| &entity.derived) + .chain( + module + .extend_entities + .iter() + .flat_map(|extension| &extension.derived), + ) + .map(|derived| derived.sql.clone()) + .collect::>(); + for asset_path in declared_assets { + module_assets.push(ModuleAssetSource { + module: Some(module.id.clone()), + bytes: fs::read(module_root.join(&asset_path)) + .expect("every declared module SQL asset is committed and readable"), + path: asset_path, + }); + } + modules.push((locked.id.clone(), bytes)); + parsed_modules.push(module); + } + let compiled = compile_project_with_assets( + &project, + &parsed_modules, + &module_assets, + CompileProfile::Production, + ) + .expect("pilot fixture closes under the Production compiler without repair"); Self { project, project_bytes, modules, + module_assets, compiled, } } @@ -373,7 +409,15 @@ impl PublishedPackage { id: id.clone(), path: format!("source/modules/{id}/module.yaml"), bytes: bytes.clone(), - assets: Vec::new(), + assets: sources + .module_assets + .iter() + .filter(|asset| asset.module.as_deref() == Some(id.as_str())) + .map(|asset| PackageSourceFile { + path: asset.path.clone(), + bytes: asset.bytes.clone(), + }) + .collect(), }) .collect(), fixture_journeys: PackageSourceFile { diff --git a/crates/registry-serverctl/tests/cli.rs b/crates/registry-serverctl/tests/cli.rs index 0fb37b8db8..ba2c51155f 100644 --- a/crates/registry-serverctl/tests/cli.rs +++ b/crates/registry-serverctl/tests/cli.rs @@ -890,7 +890,14 @@ fn explain_reports_are_derived_from_compiled_inventories() { assert_eq!(planner_list["apiFields"][0]["sourceKind"], "stored"); assert_eq!( planner_list["filterable"][0]["operators"], - json!(["equals", "in", "is_null", "is_not_null", "prefix"]) + json!([ + "equals", + "in", + "is_null", + "is_not_null", + "prefix", + "contains" + ]) ); assert_eq!(planner_list["bounds"]["maxPageSize"], 100); assert!(!String::from_utf8(queries.stdout) diff --git a/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml b/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml index 9c3e401c5b..39a6f0aa9c 100644 --- a/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml +++ b/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml @@ -219,6 +219,30 @@ journeys: household-code: HOUSEHOLD-SYNTH-003 local-household-number: 1003 capture: isolation-household + - id: lookup-single-headed-household + entity: household + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: lookup + selector: by-local-reference + values: {administrative-area: demonstration-north, local-household-number: 1001} + expect: + outcome: success + status: 200 + fields: {household-code: HOUSEHOLD-SYNTH-001, local-household-number: 1001} + - id: read-people-from-single-headed-household + entity: household + accessProfile: household-operator + claims: *household_operator_claims + request: + operation: read_path + path: people + recordRef: single-headed-household + select: [person-code, legal-name, person-sex] + top: 20 + count: true + expect: {outcome: success, status: 200, count: 0} - id: query-household-demographics entity: household accessProfile: household-operator diff --git a/products/registry-server/demo/README.md b/products/registry-server/demo/README.md index f38da33dd7..9f3ab24255 100644 --- a/products/registry-server/demo/README.md +++ b/products/registry-server/demo/README.md @@ -11,7 +11,9 @@ This local demo starts four real components: It then asks Mint for short-lived operator and negative-test tokens and creates eight synthetic people, three households, and eight effective-dated memberships -through Registry Server's ordinary authenticated REST API. +through Registry Server's ordinary authenticated REST API. Once the first +household has a server UUID, the demo creates a separate viewer key and Mint +client whose verified claims bind it to that UUID and household code. ## Run it @@ -23,60 +25,44 @@ products/registry-server/demo/run.sh The first run builds the four required Registry Stack binaries and may pull the pinned PostgreSQL image. When the demo is ready, leave that terminal running. -In a second terminal, execute the sample reads: +In a second terminal, execute all sample reads: ```bash -products/registry-server/demo/query.sh +products/registry-server/demo/query.sh all ``` -The query helper reads the bearer token from its owner-only file without -placing the token in a command-line argument or printing it. Press Ctrl-C in -the first terminal to stop Mint and Registry Server and remove the PostgreSQL -container. - -Use `--smoke` to run the full setup, seed and query assertions, then stop -without waiting: +Or focus on one access profile: ```bash -products/registry-server/demo/run.sh --smoke +products/registry-server/demo/query.sh operator +products/registry-server/demo/query.sh viewer ``` -## Copyable requests - -The query helper runs the GET shapes against the local server. The examples -below also show the selector lookup and viewer-denial requests with synthetic -bearer placeholders and deterministic logical IDs; when using the demo -directly, read the generated household UUID from -`demo/.run/seed-record-ids.json`. - -```bash -curl -sS -H 'Authorization: Bearer ' \ - 'http://127.0.0.1:18080/v1/records/households//people?accessProfile=household-operator&$select=person-code,legal-name,person-sex,residency-status&$orderby=person-code&$top=20&$count=true' - -curl -sS -H 'Authorization: Bearer ' \ - 'http://127.0.0.1:18080/v1/records/households?accessProfile=household-operator&$select=household-code,administrative-area,local-household-number,child-count&$filter=administrative-area%20eq%20%27north-demo%27%20and%20child-count%20eq%201&$orderby=local-household-number&$top=20&$count=true' - -curl -sS -H 'Authorization: Bearer ' \ - 'http://127.0.0.1:18080/v1/records/households?accessProfile=household-operator&$select=household-code,child-under-5-count,single-headed&$filter=single-headed%20eq%20true%20and%20child-under-5-count%20eq%201&$top=20&$count=true' +These are the real requests against the running server. The operator suite +shows relationship traversal, composable derived-field filters, and an exact +request-value selector lookup. The viewer suite proves that one claim-bound +household can be fetched by UUID or looked up from its verified household-code +claim, while list and household-to-people path requests return the concealed +`resource.not_found` response. -curl -sS -H 'Authorization: Bearer ' \ - 'http://127.0.0.1:18080/v1/records/households?accessProfile=household-operator&$select=household-code,woman-headed,child-count,elderly-count&$filter=woman-headed%20eq%20true%20and%20child-count%20eq%201%20and%20elderly-count%20eq%201&$top=20&$count=true' +The helper reads bearer tokens from owner-only files inside `.run/`. It does +not put them in command-line arguments or print them. Press Ctrl-C in the first +terminal to stop Mint and Registry Server and remove the PostgreSQL container. -curl -sS -X POST -H 'Authorization: Bearer ' \ - -H 'Content-Type: application/json' \ - --data '{"selector":"by-local-reference","value":{"administrative-area":"north-demo","local-household-number":1001}}' \ - 'http://127.0.0.1:18080/v1/records/households:lookup?accessProfile=household-operator' - -curl -sS -H 'Authorization: Bearer ' \ - 'http://127.0.0.1:18080/v1/records/households?accessProfile=household-viewer' +Use `--smoke` to run the full setup, seed and query assertions, then stop +without waiting: -curl -sS -H 'Authorization: Bearer ' \ - 'http://127.0.0.1:18080/v1/records/households?accessProfile=household-operator&$skiptoken=' +```bash +products/registry-server/demo/run.sh --smoke ``` -The `household-viewer` profile is intentionally get and lookup only. A list -attempt is expected to return the same concealed absence class as an -unauthorized resource. +The exact paths, query parameters, selector bodies, and expected statuses live +in `support/demo.py`, which `query.sh` invokes. This keeps the examples +copyable without teaching people to expand bearer tokens into process-visible +`curl` arguments. Field and selector IDs intentionally retain their configured +kebab-case spelling. The operator selector body uses the exact `values` +property, while the viewer's verified-claim selector correctly sends no +caller-provided values. ## Disposable state diff --git a/products/registry-server/demo/query.sh b/products/registry-server/demo/query.sh index 7f244943af..c984b4ee81 100755 --- a/products/registry-server/demo/query.sh +++ b/products/registry-server/demo/query.sh @@ -3,10 +3,16 @@ set -euo pipefail demo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) run_dir="$demo_dir/.run" +suite="${1:-all}" + +if [[ "$suite" != all && "$suite" != operator && "$suite" != viewer ]] || [[ $# -gt 1 ]]; then + printf '%s\n' 'usage: products/registry-server/demo/query.sh [all|operator|viewer]' >&2 + exit 2 +fi if [[ ! -d "$run_dir" || -L "$run_dir" ]]; then printf '%s\n' 'Registry Server demo is not running. Start demo/run.sh first.' >&2 exit 2 fi -python3 "$demo_dir/support/demo.py" query --root "$run_dir" +python3 "$demo_dir/support/demo.py" query --root "$run_dir" --suite "$suite" diff --git a/products/registry-server/demo/run.sh b/products/registry-server/demo/run.sh index 501da8af08..01a6c705f2 100755 --- a/products/registry-server/demo/run.sh +++ b/products/registry-server/demo/run.sh @@ -135,11 +135,14 @@ docker run --detach --name "$postgres_container" \ --publish "127.0.0.1:${database_port}:5432" \ "$postgres_image" >"$run_dir/postgres-container-id" -for attempt in $(seq 1 60); do - if docker exec "$postgres_container" pg_isready -q -U postgres; then +for attempt in $(seq 1 120); do + # The official image briefly starts a private initialization server. Wait + # until PID 1 has replaced the entrypoint with the final PostgreSQL process. + if [[ "$(docker exec "$postgres_container" cat /proc/1/comm)" == postgres ]] && + docker exec "$postgres_container" pg_isready -q -U postgres; then break fi - if [[ "$attempt" -eq 60 ]]; then + if [[ "$attempt" -eq 120 ]]; then printf '%s\n' "PostgreSQL did not become ready; see $run_dir/logs." >&2 exit 1 fi @@ -226,12 +229,32 @@ REGISTRY_SERVER_LOG=error "$registry_server" --config "$run_dir/runtime.yaml" \ server_pid=$! python3 "$support" wait-http --url "http://127.0.0.1:${server_port}/ready" --timeout 30 python3 "$support" seed --root "$run_dir" + +printf '%s\n' '== Binding a viewer credential to the first seeded household' +uv run --quiet "$mint_key_material" p256 \ + --private-out "$run_dir/keys/viewer/signing-p256-private-jwk" \ + --public-out "$run_dir/keys/viewer-public.jwk.json" +python3 "$support" configure-viewer --root "$run_dir" + +kill "$mint_pid" +wait "$mint_pid" || true +mint_pid="" +"$mint" serve --config "$run_dir/mint/mint.yaml" >>"$run_dir/logs/mint.log" 2>&1 & +mint_pid=$! +python3 "$support" wait-http --url "http://127.0.0.1:${mint_port}/ready" --timeout 30 +"$mint" token \ + --url "http://127.0.0.1:${mint_port}/token" \ + --client-id household-demo-viewer \ + --key "$run_dir/keys/viewer/signing-p256-private-jwk" | + python3 "$support" store-token --out "$run_dir/secrets/viewer-token" + "$demo_dir/query.sh" >/dev/null printf '\n%s\n' 'Registry Server household demo is ready.' printf ' Registry Server: http://127.0.0.1:%s\n' "$server_port" printf ' Registry Mint: http://127.0.0.1:%s\n' "$mint_port" -printf ' Token file: %s\n' "$run_dir/secrets/operator-token" +printf ' Operator token: %s\n' "$run_dir/secrets/operator-token" +printf ' Viewer token: %s\n' "$run_dir/secrets/viewer-token" printf ' Sample queries: %s\n' "$demo_dir/query.sh" printf ' Logs: %s\n' "$run_dir/logs" diff --git a/products/registry-server/demo/support/demo.py b/products/registry-server/demo/support/demo.py index a539d4bd58..c57dddac8d 100755 --- a/products/registry-server/demo/support/demo.py +++ b/products/registry-server/demo/support/demo.py @@ -14,6 +14,7 @@ import urllib.error import urllib.parse import urllib.request +import uuid from pathlib import Path from typing import Any @@ -24,6 +25,7 @@ AUDIENCE = "urn:registry-server:household-demo" OPERATOR_CLIENT = "household-demo" NO_PURPOSE_CLIENT = "household-demo-no-purpose" +VIEWER_CLIENT = "household-demo-viewer" MIGRATION_ROLE = "registry_demo_migration" RUNTIME_ROLE = "registry_demo_runtime" TEST_DATABASE = "registry_demo_test" @@ -92,17 +94,26 @@ def _local_project(root: Path, fixture: Path) -> None: project_path.write_text(source, encoding="utf-8") -def _mint_client(client_id: str, principal: str, public_key: dict[str, Any], purpose: str | None) -> str: - claims = f" registry_principal: {principal}\n" - if purpose is not None: - claims += f" registry_purpose: {purpose}\n" +def _mint_client( + client_id: str, + public_key: dict[str, Any], + scopes: list[str], + claims: dict[str, str], +) -> str: + rendered_scopes = ", ".join( + json.dumps(scope, ensure_ascii=True, separators=(",", ":")) for scope in scopes + ) + rendered_claims = "".join( + f" {name}: {json.dumps(value, ensure_ascii=True, separators=(',', ':'))}\n" + for name, value in sorted(claims.items()) + ) return ( f"clientId: {client_id}\n" f"principal: urn:registry-server:demo:{client_id}\n" "authorization:\n" - " scopes: [registry:household:operate]\n" + f" scopes: [{rendered_scopes}]\n" " claims:\n" - f"{claims}" + f"{rendered_claims}" f"keys: [{json.dumps(public_key, sort_keys=True, separators=(',', ':'))}]\n" ) @@ -145,7 +156,7 @@ def _runtime_config(root: Path, package_root: Path, revision: str, bind: str) -> accessTokenType: at+jwt scopeClaim: scope scopeSeparator: " " - allowedClients: [{OPERATOR_CLIENT}, {NO_PURPOSE_CLIENT}] + allowedClients: [{OPERATOR_CLIENT}, {NO_PURPOSE_CLIENT}, {VIEWER_CLIENT}] deniedKids: [] maxTokenLifetimeSeconds: 300 leewayMilliseconds: 30000 @@ -205,18 +216,21 @@ def prepare(root: Path, fixture: Path, database_port: int, mint_port: int, serve root / f"mint/clients/{OPERATOR_CLIENT}.yaml", _mint_client( OPERATOR_CLIENT, - "synthetic-household-operator", operator_public, - "household-administration", + ["registry:household:operate"], + { + "registry_principal": "synthetic-household-operator", + "registry_purpose": "household-administration", + }, ), ) _write_new( root / f"mint/clients/{NO_PURPOSE_CLIENT}.yaml", _mint_client( NO_PURPOSE_CLIENT, - "synthetic-household-operator", no_purpose_public, - None, + ["registry:household:operate"], + {"registry_principal": "synthetic-household-operator"}, ), ) _write_new( @@ -293,7 +307,10 @@ def prepare(root: Path, fixture: Path, database_port: int, mint_port: int, serve GRANT CONNECT ON DATABASE {TEST_DATABASE} TO {MIGRATION_ROLE}, {RUNTIME_ROLE}; CREATE SCHEMA registry_internal AUTHORIZATION {MIGRATION_ROLE}; CREATE SCHEMA registry_data AUTHORIZATION {MIGRATION_ROLE}; -REVOKE ALL ON SCHEMA registry_internal, registry_data FROM PUBLIC; +CREATE SCHEMA registry_source AUTHORIZATION {MIGRATION_ROLE}; +CREATE SCHEMA registry_derived AUTHORIZATION {MIGRATION_ROLE}; +CREATE SCHEMA registry_context AUTHORIZATION {MIGRATION_ROLE}; +REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context FROM PUBLIC; """, ) _write_new( @@ -328,6 +345,8 @@ def prepare(root: Path, fixture: Path, database_port: int, mint_port: int, serve - {{journeyId: household-person-lifecycle, stepId: create-single-headed-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - {{journeyId: household-person-lifecycle, stepId: create-woman-headed-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - {{journeyId: household-person-lifecycle, stepId: create-isolation-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: lookup-single-headed-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} + - {{journeyId: household-person-lifecycle, stepId: read-people-from-single-headed-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - {{journeyId: household-person-lifecycle, stepId: query-household-demographics, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - {{journeyId: household-person-lifecycle, stepId: refuse-incomplete-membership, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - {{journeyId: household-person-lifecycle, stepId: operator-without-purpose-is-concealed, credential: {{type: bearer, tokenRef: secret:file/no-purpose-token}}}} @@ -501,24 +520,127 @@ def seed(root: Path) -> None: print("Seeded 8 synthetic people, 3 households, and 8 current memberships.") -def query(root: Path) -> None: - root = _require_root(root) +def _bound_household(root: Path) -> tuple[str, str]: seed_ids = _read_json_object(root / "seed-record-ids.json") households = seed_ids.get("households") - if not isinstance(households, dict) or not isinstance(households.get("HOUSEHOLD-DEMO-001"), str): + household_code = "HOUSEHOLD-DEMO-001" + household_id = households.get(household_code) if isinstance(households, dict) else None + if not isinstance(household_id, str): raise DemoError("seed record identifiers are missing; run the demo seed first") - first_household_id = urllib.parse.quote(households["HOUSEHOLD-DEMO-001"], safe="") - queries = [ - ("People from one household", f"/v1/records/households/{first_household_id}/people?accessProfile=household-operator&$select=person-code,legal-name,person-sex,residency-status&$orderby=person-code&$top=20&$count=true"), - ("Derived stored and computed filter", "/v1/records/households?accessProfile=household-operator&$select=household-code,administrative-area,local-household-number,child-count&$filter=administrative-area%20eq%20%27north-demo%27%20and%20child-count%20eq%201&$orderby=local-household-number&$top=20&$count=true"), - ("Single headed with child under five", "/v1/records/households?accessProfile=household-operator&$select=household-code,child-under-5-count,single-headed&$filter=single-headed%20eq%20true%20and%20child-under-5-count%20eq%201&$top=20&$count=true"), - ("Woman headed with child and elderly", "/v1/records/households?accessProfile=household-operator&$select=household-code,woman-headed,child-count,elderly-count&$filter=woman-headed%20eq%20true%20and%20child-count%20eq%201%20and%20elderly-count%20eq%201&$top=20&$count=true"), - ("Selector lookup input shape", "/v1/records/households?accessProfile=household-operator&$select=household-code,local-household-number&$filter=household-code%20eq%20%27HOUSEHOLD-DEMO-001%27&$top=1"), - ] - for label, path in queries: - response, _ = _request(root, "GET", path, "operator-token") - print(f"\n{label}\n{'=' * len(label)}") - print(json.dumps(response, indent=2, sort_keys=True)) + try: + parsed = uuid.UUID(household_id) + except ValueError as error: + raise DemoError("the bound household identifier is not a UUID") from error + if str(parsed) != household_id: + raise DemoError("the bound household identifier is not canonical") + return household_id, household_code + + +def configure_viewer(root: Path) -> None: + root = _require_root(root) + household_id, household_code = _bound_household(root) + viewer_public = _read_json_object(root / "keys/viewer-public.jwk.json") + _write_new( + root / f"mint/clients/{VIEWER_CLIENT}.yaml", + _mint_client( + VIEWER_CLIENT, + viewer_public, + ["registry:household:view"], + { + "household_code": household_code, + "household_id": household_id, + "registry_principal": "synthetic-household-viewer", + "registry_purpose": "household-view", + }, + ), + ) + + +def _print_query(label: str, response: dict[str, Any]) -> None: + print(f"\n{label}\n{'=' * len(label)}") + print(json.dumps(response, indent=2, sort_keys=True)) + + +def _assert_bound_household(response: dict[str, Any], household_id: str, household_code: str) -> None: + data = response.get("data") + if ( + response.get("id") != household_id + or not isinstance(data, dict) + or data.get("household-code") != household_code + ): + raise DemoError("viewer read did not return its one bound household") + + +def query(root: Path, suite: str = "all") -> None: + root = _require_root(root) + if suite not in ("all", "operator", "viewer"): + raise DemoError("query suite must be all, operator, or viewer") + household_id, household_code = _bound_household(root) + encoded_household_id = urllib.parse.quote(household_id, safe="") + if suite in ("all", "operator"): + queries = [ + ("People from one household", f"/v1/records/households/{encoded_household_id}/people?accessProfile=household-operator&$select=person-code,legal-name,person-sex,residency-status&$orderby=person-code&$top=20&$count=true"), + ("Derived stored and computed filter", "/v1/records/households?accessProfile=household-operator&$select=household-code,administrative-area,local-household-number,child-count&$filter=administrative-area%20eq%20%27north-demo%27%20and%20child-count%20eq%201&$orderby=local-household-number&$top=20&$count=true"), + ("Single headed with child under five", "/v1/records/households?accessProfile=household-operator&$select=household-code,child-under-5-count,single-headed&$filter=single-headed%20eq%20true%20and%20child-under-5-count%20eq%201&$top=20&$count=true"), + ("Woman headed with child and elderly", "/v1/records/households?accessProfile=household-operator&$select=household-code,woman-headed,child-count,elderly-count&$filter=woman-headed%20eq%20true%20and%20child-count%20eq%201%20and%20elderly-count%20eq%201&$top=20&$count=true"), + ] + for label, path in queries: + response, _ = _request(root, "GET", path, "operator-token") + _print_query(label, response) + operator_lookup, _ = _request( + root, + "POST", + "/v1/records/households:lookup?accessProfile=household-operator", + "operator-token", + { + "selector": "by-local-reference", + "values": { + "administrative-area": "north-demo", + "local-household-number": 1001, + }, + }, + ) + _assert_bound_household(operator_lookup, household_id, household_code) + _print_query("Exact request-value selector lookup", operator_lookup) + + if suite in ("all", "viewer"): + viewer_get, _ = _request( + root, + "GET", + f"/v1/records/households/{encoded_household_id}?accessProfile=household-viewer", + "viewer-token", + ) + _assert_bound_household(viewer_get, household_id, household_code) + _print_query("Viewer get bound by verified household ID claim", viewer_get) + + viewer_lookup, _ = _request( + root, + "POST", + "/v1/records/households:lookup?accessProfile=household-viewer", + "viewer-token", + {"selector": "by-household-code"}, + ) + _assert_bound_household(viewer_lookup, household_id, household_code) + _print_query("Viewer lookup using its verified household code claim", viewer_lookup) + + denied = [ + ( + "Viewer list is concealed", + "/v1/records/households?accessProfile=household-viewer", + ), + ( + "Viewer relationship path is concealed", + f"/v1/records/households/{encoded_household_id}/people?accessProfile=household-viewer", + ), + ] + for label, path in denied: + response, _ = _request(root, "GET", path, "viewer-token", expected=404) + if response.get("code") != "resource.not_found": + raise DemoError("viewer denial did not use the concealed resource response") + rendered = json.dumps(response, sort_keys=True) + if household_id in rendered or household_code in rendered: + raise DemoError("viewer denial exposed a bound household value") + _print_query(label, response) def wait_http(url: str, timeout_seconds: float) -> None: @@ -554,8 +676,11 @@ def parser() -> argparse.ArgumentParser: runtime_parser.add_argument("--revision", required=True) seed_parser = commands.add_parser("seed") seed_parser.add_argument("--root", required=True, type=Path) + viewer_parser = commands.add_parser("configure-viewer") + viewer_parser.add_argument("--root", required=True, type=Path) query_parser = commands.add_parser("query") query_parser.add_argument("--root", required=True, type=Path) + query_parser.add_argument("--suite", choices=("all", "operator", "viewer"), default="all") wait_parser = commands.add_parser("wait-http") wait_parser.add_argument("--url", required=True) wait_parser.add_argument("--timeout", type=float, default=30.0) @@ -575,8 +700,10 @@ def main() -> int: render_runtime(args.root, args.revision) elif args.command == "seed": seed(args.root) + elif args.command == "configure-viewer": + configure_viewer(args.root) elif args.command == "query": - query(args.root) + query(args.root, args.suite) elif args.command == "wait-http": wait_http(args.url, args.timeout) elif args.command == "store-token": diff --git a/products/registry-server/demo/support/test_demo.py b/products/registry-server/demo/support/test_demo.py index f65b853fec..49a4c2963b 100755 --- a/products/registry-server/demo/support/test_demo.py +++ b/products/registry-server/demo/support/test_demo.py @@ -5,10 +5,12 @@ import importlib.util import json import os +import re import sys import tempfile import unittest from pathlib import Path +from unittest import mock MODULE_PATH = Path(__file__).with_name("demo.py") @@ -40,7 +42,7 @@ def setUp(self) -> None: password.write_text("a" * 48, encoding="ascii") password.chmod(0o600) (self.root / "keys").mkdir() - for name in ("mint", "operator", "no-purpose"): + for name in ("mint", "operator", "no-purpose", "viewer"): (self.root / f"keys/{name}-public.jwk.json").write_text( json.dumps(public_jwk(f"{name}-key")), encoding="utf-8" ) @@ -64,10 +66,10 @@ def test_prepare_binds_mint_authority_static_jwks_and_secret_database_urls(self) self.assertIn("algorithms: [ES256]", mint) self.assertNotIn("database-password", mint) operator = (self.root / "mint/clients/household-demo.yaml").read_text(encoding="utf-8") - self.assertIn("registry_principal: synthetic-household-operator", operator) - self.assertIn("registry_purpose: household-administration", operator) + self.assertIn('registry_principal: "synthetic-household-operator"', operator) + self.assertIn('registry_purpose: "household-administration"', operator) no_purpose = (self.root / "mint/clients/household-demo-no-purpose.yaml").read_text(encoding="utf-8") - self.assertIn("registry_principal: synthetic-household-operator", no_purpose) + self.assertIn('registry_principal: "synthetic-household-operator"', no_purpose) self.assertNotIn("registry_purpose", no_purpose) runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") @@ -76,6 +78,7 @@ def test_prepare_binds_mint_authority_static_jwks_and_secret_database_urls(self) self.assertIn("documentRef: secret:file/mint-jwks", runtime) self.assertIn("principal: registry_principal", runtime) self.assertIn("purpose: registry_purpose", runtime) + self.assertIn("household-demo-viewer", runtime) self.assertNotIn("a" * 48, runtime) self.assertEqual( json.loads((self.root / "secrets/mint-jwks").read_text(encoding="utf-8"))["keys"][0]["kid"], @@ -89,6 +92,17 @@ def test_prepare_binds_mint_authority_static_jwks_and_secret_database_urls(self) "mint-jwks", ): self.assertEqual(os.stat(self.root / f"secrets/{name}").st_mode & 0o077, 0) + database_setup = (self.root / "database/initialize.sql").read_text( + encoding="utf-8" + ) + for schema in ( + "registry_internal", + "registry_data", + "registry_source", + "registry_derived", + "registry_context", + ): + self.assertIn(f"CREATE SCHEMA {schema}", database_setup) def test_render_runtime_selects_exact_package_and_listener(self) -> None: DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) @@ -99,6 +113,32 @@ def test_render_runtime_selects_exact_package_and_listener(self) -> None: self.assertIn(f"root: {self.root.resolve() / 'build/package'}", runtime) self.assertIn("bind: 127.0.0.1:18080", runtime) + def test_schema_test_credentials_cover_every_packaged_journey_step(self) -> None: + DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) + journey_source = (self.root / "project/tests/journeys.yaml").read_text( + encoding="utf-8" + ) + credential_source = (self.root / "schema-test-credentials.yaml").read_text( + encoding="utf-8" + ) + journey_id = next( + line.removeprefix(" - id: ").strip() + for line in journey_source.splitlines() + if line.startswith(" - id: ") + ) + expected = { + (journey_id, line.removeprefix(" - id: ").strip()) + for line in journey_source.splitlines() + if line.startswith(" - id: ") + } + actual = set( + re.findall( + r"journeyId: ([a-z0-9-]+), stepId: ([a-z0-9-]+)", + credential_source, + ) + ) + self.assertEqual(actual, expected) + def test_seed_is_referentially_closed_and_stable(self) -> None: people, households, memberships = DEMO.seed_spec() person_codes = {person["person-code"] for person in people} @@ -121,6 +161,123 @@ def test_seed_is_referentially_closed_and_stable(self) -> None: 8, ) + def test_viewer_registration_is_created_only_after_a_household_id_is_known(self) -> None: + DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) + household_id = "0198f0f5-0877-7ae2-a853-09f2d47b6840" + (self.root / "seed-record-ids.json").write_text( + json.dumps( + { + "people": {}, + "households": {"HOUSEHOLD-DEMO-001": household_id}, + } + ), + encoding="utf-8", + ) + + DEMO.configure_viewer(self.root) + + viewer = (self.root / "mint/clients/household-demo-viewer.yaml").read_text( + encoding="utf-8" + ) + self.assertIn('scopes: ["registry:household:view"]', viewer) + self.assertIn(f'household_id: "{household_id}"', viewer) + self.assertIn('household_code: "HOUSEHOLD-DEMO-001"', viewer) + self.assertIn('registry_purpose: "household-view"', viewer) + self.assertIn("viewer-key", viewer) + self.assertNotIn("signing-p256-private-jwk", viewer) + + def test_viewer_registration_refuses_a_non_uuid_bound_record(self) -> None: + DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) + (self.root / "seed-record-ids.json").write_text( + json.dumps({"households": {"HOUSEHOLD-DEMO-001": "not-a-record-id"}}), + encoding="utf-8", + ) + + with self.assertRaisesRegex(DEMO.DemoError, "not a UUID"): + DEMO.configure_viewer(self.root) + + def test_viewer_queries_prove_bound_get_claim_lookup_and_concealed_denials(self) -> None: + household_id = "0198f0f5-0877-7ae2-a853-09f2d47b6840" + (self.root / "seed-record-ids.json").write_text( + json.dumps({"households": {"HOUSEHOLD-DEMO-001": household_id}}), + encoding="utf-8", + ) + calls: list[tuple[str, str, str, object, object]] = [] + + def request( + root: Path, + method: str, + path: str, + token_name: str, + body: dict[str, object] | None = None, + idempotency_key: str | None = None, + expected: int = 200, + ) -> tuple[dict[str, object], dict[str, str]]: + self.assertEqual(root, self.root.resolve()) + calls.append((method, path, token_name, body, expected)) + if expected == 404: + return {"code": "resource.not_found"}, {} + return { + "id": household_id, + "revision": 1, + "data": {"household-code": "HOUSEHOLD-DEMO-001"}, + }, {} + + with mock.patch.object(DEMO, "_request", side_effect=request), mock.patch.object( + DEMO, "_print_query" + ): + DEMO.query(self.root, "viewer") + + self.assertEqual(len(calls), 4) + self.assertEqual(calls[0][0:3], ("GET", f"/v1/records/households/{household_id}?accessProfile=household-viewer", "viewer-token")) + self.assertEqual(calls[1][0:3], ("POST", "/v1/records/households:lookup?accessProfile=household-viewer", "viewer-token")) + self.assertEqual(calls[1][3], {"selector": "by-household-code"}) + self.assertTrue(all(call[2] == "viewer-token" for call in calls)) + self.assertEqual([call[4] for call in calls], [200, 200, 404, 404]) + + def test_operator_selector_query_uses_the_exact_values_property(self) -> None: + household_id = "0198f0f5-0877-7ae2-a853-09f2d47b6840" + (self.root / "seed-record-ids.json").write_text( + json.dumps({"households": {"HOUSEHOLD-DEMO-001": household_id}}), + encoding="utf-8", + ) + calls: list[tuple[str, str, object]] = [] + + def request( + root: Path, + method: str, + path: str, + token_name: str, + body: dict[str, object] | None = None, + idempotency_key: str | None = None, + expected: int = 200, + ) -> tuple[dict[str, object], dict[str, str]]: + calls.append((method, path, body)) + if method == "POST": + return { + "id": household_id, + "revision": 1, + "data": {"household-code": "HOUSEHOLD-DEMO-001"}, + }, {} + return {"items": []}, {} + + with mock.patch.object(DEMO, "_request", side_effect=request), mock.patch.object( + DEMO, "_print_query" + ): + DEMO.query(self.root, "operator") + + self.assertEqual(calls[-1][0:2], ("POST", "/v1/records/households:lookup?accessProfile=household-operator")) + self.assertEqual( + calls[-1][2], + { + "selector": "by-local-reference", + "values": { + "administrative-area": "north-demo", + "local-household-number": 1001, + }, + }, + ) + def test_prepare_refuses_a_fixture_without_the_expected_localization_boundary(self) -> None: bad_fixture = Path(self.temporary.name) / "bad-fixture" bad_fixture.mkdir() diff --git a/products/registry-server/generated/asset-site-placement/generated/metadata/registry.json b/products/registry-server/generated/asset-site-placement/generated/metadata/registry.json index d6324975e4..e9c0c2f47b 100644 --- a/products/registry-server/generated/asset-site-placement/generated/metadata/registry.json +++ b/products/registry-server/generated/asset-site-placement/generated/metadata/registry.json @@ -1 +1 @@ -{"entities":[{"entries":[{"accessProfile":"asset-operator","operation":"batch","readableFields":["asset-class","asset-code","label"],"routeId":"records.asset-item.batch"},{"accessProfile":"asset-operator","operation":"create","readableFields":["asset-class","asset-code","label"],"routeId":"records.asset-item.create"},{"accessProfile":"asset-operator","operation":"get","readableFields":["asset-class","asset-code","label"],"routeId":"records.asset-item.get"},{"accessProfile":"site-planner","operation":"get","readableFields":["asset-code","label"],"routeId":"records.asset-item.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset-class","asset-code","label"],"routeId":"records.asset-item.list"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset-code","label"],"routeId":"records.asset-item.list"},{"accessProfile":"asset-operator","operation":"patch","readableFields":["asset-class","asset-code","label"],"routeId":"records.asset-item.patch"}],"id":"asset-item","route":"assets","schemaPath":"/v1/schemas/asset-item"},{"entries":[{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.as-of"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.as-of"},{"accessProfile":"asset-operator","operation":"create","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.create"},{"accessProfile":"site-planner","operation":"create","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.create"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.current"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.current"},{"accessProfile":"asset-operator","operation":"get","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.get"},{"accessProfile":"site-planner","operation":"get","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.list"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.list"},{"accessProfile":"asset-operator","operation":"patch","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.patch"},{"accessProfile":"site-planner","operation":"patch","readableFields":["asset","site","valid-from","valid-to"],"routeId":"records.asset-placement.patch"}],"id":"asset-placement","route":"placements","schemaPath":"/v1/schemas/asset-placement"},{"entries":[{"accessProfile":"asset-operator","operation":"create","readableFields":["label","site-code"],"routeId":"records.asset-site.create"},{"accessProfile":"asset-operator","operation":"get","readableFields":["label","site-code"],"routeId":"records.asset-site.get"},{"accessProfile":"site-planner","operation":"get","readableFields":["label","site-code"],"routeId":"records.asset-site.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["label","site-code"],"routeId":"records.asset-site.list"},{"accessProfile":"site-planner","operation":"list","readableFields":["label","site-code"],"routeId":"records.asset-site.list"},{"accessProfile":"asset-operator","operation":"patch","readableFields":["label","site-code"],"routeId":"records.asset-site.patch"}],"id":"asset-site","route":"sites","schemaPath":"/v1/schemas/asset-site"},{"entries":[{"accessProfile":"asset-operator","operation":"create","readableFields":["asset","observed-at","result"],"routeId":"records.inspection-event.create"},{"accessProfile":"asset-operator","operation":"get","readableFields":["asset","observed-at","result"],"routeId":"records.inspection-event.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","observed-at","result"],"routeId":"records.inspection-event.list"}],"id":"inspection-event","route":"inspections","schemaPath":"/v1/schemas/inspection-event"}],"registryId":"asset-site-placement","version":"0.1.0"} \ No newline at end of file +{"entities":[{"entries":[{"accessProfile":"asset-operator","operation":"batch","readableFields":["asset-class","asset-code","label"],"responseEntityId":"asset-item","routeId":"records.asset-item.batch"},{"accessProfile":"asset-operator","operation":"create","readableFields":["asset-class","asset-code","label"],"responseEntityId":"asset-item","routeId":"records.asset-item.create"},{"accessProfile":"asset-operator","operation":"get","readableFields":["asset-class","asset-code","label"],"responseEntityId":"asset-item","routeId":"records.asset-item.get"},{"accessProfile":"site-planner","operation":"get","readableFields":["asset-code","label"],"responseEntityId":"asset-item","routeId":"records.asset-item.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset-class","asset-code","label"],"responseEntityId":"asset-item","routeId":"records.asset-item.list"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset-code","label"],"responseEntityId":"asset-item","routeId":"records.asset-item.list"},{"accessProfile":"asset-operator","operation":"patch","readableFields":["asset-class","asset-code","label"],"responseEntityId":"asset-item","routeId":"records.asset-item.patch"}],"id":"asset-item","route":"assets","schemaPath":"/v1/schemas/asset-item"},{"entries":[{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.as-of"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.as-of"},{"accessProfile":"asset-operator","operation":"create","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.create"},{"accessProfile":"site-planner","operation":"create","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.create"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.current"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.current"},{"accessProfile":"asset-operator","operation":"get","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.get"},{"accessProfile":"site-planner","operation":"get","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.list"},{"accessProfile":"site-planner","operation":"list","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.list"},{"accessProfile":"asset-operator","operation":"patch","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.patch"},{"accessProfile":"site-planner","operation":"patch","readableFields":["asset","site","valid-from","valid-to"],"responseEntityId":"asset-placement","routeId":"records.asset-placement.patch"}],"id":"asset-placement","route":"placements","schemaPath":"/v1/schemas/asset-placement"},{"entries":[{"accessProfile":"asset-operator","operation":"create","readableFields":["label","site-code"],"responseEntityId":"asset-site","routeId":"records.asset-site.create"},{"accessProfile":"asset-operator","operation":"get","readableFields":["label","site-code"],"responseEntityId":"asset-site","routeId":"records.asset-site.get"},{"accessProfile":"site-planner","operation":"get","readableFields":["label","site-code"],"responseEntityId":"asset-site","routeId":"records.asset-site.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["label","site-code"],"responseEntityId":"asset-site","routeId":"records.asset-site.list"},{"accessProfile":"site-planner","operation":"list","readableFields":["label","site-code"],"responseEntityId":"asset-site","routeId":"records.asset-site.list"},{"accessProfile":"asset-operator","operation":"patch","readableFields":["label","site-code"],"responseEntityId":"asset-site","routeId":"records.asset-site.patch"}],"id":"asset-site","route":"sites","schemaPath":"/v1/schemas/asset-site"},{"entries":[{"accessProfile":"asset-operator","operation":"create","readableFields":["asset","observed-at","result"],"responseEntityId":"inspection-event","routeId":"records.inspection-event.create"},{"accessProfile":"asset-operator","operation":"get","readableFields":["asset","observed-at","result"],"responseEntityId":"inspection-event","routeId":"records.inspection-event.get"},{"accessProfile":"asset-operator","operation":"list","readableFields":["asset","observed-at","result"],"responseEntityId":"inspection-event","routeId":"records.inspection-event.list"}],"id":"inspection-event","route":"inspections","schemaPath":"/v1/schemas/inspection-event"}],"registryId":"asset-site-placement","version":"0.1.0"} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/metadata/registry.json b/products/registry-server/generated/publicschema-household/generated/metadata/registry.json index 3c0fdf27ba..12fae17609 100644 --- a/products/registry-server/generated/publicschema-household/generated/metadata/registry.json +++ b/products/registry-server/generated/publicschema-household/generated/metadata/registry.json @@ -1 +1 @@ -{"entities":[{"entries":[{"accessProfile":"household-operator","operation":"list","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.as-of"},{"accessProfile":"household-operator","operation":"create","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.create"},{"accessProfile":"household-operator","operation":"list","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.current"},{"accessProfile":"household-operator","operation":"get","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.list"},{"accessProfile":"household-operator","operation":"patch","readableFields":["household","person","relationship","valid-from","valid-to"],"routeId":"records.group-membership.patch"}],"id":"group-membership","route":"group-memberships","schemaPath":"/v1/schemas/group-membership"},{"entries":[{"accessProfile":"household-operator","operation":"create","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.create"},{"accessProfile":"household-operator","operation":"get","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.get"},{"accessProfile":"household-viewer","operation":"get","readableFields":["administrative-area","household-code","household-name","household-type","local-household-number"],"routeId":"records.household.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.list"},{"accessProfile":"household-operator","operation":"lookup","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.lookup"},{"accessProfile":"household-viewer","operation":"lookup","readableFields":["administrative-area","household-code","household-name","household-type","local-household-number"],"routeId":"records.household.lookup"},{"accessProfile":"household-operator","operation":"patch","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.patch"},{"accessProfile":"household-operator","operation":"list","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"routeId":"records.household.path.people"}],"id":"household","route":"households","schemaPath":"/v1/schemas/household"},{"entries":[{"accessProfile":"household-operator","operation":"create","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"routeId":"records.person.create"},{"accessProfile":"household-operator","operation":"get","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"routeId":"records.person.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"routeId":"records.person.list"},{"accessProfile":"household-operator","operation":"patch","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"routeId":"records.person.patch"}],"id":"person","route":"persons","schemaPath":"/v1/schemas/person"}],"registryId":"publicschema-household","version":"0.1.0"} \ No newline at end of file +{"entities":[{"entries":[{"accessProfile":"household-operator","operation":"list","readableFields":["household","person","relationship","valid-from","valid-to"],"responseEntityId":"group-membership","routeId":"records.group-membership.as-of"},{"accessProfile":"household-operator","operation":"create","readableFields":["household","person","relationship","valid-from","valid-to"],"responseEntityId":"group-membership","routeId":"records.group-membership.create"},{"accessProfile":"household-operator","operation":"list","readableFields":["household","person","relationship","valid-from","valid-to"],"responseEntityId":"group-membership","routeId":"records.group-membership.current"},{"accessProfile":"household-operator","operation":"get","readableFields":["household","person","relationship","valid-from","valid-to"],"responseEntityId":"group-membership","routeId":"records.group-membership.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["household","person","relationship","valid-from","valid-to"],"responseEntityId":"group-membership","routeId":"records.group-membership.list"},{"accessProfile":"household-operator","operation":"patch","readableFields":["household","person","relationship","valid-from","valid-to"],"responseEntityId":"group-membership","routeId":"records.group-membership.patch"}],"id":"group-membership","route":"group-memberships","schemaPath":"/v1/schemas/group-membership"},{"entries":[{"accessProfile":"household-operator","operation":"create","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"responseEntityId":"household","routeId":"records.household.create"},{"accessProfile":"household-operator","operation":"get","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"responseEntityId":"household","routeId":"records.household.get"},{"accessProfile":"household-viewer","operation":"get","readableFields":["administrative-area","household-code","household-name","household-type","local-household-number"],"responseEntityId":"household","routeId":"records.household.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"responseEntityId":"household","routeId":"records.household.list"},{"accessProfile":"household-operator","operation":"lookup","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"responseEntityId":"household","routeId":"records.household.lookup"},{"accessProfile":"household-viewer","operation":"lookup","readableFields":["administrative-area","household-code","household-name","household-type","local-household-number"],"responseEntityId":"household","routeId":"records.household.lookup"},{"accessProfile":"household-operator","operation":"patch","readableFields":["administrative-area","child-count","child-under-5-count","elderly-count","head-count","household-code","household-name","household-type","local-household-number","single-headed","woman-headed"],"responseEntityId":"household","routeId":"records.household.patch"},{"accessProfile":"household-operator","operation":"list","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","residency-status"],"responseEntityId":"person","routeId":"records.household.path.people"}],"id":"household","route":"households","schemaPath":"/v1/schemas/household"},{"entries":[{"accessProfile":"household-operator","operation":"create","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"responseEntityId":"person","routeId":"records.person.create"},{"accessProfile":"household-operator","operation":"get","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"responseEntityId":"person","routeId":"records.person.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"responseEntityId":"person","routeId":"records.person.list"},{"accessProfile":"household-operator","operation":"patch","readableFields":["date-of-birth","family-name","legal-name","person-code","person-sex","preferred-language","residency-status"],"responseEntityId":"person","routeId":"records.person.patch"}],"id":"person","route":"persons","schemaPath":"/v1/schemas/person"}],"registryId":"publicschema-household","version":"0.1.0"} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/openapi.json b/products/registry-server/generated/publicschema-household/generated/openapi.json index 6eb2545097..dc82dc3a2b 100644 --- a/products/registry-server/generated/publicschema-household/generated/openapi.json +++ b/products/registry-server/generated/publicschema-household/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"group-membership":{"$id":"urn:registry-server:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["household","person","relationship","valid-from"],"type":"object","x-registry-mutationMode":"mutable"},"household":{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrative-area":{"maxLength":80,"minLength":0,"type":"string"},"household-code":{"maxLength":64,"minLength":0,"type":"string"},"household-name":{"maxLength":160,"minLength":0,"type":"string"},"household-type":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"local-household-number":{"format":"int64","type":"integer"}},"required":["administrative-area","household-code","household-name","household-type","local-household-number"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:registry-server:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"date-of-birth":{"format":"date","type":"string"},"family-name":{"maxLength":120,"minLength":0,"type":"string"},"legal-name":{"maxLength":160,"minLength":0,"type":"string"},"person-code":{"maxLength":64,"minLength":0,"type":"string"},"person-sex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferred-language":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residency-status":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["legal-name","person-code","person-sex","residency-status"],"type":"object","x-registry-mutationMode":"mutable"}}},"info":{"title":"publicschema-household","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.group-membership.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"create"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get"},"patch":{"operationId":"records.group-membership.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"patch"}},"/v1/records/group-memberships:as-of":{"get":{"operationId":"records.group-membership.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/group-memberships:current":{"get":{"operationId":"records.group-membership.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/households":{"get":{"operationId":"records.household.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.household.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"get"},"patch":{"operationId":"records.household.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"patch"}},"/v1/records/households/{record_id}/people":{"get":{"operationId":"records.household.path.people","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"}},"/v1/records/households:lookup":{"post":{"operationId":"records.household.lookup","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"lookup"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.person.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"create"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get"},"patch":{"operationId":"records.person.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"patch"}}}} \ No newline at end of file +{"components":{"schemas":{"group-membership":{"$id":"urn:registry-server:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["household","person","relationship","valid-from"],"type":"object","x-registry-mutationMode":"mutable"},"household":{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrative-area":{"maxLength":80,"minLength":0,"type":"string"},"child-count":{"format":"int64","readOnly":true,"type":"integer"},"child-under-5-count":{"format":"int64","readOnly":true,"type":"integer"},"elderly-count":{"format":"int64","readOnly":true,"type":"integer"},"head-count":{"format":"int64","readOnly":true,"type":"integer"},"household-code":{"maxLength":64,"minLength":0,"type":"string"},"household-name":{"maxLength":160,"minLength":0,"type":"string"},"household-type":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"local-household-number":{"format":"int64","type":"integer"},"single-headed":{"readOnly":true,"type":"boolean"},"woman-headed":{"readOnly":true,"type":"boolean"}},"required":["administrative-area","household-code","household-name","household-type","local-household-number"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:registry-server:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"date-of-birth":{"format":"date","type":"string"},"family-name":{"maxLength":120,"minLength":0,"type":"string"},"legal-name":{"maxLength":160,"minLength":0,"type":"string"},"person-code":{"maxLength":64,"minLength":0,"type":"string"},"person-sex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferred-language":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residency-status":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["legal-name","person-code","person-sex","residency-status"],"type":"object","x-registry-mutationMode":"mutable"}}},"info":{"title":"publicschema-household","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.group-membership.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"create"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get"},"patch":{"operationId":"records.group-membership.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"patch"}},"/v1/records/group-memberships:as-of":{"get":{"operationId":"records.group-membership.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/group-memberships:current":{"get":{"operationId":"records.group-membership.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/households":{"get":{"operationId":"records.household.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.household.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"get"},"patch":{"operationId":"records.household.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"patch"}},"/v1/records/households/{record_id}/people":{"get":{"operationId":"records.household.path.people","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"}},"/v1/records/households:lookup":{"post":{"operationId":"records.household.lookup","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"lookup"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.person.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"create"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get"},"patch":{"operationId":"records.person.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"patch"}}}} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql index eb20ea5886..fd5dff45b2 100644 --- a/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql +++ b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql @@ -44,13 +44,35 @@ ALTER TABLE registry_data."rs_e_household_45e8576d356a1f75" FORCE ROW LEVEL SECU CREATE POLICY "registry_rls_select_f2348f1ea686c085c254174f" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_insert_a49ea44589b3bfd7550c1880" ON registry_data."rs_e_household_45e8576d356a1f75" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_update_b573105c01cf4eb57ca69c80" ON registry_data."rs_e_household_45e8576d356a1f75" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE POLICY "registry_rls_select_bf0afc959f020c9105952b7d" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-viewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-view') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'id' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "id" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::uuid) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_select_bf0afc959f020c9105952b7d" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-viewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-view') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'id' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND record_id = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::uuid) AND record_lifecycle = 'active'); CREATE POLICY "registry_path_rls_select_c8cb6ccd10712973c3e46aaf" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND NULLIF(current_setting('registry.read_path_id', true), '') = 'people' AND record_id = NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid AND record_lifecycle = 'active'); CREATE VIEW registry_source."household" WITH (security_invoker=true, security_barrier=true) AS SELECT record_id AS id, "rs_f_household_household_code_44029c0143d71ab3" AS "household_code", "rs_f_household_local_household_number_040305e8ef37727d" AS "local_household_number", "rs_f_household_household_name_aeac0ac6071a6b3d" AS "household_name", "rs_f_household_administrative_area_1946b433a9241a87" AS "administrative_area", "rs_f_household_household_type_87fa3a1f7183bbe0" AS "household_type" FROM registry_data."rs_e_household_45e8576d356a1f75" WHERE record_lifecycle = 'active'; +ALTER TABLE registry_data."rs_e_person_a28225974420754a" ENABLE ROW LEVEL SECURITY; +ALTER TABLE registry_data."rs_e_person_a28225974420754a" FORCE ROW LEVEL SECURITY; +CREATE POLICY "registry_rls_select_799283181617a6fa58c49141" ON registry_data."rs_e_person_a28225974420754a" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_insert_4439eb1ffa28a9c2175ef14d" ON registry_data."rs_e_person_a28225974420754a" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_update_1c8bc48adff601b70fae3086" ON registry_data."rs_e_person_a28225974420754a" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE POLICY "registry_path_rls_select_b32737d82dfcb2dfac05ef5c" ON registry_data."rs_e_person_a28225974420754a" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration')) AND NULLIF(current_setting('registry.read_path_id', true), '') = 'people' AND record_lifecycle = 'active' + AND EXISTS ( + SELECT 1 + FROM registry_data."rs_e_group_membership_6b97f4204f141f28" AS path_edge + JOIN registry_data."rs_e_household_45e8576d356a1f75" AS path_source + ON path_source.record_id = path_edge."rs_f_group_membership_household_9ce011eef65483bd" + WHERE path_edge."rs_f_group_membership_person_f16f370962050e27" = "rs_e_person_a28225974420754a".record_id + AND path_edge."rs_f_group_membership_household_9ce011eef65483bd" = NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid + AND path_edge.record_lifecycle = 'active' + AND path_source.record_lifecycle = 'active' + AND (NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) + )); +CREATE VIEW registry_source."person" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS id, "rs_f_person_person_code_7514464caf72c5a7" AS "person_code", "rs_f_person_legal_name_142f648a19dcd2a4" AS "legal_name", "rs_f_person_family_name_1a6b1252713201d7" AS "family_name", "rs_f_person_date_of_birth_d4d8fa151f4a4285" AS "date_of_birth", "rs_f_person_person_sex_01e02174128c75d2" AS "person_sex", "rs_f_person_residency_status_19ed35302430c5ac" AS "residency_status", "rs_f_person_preferred_language_d36dc5f1bd7bec3c" AS "preferred_language" + FROM registry_data."rs_e_person_a28225974420754a" + WHERE record_lifecycle = 'active'; CREATE VIEW registry_derived."household__household_demographics" WITH (security_invoker=true, security_barrier=true) AS SELECT "id"::uuid AS "id", "head_count"::bigint AS "head_count", "child_count"::bigint AS "child_count", "child_under_5_count"::bigint AS "child_under_5_count", "elderly_count"::bigint AS "elderly_count", "single_headed"::boolean AS "single_headed", "woman_headed"::boolean AS "woman_headed" @@ -103,25 +125,3 @@ LEFT JOIN registry_source.group_membership gm LEFT JOIN registry_source.person p ON p.id = gm.person GROUP BY h.id) AS trusted_derived; -ALTER TABLE registry_data."rs_e_person_a28225974420754a" ENABLE ROW LEVEL SECURITY; -ALTER TABLE registry_data."rs_e_person_a28225974420754a" FORCE ROW LEVEL SECURITY; -CREATE POLICY "registry_rls_select_799283181617a6fa58c49141" ON registry_data."rs_e_person_a28225974420754a" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE POLICY "registry_rls_insert_4439eb1ffa28a9c2175ef14d" ON registry_data."rs_e_person_a28225974420754a" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE POLICY "registry_rls_update_1c8bc48adff601b70fae3086" ON registry_data."rs_e_person_a28225974420754a" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE POLICY "registry_path_rls_select_b32737d82dfcb2dfac05ef5c" ON registry_data."rs_e_person_a28225974420754a" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration')) AND NULLIF(current_setting('registry.read_path_id', true), '') = 'people' AND record_lifecycle = 'active' - AND EXISTS ( - SELECT 1 - FROM registry_data."rs_e_group_membership_6b97f4204f141f28" AS path_edge - JOIN registry_data."rs_e_household_45e8576d356a1f75" AS path_source - ON path_source.record_id = path_edge."rs_f_group_membership_household_9ce011eef65483bd" - WHERE path_edge."rs_f_group_membership_person_f16f370962050e27" = record_id - AND path_edge."rs_f_group_membership_household_9ce011eef65483bd" = NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid - AND path_edge.record_lifecycle = 'active' - AND path_source.record_lifecycle = 'active' - AND (NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) - )); -CREATE VIEW registry_source."person" - WITH (security_invoker=true, security_barrier=true) - AS SELECT record_id AS id, "rs_f_person_person_code_7514464caf72c5a7" AS "person_code", "rs_f_person_legal_name_142f648a19dcd2a4" AS "legal_name", "rs_f_person_family_name_1a6b1252713201d7" AS "family_name", "rs_f_person_date_of_birth_d4d8fa151f4a4285" AS "date_of_birth", "rs_f_person_person_sex_01e02174128c75d2" AS "person_sex", "rs_f_person_residency_status_19ed35302430c5ac" AS "residency_status", "rs_f_person_preferred_language_d36dc5f1bd7bec3c" AS "preferred_language" - FROM registry_data."rs_e_person_a28225974420754a" - WHERE record_lifecycle = 'active'; diff --git a/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json b/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json index e017b738d6..34d8c7c813 100644 --- a/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json +++ b/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json @@ -1 +1 @@ -{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrative-area":{"maxLength":80,"minLength":0,"type":"string"},"household-code":{"maxLength":64,"minLength":0,"type":"string"},"household-name":{"maxLength":160,"minLength":0,"type":"string"},"household-type":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"local-household-number":{"format":"int64","type":"integer"}},"required":["administrative-area","household-code","household-name","household-type","local-household-number"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrative-area":{"maxLength":80,"minLength":0,"type":"string"},"child-count":{"format":"int64","readOnly":true,"type":"integer"},"child-under-5-count":{"format":"int64","readOnly":true,"type":"integer"},"elderly-count":{"format":"int64","readOnly":true,"type":"integer"},"head-count":{"format":"int64","readOnly":true,"type":"integer"},"household-code":{"maxLength":64,"minLength":0,"type":"string"},"household-name":{"maxLength":160,"minLength":0,"type":"string"},"household-type":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"local-household-number":{"format":"int64","type":"integer"},"single-headed":{"readOnly":true,"type":"boolean"},"woman-headed":{"readOnly":true,"type":"boolean"}},"required":["administrative-area","household-code","household-name","household-type","local-household-number"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file From 681656d30417a3cc31ea0cf103361d5e1df78413 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 30 Aug 2026 19:14:08 +0700 Subject: [PATCH 07/19] fix(server): enforce derived relation cardinality Signed-off-by: Jeremi Joslin --- crates/registry-server/src/generated_ddl.rs | 14 ++++- .../tests/compiler_contract.rs | 3 + .../tests/postgres_compiled_schema.rs | 57 ++++++++++++++++++- .../generated/postgres/schema.sql | 12 +++- 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/crates/registry-server/src/generated_ddl.rs b/crates/registry-server/src/generated_ddl.rs index bafa86568d..4c13fc6d4a 100644 --- a/crates/registry-server/src/generated_ddl.rs +++ b/crates/registry-server/src/generated_ddl.rs @@ -331,9 +331,11 @@ pub(crate) fn generate_ddl( .expect("derived SQL asset was UTF-8 validated") .trim() .trim_end_matches(';'); + let key = quote_identifier(&relation.key_field.replace('-', "_")); + let cardinality = quote_identifier("registry_derived_key_cardinality"); let mut columns = vec![format!( "{}::{} AS {}", - quote_identifier(&relation.key_field.replace('-', "_")), + key, sql_type(&entity.canonical_id.field_type), quote_identifier(&entity.canonical_id.sql_name) )]; @@ -356,7 +358,15 @@ pub(crate) fn generate_ddl( "CREATE VIEW registry_derived.{view} WITH (security_invoker=true, security_barrier=true) AS SELECT {} - FROM ({sql}) AS trusted_derived", + FROM ( + SELECT trusted_derived.*, + count(*) OVER (PARTITION BY trusted_derived.{key}) AS {cardinality} + FROM ({sql}) AS trusted_derived + ) AS checked_derived + WHERE CASE + WHEN {cardinality} = 1 THEN true + ELSE 1 / ({cardinality} - {cardinality}) = 0 + END", columns.join(", "), ), }); diff --git a/crates/registry-server/tests/compiler_contract.rs b/crates/registry-server/tests/compiler_contract.rs index d0beab6695..60a35fc1af 100644 --- a/crates/registry-server/tests/compiler_contract.rs +++ b/crates/registry-server/tests/compiler_contract.rs @@ -167,6 +167,9 @@ fn derived_fields_selectors_and_read_paths_compile_to_route_specific_inventories last_source_view < first_derived_view, "all source views must exist before cross-entity derived SQL is installed" ); + let derived_view = &compiled.ddl().statements[first_derived_view].sql; + assert!(derived_view.contains("count(*) OVER (PARTITION BY trusted_derived.\"id\")")); + assert!(derived_view.contains("registry_derived_key_cardinality")); assert!(compiled .routes() .routes diff --git a/crates/registry-server/tests/postgres_compiled_schema.rs b/crates/registry-server/tests/postgres_compiled_schema.rs index b2a534453a..1b5a0a8437 100644 --- a/crates/registry-server/tests/postgres_compiled_schema.rs +++ b/crates/registry-server/tests/postgres_compiled_schema.rs @@ -472,6 +472,61 @@ async fn install_derived_view_fixture() { .is_err()); transaction.rollback().await.expect("proof rolls back"); + let duplicate_transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &claims, + ) + .await + .expect("duplicate-key seed transaction starts"); + duplicate_transaction + .transaction_for_test() + .execute( + &format!( + "INSERT INTO registry_data.{table} (record_id, {tenant}, {size}) + VALUES ('00000000-0000-4000-8000-000000000302', 'north', 4)" + ), + &[], + ) + .await + .expect("second matching source row is accepted"); + duplicate_transaction + .commit() + .await + .expect("duplicate-key seed commits"); + + let duplicate_read = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &claims, + ) + .await + .expect("duplicate-key read transaction starts"); + duplicate_read + .transaction_for_test() + .execute( + "SELECT set_config('registry.evaluation_date', '2026-08-30', true)", + &[], + ) + .await + .expect("duplicate-key proof installs explicit evaluation date"); + assert!(duplicate_read + .transaction_for_test() + .query( + &format!("SELECT * FROM registry_derived.{derived_view}"), + &[] + ) + .await + .is_err()); + duplicate_read + .rollback() + .await + .expect("duplicate-key refusal rolls back"); + let denied_claims = ClaimContext::for_compiled( ®istry, "household", @@ -941,7 +996,7 @@ fn derived_registry() -> registry_server::CompiledRegistry { &[ModuleAssetSource { module: None, path: "sql/facts.sql".to_owned(), - bytes: b"SELECT h.id AS id, h.size AS child_count, registry_context.evaluation_date() AS observed_on FROM registry_source.household h".to_vec(), + bytes: b"SELECT h.id AS id, h.size AS child_count, registry_context.evaluation_date() AS observed_on FROM registry_source.household h JOIN registry_source.household sibling ON sibling.tenant = h.tenant".to_vec(), }], CompileProfile::Authoring, ) diff --git a/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql index fd5dff45b2..6a793715fb 100644 --- a/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql +++ b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql @@ -76,7 +76,10 @@ CREATE VIEW registry_source."person" CREATE VIEW registry_derived."household__household_demographics" WITH (security_invoker=true, security_barrier=true) AS SELECT "id"::uuid AS "id", "head_count"::bigint AS "head_count", "child_count"::bigint AS "child_count", "child_under_5_count"::bigint AS "child_under_5_count", "elderly_count"::bigint AS "elderly_count", "single_headed"::boolean AS "single_headed", "woman_headed"::boolean AS "woman_headed" - FROM (SELECT + FROM ( + SELECT trusted_derived.*, + count(*) OVER (PARTITION BY trusted_derived."id") AS "registry_derived_key_cardinality" + FROM (SELECT h.id AS id, count(*) FILTER ( WHERE gm.relationship = 'head' @@ -124,4 +127,9 @@ LEFT JOIN registry_source.group_membership gm ON gm.household = h.id LEFT JOIN registry_source.person p ON p.id = gm.person -GROUP BY h.id) AS trusted_derived; +GROUP BY h.id) AS trusted_derived + ) AS checked_derived + WHERE CASE + WHEN "registry_derived_key_cardinality" = 1 THEN true + ELSE 1 / ("registry_derived_key_cardinality" - "registry_derived_key_cardinality") = 0 + END; From 828128622d0645aaa32ccb61222fff1deedf23b8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 30 Aug 2026 19:24:54 +0700 Subject: [PATCH 08/19] fix(server): canonicalize derived relation keys Signed-off-by: Jeremi Joslin --- crates/registry-server/src/generated_ddl.rs | 26 +++++++---- .../tests/compiler_contract.rs | 11 +++-- .../tests/postgres_compiled_schema.rs | 43 ++++++++++++++++++- products/registry-server/README.md | 4 ++ .../generated/postgres/schema.sql | 19 +++++--- 5 files changed, 84 insertions(+), 19 deletions(-) diff --git a/crates/registry-server/src/generated_ddl.rs b/crates/registry-server/src/generated_ddl.rs index 4c13fc6d4a..5a020d258a 100644 --- a/crates/registry-server/src/generated_ddl.rs +++ b/crates/registry-server/src/generated_ddl.rs @@ -332,11 +332,13 @@ pub(crate) fn generate_ddl( .trim() .trim_end_matches(';'); let key = quote_identifier(&relation.key_field.replace('-', "_")); - let cardinality = quote_identifier("registry_derived_key_cardinality"); + // `$` is outside the closed logical identifier grammar, so these + // wrapper-only names cannot collide with an authored SQL output. + let canonical_key = quote_identifier("__registry$derived$key"); + let cardinality = quote_identifier("__registry$derived$cardinality"); let mut columns = vec![format!( - "{}::{} AS {}", - key, - sql_type(&entity.canonical_id.field_type), + "{} AS {}", + canonical_key, quote_identifier(&entity.canonical_id.sql_name) )]; for field_id in &relation.fields { @@ -359,15 +361,23 @@ pub(crate) fn generate_ddl( WITH (security_invoker=true, security_barrier=true) AS SELECT {} FROM ( - SELECT trusted_derived.*, - count(*) OVER (PARTITION BY trusted_derived.{key}) AS {cardinality} - FROM ({sql}) AS trusted_derived + SELECT canonical_derived.*, + count(*) OVER (PARTITION BY canonical_derived.{canonical_key}) AS {cardinality} + FROM ( + SELECT trusted_derived.*, + trusted_derived.{key}::{} AS {canonical_key} + FROM ({sql}) AS trusted_derived + ) AS canonical_derived ) AS checked_derived WHERE CASE - WHEN {cardinality} = 1 THEN true + WHEN {canonical_key} IS NOT NULL AND {cardinality} = 1 THEN true + -- PostgreSQL has no scalar ASSERT. This row-dependent + -- expression raises one stable, value-free error for + -- a null or duplicate canonical key. ELSE 1 / ({cardinality} - {cardinality}) = 0 END", columns.join(", "), + sql_type(&entity.canonical_id.field_type), ), }); views.push(DdlView { diff --git a/crates/registry-server/tests/compiler_contract.rs b/crates/registry-server/tests/compiler_contract.rs index 60a35fc1af..b3f0999cf3 100644 --- a/crates/registry-server/tests/compiler_contract.rs +++ b/crates/registry-server/tests/compiler_contract.rs @@ -85,7 +85,8 @@ fn derived_fields_selectors_and_read_paths_compile_to_route_specific_inventories "id":"demographics","sql":"sql/household-demographics.sql","key":"id","execution":"live", "fields":[ {"id":"child-count","type":"int64","classification":"restricted"}, - {"id":"single-headed","type":"boolean","classification":"restricted"} + {"id":"single-headed","type":"boolean","classification":"restricted"}, + {"id":"registry-derived-key-cardinality","type":"int64","classification":"restricted"} ] }], "selectorProfiles":[ @@ -121,7 +122,7 @@ fn derived_fields_selectors_and_read_paths_compile_to_route_specific_inventories ] }] }"#; - let sql = "SELECT h.id AS id, 0::bigint AS child_count, false AS single_headed FROM registry_source.household h"; + let sql = "SELECT h.id AS id, 0::bigint AS child_count, false AS single_headed, 1::bigint AS registry_derived_key_cardinality FROM registry_source.household h"; let compiled = compile_json_with_assets( project, vec![derived_sql_asset("sql/household-demographics.sql", sql)], @@ -168,8 +169,10 @@ fn derived_fields_selectors_and_read_paths_compile_to_route_specific_inventories "all source views must exist before cross-entity derived SQL is installed" ); let derived_view = &compiled.ddl().statements[first_derived_view].sql; - assert!(derived_view.contains("count(*) OVER (PARTITION BY trusted_derived.\"id\")")); - assert!(derived_view.contains("registry_derived_key_cardinality")); + assert!(derived_view + .contains("count(*) OVER (PARTITION BY canonical_derived.\"__registry$derived$key\")")); + assert!(derived_view.contains("\"__registry$derived$cardinality\"")); + assert!(derived_view.contains("\"registry_derived_key_cardinality\"::bigint")); assert!(compiled .routes() .routes diff --git a/crates/registry-server/tests/postgres_compiled_schema.rs b/crates/registry-server/tests/postgres_compiled_schema.rs index 1b5a0a8437..df6bd73dd0 100644 --- a/crates/registry-server/tests/postgres_compiled_schema.rs +++ b/crates/registry-server/tests/postgres_compiled_schema.rs @@ -472,6 +472,47 @@ async fn install_derived_view_fixture() { .is_err()); transaction.rollback().await.expect("proof rolls back"); + let null_key_transaction = begin_record_transaction( + &mut client, + lock_key, + Duration::from_secs(1), + &identity, + &claims, + ) + .await + .expect("null-key proof transaction starts"); + null_key_transaction + .transaction_for_test() + .execute( + &format!( + "INSERT INTO registry_data.{table} (record_id, {tenant}, {size}) + VALUES ('00000000-0000-4000-8000-000000000303', 'north', 0)" + ), + &[], + ) + .await + .expect("invalid derived-key source row is visible inside the proof transaction"); + null_key_transaction + .transaction_for_test() + .execute( + "SELECT set_config('registry.evaluation_date', '2026-08-30', true)", + &[], + ) + .await + .expect("null-key proof installs explicit evaluation date"); + assert!(null_key_transaction + .transaction_for_test() + .query( + &format!("SELECT * FROM registry_derived.{derived_view}"), + &[] + ) + .await + .is_err()); + null_key_transaction + .rollback() + .await + .expect("null-key source row rolls back"); + let duplicate_transaction = begin_record_transaction( &mut client, lock_key, @@ -996,7 +1037,7 @@ fn derived_registry() -> registry_server::CompiledRegistry { &[ModuleAssetSource { module: None, path: "sql/facts.sql".to_owned(), - bytes: b"SELECT h.id AS id, h.size AS child_count, registry_context.evaluation_date() AS observed_on FROM registry_source.household h JOIN registry_source.household sibling ON sibling.tenant = h.tenant".to_vec(), + bytes: b"SELECT CASE WHEN h.size = 0 THEN NULL WHEN sibling.id = h.id THEN '00000000-0000-4000-8000-000000000301' ELSE '00000000000040008000000000000301' END AS id, h.size AS child_count, registry_context.evaluation_date() AS observed_on FROM registry_source.household h JOIN registry_source.household sibling ON sibling.tenant = h.tenant AND sibling.size > 0".to_vec(), }], CompileProfile::Authoring, ) diff --git a/products/registry-server/README.md b/products/registry-server/README.md index bec463ed77..d8d33a99f6 100644 --- a/products/registry-server/README.md +++ b/products/registry-server/README.md @@ -143,6 +143,10 @@ membership, and a reviewed live SQL asset that contributes derived household facts such as head count, child count, under-five child count, elderly count, single-headed, and woman-headed: +Every emitted derived row must have a non-null canonical `id`, and one derived +relation may emit at most one row for that `id`. Registry Server refuses the +query atomically when reviewed SQL violates either rule. + ```bash registry-serverctl generate manifest \ products/registry-server/acceptance/publicschema-household \ diff --git a/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql index 6a793715fb..60937a17ab 100644 --- a/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql +++ b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql @@ -75,11 +75,14 @@ CREATE VIEW registry_source."person" WHERE record_lifecycle = 'active'; CREATE VIEW registry_derived."household__household_demographics" WITH (security_invoker=true, security_barrier=true) - AS SELECT "id"::uuid AS "id", "head_count"::bigint AS "head_count", "child_count"::bigint AS "child_count", "child_under_5_count"::bigint AS "child_under_5_count", "elderly_count"::bigint AS "elderly_count", "single_headed"::boolean AS "single_headed", "woman_headed"::boolean AS "woman_headed" + AS SELECT "__registry$derived$key" AS "id", "head_count"::bigint AS "head_count", "child_count"::bigint AS "child_count", "child_under_5_count"::bigint AS "child_under_5_count", "elderly_count"::bigint AS "elderly_count", "single_headed"::boolean AS "single_headed", "woman_headed"::boolean AS "woman_headed" FROM ( - SELECT trusted_derived.*, - count(*) OVER (PARTITION BY trusted_derived."id") AS "registry_derived_key_cardinality" - FROM (SELECT + SELECT canonical_derived.*, + count(*) OVER (PARTITION BY canonical_derived."__registry$derived$key") AS "__registry$derived$cardinality" + FROM ( + SELECT trusted_derived.*, + trusted_derived."id"::uuid AS "__registry$derived$key" + FROM (SELECT h.id AS id, count(*) FILTER ( WHERE gm.relationship = 'head' @@ -128,8 +131,12 @@ LEFT JOIN registry_source.group_membership gm LEFT JOIN registry_source.person p ON p.id = gm.person GROUP BY h.id) AS trusted_derived + ) AS canonical_derived ) AS checked_derived WHERE CASE - WHEN "registry_derived_key_cardinality" = 1 THEN true - ELSE 1 / ("registry_derived_key_cardinality" - "registry_derived_key_cardinality") = 0 + WHEN "__registry$derived$key" IS NOT NULL AND "__registry$derived$cardinality" = 1 THEN true + -- PostgreSQL has no scalar ASSERT. This row-dependent + -- expression raises one stable, value-free error for + -- a null or duplicate canonical key. + ELSE 1 / ("__registry$derived$cardinality" - "__registry$derived$cardinality") = 0 END; From cd90e37076d347b15dd8b5f9099805b5144094cf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 30 Aug 2026 20:11:19 +0700 Subject: [PATCH 09/19] fix(registry-server): align governed API and webhook workflows Signed-off-by: Jeremi Joslin --- crates/registry-server/src/api/mod.rs | 41 +++-- crates/registry-server/src/artifacts.rs | 2 +- crates/registry-server/src/data.rs | 97 +++++++++--- crates/registry-server/src/fixtures.rs | 148 +++++++++++++++++- .../registry-server/src/postgres/interlock.rs | 8 +- crates/registry-server/src/webhook.rs | 8 +- .../registry-server/tests/data_operations.rs | 76 +++++++++ .../registry-server/tests/http_read_only.rs | 41 ++--- .../tests/postgres_data_farmer.rs | 18 +-- .../registry-server/tests/postgres_package.rs | 29 +++- .../tests/postgres_pilot_acceptance.rs | 24 +-- .../tests/postgres_webhook_delivery.rs | 26 ++- products/registry-server/demo/README.md | 8 +- products/registry-server/demo/support/demo.py | 98 +++++++----- .../registry-server/demo/support/test_demo.py | 89 +++++++++-- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 32 ++-- .../generated/schemas/asset-item.schema.json | 2 +- .../schemas/asset-placement.schema.json | 2 +- .../generated/schemas/asset-site.schema.json | 2 +- .../schemas/inspection-event.schema.json | 2 +- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 26 +-- .../schemas/group-membership.schema.json | 2 +- .../generated/schemas/household.schema.json | 2 +- .../generated/schemas/person.schema.json | 2 +- 26 files changed, 612 insertions(+), 177 deletions(-) diff --git a/crates/registry-server/src/api/mod.rs b/crates/registry-server/src/api/mod.rs index 4cba4bb87f..ac1dc30e2c 100644 --- a/crates/registry-server/src/api/mod.rs +++ b/crates/registry-server/src/api/mod.rs @@ -2099,18 +2099,6 @@ fn resolve_data_field_id<'a>(entity: &'a CompiledEntity, api_name: &str) -> Opti .chain(entity.derived_fields.values().map(|field| &field.logical)) .find(|field| field.api_name == api_name) .map(|field| field.id.as_str()) - .or_else(|| { - entity - .fields - .get_key_value(api_name) - .map(|(field_id, _)| field_id.as_str()) - }) - .or_else(|| { - entity - .derived_fields - .get_key_value(api_name) - .map(|(field_id, _)| field_id.as_str()) - }) } fn projection_plan( @@ -2926,8 +2914,19 @@ fn lookup_request_values( selector: &crate::model::CompiledSelectorProfile, values: &BTreeMap, ) -> Result, LookupResolutionError> { - let expected = selector.fields.iter().collect::>(); - let actual = values.keys().collect::>(); + let expected = selector + .fields + .iter() + .map(|field_id| { + entity + .stored_fields + .iter() + .find(|field| field.logical.id == *field_id) + .map(|field| field.logical.api_name.as_str()) + .ok_or(LookupResolutionError::Unresolved) + }) + .collect::, _>>()?; + let actual = values.keys().map(String::as_str).collect::>(); if expected != actual { return Err(LookupResolutionError::InvalidRequest); } @@ -2939,9 +2938,15 @@ fn lookup_request_values( .fields .get(field_id) .ok_or(LookupResolutionError::Unresolved)?; + let api_name = entity + .stored_fields + .iter() + .find(|stored| stored.logical.id == *field_id) + .map(|stored| stored.logical.api_name.as_str()) + .ok_or(LookupResolutionError::Unresolved)?; let value = lookup_json_scalar( values - .get(field_id) + .get(api_name) .ok_or(LookupResolutionError::InvalidRequest)?, &field.field_type, )?; @@ -3036,8 +3041,10 @@ fn filtered_schema( let readable_api_names = entity .stored_fields .iter() - .filter(|field| readable_fields.contains(&field.logical.id)) - .map(|field| field.logical.api_name.as_str()) + .map(|field| &field.logical) + .chain(entity.derived_fields.values().map(|field| &field.logical)) + .filter(|field| readable_fields.contains(&field.id)) + .map(|field| field.api_name.as_str()) .collect::>(); let path = format!("generated/schemas/{entity_id}.schema.json"); let artifact = service.registry.artifacts().get(&path)?; diff --git a/crates/registry-server/src/artifacts.rs b/crates/registry-server/src/artifacts.rs index 6e961dc863..12e3b0284c 100644 --- a/crates/registry-server/src/artifacts.rs +++ b/crates/registry-server/src/artifacts.rs @@ -255,7 +255,7 @@ fn entity_schema(entity: &CompiledEntity) -> Value { .as_object_mut() .expect("field schemas are objects") .insert("readOnly".to_owned(), Value::Bool(true)); - properties.insert(field.logical.id.clone(), schema); + properties.insert(field.logical.api_name.clone(), schema); } json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/crates/registry-server/src/data.rs b/crates/registry-server/src/data.rs index a66d63bda9..1920c8b6e8 100644 --- a/crates/registry-server/src/data.rs +++ b/crates/registry-server/src/data.rs @@ -22,7 +22,9 @@ use crate::contract::{ valid_crs84_point, valid_decimal_value, valid_structured_value, FieldTypeSource, MutationMode, Operation, }; -use crate::model::{CompiledEntity, CompiledQueryKind, CompiledRegistry, HttpMethod}; +use crate::model::{ + CompiledEntity, CompiledQueryKind, CompiledRegistry, CompiledStoredField, HttpMethod, +}; const DATA_API_VERSION: &str = "registry.registrystack.org/v1alpha1"; const IMPORT_CHECKPOINT_KIND: &str = "RegistryDataImportCheckpoint"; @@ -416,9 +418,13 @@ impl DataImportPlan { response_fields: entity.access_profiles[profile_id] .readable_fields .iter() - .map(|field_id| { - let field = &entity.fields[field_id]; - (field_id.clone(), (field.field_type.clone(), field.required)) + .filter_map(|field_id| { + let stored = stored_field_by_id(entity, field_id)?; + let field = entity.fields.get(field_id)?; + Some(( + stored.logical.api_name.clone(), + (field.field_type.clone(), field.required), + )) }) .collect(), }) @@ -577,15 +583,19 @@ fn validate_create_data( ) -> Result<(), DataError> { let profile = &entity.access_profiles[profile_id]; if entity - .fields - .values() - .any(|field| field.required && !data.contains_key(&field.id)) + .stored_fields + .iter() + .any(|field| field.required && !data.contains_key(&field.logical.api_name)) { return Err(DataError::InvalidItem); } - for (field_id, value) in data { - let field = entity.fields.get(field_id).ok_or(DataError::InvalidItem)?; - if !profile.writable_fields.contains(field_id) + for (api_name, value) in data { + let stored = stored_field_by_api_name(entity, api_name).ok_or(DataError::InvalidItem)?; + let field = entity + .fields + .get(&stored.logical.id) + .ok_or(DataError::InvalidItem)?; + if !profile.writable_fields.contains(&stored.logical.id) || value.is_null() && field.required || !value.is_null() && !validate_field_value(FieldValue::Json(value), &field.field_type) { @@ -614,13 +624,15 @@ fn validate_patch( .get("path") .and_then(Value::as_str) .ok_or(DataError::InvalidItem)?; - let field_id = patch_field(path)?; - let field = entity.fields.get(&field_id).ok_or(DataError::InvalidItem)?; + let api_name = patch_field(path)?; + let stored = stored_field_by_api_name(entity, &api_name).ok_or(DataError::InvalidItem)?; + let field_id = &stored.logical.id; + let field = entity.fields.get(field_id).ok_or(DataError::InvalidItem)?; match name { "add" | "replace" => { require_exact_keys(operation, &["op", "path", "value"])?; let value = &operation["value"]; - if !profile.writable_fields.contains(&field_id) + if !profile.writable_fields.contains(field_id) || value.is_null() && field.required || !value.is_null() && !validate_field_value(FieldValue::Json(value), &field.field_type) @@ -631,7 +643,7 @@ fn validate_patch( } "remove" => { require_exact_keys(operation, &["op", "path"])?; - if field.required || !profile.writable_fields.contains(&field_id) { + if field.required || !profile.writable_fields.contains(field_id) { return Err(DataError::InvalidItem); } mutated = true; @@ -639,7 +651,7 @@ fn validate_patch( "test" => { require_exact_keys(operation, &["op", "path", "value"])?; let value = &operation["value"]; - if !profile.readable_fields.contains(&field_id) + if !profile.readable_fields.contains(field_id) || !value.is_null() && !validate_field_value(FieldValue::Json(value), &field.field_type) { @@ -679,6 +691,26 @@ fn patch_field(path: &str) -> Result { Ok(decoded) } +fn stored_field_by_api_name<'a>( + entity: &'a CompiledEntity, + api_name: &str, +) -> Option<&'a CompiledStoredField> { + entity + .stored_fields + .iter() + .find(|field| field.logical.api_name == api_name) +} + +fn stored_field_by_id<'a>( + entity: &'a CompiledEntity, + field_id: &str, +) -> Option<&'a CompiledStoredField> { + entity + .stored_fields + .iter() + .find(|field| field.logical.id == field_id) +} + fn require_exact_keys(object: &Map, expected: &[&str]) -> Result<(), DataError> { if object.len() != expected.len() || expected.iter().any(|key| !object.contains_key(*key)) { return Err(DataError::InvalidItem); @@ -1095,7 +1127,20 @@ impl DataExportPlan { { return Err(DataError::InvalidBinding); } - let requested = fields.iter().cloned().collect::>(); + let requested_api_names = fields.iter().cloned().collect::>(); + let requested = requested_api_names + .iter() + .map(|api_name| { + entity + .stored_fields + .iter() + .map(|field| &field.logical) + .chain(entity.derived_fields.values().map(|field| &field.logical)) + .find(|field| field.api_name == *api_name) + .map(|field| field.id.clone()) + .ok_or(DataError::InvalidBinding) + }) + .collect::, _>>()?; let expected_projection = profile.readable_fields.iter().cloned().collect::>(); let access_matches = registry.access().entries.iter().any(|entry| { entry.entity_id == entity_id @@ -1131,17 +1176,27 @@ impl DataExportPlan { { return Err(DataError::InvalidBinding); } - let response_fields = requested + let response_fields = requested_api_names .iter() - .map(|field_id| { - let field = &entity.fields[field_id]; - (field_id.clone(), (field.field_type.clone(), field.required)) + .map(|api_name| { + if let Some(field) = stored_field_by_api_name(entity, api_name) { + return ( + api_name.clone(), + (field.logical.field_type.clone(), field.required), + ); + } + let field = entity + .derived_fields + .values() + .find(|field| field.logical.api_name == *api_name) + .expect("requested fields were resolved against compiled data fields"); + (api_name.clone(), (field.logical.field_type.clone(), false)) }) .collect(); Ok(Self { entity_id: entity_id.to_owned(), profile_id: profile_id.to_owned(), - requested_fields: requested.into_iter().collect(), + requested_fields: requested_api_names.into_iter().collect(), route_path: route.expect("checked list route").path.clone(), maximum_page_size: query.expect("checked list query").max_page_size, response_fields, diff --git a/crates/registry-server/src/fixtures.rs b/crates/registry-server/src/fixtures.rs index 397f80c5d0..4b9ad88d9d 100644 --- a/crates/registry-server/src/fixtures.rs +++ b/crates/registry-server/src/fixtures.rs @@ -409,7 +409,15 @@ pub fn validate_fixture_journeys( validate_claims(&step.claims, profile, step.expect.outcome)?; validate_action_fields(&step.request, registry, entity, profile)?; validate_expectation(&step.expect, operation, profile, capture.is_some())?; - let response_readable_fields = match &step.request { + let response_entity = match &step.request { + ActionSource::ReadPath { path, .. } => entity + .read_paths + .get(path) + .and_then(|read_path| registry.entities().get(&read_path.to)) + .ok_or(FixtureError::LogicalReferenceRefused)?, + _ => entity, + }; + let response_readable_field_ids = match &step.request { ActionSource::ReadPath { path, .. } => profile .read_paths .iter() @@ -418,6 +426,10 @@ pub fn validate_fixture_journeys( .ok_or(FixtureError::LogicalReferenceRefused)?, _ => profile.readable_fields.clone(), }; + let response_readable_fields = + externalize_field_set(response_entity, &response_readable_field_ids)?; + let action = externalize_action(&step.request, registry, entity)?; + let expect = externalize_expectation(&step.expect, response_entity)?; steps.push(ValidatedStep { id: step.id, entity: step.entity, @@ -426,8 +438,8 @@ pub fn validate_fixture_journeys( route, profile: profile.clone(), response_readable_fields, - action: step.request, - expect: step.expect, + action, + expect, capture, }); } @@ -690,6 +702,136 @@ fn compiled_field_exists(entity: &crate::model::CompiledEntity, field: &str) -> entity.fields.contains_key(field) || entity.derived_fields.contains_key(field) } +fn field_api_name<'a>(entity: &'a crate::model::CompiledEntity, field_id: &str) -> Option<&'a str> { + if field_id == "id" { + return Some("id"); + } + if field_id == "revision" { + return Some("revision"); + } + entity + .stored_fields + .iter() + .map(|field| &field.logical) + .chain(entity.derived_fields.values().map(|field| &field.logical)) + .find(|field| field.id == field_id) + .map(|field| field.api_name.as_str()) +} + +fn externalize_field_set( + entity: &crate::model::CompiledEntity, + fields: &BTreeSet, +) -> Result, FixtureError> { + fields + .iter() + .map(|field| { + field_api_name(entity, field) + .map(str::to_owned) + .ok_or(FixtureError::LogicalReferenceRefused) + }) + .collect() +} + +fn externalize_data( + entity: &crate::model::CompiledEntity, + data: &Map, +) -> Result, FixtureError> { + data.iter() + .map(|(field, value)| { + field_api_name(entity, field) + .map(|api_name| (api_name.to_owned(), value.clone())) + .ok_or(FixtureError::LogicalReferenceRefused) + }) + .collect() +} + +fn externalize_action( + action: &ActionSource, + registry: &CompiledRegistry, + entity: &crate::model::CompiledEntity, +) -> Result { + Ok(match action { + ActionSource::Create { data } => ActionSource::Create { + data: externalize_data(entity, data)?, + }, + ActionSource::Get { record_ref } => ActionSource::Get { + record_ref: record_ref.clone(), + }, + ActionSource::List => ActionSource::List, + ActionSource::Query { select, top, count } => ActionSource::Query { + select: externalize_field_set(entity, select)?, + top: *top, + count: *count, + }, + ActionSource::Lookup { selector, values } => ActionSource::Lookup { + selector: selector.clone(), + values: externalize_data(entity, values)?, + }, + ActionSource::ReadPath { + path, + record_ref, + select, + top, + count, + } => { + let target = entity + .read_paths + .get(path) + .and_then(|read_path| registry.entities().get(&read_path.to)) + .ok_or(FixtureError::LogicalReferenceRefused)?; + ActionSource::ReadPath { + path: path.clone(), + record_ref: record_ref.clone(), + select: externalize_field_set(target, select)?, + top: *top, + count: *count, + } + } + ActionSource::Patch { + record_ref, + etag_ref, + changes, + } => ActionSource::Patch { + record_ref: record_ref.clone(), + etag_ref: etag_ref.clone(), + changes: changes + .iter() + .map(|change| { + Ok(FieldChangeSource { + field: field_api_name(entity, &change.field) + .ok_or(FixtureError::LogicalReferenceRefused)? + .to_owned(), + value: change.value.clone(), + }) + }) + .collect::, FixtureError>>()?, + }, + ActionSource::Batch { items } => ActionSource::Batch { + items: items + .iter() + .map(|item| match item { + BatchItemSource::Create { data } => { + externalize_data(entity, data).map(|data| BatchItemSource::Create { data }) + } + }) + .collect::, FixtureError>>()?, + }, + }) +} + +fn externalize_expectation( + expectation: &ExpectationSource, + response_entity: &crate::model::CompiledEntity, +) -> Result { + Ok(ExpectationSource { + outcome: expectation.outcome, + status: expectation.status, + fields: externalize_data(response_entity, &expectation.fields)?, + count: expectation.count, + problem_code: expectation.problem_code.clone(), + }) +} + fn validate_expectation( expectation: &ExpectationSource, operation: Operation, diff --git a/crates/registry-server/src/postgres/interlock.rs b/crates/registry-server/src/postgres/interlock.rs index 1240eda950..f6afbafcb3 100644 --- a/crates/registry-server/src/postgres/interlock.rs +++ b/crates/registry-server/src/postgres/interlock.rs @@ -1198,7 +1198,13 @@ async fn verify_retained_webhook_delivery_bindings( AND delivery.compiled_delivery_id = state.compiled_delivery_id JOIN registry_internal.registry_outbox AS outbox ON outbox.event_id = delivery.event_id - WHERE state.state IN ('pending', 'leased') + WHERE ( + state.state IN ('pending', 'leased') + OR ( + state.state = 'dead_lettered' + AND delivery.operator_replay + ) + ) AND outbox.payload IS NOT NULL AND outbox.payload_expires_at > transaction_timestamp() AND NOT EXISTS ( diff --git a/crates/registry-server/src/webhook.rs b/crates/registry-server/src/webhook.rs index be9fd0a374..adaac69896 100644 --- a/crates/registry-server/src/webhook.rs +++ b/crates/registry-server/src/webhook.rs @@ -228,7 +228,13 @@ impl WebhookDeliveryService { AND delivery.compiled_delivery_id = state.compiled_delivery_id JOIN registry_internal.registry_outbox AS outbox ON outbox.event_id = delivery.event_id - WHERE state.state IN ('pending', 'leased') + WHERE ( + state.state IN ('pending', 'leased') + OR ( + state.state = 'dead_lettered' + AND delivery.operator_replay + ) + ) AND outbox.payload IS NOT NULL AND outbox.payload_expires_at > transaction_timestamp()", &[], diff --git a/crates/registry-server/tests/data_operations.rs b/crates/registry-server/tests/data_operations.rs index 99929e9caf..e714faaede 100644 --- a/crates/registry-server/tests/data_operations.rs +++ b/crates/registry-server/tests/data_operations.rs @@ -280,6 +280,82 @@ fn data_validate_and_chunk_plan_reuse_runtime_rules_and_compiled_batch_bounds() ); } +#[test] +fn data_lifecycle_uses_exact_compiled_api_names() { + let registry = compile_source(json!({ + "apiVersion": "registry.registrystack.org/v1alpha1", + "kind": "RegistryProject", + "registry": {"id": "data-logical-names", "version": "1", "defaultLanguage": "en"}, + "entities": [{ + "id": ENTITY, + "route": "records", + "mutationMode": "mutable", + "batch": {"maximumItems": 2, "maximumBytes": 400}, + "fields": [{ + "id": "record-code", + "apiName": "publicCode", + "type": "string", + "minLength": 2, + "maxLength": 16, + "required": true, + "classification": "internal" + }], + "accessProfiles": [{ + "id": PROFILE, + "principalClaim": "principal", + "operations": ["create", "patch", "batch", "list"], + "readableFields": ["record-code"], + "writableFields": ["record-code"], + "allowDataExport": true + }] + }] + })) + .unwrap(); + + let create = b"{\"operation\":\"create\",\"data\":{\"publicCode\":\"AA\"}}\n"; + let create_plan = DataImportPlan::from_jsonl( + ®istry, + ENTITY, + DataImportOperation::Create, + PROFILE, + create, + ) + .unwrap(); + let canonical = parse_json_strict(create_plan.chunks()[0].canonical_body()).unwrap(); + assert_eq!(canonical["items"][0]["data"]["publicCode"], "AA"); + assert!(canonical["items"][0]["data"].get("record-code").is_none()); + + let internal_create = b"{\"operation\":\"create\",\"data\":{\"record-code\":\"AA\"}}\n"; + assert_eq!( + DataImportPlan::from_jsonl( + ®istry, + ENTITY, + DataImportOperation::Create, + PROFILE, + internal_create, + ), + Err(DataError::InvalidItem) + ); + + let patch = b"{\"operation\":\"patch\",\"recordId\":\"018f06d6-0248-7c7f-8a7e-df9dfbd83d2c\",\"ifMatch\":\"\\\"rs-revision\\\"\",\"patch\":[{\"op\":\"replace\",\"path\":\"/data/publicCode\",\"value\":\"BB\"}]}\n"; + DataImportPlan::from_jsonl( + ®istry, + ENTITY, + DataImportOperation::Patch, + PROFILE, + patch, + ) + .unwrap(); + + let export = DataExportPlan::from_compiled(®istry, ENTITY, PROFILE, ["publicCode"]) + .expect("the exact compiled API name is exportable"); + assert_eq!(export.requested_fields(), &["publicCode"]); + assert_eq!( + DataExportPlan::from_compiled(®istry, ENTITY, PROFILE, ["record-code"]), + Err(DataError::InvalidBinding) + ); +} + #[test] fn data_import_checkpoint_and_idempotency_are_exact_and_value_free() { let registry = compiled(true); diff --git a/crates/registry-server/tests/http_read_only.rs b/crates/registry-server/tests/http_read_only.rs index 4f18ead9ec..1bfc057271 100644 --- a/crates/registry-server/tests/http_read_only.rs +++ b/crates/registry-server/tests/http_read_only.rs @@ -422,13 +422,13 @@ async fn lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value let accepted = harness .send_json( Method::POST, - "/v1/records/households:lookup?accessProfile=operator&$select=household-code", + "/v1/records/households:lookup?accessProfile=operator&$select=householdCode", operator_claims.clone(), json!({ "selector": "by-local-reference", "values": { - "administrative-area": "area-a", - "local-household-number": 7 + "administrativeArea": "area-a", + "localHouseholdNumber": 7 } }), ) @@ -462,9 +462,10 @@ async fn lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value for body in [ json!({"selector": "by-local-reference"}), - json!({"selector": "by-local-reference", "values": {"administrative-area": "area-a", "local-household-number": "7"}}), - json!({"selector": "by-local-reference", "values": {"administrative-area": "area-a", "local-household-number": 7, "private-note": "DO-NOT-LEAK"}}), - json!({"selector": "by-local-reference", "values": {"administrative-area": "area-a", "local-household-number": 7}, "extra": "DO-NOT-LEAK"}), + json!({"selector": "by-local-reference", "values": {"administrativeArea": "area-a", "localHouseholdNumber": "7"}}), + json!({"selector": "by-local-reference", "values": {"administrativeArea": "area-a", "localHouseholdNumber": 7, "privateNote": "DO-NOT-LEAK"}}), + json!({"selector": "by-local-reference", "values": {"administrativeArea": "area-a", "localHouseholdNumber": 7}, "extra": "DO-NOT-LEAK"}), + json!({"selector": "by-local-reference", "values": {"administrative-area": "area-a", "local-household-number": 7}}), ] { let before = harness.records.calls(); let response = harness @@ -483,7 +484,7 @@ async fn lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value } let oversized_body = format!( - r#"{{"selector":"by-household-code","values":{{"household-code":"{}"}}}}"#, + r#"{{"selector":"by-household-code","values":{{"householdCode":"{}"}}}}"#, "x".repeat(17 * 1024) ); let before = harness.records.calls(); @@ -505,7 +506,7 @@ async fn lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value Method::POST, "/v1/records/households:lookup?accessProfile=operator", operator_claims.clone(), - json!({"selector": "missing-canary", "values": {"household-code": "DO-NOT-LEAK"}}), + json!({"selector": "missing-canary", "values": {"householdCode": "DO-NOT-LEAK"}}), ) .await; let unknown_body = body_bytes(unknown).await; @@ -514,7 +515,7 @@ async fn lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value Method::POST, "/v1/records/households:lookup?accessProfile=operator", operator_claims.clone(), - json!({"selector": "by-private-note", "values": {"private-note": "DO-NOT-LEAK"}}), + json!({"selector": "by-private-note", "values": {"privateNote": "DO-NOT-LEAK"}}), ) .await; let ungranted_body = body_bytes(ungranted).await; @@ -572,7 +573,7 @@ async fn lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value .expect("claim value"), )], )), - json!({"selector": "by-household-code", "values": {"household-code": "DO-NOT-LEAK"}}), + json!({"selector": "by-household-code", "values": {"householdCode": "DO-NOT-LEAK"}}), ) .await; assert_eq!(claim_values_body.status(), StatusCode::BAD_REQUEST); @@ -607,7 +608,7 @@ async fn relationship_route_uses_path_grant_not_direct_target_rights() { .send( Method::GET, &format!( - "/v1/records/households/{root}/people?accessProfile=operator&$select=person-code&$filter=startswith(person-code,'P-')&$orderby=person-code&$top=5&$count=true" + "/v1/records/households/{root}/people?accessProfile=operator&$select=personCode&$filter=startswith(personCode,'P-')&$orderby=personCode&$top=5&$count=true" ), Some(caseworker_claims("case-management")), ) @@ -653,7 +654,7 @@ async fn relationship_route_uses_path_grant_not_direct_target_rights() { .send( Method::GET, &format!( - "/v1/records/households/{root}/people?accessProfile=operator&$select=sensitive-note" + "/v1/records/households/{root}/people?accessProfile=operator&$select=sensitiveNote" ), Some(caseworker_claims("case-management")), ) @@ -661,7 +662,7 @@ async fn relationship_route_uses_path_grant_not_direct_target_rights() { assert_eq!(widened.status(), StatusCode::BAD_REQUEST); let widened_body = body_json(widened).await; assert_eq!(widened_body["code"], "query.invalid"); - assert!(!widened_body.to_string().contains("sensitive-note")); + assert!(!widened_body.to_string().contains("sensitiveNote")); assert_eq!(harness.records.calls(), before); let unknown_path = harness @@ -757,8 +758,8 @@ async fn relationship_discovery_uses_target_entity_and_unions_authorized_operati assert_eq!( openapi["components"]["schemas"]["person"]["properties"], json!({ - "person-code": {"type": "string", "minLength": 0, "maxLength": 64}, - "sensitive-note": {"type": "string", "minLength": 0, "maxLength": 64} + "personCode": {"type": "string", "minLength": 0, "maxLength": 64}, + "sensitiveNote": {"type": "string", "minLength": 0, "maxLength": 64} }) ); @@ -787,9 +788,9 @@ async fn relationship_discovery_uses_target_entity_and_unions_authorized_operati ) .await; assert!(household_schema["properties"] - .get("household-code") + .get("householdCode") .is_some()); - assert!(household_schema["properties"].get("person-code").is_none()); + assert!(household_schema["properties"].get("personCode").is_none()); let metadata = body_json( harness @@ -848,7 +849,7 @@ async fn derived_fields_are_discoverable_as_read_only_response_properties() { ) .await; assert_eq!( - schema["properties"]["eligibility-score"], + schema["properties"]["eligibilityScore"], json!({"type": "integer", "format": "int64", "readOnly": true}) ); assert_eq!( @@ -860,7 +861,7 @@ async fn derived_fields_are_discoverable_as_read_only_response_properties() { let response = send_to( &app, Method::GET, - "/v1/records/benefit-records/00000000-0000-4000-8000-000000000001?accessProfile=operator&$select=eligibility-score", + "/v1/records/benefit-records/00000000-0000-4000-8000-000000000001?accessProfile=operator&$select=eligibilityScore", Some(caseworker_claims("case-management")), ) .await; @@ -1595,6 +1596,7 @@ async fn lower_camel_select_resolves_only_compiled_authorized_api_names() { for uri in [ "/v1/records/logical-records/00000000-0000-4000-8000-000000000001?$select=unknownCanary", "/v1/records/logical-records/00000000-0000-4000-8000-000000000001?$select=privateCanary", + "/v1/records/logical-records/00000000-0000-4000-8000-000000000001?$select=household-code", ] { let refused = harness.send(Method::GET, uri, None).await; assert_eq!(refused.status(), StatusCode::NOT_FOUND, "{uri}"); @@ -1603,6 +1605,7 @@ async fn lower_camel_select_resolves_only_compiled_authorized_api_names() { let rendered = problem.to_string(); assert!(!rendered.contains("unknownCanary")); assert!(!rendered.contains("privateCanary")); + assert!(!rendered.contains("household-code")); } assert_eq!(harness.records.calls(), before); diff --git a/crates/registry-server/tests/postgres_data_farmer.rs b/crates/registry-server/tests/postgres_data_farmer.rs index 0754a528b6..a31e7c5e9b 100644 --- a/crates/registry-server/tests/postgres_data_farmer.rs +++ b/crates/registry-server/tests/postgres_data_farmer.rs @@ -35,8 +35,8 @@ async fn real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race "/v1/records/farmers", "data-farmer-seed", json!({ - "farmer-code":"F-DATA", "display-name":"Data import operator", - "administrative-boundary":"north-district" + "farmerCode":"F-DATA", "displayName":"Data import operator", + "administrativeBoundary":"north-district" }), ) .await; @@ -46,9 +46,9 @@ async fn real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race "/v1/records/holdings", "data-holding-seed", json!({ - "holding-code":"H-DATA", "farmer":farmer_id, "tenure-type":"owned", - "tenure-start":"2026-01-01", "administrative-boundary":"north-district", - "import-source":"data-seed", "source-record-id":"holding" + "holdingCode":"H-DATA", "farmer":farmer_id, "tenureType":"owned", + "tenureStart":"2026-01-01", "administrativeBoundary":"north-district", + "importSource":"data-seed", "sourceRecordId":"holding" }), ) .await; @@ -290,11 +290,11 @@ fn plot_item( longitude: f64, ) -> Value { json!({"operation":"create", "data":{ - "plot-code":plot_code, "holding":holding_id, - "administrative-boundary":"north-district", + "plotCode":plot_code, "holding":holding_id, + "administrativeBoundary":"north-district", "centroid":{"type":"Point","coordinates":[longitude,-9.5]}, - "area-value":"1.2500", "area-unit":"hectare", - "import-source":source, "source-record-id":source_record_id + "areaValue":"1.2500", "areaUnit":"hectare", + "importSource":source, "sourceRecordId":source_record_id }}) } diff --git a/crates/registry-server/tests/postgres_package.rs b/crates/registry-server/tests/postgres_package.rs index fd892657c5..2b05d4d365 100644 --- a/crates/registry-server/tests/postgres_package.rs +++ b/crates/registry-server/tests/postgres_package.rs @@ -2114,10 +2114,11 @@ async fn successor_apply_refuses_to_strand_retained_webhook_work() { ) .await .expect("upgrade test expires one pending payload before retention cleanup"); + let replayable_dead_letter_event = Uuid::new_v4(); insert_upgrade_webhook_delivery( &database, &active, - Uuid::new_v4(), + replayable_dead_letter_event, "removed-dead-letter-destination", &fingerprint(32), data_schema, @@ -2268,6 +2269,32 @@ async fn successor_apply_refuses_to_strand_retained_webhook_work() { target_inventory.binding_digest("neutral-events"), Some(exact_digest.as_str()) ); + let replayable_dead_letter_refused = apply_package_with_event_destination_compatibility( + &database, + &verified_second, + ApplyPrecondition::Successor { current: &active }, + &target_inventory, + ) + .await; + assert_eq!( + replayable_dead_letter_refused.err(), + Some(MigrationError::ApplyFailed) + ); + assert_eq!( + registry_state_snapshot(&database.admin).await, + before_refusals, + "an incompatible retained replayable dead letter is refused before maintenance state changes" + ); + database + .admin + .execute( + "UPDATE registry_internal.registry_webhook_deliveries + SET operator_replay = false + WHERE event_id = $1", + &[&replayable_dead_letter_event], + ) + .await + .expect("upgrade test disables replay for one retained dead letter"); let upgraded = apply_package_with_event_destination_compatibility( &database, &verified_second, diff --git a/crates/registry-server/tests/postgres_pilot_acceptance.rs b/crates/registry-server/tests/postgres_pilot_acceptance.rs index 0763055d96..fdd03e29fe 100644 --- a/crates/registry-server/tests/postgres_pilot_acceptance.rs +++ b/crates/registry-server/tests/postgres_pilot_acceptance.rs @@ -211,9 +211,11 @@ async fn household_journey(harness: &PilotHarness) { ["x-registry-vocabulary"], "residency-status" ); - assert!(openapi["components"]["schemas"]["person"]["properties"] - .get("preferredLanguage") - .is_none()); + assert_eq!( + openapi["components"]["schemas"]["person"]["properties"]["preferredLanguage"] + ["x-registry-vocabulary"], + "preferred-language" + ); let person = create_record( harness, @@ -307,7 +309,7 @@ async fn household_journey(harness: &PilotHarness) { .send( Method::GET, &format!( - "/v1/records/households/{}?accessProfile=household-operator&$select=household-code,head-count,single-headed,woman-headed", + "/v1/records/households/{}?accessProfile=household-operator&$select=householdCode,headCount,singleHeaded,womanHeaded", current_household.id ), Some(&token), @@ -317,16 +319,16 @@ async fn household_journey(harness: &PilotHarness) { .await; assert_eq!(household.status(), StatusCode::OK); let household = response_json(household).await; - assert_eq!(household["data"]["household-code"], "H-CURRENT"); - assert_eq!(household["data"]["head-count"], 1); - assert_eq!(household["data"]["single-headed"], true); - assert_eq!(household["data"]["woman-headed"], true); + assert_eq!(household["data"]["householdCode"], "H-CURRENT"); + assert_eq!(household["data"]["headCount"], 1); + assert_eq!(household["data"]["singleHeaded"], true); + assert_eq!(household["data"]["womanHeaded"], true); let people = harness .send( Method::GET, &format!( - "/v1/records/households/{}/people?accessProfile=household-operator&$select=person-code,person-sex&$filter=person-sex%20eq%20%27female%27&$count=true", + "/v1/records/households/{}/people?accessProfile=household-operator&$select=personCode,personSex&$filter=personSex%20eq%20%27female%27&$count=true", current_household.id ), Some(&token), @@ -337,8 +339,8 @@ async fn household_journey(harness: &PilotHarness) { assert_eq!(people.status(), StatusCode::OK); let people = response_json(people).await; assert_eq!(people["count"], 1); - assert_eq!(people["items"][0]["data"]["person-code"], "P-100"); - assert_eq!(people["items"][0]["data"]["person-sex"], "female"); + assert_eq!(people["items"][0]["data"]["personCode"], "P-100"); + assert_eq!(people["items"][0]["data"]["personSex"], "female"); let overlap = harness .send_json( diff --git a/crates/registry-server/tests/postgres_webhook_delivery.rs b/crates/registry-server/tests/postgres_webhook_delivery.rs index bfefbaf82d..3efdd8b3bb 100644 --- a/crates/registry-server/tests/postgres_webhook_delivery.rs +++ b/crates/registry-server/tests/postgres_webhook_delivery.rs @@ -636,10 +636,11 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun "destination_binding_refused", ) .await; - service - .verify_retained_bindings() - .await - .expect("a terminal dead letter never blocks successor startup"); + assert_eq!( + service.verify_retained_bindings().await, + Err(WebhookDeliveryError::Unavailable), + "a retained replayable dead letter with an incompatible binding blocks startup" + ); assert_eq!( service .replay( @@ -651,6 +652,23 @@ async fn real_postgres_webhook_delivery_retry_dead_letter_replay_is_package_boun Err(WebhookDeliveryError::Unavailable), "replay fails closed when the current destination does not match the captured binding" ); + database + .admin + .execute( + "UPDATE registry_internal.registry_webhook_deliveries + SET operator_replay = false + WHERE event_id = $1 AND compiled_delivery_id = $2", + &[ + &binding_refused.event_id, + &binding_refused.compiled_delivery_id, + ], + ) + .await + .expect("administrator disables replay for the incompatible dead letter"); + service + .verify_retained_bindings() + .await + .expect("non-replayable and expired or erased dead letters do not block startup"); let payload_refused = create_event( &database, diff --git a/products/registry-server/demo/README.md b/products/registry-server/demo/README.md index 58c7d3776f..a51ccb0239 100644 --- a/products/registry-server/demo/README.md +++ b/products/registry-server/demo/README.md @@ -79,10 +79,10 @@ bearer token or HMAC key. The exact paths, query parameters, selector bodies, and expected statuses live in `support/demo.py`, which `query.sh` invokes. This keeps the examples copyable without teaching people to expand bearer tokens into process-visible -`curl` arguments. Field and selector IDs intentionally retain their configured -kebab-case spelling. The operator selector body uses the exact `values` -property, while the viewer's verified-claim selector correctly sends no -caller-provided values. +`curl` arguments. Public field names use their compiled lower-camel API names, +while selector IDs retain their configured kebab-case spelling. The operator +selector body uses the exact `values` property, while the viewer's +verified-claim selector correctly sends no caller-provided values. ## Disposable state diff --git a/products/registry-server/demo/support/demo.py b/products/registry-server/demo/support/demo.py index d002c3e799..94f21a609b 100755 --- a/products/registry-server/demo/support/demo.py +++ b/products/registry-server/demo/support/demo.py @@ -44,6 +44,7 @@ WEBHOOK_EVENT_ID = "usual-resident-created-v1" WEBHOOK_MODULE_ID = "publicschema-household-demographics" WEBHOOK_MODULE_LOCK = " - id: publicschema-household-demographics\n version: 0.1.0\n" +WEBHOOK_ENTITY_INSERTION = " - entity: household\n" WEBHOOK_MODULE_SOURCE = """ events: - id: usual-resident-created-v1 trigger: created @@ -131,9 +132,20 @@ def _local_project(root: Path, fixture: Path, webhook: bool) -> None: source = before_lock + WEBHOOK_MODULE_LOCK + after_digest module_path = target / f"modules/{WEBHOOK_MODULE_ID}/module.yaml" module_source = module_path.read_text(encoding="utf-8") - if " events:\n" in module_source or not module_source.endswith("\n"): + if ( + " events:\n" in module_source + or module_source.count(WEBHOOK_ENTITY_INSERTION) != 1 + or not module_source.endswith("\n") + ): raise DemoError("household demographics module cannot receive the demo event") - module_path.write_text(module_source + WEBHOOK_MODULE_SOURCE, encoding="utf-8") + module_path.write_text( + module_source.replace( + WEBHOOK_ENTITY_INSERTION, + WEBHOOK_MODULE_SOURCE + WEBHOOK_ENTITY_INSERTION, + 1, + ), + encoding="utf-8", + ) project_path.write_text(source, encoding="utf-8") @@ -767,8 +779,12 @@ def verify_webhook(root: Path) -> None: root = _require_root(root) state = _read_json_object(root / "webhook-receiver-state.json") events = sorted(state.get("events", {}).values(), key=lambda event: event.get("slot", 0)) - if state.get("verificationFailures") != 0 or len(events) != 4: - raise DemoError("the webhook receiver did not verify exactly four matching events") + people, _, _ = seed_spec() + expected_events = sum( + person.get("residencyStatus") == "usual-resident" for person in people + ) + if state.get("verificationFailures") != 0 or len(events) != expected_events: + raise DemoError("the webhook receiver did not verify every matching seeded event") if not any(item.get("accepted") for item in events[0].get("attempts", [])): raise DemoError("the webhook receiver did not prove immediate success") if not any( @@ -781,6 +797,16 @@ def verify_webhook(root: Path) -> None: for item in events[2].get("attempts", []) ): raise DemoError("the webhook receiver did not prove replay success") + if any( + not any( + item.get("accepted") + and item.get("generation") == 1 + and item.get("attempt") == 1 + for item in event.get("attempts", []) + ) + for event in events[3:] + ): + raise DemoError("the webhook receiver did not accept the remaining seeded events") def _request( @@ -836,29 +862,29 @@ def _create(root: Path, route: str, logical_key: str, data: dict[str, Any]) -> s def seed_spec() -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: people = [ - {"person-code": "PERSON-DEMO-001", "legal-name": "Omar Example", "family-name": "Example", "date-of-birth": "1986-02-22", "person-sex": "male", "residency-status": "usual-resident", "preferred-language": "en"}, - {"person-code": "PERSON-DEMO-002", "legal-name": "Lina Example", "family-name": "Example", "date-of-birth": "2023-03-14", "person-sex": "female", "residency-status": "usual-resident", "preferred-language": "en"}, - {"person-code": "PERSON-DEMO-003", "legal-name": "Sofia Sample", "family-name": "Sample", "date-of-birth": "1980-11-02", "person-sex": "female", "residency-status": "usual-resident", "preferred-language": "es"}, - {"person-code": "PERSON-DEMO-004", "legal-name": "Diego Sample", "family-name": "Sample", "date-of-birth": "2016-06-17", "person-sex": "male", "residency-status": "usual-resident", "preferred-language": "es"}, - {"person-code": "PERSON-DEMO-005", "legal-name": "Rosa Sample", "family-name": "Sample", "date-of-birth": "1940-08-20", "person-sex": "female", "residency-status": "usual-resident", "preferred-language": "es"}, - {"person-code": "PERSON-DEMO-006", "legal-name": "Karim Control", "family-name": "Control", "date-of-birth": "1975-01-09", "person-sex": "male", "residency-status": "usual-resident", "preferred-language": "fr"}, - {"person-code": "PERSON-DEMO-007", "legal-name": "Hana Control", "family-name": "Control", "date-of-birth": "1977-09-23", "person-sex": "female", "residency-status": "usual-resident", "preferred-language": "fr"}, - {"person-code": "PERSON-DEMO-008", "legal-name": "Noor Control", "family-name": "Control", "date-of-birth": "2018-05-06", "person-sex": "female", "residency-status": "usual-resident", "preferred-language": "fr"}, + {"personCode": "PERSON-DEMO-001", "legalName": "Omar Example", "familyName": "Example", "dateOfBirth": "1986-02-22", "personSex": "male", "residencyStatus": "usual-resident", "preferredLanguage": "en"}, + {"personCode": "PERSON-DEMO-002", "legalName": "Lina Example", "familyName": "Example", "dateOfBirth": "2023-03-14", "personSex": "female", "residencyStatus": "usual-resident", "preferredLanguage": "en"}, + {"personCode": "PERSON-DEMO-003", "legalName": "Sofia Sample", "familyName": "Sample", "dateOfBirth": "1980-11-02", "personSex": "female", "residencyStatus": "usual-resident", "preferredLanguage": "es"}, + {"personCode": "PERSON-DEMO-004", "legalName": "Diego Sample", "familyName": "Sample", "dateOfBirth": "2016-06-17", "personSex": "male", "residencyStatus": "usual-resident", "preferredLanguage": "es"}, + {"personCode": "PERSON-DEMO-005", "legalName": "Rosa Sample", "familyName": "Sample", "dateOfBirth": "1940-08-20", "personSex": "female", "residencyStatus": "usual-resident", "preferredLanguage": "es"}, + {"personCode": "PERSON-DEMO-006", "legalName": "Karim Control", "familyName": "Control", "dateOfBirth": "1975-01-09", "personSex": "male", "residencyStatus": "usual-resident", "preferredLanguage": "fr"}, + {"personCode": "PERSON-DEMO-007", "legalName": "Hana Control", "familyName": "Control", "dateOfBirth": "1977-09-23", "personSex": "female", "residencyStatus": "usual-resident", "preferredLanguage": "fr"}, + {"personCode": "PERSON-DEMO-008", "legalName": "Noor Control", "familyName": "Control", "dateOfBirth": "2018-05-06", "personSex": "female", "residencyStatus": "usual-resident", "preferredLanguage": "fr"}, ] households = [ - {"household-code": "HOUSEHOLD-DEMO-001", "local-household-number": 1001, "household-name": "Single Headed Under Five Household", "administrative-area": "north-demo", "household-type": "private"}, - {"household-code": "HOUSEHOLD-DEMO-002", "local-household-number": 1002, "household-name": "Woman Headed Child Elderly Household", "administrative-area": "central-demo", "household-type": "private"}, - {"household-code": "HOUSEHOLD-DEMO-003", "local-household-number": 1003, "household-name": "Isolation Control Household", "administrative-area": "south-demo", "household-type": "private"}, + {"householdCode": "HOUSEHOLD-DEMO-001", "localHouseholdNumber": 1001, "householdName": "Single Headed Under Five Household", "administrativeArea": "north-demo", "householdType": "private"}, + {"householdCode": "HOUSEHOLD-DEMO-002", "localHouseholdNumber": 1002, "householdName": "Woman Headed Child Elderly Household", "administrativeArea": "central-demo", "householdType": "private"}, + {"householdCode": "HOUSEHOLD-DEMO-003", "localHouseholdNumber": 1003, "householdName": "Isolation Control Household", "administrativeArea": "south-demo", "householdType": "private"}, ] memberships = [ - {"person-code": "PERSON-DEMO-001", "household-code": "HOUSEHOLD-DEMO-001", "relationship": "head", "valid-from": "2026-01-01"}, - {"person-code": "PERSON-DEMO-002", "household-code": "HOUSEHOLD-DEMO-001", "relationship": "child", "valid-from": "2026-01-01"}, - {"person-code": "PERSON-DEMO-003", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "head", "valid-from": "2026-01-01"}, - {"person-code": "PERSON-DEMO-004", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "child", "valid-from": "2026-01-01"}, - {"person-code": "PERSON-DEMO-005", "household-code": "HOUSEHOLD-DEMO-002", "relationship": "dependent", "valid-from": "2026-01-01"}, - {"person-code": "PERSON-DEMO-006", "household-code": "HOUSEHOLD-DEMO-003", "relationship": "head", "valid-from": "2026-01-01"}, - {"person-code": "PERSON-DEMO-007", "household-code": "HOUSEHOLD-DEMO-003", "relationship": "spouse", "valid-from": "2026-01-01"}, - {"person-code": "PERSON-DEMO-008", "household-code": "HOUSEHOLD-DEMO-003", "relationship": "child", "valid-from": "2026-01-01"}, + {"personCode": "PERSON-DEMO-001", "householdCode": "HOUSEHOLD-DEMO-001", "relationship": "head", "validFrom": "2026-01-01"}, + {"personCode": "PERSON-DEMO-002", "householdCode": "HOUSEHOLD-DEMO-001", "relationship": "child", "validFrom": "2026-01-01"}, + {"personCode": "PERSON-DEMO-003", "householdCode": "HOUSEHOLD-DEMO-002", "relationship": "head", "validFrom": "2026-01-01"}, + {"personCode": "PERSON-DEMO-004", "householdCode": "HOUSEHOLD-DEMO-002", "relationship": "child", "validFrom": "2026-01-01"}, + {"personCode": "PERSON-DEMO-005", "householdCode": "HOUSEHOLD-DEMO-002", "relationship": "dependent", "validFrom": "2026-01-01"}, + {"personCode": "PERSON-DEMO-006", "householdCode": "HOUSEHOLD-DEMO-003", "relationship": "head", "validFrom": "2026-01-01"}, + {"personCode": "PERSON-DEMO-007", "householdCode": "HOUSEHOLD-DEMO-003", "relationship": "spouse", "validFrom": "2026-01-01"}, + {"personCode": "PERSON-DEMO-008", "householdCode": "HOUSEHOLD-DEMO-003", "relationship": "child", "validFrom": "2026-01-01"}, ] return people, households, memberships @@ -867,12 +893,12 @@ def seed(root: Path) -> None: root = _require_root(root) people, households, memberships = seed_spec() person_ids = { - person["person-code"]: _create(root, "/v1/records/persons", person["person-code"].lower(), person) + person["personCode"]: _create(root, "/v1/records/persons", person["personCode"].lower(), person) for person in people } household_ids = { - household["household-code"]: _create( - root, "/v1/records/households", household["household-code"].lower(), household + household["householdCode"]: _create( + root, "/v1/records/households", household["householdCode"].lower(), household ) for household in households } @@ -882,10 +908,10 @@ def seed(root: Path) -> None: "/v1/records/group-memberships", f"membership-{index}", { - "person": person_ids[membership["person-code"]], - "household": household_ids[membership["household-code"]], + "person": person_ids[membership["personCode"]], + "household": household_ids[membership["householdCode"]], "relationship": membership["relationship"], - "valid-from": membership["valid-from"], + "validFrom": membership["validFrom"], }, ) _write_json(root / "seed-record-ids.json", {"people": person_ids, "households": household_ids}) @@ -965,7 +991,7 @@ def _assert_bound_household(response: dict[str, Any], household_id: str, househo if ( response.get("id") != household_id or not isinstance(data, dict) - or data.get("household-code") != household_code + or data.get("householdCode") != household_code ): raise DemoError("viewer read did not return its one bound household") @@ -978,10 +1004,10 @@ def query(root: Path, suite: str = "all") -> None: encoded_household_id = urllib.parse.quote(household_id, safe="") if suite in ("all", "operator"): queries = [ - ("People from one household", f"/v1/records/households/{encoded_household_id}/people?accessProfile=household-operator&$select=person-code,legal-name,person-sex,residency-status&$orderby=person-code&$top=20&$count=true"), - ("Derived stored and computed filter", "/v1/records/households?accessProfile=household-operator&$select=household-code,administrative-area,local-household-number,child-count&$filter=administrative-area%20eq%20%27north-demo%27%20and%20child-count%20eq%201&$orderby=local-household-number&$top=20&$count=true"), - ("Single headed with child under five", "/v1/records/households?accessProfile=household-operator&$select=household-code,child-under-5-count,single-headed&$filter=single-headed%20eq%20true%20and%20child-under-5-count%20eq%201&$top=20&$count=true"), - ("Woman headed with child and elderly", "/v1/records/households?accessProfile=household-operator&$select=household-code,woman-headed,child-count,elderly-count&$filter=woman-headed%20eq%20true%20and%20child-count%20eq%201%20and%20elderly-count%20eq%201&$top=20&$count=true"), + ("People from one household", f"/v1/records/households/{encoded_household_id}/people?accessProfile=household-operator&$select=personCode,legalName,personSex,residencyStatus&$orderby=personCode&$top=20&$count=true"), + ("Derived stored and computed filter", "/v1/records/households?accessProfile=household-operator&$select=householdCode,administrativeArea,localHouseholdNumber,childCount&$filter=administrativeArea%20eq%20%27north-demo%27%20and%20childCount%20eq%201&$orderby=localHouseholdNumber&$top=20&$count=true"), + ("Single headed with child under five", "/v1/records/households?accessProfile=household-operator&$select=householdCode,childUnder5Count,singleHeaded&$filter=singleHeaded%20eq%20true%20and%20childUnder5Count%20eq%201&$top=20&$count=true"), + ("Woman headed with child and elderly", "/v1/records/households?accessProfile=household-operator&$select=householdCode,womanHeaded,childCount,elderlyCount&$filter=womanHeaded%20eq%20true%20and%20childCount%20eq%201%20and%20elderlyCount%20eq%201&$top=20&$count=true"), ] for label, path in queries: response, _ = _request(root, "GET", path, "operator-token") @@ -994,8 +1020,8 @@ def query(root: Path, suite: str = "all") -> None: { "selector": "by-local-reference", "values": { - "administrative-area": "north-demo", - "local-household-number": 1001, + "administrativeArea": "north-demo", + "localHouseholdNumber": 1001, }, }, ) diff --git a/products/registry-server/demo/support/test_demo.py b/products/registry-server/demo/support/test_demo.py index 037580e47f..21ac082f53 100755 --- a/products/registry-server/demo/support/test_demo.py +++ b/products/registry-server/demo/support/test_demo.py @@ -137,6 +137,10 @@ def test_webhook_mode_extends_only_the_disposable_module_and_binds_its_compiled_ self.assertNotIn(DEMO.WEBHOOK_MODULE_LOCK + " digest:", project) self.assertIn("id: usual-resident-created-v1", module) self.assertIn("afterEquals: {residency-status: usual-resident}", module) + self.assertLess( + module.index("id: usual-resident-created-v1"), + module.index(DEMO.WEBHOOK_ENTITY_INSERTION), + ) self.assertEqual(fixture_module.read_bytes(), original_fixture_module) digest = "sha256:" + "3" * 64 @@ -242,6 +246,39 @@ def test_dead_letter_selection_returns_only_replay_eligible_value_free_metadata( (event_id, "person.usual-resident-created-v1.webhook", 1), ) + def test_webhook_verification_covers_every_matching_seeded_person(self) -> None: + people, _, _ = DEMO.seed_spec() + events = {} + for index, person in enumerate(people, start=1): + if person["residencyStatus"] != "usual-resident": + continue + attempts = [{"generation": 1, "attempt": 1, "accepted": True}] + if index == 2: + attempts = [ + {"generation": 1, "attempt": 1, "accepted": False}, + {"generation": 1, "attempt": 2, "accepted": True}, + ] + elif index == 3: + attempts = [ + {"generation": 1, "attempt": 1, "accepted": False}, + {"generation": 2, "attempt": 1, "accepted": True}, + ] + events[f"event-{index}"] = {"slot": index, "attempts": attempts} + (self.root / "webhook-receiver-state.json").write_text( + json.dumps({"verificationFailures": 0, "events": events}), + encoding="utf-8", + ) + + DEMO.verify_webhook(self.root) + + events.pop(next(reversed(events))) + (self.root / "webhook-receiver-state.json").write_text( + json.dumps({"verificationFailures": 0, "events": events}), + encoding="utf-8", + ) + with self.assertRaisesRegex(DEMO.DemoError, "every matching seeded event"): + DEMO.verify_webhook(self.root) + def test_schema_test_credentials_cover_every_packaged_journey_step(self) -> None: DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) journey_source = (self.root / "project/tests/journeys.yaml").read_text( @@ -270,23 +307,32 @@ def test_schema_test_credentials_cover_every_packaged_journey_step(self) -> None def test_seed_is_referentially_closed_and_stable(self) -> None: people, households, memberships = DEMO.seed_spec() - person_codes = {person["person-code"] for person in people} - household_codes = {household["household-code"] for household in households} + person_codes = {person["personCode"] for person in people} + household_codes = {household["householdCode"] for household in households} self.assertEqual((len(people), len(households), len(memberships)), (8, 3, 8)) self.assertEqual(len(person_codes), len(people)) self.assertEqual(len(household_codes), len(households)) + self.assertTrue( + all( + "-" not in key + for rows in (people, households, memberships) + for row in rows + for key in row + ), + "seed data must use compiled public API field names", + ) self.assertEqual( - [household["local-household-number"] for household in households], + [household["localHouseholdNumber"] for household in households], [1001, 1002, 1003], ) - self.assertTrue(all(row["person-code"] in person_codes for row in memberships)) - self.assertTrue(all(row["household-code"] in household_codes for row in memberships)) + self.assertTrue(all(row["personCode"] in person_codes for row in memberships)) + self.assertTrue(all(row["householdCode"] in household_codes for row in memberships)) self.assertEqual( - {person["person-sex"] for person in people}, + {person["personSex"] for person in people}, {"female", "male"}, ) self.assertEqual( - sum(person["residency-status"] == "usual-resident" for person in people), + sum(person["residencyStatus"] == "usual-resident" for person in people), 8, ) @@ -349,7 +395,7 @@ def request( return { "id": household_id, "revision": 1, - "data": {"household-code": "HOUSEHOLD-DEMO-001"}, + "data": {"householdCode": "HOUSEHOLD-DEMO-001"}, }, {} with mock.patch.object(DEMO, "_request", side_effect=request), mock.patch.object( @@ -386,7 +432,7 @@ def request( return { "id": household_id, "revision": 1, - "data": {"household-code": "HOUSEHOLD-DEMO-001"}, + "data": {"householdCode": "HOUSEHOLD-DEMO-001"}, }, {} return {"items": []}, {} @@ -395,14 +441,35 @@ def request( ): DEMO.query(self.root, "operator") + query_paths = [call[1] for call in calls[:-1]] + self.assertIn("$select=personCode,legalName,personSex,residencyStatus", query_paths[0]) + self.assertIn("$orderby=personCode", query_paths[0]) + self.assertIn("$filter=administrativeArea%20eq", query_paths[1]) + self.assertIn("$orderby=localHouseholdNumber", query_paths[1]) + self.assertIn("$filter=singleHeaded%20eq", query_paths[2]) + self.assertIn("childUnder5Count%20eq", query_paths[2]) + self.assertIn("$filter=womanHeaded%20eq", query_paths[3]) + self.assertTrue( + all( + internal_name not in path + for path in query_paths + for internal_name in ( + "person-code", + "administrative-area", + "local-household-number", + "child-under-5-count", + "woman-headed", + ) + ) + ) self.assertEqual(calls[-1][0:2], ("POST", "/v1/records/households:lookup?accessProfile=household-operator")) self.assertEqual( calls[-1][2], { "selector": "by-local-reference", "values": { - "administrative-area": "north-demo", - "local-household-number": 1001, + "administrativeArea": "north-demo", + "localHouseholdNumber": 1001, }, }, ) diff --git a/products/registry-server/generated/asset-site-placement/generated/openapi.json b/products/registry-server/generated/asset-site-placement/generated/openapi.json index 8db7c7fb04..adc77706e3 100644 --- a/products/registry-server/generated/asset-site-placement/generated/openapi.json +++ b/products/registry-server/generated/asset-site-placement/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"asset-item":{"$id":"urn:registry-server:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset-class":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"asset-code":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["asset-class","asset-code","label"],"type":"object","x-registry-mutationMode":"mutable"},"asset-placement":{"$id":"urn:registry-server:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["asset","site","valid-from"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site":{"$id":"urn:registry-server:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"site-code":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label","site-code"],"type":"object","x-registry-mutationMode":"mutable"},"inspection-event":{"$id":"urn:registry-server:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observed-at":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observed-at","result"],"type":"object","x-registry-mutationMode":"create_only"}}},"info":{"title":"asset-site-placement","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/assets":{"get":{"operationId":"records.asset-item.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-item.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"create"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset-item.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"get"},"patch":{"operationId":"records.asset-item.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"patch"}},"/v1/records/assets:batch":{"post":{"operationId":"records.asset-item.batch","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"items":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"operation":{"const":"create"}},"required":["operation","data"],"type":"object"},{"additionalProperties":false,"properties":{"ifMatch":{"type":"string"},"operation":{"const":"patch"},"patch":{"maxItems":128,"minItems":1,"type":"array"},"recordId":{"format":"uuid","type":"string"}},"required":["operation","recordId","ifMatch","patch"],"type":"object"}]},"maxItems":4,"minItems":1,"type":"array"}},"required":["items"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"results":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"etag":{"type":"string"},"id":{"format":"uuid","type":"string"},"operation":{"enum":["create","patch"]},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["operation","id","revision","etag","data"],"type":"object"},"maxItems":4,"minItems":1,"type":"array"}},"required":["results"],"type":"object"}}},"description":"Atomic batch committed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-maximumBytes":16384,"x-registry-maximumItems":4,"x-registry-operation":"batch"}},"/v1/records/inspections":{"get":{"operationId":"records.inspection-event.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.inspection-event.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"create"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.inspection-event.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"get"}},"/v1/records/placements":{"get":{"operationId":"records.asset-placement.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-placement.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"create"}},"/v1/records/placements/{record_id}":{"get":{"operationId":"records.asset-placement.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"get"},"patch":{"operationId":"records.asset-placement.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"patch"}},"/v1/records/placements:as-of":{"get":{"operationId":"records.asset-placement.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/placements:current":{"get":{"operationId":"records.asset-placement.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/sites":{"get":{"operationId":"records.asset-site.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-site.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"create"}},"/v1/records/sites/{record_id}":{"get":{"operationId":"records.asset-site.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"get"},"patch":{"operationId":"records.asset-site.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"patch"}}}} \ No newline at end of file +{"components":{"schemas":{"asset-item":{"$id":"urn:registry-server:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-placement":{"$id":"urn:registry-server:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"format":"date","type":"string"}},"required":["asset","site","validFrom"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site":{"$id":"urn:registry-server:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"siteCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["siteCode","label"],"type":"object","x-registry-mutationMode":"mutable"},"inspection-event":{"$id":"urn:registry-server:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result"],"type":"object","x-registry-mutationMode":"create_only"}}},"info":{"title":"asset-site-placement","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/assets":{"get":{"operationId":"records.asset-item.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-item.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"create"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset-item.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"get"},"patch":{"operationId":"records.asset-item.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"patch"}},"/v1/records/assets:batch":{"post":{"operationId":"records.asset-item.batch","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"items":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"operation":{"const":"create"}},"required":["operation","data"],"type":"object"},{"additionalProperties":false,"properties":{"ifMatch":{"type":"string"},"operation":{"const":"patch"},"patch":{"maxItems":128,"minItems":1,"type":"array"},"recordId":{"format":"uuid","type":"string"}},"required":["operation","recordId","ifMatch","patch"],"type":"object"}]},"maxItems":4,"minItems":1,"type":"array"}},"required":["items"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"results":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"etag":{"type":"string"},"id":{"format":"uuid","type":"string"},"operation":{"enum":["create","patch"]},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["operation","id","revision","etag","data"],"type":"object"},"maxItems":4,"minItems":1,"type":"array"}},"required":["results"],"type":"object"}}},"description":"Atomic batch committed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-maximumBytes":16384,"x-registry-maximumItems":4,"x-registry-operation":"batch"}},"/v1/records/inspections":{"get":{"operationId":"records.inspection-event.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.inspection-event.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"create"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.inspection-event.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"get"}},"/v1/records/placements":{"get":{"operationId":"records.asset-placement.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-placement.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"create"}},"/v1/records/placements/{record_id}":{"get":{"operationId":"records.asset-placement.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"get"},"patch":{"operationId":"records.asset-placement.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"patch"}},"/v1/records/placements:as-of":{"get":{"operationId":"records.asset-placement.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/placements:current":{"get":{"operationId":"records.asset-placement.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/sites":{"get":{"operationId":"records.asset-site.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-site.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"create"}},"/v1/records/sites/{record_id}":{"get":{"operationId":"records.asset-site.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"get"},"patch":{"operationId":"records.asset-site.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"patch"}}}} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql b/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql index 046fd7dc6c..fab0b310b0 100644 --- a/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql +++ b/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql @@ -27,11 +27,6 @@ CREATE POLICY "registry_rls_select_ae0796eafa1e9eac571bb87c" ON registry_data."r CREATE POLICY "registry_rls_insert_d025d90a72995a769e8a6173" ON registry_data."rs_e_asset_item_847d26c3e6e68a51" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_update_705b1fb4f79ed895a0e92256" ON registry_data."rs_e_asset_item_847d26c3e6e68a51" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_select_ef9fd8aeff50702410afaaaa" ON registry_data."rs_e_asset_item_847d26c3e6e68a51" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE VIEW registry_source."asset_item" - WITH (security_invoker=true, security_barrier=true) - AS SELECT record_id AS id, "rs_f_asset_item_asset_code_3dcfb11c8485c27d" AS "asset_code", "rs_f_asset_item_label_07f15f9906c86214" AS "label", "rs_f_asset_item_asset_class_d600d2cfe0601df0" AS "asset_class" - FROM registry_data."rs_e_asset_item_847d26c3e6e68a51" - WHERE record_lifecycle = 'active'; ALTER TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."rs_e_asset_placement_36f204044c8d76ff" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_607646a772003fc998702c5d" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); @@ -40,28 +35,33 @@ CREATE POLICY "registry_rls_update_3954a4ba2983e7cdfcde8af4" ON registry_data."r CREATE POLICY "registry_rls_select_975f856168c7c15a912dda52" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_insert_bc00fa6b634ab2ca59bb7efd" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_update_240cb40a7ff28a3eda3e35fa" ON registry_data."rs_e_asset_placement_36f204044c8d76ff" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE VIEW registry_source."asset_placement" - WITH (security_invoker=true, security_barrier=true) - AS SELECT record_id AS id, "rs_f_asset_placement_asset_c9ca09383d36692d" AS "asset", "rs_f_asset_placement_site_1f363a0accf66d99" AS "site", "rs_f_asset_placement_valid_from_26d05bd0c44857c7" AS "valid_from", "rs_f_asset_placement_valid_to_f90aaf0250c93a70" AS "valid_to" - FROM registry_data."rs_e_asset_placement_36f204044c8d76ff" - WHERE record_lifecycle = 'active'; ALTER TABLE registry_data."rs_e_asset_site_db7008b8eaed2382" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."rs_e_asset_site_db7008b8eaed2382" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_34fa8a622e702a16f5b0b398" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_insert_e160cd033236bc16b7069084" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_update_50997e5bd81b338659ce5217" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_select_1077759afe589ed883cbf7e8" ON registry_data."rs_e_asset_site_db7008b8eaed2382" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE VIEW registry_source."asset_site" - WITH (security_invoker=true, security_barrier=true) - AS SELECT record_id AS id, "rs_f_asset_site_site_code_078b8d51a606a531" AS "site_code", "rs_f_asset_site_label_12f77c179e4d46c0" AS "label" - FROM registry_data."rs_e_asset_site_db7008b8eaed2382" - WHERE record_lifecycle = 'active'; ALTER TABLE registry_data."rs_e_inspection_event_8d78d2871d86ffa3" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."rs_e_inspection_event_8d78d2871d86ffa3" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_160ed0ec696ef3506f21244c" ON registry_data."rs_e_inspection_event_8d78d2871d86ffa3" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_insert_e57571ab1a9e5b1bd4f66b59" ON registry_data."rs_e_inspection_event_8d78d2871d86ffa3" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); +CREATE VIEW registry_source."asset_item" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS "id", "rs_f_asset_item_asset_code_3dcfb11c8485c27d" AS "asset_code", "rs_f_asset_item_label_07f15f9906c86214" AS "label", "rs_f_asset_item_asset_class_d600d2cfe0601df0" AS "asset_class" + FROM registry_data."rs_e_asset_item_847d26c3e6e68a51" + WHERE record_lifecycle = 'active'; +CREATE VIEW registry_source."asset_placement" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS "id", "rs_f_asset_placement_asset_c9ca09383d36692d" AS "asset", "rs_f_asset_placement_site_1f363a0accf66d99" AS "site", "rs_f_asset_placement_valid_from_26d05bd0c44857c7" AS "valid_from", "rs_f_asset_placement_valid_to_f90aaf0250c93a70" AS "valid_to" + FROM registry_data."rs_e_asset_placement_36f204044c8d76ff" + WHERE record_lifecycle = 'active'; +CREATE VIEW registry_source."asset_site" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS "id", "rs_f_asset_site_site_code_078b8d51a606a531" AS "site_code", "rs_f_asset_site_label_12f77c179e4d46c0" AS "label" + FROM registry_data."rs_e_asset_site_db7008b8eaed2382" + WHERE record_lifecycle = 'active'; CREATE VIEW registry_source."inspection_event" WITH (security_invoker=true, security_barrier=true) - AS SELECT record_id AS id, "rs_f_inspection_event_asset_3a5ad890cb504dce" AS "asset", "rs_f_inspection_event_observed_at_5ae9cec8794e85a2" AS "observed_at", "rs_f_inspection_event_result_f0d09fc75deb558a" AS "result" + AS SELECT record_id AS "id", "rs_f_inspection_event_asset_3a5ad890cb504dce" AS "asset", "rs_f_inspection_event_observed_at_5ae9cec8794e85a2" AS "observed_at", "rs_f_inspection_event_result_f0d09fc75deb558a" AS "result" FROM registry_data."rs_e_inspection_event_8d78d2871d86ffa3" WHERE record_lifecycle = 'active'; diff --git a/products/registry-server/generated/asset-site-placement/generated/schemas/asset-item.schema.json b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-item.schema.json index c55206a5ca..15f500029f 100644 --- a/products/registry-server/generated/asset-site-placement/generated/schemas/asset-item.schema.json +++ b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-item.schema.json @@ -1 +1 @@ -{"$id":"urn:registry-server:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset-class":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"asset-code":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["asset-class","asset-code","label"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:registry-server:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/schemas/asset-placement.schema.json b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-placement.schema.json index 3f74cf3b14..83651a836d 100644 --- a/products/registry-server/generated/asset-site-placement/generated/schemas/asset-placement.schema.json +++ b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-placement.schema.json @@ -1 +1 @@ -{"$id":"urn:registry-server:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["asset","site","valid-from"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:registry-server:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"format":"date","type":"string"}},"required":["asset","site","validFrom"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/schemas/asset-site.schema.json b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-site.schema.json index e5e2e55c75..983304b8b9 100644 --- a/products/registry-server/generated/asset-site-placement/generated/schemas/asset-site.schema.json +++ b/products/registry-server/generated/asset-site-placement/generated/schemas/asset-site.schema.json @@ -1 +1 @@ -{"$id":"urn:registry-server:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"site-code":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label","site-code"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:registry-server:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"siteCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["siteCode","label"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/generated/asset-site-placement/generated/schemas/inspection-event.schema.json b/products/registry-server/generated/asset-site-placement/generated/schemas/inspection-event.schema.json index 1264cd3525..2e86aa638e 100644 --- a/products/registry-server/generated/asset-site-placement/generated/schemas/inspection-event.schema.json +++ b/products/registry-server/generated/asset-site-placement/generated/schemas/inspection-event.schema.json @@ -1 +1 @@ -{"$id":"urn:registry-server:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observed-at":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observed-at","result"],"type":"object","x-registry-mutationMode":"create_only"} \ No newline at end of file +{"$id":"urn:registry-server:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result"],"type":"object","x-registry-mutationMode":"create_only"} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/openapi.json b/products/registry-server/generated/publicschema-household/generated/openapi.json index dc82dc3a2b..3703798410 100644 --- a/products/registry-server/generated/publicschema-household/generated/openapi.json +++ b/products/registry-server/generated/publicschema-household/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"group-membership":{"$id":"urn:registry-server:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["household","person","relationship","valid-from"],"type":"object","x-registry-mutationMode":"mutable"},"household":{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrative-area":{"maxLength":80,"minLength":0,"type":"string"},"child-count":{"format":"int64","readOnly":true,"type":"integer"},"child-under-5-count":{"format":"int64","readOnly":true,"type":"integer"},"elderly-count":{"format":"int64","readOnly":true,"type":"integer"},"head-count":{"format":"int64","readOnly":true,"type":"integer"},"household-code":{"maxLength":64,"minLength":0,"type":"string"},"household-name":{"maxLength":160,"minLength":0,"type":"string"},"household-type":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"local-household-number":{"format":"int64","type":"integer"},"single-headed":{"readOnly":true,"type":"boolean"},"woman-headed":{"readOnly":true,"type":"boolean"}},"required":["administrative-area","household-code","household-name","household-type","local-household-number"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:registry-server:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"date-of-birth":{"format":"date","type":"string"},"family-name":{"maxLength":120,"minLength":0,"type":"string"},"legal-name":{"maxLength":160,"minLength":0,"type":"string"},"person-code":{"maxLength":64,"minLength":0,"type":"string"},"person-sex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferred-language":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residency-status":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["legal-name","person-code","person-sex","residency-status"],"type":"object","x-registry-mutationMode":"mutable"}}},"info":{"title":"publicschema-household","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.group-membership.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"create"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get"},"patch":{"operationId":"records.group-membership.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"patch"}},"/v1/records/group-memberships:as-of":{"get":{"operationId":"records.group-membership.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/group-memberships:current":{"get":{"operationId":"records.group-membership.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/households":{"get":{"operationId":"records.household.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.household.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"get"},"patch":{"operationId":"records.household.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"patch"}},"/v1/records/households/{record_id}/people":{"get":{"operationId":"records.household.path.people","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"}},"/v1/records/households:lookup":{"post":{"operationId":"records.household.lookup","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"lookup"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.person.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"create"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get"},"patch":{"operationId":"records.person.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"patch"}}}} \ No newline at end of file +{"components":{"schemas":{"group-membership":{"$id":"urn:registry-server:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"validFrom":{"format":"date","type":"string"},"validTo":{"format":"date","type":"string"}},"required":["person","household","relationship","validFrom"],"type":"object","x-registry-mutationMode":"mutable"},"household":{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"childCount":{"format":"int64","readOnly":true,"type":"integer"},"childUnder5Count":{"format":"int64","readOnly":true,"type":"integer"},"elderlyCount":{"format":"int64","readOnly":true,"type":"integer"},"headCount":{"format":"int64","readOnly":true,"type":"integer"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"},"singleHeaded":{"readOnly":true,"type":"boolean"},"womanHeaded":{"readOnly":true,"type":"boolean"}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:registry-server:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"dateOfBirth":{"format":"date","type":"string"},"familyName":{"maxLength":120,"minLength":0,"type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferredLanguage":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["personCode","legalName","personSex","residencyStatus"],"type":"object","x-registry-mutationMode":"mutable"}}},"info":{"title":"publicschema-household","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.group-membership.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"create"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get"},"patch":{"operationId":"records.group-membership.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"patch"}},"/v1/records/group-memberships:as-of":{"get":{"operationId":"records.group-membership.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/group-memberships:current":{"get":{"operationId":"records.group-membership.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/households":{"get":{"operationId":"records.household.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.household.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"get"},"patch":{"operationId":"records.household.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"patch"}},"/v1/records/households/{record_id}/people":{"get":{"operationId":"records.household.path.people","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"}},"/v1/records/households:lookup":{"post":{"operationId":"records.household.lookup","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"lookup"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.person.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"create"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get"},"patch":{"operationId":"records.person.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"patch"}}}} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql index 60937a17ab..c55911b06b 100644 --- a/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql +++ b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql @@ -34,23 +34,13 @@ CREATE POLICY "registry_path_rls_select_d40194fdac27176f50e7bab9" ON registry_da AND path_source.record_lifecycle = 'active' AND (NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) )); -CREATE VIEW registry_source."group_membership" - WITH (security_invoker=true, security_barrier=true) - AS SELECT record_id AS id, "rs_f_group_membership_person_f16f370962050e27" AS "person", "rs_f_group_membership_household_9ce011eef65483bd" AS "household", "rs_f_group_membership_relationship_4da0b16845ccd25c" AS "relationship", "rs_f_group_membership_valid_from_9982e6778a7c4410" AS "valid_from", "rs_f_group_membership_valid_to_6eb49ef9d6a65085" AS "valid_to" - FROM registry_data."rs_e_group_membership_6b97f4204f141f28" - WHERE record_lifecycle = 'active'; ALTER TABLE registry_data."rs_e_household_45e8576d356a1f75" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."rs_e_household_45e8576d356a1f75" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_f2348f1ea686c085c254174f" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_insert_a49ea44589b3bfd7550c1880" ON registry_data."rs_e_household_45e8576d356a1f75" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_update_b573105c01cf4eb57ca69c80" ON registry_data."rs_e_household_45e8576d356a1f75" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE POLICY "registry_rls_select_bf0afc959f020c9105952b7d" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-viewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-view') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'id' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND record_id = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::uuid) AND record_lifecycle = 'active'); +CREATE POLICY "registry_rls_select_bf0afc959f020c9105952b7d" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-viewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-view') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'id' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "record_id" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::uuid) AND record_lifecycle = 'active'); CREATE POLICY "registry_path_rls_select_c8cb6ccd10712973c3e46aaf" ON registry_data."rs_e_household_45e8576d356a1f75" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND NULLIF(current_setting('registry.read_path_id', true), '') = 'people' AND record_id = NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid AND record_lifecycle = 'active'); -CREATE VIEW registry_source."household" - WITH (security_invoker=true, security_barrier=true) - AS SELECT record_id AS id, "rs_f_household_household_code_44029c0143d71ab3" AS "household_code", "rs_f_household_local_household_number_040305e8ef37727d" AS "local_household_number", "rs_f_household_household_name_aeac0ac6071a6b3d" AS "household_name", "rs_f_household_administrative_area_1946b433a9241a87" AS "administrative_area", "rs_f_household_household_type_87fa3a1f7183bbe0" AS "household_type" - FROM registry_data."rs_e_household_45e8576d356a1f75" - WHERE record_lifecycle = 'active'; ALTER TABLE registry_data."rs_e_person_a28225974420754a" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."rs_e_person_a28225974420754a" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_799283181617a6fa58c49141" ON registry_data."rs_e_person_a28225974420754a" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); @@ -62,15 +52,25 @@ CREATE POLICY "registry_path_rls_select_b32737d82dfcb2dfac05ef5c" ON registry_da FROM registry_data."rs_e_group_membership_6b97f4204f141f28" AS path_edge JOIN registry_data."rs_e_household_45e8576d356a1f75" AS path_source ON path_source.record_id = path_edge."rs_f_group_membership_household_9ce011eef65483bd" - WHERE path_edge."rs_f_group_membership_person_f16f370962050e27" = "rs_e_person_a28225974420754a".record_id + WHERE path_edge."rs_f_group_membership_person_f16f370962050e27" = "rs_e_person_a28225974420754a"."record_id" AND path_edge."rs_f_group_membership_household_9ce011eef65483bd" = NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid AND path_edge.record_lifecycle = 'active' AND path_source.record_lifecycle = 'active' AND (NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) )); +CREATE VIEW registry_source."group_membership" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS "id", "rs_f_group_membership_person_f16f370962050e27" AS "person", "rs_f_group_membership_household_9ce011eef65483bd" AS "household", "rs_f_group_membership_relationship_4da0b16845ccd25c" AS "relationship", "rs_f_group_membership_valid_from_9982e6778a7c4410" AS "valid_from", "rs_f_group_membership_valid_to_6eb49ef9d6a65085" AS "valid_to" + FROM registry_data."rs_e_group_membership_6b97f4204f141f28" + WHERE record_lifecycle = 'active'; +CREATE VIEW registry_source."household" + WITH (security_invoker=true, security_barrier=true) + AS SELECT record_id AS "id", "rs_f_household_household_code_44029c0143d71ab3" AS "household_code", "rs_f_household_local_household_number_040305e8ef37727d" AS "local_household_number", "rs_f_household_household_name_aeac0ac6071a6b3d" AS "household_name", "rs_f_household_administrative_area_1946b433a9241a87" AS "administrative_area", "rs_f_household_household_type_87fa3a1f7183bbe0" AS "household_type" + FROM registry_data."rs_e_household_45e8576d356a1f75" + WHERE record_lifecycle = 'active'; CREATE VIEW registry_source."person" WITH (security_invoker=true, security_barrier=true) - AS SELECT record_id AS id, "rs_f_person_person_code_7514464caf72c5a7" AS "person_code", "rs_f_person_legal_name_142f648a19dcd2a4" AS "legal_name", "rs_f_person_family_name_1a6b1252713201d7" AS "family_name", "rs_f_person_date_of_birth_d4d8fa151f4a4285" AS "date_of_birth", "rs_f_person_person_sex_01e02174128c75d2" AS "person_sex", "rs_f_person_residency_status_19ed35302430c5ac" AS "residency_status", "rs_f_person_preferred_language_d36dc5f1bd7bec3c" AS "preferred_language" + AS SELECT record_id AS "id", "rs_f_person_person_code_7514464caf72c5a7" AS "person_code", "rs_f_person_legal_name_142f648a19dcd2a4" AS "legal_name", "rs_f_person_family_name_1a6b1252713201d7" AS "family_name", "rs_f_person_date_of_birth_d4d8fa151f4a4285" AS "date_of_birth", "rs_f_person_person_sex_01e02174128c75d2" AS "person_sex", "rs_f_person_residency_status_19ed35302430c5ac" AS "residency_status", "rs_f_person_preferred_language_d36dc5f1bd7bec3c" AS "preferred_language" FROM registry_data."rs_e_person_a28225974420754a" WHERE record_lifecycle = 'active'; CREATE VIEW registry_derived."household__household_demographics" diff --git a/products/registry-server/generated/publicschema-household/generated/schemas/group-membership.schema.json b/products/registry-server/generated/publicschema-household/generated/schemas/group-membership.schema.json index bfd3c061cd..6140e5a3d2 100644 --- a/products/registry-server/generated/publicschema-household/generated/schemas/group-membership.schema.json +++ b/products/registry-server/generated/publicschema-household/generated/schemas/group-membership.schema.json @@ -1 +1 @@ -{"$id":"urn:registry-server:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"valid-from":{"format":"date","type":"string"},"valid-to":{"format":"date","type":"string"}},"required":["household","person","relationship","valid-from"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:registry-server:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"validFrom":{"format":"date","type":"string"},"validTo":{"format":"date","type":"string"}},"required":["person","household","relationship","validFrom"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json b/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json index 34d8c7c813..1237f97d39 100644 --- a/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json +++ b/products/registry-server/generated/publicschema-household/generated/schemas/household.schema.json @@ -1 +1 @@ -{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrative-area":{"maxLength":80,"minLength":0,"type":"string"},"child-count":{"format":"int64","readOnly":true,"type":"integer"},"child-under-5-count":{"format":"int64","readOnly":true,"type":"integer"},"elderly-count":{"format":"int64","readOnly":true,"type":"integer"},"head-count":{"format":"int64","readOnly":true,"type":"integer"},"household-code":{"maxLength":64,"minLength":0,"type":"string"},"household-name":{"maxLength":160,"minLength":0,"type":"string"},"household-type":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"local-household-number":{"format":"int64","type":"integer"},"single-headed":{"readOnly":true,"type":"boolean"},"woman-headed":{"readOnly":true,"type":"boolean"}},"required":["administrative-area","household-code","household-name","household-type","local-household-number"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"childCount":{"format":"int64","readOnly":true,"type":"integer"},"childUnder5Count":{"format":"int64","readOnly":true,"type":"integer"},"elderlyCount":{"format":"int64","readOnly":true,"type":"integer"},"headCount":{"format":"int64","readOnly":true,"type":"integer"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"},"singleHeaded":{"readOnly":true,"type":"boolean"},"womanHeaded":{"readOnly":true,"type":"boolean"}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/registry-server/generated/publicschema-household/generated/schemas/person.schema.json b/products/registry-server/generated/publicschema-household/generated/schemas/person.schema.json index a9412445ed..79d7ac42c8 100644 --- a/products/registry-server/generated/publicschema-household/generated/schemas/person.schema.json +++ b/products/registry-server/generated/publicschema-household/generated/schemas/person.schema.json @@ -1 +1 @@ -{"$id":"urn:registry-server:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"date-of-birth":{"format":"date","type":"string"},"family-name":{"maxLength":120,"minLength":0,"type":"string"},"legal-name":{"maxLength":160,"minLength":0,"type":"string"},"person-code":{"maxLength":64,"minLength":0,"type":"string"},"person-sex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferred-language":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residency-status":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["legal-name","person-code","person-sex","residency-status"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:registry-server:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"dateOfBirth":{"format":"date","type":"string"},"familyName":{"maxLength":120,"minLength":0,"type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferredLanguage":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["personCode","legalName","personSex","residencyStatus"],"type":"object","x-registry-mutationMode":"mutable"} \ No newline at end of file From 0e0afaf013997b33e71c397ad25826689638ce3a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 31 Aug 2026 01:38:29 +0700 Subject: [PATCH 10/19] feat(registry-server): streamline adopter experience Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 3 + .../examples/runtime-schema.rs | 47 + crates/registry-server/src/api/mod.rs | 699 ++++----- crates/registry-server/src/api/service.rs | 18 + crates/registry-server/src/artifacts.rs | 1348 ++++++++++++++--- crates/registry-server/src/audit.rs | 22 + crates/registry-server/src/auth.rs | 10 +- crates/registry-server/src/compiler.rs | 60 +- crates/registry-server/src/contract.rs | 15 +- crates/registry-server/src/correlation.rs | 196 +++ .../registry-server/src/event_destination.rs | 5 + crates/registry-server/src/lib.rs | 2 + crates/registry-server/src/mutation.rs | 13 + crates/registry-server/src/package.rs | 36 +- .../registry-server/src/postgres/context.rs | 80 +- .../registry-server/src/postgres/mutation.rs | 44 +- crates/registry-server/src/postgres/read.rs | 15 +- .../src/postgres/revision_read.rs | 4 + crates/registry-server/src/runtime_config.rs | 788 ++++++++++ crates/registry-server/src/schema.rs | 534 +++++++ crates/registry-server/src/startup.rs | 25 +- .../tests/compiler_contract.rs | 830 ++++++++-- .../registry-server/tests/data_operations.rs | 40 +- .../fixtures/fixture-tooling/project.yaml | 12 +- crates/registry-server/tests/http_auth.rs | 30 +- .../registry-server/tests/http_read_only.rs | 325 ++-- .../registry-server/tests/migration_plan.rs | 9 +- .../tests/package_change_plan.rs | 2 +- .../registry-server/tests/postgres_batch.rs | 40 +- .../tests/postgres_compiled_schema.rs | 57 +- .../tests/postgres_constraint_races.rs | 35 +- .../tests/postgres_data_export.rs | 9 +- .../tests/postgres_fixture_journeys.rs | 4 +- .../tests/postgres_mutation.rs | 161 +- .../registry-server/tests/postgres_package.rs | 164 +- .../tests/postgres_partial_unique.rs | 8 +- crates/registry-server/tests/postgres_read.rs | 67 +- .../tests/postgres_revision_http.rs | 11 +- .../registry-server/tests/postgres_startup.rs | 3 +- .../tests/postgres_tombstone_revision.rs | 30 +- .../tests/postgres_webhook_delivery.rs | 22 +- .../tests/postgres_webhook_outbox.rs | 23 +- .../registry-server/tests/runtime_config.rs | 261 +++- .../tests/schema_fingerprint_rehearsal.rs | 14 +- crates/registry-server/tests/startup_http.rs | 308 +++- .../registry-server/tests/startup_ordering.rs | 3 +- .../tests/support/pilot_acceptance_harness.rs | 4 +- crates/registry-serverctl/README.md | 10 + .../registry-serverctl/src/apply_lifecycle.rs | 6 +- .../registry-serverctl/src/data_lifecycle.rs | 11 +- crates/registry-serverctl/src/lib.rs | 940 ++++++++++-- crates/registry-serverctl/tests/cli.rs | 328 +++- crates/registry-serverctl/tests/diff.rs | 7 +- products/registry-server/DECISIONS.md | 17 +- products/registry-server/README.md | 50 + .../asset-site-placement/registry.yaml | 18 +- .../acceptance/business/registry.yaml | 32 +- .../acceptance/disability/registry.yaml | 8 +- .../acceptance/farmer/registry.yaml | 10 +- .../publicschema-household/registry.yaml | 12 +- .../contracts/artifact-inventory.yaml | 2 + .../contracts/definition-of-done.yaml | 8 +- .../contracts/package-layout.yaml | 6 +- products/registry-server/demo/support/demo.py | 4 +- .../registry-server/demo/support/test_demo.py | 2 + .../generated/openapi.json | 2 +- .../authoring/registry-project.schema.json | 137 +- .../generated/openapi.json | 2 +- .../generated/runtime/runtime.schema.json | 849 +++++++++++ .../registry-server/quickstart/.gitignore | 1 + products/registry-server/quickstart/README.md | 61 + products/registry-server/quickstart/query.sh | 64 + products/registry-server/quickstart/run.sh | 266 ++++ .../registry-server/quickstart/self-test.sh | 11 + .../quickstart/support/quickstart.py | 587 +++++++ .../scripts/check-contracts.sh | 1 + .../scripts/check-generated.sh | 13 + .../scripts/test-adopter-workflow.sh | 2 + .../scripts/test_generated_gates.py | 5 + .../scripts/test_quickstart.py | 38 + .../scripts/validate_product.py | 4 +- 81 files changed, 8417 insertions(+), 1533 deletions(-) create mode 100644 crates/registry-server/examples/runtime-schema.rs create mode 100644 crates/registry-server/src/correlation.rs create mode 100644 products/registry-server/generated/runtime/runtime.schema.json create mode 100644 products/registry-server/quickstart/.gitignore create mode 100644 products/registry-server/quickstart/README.md create mode 100755 products/registry-server/quickstart/query.sh create mode 100755 products/registry-server/quickstart/run.sh create mode 100755 products/registry-server/quickstart/self-test.sh create mode 100755 products/registry-server/quickstart/support/quickstart.py create mode 100755 products/registry-server/scripts/test_quickstart.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d4e9c2c73..d53dd5316c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -647,6 +647,9 @@ jobs: - name: Registry Server clean-checkout adopter workflow run: products/registry-server/scripts/test-adopter-workflow.sh + - name: Registry Server generic quickstart + run: products/registry-server/quickstart/run.sh --smoke + identifiers: name: Public identifier catalog needs: changes diff --git a/crates/registry-server/examples/runtime-schema.rs b/crates/registry-server/examples/runtime-schema.rs new file mode 100644 index 0000000000..0aeee06cb2 --- /dev/null +++ b/crates/registry-server/examples/runtime-schema.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(all(feature = "runtime", feature = "schema"))] +use std::{env, fs, path::PathBuf, process::ExitCode}; + +#[cfg(all(feature = "runtime", feature = "schema"))] +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("runtime schema generation failed: {message}"); + ExitCode::FAILURE + } + } +} + +#[cfg(all(feature = "runtime", feature = "schema"))] +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: runtime-schema --output ".to_owned()); + } + let output = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| "usage: runtime-schema --output ".to_owned())?; + if arguments.next().is_some() { + return Err("usage: runtime-schema --output ".to_owned()); + } + + let documents = registry_server::schema::runtime_documents() + .map_err(|error| format!("the runtime schema could not be generated: {error}"))?; + fs::create_dir_all(&output) + .map_err(|error| format!("failed to create {}: {error}", output.display()))?; + for (name, contents) in documents { + let path = output.join(name); + fs::write(&path, contents) + .map_err(|error| format!("failed to write {}: {error}", path.display()))?; + } + Ok(()) +} + +#[cfg(not(all(feature = "runtime", feature = "schema")))] +fn main() -> std::process::ExitCode { + eprintln!("runtime-schema requires the registry-server runtime and schema features"); + std::process::ExitCode::from(2) +} diff --git a/crates/registry-server/src/api/mod.rs b/crates/registry-server/src/api/mod.rs index ac1dc30e2c..eca93340b4 100644 --- a/crates/registry-server/src/api/mod.rs +++ b/crates/registry-server/src/api/mod.rs @@ -15,7 +15,7 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, patch, post}; use axum::{middleware, Extension, Json, Router}; use registry_platform_canonical_json::parse_json_strict; -use registry_platform_httpsec::{security_headers, CspBuilder, Problem}; +use registry_platform_httpsec::{security_headers, CspBuilder}; use serde_json::{json, Map, Value}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; @@ -25,11 +25,11 @@ pub use context::{ }; pub use service::{ BatchMutationInput, CompiledLookupSelector, CompiledReadQuery, ConditionalMutationInput, - HeldReadResponse, HttpService, LookupSelectorValue, ReadFilterExpr, ReadFilterOperator, - ReadFilterPredicate, ReadLogicalOp, ReadOrderClause, ReadProjectionField, ReadRuntimeIdentity, - ReadServiceError, ReadinessProbe, RecordReadKind, RecordReadRefusal, RecordReadRequest, - RecordReadService, RevisionReadRefusal, RevisionReadRequest, RevisionReadService, - ServiceFuture, + CreateMutationInput, HeldReadResponse, HttpService, LookupSelectorValue, ReadFilterExpr, + ReadFilterOperator, ReadFilterPredicate, ReadLogicalOp, ReadOrderClause, ReadProjectionField, + ReadRuntimeIdentity, ReadServiceError, ReadinessProbe, RecordReadKind, RecordReadRefusal, + RecordReadRequest, RecordReadService, RevisionReadRefusal, RevisionReadRequest, + RevisionReadService, ServiceFuture, }; use crate::auth::{authenticate_request, RegistryAuthenticator}; @@ -37,6 +37,7 @@ use crate::contract::{ AccessProfileSource, BoundaryOperator, Classification, FieldTypeSource, LookupValueOrigin, Operation, }; +use crate::correlation::RequestCorrelation; use crate::cursor::{ now_unix_seconds, CursorBinding, CursorError, CursorFilterExpr, CursorFilterOperator, CursorFilterPredicate, CursorLogicalOp, CursorOrderClause, CursorProjectionField, @@ -52,6 +53,11 @@ use crate::mutation::{parse_json_patch_document, BatchMutationItem, MutationErro use crate::query as strict_query; use uuid::Uuid; +use crate::artifacts::{ + openapi_components, openapi_entity_input_schema, openapi_input_schema_id, openapi_operation, + OpenApiAccessProfiles, OpenApiOperationSpec, +}; + const MAX_MUTATION_BODY_BYTES: usize = 2 * 1024 * 1024; const MAX_LOOKUP_BODY_BYTES: usize = 16 * 1024; const MAX_IDEMPOTENCY_KEY_BYTES: usize = 256; @@ -65,11 +71,14 @@ const MAX_IN_VALUES: usize = 100; /// This seam preserves focused authorization and record-kernel tests without /// allowing request headers or query values to construct authority. pub fn router(service: Arc) -> Router { - route_set(service).layer(security_headers(CspBuilder::restrictive())) + route_set(service) + .layer(middleware::from_fn(crate::correlation::observe)) + .layer(security_headers(CspBuilder::restrictive())) } fn route_set(service: Arc) -> Router { let mut app = Router::new() + .route("/health", get(health)) .route("/healthz", get(health)) .route("/ready", get(ready)) .route("/openapi.json", get(openapi)) @@ -151,6 +160,7 @@ pub fn authenticated_router( authenticator, authenticate_request, )) + .layer(middleware::from_fn(crate::correlation::observe)) .layer(security_headers(CspBuilder::restrictive())) } @@ -188,6 +198,8 @@ async fn openapi( let mut paths = Map::new(); let mut readable_by_entity: BTreeMap> = BTreeMap::new(); + let mut writable_by_input_schema: BTreeMap)> = + BTreeMap::new(); for surface in &visible { let path = paths .entry(surface.route.path.clone()) @@ -195,84 +207,22 @@ async fn openapi( let Value::Object(methods) = path else { unreachable!("OpenAPI paths are objects") }; - let mut operation = Map::from_iter([ - ("operationId".to_owned(), json!(surface.route.id)), - ( - "x-registry-entity".to_owned(), - json!(surface.route.entity_id), - ), - ( - "x-registry-responseEntity".to_owned(), - json!(surface.response_entity.id), - ), - ( - "x-registry-operation".to_owned(), - json!(operation_name(surface.route.operation)), - ), - ( - "x-registry-accessProfile".to_owned(), - json!(surface.context.selected_profile()), - ), - ( - "responses".to_owned(), - json!({"200": {"description": "Operation completed"}}), - ), - ]); - if let Some(kind) = surface.route.query_kind { - operation.insert( - "x-registry-queryKind".to_owned(), - Value::String(query_kind_name(kind).to_owned()), - ); - operation.insert("parameters".to_owned(), query_parameters(kind)); - } else if surface.route.operation == Operation::Lookup { - operation.insert("parameters".to_owned(), lookup_parameters()); - operation.insert("requestBody".to_owned(), lookup_request_body()); - } else if let Some(kind) = surface.route.revision_kind { - operation.insert("parameters".to_owned(), revision_parameters(kind)); - operation.insert( - "x-registry-maximumRecords".to_owned(), - json!(surface.route.maximum_records), - ); - } else if surface.route.operation == Operation::Batch { - let batch = surface - .entity - .batch - .as_ref() - .expect("authorized batch routes have compiled bounds"); - let profile = &surface.entity.access_profiles[surface.context.selected_profile()]; - let allow_create = profile.operations.contains(&Operation::Create); - let allow_patch = profile.operations.contains(&Operation::Patch); - operation.insert("parameters".to_owned(), access_profile_parameters()); - operation.insert( - "x-registry-maximumItems".to_owned(), - json!(batch.maximum_items), - ); - operation.insert( - "x-registry-maximumBytes".to_owned(), - json!(batch.maximum_bytes), - ); - operation.insert( - "requestBody".to_owned(), - batch_request_body( - &surface.route.entity_id, - batch.maximum_items, - allow_create, - allow_patch, - ), - ); - operation.insert( - "responses".to_owned(), - batch_response( - &surface.route.entity_id, - batch.maximum_items, - allow_create, - allow_patch, - ), - ); - } + let request_schema_ref = + openapi_input_schema_id(&surface.entity.id, surface.route.operation); methods.insert( method_name(surface.route.method).to_owned(), - Value::Object(operation), + openapi_operation(OpenApiOperationSpec { + route: surface.route, + entity: surface.entity, + response_entity: surface.response_entity, + query: service.registry.queries(), + schema_ref: &surface.response_entity.id, + request_schema_ref: &request_schema_ref, + readable_fields: Some(&surface.readable_fields), + access_profiles: OpenApiAccessProfiles::Selected( + surface.context.selected_profile(), + ), + }), ); readable_by_entity .entry(surface.response_entity.id.clone()) @@ -280,19 +230,39 @@ async fn openapi( fields.extend(surface.readable_fields.iter().cloned()); }) .or_insert_with(|| surface.readable_fields.clone()); + if matches!( + surface.route.operation, + Operation::Create | Operation::Batch + ) { + let profile = &surface.entity.access_profiles[surface.context.selected_profile()]; + let entry = writable_by_input_schema + .entry(request_schema_ref) + .or_insert_with(|| (surface.entity.id.clone(), BTreeSet::new())); + entry.1.extend(profile.writable_fields.iter().cloned()); + } } - let schemas = readable_by_entity + let mut schemas = readable_by_entity .iter() .filter_map(|(entity_id, readable)| { filtered_schema(&service, entity_id, readable).map(|schema| (entity_id.clone(), schema)) }) .collect::>(); + schemas.extend(writable_by_input_schema.iter().filter_map( + |(schema_id, (entity_id, writable))| { + service.registry.entities().get(entity_id).map(|entity| { + ( + schema_id.clone(), + openapi_entity_input_schema(entity, Some(writable)), + ) + }) + }, + )); Json(json!({ "openapi": "3.1.0", "info": {"title": service.registry.registry_id(), "version": service.registry.version()}, "paths": paths, - "components": {"schemas": schemas} + "components": openapi_components(schemas) })) .into_response() } @@ -397,6 +367,7 @@ async fn entity_schema( async fn read_dispatch( State(service): State>, Extension(route): Extension, + Extension(correlation): Extension, claims: Option>, RawQuery(raw_query): RawQuery, Path(path): Path>, @@ -413,14 +384,21 @@ async fn read_dispatch( &claims, path.get("record_id"), invalid_query(), + &correlation, ) .await; } }; let Some(surface) = authorize_route(&service, &route, &claims, &options) else { - let response = - audited_read_concealment(&service, &route, &options, &claims, path.get("record_id")) - .await; + let response = audited_read_concealment( + &service, + &route, + &options, + &claims, + path.get("record_id"), + &correlation, + ) + .await; return response; }; @@ -433,11 +411,20 @@ async fn read_dispatch( &surface, path.get("record_id"), invalid_query(), + &correlation, ) .await; } let Some(record_id) = path.get("record_id") else { - return audited_read_refusal(&service, &route, &surface, None, concealed()).await; + return audited_read_refusal( + &service, + &route, + &surface, + None, + concealed(), + &correlation, + ) + .await; }; if !valid_canonical_record_uuid(record_id) { return audited_read_refusal( @@ -446,6 +433,7 @@ async fn read_dispatch( &surface, Some(record_id), concealed(), + &correlation, ) .await; } @@ -463,6 +451,7 @@ async fn read_dispatch( &surface, Some(record_id), concealed(), + &correlation, ) .await; } @@ -477,6 +466,7 @@ async fn read_dispatch( id: record_id.clone(), }, maximum_records: 1, + correlation: correlation.clone(), }; match service.records.get(request).await { Ok(Some(record)) => exact_json(record), @@ -497,6 +487,7 @@ async fn read_dispatch( &surface, path.get("record_id"), concealed(), + &correlation, ) .await; } @@ -518,6 +509,7 @@ async fn read_dispatch( &surface, path.get("record_id"), invalid_query(), + &correlation, ) .await; } @@ -528,6 +520,7 @@ async fn read_dispatch( &surface, path.get("record_id"), cursor_invalid(), + &correlation, ) .await; } @@ -545,6 +538,7 @@ async fn read_dispatch( &surface, path.get("record_id"), concealed(), + &correlation, ) .await; } @@ -569,6 +563,7 @@ async fn read_dispatch( selected_fields: readable_fields, kind, maximum_records, + correlation: correlation.clone(), }; match service.records.list(request).await { Ok(response) => exact_json_no_store(response), @@ -583,6 +578,7 @@ async fn read_dispatch( async fn lookup_dispatch( State(service): State>, Extension(route): Extension, + Extension(correlation): Extension, claims: Option>, RawQuery(raw_query): RawQuery, headers: HeaderMap, @@ -594,15 +590,31 @@ async fn lookup_dispatch( let options = match QueryOptions::parse(raw_query.as_deref(), true) { Ok(options) => options, Err(QueryParseError::Invalid) => { - return audited_known_read_refusal(&service, &route, &claims, None, invalid_query()) - .await; + return audited_known_read_refusal( + &service, + &route, + &claims, + None, + invalid_query(), + &correlation, + ) + .await; } }; let Some(surface) = authorize_route(&service, &route, &claims, &options) else { - return audited_read_concealment(&service, &route, &options, &claims, None).await; + return audited_read_concealment(&service, &route, &options, &claims, None, &correlation) + .await; }; if surface.read_path.is_some() || options.has_non_projection_query_members() { - return audited_read_refusal(&service, &route, &surface, None, invalid_query()).await; + return audited_read_refusal( + &service, + &route, + &surface, + None, + invalid_query(), + &correlation, + ) + .await; } let readable_fields = match resolve_select( surface.response_entity, @@ -612,45 +624,101 @@ async fn lookup_dispatch( Ok(Some(fields)) => fields, Ok(None) => surface.readable_fields.clone(), Err(()) => { - return audited_read_refusal(&service, &route, &surface, None, concealed()).await; + return audited_read_refusal( + &service, + &route, + &surface, + None, + concealed(), + &correlation, + ) + .await; } }; if !single_content_type(&headers, "application/json") { - return audited_read_refusal(&service, &route, &surface, None, unsupported_media_type()) - .await; + return audited_read_refusal( + &service, + &route, + &surface, + None, + unsupported_media_type(), + &correlation, + ) + .await; } let Ok(body) = bounded_body_to(body, MAX_LOOKUP_BODY_BYTES).await else { - return audited_read_refusal(&service, &route, &surface, None, invalid_request()).await; + return audited_read_refusal( + &service, + &route, + &surface, + None, + invalid_request(), + &correlation, + ) + .await; }; let body = match parse_lookup_body(&body) { Ok(body) => body, Err(()) => { - return audited_read_refusal(&service, &route, &surface, None, invalid_request()).await; + return audited_read_refusal( + &service, + &route, + &surface, + None, + invalid_request(), + &correlation, + ) + .await; } }; let selector = match resolve_lookup_selector(&service, &route, &surface, &claims, &body) { Ok(selector) => selector, Err(LookupResolutionError::InvalidRequest) => { - return audited_read_refusal(&service, &route, &surface, None, invalid_request()).await; + return audited_read_refusal( + &service, + &route, + &surface, + None, + invalid_request(), + &correlation, + ) + .await; } Err(LookupResolutionError::Unresolved) => { - return audited_read_refusal(&service, &route, &surface, None, lookup_unresolved()) - .await; + return audited_read_refusal( + &service, + &route, + &surface, + None, + lookup_unresolved(), + &correlation, + ) + .await; } }; if !readable_fields.is_subset(&surface.readable_fields) { - return audited_read_refusal(&service, &route, &surface, None, concealed()).await; + return audited_read_refusal(&service, &route, &surface, None, concealed(), &correlation) + .await; } let Some(operation) = lookup_query_operation_for_selector(&service, &route, &surface, &selector.selector_id) else { - return audited_read_refusal(&service, &route, &surface, None, lookup_unresolved()).await; + return audited_read_refusal( + &service, + &route, + &surface, + None, + lookup_unresolved(), + &correlation, + ) + .await; }; if !readable_fields .iter() .all(|field| operation.projection_fields.contains(field)) { - return audited_read_refusal(&service, &route, &surface, None, concealed()).await; + return audited_read_refusal(&service, &route, &surface, None, concealed(), &correlation) + .await; } let request = RecordReadRequest { entity_id: route.entity_id.clone(), @@ -660,6 +728,7 @@ async fn lookup_dispatch( selected_fields: readable_fields, kind: RecordReadKind::Lookup { selector }, maximum_records: 2, + correlation: correlation.clone(), }; match service.records.lookup(request).await { Ok(Some(record)) => exact_json_no_store(record), @@ -671,6 +740,7 @@ async fn lookup_dispatch( async fn revision_dispatch( State(service): State>, Extension(route): Extension, + Extension(correlation): Extension, claims: Option>, RawQuery(raw_query): RawQuery, Path(path): Path>, @@ -690,6 +760,7 @@ async fn revision_dispatch( &claims, path.get("record_id"), invalid_query(), + &correlation, ) .await; } @@ -701,12 +772,20 @@ async fn revision_dispatch( &options, &claims, path.get("record_id"), + &correlation, ) .await; }; let Some(record_id) = path.get("record_id") else { - return audited_revision_refusal(revisions.as_ref(), &route, &surface, None, concealed()) - .await; + return audited_revision_refusal( + revisions.as_ref(), + &route, + &surface, + None, + concealed(), + &correlation, + ) + .await; }; let revision = match route.revision_kind { Some(CompiledRevisionKind::List) if !path.contains_key("revision") => None, @@ -721,6 +800,7 @@ async fn revision_dispatch( &surface, Some(record_id), concealed(), + &correlation, ) .await; }; @@ -733,6 +813,7 @@ async fn revision_dispatch( &surface, Some(record_id), concealed(), + &correlation, ) .await; } @@ -744,6 +825,7 @@ async fn revision_dispatch( &surface, Some(record_id), concealed(), + &correlation, ) .await; } @@ -766,6 +848,7 @@ async fn revision_dispatch( context: surface.context, selected_fields: surface.readable_fields, maximum_records, + correlation: correlation.clone(), }; match route.revision_kind { Some(CompiledRevisionKind::List) => match revisions.list(request).await { @@ -788,6 +871,7 @@ async fn audited_known_revision_refusal( claims: &VerifiedRequestClaims, target_record: Option<&String>, response: Response, + correlation: &RequestCorrelation, ) -> Response { match revisions .refusal(RevisionReadRefusal { @@ -797,6 +881,7 @@ async fn audited_known_revision_refusal( principal: claims.principal().map(str::to_owned), selected_access_profile: None, purpose_present: claims.purpose().is_some(), + correlation: correlation.clone(), }) .await { @@ -811,6 +896,7 @@ async fn audited_revision_refusal( surface: &AuthorizedSurface<'_>, target_record: Option<&String>, response: Response, + correlation: &RequestCorrelation, ) -> Response { match revisions .refusal(RevisionReadRefusal { @@ -820,6 +906,7 @@ async fn audited_revision_refusal( principal: surface.context.principal().map(str::to_owned), selected_access_profile: Some(surface.context.selected_profile().to_owned()), purpose_present: surface.context.purpose().is_some(), + correlation: correlation.clone(), }) .await { @@ -834,6 +921,7 @@ async fn audited_revision_concealment( options: &QueryOptions, claims: &VerifiedRequestClaims, target_record: Option<&String>, + correlation: &RequestCorrelation, ) -> Response { let selected_access_profile = options.access_profile().and_then(|profile| { route @@ -850,6 +938,7 @@ async fn audited_revision_concealment( principal: claims.principal().map(str::to_owned), selected_access_profile, purpose_present: claims.purpose().is_some(), + correlation: correlation.clone(), }) .await { @@ -864,6 +953,7 @@ async fn audited_known_read_refusal( claims: &VerifiedRequestClaims, target_record: Option<&String>, response: Response, + correlation: &RequestCorrelation, ) -> Response { match service .records @@ -874,6 +964,7 @@ async fn audited_known_read_refusal( principal: claims.principal().map(str::to_owned), selected_access_profile: None, purpose_present: claims.purpose().is_some(), + correlation: correlation.clone(), }) .await { @@ -888,6 +979,7 @@ async fn audited_read_refusal( surface: &AuthorizedSurface<'_>, target_record: Option<&String>, response: Response, + correlation: &RequestCorrelation, ) -> Response { match service .records @@ -898,6 +990,7 @@ async fn audited_read_refusal( principal: surface.context.principal().map(str::to_owned), selected_access_profile: Some(surface.context.selected_profile().to_owned()), purpose_present: surface.context.purpose().is_some(), + correlation: correlation.clone(), }) .await { @@ -912,6 +1005,7 @@ async fn audited_read_concealment( options: &QueryOptions, claims: &VerifiedRequestClaims, target_record: Option<&String>, + correlation: &RequestCorrelation, ) -> Response { let selected_access_profile = options.access_profile().and_then(|profile| { route @@ -929,6 +1023,7 @@ async fn audited_read_concealment( principal: claims.principal().map(str::to_owned), selected_access_profile, purpose_present: claims.purpose().is_some(), + correlation: correlation.clone(), }) .await { @@ -940,6 +1035,7 @@ async fn audited_read_concealment( async fn create_dispatch( State(service): State>, Extension(route): Extension, + Extension(correlation): Extension, claims: Option>, RawQuery(raw_query): RawQuery, headers: HeaderMap, @@ -958,11 +1054,20 @@ async fn create_dispatch( &QueryOptions::default(), &claims, None, + &correlation, ) .await; }; let Some(surface) = authorize_route(&service, &route, &claims, &options) else { - return audited_mutation_concealment(mutations, &route, &options, &claims, None).await; + return audited_mutation_concealment( + mutations, + &route, + &options, + &claims, + None, + &correlation, + ) + .await; }; let Some(idempotency_key) = single_header(&headers, "idempotency-key") else { return audited_mutation_refusal( @@ -971,6 +1076,7 @@ async fn create_dispatch( &surface.context, None, invalid_request(), + &correlation, ) .await; }; @@ -981,6 +1087,7 @@ async fn create_dispatch( &surface.context, None, invalid_request(), + &correlation, ) .await; } @@ -991,6 +1098,7 @@ async fn create_dispatch( &surface.context, None, unsupported_media_type(), + &correlation, ) .await; } @@ -1001,6 +1109,7 @@ async fn create_dispatch( &surface.context, None, invalid_request(), + &correlation, ) .await; }; @@ -1011,18 +1120,20 @@ async fn create_dispatch( &surface.context, None, invalid_request(), + &correlation, ) .await; }; match mutations - .create( - &route.id, + .create(CreateMutationInput { + route_id: &route.id, idempotency_key, - &surface.context, - &route.entity_id, + context: &surface.context, + entity_id: &route.entity_id, data, - surface.readable_fields, - ) + response_fields: surface.readable_fields, + correlation: &correlation, + }) .await { Ok(outcome) => exact_mutation(outcome.response()), @@ -1030,9 +1141,11 @@ async fn create_dispatch( } } +#[allow(clippy::too_many_arguments)] // Axum extractors are the HTTP contract. async fn patch_dispatch( State(service): State>, Extension(route): Extension, + Extension(correlation): Extension, claims: Option>, RawQuery(raw_query): RawQuery, Path(path): Path>, @@ -1055,6 +1168,7 @@ async fn patch_dispatch( &QueryOptions::default(), &claims, Some(record_id.as_str()), + &correlation, ) .await; }; @@ -1065,6 +1179,7 @@ async fn patch_dispatch( &options, &claims, Some(record_id.as_str()), + &correlation, ) .await; }; @@ -1075,6 +1190,7 @@ async fn patch_dispatch( &surface.context, Some(record_id.as_str()), invalid_request(), + &correlation, ) .await; }; @@ -1085,6 +1201,7 @@ async fn patch_dispatch( &surface.context, Some(record_id.as_str()), invalid_request(), + &correlation, ) .await; } @@ -1095,6 +1212,7 @@ async fn patch_dispatch( &surface.context, Some(record_id.as_str()), precondition_required(), + &correlation, ) .await; }; @@ -1105,6 +1223,7 @@ async fn patch_dispatch( &surface.context, Some(record_id.as_str()), precondition_failed(), + &correlation, ) .await; } @@ -1115,6 +1234,7 @@ async fn patch_dispatch( &surface.context, Some(record_id.as_str()), unsupported_media_type(), + &correlation, ) .await; } @@ -1125,6 +1245,7 @@ async fn patch_dispatch( &surface.context, Some(record_id.as_str()), invalid_request(), + &correlation, ) .await; }; @@ -1135,6 +1256,7 @@ async fn patch_dispatch( &surface.context, Some(record_id.as_str()), invalid_request(), + &correlation, ) .await; }; @@ -1145,6 +1267,7 @@ async fn patch_dispatch( &surface.context, Some(record_id.as_str()), invalid_request(), + &correlation, ) .await; }; @@ -1158,6 +1281,7 @@ async fn patch_dispatch( entity_id: &route.entity_id, record_id, response_fields: surface.readable_fields, + correlation: &correlation, }, patch, ) @@ -1171,6 +1295,7 @@ async fn patch_dispatch( async fn batch_dispatch( State(service): State>, Extension(route): Extension, + Extension(correlation): Extension, claims: Option>, RawQuery(raw_query): RawQuery, headers: HeaderMap, @@ -1189,11 +1314,20 @@ async fn batch_dispatch( &QueryOptions::default(), &claims, None, + &correlation, ) .await; }; let Some(surface) = authorize_route(&service, &route, &claims, &options) else { - return audited_mutation_concealment(mutations, &route, &options, &claims, None).await; + return audited_mutation_concealment( + mutations, + &route, + &options, + &claims, + None, + &correlation, + ) + .await; }; let Some(batch) = surface.entity.batch.as_ref() else { return audited_mutation_refusal( @@ -1202,6 +1336,7 @@ async fn batch_dispatch( &surface.context, None, invalid_request(), + &correlation, ) .await; }; @@ -1212,6 +1347,7 @@ async fn batch_dispatch( &surface.context, None, invalid_request(), + &correlation, ) .await; }; @@ -1222,6 +1358,7 @@ async fn batch_dispatch( &surface.context, None, invalid_request(), + &correlation, ) .await; } @@ -1232,6 +1369,7 @@ async fn batch_dispatch( &surface.context, None, unsupported_media_type(), + &correlation, ) .await; } @@ -1242,6 +1380,7 @@ async fn batch_dispatch( &surface.context, None, invalid_request(), + &correlation, ) .await; }; @@ -1252,6 +1391,7 @@ async fn batch_dispatch( &surface.context, None, invalid_request(), + &correlation, ) .await; }; @@ -1264,6 +1404,7 @@ async fn batch_dispatch( items, response_fields: surface.readable_fields, body_bytes: body.len(), + correlation: &correlation, }) .await { @@ -1272,9 +1413,11 @@ async fn batch_dispatch( } } +#[allow(clippy::too_many_arguments)] // Axum extractors are the HTTP contract. async fn tombstone_dispatch( State(service): State>, Extension(route): Extension, + Extension(correlation): Extension, claims: Option>, RawQuery(raw_query): RawQuery, Path(path): Path>, @@ -1297,6 +1440,7 @@ async fn tombstone_dispatch( &QueryOptions::default(), &claims, Some(record_id.as_str()), + &correlation, ) .await; }; @@ -1307,6 +1451,7 @@ async fn tombstone_dispatch( &options, &claims, Some(record_id.as_str()), + &correlation, ) .await; }; @@ -1317,6 +1462,7 @@ async fn tombstone_dispatch( &surface.context, Some(record_id.as_str()), invalid_request(), + &correlation, ) .await; }; @@ -1327,6 +1473,7 @@ async fn tombstone_dispatch( &surface.context, Some(record_id.as_str()), invalid_request(), + &correlation, ) .await; } @@ -1337,6 +1484,7 @@ async fn tombstone_dispatch( &surface.context, Some(record_id.as_str()), precondition_required(), + &correlation, ) .await; }; @@ -1347,6 +1495,7 @@ async fn tombstone_dispatch( &surface.context, Some(record_id.as_str()), precondition_failed(), + &correlation, ) .await; } @@ -1357,6 +1506,7 @@ async fn tombstone_dispatch( &surface.context, Some(record_id.as_str()), unsupported_media_type(), + &correlation, ) .await; } @@ -1367,6 +1517,7 @@ async fn tombstone_dispatch( &surface.context, Some(record_id.as_str()), invalid_request(), + &correlation, ) .await; } @@ -1379,6 +1530,7 @@ async fn tombstone_dispatch( entity_id: &route.entity_id, record_id, response_fields: surface.readable_fields, + correlation: &correlation, }) .await { @@ -1393,16 +1545,18 @@ async fn audited_mutation_refusal( context: &AuthorizedRequestContext, target_record: Option<&str>, response: Response, + correlation: &RequestCorrelation, ) -> Response { match mutations - .record_refusal( - route.method, - &route.id, + .record_refusal(crate::audit::HttpRefusalAudit { + method: route.method, + operation_id: &route.id, target_record, - context.principal(), - Some(context.selected_profile()), - context.purpose().is_some(), - ) + principal: context.principal(), + selected_access_profile: Some(context.selected_profile()), + purpose_present: context.purpose().is_some(), + correlation, + }) .await { Ok(()) => response, @@ -1416,20 +1570,22 @@ async fn audited_mutation_concealment( options: &QueryOptions, claims: &VerifiedRequestClaims, target_record: Option<&str>, + correlation: &RequestCorrelation, ) -> Response { let selected_profile = options .access_profile() .map(String::as_str) .or(Some(route.default_access_profile.as_str())); match mutations - .record_refusal( - route.method, - &route.id, + .record_refusal(crate::audit::HttpRefusalAudit { + method: route.method, + operation_id: &route.id, target_record, - claims.principal(), - selected_profile, - claims.purpose().is_some(), - ) + principal: claims.principal(), + selected_access_profile: selected_profile, + purpose_present: claims.purpose().is_some(), + correlation, + }) .await { Ok(()) => concealed(), @@ -3219,171 +3375,6 @@ fn operation_name(operation: Operation) -> &'static str { } } -fn query_kind_name(kind: CompiledQueryKind) -> &'static str { - match kind { - CompiledQueryKind::List => "list", - CompiledQueryKind::Current => "current", - CompiledQueryKind::AsOf => "as_of", - } -} - -fn query_parameters(kind: CompiledQueryKind) -> Value { - let mut parameters = vec![ - query_parameter( - "accessProfile", - false, - false, - json!({"type": "string"}), - "Select one compiled access profile.", - ), - query_parameter( - "$select", - false, - false, - json!({"type": "string"}), - "Comma-separated subset of readable API property names.", - ), - query_parameter( - "$filter", - false, - false, - json!({"type": "string"}), - "Strict Registry read filter expression over compiled filterable properties.", - ), - query_parameter( - "$orderby", - false, - false, - json!({"type": "string"}), - "One compiled sortable property, ascending only.", - ), - query_parameter( - "$top", - false, - false, - json!({"type": "integer", "minimum": 1, "maximum": strict_query::MAX_TOP}), - "Bounded page size.", - ), - query_parameter( - "$count", - false, - false, - json!({"type": "boolean"}), - "Request a total count when the compiled operation allows it.", - ), - query_parameter( - "$skiptoken", - false, - false, - json!({"type": "string"}), - "Opaque continuation cursor for the next page.", - ), - ]; - if kind == CompiledQueryKind::AsOf { - parameters.push(query_parameter( - "asOf", - true, - false, - json!({"type": "string", "format": "date-time"}), - "Strict UTC RFC3339 instant for the as-of temporal query.", - )); - } - Value::Array(parameters) -} - -fn lookup_parameters() -> Value { - Value::Array(vec![ - query_parameter( - "accessProfile", - false, - false, - json!({"type": "string"}), - "Select one compiled access profile.", - ), - query_parameter( - "$select", - false, - false, - json!({"type": "string"}), - "Comma-separated subset of readable API property names.", - ), - ]) -} - -fn lookup_request_body() -> Value { - json!({ - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["selector"], - "properties": { - "selector": {"type": "string"}, - "values": { - "type": "object", - "additionalProperties": { - "type": ["string", "integer", "boolean"] - } - } - } - } - } - } - }) -} - -fn revision_parameters(kind: CompiledRevisionKind) -> Value { - let mut parameters = vec![query_parameter( - "accessProfile", - false, - false, - json!({"type": "string"}), - "Select one compiled access profile.", - )]; - parameters.push(path_parameter( - "record_id", - json!({"type": "string", "format": "uuid"}), - "Canonical record UUID.", - )); - if kind == CompiledRevisionKind::Detail { - parameters.push(path_parameter( - "revision", - json!({"type": "integer", "format": "int64", "minimum": 1}), - "Exact positive record revision.", - )); - } - Value::Array(parameters) -} - -fn query_parameter( - name: &str, - required: bool, - repeatable: bool, - schema: Value, - description: &str, -) -> Value { - json!({ - "name": name, - "in": "query", - "required": required, - "description": description, - "schema": schema, - "explode": repeatable, - }) -} - -fn path_parameter(name: &str, schema: Value, description: &str) -> Value { - json!({ - "name": name, - "in": "path", - "required": true, - "description": description, - "schema": schema, - }) -} - fn valid_canonical_record_uuid(value: &str) -> bool { value.len() == 36 && Uuid::parse_str(value).is_ok_and(|identifier| identifier.to_string() == value) @@ -3549,117 +3540,6 @@ fn parse_batch_body(body: &[u8], maximum_items: usize) -> Result Value { - Value::Array(vec![query_parameter( - "accessProfile", - false, - false, - json!({"type": "string"}), - "Select one compiled access profile.", - )]) -} - -fn batch_request_body( - entity_id: &str, - maximum_items: u16, - allow_create: bool, - allow_patch: bool, -) -> Value { - let mut item_schemas = Vec::new(); - if allow_create { - item_schemas.push(json!({ - "type": "object", - "additionalProperties": false, - "required": ["operation", "data"], - "properties": { - "operation": {"const": "create"}, - "data": {"$ref": format!("#/components/schemas/{entity_id}")}, - } - })); - } - if allow_patch { - item_schemas.push(json!({ - "type": "object", - "additionalProperties": false, - "required": ["operation", "recordId", "ifMatch", "patch"], - "properties": { - "operation": {"const": "patch"}, - "recordId": {"type": "string", "format": "uuid"}, - "ifMatch": {"type": "string"}, - "patch": {"type": "array", "minItems": 1, "maxItems": 128}, - } - })); - } - json!({ - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["items"], - "properties": { - "items": { - "type": "array", - "minItems": 1, - "maxItems": maximum_items, - "items": {"oneOf": item_schemas} - } - } - } - } - } - }) -} - -fn batch_response( - entity_id: &str, - maximum_items: u16, - allow_create: bool, - allow_patch: bool, -) -> Value { - let operations = [ - allow_create.then_some("create"), - allow_patch.then_some("patch"), - ] - .into_iter() - .flatten() - .collect::>(); - json!({ - "200": { - "description": "Atomic batch committed", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["results"], - "properties": { - "results": { - "type": "array", - "minItems": 1, - "maxItems": maximum_items, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["operation", "id", "revision", "etag", "data"], - "properties": { - "operation": {"enum": operations}, - "id": {"type": "string", "format": "uuid"}, - "revision": {"type": "integer", "format": "int64", "minimum": 1}, - "etag": {"type": "string"}, - "data": {"$ref": format!("#/components/schemas/{entity_id}")}, - } - } - } - } - } - } - } - } - }) -} - async fn body_is_empty(body: Body) -> bool { to_bytes(body, 0).await.is_ok_and(|bytes| bytes.is_empty()) } @@ -3763,12 +3643,11 @@ fn mutation_problem(error: MutationError) -> Response { } fn fixed_problem(status: StatusCode, code: &'static str, detail: &'static str) -> Response { - Problem::new( - &format!("urn:registry-server:problem:{code}"), - status.canonical_reason().unwrap_or("Request failed"), + crate::correlation::problem_response( status, + format!("urn:registry-server:problem:{code}"), + status.canonical_reason().unwrap_or("Request failed"), + detail, + code, ) - .detail(detail) - .with_extra("code", Value::String(code.to_owned())) - .into_response() } diff --git a/crates/registry-server/src/api/service.rs b/crates/registry-server/src/api/service.rs index d07dede68b..849f9b4b13 100644 --- a/crates/registry-server/src/api/service.rs +++ b/crates/registry-server/src/api/service.rs @@ -10,6 +10,7 @@ use serde_json::Value; use super::context::AuthorizedRequestContext; use crate::contract::FieldTypeSource; +use crate::correlation::RequestCorrelation; use crate::cursor::{CursorBinding, CursorCodec, CursorContinuation, CursorQuery}; use crate::model::{ CompiledQueryFilterOperator, CompiledQueryKind, CompiledQuerySortDirection, CompiledRegistry, @@ -52,6 +53,17 @@ impl HeldReadResponse { } } +/// Compiler-authorized input for record creation. +pub struct CreateMutationInput<'a> { + pub route_id: &'a str, + pub idempotency_key: &'a str, + pub context: &'a AuthorizedRequestContext, + pub entity_id: &'a str, + pub data: serde_json::Map, + pub response_fields: BTreeSet, + pub correlation: &'a RequestCorrelation, +} + /// Compiler-authorized input shared by conditional record mutations. pub struct ConditionalMutationInput<'a> { pub route_id: &'a str, @@ -61,6 +73,7 @@ pub struct ConditionalMutationInput<'a> { pub entity_id: &'a str, pub record_id: &'a str, pub response_fields: BTreeSet, + pub correlation: &'a RequestCorrelation, } /// Compiler-authorized input for one bounded entity-local batch transaction. @@ -72,6 +85,7 @@ pub struct BatchMutationInput<'a> { pub items: Vec, pub response_fields: BTreeSet, pub body_bytes: usize, + pub correlation: &'a RequestCorrelation, } #[derive(Clone)] @@ -89,6 +103,7 @@ pub struct RecordReadRequest { /// Hard source-execution result bound. Implementations must apply it in /// the database plan before rows are materialized. pub maximum_records: usize, + pub correlation: RequestCorrelation, } impl fmt::Debug for RecordReadRequest { @@ -363,6 +378,7 @@ pub struct RecordReadRefusal { pub principal: Option, pub selected_access_profile: Option, pub purpose_present: bool, + pub correlation: RequestCorrelation, } #[derive(Clone)] @@ -375,6 +391,7 @@ pub struct RevisionReadRequest { pub context: AuthorizedRequestContext, pub selected_fields: BTreeSet, pub maximum_records: usize, + pub correlation: RequestCorrelation, } impl fmt::Debug for RevisionReadRequest { @@ -401,6 +418,7 @@ pub struct RevisionReadRefusal { pub principal: Option, pub selected_access_profile: Option, pub purpose_present: bool, + pub correlation: RequestCorrelation, } impl fmt::Debug for RevisionReadRefusal { diff --git a/crates/registry-server/src/artifacts.rs b/crates/registry-server/src/artifacts.rs index 12e3b0284c..08609e0611 100644 --- a/crates/registry-server/src/artifacts.rs +++ b/crates/registry-server/src/artifacts.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use registry_platform_canonical_json::canonicalize_json; use serde::{Deserialize, Serialize}; @@ -17,7 +17,8 @@ use crate::manifest_adapter::project_manifest_artifacts; use crate::model::{ CompiledAccessInventory, CompiledEntity, CompiledEventDeliveryInventory, CompiledMetadataInventory, CompiledModuleIdentity, CompiledQueryInventory, CompiledQueryKind, - CompiledRevisionKind, CompiledRouteInventory, HttpMethod, + CompiledQueryOperation, CompiledRevisionKind, CompiledRoute, CompiledRouteInventory, + HttpMethod, }; use crate::physical_names::{hex_prefix, PhysicalNameInventory}; @@ -155,7 +156,7 @@ pub(crate) fn generate_artifacts( debug_assert_eq!(delivery.data_schema_artifact_path, binding.artifact_path); insert_json_value(&mut artifacts, &binding.artifact_path, &binding.schema)?; } - let openapi = openapi_document(registry_id, version, entities, routes, &schemas); + let openapi = openapi_document(registry_id, version, entities, routes, query, &schemas); insert_json_value(&mut artifacts, "generated/openapi.json", &openapi)?; if let Some(projection) = manifest_projection { let projected = project_manifest_artifacts(registry_id, projection, entities)?; @@ -271,6 +272,42 @@ fn entity_schema(entity: &CompiledEntity) -> Value { }) } +pub(crate) fn openapi_input_schema_id(entity_id: &str, operation: Operation) -> String { + format!("{entity_id}-{}-input", operation_name(operation)) +} + +pub(crate) fn openapi_entity_input_schema( + entity: &CompiledEntity, + writable_fields: Option<&BTreeSet>, +) -> Value { + let mut properties = Map::new(); + let mut required = Vec::new(); + for field in &entity.stored_fields { + if writable_fields.is_some_and(|fields| !fields.contains(&field.logical.id)) { + continue; + } + properties.insert( + field.logical.api_name.clone(), + field_schema(&field.logical.field_type), + ); + if field.required { + required.push(Value::String(field.logical.api_name.clone())); + } + } + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": format!("urn:registry-server:entity:{}:input", entity.id), + "type": "object", + "additionalProperties": false, + "properties": properties, + "required": required, + "x-registry-mutationMode": match entity.mutation_mode { + MutationMode::Mutable => "mutable", + MutationMode::CreateOnly => "create_only", + } + }) +} + fn field_schema(field_type: &FieldTypeSource) -> Value { match field_type { FieldTypeSource::Boolean => json!({"type": "boolean"}), @@ -387,123 +424,450 @@ fn openapi_document( version: &str, entities: &BTreeMap, routes: &CompiledRouteInventory, + query: &CompiledQueryInventory, schemas: &BTreeMap, ) -> Value { let mut paths = Map::new(); + let mut input_schemas = Map::new(); for route in &routes.routes { - let method = match route.method { - HttpMethod::Delete => "delete", - HttpMethod::Get => "get", - HttpMethod::Patch => "patch", - HttpMethod::Post => "post", - }; + let entity = entities + .get(&route.entity_id) + .expect("compiled route refers to a compiled entity"); + let response_entity = response_entity_for_route(route, entities); let path_entry = paths .entry(route.path.clone()) .or_insert_with(|| Value::Object(Map::new())); let Value::Object(operations) = path_entry else { unreachable!("OpenAPI path entries are objects") }; - let (status, description) = if route.operation == Operation::Create { - ("201", "Record created") - } else { - ("200", "Operation completed") - }; - let mut responses = Map::new(); - responses.insert(status.to_owned(), json!({"description": description})); - let mut operation = Map::from_iter([ - ("operationId".to_owned(), json!(route.id)), - ("x-registry-entity".to_owned(), json!(route.entity_id)), - ( - "x-registry-operation".to_owned(), - json!(operation_name(route.operation)), - ), - ( - "x-registry-accessProfiles".to_owned(), - json!(route.access_profiles), - ), - ("responses".to_owned(), Value::Object(responses)), - ]); - if let Some(kind) = route.query_kind { - operation.insert( - "x-registry-queryKind".to_owned(), - Value::String(query_kind_name(kind).to_owned()), - ); - operation.insert("parameters".to_owned(), query_parameters(kind)); - } else if let Some(kind) = route.revision_kind { - operation.insert("parameters".to_owned(), revision_parameters(kind)); - operation.insert( - "x-registry-maximumRecords".to_owned(), - json!(route.maximum_records), - ); - } else if route.operation == Operation::Batch { - let batch = entities - .get(&route.entity_id) - .and_then(|entity| entity.batch.as_ref()) - .expect("batch routes require compiled bounds"); - let allow_create = route.access_profiles.iter().any(|profile_id| { - entities[&route.entity_id].access_profiles[profile_id] - .operations - .contains(&Operation::Create) - }); - let allow_patch = route.access_profiles.iter().any(|profile_id| { - entities[&route.entity_id].access_profiles[profile_id] - .operations - .contains(&Operation::Patch) - }); - operation.insert("parameters".to_owned(), access_profile_parameters()); - operation.insert( - "x-registry-maximumItems".to_owned(), - json!(batch.maximum_items), - ); - operation.insert( - "x-registry-maximumBytes".to_owned(), - json!(batch.maximum_bytes), - ); - operation.insert( - "requestBody".to_owned(), - batch_request_body( - &route.entity_id, - batch.maximum_items, - allow_create, - allow_patch, - ), - ); - operation.insert( - "responses".to_owned(), - batch_response( - &route.entity_id, - batch.maximum_items, - allow_create, - allow_patch, - ), + let request_schema_ref = openapi_input_schema_id(&entity.id, route.operation); + if matches!(route.operation, Operation::Create | Operation::Batch) { + let writable_fields = writable_fields_for_route(route, entity); + input_schemas.insert( + request_schema_ref.clone(), + openapi_entity_input_schema(entity, Some(&writable_fields)), ); } - operations.insert(method.to_owned(), Value::Object(operation)); + operations.insert( + method_name(route.method).to_owned(), + openapi_operation(OpenApiOperationSpec { + route, + entity, + response_entity, + query, + schema_ref: &response_entity.id, + request_schema_ref: &request_schema_ref, + readable_fields: None, + access_profiles: OpenApiAccessProfiles::All, + }), + ); } - let component_schemas: Map = schemas + let mut component_schemas: Map = schemas .iter() .map(|(id, schema)| (id.clone(), schema.clone())) .collect(); + component_schemas.extend(input_schemas); json!({ "openapi": "3.1.0", "info": {"title": registry_id, "version": version}, "paths": paths, - "components": {"schemas": component_schemas} + "components": openapi_components(component_schemas) + }) +} + +#[derive(Clone, Copy)] +#[cfg_attr(not(feature = "runtime"), allow(dead_code))] +pub(crate) enum OpenApiAccessProfiles<'a> { + All, + Selected(&'a str), +} + +#[derive(Clone, Copy)] +pub(crate) struct OpenApiOperationSpec<'a> { + pub route: &'a CompiledRoute, + pub entity: &'a CompiledEntity, + pub response_entity: &'a CompiledEntity, + pub query: &'a CompiledQueryInventory, + pub schema_ref: &'a str, + pub request_schema_ref: &'a str, + pub readable_fields: Option<&'a BTreeSet>, + pub access_profiles: OpenApiAccessProfiles<'a>, +} + +const OPENAPI_EXAMPLE_TRACE_ID: &str = "11111111111111111111111111111111"; +const OPENAPI_EXAMPLE_TRACEPARENT: &str = "00-11111111111111111111111111111111-2222222222222222-01"; + +pub(crate) fn openapi_components(mut schemas: Map) -> Value { + schemas.insert("Problem".to_owned(), problem_schema()); + json!({ + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": schemas + }) +} + +pub(crate) fn openapi_operation(spec: OpenApiOperationSpec<'_>) -> Value { + let mut operation = Map::from_iter([ + ("operationId".to_owned(), json!(spec.route.id)), + ("x-registry-entity".to_owned(), json!(spec.route.entity_id)), + ( + "x-registry-responseEntity".to_owned(), + json!(spec.response_entity.id), + ), + ( + "x-registry-operation".to_owned(), + json!(operation_name(spec.route.operation)), + ), + ("security".to_owned(), operation_security(spec)), + ]); + match spec.access_profiles { + OpenApiAccessProfiles::All => { + operation.insert( + "x-registry-accessProfiles".to_owned(), + json!(spec.route.access_profiles), + ); + } + OpenApiAccessProfiles::Selected(profile) => { + operation.insert("x-registry-accessProfile".to_owned(), json!(profile)); + } + } + if let Some(kind) = spec.route.query_kind { + operation.insert( + "x-registry-queryKind".to_owned(), + Value::String(query_kind_name(kind).to_owned()), + ); + } + if let Some(kind) = spec.route.revision_kind { + operation.insert( + "x-registry-revisionKind".to_owned(), + Value::String(revision_kind_name(kind).to_owned()), + ); + operation.insert( + "x-registry-maximumRecords".to_owned(), + json!(spec.route.maximum_records), + ); + } + if spec.route.operation == Operation::Batch { + let batch = spec + .entity + .batch + .as_ref() + .expect("batch routes require compiled bounds"); + operation.insert( + "x-registry-maximumItems".to_owned(), + json!(batch.maximum_items), + ); + operation.insert( + "x-registry-maximumBytes".to_owned(), + json!(batch.maximum_bytes), + ); + } + if let Some(query_profile) = query_profile_extension(spec) { + operation.insert(query_profile.0, query_profile.1); + } + let parameters = operation_parameters(spec.route, spec.query, spec.access_profiles); + if !parameters.is_empty() { + operation.insert("parameters".to_owned(), Value::Array(parameters)); + } + if let Some(request_body) = operation_request_body(spec) { + operation.insert("requestBody".to_owned(), request_body); + } + operation.insert("responses".to_owned(), operation_responses(spec)); + Value::Object(operation) +} + +fn operation_security(spec: OpenApiOperationSpec<'_>) -> Value { + let profiles = match spec.access_profiles { + OpenApiAccessProfiles::All => spec.route.access_profiles.clone(), + OpenApiAccessProfiles::Selected(profile) => vec![profile.to_owned()], + }; + let mut allows_anonymous = false; + let mut requires_bearer = false; + for profile_id in profiles { + let Some(profile) = spec.entity.access_profiles.get(&profile_id) else { + continue; + }; + if profile.anonymous { + allows_anonymous = true; + } else { + requires_bearer = true; + } + } + let mut alternatives = Vec::new(); + if allows_anonymous { + alternatives.push(json!({})); + } + if requires_bearer { + alternatives.push(json!({"bearerAuth": []})); + } + if alternatives.is_empty() { + alternatives.push(json!({"bearerAuth": []})); + } + Value::Array(alternatives) +} + +fn operation_parameters( + route: &CompiledRoute, + query: &CompiledQueryInventory, + access_profiles: OpenApiAccessProfiles<'_>, +) -> Vec { + let mut parameters = Vec::new(); + if route.path.contains("{record_id}") { + parameters.push(path_parameter( + "record_id", + json!({"type": "string", "format": "uuid"}), + "Canonical record UUID.", + )); + } + if route.path.contains("{revision}") { + parameters.push(path_parameter( + "revision", + json!({"type": "integer", "format": "int64", "minimum": 1}), + "Exact positive record revision.", + )); + } + parameters.push(header_parameter( + "traceparent", + false, + traceparent_schema(), + "Optional W3C trace context. Responses carry Registry trace context for the request.", + )); + parameters.push(access_profile_parameter()); + match route.operation { + Operation::Get => parameters.push(query_parameter( + "$select", + false, + false, + json!({"type": "string", "maxLength": crate::query::MAX_QUERY_PAYLOAD_BYTES}), + "Comma-separated subset of readable API property names.", + )), + Operation::List => { + parameters.extend(read_query_parameters(route, query, access_profiles)); + } + Operation::Lookup => parameters.push(query_parameter( + "$select", + false, + false, + json!({"type": "string", "maxLength": crate::query::MAX_QUERY_PAYLOAD_BYTES}), + "Comma-separated subset of readable API property names.", + )), + Operation::Create | Operation::Patch | Operation::Tombstone | Operation::Batch => { + parameters.push(header_parameter( + "Idempotency-Key", + true, + json!({"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$"}), + "Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.", + )); + if matches!(route.operation, Operation::Patch | Operation::Tombstone) { + parameters.push(header_parameter( + "If-Match", + true, + json!({"type": "string", "minLength": 6, "maxLength": 256, "pattern": "^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$"}), + "Strong Registry ETag for the currently visible record representation.", + )); + } + } + Operation::Revisions => {} + } + parameters +} + +fn read_query_parameters( + route: &CompiledRoute, + query: &CompiledQueryInventory, + access_profiles: OpenApiAccessProfiles<'_>, +) -> Vec { + let mut parameters = vec![ + query_parameter( + "$select", + false, + false, + json!({"type": "string", "maxLength": crate::query::MAX_QUERY_PAYLOAD_BYTES}), + "Comma-separated subset of readable API property names.", + ), + query_parameter( + "$filter", + false, + false, + json!({"type": "string", "maxLength": crate::query::MAX_QUERY_PAYLOAD_BYTES}), + "Strict Registry read filter expression over compiled filterable API properties.", + ), + query_parameter( + "$orderby", + false, + false, + json!({"type": "string", "maxLength": crate::query::MAX_IDENTIFIER_BYTES}), + "One compiled sortable property, ascending only.", + ), + query_parameter( + "$top", + false, + false, + json!({"type": "integer", "minimum": 1, "maximum": max_page_size(route, query, access_profiles)}), + "Bounded page size.", + ), + query_parameter( + "$count", + false, + false, + json!({"type": "boolean"}), + "Request count when the selected compiled query profile allows it.", + ), + query_parameter( + "$skiptoken", + false, + false, + json!({"type": "string", "maxLength": crate::query::MAX_OPAQUE_VALUE_BYTES}), + "Opaque continuation cursor for the next page.", + ), + ]; + if route.query_kind == Some(CompiledQueryKind::AsOf) { + parameters.push(query_parameter( + "asOf", + true, + false, + json!({"type": "string", "format": "date-time"}), + "Strict UTC RFC3339 instant for the as-of temporal query.", + )); + } + parameters +} + +fn query_parameter( + name: &str, + required: bool, + repeatable: bool, + schema: Value, + description: &str, +) -> Value { + json!({ + "name": name, + "in": "query", + "required": required, + "description": description, + "schema": schema, + "explode": repeatable, }) } -fn access_profile_parameters() -> Value { - Value::Array(vec![query_parameter( +fn access_profile_parameter() -> Value { + query_parameter( "accessProfile", false, false, - json!({"type": "string"}), - "Select one compiled access profile.", - )]) + json!({"type": "string", "maxLength": crate::query::MAX_IDENTIFIER_BYTES}), + "Select one compiled access profile. Omit to use the route default.", + ) +} + +fn header_parameter(name: &str, required: bool, schema: Value, description: &str) -> Value { + json!({ + "name": name, + "in": "header", + "required": required, + "description": description, + "schema": schema, + }) +} + +fn path_parameter(name: &str, schema: Value, description: &str) -> Value { + json!({ + "name": name, + "in": "path", + "required": true, + "description": description, + "schema": schema, + }) +} + +fn operation_request_body(spec: OpenApiOperationSpec<'_>) -> Option { + match spec.route.operation { + Operation::Create => Some(json_request_body(json!({ + "type": "object", + "additionalProperties": false, + "required": ["data"], + "properties": { + "data": {"$ref": format!("#/components/schemas/{}", spec.request_schema_ref)} + } + }))), + Operation::Patch => Some(json_patch_request_body()), + Operation::Lookup => Some(json_request_body(json!({ + "type": "object", + "additionalProperties": false, + "required": ["selector"], + "properties": { + "selector": {"type": "string", "maxLength": crate::query::MAX_IDENTIFIER_BYTES}, + "values": { + "type": "object", + "maxProperties": 16, + "additionalProperties": { + "oneOf": [ + {"type": "string", "maxLength": crate::query::MAX_LITERAL_BYTES}, + {"type": "integer", "format": "int64"}, + {"type": "boolean"} + ] + } + } + } + }))), + Operation::Batch => { + let batch = spec + .entity + .batch + .as_ref() + .expect("batch routes require compiled bounds"); + let (allow_create, allow_patch) = batch_permissions(spec); + Some(batch_request_body( + spec.request_schema_ref, + batch.maximum_items, + allow_create, + allow_patch, + )) + } + Operation::Get | Operation::List | Operation::Tombstone | Operation::Revisions => None, + } +} + +fn json_request_body(schema: Value) -> Value { + json!({ + "required": true, + "content": {"application/json": {"schema": schema}} + }) +} + +fn json_patch_request_body() -> Value { + json!({ + "required": true, + "content": { + "application/json-patch+json": { + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": true, + "required": ["op", "path"], + "properties": { + "op": {"type": "string", "enum": ["add", "remove", "replace", "move", "copy", "test"]}, + "path": {"type": "string", "maxLength": 1024}, + "from": {"type": "string", "maxLength": 1024}, + "value": true + } + } + } + } + } + }) } fn batch_request_body( - entity_id: &str, + schema_ref: &str, maximum_items: u16, allow_create: bool, allow_patch: bool, @@ -516,7 +880,7 @@ fn batch_request_body( "required": ["operation", "data"], "properties": { "operation": {"const": "create"}, - "data": {"$ref": format!("#/components/schemas/{entity_id}")}, + "data": {"$ref": format!("#/components/schemas/{schema_ref}")}, } })); } @@ -528,8 +892,8 @@ fn batch_request_body( "properties": { "operation": {"const": "patch"}, "recordId": {"type": "string", "format": "uuid"}, - "ifMatch": {"type": "string"}, - "patch": {"type": "array", "minItems": 1, "maxItems": 128}, + "ifMatch": {"type": "string", "minLength": 6, "maxLength": 256, "pattern": "^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$"}, + "patch": json_patch_array_schema(), } })); } @@ -555,8 +919,220 @@ fn batch_request_body( }) } -fn batch_response( - entity_id: &str, +fn json_patch_array_schema() -> Value { + json!({ + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": true, + "required": ["op", "path"], + "properties": { + "op": {"type": "string", "enum": ["add", "remove", "replace", "move", "copy", "test"]}, + "path": {"type": "string", "maxLength": 1024}, + "from": {"type": "string", "maxLength": 1024}, + "value": true + } + } + }) +} + +fn operation_responses(spec: OpenApiOperationSpec<'_>) -> Value { + let success = match spec.route.operation { + Operation::Create => success_response( + "Record created", + StatusResponseHeaders::MutationCreate, + record_response_schema(spec.schema_ref), + ), + Operation::Get => success_response( + "Record returned", + StatusResponseHeaders::ReadDetail, + record_response_schema(spec.schema_ref), + ), + Operation::Lookup => success_response( + "Lookup resolved to one record", + StatusResponseHeaders::NoStore, + record_response_schema(spec.schema_ref), + ), + Operation::List => success_response( + "Records returned", + StatusResponseHeaders::NoStore, + list_response_schema(spec.schema_ref), + ), + Operation::Patch => success_response( + "Record patched", + StatusResponseHeaders::Mutation, + record_response_schema(spec.schema_ref), + ), + Operation::Tombstone => success_response( + "Record tombstoned", + StatusResponseHeaders::Mutation, + record_response_schema(spec.schema_ref), + ), + Operation::Batch => { + let batch = spec + .entity + .batch + .as_ref() + .expect("batch routes require compiled bounds"); + let (allow_create, allow_patch) = batch_permissions(spec); + success_response( + "Atomic batch committed", + StatusResponseHeaders::Mutation, + batch_response_schema( + spec.schema_ref, + batch.maximum_items, + allow_create, + allow_patch, + ), + ) + } + Operation::Revisions => success_response( + "Record revisions returned", + StatusResponseHeaders::NoStore, + revision_response_schema(spec.schema_ref, spec.route.revision_kind), + ), + }; + let success_status = if spec.route.operation == Operation::Create { + "201" + } else { + "200" + }; + let mut responses = Map::from_iter([(success_status.to_owned(), success)]); + for (status, problems) in problem_responses(spec.route.operation) { + let examples = problems + .iter() + .map(|problem| { + ( + problem.code.to_owned(), + json!({"value": problem_example(status, problem.code, problem.detail)}), + ) + }) + .collect::>(); + responses.insert( + status.to_owned(), + json!({ + "description": "Problem response", + "headers": { + "traceparent": traceparent_header("Trace context for this problem response.") + }, + "content": { + "application/problem+json": { + "schema": {"$ref": "#/components/schemas/Problem"}, + "examples": examples + } + } + }), + ); + } + Value::Object(responses) +} + +#[derive(Clone, Copy)] +enum StatusResponseHeaders { + ReadDetail, + NoStore, + Mutation, + MutationCreate, +} + +fn success_response(description: &str, headers: StatusResponseHeaders, schema: Value) -> Value { + let mut response = Map::from_iter([ + ("description".to_owned(), json!(description)), + ( + "content".to_owned(), + json!({"application/json": {"schema": schema}}), + ), + ]); + let mut header_map = match headers { + StatusResponseHeaders::ReadDetail => json!({ + "ETag": etag_header(), + }), + StatusResponseHeaders::NoStore => json!({ + "Cache-Control": {"description": "Always no-store for caller-bound read collections, lookup results, and revision history.", "schema": {"const": "no-store"}}, + }), + StatusResponseHeaders::Mutation => json!({ + "ETag": etag_header(), + }), + StatusResponseHeaders::MutationCreate => json!({ + "ETag": etag_header(), + "Location": {"description": "Relative URL of the created record.", "schema": {"type": "string"}}, + }), + }; + header_map + .as_object_mut() + .expect("response headers are objects") + .insert( + "traceparent".to_owned(), + traceparent_header("Trace context for this response."), + ); + response.insert("headers".to_owned(), header_map); + Value::Object(response) +} + +fn etag_header() -> Value { + json!({ + "description": "Strong Registry ETag bound to the record, package revision, caller profile, and response field set.", + "schema": {"type": "string", "pattern": "^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$"} + }) +} + +fn traceparent_header(description: &str) -> Value { + json!({ + "description": description, + "schema": traceparent_schema(), + "example": OPENAPI_EXAMPLE_TRACEPARENT, + }) +} + +fn traceparent_schema() -> Value { + json!({ + "type": "string", + "minLength": 55, + "maxLength": 55, + "pattern": "^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$" + }) +} + +fn record_response_schema(schema_ref: &str) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["id", "revision", "data"], + "properties": { + "id": {"type": "string", "format": "uuid"}, + "revision": {"type": "integer", "format": "int64", "minimum": 1}, + "data": {"$ref": format!("#/components/schemas/{schema_ref}")}, + } + }) +} + +fn list_response_schema(schema_ref: &str) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["items", "pageInfo"], + "properties": { + "items": { + "type": "array", + "items": record_response_schema(schema_ref), + }, + "pageInfo": { + "type": "object", + "additionalProperties": false, + "required": ["nextCursor"], + "properties": { + "nextCursor": {"type": ["string", "null"], "maxLength": crate::query::MAX_OPAQUE_VALUE_BYTES} + } + }, + "count": {"type": "integer", "format": "int64", "minimum": 0} + } + }) +} + +fn batch_response_schema( + schema_ref: &str, maximum_items: u16, allow_create: bool, allow_patch: bool, @@ -569,33 +1145,24 @@ fn batch_response( .flatten() .collect::>(); json!({ - "200": { - "description": "Atomic batch committed", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": false, - "required": ["results"], - "properties": { - "results": { - "type": "array", - "minItems": 1, - "maxItems": maximum_items, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["operation", "id", "revision", "etag", "data"], - "properties": { - "operation": {"enum": operations}, - "id": {"type": "string", "format": "uuid"}, - "revision": {"type": "integer", "format": "int64", "minimum": 1}, - "etag": {"type": "string"}, - "data": {"$ref": format!("#/components/schemas/{entity_id}")}, - } - } - } - } + "type": "object", + "additionalProperties": false, + "required": ["results"], + "properties": { + "results": { + "type": "array", + "minItems": 1, + "maxItems": maximum_items, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["operation", "id", "revision", "etag", "data"], + "properties": { + "operation": {"enum": operations}, + "id": {"type": "string", "format": "uuid"}, + "revision": {"type": "integer", "format": "int64", "minimum": 1}, + "etag": {"type": "string", "pattern": "^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$"}, + "data": {"$ref": format!("#/components/schemas/{schema_ref}")}, } } } @@ -603,120 +1170,467 @@ fn batch_response( }) } -fn revision_parameters(kind: CompiledRevisionKind) -> Value { - let mut parameters = vec![query_parameter( - "accessProfile", - false, - false, - json!({"type": "string"}), - "Select one compiled access profile.", - )]; - parameters.push(path_parameter( - "record_id", - json!({"type": "string", "format": "uuid"}), - "Canonical record UUID.", - )); - if kind == CompiledRevisionKind::Detail { - parameters.push(path_parameter( +fn revision_response_schema(schema_ref: &str, kind: Option) -> Value { + let item = json!({ + "type": "object", + "additionalProperties": false, + "required": [ "revision", - json!({"type": "integer", "format": "int64", "minimum": 1}), - "Exact positive record revision.", - )); + "predecessorRevision", + "lifecycle", + "mutationKind", + "actorReference", + "requestReference", + "createdAt", + "data" + ], + "properties": { + "revision": {"type": "integer", "format": "int64", "minimum": 1}, + "predecessorRevision": {"type": ["integer", "null"], "format": "int64", "minimum": 1}, + "lifecycle": {"type": "string", "enum": ["active", "tombstoned"]}, + "mutationKind": {"type": "string", "enum": ["create", "patch", "tombstone"]}, + "actorReference": {"type": "string"}, + "requestReference": {"type": "string"}, + "createdAt": {"type": "string", "format": "date-time"}, + "data": {"$ref": format!("#/components/schemas/{schema_ref}")}, + } + }); + if kind == Some(CompiledRevisionKind::Detail) { + item + } else { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { + "type": "array", + "maxItems": crate::model::MAX_REVISION_HISTORY_RECORDS, + "items": item + } + } + }) } - Value::Array(parameters) } -fn query_parameters(kind: CompiledQueryKind) -> Value { - let mut parameters = vec![ - query_parameter( - "accessProfile", - false, - false, - json!({"type": "string"}), - "Select one compiled access profile.", - ), - query_parameter( - "$select", - false, - false, - json!({"type": "string"}), - "Comma-separated subset of readable API property names.", - ), - query_parameter( - "$filter", - false, - false, - json!({"type": "string"}), - "Strict Registry read filter expression over compiled filterable properties.", +#[derive(Clone, Copy)] +struct ProblemExample { + code: &'static str, + detail: &'static str, +} + +fn problem_responses(operation: Operation) -> BTreeMap<&'static str, Vec> { + let mut responses = BTreeMap::from([ + ( + "400", + vec![ProblemExample { + code: "request.invalid", + detail: "The request is invalid.", + }], ), - query_parameter( - "$orderby", - false, - false, - json!({"type": "string"}), - "One compiled sortable property, ascending only.", + ( + "401", + vec![ProblemExample { + code: "authentication.refused", + detail: "The bearer credential is missing or refused.", + }], ), - query_parameter( - "$top", - false, - false, - json!({"type": "integer", "minimum": 1, "maximum": 100}), - "Bounded page size.", + ( + "404", + vec![ProblemExample { + code: "resource.not_found", + detail: "The requested resource was not found.", + }], ), - query_parameter( - "$count", - false, - false, - json!({"type": "boolean"}), - "Request a total count when the compiled operation allows it.", + ( + "503", + vec![ProblemExample { + code: "source.unavailable", + detail: "The Registry data service is unavailable.", + }], ), - query_parameter( - "$skiptoken", - false, - false, - json!({"type": "string"}), - "Opaque continuation cursor for the next page.", + ( + "504", + vec![ProblemExample { + code: "request.timeout", + detail: "The request timed out.", + }], ), - ]; - if kind == CompiledQueryKind::AsOf { - parameters.push(query_parameter( - "asOf", - true, - false, - json!({"type": "string", "format": "date-time"}), - "Strict UTC RFC3339 instant for the as-of temporal query.", - )); + ]); + if matches!(operation, Operation::List | Operation::Lookup) { + responses.entry("400").or_default().extend([ + ProblemExample { + code: "query.invalid", + detail: "The query request is invalid.", + }, + ProblemExample { + code: "query.cursor_invalid", + detail: "The query cursor is invalid.", + }, + ]); + } + if operation == Operation::Lookup { + responses.entry("404").or_default().push(ProblemExample { + code: "lookup.unresolved", + detail: "The lookup did not resolve exactly one record.", + }); + responses.insert( + "415", + vec![ProblemExample { + code: "unsupported.media_type", + detail: "The request media type is not supported.", + }], + ); + } + if matches!( + operation, + Operation::Create | Operation::Patch | Operation::Tombstone | Operation::Batch + ) { + responses.insert( + "409", + vec![ + ProblemExample { + code: "mutation.conflict", + detail: "The mutation conflicts with current state.", + }, + ProblemExample { + code: "idempotency.conflict", + detail: "The idempotency key is bound to another request.", + }, + ], + ); + responses.insert( + "415", + vec![ProblemExample { + code: "unsupported.media_type", + detail: "The request media type is not supported.", + }], + ); + } + if matches!(operation, Operation::Patch | Operation::Tombstone) { + responses.insert( + "412", + vec![ProblemExample { + code: "precondition.failed", + detail: "The mutation precondition failed.", + }], + ); + responses.insert( + "428", + vec![ProblemExample { + code: "precondition.required", + detail: "The mutation precondition is required.", + }], + ); } - Value::Array(parameters) + responses } -fn query_parameter( - name: &str, - required: bool, - repeatable: bool, - schema: Value, - description: &str, -) -> Value { +fn problem_schema() -> Value { json!({ - "name": name, - "in": "query", - "required": required, - "description": description, - "schema": schema, - "explode": repeatable, + "type": "object", + "additionalProperties": false, + "required": ["type", "title", "status", "detail", "code", "traceId"], + "properties": { + "type": {"type": "string", "format": "uri", "maxLength": 256}, + "title": {"type": "string", "maxLength": 128}, + "status": {"type": "integer", "minimum": 400, "maximum": 599}, + "detail": {"type": "string", "maxLength": 256}, + "traceId": {"type": "string", "minLength": 32, "maxLength": 32, "pattern": "^[0-9a-f]{32}$"}, + "code": { + "type": "string", + "enum": [ + "authentication.refused", + "idempotency.conflict", + "lookup.unresolved", + "mutation.conflict", + "precondition.failed", + "precondition.required", + "query.cursor_invalid", + "query.invalid", + "request.invalid", + "request.timeout", + "resource.not_found", + "service.unavailable", + "source.unavailable", + "unsupported.media_type" + ] + } + } }) } -fn path_parameter(name: &str, schema: Value, description: &str) -> Value { +fn problem_example(status: &str, code: &str, detail: &str) -> Value { json!({ - "name": name, - "in": "path", - "required": true, - "description": description, - "schema": schema, + "type": format!("urn:registry-server:problem:{code}"), + "title": match status { + "400" => "Bad Request", + "401" => "Unauthorized", + "404" => "Not Found", + "409" => "Conflict", + "412" => "Precondition Failed", + "415" => "Unsupported Media Type", + "428" => "Precondition Required", + "503" => "Service Unavailable", + "504" => "Gateway Timeout", + _ => "Request failed", + }, + "status": status.parse::().expect("problem status is numeric"), + "detail": detail, + "code": code, + "traceId": OPENAPI_EXAMPLE_TRACE_ID, + }) +} + +fn batch_permissions(spec: OpenApiOperationSpec<'_>) -> (bool, bool) { + match spec.access_profiles { + OpenApiAccessProfiles::All => ( + spec.route.access_profiles.iter().any(|profile_id| { + spec.entity.access_profiles[profile_id] + .operations + .contains(&Operation::Create) + }), + spec.route.access_profiles.iter().any(|profile_id| { + spec.entity.access_profiles[profile_id] + .operations + .contains(&Operation::Patch) + }), + ), + OpenApiAccessProfiles::Selected(profile_id) => { + let profile = &spec.entity.access_profiles[profile_id]; + ( + profile.operations.contains(&Operation::Create), + profile.operations.contains(&Operation::Patch), + ) + } + } +} + +fn writable_fields_for_route(route: &CompiledRoute, entity: &CompiledEntity) -> BTreeSet { + route + .access_profiles + .iter() + .filter_map(|profile_id| entity.access_profiles.get(profile_id)) + .flat_map(|profile| profile.writable_fields.iter().cloned()) + .collect() +} + +fn query_profile_extension(spec: OpenApiOperationSpec<'_>) -> Option<(String, Value)> { + if spec.route.query_kind.is_none() && spec.route.operation != Operation::Lookup { + return None; + } + let profiles = query_profiles_for_route(spec.route, spec.query, spec.access_profiles); + if profiles.is_empty() { + return None; + } + match spec.access_profiles { + OpenApiAccessProfiles::Selected(_) => Some(( + "x-registry-queryProfile".to_owned(), + render_query_profile( + spec.response_entity, + profiles[0], + selectable_fields_for_profile(spec, &profiles[0].profile_id), + ), + )), + OpenApiAccessProfiles::All => Some(( + "x-registry-queryProfiles".to_owned(), + Value::Object( + profiles + .into_iter() + .map(|profile| { + ( + profile.profile_id.clone(), + render_query_profile( + spec.response_entity, + profile, + selectable_fields_for_profile(spec, &profile.profile_id), + ), + ) + }) + .collect(), + ), + )), + } +} + +fn query_profiles_for_route<'a>( + route: &CompiledRoute, + query: &'a CompiledQueryInventory, + access_profiles: OpenApiAccessProfiles<'_>, +) -> Vec<&'a CompiledQueryOperation> { + let mut profiles = query + .operations + .iter() + .filter(|operation| operation.route_id == route.id) + .filter(|operation| match access_profiles { + OpenApiAccessProfiles::All => route + .access_profiles + .iter() + .any(|profile| profile == &operation.profile_id), + OpenApiAccessProfiles::Selected(profile) => operation.profile_id == profile, + }) + .collect::>(); + profiles.sort_by(|left, right| left.profile_id.cmp(&right.profile_id)); + profiles +} + +fn render_query_profile( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + selectable_fields: BTreeSet, +) -> Value { + json!({ + "profile": operation.profile_id, + "kind": query_kind_name(operation.kind), + "maxPageSize": operation.max_page_size, + "allowCount": operation.allow_count, + "selectableProperties": api_field_names(entity, &selectable_fields), + "filterableProperties": operation.filter_fields.iter().map(|field| { + json!({ + "property": api_field_name(entity, &field.field).unwrap_or(field.field.as_str()), + "operators": field.operators.iter().map(|operator| query_filter_operator_name(*operator)).collect::>() + }) + }).collect::>(), + "sortableProperties": operation.sort_fields.iter().map(|field| { + json!({ + "property": api_field_name(entity, &field.field).unwrap_or(field.field.as_str()), + "directions": field.directions.iter().map(|direction| match direction { + crate::model::CompiledQuerySortDirection::Asc => "asc", + }).collect::>() + }) + }).collect::>(), + "selectorProperties": api_field_names(entity, &operation.selector_fields), + "temporal": operation.temporal.as_ref().map(|temporal| json!({ + "startProperty": api_field_name(entity, &temporal.start_field).unwrap_or(temporal.start_field.as_str()), + "endProperty": api_field_name(entity, &temporal.end_field).unwrap_or(temporal.end_field.as_str()), + "scopeProperties": api_field_names(entity, &temporal.scope_fields), + "semantics": "start_inclusive_end_exclusive", + })) }) } +fn query_filter_operator_name(operator: crate::model::CompiledQueryFilterOperator) -> &'static str { + match operator { + crate::model::CompiledQueryFilterOperator::Equals => "equals", + crate::model::CompiledQueryFilterOperator::In => "in", + crate::model::CompiledQueryFilterOperator::Range => "range", + crate::model::CompiledQueryFilterOperator::IsNull => "is_null", + crate::model::CompiledQueryFilterOperator::IsNotNull => "is_not_null", + crate::model::CompiledQueryFilterOperator::Prefix => "prefix", + crate::model::CompiledQueryFilterOperator::Contains => "contains", + } +} + +fn selectable_fields_for_profile( + spec: OpenApiOperationSpec<'_>, + profile_id: &str, +) -> BTreeSet { + if let Some(readable_fields) = spec.readable_fields { + return readable_fields.clone(); + } + if let Some(read_path) = read_path_for_route(spec.route, spec.entity) { + return spec + .entity + .access_profiles + .get(profile_id) + .and_then(|profile| { + profile + .read_paths + .iter() + .find(|grant| grant.path == read_path.id) + }) + .map(|grant| grant.readable_fields.clone()) + .unwrap_or_default(); + } + spec.entity + .access_profiles + .get(profile_id) + .map(|profile| profile.readable_fields.clone()) + .unwrap_or_default() +} + +fn api_field_names<'a>( + entity: &CompiledEntity, + fields: impl IntoIterator, +) -> Vec { + fields + .into_iter() + .filter_map(|field| api_field_name(entity, field).map(str::to_owned)) + .collect() +} + +fn api_field_name<'a>(entity: &'a CompiledEntity, field_id: &str) -> Option<&'a str> { + entity + .stored_fields + .iter() + .find(|field| field.logical.id == field_id) + .map(|field| field.logical.api_name.as_str()) + .or_else(|| { + entity + .derived_fields + .get(field_id) + .map(|field| field.logical.api_name.as_str()) + }) + .or_else(|| { + (entity.canonical_id.id == field_id).then_some(entity.canonical_id.api_name.as_str()) + }) +} + +fn max_page_size( + route: &CompiledRoute, + query: &CompiledQueryInventory, + access_profiles: OpenApiAccessProfiles<'_>, +) -> u16 { + query_profiles_for_route(route, query, access_profiles) + .into_iter() + .map(|operation| operation.max_page_size) + .max() + .unwrap_or(crate::query::MAX_TOP as u16) +} + +fn response_entity_for_route<'a>( + route: &CompiledRoute, + entities: &'a BTreeMap, +) -> &'a CompiledEntity { + let entity = entities + .get(&route.entity_id) + .expect("compiled route refers to a compiled entity"); + if route.operation == Operation::List && route.id.contains(".path.") { + if let Some(path) = read_path_for_route(route, entity) { + return entities + .get(&path.to) + .expect("compiled read path refers to a compiled entity"); + } + } + entity +} + +fn read_path_for_route<'a>( + route: &CompiledRoute, + entity: &'a CompiledEntity, +) -> Option<&'a crate::model::CompiledReadPath> { + entity + .read_paths + .values() + .find(|path| route.id == format!("records.{}.path.{}", entity.id, path.id)) +} + +fn revision_kind_name(kind: CompiledRevisionKind) -> &'static str { + match kind { + CompiledRevisionKind::List => "list", + CompiledRevisionKind::Detail => "detail", + } +} + +fn method_name(method: HttpMethod) -> &'static str { + match method { + HttpMethod::Delete => "delete", + HttpMethod::Get => "get", + HttpMethod::Patch => "patch", + HttpMethod::Post => "post", + } +} + fn query_kind_name(kind: CompiledQueryKind) -> &'static str { match kind { CompiledQueryKind::List => "list", diff --git a/crates/registry-server/src/audit.rs b/crates/registry-server/src/audit.rs index 521e7b581d..9eefa305d9 100644 --- a/crates/registry-server/src/audit.rs +++ b/crates/registry-server/src/audit.rs @@ -11,6 +11,7 @@ use serde_json::{json, Value}; use tokio_postgres::Transaction; use uuid::Uuid; +use crate::correlation::RequestCorrelation; use crate::model::HttpMethod; use crate::postgres::{ begin_record_transaction, ClaimContext, ExpectedRegistryIdentity, RegistryLockKey, @@ -27,6 +28,7 @@ pub struct PreIoAudit<'a> { pub method: HttpMethod, pub operation_id: &'a str, pub target_record: Option<&'a str>, + pub correlation: &'a RequestCorrelation, } pub(crate) struct HttpRefusalAudit<'a> { @@ -36,6 +38,7 @@ pub(crate) struct HttpRefusalAudit<'a> { pub principal: Option<&'a str>, pub selected_access_profile: Option<&'a str>, pub purpose_present: bool, + pub correlation: &'a RequestCorrelation, } #[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] @@ -59,6 +62,7 @@ pub(crate) struct TerminalAudit { pub record_revision: Option, pub result_count: Option, pub field_set_reference: Option, + pub correlation: RequestCorrelation, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -166,6 +170,8 @@ pub async fn record_pre_io_audit( }, "method": method_name(event.method), "operationId": event.operation_id, + "requestId": event.correlation.request_id().to_string(), + "traceId": event.correlation.trace_id().as_str(), "packageRevision": expected.package_revision, "selectedAccessProfile": claims.access_profile(), "purposePresent": claims.purpose().is_some(), @@ -279,6 +285,14 @@ pub(crate) async fn record_http_refusal_audit( "operationId".to_owned(), Value::String(event.operation_id.to_owned()), ), + ( + "requestId".to_owned(), + Value::String(event.correlation.request_id().to_string()), + ), + ( + "traceId".to_owned(), + Value::String(event.correlation.trace_id().as_str().to_owned()), + ), ( "packageRevision".to_owned(), Value::String(expected.package_revision.clone()), @@ -478,6 +492,14 @@ fn terminal_record(terminal: TerminalAudit) -> serde_json::Map { "operationId".to_owned(), Value::String(terminal.operation_id), ), + ( + "requestId".to_owned(), + Value::String(terminal.correlation.request_id().to_string()), + ), + ( + "traceId".to_owned(), + Value::String(terminal.correlation.trace_id().as_str().to_owned()), + ), ("entityId".to_owned(), Value::String(terminal.entity_id)), ( "packageRevision".to_owned(), diff --git a/crates/registry-server/src/auth.rs b/crates/registry-server/src/auth.rs index 65580106df..fc5cc6390c 100644 --- a/crates/registry-server/src/auth.rs +++ b/crates/registry-server/src/auth.rs @@ -12,7 +12,6 @@ use axum::http::{Request, StatusCode}; use axum::middleware::Next; use axum::response::Response; use registry_platform_authcommon::{parse_bearer_token, validate_compact_access_token}; -use registry_platform_httpsec::Problem; use registry_platform_oidc::{Audience, JwksFetcher, TokenVerifier, TokenVerifierConfig}; use serde_json::Value; use thiserror::Error; @@ -509,12 +508,11 @@ fn mapped_scalar_claim( } fn authentication_refused() -> Response { - Problem::new( + crate::correlation::problem_response( + StatusCode::UNAUTHORIZED, "urn:registry-server:problem:authentication.refused", "Unauthorized", - StatusCode::UNAUTHORIZED, + "The bearer credential is missing or refused.", + "authentication.refused", ) - .detail("The bearer credential is missing or refused.") - .with_extra("code", Value::String("authentication.refused".to_owned())) - .into_response() } diff --git a/crates/registry-server/src/compiler.rs b/crates/registry-server/src/compiler.rs index 9998a83600..9d58d8baa9 100644 --- a/crates/registry-server/src/compiler.rs +++ b/crates/registry-server/src/compiler.rs @@ -107,6 +107,7 @@ pub fn compile_project_with_assets( &module_map, &mut diagnostics, ); + validate_project_entity_access_profiles(project, &mut diagnostics); expand_project_access(project, &mut sources, &mut diagnostics); resolve_vocabularies(project, &mut sources, &mut diagnostics); validate_entities(&sources, profile, &mut diagnostics); @@ -249,11 +250,7 @@ fn validate_project_header( "project.manifestProjection", "production Registry Manifest projection has not been declared", )), - (None, CompileProfile::Production) => errors.push(Diagnostic::error( - "manifest_projection.required", - "project.manifestProjection", - "production compilation requires a Registry Manifest projection", - )), + (None, CompileProfile::Production) => {} (Some(projection), _) => { validate_id( &projection.access_profile, @@ -1014,6 +1011,23 @@ fn merge_by_id( } } +fn validate_project_entity_access_profiles( + project: &RegistryProject, + errors: &mut Vec, +) { + if project + .entities + .iter() + .any(|entity| !entity.access_profiles.is_empty()) + { + errors.push(Diagnostic::error( + "access_profile.project_entity_local.forbidden", + "project.entities[].accessProfiles", + "root project entities must declare access through top-level accessProfiles", + )); + } +} + fn expand_project_access( project: &RegistryProject, entities: &mut BTreeMap, @@ -1029,12 +1043,28 @@ fn expand_project_access( "an access profile identifier is duplicated", )); } - nonempty( - &profile.principal_claim, - "project.accessProfiles[].principalClaim", - "access_profile.principal_claim.empty", - errors, - ); + if profile.anonymous { + if profile.principal_claim.is_some() { + errors.push(Diagnostic::error( + "access_profile.principal_claim.forbidden", + "project.accessProfiles[].principalClaim", + "an anonymous profile cannot declare a principal claim", + )); + } + if !profile.required_scopes.is_empty() || !profile.required_purposes.is_empty() { + errors.push(Diagnostic::error( + "access_profile.anonymous.claim_requirements_forbidden", + "project.accessProfiles[]", + "an anonymous profile cannot require scopes or purposes", + )); + } + } else if profile.principal_claim.as_deref().is_none_or(str::is_empty) { + errors.push(Diagnostic::error( + "access_profile.principal_claim.required", + "project.accessProfiles[].principalClaim", + "an authenticated profile requires a direct principal claim", + )); + } let mut granted_entities = BTreeSet::new(); for grant in &profile.grants { if !granted_entities.insert(grant.entity.as_str()) { @@ -1068,11 +1098,11 @@ fn expand_project_access( entity.access_profiles.push(AccessProfileSource { id: profile.id.clone(), default: profile.default, - anonymous: false, - principal_claim: Some(profile.principal_claim.clone()), + anonymous: profile.anonymous, + principal_claim: profile.principal_claim.clone(), required_scopes: profile.required_scopes.clone(), - required_purposes: profile.purposes.clone(), - operations: grant.actions.clone(), + required_purposes: profile.required_purposes.clone(), + operations: grant.operations.clone(), readable_fields: grant.readable_fields.clone(), writable_fields: grant.writable_fields.clone(), filterable_fields: grant.filterable_fields.clone(), diff --git a/crates/registry-server/src/contract.rs b/crates/registry-server/src/contract.rs index e3e0167db6..dd7171eeb1 100644 --- a/crates/registry-server/src/contract.rs +++ b/crates/registry-server/src/contract.rs @@ -292,7 +292,10 @@ pub struct EntitySource { pub constraints: Vec, #[serde(default)] pub indexes: Vec, + /// Internal/module profile contributions. Public project authoring should use + /// top-level `accessProfiles`. #[serde(default)] + #[cfg_attr(feature = "schema", schemars(skip))] pub access_profiles: Vec, #[serde(default)] pub events: Vec, @@ -327,7 +330,10 @@ pub struct EntityExtensionSource { pub constraints: Vec, #[serde(default)] pub indexes: Vec, + /// Internal/module profile contributions. Public project authoring should use + /// top-level `accessProfiles`. #[serde(default)] + #[cfg_attr(feature = "schema", schemars(skip))] pub access_profiles: Vec, #[serde(default)] pub events: Vec, @@ -1641,11 +1647,14 @@ pub struct ProjectAccessProfileSource { pub id: String, #[serde(default)] pub default: bool, - pub principal_claim: String, + #[serde(default)] + pub anonymous: bool, + #[serde(default)] + pub principal_claim: Option, #[serde(default)] pub required_scopes: BTreeSet, #[serde(default)] - pub purposes: BTreeSet, + pub required_purposes: BTreeSet, #[serde(default)] pub grants: Vec, } @@ -1655,7 +1664,7 @@ pub struct ProjectAccessProfileSource { #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct AccessGrantSource { pub entity: String, - pub actions: BTreeSet, + pub operations: BTreeSet, #[serde(default)] pub readable_fields: BTreeSet, #[serde(default)] diff --git a/crates/registry-server/src/correlation.rs b/crates/registry-server/src/correlation.rs new file mode 100644 index 0000000000..08886b1af0 --- /dev/null +++ b/crates/registry-server/src/correlation.rs @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Request correlation at the Registry Server HTTP boundary. + +use std::time::Instant; + +use axum::body::Body; +use axum::http::header::{CONTENT_LENGTH, CONTENT_TYPE}; +use axum::http::{HeaderMap, Request, StatusCode}; +use axum::middleware::Next; +use axum::response::Response; +use registry_platform_httpsec::{Problem, ProblemBody, TraceContext, TraceId}; +use serde_json::Value; +use uuid::Uuid; + +/// Server-owned request identifier and the effective W3C trace context. +/// +/// `request_id` is always freshly minted by Registry Server. The trace may +/// retain one valid inbound `traceparent`, as defined by the shared platform +/// parser, but caller-supplied `tracestate` is never reflected. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RequestCorrelation { + request_id: Uuid, + trace: TraceContext, +} + +impl RequestCorrelation { + #[must_use] + pub fn from_headers(headers: &HeaderMap) -> Self { + Self { + request_id: Uuid::new_v4(), + trace: TraceContext::from_headers(headers), + } + } + + #[must_use] + pub fn server_created() -> Self { + Self { + request_id: Uuid::new_v4(), + trace: TraceContext::server_created(), + } + } + + #[must_use] + pub const fn request_id(&self) -> Uuid { + self.request_id + } + + #[must_use] + pub fn trace_id(&self) -> &TraceId { + &self.trace.trace_id + } + + fn apply_trace(&self, headers: &mut HeaderMap) { + self.trace.apply(headers); + } +} + +/// Closed public problem metadata used by the correlation boundary. +/// +/// Carrying this in response extensions lets the outer timeout/observation +/// layer render the shared [`ProblemBody`] with the exact effective trace ID +/// without parsing or trusting response bytes. +#[derive(Clone)] +pub(crate) struct PublicProblem { + type_uri: String, + title: &'static str, + status: StatusCode, + detail: &'static str, + code: &'static str, +} + +/// Build one value-free public problem. The boundary replaces this provisional +/// body with [`ProblemBody`] carrying the request's effective trace ID. +pub(crate) fn problem_response( + status: StatusCode, + type_uri: impl Into, + title: &'static str, + detail: &'static str, + code: &'static str, +) -> Response { + let type_uri = type_uri.into(); + let problem = PublicProblem { + type_uri: type_uri.clone(), + title, + status, + detail, + code, + }; + let mut response = Problem::new(&type_uri, title, status) + .detail(detail) + .with_extra("code", Value::String(code.to_owned())) + .into_response(); + response.extensions_mut().insert(problem); + response +} + +/// Observe a router that is not already owned by an outer correlation layer. +/// Nested use is deliberately idempotent so the production timeout wrapper +/// and focused test routers share one request ID and emit one log record. +pub(crate) async fn observe(mut request: Request, next: Next) -> Response { + if request.extensions().get::().is_some() { + return next.run(request).await; + } + let correlation = RequestCorrelation::from_headers(request.headers()); + request.extensions_mut().insert(correlation.clone()); + let method = method_name(request.method()); + let started = Instant::now(); + let response = next.run(request).await; + finish_response(response, &correlation, method, started) +} + +/// Establish correlation for an outer boundary such as the request timeout. +pub(crate) fn begin_request(request: &mut Request) -> (RequestCorrelation, bool) { + if let Some(correlation) = request.extensions().get::() { + return (correlation.clone(), false); + } + let correlation = RequestCorrelation::from_headers(request.headers()); + request.extensions_mut().insert(correlation.clone()); + (correlation, true) +} + +/// Render correlated problem details, attach the effective trace, and emit one +/// closed operational request record when this layer owns the boundary. +pub(crate) fn finish_response( + response: Response, + correlation: &RequestCorrelation, + method: &'static str, + started: Instant, +) -> Response { + let elapsed = started.elapsed(); + let problem = response.extensions().get::().cloned(); + let mut response = if let Some(problem) = &problem { + let (mut parts, _) = response.into_parts(); + let body = ProblemBody { + type_uri: problem.type_uri.clone(), + title: problem.title, + status: problem.status.as_u16(), + detail: problem.detail, + code: problem.code, + trace_id: correlation.trace_id().clone(), + }; + let body = serde_json::to_vec(&body).expect("ProblemBody serialization is infallible"); + parts.headers.remove(CONTENT_LENGTH); + parts.headers.insert( + CONTENT_TYPE, + "application/problem+json" + .parse() + .expect("problem content type is valid"), + ); + Response::from_parts(parts, Body::from(body)) + } else { + response + }; + correlation.apply_trace(response.headers_mut()); + + let status = status_class(response.status()); + let problem_code = problem.as_ref().map_or("none", |problem| problem.code); + tracing::info!( + target: "registry_server::request", + method, + request_id = %correlation.request_id(), + trace_id = correlation.trace_id().as_str(), + duration_ms = duration_milliseconds(elapsed), + status, + problem_code, + "Registry Server request served" + ); + response +} + +pub(crate) fn method_name(method: &axum::http::Method) -> &'static str { + match *method { + axum::http::Method::GET => "GET", + axum::http::Method::POST => "POST", + axum::http::Method::PATCH => "PATCH", + axum::http::Method::DELETE => "DELETE", + axum::http::Method::HEAD => "HEAD", + axum::http::Method::OPTIONS => "OPTIONS", + _ => "OTHER", + } +} + +fn status_class(status: StatusCode) -> &'static str { + if status.is_server_error() { + "server_error" + } else if status.is_client_error() { + "client_error" + } else { + "success" + } +} + +fn duration_milliseconds(elapsed: std::time::Duration) -> u64 { + u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX) +} diff --git a/crates/registry-server/src/event_destination.rs b/crates/registry-server/src/event_destination.rs index 8ad2c25a37..545177e533 100644 --- a/crates/registry-server/src/event_destination.rs +++ b/crates/registry-server/src/event_destination.rs @@ -545,6 +545,7 @@ impl EventDestinationDeliveryCeilings { } } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Clone, Copy, Deserialize)] #[serde(rename_all = "camelCase")] enum EventDestinationNetworkProfile { @@ -574,6 +575,7 @@ impl EventDestinationNetworkProfile { } } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Clone, Copy, Deserialize)] #[serde(rename_all = "camelCase")] enum EventDestinationDnsFamily { @@ -604,6 +606,7 @@ type ConfigResult = std::result::Result; pub(crate) type RawEventDestinationConfigs = BTreeMap; +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct RawEventDestinationConfig { @@ -619,6 +622,7 @@ pub(crate) struct RawEventDestinationConfig { delivery_ceilings: RawEventDestinationDeliveryCeilings, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawEventDestinationTlsConfig { @@ -628,6 +632,7 @@ struct RawEventDestinationTlsConfig { client_identity_ref: Option, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawEventDestinationDeliveryCeilings { diff --git a/crates/registry-server/src/lib.rs b/crates/registry-server/src/lib.rs index 49b4a64093..fa77e640d4 100644 --- a/crates/registry-server/src/lib.rs +++ b/crates/registry-server/src/lib.rs @@ -12,6 +12,8 @@ pub mod auth; pub mod compiler; pub mod contract; #[cfg(feature = "runtime")] +pub mod correlation; +#[cfg(feature = "runtime")] pub mod cursor; pub mod data; pub mod derived_sql; diff --git a/crates/registry-server/src/mutation.rs b/crates/registry-server/src/mutation.rs index d51587e04f..44fe08f72c 100644 --- a/crates/registry-server/src/mutation.rs +++ b/crates/registry-server/src/mutation.rs @@ -28,6 +28,7 @@ use crate::compiler::{ use crate::contract::{ AccessProfileSource, EventTrigger, FieldTypeSource, MutationMode, Operation, }; +use crate::correlation::RequestCorrelation; use crate::data::{validate_field_value, FieldValue}; use crate::event_destination::ActivatedEventDestinationRegistry; use crate::idempotency::{ @@ -712,6 +713,7 @@ pub struct MutationRequest<'a> { pub expected_etag: Option<&'a str>, pub body: MutationBody, pub response_fields: BTreeSet, + pub correlation: RequestCorrelation, } pub struct BatchMutationRequest<'a> { @@ -721,6 +723,7 @@ pub struct BatchMutationRequest<'a> { pub items: Vec, pub response_fields: BTreeSet, pub body_bytes: usize, + pub correlation: RequestCorrelation, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -872,6 +875,7 @@ impl MutationCoordinator { expected_etag: request.expected_etag, body: normalized_body, response_fields: request.response_fields.clone(), + correlation: request.correlation.clone(), }; if let Err(error) = validate_request(&request, &self.expected) { self.record_boundary_audit(client, &request, PreIoAuditKind::Refusal) @@ -912,6 +916,7 @@ impl MutationCoordinator { items: normalized_items, response_fields: request.response_fields.clone(), body_bytes: request.body_bytes, + correlation: request.correlation.clone(), }; if let Err(error) = validate_batch_request(&request, &self.expected) { self.record_batch_boundary_audit(client, &request, PreIoAuditKind::Refusal) @@ -948,6 +953,7 @@ impl MutationCoordinator { method: request.plan.route.method, operation_id: &request.plan.route.id, target_record: None, + correlation: &request.correlation, }, ) .await?; @@ -972,6 +978,7 @@ impl MutationCoordinator { method: request.plan.route.method, operation_id: &request.plan.route.id, target_record: request.record_id, + correlation: &request.correlation, }, ) .await?; @@ -1038,6 +1045,7 @@ impl MutationCoordinator { }, result_count: None, field_set_reference: None, + correlation: request.correlation.clone(), }, ) .await?; @@ -1131,6 +1139,7 @@ impl MutationCoordinator { record_revision: Some(current.record_revision), result_count: None, field_set_reference: None, + correlation: request.correlation.clone(), }, ) .await?; @@ -1207,6 +1216,7 @@ impl MutationCoordinator { record_revision: None, result_count: Some(usize::from(result_count)), field_set_reference: None, + correlation: request.correlation.clone(), }, ) .await?; @@ -1234,6 +1244,7 @@ impl MutationCoordinator { expected_etag, body, response_fields: request.response_fields.clone(), + correlation: request.correlation.clone(), }; fault.fail_at(MutationFaultPoint::BeforeCurrentRow)?; let current = apply_current_row( @@ -1329,6 +1340,7 @@ impl MutationCoordinator { record_revision: None, result_count: Some(usize::from(result_count)), field_set_reference: None, + correlation: request.correlation.clone(), }, ) .await?; @@ -2366,6 +2378,7 @@ fn validate_batch_request( expected_etag, body, response_fields: request.response_fields.clone(), + correlation: request.correlation.clone(), }; validate_request(&item_request, expected)?; if let MutationBody::Patch(operations) = &item_request.body { diff --git a/crates/registry-server/src/package.rs b/crates/registry-server/src/package.rs index 87ef8a3215..504b6c5343 100644 --- a/crates/registry-server/src/package.rs +++ b/crates/registry-server/src/package.rs @@ -1998,26 +1998,22 @@ fn add_compiled_artifacts( .bytes .clone(), )?; - insert_generated( - files, - "manifest/registry-manifest.json", - compiled - .artifacts() - .get("generated/manifest/registry-manifest.json") - .ok_or(PackageError::Derivation)? - .bytes - .clone(), - )?; - insert_generated( - files, - "manifest/dcat.jsonld", - compiled - .artifacts() - .get("generated/manifest/dcat.jsonld") - .ok_or(PackageError::Derivation)? - .bytes - .clone(), - )?; + let registry_manifest = compiled + .artifacts() + .get("generated/manifest/registry-manifest.json"); + let dcat = compiled.artifacts().get("generated/manifest/dcat.jsonld"); + match (compiled.manifest_projection(), registry_manifest, dcat) { + (Some(_), Some(registry_manifest), Some(dcat)) => { + insert_generated( + files, + "manifest/registry-manifest.json", + registry_manifest.bytes.clone(), + )?; + insert_generated(files, "manifest/dcat.jsonld", dcat.bytes.clone())?; + } + (None, None, None) => {} + _ => return Err(PackageError::Derivation), + } for (path, artifact) in compiled.artifacts().entries() { let Some(schema_name) = path.strip_prefix("generated/schemas/") else { continue; diff --git a/crates/registry-server/src/postgres/context.rs b/crates/registry-server/src/postgres/context.rs index beba574baa..001b4b9001 100644 --- a/crates/registry-server/src/postgres/context.rs +++ b/crates/registry-server/src/postgres/context.rs @@ -432,8 +432,9 @@ mod tests { use crate::compiler::{compile_project, CompileProfile}; use crate::contract::{ - parse_project_json, AccessProfileSource, Classification, EntitySource, FieldSource, - FieldTypeSource, MutationMode, Operation, RegistryProject, RowBoundarySource, + parse_project_json, AccessGrantSource, Classification, EntitySource, FieldSource, + FieldTypeSource, MutationMode, Operation, ProjectAccessProfileSource, RegistryProject, + RowBoundarySource, }; use super::*; @@ -620,11 +621,7 @@ mod tests { "entities":[ { "id":"parent-entry","route":"parents","mutationMode":"mutable", - "fields":[{"id":"name","type":"string","minLength":1,"maxLength":8,"required":true,"classification":"internal"}], - "accessProfiles":[{ - "id":"typed","default":true,"principalClaim":"registry_principal", - "operations":["get"],"readableFields":["name"] - }] + "fields":[{"id":"name","type":"string","minLength":1,"maxLength":8,"required":true,"classification":"internal"}] }, { "id":"typed-entry","route":"typed","mutationMode":"mutable", @@ -639,10 +636,17 @@ mod tests { {"id":"short-name","type":"string","minLength":1,"maxLength":4,"required":true,"classification":"internal"}, {"id":"notes","type":"text","maxLength":6,"required":true,"classification":"internal"}, {"id":"color","type":"vocabulary-code","vocabulary":"colors","required":true,"classification":"internal"} - ], - "accessProfiles":[{ - "id":"typed","default":true,"principalClaim":"registry_principal", - "operations":["get"], + ] + } + ], + "accessProfiles":[{ + "id":"typed","default":true,"principalClaim":"registry_principal", + "grants":[ + { + "entity":"parent-entry","operations":["get"],"readableFields":["name"] + }, + { + "entity":"typed-entry","operations":["get"], "readableFields":["enabled","count","amount","effective-on","observed-at","identifier","parent","short-name","notes","color"], "rowBoundaries":[ {"field":"enabled","claim":"enabled_claim","operator":"equals"}, @@ -656,9 +660,9 @@ mod tests { {"field":"notes","claim":"text_claim","operator":"in"}, {"field":"color","claim":"vocabulary_claim","operator":"equals"} ] - }] - } - ], + } + ] + }], "vocabularies":[{"id":"colors","values":["red","blue"]}] }"#, ) @@ -718,14 +722,19 @@ mod tests { constraints: Vec::new(), temporal: None, indexes: Vec::new(), - access_profiles: vec![ - AccessProfileSource { - id: "operator".to_owned(), - default: true, - anonymous: false, - principal_claim: Some("registry_principal".to_owned()), - required_scopes: BTreeSet::new(), - required_purposes: BTreeSet::from(["operations".to_owned()]), + access_profiles: Vec::new(), + events: Vec::new(), + }], + access_profiles: vec![ + ProjectAccessProfileSource { + id: "operator".to_owned(), + default: true, + anonymous: false, + principal_claim: Some("registry_principal".to_owned()), + required_scopes: BTreeSet::new(), + required_purposes: BTreeSet::from(["operations".to_owned()]), + grants: vec![AccessGrantSource { + entity: "entry".to_owned(), operations: operations.clone(), readable_fields: BTreeSet::from(["tenant".to_owned(), "region".to_owned()]), writable_fields: BTreeSet::new(), @@ -748,14 +757,17 @@ mod tests { lookups: Vec::new(), read_paths: Vec::new(), allow_count: false, - }, - AccessProfileSource { - id: "viewer".to_owned(), - default: false, - anonymous: false, - principal_claim: Some("registry_principal".to_owned()), - required_scopes: BTreeSet::new(), - required_purposes: BTreeSet::new(), + }], + }, + ProjectAccessProfileSource { + id: "viewer".to_owned(), + default: false, + anonymous: false, + principal_claim: Some("registry_principal".to_owned()), + required_scopes: BTreeSet::new(), + required_purposes: BTreeSet::new(), + grants: vec![AccessGrantSource { + entity: "entry".to_owned(), operations, readable_fields: BTreeSet::from(["tenant".to_owned()]), writable_fields: BTreeSet::new(), @@ -771,11 +783,9 @@ mod tests { lookups: Vec::new(), read_paths: Vec::new(), allow_count: false, - }, - ], - events: Vec::new(), - }], - access_profiles: Vec::new(), + }], + }, + ], vocabularies: Vec::new(), }; compile_project(&project, &[], CompileProfile::Authoring).expect("test project compiles") diff --git a/crates/registry-server/src/postgres/mutation.rs b/crates/registry-server/src/postgres/mutation.rs index c1350a564e..589ae1ea97 100644 --- a/crates/registry-server/src/postgres/mutation.rs +++ b/crates/registry-server/src/postgres/mutation.rs @@ -2,20 +2,18 @@ //! Concrete PostgreSQL mutation runtime for the compiled HTTP surface. -use std::collections::BTreeSet; use std::sync::Arc; use std::time::Duration; use registry_platform_audit::AuditProfile; -use serde_json::Map; use crate::api::{ - AuthorizedRequestContext, BatchMutationInput, ConditionalMutationInput, + AuthorizedRequestContext, BatchMutationInput, ConditionalMutationInput, CreateMutationInput, RowBoundaryOperator as ApiRowBoundaryOperator, VerifiedRowBoundary, }; use crate::audit::{record_http_refusal_audit, HttpRefusalAudit}; use crate::event_destination::ActivatedEventDestinationRegistry; -use crate::model::{CompiledRegistry, HttpMethod}; +use crate::model::CompiledRegistry; #[cfg(feature = "postgres-test")] use crate::mutation::MutationFaultPoint; use crate::mutation::{ @@ -105,14 +103,9 @@ impl PostgresRecordMutationService { self } - pub async fn record_refusal( + pub(crate) async fn record_refusal( &self, - method: HttpMethod, - operation_id: &str, - target_record: Option<&str>, - principal: Option<&str>, - selected_access_profile: Option<&str>, - purpose_present: bool, + event: HttpRefusalAudit<'_>, ) -> Result<(), MutationError> { #[cfg(feature = "postgres-test")] if matches!(self.fault, MutationFaultControl::RefusalAudit) { @@ -129,14 +122,7 @@ impl PostgresRecordMutationService { self.lock_timeout, &self.expected, &self.audit_profile, - HttpRefusalAudit { - method, - operation_id, - target_record, - principal, - selected_access_profile, - purpose_present, - }, + event, ) .await .map_err(MutationError::from) @@ -144,30 +130,26 @@ impl PostgresRecordMutationService { pub async fn create( &self, - route_id: &str, - idempotency_key: &str, - context: &AuthorizedRequestContext, - entity_id: &str, - data: Map, - response_fields: BTreeSet, + input: CreateMutationInput<'_>, ) -> Result { let mut client = self .pool .get() .await .map_err(|_| MutationError::Unavailable)?; - let claims = strict_claim_context(&self.registry, context, entity_id)?; - let plan = MutationPlan::from_compiled(&self.registry, route_id)?; + let claims = strict_claim_context(&self.registry, input.context, input.entity_id)?; + let plan = MutationPlan::from_compiled(&self.registry, input.route_id)?; self.execute_request( &mut client, MutationRequest { plan: &plan, - idempotency_key, + idempotency_key: input.idempotency_key, claims: &claims, record_id: None, expected_etag: None, - body: MutationBody::Create(data), - response_fields, + body: MutationBody::Create(input.data), + response_fields: input.response_fields, + correlation: input.correlation.clone(), }, ) .await @@ -200,6 +182,7 @@ impl PostgresRecordMutationService { items: input.items, response_fields: input.response_fields, body_bytes: input.body_bytes, + correlation: input.correlation.clone(), }; #[cfg(feature = "postgres-test")] if let MutationFaultControl::At(fault) = self.fault { @@ -241,6 +224,7 @@ impl PostgresRecordMutationService { expected_etag: Some(input.if_match), body, response_fields: input.response_fields, + correlation: input.correlation.clone(), }, ) .await diff --git a/crates/registry-server/src/postgres/read.rs b/crates/registry-server/src/postgres/read.rs index ad086ce035..d5b48ddde2 100644 --- a/crates/registry-server/src/postgres/read.rs +++ b/crates/registry-server/src/postgres/read.rs @@ -117,6 +117,7 @@ impl PostgresRecordReadService { method: request.method, operation_id: &request.operation_id, target_record, + correlation: &request.correlation, }, ) .await @@ -137,6 +138,7 @@ impl PostgresRecordReadService { method: request.method, operation_id: &request.operation_id, target_record, + correlation: &request.correlation, }, ) .await @@ -156,6 +158,7 @@ impl PostgresRecordReadService { method: request.method, operation_id: &request.operation_id, target_record, + correlation: &request.correlation, }, ) .await @@ -500,6 +503,7 @@ impl PostgresRecordReadService { record_revision, result_count: (outcome != TerminalAuditOutcome::Unresolved).then_some(result_count), field_set_reference: Some(field_set_reference), + correlation: request.correlation.clone(), }) } } @@ -560,6 +564,7 @@ impl RecordReadService for PostgresRecordReadService { principal: request.principal.as_deref(), selected_access_profile: request.selected_access_profile.as_deref(), purpose_present: request.purpose_present, + correlation: &request.correlation, }, ) .await @@ -2186,9 +2191,12 @@ mod tests { "fields":[ {"id":"label","type":"string","required":true,"maxLength":32,"classification":"public"}, {"id":"secret","type":"string","required":true,"maxLength":32,"classification":"restricted"} - ], - "accessProfiles":[{ - "id":"public","default":true,"anonymous":true,"operations":["list"], + ] + }], + "accessProfiles":[{ + "id":"public","default":true,"anonymous":true, + "grants":[{ + "entity":"case","operations":["list"], "readableFields":["label"],"filterableFields":["label"],"sortableFields":["label"] }] }] @@ -2297,6 +2305,7 @@ mod tests { }, }, maximum_records: 11, + correlation: crate::correlation::RequestCorrelation::server_created(), }; assert!(ReadPlan::from_request(®istry, &expected, &cursors, &request).is_err()); diff --git a/crates/registry-server/src/postgres/revision_read.rs b/crates/registry-server/src/postgres/revision_read.rs index b47ea278d3..d64ac09271 100644 --- a/crates/registry-server/src/postgres/revision_read.rs +++ b/crates/registry-server/src/postgres/revision_read.rs @@ -106,6 +106,7 @@ impl PostgresRevisionReadService { method: request.method, operation_id: &request.operation_id, target_record: Some(&request.record_id), + correlation: &request.correlation, }, ) .await @@ -126,6 +127,7 @@ impl PostgresRevisionReadService { method: request.method, operation_id: &request.operation_id, target_record: Some(&request.record_id), + correlation: &request.correlation, }, ) .await @@ -284,6 +286,7 @@ impl PostgresRevisionReadService { record_revision: None, result_count: Some(result_count), field_set_reference: Some(field_set_reference), + correlation: request.correlation.clone(), }, query_reference: None, row_boundary_reference: Some(row_boundary_reference), @@ -335,6 +338,7 @@ impl RevisionReadService for PostgresRevisionReadService { principal: request.principal.as_deref(), selected_access_profile: request.selected_access_profile.as_deref(), purpose_present: request.purpose_present, + correlation: &request.correlation, }, ) .await diff --git a/crates/registry-server/src/runtime_config.rs b/crates/registry-server/src/runtime_config.rs index 07dc9b8a3a..b6eb7fa0ae 100644 --- a/crates/registry-server/src/runtime_config.rs +++ b/crates/registry-server/src/runtime_config.rs @@ -19,6 +19,10 @@ use registry_platform_config::{ expand_config_env_vars_with, SecretError, SecretProvider, SecretReference, SecretResolver, }; use registry_platform_crypto::{parse_json_strict, PublicJwk, SigningAlgorithm}; +#[cfg(feature = "schema")] +use registry_platform_httputil::destination::{ + MAX_DESTINATION_ORIGIN_URL_BYTES, MAX_DESTINATION_PRIVATE_CIDRS, MAX_DESTINATION_TARGET_BYTES, +}; use registry_platform_oidc::{ fetch_discovery, JwksFetcher, JwksFetcherConfig, OidcDiscoveryConfig, TokenVerifierConfig, }; @@ -27,6 +31,10 @@ use serde_json::{Map, Value}; use thiserror::Error; use zeroize::Zeroizing; +#[cfg(feature = "schema")] +use crate::compiler::{ + MAX_WEBHOOK_ATTEMPTS, MAX_WEBHOOK_ATTEMPT_TIMEOUT_MS, MIN_WEBHOOK_ATTEMPT_TIMEOUT_MS, +}; use crate::{ auth::AuthorityClaimConfig, cursor::CursorCodec, @@ -50,6 +58,47 @@ const MAX_RSA_MODULUS_BITS: usize = 8192; const MAX_RSA_EXPONENT_BYTES: usize = 8; const DEFAULT_WEBHOOK_PAYLOAD_RETENTION_DAYS: u8 = 7; const MAX_WEBHOOK_PAYLOAD_RETENTION_DAYS: u8 = 30; +const DEFAULT_POOL_WAIT_TIMEOUT_MILLISECONDS: u64 = 30_000; +const DEFAULT_POOL_CREATE_TIMEOUT_MILLISECONDS: u64 = 30_000; +const DEFAULT_POOL_RECYCLE_TIMEOUT_MILLISECONDS: u64 = 30_000; +const DEFAULT_JWKS_CACHE_TTL_SECONDS: u64 = 600; +const DEFAULT_JWKS_NEGATIVE_CACHE_TTL_SECONDS: u64 = 60; +const DEFAULT_JWKS_REFRESH_COOLDOWN_SECONDS: u64 = 30; +const DEFAULT_JWKS_MAX_DOCUMENT_BYTES: u64 = 65_536; +const DEFAULT_JWKS_REQUEST_TIMEOUT_MILLISECONDS: u64 = 5_000; +const DEFAULT_JWKS_OUTAGE_TOLERANCE_SECONDS: u64 = 900; +const DEFAULT_CURSOR_MAX_AGE_SECONDS: u64 = 300; +const DEFAULT_HTTP_REQUEST_TIMEOUT_MILLISECONDS: u64 = 10_000; +const DEFAULT_SHUTDOWN_GRACE_MILLISECONDS: u64 = 30_000; +const DEFAULT_RECORD_LOCK_MILLISECONDS: u64 = 5_000; +const DEFAULT_MIGRATION_LOCK_MILLISECONDS: u64 = 30_000; +const DEFAULT_MIGRATION_STATEMENT_MILLISECONDS: u64 = 60_000; +#[cfg(feature = "schema")] +const MAX_DATABASE_POOL_SIZE: u64 = 128; +#[cfg(feature = "schema")] +const SECRET_REFERENCE_SCHEMA_PATTERN: &str = + "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$"; +#[cfg(feature = "schema")] +const MAX_SECRET_REFERENCE_SCHEMA_LENGTH: usize = "secret:file/".len() + 128; +#[cfg(feature = "schema")] +const SQL_IDENTIFIER_SCHEMA_PATTERN: &str = "^[_a-z][_a-z0-9]{0,62}$"; +#[cfg(feature = "schema")] +const CLAIM_NAME_SCHEMA_PATTERN: &str = "^[\\x21-\\x7E]+$"; +#[cfg(feature = "schema")] +const LIST_VALUE_SCHEMA_PATTERN: &str = "^[^\\x00-\\x20\\x7F]+$"; +#[cfg(feature = "schema")] +const VALUE_NO_EDGE_WHITESPACE_SCHEMA_PATTERN: &str = + "^[^\\s\\x00-\\x1F\\x7F](?:[^\\x00-\\x1F\\x7F]*[^\\s\\x00-\\x1F\\x7F])?$"; +#[cfg(feature = "schema")] +const SCOPE_SEPARATOR_SCHEMA_PATTERN: &str = "^[^A-Za-z0-9\\x00-\\x1F\\x7F]$"; +#[cfg(feature = "schema")] +const EVENT_DESTINATION_ID_SCHEMA_PATTERN: &str = "^[a-z][a-z0-9_-]{0,63}$"; +#[cfg(feature = "schema")] +const EVENT_DESTINATION_PATH_SCHEMA_PATTERN: &str = + "^/[\\x20-\\x22\\x24\\x26-\\x3E\\x40-\\x5B\\x5D-\\x7E]*$"; + +pub const RUNTIME_CONFIG_API_VERSION: &str = "registry.registrystack.org/server-runtime/v1alpha1"; +pub const RUNTIME_CONFIG_KIND: &str = "RegistryServerRuntimeConfig"; #[derive(Debug, Error, Clone, Copy, Eq, PartialEq)] pub enum RuntimeConfigError { @@ -63,6 +112,10 @@ pub enum RuntimeConfigError { EnvExpansion, #[error("the runtime configuration document is invalid")] Document, + #[error("runtime configuration uses an unsupported apiVersion")] + InvalidApiVersion, + #[error("runtime configuration uses an unsupported kind")] + InvalidKind, #[error("runtime configuration contains a governed member")] GovernedMember, #[error("runtime configuration contains an invalid deployment binding")] @@ -89,6 +142,84 @@ pub enum RuntimeConfigError { Secret, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RuntimeConfigErrorMetadata { + code: &'static str, + path: &'static str, +} + +impl RuntimeConfigErrorMetadata { + #[must_use] + pub const fn code(self) -> &'static str { + self.code + } + + #[must_use] + pub const fn path(self) -> &'static str { + self.path + } +} + +impl RuntimeConfigError { + #[must_use] + pub const fn metadata(self) -> RuntimeConfigErrorMetadata { + RuntimeConfigErrorMetadata { + code: self.code(), + path: self.path(), + } + } + + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::Unavailable => "runtime_config.unavailable", + Self::UnsafeFile => "runtime_config.unsafe_file", + Self::Bounds => "runtime_config.bounds", + Self::EnvExpansion => "runtime_config.env_expansion", + Self::Document => "runtime_config.document", + Self::InvalidApiVersion => "runtime_config.invalid_api_version", + Self::InvalidKind => "runtime_config.invalid_kind", + Self::GovernedMember => "runtime_config.governed_member", + Self::InvalidBinding => "runtime_config.invalid_binding", + Self::InvalidListener => "runtime_config.invalid_listener", + Self::InvalidSecretProvider => "runtime_config.invalid_secret_provider", + Self::InvalidDatabase => "runtime_config.invalid_database", + Self::InvalidPackage => "runtime_config.invalid_package", + Self::InvalidOidc => "runtime_config.invalid_oidc", + Self::InvalidAudit => "runtime_config.invalid_audit", + Self::InvalidCursor => "runtime_config.invalid_cursor", + Self::InvalidEventDestination => "runtime_config.invalid_event_destination", + Self::InvalidBounds => "runtime_config.invalid_bounds", + Self::Secret => "runtime_config.secret", + } + } + + #[must_use] + pub const fn path(self) -> &'static str { + match self { + Self::Unavailable + | Self::UnsafeFile + | Self::Bounds + | Self::EnvExpansion + | Self::Document + | Self::GovernedMember + | Self::InvalidBinding + | Self::Secret => "/", + Self::InvalidApiVersion => "/apiVersion", + Self::InvalidKind => "/kind", + Self::InvalidListener => "/listener", + Self::InvalidSecretProvider => "/secretProviders", + Self::InvalidDatabase => "/database", + Self::InvalidPackage => "/package", + Self::InvalidOidc => "/authentication/oidc", + Self::InvalidAudit => "/audit", + Self::InvalidCursor => "/cursor", + Self::InvalidEventDestination => "/eventDestinations", + Self::InvalidBounds => "/operationalTimeouts", + } + } +} + impl From for RuntimeConfigError { fn from(_error: SecretError) -> Self { Self::Secret @@ -207,6 +338,12 @@ pub struct RuntimeConfig { impl RuntimeConfig { fn from_raw(raw: RawRuntimeConfig) -> Result { + if raw.api_version != RUNTIME_CONFIG_API_VERSION { + return Err(RuntimeConfigError::InvalidApiVersion); + } + if raw.kind != RUNTIME_CONFIG_KIND { + return Err(RuntimeConfigError::InvalidKind); + } let listener = ListenerConfig::from_raw(raw.listener)?; let identity = DeploymentIdentity::from_raw(raw.identity)?; let secret_providers = SecretProvidersConfig::from_raw(raw.secret_providers)?; @@ -442,6 +579,7 @@ impl fmt::Debug for ListenerConfig { } } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "kebab-case")] pub enum TrustedProxyPosture { @@ -855,6 +993,7 @@ impl fmt::Debug for OidcVerifierConfig { } } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] pub enum OidcAlgorithm { EdDSA, @@ -1362,9 +1501,12 @@ pub(crate) fn parse_secret_reference( SecretReference::parse(value).map_err(|_| error) } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawRuntimeConfig { + api_version: String, + kind: String, listener: RawListenerConfig, identity: RawDeploymentIdentity, secret_providers: RawSecretProvidersConfig, @@ -1375,11 +1517,15 @@ struct RawRuntimeConfig { cursor: RawCursorConfig, #[serde(default)] event_destinations: RawEventDestinationConfigs, + /// Optional event-delivery tuning. Defaults to the server's bounded retention policy. #[serde(default)] event_delivery: RawEventDeliveryConfig, + /// Optional operational request, shutdown, locking, and migration timeout tuning. + #[serde(default)] operational_timeouts: RawOperationalTimeouts, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawListenerConfig { @@ -1387,6 +1533,7 @@ struct RawListenerConfig { trusted_proxy: TrustedProxyPosture, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawDeploymentIdentity { @@ -1396,6 +1543,7 @@ struct RawDeploymentIdentity { database_initialization_environment: String, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawSecretProvidersConfig { @@ -1403,16 +1551,19 @@ struct RawSecretProvidersConfig { file: Option, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RawEnvironmentSecretProviderConfig {} +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RawFileSecretProviderConfig { root: PathBuf, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawDatabaseConfig { @@ -1421,22 +1572,33 @@ struct RawDatabaseConfig { pool: RawPoolBounds, roles: RawSqlRoles, #[serde(default)] + #[cfg_attr(feature = "schema", schemars(skip))] plaintext: Option, #[serde(default)] + #[cfg_attr(feature = "schema", schemars(skip))] url: Option, #[serde(default)] + #[cfg_attr(feature = "schema", schemars(skip))] password: Option, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawPoolBounds { max_size: usize, + /// Defaults to the bounded PostgreSQL pool wait timeout. + #[serde(default = "default_pool_wait_timeout_milliseconds")] wait_timeout_milliseconds: u64, + /// Defaults to the bounded PostgreSQL pool connection-creation timeout. + #[serde(default = "default_pool_create_timeout_milliseconds")] create_timeout_milliseconds: u64, + /// Defaults to the bounded PostgreSQL pool connection-recycle timeout. + #[serde(default = "default_pool_recycle_timeout_milliseconds")] recycle_timeout_milliseconds: u64, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawSqlRoles { @@ -1444,6 +1606,7 @@ struct RawSqlRoles { runtime: String, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawPackageConfig { @@ -1454,6 +1617,7 @@ struct RawPackageConfig { active_sequence: u64, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawAuthenticationConfig { @@ -1461,6 +1625,7 @@ struct RawAuthenticationConfig { authority_claims: RawAuthorityClaimsConfig, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawOidcVerifierConfig { @@ -1476,11 +1641,14 @@ struct RawOidcVerifierConfig { denied_kids: Vec, max_token_lifetime_seconds: u64, leeway_milliseconds: u64, + /// Optional JWKS fetch and cache tuning. Defaults to bounded cache behavior. + #[serde(default)] jwks_cache: RawJwksCacheConfig, #[serde(default)] jwks_source: Option, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde( rename_all = "camelCase", @@ -1493,17 +1661,31 @@ enum RawOidcJwksSource { Static { document_ref: String }, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawJwksCacheConfig { + /// Defaults to the bounded JWKS cache time-to-live. + #[serde(default = "default_jwks_cache_ttl_seconds")] cache_ttl_seconds: u64, + /// Defaults to the bounded JWKS negative-cache time-to-live. + #[serde(default = "default_jwks_negative_cache_ttl_seconds")] negative_cache_ttl_seconds: u64, + /// Defaults to the bounded JWKS refresh cooldown. + #[serde(default = "default_jwks_refresh_cooldown_seconds")] refresh_cooldown_seconds: u64, + /// Defaults to the bounded maximum JWKS document size. + #[serde(default = "default_jwks_max_document_bytes")] max_document_bytes: u64, + /// Defaults to the bounded JWKS fetch timeout. + #[serde(default = "default_jwks_request_timeout_milliseconds")] request_timeout_milliseconds: u64, + /// Defaults to the bounded cached-key outage tolerance. + #[serde(default = "default_jwks_outage_tolerance_seconds")] outage_tolerance_seconds: u64, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawAuthorityClaimsConfig { @@ -1512,22 +1694,28 @@ struct RawAuthorityClaimsConfig { purpose: Option, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawAuditConfig { hash_key_ref: String, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawCursorConfig { secret_ref: String, + /// Defaults to the bounded cursor validity lifetime. + #[serde(default = "default_cursor_max_age_seconds")] max_age_seconds: u64, } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawEventDeliveryConfig { + /// Defaults to the bounded retained payload lifetime for pending or dead-letter webhook work. #[serde(default = "default_webhook_payload_retention_days")] payload_retention_days: u8, } @@ -1544,16 +1732,616 @@ const fn default_webhook_payload_retention_days() -> u8 { DEFAULT_WEBHOOK_PAYLOAD_RETENTION_DAYS } +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawOperationalTimeouts { + /// Defaults to the bounded per-request HTTP timeout. + #[serde(default = "default_http_request_timeout_milliseconds")] http_request_milliseconds: u64, + /// Defaults to the bounded graceful-shutdown timeout. + #[serde(default = "default_shutdown_grace_milliseconds")] shutdown_grace_milliseconds: u64, + /// Defaults to the bounded record lock timeout. + #[serde(default = "default_record_lock_milliseconds")] record_lock_milliseconds: u64, + /// Defaults to the bounded migration lock timeout. + #[serde(default = "default_migration_lock_milliseconds")] migration_lock_milliseconds: u64, + /// Defaults to the bounded migration statement timeout. + #[serde(default = "default_migration_statement_milliseconds")] migration_statement_milliseconds: u64, } +impl Default for RawOperationalTimeouts { + fn default() -> Self { + Self { + http_request_milliseconds: default_http_request_timeout_milliseconds(), + shutdown_grace_milliseconds: default_shutdown_grace_milliseconds(), + record_lock_milliseconds: default_record_lock_milliseconds(), + migration_lock_milliseconds: default_migration_lock_milliseconds(), + migration_statement_milliseconds: default_migration_statement_milliseconds(), + } + } +} + +impl Default for RawJwksCacheConfig { + fn default() -> Self { + Self { + cache_ttl_seconds: default_jwks_cache_ttl_seconds(), + negative_cache_ttl_seconds: default_jwks_negative_cache_ttl_seconds(), + refresh_cooldown_seconds: default_jwks_refresh_cooldown_seconds(), + max_document_bytes: default_jwks_max_document_bytes(), + request_timeout_milliseconds: default_jwks_request_timeout_milliseconds(), + outage_tolerance_seconds: default_jwks_outage_tolerance_seconds(), + } + } +} + +const fn default_pool_wait_timeout_milliseconds() -> u64 { + DEFAULT_POOL_WAIT_TIMEOUT_MILLISECONDS +} + +const fn default_pool_create_timeout_milliseconds() -> u64 { + DEFAULT_POOL_CREATE_TIMEOUT_MILLISECONDS +} + +const fn default_pool_recycle_timeout_milliseconds() -> u64 { + DEFAULT_POOL_RECYCLE_TIMEOUT_MILLISECONDS +} + +const fn default_jwks_cache_ttl_seconds() -> u64 { + DEFAULT_JWKS_CACHE_TTL_SECONDS +} + +const fn default_jwks_negative_cache_ttl_seconds() -> u64 { + DEFAULT_JWKS_NEGATIVE_CACHE_TTL_SECONDS +} + +const fn default_jwks_refresh_cooldown_seconds() -> u64 { + DEFAULT_JWKS_REFRESH_COOLDOWN_SECONDS +} + +const fn default_jwks_max_document_bytes() -> u64 { + DEFAULT_JWKS_MAX_DOCUMENT_BYTES +} + +const fn default_jwks_request_timeout_milliseconds() -> u64 { + DEFAULT_JWKS_REQUEST_TIMEOUT_MILLISECONDS +} + +const fn default_jwks_outage_tolerance_seconds() -> u64 { + DEFAULT_JWKS_OUTAGE_TOLERANCE_SECONDS +} + +const fn default_cursor_max_age_seconds() -> u64 { + DEFAULT_CURSOR_MAX_AGE_SECONDS +} + +const fn default_http_request_timeout_milliseconds() -> u64 { + DEFAULT_HTTP_REQUEST_TIMEOUT_MILLISECONDS +} + +const fn default_shutdown_grace_milliseconds() -> u64 { + DEFAULT_SHUTDOWN_GRACE_MILLISECONDS +} + +const fn default_record_lock_milliseconds() -> u64 { + DEFAULT_RECORD_LOCK_MILLISECONDS +} + +const fn default_migration_lock_milliseconds() -> u64 { + DEFAULT_MIGRATION_LOCK_MILLISECONDS +} + +const fn default_migration_statement_milliseconds() -> u64 { + DEFAULT_MIGRATION_STATEMENT_MILLISECONDS +} + +#[cfg(feature = "schema")] +pub fn runtime_config_schema() -> std::result::Result { + let mut schema = serde_json::to_value(schemars::schema_for!(RawRuntimeConfig))?; + install_schema_const_property(&mut schema, "apiVersion", RUNTIME_CONFIG_API_VERSION); + install_schema_const_property(&mut schema, "kind", RUNTIME_CONFIG_KIND); + install_schema_constraints(&mut schema); + for pointer in [ + "/properties/eventDestinations", + "/$defs/RawAuthorityClaimsConfig/properties/purpose", + "/$defs/RawDatabaseConfig/properties/password", + "/$defs/RawDatabaseConfig/properties/plaintext", + "/$defs/RawDatabaseConfig/properties/url", + "/$defs/RawEventDestinationConfig/properties/tls", + "/$defs/RawEventDestinationTlsConfig/properties/caBundleRef", + "/$defs/RawEventDestinationTlsConfig/properties/clientIdentityRef", + "/$defs/RawOidcVerifierConfig/properties/allowedClients", + "/$defs/RawOidcVerifierConfig/properties/deniedKids", + "/$defs/RawOidcVerifierConfig/properties/jwksSource", + ] { + remove_schema_default(&mut schema, pointer); + } + Ok(schema) +} + +#[cfg(feature = "schema")] +fn install_schema_constraints(schema: &mut Value) { + for (pointer, minimum, maximum) in [ + ( + "/$defs/RawPoolBounds/properties/maxSize", + 1, + MAX_DATABASE_POOL_SIZE, + ), + ( + "/$defs/RawPoolBounds/properties/waitTimeoutMilliseconds", + 1, + 60_000, + ), + ( + "/$defs/RawPoolBounds/properties/createTimeoutMilliseconds", + 1, + 60_000, + ), + ( + "/$defs/RawPoolBounds/properties/recycleTimeoutMilliseconds", + 1, + 60_000, + ), + ( + "/$defs/RawPackageConfig/properties/activeSequence", + 1, + u64::MAX, + ), + ( + "/$defs/RawOidcVerifierConfig/properties/maxTokenLifetimeSeconds", + 1, + 3_600, + ), + ( + "/$defs/RawOidcVerifierConfig/properties/leewayMilliseconds", + 0, + 300_000, + ), + ( + "/$defs/RawJwksCacheConfig/properties/cacheTtlSeconds", + 1, + 86_400, + ), + ( + "/$defs/RawJwksCacheConfig/properties/negativeCacheTtlSeconds", + 1, + 3_600, + ), + ( + "/$defs/RawJwksCacheConfig/properties/refreshCooldownSeconds", + 1, + 3_600, + ), + ( + "/$defs/RawJwksCacheConfig/properties/maxDocumentBytes", + 1, + MAX_JWKS_DOCUMENT_BYTES, + ), + ( + "/$defs/RawJwksCacheConfig/properties/requestTimeoutMilliseconds", + 1, + 30_000, + ), + ( + "/$defs/RawJwksCacheConfig/properties/outageToleranceSeconds", + 0, + 86_400, + ), + ("/$defs/RawCursorConfig/properties/maxAgeSeconds", 1, 86_400), + ( + "/$defs/RawEventDeliveryConfig/properties/payloadRetentionDays", + 1, + u64::from(MAX_WEBHOOK_PAYLOAD_RETENTION_DAYS), + ), + ( + "/$defs/RawOperationalTimeouts/properties/httpRequestMilliseconds", + 1, + 60_000, + ), + ( + "/$defs/RawOperationalTimeouts/properties/shutdownGraceMilliseconds", + 1, + 300_000, + ), + ( + "/$defs/RawOperationalTimeouts/properties/recordLockMilliseconds", + 1, + 30_000, + ), + ( + "/$defs/RawOperationalTimeouts/properties/migrationLockMilliseconds", + 1, + 300_000, + ), + ( + "/$defs/RawOperationalTimeouts/properties/migrationStatementMilliseconds", + 1, + 3_600_000, + ), + ( + "/$defs/RawEventDestinationDeliveryCeilings/properties/attemptTimeoutMilliseconds", + u64::from(MIN_WEBHOOK_ATTEMPT_TIMEOUT_MS), + u64::from(MAX_WEBHOOK_ATTEMPT_TIMEOUT_MS), + ), + ( + "/$defs/RawEventDestinationDeliveryCeilings/properties/maximumAttempts", + 1, + u64::from(MAX_WEBHOOK_ATTEMPTS), + ), + ] { + install_schema_integer_bounds(schema, pointer, minimum, maximum); + } + + for (pointer, minimum, maximum, pattern) in [ + ( + "/$defs/RawDeploymentIdentity/properties/environment", + 1, + MAX_DEPLOYMENT_VALUE_BYTES, + VALUE_NO_EDGE_WHITESPACE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawDeploymentIdentity/properties/instanceId", + 1, + MAX_DEPLOYMENT_VALUE_BYTES, + VALUE_NO_EDGE_WHITESPACE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawDeploymentIdentity/properties/databaseId", + 1, + MAX_DEPLOYMENT_VALUE_BYTES, + VALUE_NO_EDGE_WHITESPACE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawDeploymentIdentity/properties/databaseInitializationEnvironment", + 1, + MAX_DEPLOYMENT_VALUE_BYTES, + VALUE_NO_EDGE_WHITESPACE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawFileSecretProviderConfig/properties/root", + 1, + MAX_PATH_BYTES, + "", + ), + ( + "/$defs/RawDatabaseConfig/properties/runtimeUrlRef", + 1, + MAX_SECRET_REFERENCE_SCHEMA_LENGTH, + SECRET_REFERENCE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawDatabaseConfig/properties/migrationUrlRef", + 1, + MAX_SECRET_REFERENCE_SCHEMA_LENGTH, + SECRET_REFERENCE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawSqlRoles/properties/migration", + 1, + 63, + SQL_IDENTIFIER_SCHEMA_PATTERN, + ), + ( + "/$defs/RawSqlRoles/properties/runtime", + 1, + 63, + SQL_IDENTIFIER_SCHEMA_PATTERN, + ), + ( + "/$defs/RawPackageConfig/properties/root", + 1, + MAX_PATH_BYTES, + "", + ), + ( + "/$defs/RawPackageConfig/properties/trustAnchorPath", + 1, + MAX_PATH_BYTES, + "", + ), + ( + "/$defs/RawPackageConfig/properties/compilerSourceRevision", + 1, + MAX_DEPLOYMENT_VALUE_BYTES, + VALUE_NO_EDGE_WHITESPACE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawPackageConfig/properties/activeRevision", + 1, + MAX_DEPLOYMENT_VALUE_BYTES, + VALUE_NO_EDGE_WHITESPACE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawOidcVerifierConfig/properties/issuer", + 1, + MAX_OIDC_VALUE_BYTES, + VALUE_NO_EDGE_WHITESPACE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawOidcVerifierConfig/properties/audience", + 1, + MAX_OIDC_VALUE_BYTES, + VALUE_NO_EDGE_WHITESPACE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawOidcVerifierConfig/properties/accessTokenType", + 1, + MAX_OIDC_VALUE_BYTES, + VALUE_NO_EDGE_WHITESPACE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawOidcVerifierConfig/properties/scopeClaim", + 1, + 128, + CLAIM_NAME_SCHEMA_PATTERN, + ), + ( + "/$defs/RawOidcVerifierConfig/properties/scopeSeparator", + 1, + 1, + SCOPE_SEPARATOR_SCHEMA_PATTERN, + ), + ( + "/$defs/RawOidcJwksSource/oneOf/1/properties/documentRef", + 1, + MAX_SECRET_REFERENCE_SCHEMA_LENGTH, + SECRET_REFERENCE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawAuthorityClaimsConfig/properties/principal", + 1, + 128, + CLAIM_NAME_SCHEMA_PATTERN, + ), + ( + "/$defs/RawAuthorityClaimsConfig/properties/purpose", + 1, + 128, + CLAIM_NAME_SCHEMA_PATTERN, + ), + ( + "/$defs/RawAuditConfig/properties/hashKeyRef", + 1, + MAX_SECRET_REFERENCE_SCHEMA_LENGTH, + SECRET_REFERENCE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawCursorConfig/properties/secretRef", + 1, + MAX_SECRET_REFERENCE_SCHEMA_LENGTH, + SECRET_REFERENCE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawEventDestinationConfig/properties/origin", + 1, + MAX_DESTINATION_ORIGIN_URL_BYTES, + "", + ), + ( + "/$defs/RawEventDestinationConfig/properties/path", + 1, + MAX_DESTINATION_TARGET_BYTES, + EVENT_DESTINATION_PATH_SCHEMA_PATTERN, + ), + ( + "/$defs/RawEventDestinationConfig/properties/hmacSha256KeyRef", + 1, + MAX_SECRET_REFERENCE_SCHEMA_LENGTH, + SECRET_REFERENCE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawEventDestinationTlsConfig/properties/caBundleRef", + 1, + MAX_SECRET_REFERENCE_SCHEMA_LENGTH, + SECRET_REFERENCE_SCHEMA_PATTERN, + ), + ( + "/$defs/RawEventDestinationTlsConfig/properties/clientIdentityRef", + 1, + MAX_SECRET_REFERENCE_SCHEMA_LENGTH, + SECRET_REFERENCE_SCHEMA_PATTERN, + ), + ] { + install_schema_string_constraints(schema, pointer, minimum, maximum, pattern); + } + + for pointer in [ + "/$defs/RawOidcVerifierConfig/properties/allowedClients", + "/$defs/RawOidcVerifierConfig/properties/deniedKids", + ] { + install_schema_array_constraints(schema, pointer, MAX_LIST_ITEMS, true); + if let Some(items) = schema + .pointer_mut(pointer) + .and_then(Value::as_object_mut) + .and_then(|member| member.get_mut("items")) + .and_then(Value::as_object_mut) + { + install_string_constraints_in_object( + items, + 1, + MAX_LIST_VALUE_BYTES, + LIST_VALUE_SCHEMA_PATTERN, + ); + } + } + install_schema_array_constraints( + schema, + "/$defs/RawEventDestinationConfig/properties/allowedPrivateCidrs", + MAX_DESTINATION_PRIVATE_CIDRS, + true, + ); + install_schema_property_names( + schema, + "/properties/eventDestinations", + EVENT_DESTINATION_ID_SCHEMA_PATTERN, + ); + if let Some(member) = schema + .pointer_mut("/properties/eventDestinations") + .and_then(Value::as_object_mut) + { + member.insert("maxProperties".to_owned(), Value::from(128_u64)); + } + install_schema_authority_claim_exclusions(schema); + install_schema_tls_presence_constraint(schema); +} + +#[cfg(feature = "schema")] +fn install_schema_const_property(schema: &mut Value, property: &'static str, expected: &str) { + let Some(properties) = schema + .get_mut("properties") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + let Some(member) = properties + .get_mut(property) + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + member.clear(); + member.insert("type".to_owned(), Value::String("string".to_owned())); + member.insert("const".to_owned(), Value::String(expected.to_owned())); +} + +#[cfg(feature = "schema")] +fn install_schema_integer_bounds(schema: &mut Value, pointer: &str, minimum: u64, maximum: u64) { + if let Some(member) = schema.pointer_mut(pointer).and_then(Value::as_object_mut) { + member.insert("minimum".to_owned(), Value::from(minimum)); + if maximum != u64::MAX { + member.insert("maximum".to_owned(), Value::from(maximum)); + } + } +} + +#[cfg(feature = "schema")] +fn install_schema_string_constraints( + schema: &mut Value, + pointer: &str, + minimum: usize, + maximum: usize, + pattern: &str, +) { + if let Some(member) = schema.pointer_mut(pointer).and_then(Value::as_object_mut) { + install_string_constraints_in_object(member, minimum, maximum, pattern); + } +} + +#[cfg(feature = "schema")] +fn install_string_constraints_in_object( + member: &mut Map, + minimum: usize, + maximum: usize, + pattern: &str, +) { + member.insert("minLength".to_owned(), Value::from(minimum)); + member.insert("maxLength".to_owned(), Value::from(maximum)); + if !pattern.is_empty() { + member.insert("pattern".to_owned(), Value::String(pattern.to_owned())); + } +} + +#[cfg(feature = "schema")] +fn install_schema_array_constraints( + schema: &mut Value, + pointer: &str, + maximum: usize, + unique_items: bool, +) { + if let Some(member) = schema.pointer_mut(pointer).and_then(Value::as_object_mut) { + member.insert("maxItems".to_owned(), Value::from(maximum)); + if unique_items { + member.insert("uniqueItems".to_owned(), Value::Bool(true)); + } + } +} + +#[cfg(feature = "schema")] +fn install_schema_property_names(schema: &mut Value, pointer: &str, pattern: &str) { + if let Some(member) = schema.pointer_mut(pointer).and_then(Value::as_object_mut) { + member.insert( + "propertyNames".to_owned(), + serde_json::json!({ + "type": "string", + "pattern": pattern, + }), + ); + } +} + +#[cfg(feature = "schema")] +fn install_schema_authority_claim_exclusions(schema: &mut Value) { + let registered = serde_json::json!({ + "enum": [ + "iss", + "aud", + "exp", + "iat", + "nbf", + "sub", + "client_id", + "azp", + "jti", + "cnf" + ] + }); + for pointer in [ + "/$defs/RawAuthorityClaimsConfig/properties/principal", + "/$defs/RawAuthorityClaimsConfig/properties/purpose", + ] { + if let Some(member) = schema.pointer_mut(pointer).and_then(Value::as_object_mut) { + member.insert("not".to_owned(), registered.clone()); + } + } +} + +#[cfg(feature = "schema")] +fn install_schema_tls_presence_constraint(schema: &mut Value) { + let Some(member) = schema + .pointer_mut("/$defs/RawEventDestinationTlsConfig") + .and_then(Value::as_object_mut) + else { + return; + }; + member.insert( + "anyOf".to_owned(), + serde_json::json!([ + { + "required": ["caBundleRef"], + "properties": { + "caBundleRef": { + "type": "string", + "minLength": 1, + "maxLength": MAX_SECRET_REFERENCE_SCHEMA_LENGTH, + "pattern": SECRET_REFERENCE_SCHEMA_PATTERN + } + } + }, + { + "required": ["clientIdentityRef"], + "properties": { + "clientIdentityRef": { + "type": "string", + "minLength": 1, + "maxLength": MAX_SECRET_REFERENCE_SCHEMA_LENGTH, + "pattern": SECRET_REFERENCE_SCHEMA_PATTERN + } + } + } + ]), + ); +} + +#[cfg(feature = "schema")] +fn remove_schema_default(schema: &mut Value, pointer: &str) { + if let Some(member) = schema.pointer_mut(pointer).and_then(Value::as_object_mut) { + member.remove("default"); + } +} + fn read_bounded_runtime_config(path: &Path, maximum: u64) -> Result> { let scanned = fs::symlink_metadata(path).map_err(|_| RuntimeConfigError::Unavailable)?; if scanned.file_type().is_symlink() || !scanned.is_file() { diff --git a/crates/registry-server/src/schema.rs b/crates/registry-server/src/schema.rs index 426c8a8cdb..b0971c93cc 100644 --- a/crates/registry-server/src/schema.rs +++ b/crates/registry-server/src/schema.rs @@ -6,12 +6,19 @@ use std::collections::BTreeMap; use serde_json::{Map, Value}; use crate::contract::RegistryProject; +#[cfg(feature = "runtime")] +use crate::runtime_config::runtime_config_schema; const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; pub const REGISTRY_PROJECT_SCHEMA_FILE: &str = "registry-project.schema.json"; pub const REGISTRY_PROJECT_SCHEMA_ID: &str = "https://id.registrystack.org/schemas/registry-server/authoring/registry-project.v1alpha1.schema.json"; +#[cfg(feature = "runtime")] +pub const RUNTIME_CONFIG_SCHEMA_FILE: &str = "runtime.schema.json"; +#[cfg(feature = "runtime")] +pub const RUNTIME_CONFIG_SCHEMA_ID: &str = + "https://id.registrystack.org/schemas/registry-server/runtime/runtime.v1alpha1.schema.json"; /// Every authoring schema under its committed artifact filename. pub fn documents() -> Result, serde_json::Error> { @@ -29,6 +36,23 @@ pub fn documents() -> Result, serde_json::Error> .collect() } +/// Every runtime schema under its committed artifact filename. +#[cfg(feature = "runtime")] +pub fn runtime_documents() -> Result, serde_json::Error> { + let entries = [( + RUNTIME_CONFIG_SCHEMA_FILE, + "Registry Server runtime configuration", + RUNTIME_CONFIG_SCHEMA_ID, + runtime_config_schema()?, + )]; + entries + .into_iter() + .map(|(file, title, identifier, derived)| { + Ok((file, render(published(derived, title, identifier))?)) + }) + .collect() +} + fn published(derived: Value, title: &str, identifier: &str) -> Value { let mut object = match derived { Value::Object(object) => object, @@ -61,6 +85,12 @@ mod tests { use serde_json::Value; use super::{documents, REGISTRY_PROJECT_SCHEMA_FILE, REGISTRY_PROJECT_SCHEMA_ID}; + #[cfg(feature = "runtime")] + use super::{runtime_documents, RUNTIME_CONFIG_SCHEMA_FILE, RUNTIME_CONFIG_SCHEMA_ID}; + #[cfg(feature = "runtime")] + use crate::runtime_config::{ + parse_runtime_config, RUNTIME_CONFIG_API_VERSION, RUNTIME_CONFIG_KIND, + }; const ACCEPTANCE_PROJECTS: &[&str] = &[ "asset-site-placement", @@ -85,6 +115,113 @@ mod tests { .expect("a generated schema compiles as 2020-12") } + #[cfg(feature = "runtime")] + fn runtime_schema_document() -> String { + runtime_documents() + .expect("the Registry Server runtime schema generates") + .remove(RUNTIME_CONFIG_SCHEMA_FILE) + .expect("the RuntimeConfig schema is generated") + } + + #[cfg(feature = "runtime")] + fn runtime_instance() -> Value { + serde_json::json!({ + "apiVersion": RUNTIME_CONFIG_API_VERSION, + "kind": RUNTIME_CONFIG_KIND, + "listener": { + "bind": "127.0.0.1:8080", + "trustedProxy": "direct" + }, + "identity": { + "environment": "production", + "instanceId": "registry-primary", + "databaseId": "registry-db", + "databaseInitializationEnvironment": "production" + }, + "secretProviders": { + "environment": {}, + "file": { + "root": "/var/lib/registry-server/secrets" + } + }, + "database": { + "runtimeUrlRef": "secret:env/REGISTRY_SERVER_DATABASE_URL", + "migrationUrlRef": "secret:env/REGISTRY_SERVER_MIGRATION_DATABASE_URL", + "pool": { + "maxSize": 4 + }, + "roles": { + "migration": "registry_migration", + "runtime": "registry_runtime" + } + }, + "package": { + "root": "/var/lib/registry-server/package", + "trustAnchorPath": "/etc/registry-server/package-trust-anchor.json", + "compilerSourceRevision": "source-revision", + "activeRevision": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "activeSequence": 1 + }, + "authentication": { + "oidc": { + "issuer": "https://issuer.example", + "audience": "urn:registry-server:test", + "allowedAlgorithm": "EdDSA", + "accessTokenType": "JWT", + "scopeClaim": "scope", + "scopeSeparator": " ", + "maxTokenLifetimeSeconds": 300, + "leewayMilliseconds": 60000 + }, + "authorityClaims": { + "principal": "registry_principal" + } + }, + "audit": { + "hashKeyRef": "secret:file/audit-key" + }, + "cursor": { + "secretRef": "secret:file/cursor-key" + } + }) + } + + #[cfg(feature = "runtime")] + fn valid_event_destination() -> Value { + serde_json::json!({ + "origin": "https://webhook.example", + "path": "/events", + "networkProfile": "productionHttps", + "dnsFamily": "dualStackStrict", + "allowedPrivateCidrs": [], + "hmacSha256KeyRef": "secret:file/webhook-hmac", + "classificationCeiling": "public", + "deliveryCeilings": { + "attemptTimeoutMilliseconds": 100, + "maximumAttempts": 1 + } + }) + } + + #[cfg(feature = "runtime")] + fn assert_schema_rejects_parser_refused_runtime( + schema: &JSONSchema, + label: &str, + mutate: impl FnOnce(&mut Value), + ) { + let mut instance = runtime_instance(); + mutate(&mut instance); + let raw = serde_json::to_string(&instance).expect("the mutated runtime is JSON"); + assert!( + parse_runtime_config(&raw).is_err(), + "{label}: parser accepted the representative invalid runtime" + ); + assert!( + !schema.is_valid(&instance), + "{label}: schema accepted a runtime the parser rejects" + ); + } + fn acceptance_root() -> std::path::PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../products/registry-server/acceptance") } @@ -164,6 +301,44 @@ mod tests { assert!(!schema.is_valid(&instance)); } + #[test] + fn schema_rejects_the_legacy_top_level_access_profile_vocabulary() { + let document = schema_document(); + let schema = compile(&document); + let mut old_purposes = fixture("asset-site-placement"); + let required_purposes = old_purposes["accessProfiles"][0] + .as_object_mut() + .expect("access profile is an object") + .remove("requiredPurposes") + .expect("fixture uses canonical requiredPurposes"); + old_purposes["accessProfiles"][0]["purposes"] = required_purposes; + assert!(!schema.is_valid(&old_purposes)); + + let mut old_actions = fixture("asset-site-placement"); + let operations = old_actions["accessProfiles"][0]["grants"][0] + .as_object_mut() + .expect("access grant is an object") + .remove("operations") + .expect("fixture uses canonical operations"); + old_actions["accessProfiles"][0]["grants"][0]["actions"] = operations; + assert!(!schema.is_valid(&old_actions)); + } + + #[test] + fn schema_rejects_entity_local_project_access_profiles() { + let document = schema_document(); + let schema = compile(&document); + let mut instance = fixture("business"); + instance["entities"][0]["accessProfiles"] = serde_json::json!([{ + "id": "entity-local-reader", + "anonymous": true, + "operations": ["get"], + "readableFields": ["legal-name"] + }]); + + assert!(!schema.is_valid(&instance)); + } + #[test] fn schema_rejects_field_options_that_do_not_belong_to_the_field_type() { let document = schema_document(); @@ -242,4 +417,363 @@ mod tests { schema_document(), ); } + + #[cfg(feature = "runtime")] + #[test] + fn runtime_schema_declares_the_published_dialect_identifier_and_title() { + let document = runtime_schema_document(); + let value: Value = serde_json::from_str(&document).expect("the schema is JSON"); + assert_eq!( + value.get("$schema").and_then(Value::as_str), + Some("https://json-schema.org/draft/2020-12/schema"), + ); + assert_eq!( + value.get("$id").and_then(Value::as_str), + Some(RUNTIME_CONFIG_SCHEMA_ID), + ); + assert_eq!( + value.get("title").and_then(Value::as_str), + Some("Registry Server runtime configuration"), + ); + assert_eq!(value.get("additionalProperties"), Some(&Value::Bool(false))); + assert_eq!( + value + .pointer("/properties/apiVersion/const") + .and_then(Value::as_str), + Some(RUNTIME_CONFIG_API_VERSION), + ); + assert_eq!( + value + .pointer("/properties/kind/const") + .and_then(Value::as_str), + Some(RUNTIME_CONFIG_KIND), + ); + } + + #[cfg(feature = "runtime")] + #[test] + fn runtime_schema_publishes_only_safe_operational_defaults() { + let document = runtime_schema_document(); + let value: Value = serde_json::from_str(&document).expect("the schema is JSON"); + for (pointer, expected) in [ + ( + "/$defs/RawPoolBounds/properties/waitTimeoutMilliseconds/default", + Value::from(30_000_u64), + ), + ( + "/$defs/RawPoolBounds/properties/createTimeoutMilliseconds/default", + Value::from(30_000_u64), + ), + ( + "/$defs/RawPoolBounds/properties/recycleTimeoutMilliseconds/default", + Value::from(30_000_u64), + ), + ( + "/$defs/RawOidcVerifierConfig/properties/jwksCache/default/requestTimeoutMilliseconds", + Value::from(5_000_u64), + ), + ( + "/$defs/RawJwksCacheConfig/properties/cacheTtlSeconds/default", + Value::from(600_u64), + ), + ( + "/$defs/RawJwksCacheConfig/properties/negativeCacheTtlSeconds/default", + Value::from(60_u64), + ), + ( + "/$defs/RawJwksCacheConfig/properties/refreshCooldownSeconds/default", + Value::from(30_u64), + ), + ( + "/$defs/RawJwksCacheConfig/properties/maxDocumentBytes/default", + Value::from(65_536_u64), + ), + ( + "/$defs/RawJwksCacheConfig/properties/requestTimeoutMilliseconds/default", + Value::from(5_000_u64), + ), + ( + "/$defs/RawJwksCacheConfig/properties/outageToleranceSeconds/default", + Value::from(900_u64), + ), + ( + "/$defs/RawCursorConfig/properties/maxAgeSeconds/default", + Value::from(300_u64), + ), + ( + "/properties/eventDelivery/default/payloadRetentionDays", + Value::from(7_u64), + ), + ( + "/properties/operationalTimeouts/default/httpRequestMilliseconds", + Value::from(10_000_u64), + ), + ( + "/$defs/RawOperationalTimeouts/properties/httpRequestMilliseconds/default", + Value::from(10_000_u64), + ), + ( + "/$defs/RawOperationalTimeouts/properties/shutdownGraceMilliseconds/default", + Value::from(30_000_u64), + ), + ( + "/$defs/RawOperationalTimeouts/properties/recordLockMilliseconds/default", + Value::from(5_000_u64), + ), + ( + "/$defs/RawOperationalTimeouts/properties/migrationLockMilliseconds/default", + Value::from(30_000_u64), + ), + ( + "/$defs/RawOperationalTimeouts/properties/migrationStatementMilliseconds/default", + Value::from(60_000_u64), + ), + ] { + assert_eq!(value.pointer(pointer), Some(&expected), "{pointer}"); + } + for pointer in [ + "/properties/eventDestinations/default", + "/$defs/RawAuthorityClaimsConfig/properties/purpose/default", + "/$defs/RawDatabaseConfig/properties/password/default", + "/$defs/RawDatabaseConfig/properties/plaintext/default", + "/$defs/RawDatabaseConfig/properties/url/default", + "/$defs/RawEventDestinationConfig/properties/tls/default", + "/$defs/RawEventDestinationTlsConfig/properties/caBundleRef/default", + "/$defs/RawEventDestinationTlsConfig/properties/clientIdentityRef/default", + "/$defs/RawOidcVerifierConfig/properties/allowedClients/default", + "/$defs/RawOidcVerifierConfig/properties/deniedKids/default", + "/$defs/RawOidcVerifierConfig/properties/jwksSource/default", + ] { + assert_eq!(value.pointer(pointer), None, "{pointer}"); + } + } + + #[cfg(feature = "runtime")] + #[test] + fn runtime_schema_accepts_defaulted_runtime_and_rejects_inline_database_material() { + let schema = compile(&runtime_schema_document()); + assert!( + schema.is_valid(&runtime_instance()), + "minimal runtime with safe defaults validates" + ); + + for (field, value) in [ + ( + "url", + Value::String("postgres://inline.example/db".to_owned()), + ), + ("password", Value::String("inline-password".to_owned())), + ("plaintext", Value::Bool(true)), + ] { + let mut instance = runtime_instance(); + instance["database"][field] = value; + assert!(!schema.is_valid(&instance), "{field}"); + } + + let mut wrong_kind = runtime_instance(); + wrong_kind["kind"] = Value::String("RegistryProject".to_owned()); + assert!(!schema.is_valid(&wrong_kind)); + + let value: Value = + serde_json::from_str(&runtime_schema_document()).expect("the schema is JSON"); + assert!(value.pointer("/$defs/RawEventDestinationConfig").is_some()); + assert!(value + .pointer("/$defs/RawEventDestinationConfigSchema") + .is_none()); + } + + #[cfg(feature = "runtime")] + #[test] + fn runtime_schema_rejects_representative_parser_refused_values() { + let schema = compile(&runtime_schema_document()); + assert_schema_rejects_parser_refused_runtime( + &schema, + "payload retention above runtime maximum", + |instance| { + instance["eventDelivery"] = serde_json::json!({"payloadRetentionDays": 31}); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "package active sequence must be positive", + |instance| { + instance["package"]["activeSequence"] = Value::from(0_u64); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "pool size above runtime maximum", + |instance| { + instance["database"]["pool"]["maxSize"] = Value::from(129_u64); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "pool wait timeout above runtime maximum", + |instance| { + instance["database"]["pool"]["waitTimeoutMilliseconds"] = Value::from(60_001_u64); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "SQL role identifier grammar", + |instance| { + instance["database"]["roles"]["runtime"] = Value::String("RegistryRuntime".into()); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "database secret reference grammar", + |instance| { + instance["database"]["runtimeUrlRef"] = + Value::String("secret:env/not_uppercase".into()); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "OIDC maximum token lifetime", + |instance| { + instance["authentication"]["oidc"]["maxTokenLifetimeSeconds"] = + Value::from(3_601_u64); + }, + ); + assert_schema_rejects_parser_refused_runtime(&schema, "OIDC leeway", |instance| { + instance["authentication"]["oidc"]["leewayMilliseconds"] = Value::from(300_001_u64); + }); + assert_schema_rejects_parser_refused_runtime( + &schema, + "OIDC scope claim grammar", + |instance| { + instance["authentication"]["oidc"]["scopeClaim"] = + Value::String("bad claim".into()); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "OIDC scope separator grammar", + |instance| { + instance["authentication"]["oidc"]["scopeSeparator"] = Value::String("a".into()); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "OIDC client list uniqueness", + |instance| { + instance["authentication"]["oidc"]["allowedClients"] = + serde_json::json!(["client-a", "client-a"]); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "authority claim excludes registered JWT claims", + |instance| { + instance["authentication"]["authorityClaims"]["principal"] = + Value::String("iss".into()); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "JWKS cache document size", + |instance| { + instance["authentication"]["oidc"]["jwksCache"] = + serde_json::json!({"maxDocumentBytes": 1_048_577}); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "JWKS negative cache lower bound", + |instance| { + instance["authentication"]["oidc"]["jwksCache"] = + serde_json::json!({"negativeCacheTtlSeconds": 0}); + }, + ); + assert_schema_rejects_parser_refused_runtime(&schema, "cursor maximum age", |instance| { + instance["cursor"]["maxAgeSeconds"] = Value::from(86_401_u64); + }); + assert_schema_rejects_parser_refused_runtime( + &schema, + "operational timeout upper bound", + |instance| { + instance["operationalTimeouts"] = + serde_json::json!({"migrationStatementMilliseconds": 3_600_001}); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "event destination identifier grammar", + |instance| { + instance["eventDestinations"] = + serde_json::json!({"AssetOps": valid_event_destination()}); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "event destination signing secret reference grammar", + |instance| { + let mut destination = valid_event_destination(); + destination["hmacSha256KeyRef"] = Value::String("secret:file/".into()); + instance["eventDestinations"] = serde_json::json!({"asset_ops": destination}); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "event destination attempt timeout lower bound", + |instance| { + let mut destination = valid_event_destination(); + destination["deliveryCeilings"]["attemptTimeoutMilliseconds"] = Value::from(99_u64); + instance["eventDestinations"] = serde_json::json!({"asset_ops": destination}); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "event destination maximum attempts upper bound", + |instance| { + let mut destination = valid_event_destination(); + destination["deliveryCeilings"]["maximumAttempts"] = Value::from(6_u64); + instance["eventDestinations"] = serde_json::json!({"asset_ops": destination}); + }, + ); + assert_schema_rejects_parser_refused_runtime( + &schema, + "event destination TLS requires at least one string ref", + |instance| { + let mut destination = valid_event_destination(); + destination["tls"] = serde_json::json!({}); + instance["eventDestinations"] = serde_json::json!({"asset_ops": destination}); + }, + ); + } + + #[cfg(feature = "runtime")] + #[test] + fn runtime_schema_reproduces_byte_for_byte() { + assert_eq!( + runtime_documents().expect("the Registry Server runtime schema generates"), + runtime_documents().expect("the Registry Server runtime schema generates again"), + ); + } + + #[cfg(feature = "runtime")] + #[test] + fn runtime_schema_is_pretty_json_with_one_trailing_newline() { + let document = runtime_schema_document(); + assert!(document.ends_with('\n') && !document.ends_with("\n\n")); + let value: Value = serde_json::from_str(&document).expect("the schema is JSON"); + let mut rendered = + serde_json::to_string_pretty(&value).expect("a parsed schema renders again"); + rendered.push('\n'); + assert_eq!(document, rendered); + } + + #[cfg(feature = "runtime")] + #[test] + fn committed_runtime_schema_matches_generated_bytes() { + let committed = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/registry-server/generated/runtime") + .join(RUNTIME_CONFIG_SCHEMA_FILE); + assert_eq!( + fs::read_to_string(committed).expect("the committed runtime schema exists"), + runtime_schema_document(), + ); + } } diff --git a/crates/registry-server/src/startup.rs b/crates/registry-server/src/startup.rs index ecd3ca25d7..fa18684877 100644 --- a/crates/registry-server/src/startup.rs +++ b/crates/registry-server/src/startup.rs @@ -13,7 +13,6 @@ use axum::middleware::Next; use axum::response::Response; use axum::{middleware, Router}; use registry_platform_audit::AuditProfile; -use registry_platform_httpsec::Problem; use registry_platform_oidc::JwksFetcher; use thiserror::Error; use tokio::net::TcpListener; @@ -745,27 +744,31 @@ fn with_request_timeout(app: Router, timeout: Duration) -> Router { async fn request_timeout( axum::extract::State(timeout): axum::extract::State, - request: Request, + mut request: Request, next: Next, ) -> Response { - match tokio::time::timeout(timeout, next.run(request)).await { + let method = crate::correlation::method_name(request.method()); + let started = std::time::Instant::now(); + let (correlation, owns_boundary) = crate::correlation::begin_request(&mut request); + let response = match tokio::time::timeout(timeout, next.run(request)).await { Ok(response) => response, Err(_) => timeout_problem(), + }; + if owns_boundary { + crate::correlation::finish_response(response, &correlation, method, started) + } else { + response } } fn timeout_problem() -> Response { - Problem::new( + crate::correlation::problem_response( + StatusCode::GATEWAY_TIMEOUT, "urn:registry-server:problem:request.timeout", "Gateway Timeout", - StatusCode::GATEWAY_TIMEOUT, - ) - .detail("The request timed out.") - .with_extra( - "code", - serde_json::Value::String("request.timeout".to_owned()), + "The request timed out.", + "request.timeout", ) - .into_response() } /// Bind and serve a previously prepared server. Binding consumes the diff --git a/crates/registry-server/tests/compiler_contract.rs b/crates/registry-server/tests/compiler_contract.rs index d2a20e8ea6..c185ccaa6d 100644 --- a/crates/registry-server/tests/compiler_contract.rs +++ b/crates/registry-server/tests/compiler_contract.rs @@ -13,9 +13,10 @@ use registry_server::compiler::{ }; use registry_server::contract::{ parse_module_json, parse_module_yaml, parse_project_json, parse_project_yaml, - AccessProfileSource, BoundaryOperator, Classification, ComparisonOperator, ConstraintSource, - FieldTypeSource, ModuleAssetSource, Operation, PackageIdentitySource, ReferenceDelete, - RegistryModule, RowBoundarySource, UniqueWhenPredicate, + AccessGrantSource, BoundaryOperator, Classification, ComparisonOperator, ConstraintSource, + FieldTypeSource, ModuleAssetSource, Operation, PackageIdentitySource, + ProjectAccessProfileSource, ReferenceDelete, RegistryModule, RowBoundarySource, + UniqueWhenPredicate, }; use registry_server::diagnostics::CompileFailure; use registry_server::generated_ddl::DdlStatementKind; @@ -92,9 +93,23 @@ fn derived_fields_selectors_and_read_paths_compile_to_route_specific_inventories "selectorProfiles":[ {"id":"by-local-reference","fields":["administrative-area","local-household-number"]} ], - "readPaths":[{"id":"people","through":"group-membership","to":"person","route":"people"}], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"sub","operations":["get","lookup","list"], + "readPaths":[{"id":"people","through":"group-membership","to":"person","route":"people"}] + },{ + "id":"person","route":"people","mutationMode":"mutable", + "fields":[ + {"id":"legal-name","type":"string","maxLength":80,"classification":"internal"}, + {"id":"date-of-birth","type":"date","classification":"internal"} + ] + },{ + "id":"group-membership","route":"memberships","mutationMode":"mutable", + "fields":[ + {"id":"household","type":"reference","target":"household","classification":"internal"}, + {"id":"person","type":"reference","target":"person","classification":"internal"} + ] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"sub","grants":[{ + "entity":"household","operations":["get","lookup","list"], "readableFields":["household-code","child-count","single-headed"], "filterableFields":["child-count","single-headed"], "sortableFields":["child-count"], @@ -108,18 +123,6 @@ fn derived_fields_selectors_and_read_paths_compile_to_route_specific_inventories "allowCount":true }] }] - },{ - "id":"person","route":"people","mutationMode":"mutable", - "fields":[ - {"id":"legal-name","type":"string","maxLength":80,"classification":"internal"}, - {"id":"date-of-birth","type":"date","classification":"internal"} - ] - },{ - "id":"group-membership","route":"memberships","mutationMode":"mutable", - "fields":[ - {"id":"household","type":"reference","target":"household","classification":"internal"}, - {"id":"person","type":"reference","target":"person","classification":"internal"} - ] }] }"#; let sql = "SELECT h.id AS id, count(p.id)::bigint AS child_count, false AS single_headed, 1::bigint AS registry_derived_key_cardinality FROM registry_source.household h LEFT JOIN registry_source.group_membership gm ON gm.household = h.id LEFT JOIN registry_source.person p ON p.id = gm.person GROUP BY h.id"; @@ -234,9 +237,11 @@ fn canonical_id_row_boundary_targets_the_physical_record_id_column() { "id":"household","route":"households","mutationMode":"mutable", "fields":[ {"id":"household-code","type":"string","maxLength":32,"classification":"internal"} - ], - "accessProfiles":[{ - "id":"viewer","principalClaim":"sub","operations":["get"], + ] + }], + "accessProfiles":[{ + "id":"viewer","principalClaim":"sub","grants":[{ + "entity":"household","operations":["get"], "readableFields":["household-code"], "rowBoundaries":[{"field":"id","claim":"household_id","operator":"equals"}] }] @@ -343,9 +348,9 @@ fn anonymous_access_cannot_process_selector_path_or_derived_private_fields() { "fields":[{{"id":"public-code","type":"string","maxLength":32,"classification":"public"}}, {{"id":"private-code","type":"string","maxLength":32,"classification":"restricted"}}], "derived":[{{"id":"flags","sql":"sql/flags.sql","key":"id","fields":[{{"id":"risk-flag","type":"boolean","classification":"public"}}]}}], - "selectorProfiles":[{{"id":"by-private-code","fields":["private-code"]}}], - "accessProfiles":[{{"id":"anon","anonymous":true,"operations":["lookup"],{extra}}}] - }}] + "selectorProfiles":[{{"id":"by-private-code","fields":["private-code"]}}] + }}], + "accessProfiles":[{{"id":"anon","anonymous":true,"grants":[{{"entity":"household","operations":["lookup"],{extra}}}]}}] }}"# ) }; @@ -418,9 +423,11 @@ fn batch_route_requires_explicit_bounds_and_compiles_bounded_openapi() { "registry":{{"id":"batch-contract","version":"1","defaultLanguage":"en"}}, "entities":[{{ "id":"record","route":"records","mutationMode":"mutable"{batch}, - "fields":[{{"id":"label","type":"string","maxLength":32,"required":true,"classification":"internal"}}], - "accessProfiles":[{{ - "id":"writer","principalClaim":"principal","operations":{operations}, + "fields":[{{"id":"label","type":"string","maxLength":32,"required":true,"classification":"internal"}}] + }}], + "accessProfiles":[{{ + "id":"writer","principalClaim":"principal","grants":[{{ + "entity":"record","operations":{operations}, "readableFields":["label"],"writableFields":["label"] }}] }}] @@ -586,18 +593,26 @@ fn production_refuses_incomplete_authoring_closure() { } #[test] -fn production_requires_explicit_manifest_projection() { - let failure = compile_project( +fn production_allows_missing_manifest_projection_and_emits_no_manifest_artifacts() { + let compiled = compile_project( &parse_project_json( br#"{ "apiVersion":"registry.registrystack.org/v1alpha1", "kind":"RegistryProject", "registry":{"id":"neutral","version":"1","defaultLanguage":"en"}, - "package":{"environment":"local","instanceId":"local_instance","sequence":1,"sourceRevision":"source"}, + "package":{"environment":"local","instanceId":"local-instance","sequence":1,"sourceRevision":"source"}, "entities":[{ "id":"record","route":"records","mutationMode":"create_only", - "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}], - "accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}] + "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}] + }], + "accessProfiles":[{ + "id":"reader", + "principalClaim":"principal", + "grants":[{ + "entity":"record", + "operations":["get"], + "readableFields":["code"] + }] }] }"#, ) @@ -605,14 +620,313 @@ fn production_requires_explicit_manifest_projection() { &[], CompileProfile::Production, ) - .expect_err("production requires a manifest projection"); + .expect("production compilation does not require a manifest projection"); - assert!(failure.diagnostics().iter().any(|diagnostic| { - diagnostic.code == "manifest_projection.required" - && diagnostic.path == "project.manifestProjection" + assert!(compiled.manifest_projection().is_none()); + assert!(compiled + .artifacts() + .entries() + .keys() + .all(|path| !path.starts_with("generated/manifest/"))); + assert!(compiled.findings().is_empty()); +} + +#[test] +fn project_access_profiles_use_the_entity_access_vocabulary() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"profile-vocabulary","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"case-file","route":"case-files","mutationMode":"mutable", + "fields":[ + {"id":"case-code","type":"string","maxLength":32,"classification":"internal"}, + {"id":"status","type":"string","maxLength":32,"classification":"internal"} + ] + }], + "accessProfiles":[{ + "id":"operator", + "default":true, + "principalClaim":"sub", + "requiredScopes":["records.read"], + "requiredPurposes":["case-management"], + "grants":[{ + "entity":"case-file", + "operations":["get","list"], + "readableFields":["case-code","status"], + "filterableFields":["status"], + "allowCount":true + }] + }] + }"#, + ) + .expect("canonical top-level profile source parses"); + + let compiled = compile_project(&project, &[], CompileProfile::Authoring) + .expect("canonical top-level profile source compiles"); + let profile = compiled + .entities() + .get("case-file") + .and_then(|entity| entity.access_profiles.get("operator")) + .expect("top-level profile is expanded onto its granted entity"); + + assert_eq!(profile.principal_claim.as_deref(), Some("sub")); + assert_eq!( + profile.required_scopes, + ["records.read".to_owned()].into_iter().collect() + ); + assert_eq!( + profile.required_purposes, + ["case-management".to_owned()].into_iter().collect() + ); + assert_eq!( + profile.operations, + [Operation::Get, Operation::List].into_iter().collect() + ); + assert!(profile.allow_count); + assert!(compiled.access().entries.iter().any(|entry| { + entry.entity_id == "case-file" + && entry.operation == Operation::List + && entry.profile_ids == ["operator".to_owned()].into_iter().collect() + && entry.default_profile_id == "operator" })); } +#[test] +fn root_project_entity_access_profiles_are_compile_time_errors() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"profile-vocabulary","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"case-file","route":"case-files","mutationMode":"mutable", + "fields":[{"id":"case-code","type":"string","maxLength":32,"classification":"internal"}], + "accessProfiles":[{ + "id":"entity-local-reader", + "principalClaim":"sub", + "operations":["get"], + "readableFields":["case-code"] + }] + }] + }"#, + ) + .expect("the shared parser still accepts the internal/module profile field"); + + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("root project entity-local profiles match the public schema refusal"); + let diagnostic = failure + .diagnostics() + .iter() + .find(|diagnostic| diagnostic.code == "access_profile.project_entity_local.forbidden") + .expect("root entity-local profile refusal is reported"); + assert_eq!(diagnostic.path, "project.entities[].accessProfiles"); +} + +#[test] +fn module_entity_access_profiles_remain_supported_for_module_composition() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"module-profile","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"case-file","route":"case-files","mutationMode":"mutable", + "fields":[{"id":"case-code","type":"string","maxLength":32,"classification":"internal"}] + }], + "modules":[{"id":"core","version":"1"}] + }"#, + ) + .expect("project parses"); + let module = parse_module_json( + br#"{ + "id":"core", + "version":"1", + "entities":[{ + "id":"module-record","route":"module-records","mutationMode":"mutable", + "fields":[{"id":"case-code","type":"string","maxLength":32,"classification":"internal"}], + "accessProfiles":[{ + "id":"module-reader", + "principalClaim":"sub", + "operations":["get"], + "readableFields":["case-code"] + }] + }], + "extendEntities":[{ + "entity":"case-file", + "accessProfiles":[{ + "id":"extension-reader", + "principalClaim":"sub", + "operations":["get"], + "readableFields":["case-code"] + }] + }] + }"#, + ) + .expect("module parses"); + + let compiled = compile_project(&project, &[module], CompileProfile::Authoring) + .expect("module-local access profiles remain module composition input"); + assert!(compiled.entities()["module-record"] + .access_profiles + .contains_key("module-reader")); + assert!(compiled.entities()["case-file"] + .access_profiles + .contains_key("extension-reader")); +} + +#[test] +fn anonymous_project_access_profiles_expand_without_authenticated_claims() { + let project = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"anonymous-profile","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"public-record","route":"public-records","mutationMode":"mutable","classification":"public", + "fields":[ + {"id":"code","type":"string","maxLength":32,"classification":"public"}, + {"id":"name","type":"string","maxLength":80,"classification":"public"} + ] + }], + "accessProfiles":[{ + "id":"public-reader", + "default":true, + "anonymous":true, + "grants":[{ + "entity":"public-record", + "operations":["get","list"], + "readableFields":["code","name"], + "filterableFields":["code"] + }] + }] + }"#, + ) + .expect("anonymous project profile source parses"); + + let compiled = compile_project(&project, &[], CompileProfile::Authoring) + .expect("anonymous project profile compiles"); + let profile = compiled + .entities() + .get("public-record") + .and_then(|entity| entity.access_profiles.get("public-reader")) + .expect("anonymous top-level profile is expanded onto its granted entity"); + + assert!(profile.anonymous); + assert_eq!(profile.principal_claim, None); + assert!(profile.required_scopes.is_empty()); + assert!(profile.required_purposes.is_empty()); + assert_eq!( + profile.operations, + [Operation::Get, Operation::List].into_iter().collect() + ); +} + +#[test] +fn anonymous_project_access_profiles_cannot_require_authenticated_claims() { + let source = |extra: &str| { + format!( + r#"{{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{{"id":"anonymous-profile","version":"1","defaultLanguage":"en"}}, + "entities":[{{ + "id":"public-record","route":"public-records","mutationMode":"mutable","classification":"public", + "fields":[{{"id":"code","type":"string","maxLength":32,"classification":"public"}}] + }}], + "accessProfiles":[{{ + "id":"public-reader", + "anonymous":true, + {extra} + "grants":[{{"entity":"public-record","operations":["get"],"readableFields":["code"]}}] + }}] + }}"# + ) + }; + + for (source, code, path) in [ + ( + source(r#""principalClaim":"sub","#), + "access_profile.principal_claim.forbidden", + "project.accessProfiles[].principalClaim", + ), + ( + source(r#""requiredScopes":["records.read"],"#), + "access_profile.anonymous.claim_requirements_forbidden", + "project.accessProfiles[]", + ), + ( + source(r#""requiredPurposes":["case-management"],"#), + "access_profile.anonymous.claim_requirements_forbidden", + "project.accessProfiles[]", + ), + ] { + let project = parse_project_json(source.as_bytes()).expect("project source parses"); + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("anonymous profiles cannot require authenticated claims"); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == code && diagnostic.path == path)); + } +} + +#[test] +fn project_access_profiles_reject_the_legacy_purpose_vocabulary() { + let failure = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"profile-vocabulary","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"case-file","route":"case-files","mutationMode":"mutable", + "fields":[{"id":"case-code","type":"string","maxLength":32,"classification":"internal"}] + }], + "accessProfiles":[{ + "id":"operator", + "principalClaim":"sub", + "purposes":["case-management"], + "grants":[{"entity":"case-file","operations":["get"],"readableFields":["case-code"]}] + }] + }"#, + ) + .expect_err("legacy purposes key is no longer part of the authoring contract"); + + let diagnostic = &failure.diagnostics()[0]; + assert_eq!(diagnostic.code, "source.shape.invalid"); + assert_eq!(diagnostic.path, "project.accessProfiles[0].purposes"); +} + +#[test] +fn project_access_grants_reject_the_legacy_action_vocabulary() { + let failure = parse_project_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"profile-vocabulary","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"case-file","route":"case-files","mutationMode":"mutable", + "fields":[{"id":"case-code","type":"string","maxLength":32,"classification":"internal"}] + }], + "accessProfiles":[{ + "id":"operator", + "principalClaim":"sub", + "requiredPurposes":["case-management"], + "grants":[{"entity":"case-file","actions":["get"],"readableFields":["case-code"]}] + }] + }"#, + ) + .expect_err("legacy actions key is no longer part of the authoring contract"); + + let diagnostic = &failure.diagnostics()[0]; + assert_eq!(diagnostic.code, "source.shape.invalid"); + assert_eq!( + diagnostic.path, + "project.accessProfiles[0].grants[0].actions" + ); +} + #[test] fn manifest_projection_unknown_nested_keys_are_rejected_without_values() { let failure = parse_project_json( @@ -812,20 +1126,24 @@ fn manifest_projection_filters_by_selected_profile_and_classification_ceiling() }, "entities":[ {"id":"visible-target","route":"visible-targets","mutationMode":"create_only","classification":"public", - "fields":[{"id":"label","type":"string","maxLength":64,"classification":"public"}], - "accessProfiles":[{"id":"operator","principalClaim":"principal","operations":["get"],"readableFields":["label"]}]}, + "fields":[{"id":"label","type":"string","maxLength":64,"classification":"public"}]}, {"id":"hidden-target","route":"hidden-targets","mutationMode":"create_only","classification":"restricted", - "fields":[{"id":"label","type":"string","maxLength":64,"classification":"restricted"}], - "accessProfiles":[{"id":"operator","principalClaim":"principal","operations":["get"],"readableFields":["label"]}]}, + "fields":[{"id":"label","type":"string","maxLength":64,"classification":"restricted"}]}, {"id":"link","route":"links","mutationMode":"create_only","classification":"public", "fields":[ {"id":"name","type":"string","maxLength":64,"classification":"public"}, {"id":"operator-note","type":"string","maxLength":64,"classification":"internal"}, {"id":"visible-ref","type":"reference","target":"visible-target","classification":"public"}, {"id":"hidden-ref","type":"reference","target":"hidden-target","classification":"public"} - ], - "accessProfiles":[{"id":"operator","principalClaim":"principal","operations":["get"],"readableFields":["name","operator-note","visible-ref","hidden-ref"]}]} - ] + ]} + ], + "accessProfiles":[{ + "id":"operator","principalClaim":"principal","grants":[ + {"entity":"visible-target","operations":["get"],"readableFields":["label"]}, + {"entity":"hidden-target","operations":["get"],"readableFields":["label"]}, + {"entity":"link","operations":["get"],"readableFields":["name","operator-note","visible-ref","hidden-ref"]} + ] + }] }"#, ) .expect("project parses"); @@ -887,11 +1205,13 @@ fn manifest_projection_metadata_cannot_describe_hidden_entities_or_fields() { {"id":"name","type":"string","maxLength":64,"classification":"public"}, {"id":"secret-note","type":"string","maxLength":64,"classification":"restricted"}, {"id":"profile","type":"structured","maxBytes":1024,"schema":{"type":"object","additionalProperties":false},"classification":"public"} - ], - "accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["name","profile"]}]}, + ]}, {"id":"secret-record","route":"secret-records","mutationMode":"create_only","classification":"restricted", - "fields":[{"id":"name","type":"string","maxLength":64,"classification":"restricted"}], - "accessProfiles":[{"id":"other-reader","principalClaim":"principal","operations":["get"],"readableFields":["name"]}]} + "fields":[{"id":"name","type":"string","maxLength":64,"classification":"restricted"}]} + ], + "accessProfiles":[ + {"id":"reader","principalClaim":"principal","grants":[{"entity":"record","operations":["get"],"readableFields":["name","profile"]}]}, + {"id":"other-reader","principalClaim":"principal","grants":[{"entity":"secret-record","operations":["get"],"readableFields":["name"]}]} ] }"#, ) @@ -947,9 +1267,11 @@ fn independent_additive_modules_are_order_independent() { ], "entities":[{ "id":"object","route":"objects","mutationMode":"mutable", - "fields":[{"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal","operations":["create","get","list","patch"], + "fields":[{"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal","grants":[{ + "entity":"object","operations":["create","get","list","patch"], "readableFields":["code"],"writableFields":["code"] }] }] @@ -1002,7 +1324,7 @@ fn project_access_profile_required_scopes_compile_into_each_grant() { "id":"operator","principalClaim":"registry_principal", "requiredScopes":["registry:record:operate"], "grants":[{ - "entity":"record","actions":["get"],"readableFields":["code"] + "entity":"record","operations":["get"],"readableFields":["code"] }] }] }"#, @@ -1140,9 +1462,11 @@ fn generic_decimal_crs84_point_and_structured_fields_compile_to_deterministic_dd "properties":{"batch":{"type":"string","maxLength":32}}, "required":["batch"] }} - ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","operations":["create","get","list","patch"], + ] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "entity":"reading","operations":["create","get","list","patch"], "readableFields":["amount","location","payload"], "writableFields":["amount","location","payload"] }] @@ -1353,9 +1677,9 @@ fn generic_scalar_option_and_schema_negatives_fail_before_ddl_generation() { "registry":{{"id":"generic-scalars","version":"1","defaultLanguage":"en"}}, "entities":[{{ "id":"reading","route":"readings","mutationMode":"mutable", - "fields":[{field}], - "accessProfiles":[{{"id":"operator","default":true,"principalClaim":"principal","operations":["get"],"readableFields":["{}"]}}] - }}] + "fields":[{field}] + }}], + "accessProfiles":[{{"id":"operator","default":true,"principalClaim":"principal","grants":[{{"entity":"reading","operations":["get"],"readableFields":["{}"]}}]}}] }}"#, if field.contains("\"amount\"") { "amount" @@ -1393,9 +1717,11 @@ fn crs84_point_and_structured_fields_cannot_be_row_boundaries_until_equality_is_ "registry":{{"id":"generic-scalars","version":"1","defaultLanguage":"en"}}, "entities":[{{ "id":"reading","route":"readings","mutationMode":"mutable", - "fields":[{field}], - "accessProfiles":[{{ - "id":"operator","default":true,"principalClaim":"principal","operations":["get"], + "fields":[{field}] + }}], + "accessProfiles":[{{ + "id":"operator","default":true,"principalClaim":"principal","grants":[{{ + "entity":"reading","operations":["get"], "readableFields":["{field_id}"], "rowBoundaries":[{{"field":"{field_id}","claim":"claim","operator":"equals"}}] }}] @@ -1974,10 +2300,11 @@ fn anonymous_profiles_cannot_inherit_partial_unique_processing_over_non_public_f "constraints":[{ "kind":"unique","fields":["code"], "when":[{"kind":"field_is_not_null","field":"protected-marker"}] - }], - "accessProfiles":[{ - "id":"public-reader","anonymous":true,"default":true, - "operations":["get"],"readableFields":["code"] + }] + }], + "accessProfiles":[{ + "id":"public-reader","anonymous":true,"default":true,"grants":[{ + "entity":"entry","operations":["get"],"readableFields":["code"] }] }] }"#; @@ -2019,10 +2346,11 @@ fn anonymous_public_surface_rejects_every_non_public_constraint_field() { {"kind":"int_range","field":"range-field","minimum":0,"maximum":10}, {"kind":"vocabulary","field":"vocabulary-field","values":["active"]}, {"kind":"temporal-non-overlap","scopeFields":["temporal-scope"],"startField":"temporal-start","endField":"temporal-end"} - ], - "accessProfiles":[{ - "id":"public-reader","anonymous":true,"default":true, - "operations":["get"],"readableFields":["label"] + ] + }], + "accessProfiles":[{ + "id":"public-reader","anonymous":true,"default":true,"grants":[{ + "entity":"record","operations":["get"],"readableFields":["label"] }] }], "vocabularies":[{"id":"status","values":["active","inactive"]}] @@ -2074,7 +2402,7 @@ fn anonymous_public_surface_rejects_every_non_public_constraint_field() { } let mut authenticated = base; - let profile = &mut authenticated.entities[0].access_profiles[0]; + let profile = &mut authenticated.access_profiles[0]; profile.anonymous = false; profile.principal_claim = Some("principal".to_owned()); for (_, field_id) in cases { @@ -2104,10 +2432,11 @@ fn compiled_partial_unique_constraint_keeps_closed_predicates_in_the_model() { "constraints":[{ "kind":"unique","fields":["code"], "when":[{"kind":"active_lifecycle"},{"kind":"field_equals","field":"status","value":"active"}] - }], - "accessProfiles":[{ - "id":"public-reader","anonymous":true,"default":true, - "operations":["get"],"readableFields":["code","status"],"filterableFields":["status"] + }] + }], + "accessProfiles":[{ + "id":"public-reader","anonymous":true,"default":true,"grants":[{ + "entity":"entry","operations":["get"],"readableFields":["code","status"],"filterableFields":["status"] }] }], "vocabularies":[{"id":"status","values":["active","closed"]}] @@ -2149,7 +2478,7 @@ fn create_only_operation_conflict_fails_before_artifact_generation() { .iter_mut() .find(|grant| grant.entity == "inspection-event") .expect("fixture grants the create-only entity"); - grant.actions.insert(Operation::Patch); + grant.operations.insert(Operation::Patch); let failure = compile_project(&project, &[], CompileProfile::Authoring) .expect_err("create-only patch is refused"); @@ -2179,6 +2508,130 @@ fn generated_openapi_routes_and_physical_names_share_one_compiled_inventory() { .map(|entry| entry.as_object().expect("path item is an object").len()) .sum(); assert_eq!(generated_operation_count, compiled.routes().routes.len()); + assert_eq!( + value["components"]["securitySchemes"]["bearerAuth"], + json!({"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}) + ); + assert_eq!( + value["components"]["schemas"]["Problem"]["properties"]["code"]["enum"], + json!([ + "authentication.refused", + "idempotency.conflict", + "lookup.unresolved", + "mutation.conflict", + "precondition.failed", + "precondition.required", + "query.cursor_invalid", + "query.invalid", + "request.invalid", + "request.timeout", + "resource.not_found", + "service.unavailable", + "source.unavailable", + "unsupported.media_type" + ]) + ); + assert_eq!( + value["components"]["schemas"]["Problem"]["required"], + json!(["type", "title", "status", "detail", "code", "traceId"]) + ); + assert_eq!( + value["components"]["schemas"]["Problem"]["properties"]["traceId"]["pattern"], + "^[0-9a-f]{32}$" + ); + + let list = &value["paths"]["/v1/records/assets"]["get"]; + assert_eq!(list["security"], json!([{"bearerAuth": []}])); + assert!(list["responses"]["200"]["headers"] + .get("traceparent") + .is_some()); + assert!( + list["responses"]["200"]["content"]["application/json"]["schema"]["required"] + .as_array() + .expect("list response required members") + .contains(&json!("pageInfo")) + ); + assert!( + list["responses"]["200"]["content"]["application/json"]["schema"]["properties"] + .get("count") + .is_some() + ); + assert!( + list["responses"]["200"]["content"]["application/json"]["schema"]["properties"] + .get("totalCount") + .is_none() + ); + assert_eq!( + list["x-registry-queryProfiles"]["asset-operator"]["selectableProperties"], + json!(["assetClass", "assetCode", "label"]) + ); + assert_eq!( + list["x-registry-queryProfiles"]["site-planner"]["selectableProperties"], + json!(["assetCode", "label"]) + ); + assert!(list["x-registry-queryProfiles"]["asset-operator"]["filterableProperties"].is_array()); + + let detail = &value["paths"]["/v1/records/assets/{record_id}"]["get"]; + assert_eq!( + query_parameter_names(&detail["parameters"]), + ["$select", "accessProfile", "record_id", "traceparent"] + ); + assert!(detail["responses"]["200"]["headers"].get("ETag").is_some()); + assert!(detail["responses"]["200"]["headers"] + .get("traceparent") + .is_some()); + assert!(detail["responses"]["504"]["headers"] + .get("traceparent") + .is_some()); + assert_eq!( + detail["responses"]["504"]["content"]["application/problem+json"]["examples"] + ["request.timeout"]["value"]["traceId"], + "11111111111111111111111111111111" + ); + assert_eq!( + detail["responses"]["200"]["content"]["application/json"]["schema"]["properties"]["data"], + json!({"$ref": "#/components/schemas/asset-item"}) + ); + + let create = &value["paths"]["/v1/records/assets"]["post"]; + assert_eq!( + query_parameter_names(&create["parameters"]), + ["Idempotency-Key", "accessProfile", "traceparent"] + ); + assert!(create["responses"]["201"]["headers"].get("ETag").is_some()); + assert!(create["responses"]["201"]["headers"] + .get("Location") + .is_some()); + assert!(create["responses"]["201"]["headers"] + .get("traceparent") + .is_some()); + assert_eq!( + create["requestBody"]["content"]["application/json"]["schema"]["properties"]["data"], + json!({"$ref": "#/components/schemas/asset-item-create-input"}) + ); + + let patch = &value["paths"]["/v1/records/assets/{record_id}"]["patch"]; + assert_eq!( + query_parameter_names(&patch["parameters"]), + [ + "Idempotency-Key", + "If-Match", + "accessProfile", + "record_id", + "traceparent" + ] + ); + assert!(patch["requestBody"]["content"] + .get("application/json-patch+json") + .is_some()); + assert!(patch["responses"]["428"]["headers"] + .get("traceparent") + .is_some()); + assert!( + patch["responses"]["428"]["content"]["application/problem+json"]["schema"] + .get("$ref") + .is_some() + ); for names in compiled.physical_names().entities.values() { let all = std::iter::once(&names.table) @@ -2195,6 +2648,90 @@ fn generated_openapi_routes_and_physical_names_share_one_compiled_inventory() { } } +#[test] +fn generated_openapi_separates_security_and_mutation_input_from_read_schema() { + let compiled = compile_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"business-contract","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"business-record","route":"business-records","mutationMode":"mutable","classification":"public", + "fields":[ + {"id":"code","type":"string","required":true,"maxLength":32,"classification":"public"}, + {"id":"business-note","apiName":"businessNote","type":"string","maxLength":80,"classification":"public"}, + {"id":"draft-note","apiName":"draftNote","type":"string","maxLength":80,"classification":"internal"} + ] + }], + "accessProfiles":[{ + "id":"public", + "default":true, + "anonymous":true, + "grants":[{ + "entity":"business-record", + "operations":["get","list"], + "readableFields":["code","business-note"] + }] + },{ + "id":"business", + "principalClaim":"registry_principal", + "requiredPurposes":["business"], + "grants":[{ + "entity":"business-record", + "operations":["create","get"], + "readableFields":["code","business-note"], + "writableFields":["code","draft-note"] + }] + }] + }"#, + ) + .expect("business contract compiles"); + let openapi = compiled + .artifacts() + .get("generated/openapi.json") + .expect("OpenAPI is generated"); + let openapi = parse_json_strict(&openapi.bytes).expect("OpenAPI is strict JSON"); + + assert_eq!( + openapi["paths"]["/v1/records/business-records/{record_id}"]["get"]["security"], + json!([{}, {"bearerAuth": []}]) + ); + assert_eq!( + openapi["paths"]["/v1/records/business-records"]["post"]["security"], + json!([{"bearerAuth": []}]) + ); + assert_eq!( + openapi["paths"]["/v1/records/business-records"]["post"]["requestBody"]["content"] + ["application/json"]["schema"]["properties"]["data"], + json!({"$ref": "#/components/schemas/business-record-create-input"}) + ); + assert_eq!( + openapi["paths"]["/v1/records/business-records"]["post"]["responses"]["201"]["content"] + ["application/json"]["schema"]["properties"]["data"], + json!({"$ref": "#/components/schemas/business-record"}) + ); + assert_eq!( + openapi["components"]["schemas"]["business-record-create-input"]["properties"], + json!({ + "code": {"type": "string", "minLength": 0, "maxLength": 32}, + "draftNote": {"type": "string", "minLength": 0, "maxLength": 80} + }) + ); + assert_eq!( + openapi["components"]["schemas"]["business-record-create-input"]["required"], + json!(["code"]) + ); + assert!( + openapi["components"]["schemas"]["business-record"]["properties"] + .get("businessNote") + .is_some() + ); + assert_ne!( + openapi["components"]["schemas"]["business-record-create-input"]["properties"], + openapi["components"]["schemas"]["business-record"]["properties"] + ); +} + #[test] fn entity_schema_uses_compiled_api_names_and_preserves_field_contracts() { let compiled = compile_json( @@ -2353,10 +2890,11 @@ fn compiler_produces_both_revision_routes_when_explicitly_configured() { "registry":{"id":"revision-surface","version":"1","defaultLanguage":"en"}, "entities":[{ "id":"entry","route":"entries","mutationMode":"create_only","classification":"internal", - "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}], - "accessProfiles":[{ - "id":"auditor","default":true,"principalClaim":"principal", - "operations":["revisions"],"revisionAccess":true,"readableFields":["code"] + "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}] + }], + "accessProfiles":[{ + "id":"auditor","default":true,"principalClaim":"principal","grants":[{ + "entity":"entry","operations":["revisions"],"revisionAccess":true,"readableFields":["code"] }] }] }"#, @@ -2405,16 +2943,26 @@ fn compiler_produces_both_revision_routes_when_explicitly_configured() { query_parameter_names( &openapi["paths"]["/v1/records/entries/{record_id}/revisions"]["get"]["parameters"] ), - ["accessProfile", "record_id"] + ["accessProfile", "record_id", "traceparent"] ); } #[test] fn compiler_omits_revision_routes_when_not_configured_or_revision_access_is_false() { - for (operations, revision_access, anonymous) in [ - (r#"["get"]"#, "true", "false"), - (r#"["revisions"]"#, "false", "false"), - (r#"["revisions"]"#, "true", "true"), + for (operations, revision_access, anonymous, principal_claim) in [ + ( + r#"["get"]"#, + "true", + "false", + r#""principalClaim":"principal","#, + ), + ( + r#"["revisions"]"#, + "false", + "false", + r#""principalClaim":"principal","#, + ), + (r#"["revisions"]"#, "true", "true", ""), ] { let source = format!( r#"{{ @@ -2423,10 +2971,11 @@ fn compiler_omits_revision_routes_when_not_configured_or_revision_access_is_fals "registry":{{"id":"revision-surface","version":"1","defaultLanguage":"en"}}, "entities":[{{ "id":"entry","route":"entries","mutationMode":"create_only","classification":"public", - "fields":[{{"id":"code","type":"string","maxLength":32,"classification":"public"}}], - "accessProfiles":[{{ - "id":"reader","default":true,"anonymous":{anonymous},"principalClaim":"principal", - "operations":{operations},"revisionAccess":{revision_access},"readableFields":["code"] + "fields":[{{"id":"code","type":"string","maxLength":32,"classification":"public"}}] + }}], + "accessProfiles":[{{ + "id":"reader","default":true,"anonymous":{anonymous},{principal_claim}"grants":[{{ + "entity":"entry","operations":{operations},"revisionAccess":{revision_access},"readableFields":["code"] }}] }}] }}"# @@ -2456,31 +3005,35 @@ fn public_profile_cannot_process_an_internal_field() { project.access_profiles[0].default = true; let entity = project .entities - .iter_mut() + .iter() .find(|entity| entity.id == "asset-item") .expect("asset entity exists"); - entity.access_profiles.push(AccessProfileSource { + assert!(entity.fields.iter().any(|field| field.id == "asset-code")); + project.access_profiles.push(ProjectAccessProfileSource { id: "public-reader".to_owned(), default: false, anonymous: true, principal_claim: None, required_scopes: Default::default(), required_purposes: Default::default(), - operations: [Operation::Get].into_iter().collect(), - readable_fields: ["asset-code".to_owned()].into_iter().collect(), - writable_fields: Default::default(), - filterable_fields: Default::default(), - sortable_fields: Default::default(), - row_boundaries: vec![RowBoundarySource { - field: "asset-code".to_owned(), - claim: "asset_code".to_owned(), - operator: BoundaryOperator::Equals, + grants: vec![AccessGrantSource { + entity: "asset-item".to_owned(), + operations: [Operation::Get].into_iter().collect(), + readable_fields: ["asset-code".to_owned()].into_iter().collect(), + writable_fields: Default::default(), + filterable_fields: Default::default(), + sortable_fields: Default::default(), + row_boundaries: vec![RowBoundarySource { + field: "asset-code".to_owned(), + claim: "asset_code".to_owned(), + operator: BoundaryOperator::Equals, + }], + lookups: Vec::new(), + read_paths: Vec::new(), + allow_count: false, + allow_data_export: false, + revision_access: false, }], - lookups: Vec::new(), - read_paths: Vec::new(), - allow_count: false, - allow_data_export: false, - revision_access: false, }); let failure = compile_project(&project, &[], CompileProfile::Authoring) @@ -2503,10 +3056,11 @@ fn anonymous_public_profile_cannot_filter_a_non_public_field() { "fields":[ {"id":"label","type":"string","maxLength":32,"classification":"public"}, {"id":"hidden-filter-canary","type":"string","maxLength":32,"classification":"restricted"} - ], - "accessProfiles":[{ - "id":"public-reader","anonymous":true,"default":true, - "operations":["list"],"readableFields":["label"], + ] + }], + "accessProfiles":[{ + "id":"public-reader","anonymous":true,"default":true,"grants":[{ + "entity":"entry","operations":["list"],"readableFields":["label"], "filterableFields":["hidden-filter-canary"] }] }] @@ -2557,7 +3111,8 @@ fn additive_module_conflicts_fail_instead_of_using_input_precedence() { "modules":[{"id":"core","version":"1"},{"id":"a","version":"1"},{"id":"b","version":"1"}], "entities":[{"id":"object","route":"objects","mutationMode":"mutable","fields":[ {"id":"code","type":"string","maxLength":8,"classification":"internal"} - ],"accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","operations":["get"],"readableFields":["code"]}]}] + ]}], + "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","grants":[{"entity":"object","operations":["get"],"readableFields":["code"]}]}] }"#, ) .expect("project parses"); @@ -2588,11 +3143,15 @@ fn operation_ids_preserve_distinct_valid_entity_ids_without_collisions() { "entities":[ {"id":"case-file","route":"case-files","mutationMode":"create_only","fields":[ {"id":"code","type":"string","maxLength":8,"classification":"internal"} - ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]}, + ]}, {"id":"case_file","route":"case_file_records","mutationMode":"create_only","fields":[ {"id":"code","type":"string","maxLength":8,"classification":"internal"} - ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]} - ] + ]} + ], + "accessProfiles":[{"id":"reader","principalClaim":"principal","grants":[ + {"entity":"case-file","operations":["get"],"readableFields":["code"]}, + {"entity":"case_file","operations":["get"],"readableFields":["code"]} + ]}] }"#, ) .expect("project parses"); @@ -2902,6 +3461,7 @@ fn compiled_query_inventory_is_profile_scoped_bounded_and_temporal() { "$skiptoken", "$top", "accessProfile", + "traceparent", ] ); let as_of_parameter_names = query_parameter_names( @@ -2918,6 +3478,7 @@ fn compiled_query_inventory_is_profile_scoped_bounded_and_temporal() { "$top", "accessProfile", "asOf", + "traceparent", ] ); let as_of_parameters = openapi["paths"]["/v1/records/placements:as-of"]["get"]["parameters"] @@ -2984,10 +3545,11 @@ fn query_inventory_rejects_unsupported_filter_and_sort_field_types() { "id":"entry","route":"entries","mutationMode":"mutable", "fields":[ {{"id":"payload","type":"structured","maxBytes":256,"classification":"internal","schema":{{"type":"object","additionalProperties":false}}}} - ], - "accessProfiles":[{{ - "id":"operator","default":true,"principalClaim":"principal", - "operations":["list"],"readableFields":["payload"],{member} + ] + }}], + "accessProfiles":[{{ + "id":"operator","default":true,"principalClaim":"principal","grants":[{{ + "entity":"entry","operations":["list"],"readableFields":["payload"],{member} }}] }}] }}"# @@ -3034,9 +3596,11 @@ fn reordered_stored_field_authoring_changes_revision_but_not_query_inventory() { "fields":[ {"id":"code","type":"string","maxLength":32,"classification":"internal"}, {"id":"count","type":"int64","classification":"internal"} - ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","operations":["list"], + ] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "entity":"entry","operations":["list"], "readableFields":["code","count"],"filterableFields":["count","code"],"sortableFields":["count","code"] }] }] @@ -3052,9 +3616,11 @@ fn reordered_stored_field_authoring_changes_revision_but_not_query_inventory() { "fields":[ {"id":"count","type":"int64","classification":"internal"}, {"id":"code","type":"string","maxLength":32,"classification":"internal"} - ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","operations":["list"], + ] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "entity":"entry","operations":["list"], "readableFields":["count","code"],"filterableFields":["code","count"],"sortableFields":["code","count"] }] }] @@ -3083,11 +3649,15 @@ fn duplicate_routes_fail_before_artifact_generation() { "entities":[ {"id":"first-record","route":"hidden-route-value","mutationMode":"create_only","fields":[ {"id":"code","type":"string","maxLength":8,"classification":"internal"} - ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]}, + ]}, {"id":"second-record","route":"hidden-route-value","mutationMode":"create_only","fields":[ {"id":"code","type":"string","maxLength":8,"classification":"internal"} - ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]} - ] + ]} + ], + "accessProfiles":[{"id":"reader","principalClaim":"principal","grants":[ + {"entity":"first-record","operations":["get"],"readableFields":["code"]}, + {"entity":"second-record","operations":["get"],"readableFields":["code"]} + ]}] }"#, ) .expect("project parses"); @@ -3114,10 +3684,11 @@ fn anonymous_profiles_cannot_grant_mutation_operations() { "registry":{"id":"neutral","version":"1","defaultLanguage":"en"}, "entities":[{ "id":"public-entry","route":"public-entries","mutationMode":"mutable","classification":"public", - "fields":[{"id":"label","type":"string","maxLength":32,"classification":"public"}], - "accessProfiles":[{ - "id":"anonymous-writer","anonymous":true,"default":true, - "operations":["create","patch"],"readableFields":["label"],"writableFields":["label"] + "fields":[{"id":"label","type":"string","maxLength":32,"classification":"public"}] + }], + "accessProfiles":[{ + "id":"anonymous-writer","anonymous":true,"default":true,"grants":[{ + "entity":"public-entry","operations":["create","patch"],"readableFields":["label"],"writableFields":["label"] }] }] }"#, @@ -3147,7 +3718,8 @@ fn production_refuses_a_digest_present_lock_without_module_source() { "modules":[{"id":"missing-module","version":"1","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"}], "entities":[{"id":"object","route":"objects","mutationMode":"create_only","fields":[ {"id":"code","type":"string","maxLength":8,"classification":"internal"} - ],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get"],"readableFields":["code"]}]}] + ]}], + "accessProfiles":[{"id":"reader","principalClaim":"principal","grants":[{"entity":"object","operations":["get"],"readableFields":["code"]}]}] }"#, ) .expect("project parses"); diff --git a/crates/registry-server/tests/data_operations.rs b/crates/registry-server/tests/data_operations.rs index e714faaede..3aa2a52dc8 100644 --- a/crates/registry-server/tests/data_operations.rs +++ b/crates/registry-server/tests/data_operations.rs @@ -33,10 +33,13 @@ fn compiled(allow_data_export: bool) -> registry_server::CompiledRegistry { "classification": "internal"}, {"id": "hidden", "type": "string", "maxLength": 16, "classification": "restricted"} - ], - "accessProfiles": [{ - "id": PROFILE, - "principalClaim": "principal", + ] + }], + "accessProfiles": [{ + "id": PROFILE, + "principalClaim": "principal", + "grants": [{ + "entity": ENTITY, "operations": ["create", "patch", "batch", "list"], "readableFields": ["code", "count", "readonly"], "writableFields": ["code", "count"], @@ -90,10 +93,13 @@ fn data_export_requires_explicit_nonanonymous_profile_permission() { "entities": [{ "id": ENTITY, "route": "records", "mutationMode": "create_only", "fields": [{"id": "code", "type": "string", "maxLength": 16, - "classification": "internal"}], - "accessProfiles": [{ - "id": PROFILE, "anonymous": anonymous, - "principalClaim": if anonymous { Value::Null } else { json!("principal") }, + "classification": "internal"}] + }], + "accessProfiles": [{ + "id": PROFILE, "anonymous": anonymous, + "principalClaim": if anonymous { Value::Null } else { json!("principal") }, + "grants": [{ + "entity": ENTITY, "operations": operations, "readableFields": readable, "allowDataExport": true }] @@ -124,7 +130,7 @@ fn data_export_requires_explicit_nonanonymous_profile_permission() { "registry": {"id": "project-export", "version": "1", "defaultLanguage": "en"}, "accessProfiles": [{ "id": "project-exporter", "principalClaim": "principal", - "grants": [{"entity": ENTITY, "actions": ["list"], + "grants": [{"entity": ENTITY, "operations": ["list"], "readableFields": ["code"], "allowDataExport": true}] }], "entities": [{ @@ -254,8 +260,9 @@ fn data_validate_and_chunk_plan_reuse_runtime_rules_and_compiled_batch_bounds() "entities":[{"id":ENTITY,"route":"records","mutationMode":"create_only", "batch":{"maximumItems":2,"maximumBytes":100}, "fields":[{"id":"code","type":"text","maxLength":1000,"required":true, - "classification":"internal"}], - "accessProfiles":[{"id":PROFILE,"principalClaim":"principal", + "classification":"internal"}]}], + "accessProfiles":[{"id":PROFILE,"principalClaim":"principal","grants":[{ + "entity":ENTITY, "operations":["create","batch"],"readableFields":["code"], "writableFields":["code"]}]}] }); @@ -299,10 +306,13 @@ fn data_lifecycle_uses_exact_compiled_api_names() { "maxLength": 16, "required": true, "classification": "internal" - }], - "accessProfiles": [{ - "id": PROFILE, - "principalClaim": "principal", + }] + }], + "accessProfiles": [{ + "id": PROFILE, + "principalClaim": "principal", + "grants": [{ + "entity": ENTITY, "operations": ["create", "patch", "batch", "list"], "readableFields": ["record-code"], "writableFields": ["record-code"], diff --git a/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml b/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml index 07d7530d44..f96826fc76 100644 --- a/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml +++ b/crates/registry-server/tests/fixtures/fixture-tooling/project.yaml @@ -39,11 +39,13 @@ entities: - {id: quantity, type: int64, required: true, classification: public} constraints: - {kind: unique, fields: [label]} - accessProfiles: - - id: operator - default: true - principalClaim: registry_principal - requiredPurposes: [case-management] +accessProfiles: + - id: operator + default: true + principalClaim: registry_principal + requiredPurposes: [case-management] + grants: + - entity: widget operations: [create, get, list, patch, batch] readableFields: [jurisdiction, label, note, quantity] writableFields: [jurisdiction, label, note, quantity] diff --git a/crates/registry-server/tests/http_auth.rs b/crates/registry-server/tests/http_auth.rs index c46a3d038e..4cafa8d216 100644 --- a/crates/registry-server/tests/http_auth.rs +++ b/crates/registry-server/tests/http_auth.rs @@ -55,16 +55,20 @@ entities: - {id: secret, type: string, required: true, maxLength: 100, classification: restricted} - {id: jurisdiction, type: string, required: true, maxLength: 100, classification: internal} - {id: tenant, type: string, required: true, maxLength: 100, classification: internal} - accessProfiles: - - id: public - default: true - anonymous: true +accessProfiles: + - id: public + default: true + anonymous: true + grants: + - entity: case operations: [get] readableFields: [label] - - id: caseworker - principalClaim: registry_principal - requiredScopes: [registry.read] - requiredPurposes: [case-management-never-rendered] + - id: caseworker + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management-never-rendered] + grants: + - entity: case operations: [get] readableFields: [label, secret] rowBoundaries: @@ -87,10 +91,12 @@ entities: classification: public fields: - {id: label, type: string, required: true, maxLength: 100, classification: public} - accessProfiles: - - id: caseworker - default: true - principalClaim: registry_principal +accessProfiles: + - id: caseworker + default: true + principalClaim: registry_principal + grants: + - entity: case operations: [get] readableFields: [label] rowBoundaries: diff --git a/crates/registry-server/tests/http_read_only.rs b/crates/registry-server/tests/http_read_only.rs index 1bfc057271..38a2be67a2 100644 --- a/crates/registry-server/tests/http_read_only.rs +++ b/crates/registry-server/tests/http_read_only.rs @@ -45,18 +45,28 @@ entities: - {id: label, type: string, required: true, maxLength: 100, classification: public} - {id: secret, type: string, required: true, maxLength: 100, classification: restricted} - {id: jurisdiction, type: string, required: true, maxLength: 32, classification: internal} - accessProfiles: - - id: public - default: true - anonymous: true + - id: protected-note + route: notes + mutationMode: create_only + classification: restricted + fields: + - {id: text, type: text, required: true, maxLength: 200, classification: restricted} +accessProfiles: + - id: public + default: true + anonymous: true + grants: + - entity: case operations: [get, list] readableFields: [label] filterableFields: [label] sortableFields: [label] - - id: caseworker - principalClaim: registry_principal - requiredScopes: [registry.read] - requiredPurposes: [case-management] + - id: caseworker + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + grants: + - entity: case operations: [create, get, list, patch, tombstone, batch, revisions] allowCount: true readableFields: [label, secret, jurisdiction] @@ -65,17 +75,7 @@ entities: sortableFields: [label] rowBoundaries: - {field: jurisdiction, claim: jurisdictions, operator: in} - - id: protected-note - route: notes - mutationMode: create_only - classification: restricted - fields: - - {id: text, type: text, required: true, maxLength: 200, classification: restricted} - accessProfiles: - - id: caseworker - principalClaim: registry_principal - requiredScopes: [registry.read] - requiredPurposes: [case-management] + - entity: protected-note operations: [create, get, list] readableFields: [text] writableFields: [text] @@ -105,12 +105,28 @@ entities: - {id: by-private-note, fields: [private-note]} readPaths: - {id: people, through: membership, to: person, route: people} - accessProfiles: - - id: operator - default: true - principalClaim: registry_principal - requiredScopes: [registry.read] - requiredPurposes: [case-management] + - id: membership + route: memberships + mutationMode: mutable + classification: restricted + fields: + - {id: household, type: reference, target: household, required: true, classification: restricted} + - {id: person, type: reference, target: person, required: true, classification: restricted} + - id: person + route: people + mutationMode: mutable + classification: restricted + fields: + - {id: person-code, type: string, required: true, maxLength: 64, classification: restricted} + - {id: sensitive-note, type: string, required: false, maxLength: 64, classification: restricted} +accessProfiles: + - id: operator + default: true + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + grants: + - entity: household operations: [get, lookup, list] readableFields: [household-code, administrative-area, local-household-number] filterableFields: [household-code, administrative-area, local-household-number] @@ -124,10 +140,17 @@ entities: filterableFields: [person-code] sortableFields: [person-code] allowCount: true - - id: viewer - principalClaim: registry_principal - requiredScopes: [registry.read] - requiredPurposes: [case-management] + - entity: person + operations: [get, list] + readableFields: [sensitive-note] + filterableFields: [sensitive-note] + sortableFields: [sensitive-note] + - id: viewer + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + grants: + - entity: household operations: [get, lookup] readableFields: [household-code] rowBoundaries: @@ -136,30 +159,6 @@ entities: - selector: by-household-code valueOrigin: verified_claim claimMapping: {household-code: household_code} - - id: membership - route: memberships - mutationMode: mutable - classification: restricted - fields: - - {id: household, type: reference, target: household, required: true, classification: restricted} - - {id: person, type: reference, target: person, required: true, classification: restricted} - - id: person - route: people - mutationMode: mutable - classification: restricted - fields: - - {id: person-code, type: string, required: true, maxLength: 64, classification: restricted} - - {id: sensitive-note, type: string, required: false, maxLength: 64, classification: restricted} - accessProfiles: - - id: operator - default: true - principalClaim: registry_principal - requiredScopes: [registry.read] - requiredPurposes: [case-management] - operations: [get, list] - readableFields: [sensitive-note] - filterableFields: [sensitive-note] - sortableFields: [sensitive-note] "#; const DERIVED_DISCOVERY_PROJECT: &str = r#" @@ -183,12 +182,14 @@ entities: execution: live fields: - {id: eligibility-score, type: int64, classification: restricted} - accessProfiles: - - id: operator - default: true - principalClaim: registry_principal - requiredScopes: [registry.read] - requiredPurposes: [case-management] +accessProfiles: + - id: operator + default: true + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + grants: + - entity: benefit-record operations: [get, list] readableFields: [label, eligibility-score] "#; @@ -208,22 +209,6 @@ entities: fields: - {id: label, type: string, required: true, maxLength: 100, classification: public} - {id: restricted-canary-field, type: string, maxLength: 100, classification: restricted} - accessProfiles: - - id: public - default: true - anonymous: true - operations: [get, list] - readableFields: [label] - filterableFields: [label] - sortableFields: [label] - - id: caseworker - principalClaim: registry_principal - requiredScopes: [registry.read] - requiredPurposes: [case-management] - operations: [get, list] - readableFields: [label, restricted-canary-field] - filterableFields: [label] - sortableFields: [label] - id: protected-ledger route: classified-records mutationMode: mutable @@ -244,11 +229,27 @@ entities: projection: [classified-status, valid-from] webhook: destinationId: classified-operations-destination - accessProfiles: - - id: caseworker - principalClaim: registry_principal - requiredScopes: [registry.read] - requiredPurposes: [case-management] +accessProfiles: + - id: public + default: true + anonymous: true + grants: + - entity: public-record + operations: [get, list] + readableFields: [label] + filterableFields: [label] + sortableFields: [label] + - id: caseworker + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management] + grants: + - entity: public-record + operations: [get, list] + readableFields: [label, restricted-canary-field] + filterableFields: [label] + sortableFields: [label] + - entity: protected-ledger operations: [get, list] readableFields: [classified-status, valid-from, valid-to] filterableFields: [classified-status] @@ -274,10 +275,12 @@ entities: - {id: household-code, type: string, required: true, maxLength: 64, classification: public} - {id: household-kind-code, apiName: householdKind, type: vocabulary-code, vocabulary: household-kind, required: true, classification: public} - {id: private-canary-field, apiName: privateCanary, type: string, required: true, maxLength: 64, classification: restricted} - accessProfiles: - - id: public - default: true - anonymous: true +accessProfiles: + - id: public + default: true + anonymous: true + grants: + - entity: logical-record operations: [get, list] readableFields: [household-code, household-kind-code] vocabularies: @@ -509,7 +512,7 @@ async fn lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value json!({"selector": "missing-canary", "values": {"householdCode": "DO-NOT-LEAK"}}), ) .await; - let unknown_body = body_bytes(unknown).await; + let unknown_body = problem_shape(unknown).await; let ungranted = harness .send_json( Method::POST, @@ -518,14 +521,10 @@ async fn lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value json!({"selector": "by-private-note", "values": {"privateNote": "DO-NOT-LEAK"}}), ) .await; - let ungranted_body = body_bytes(ungranted).await; + let ungranted_body = problem_shape(ungranted).await; assert_eq!(unknown_body, ungranted_body); - assert!(!String::from_utf8_lossy(&unknown_body).contains("DO-NOT-LEAK")); - assert_eq!( - serde_json::from_slice::(&unknown_body).expect("unresolved response is JSON") - ["code"], - "lookup.unresolved" - ); + assert!(!unknown_body.to_string().contains("DO-NOT-LEAK")); + assert_eq!(unknown_body["code"], "lookup.unresolved"); let claim_origin_claims = Some(caseworker_claims_with_direct( "case-management", @@ -597,7 +596,7 @@ async fn lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value json!({"selector": "by-household-code"}), ) .await; - assert_eq!(body_bytes(missing_claim).await, unknown_body); + assert_eq!(problem_shape(missing_claim).await, unknown_body); } #[tokio::test] @@ -679,7 +678,7 @@ async fn relationship_route_uses_path_grant_not_direct_target_rights() { ) .await; assert_eq!(unknown_path.status(), StatusCode::NOT_FOUND); - let unknown_path_body = body_json(unknown_path).await; + let unknown_path_body = problem_shape(unknown_path).await; let ungranted = harness .send( @@ -695,7 +694,7 @@ async fn relationship_route_uses_path_grant_not_direct_target_rights() { ) .await; assert_eq!(ungranted.status(), StatusCode::NOT_FOUND); - assert_eq!(body_json(ungranted).await, unknown_path_body); + assert_eq!(problem_shape(ungranted).await, unknown_path_body); assert_eq!(harness.records.calls(), before); } @@ -1346,9 +1345,9 @@ async fn profile_and_resource_concealment_complete_before_record_io() { assert_eq!(unauthorized.status(), StatusCode::NOT_FOUND); assert_eq!(unknown_profile.status(), StatusCode::NOT_FOUND); assert_eq!(unknown_resource.status(), StatusCode::NOT_FOUND); - let unauthorized = body_json(unauthorized).await; - assert_eq!(unauthorized, body_json(unknown_profile).await); - assert_eq!(unauthorized, body_json(unknown_resource).await); + let unauthorized = problem_shape(unauthorized).await; + assert_eq!(unauthorized, problem_shape(unknown_profile).await); + assert_eq!(unauthorized, problem_shape(unknown_resource).await); assert_eq!(unauthorized["code"], "resource.not_found"); assert!(!unauthorized.to_string().contains("caseworker")); assert!(!unauthorized.to_string().contains("another-purpose")); @@ -1470,8 +1469,30 @@ async fn discovery_surfaces_share_caller_filtered_routes_and_fields() { "$skiptoken", "$top", "accessProfile", + "traceparent", ] ); + assert_eq!( + public_openapi["paths"]["/v1/records/cases"]["get"]["security"], + json!([{}]) + ); + assert!( + public_openapi["paths"]["/v1/records/cases"]["get"]["responses"]["200"]["headers"] + .get("traceparent") + .is_some() + ); + assert!( + public_openapi["paths"]["/v1/records/cases"]["get"]["responses"]["200"]["content"] + ["application/json"]["schema"]["properties"] + .get("count") + .is_some() + ); + assert!( + public_openapi["components"]["schemas"]["Problem"]["required"] + .as_array() + .expect("Problem required is an array") + .contains(&json!("traceId")) + ); let page_size = public_openapi["paths"]["/v1/records/cases"]["get"]["parameters"] .as_array() .expect("query parameters are rendered") @@ -1525,6 +1546,10 @@ async fn discovery_surfaces_share_caller_filtered_routes_and_fields() { .get("secret") .is_some() ); + assert_eq!( + protected_openapi["paths"]["/v1/records/cases"]["get"]["security"], + json!([{"bearerAuth": []}]) + ); assert_no_mutation_methods(&protected_openapi); assert_eq!(harness.records.calls(), 0); } @@ -1689,8 +1714,10 @@ async fn caller_filtered_discovery_conceals_counts_vocabularies_events_queries_a public_openapi["components"]["schemas"] .as_object() .expect("public schemas") - .len(), - 1 + .keys() + .map(String::as_str) + .collect::>(), + BTreeSet::from(["Problem", "public-record"]) ); assert_eq!( public_openapi["paths"]["/v1/records/public-records"]["get"]["x-registry-accessProfile"], @@ -1700,7 +1727,6 @@ async fn caller_filtered_discovery_conceals_counts_vocabularies_events_queries_a public_openapi["components"]["schemas"]["public-record"]["properties"], json!({"label": {"type": "string", "minLength": 0, "maxLength": 100}}) ); - let public_metadata = body_json(send_to(&app, Method::GET, "/v1/registry", None).await).await; assert_eq!(public_metadata["entities"].as_array().unwrap().len(), 1); let metadata_artifact = registry @@ -1871,7 +1897,7 @@ async fn caller_filtered_discovery_conceals_counts_vocabularies_events_queries_a ) .await; assert_eq!(missing_profile_response.status(), StatusCode::NOT_FOUND); - let missing_profile = body_json(missing_profile_response).await; + let missing_profile = problem_shape(missing_profile_response).await; for uri in [ "/openapi.json?accessProfile=caseworker", "/v1/registry?accessProfile=caseworker", @@ -1886,14 +1912,14 @@ async fn caller_filtered_discovery_conceals_counts_vocabularies_events_queries_a ) .await; assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); - assert_eq!(body_json(response).await, missing_profile, "{uri}"); + assert_eq!(problem_shape(response).await, missing_profile, "{uri}"); } for uri in ["/v1/vocabularies", "/v1/events", "/v1/queries"] { for claims in [None, authorized_claims.clone()] { let response = send_to(&app, Method::GET, uri, claims).await; assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); - assert_eq!(body_json(response).await, missing_profile, "{uri}"); + assert_eq!(problem_shape(response).await, missing_profile, "{uri}"); } } let refusal = serde_json::to_string(&missing_profile).expect("refusal serializes"); @@ -2095,6 +2121,76 @@ fn operation_name(operation: Operation) -> &'static str { } } +#[tokio::test] +async fn runtime_openapi_contract_is_filtered_to_the_selected_acceptance_profile() { + let source = include_str!( + "../../../products/registry-server/acceptance/asset-site-placement/registry.yaml" + ); + let harness = Harness::from_project(source, true); + let openapi = body_json( + harness + .send( + Method::GET, + "/openapi.json?accessProfile=site-planner", + Some(registry_principal_claims("site-planning")), + ) + .await, + ) + .await; + + assert_eq!( + openapi["components"]["securitySchemes"]["bearerAuth"], + json!({"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}) + ); + assert!(openapi["components"]["schemas"]["Problem"] + .get("properties") + .is_some()); + assert_eq!( + openapi["components"]["schemas"]["Problem"]["required"], + json!(["type", "title", "status", "detail", "code", "traceId"]) + ); + assert_eq!( + openapi["paths"]["/v1/records/assets"]["get"]["x-registry-accessProfile"], + "site-planner" + ); + assert_eq!( + openapi["paths"]["/v1/records/assets"]["get"]["security"], + json!([{"bearerAuth": []}]) + ); + assert!(openapi["paths"]["/v1/records/assets"]["get"] + .get("x-registry-queryProfile") + .is_some()); + assert!(openapi["paths"]["/v1/records/assets"]["get"] + .get("x-registry-queryProfiles") + .is_none()); + assert_eq!( + openapi["paths"]["/v1/records/assets"]["get"]["x-registry-queryProfile"] + ["selectableProperties"], + json!(["assetCode", "label"]) + ); + assert_eq!( + query_parameter_names( + &openapi["paths"]["/v1/records/assets/{record_id}"]["get"]["parameters"] + ), + ["$select", "accessProfile", "record_id", "traceparent"] + ); + assert!( + openapi["paths"]["/v1/records/assets/{record_id}"]["get"]["responses"]["200"]["headers"] + .get("ETag") + .is_some() + ); + assert!( + openapi["paths"]["/v1/records/assets/{record_id}"]["get"]["responses"]["200"]["headers"] + .get("traceparent") + .is_some() + ); + assert!(openapi["components"]["schemas"]["asset-item"]["properties"] + .get("assetClass") + .is_none()); + assert!(openapi["paths"].get("/v1/records/inspections").is_none()); + assert_eq!(harness.records.calls(), 0); +} + #[tokio::test] async fn every_compiled_mutation_route_is_absent_from_the_served_router() { let harness = Harness::new(true); @@ -2138,6 +2234,17 @@ fn caseworker_claims(purpose: &str) -> VerifiedRequestClaims { ) } +fn registry_principal_claims(purpose: &str) -> VerifiedRequestClaims { + VerifiedRequestClaims::authenticated( + "registry_principal", + "registry-principal", + BTreeSet::new(), + Some(purpose.to_owned()), + BTreeMap::new(), + ) + .expect("registry principal claims are valid") +} + fn caseworker_claims_with_direct(purpose: &str, direct_claims: I) -> VerifiedRequestClaims where I: IntoIterator, @@ -2200,6 +2307,16 @@ async fn body_json(response: axum::response::Response) -> Value { serde_json::from_slice(&body_bytes(response).await).expect("JSON response") } +async fn problem_shape(response: axum::response::Response) -> Value { + let mut problem = body_json(response).await; + problem + .as_object_mut() + .expect("problem response is an object") + .remove("traceId") + .expect("problem response carries traceId"); + problem +} + fn assert_no_mutation_methods(document: &Value) { for path in document["paths"].as_object().unwrap().values() { let methods = path.as_object().unwrap(); diff --git a/crates/registry-server/tests/migration_plan.rs b/crates/registry-server/tests/migration_plan.rs index f01bb330d7..a04c46ec17 100644 --- a/crates/registry-server/tests/migration_plan.rs +++ b/crates/registry-server/tests/migration_plan.rs @@ -7,6 +7,7 @@ use std::fs; use registry_platform_canonical_json::canonicalize_json; use registry_server::compiler::{compile_project, module_digest, CompileProfile}; use registry_server::contract::{parse_module_yaml, parse_project_yaml}; +use registry_server::generated_ddl::DdlStatementKind; use registry_server::migration_plan::{ ArtifactDigestBinding, ChunkCursorProtocol, ExternalBackupBinding, MigrationRehearsalReceipt, RehearsalFixture, RehearsalProofs, RehearsalRowAssertion, ReviewedChangeCover, @@ -54,7 +55,13 @@ fn reviewed_migration_plan_closes_ast_sql_and_bound_evidence() { prepare_reviewed_package(Variant::RequiredField, previous, vec![artifacts.source()]) .expect("reviewed successor package prepares"); - assert!(prepared.manifest().migration_plan.statements.is_empty()); + assert!(!prepared.manifest().migration_plan.statements.is_empty()); + assert!(prepared + .manifest() + .migration_plan + .statements + .iter() + .all(|statement| statement.kind == DdlStatementKind::View)); assert_eq!( prepared.manifest().migration_plan.reviewed_descriptors, vec!["modules/core/migrations/required-field/descriptor.json"] diff --git a/crates/registry-server/tests/package_change_plan.rs b/crates/registry-server/tests/package_change_plan.rs index 93ac95f809..5516e35f80 100644 --- a/crates/registry-server/tests/package_change_plan.rs +++ b/crates/registry-server/tests/package_change_plan.rs @@ -282,7 +282,7 @@ fn complete_extension_surface_modules_are_order_independent() { let event_module = parse_module_yaml(br#"{"id":"event-extension","version":"1","extendEntities":[{"entity":"asset","accessProfiles":[{"id":"auditor","principalClaim":"principal","operations":["get","list"],"readableFields":["code","status"],"writableFields":[]}],"events":[{"id":"asset-created","trigger":"created","projection":["code","status"],"webhook":{"destinationId":"package-change-events"}}]}],"entities":[{"id":"site","route":"sites","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}]}]}"#) .expect("event extension parses"); let project_bytes = format!( - r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"local","instanceId":"{INSTANCE}","sequence":2,"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"name":"Package Test Publisher"}}}},"dataset":{{"title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}}},"entities":[{{"id":"asset","route":"assets","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}}],"accessProfiles":[{{"id":"reader","default":true,"principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}}]}}],"modules":[{{"id":"field-extension","version":"1","digest":"{}"}},{{"id":"event-extension","version":"1","digest":"{}"}}]}}"#, + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"local","instanceId":"{INSTANCE}","sequence":2,"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"name":"Package Test Publisher"}}}},"dataset":{{"title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}}},"entities":[{{"id":"asset","route":"assets","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}}]}}],"accessProfiles":[{{"id":"reader","default":true,"principalClaim":"principal","grants":[{{"entity":"asset","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}}]}}],"modules":[{{"id":"field-extension","version":"1","digest":"{}"}},{{"id":"event-extension","version":"1","digest":"{}"}}]}}"#, module_digest(&field_module), module_digest(&event_module) ); diff --git a/crates/registry-server/tests/postgres_batch.rs b/crates/registry-server/tests/postgres_batch.rs index 1011206c24..76eb787136 100644 --- a/crates/registry-server/tests/postgres_batch.rs +++ b/crates/registry-server/tests/postgres_batch.rs @@ -534,32 +534,38 @@ fn compiled_registry() -> registry_server::CompiledRegistry { {"id":"secret","type":"string","maxLength":128,"classification":"restricted"}, {"id":"quantity","type":"int64","required":true,"classification":"public"} ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal", - "requiredPurposes":["case-management","case-review"], - "operations":["create","get","patch","batch"], + "events":[ + {"id":"widget-created","trigger":"created","projection":["label"]}, + {"id":"widget-patched","trigger":"patched","projection":["label","quantity"]} + ] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management","case-review"], + "grants":[{ + "entity":"widget","operations":["create","get","patch","batch"], "readableFields":["jurisdiction","label","locked","quantity"], "writableFields":["jurisdiction","label","secret","quantity"], "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] - },{ - "id":"batch-creator","principalClaim":"registry_principal", - "requiredPurposes":["case-management"], - "operations":["create","batch"], + }] + },{ + "id":"batch-creator","principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "grants":[{ + "entity":"widget","operations":["create","batch"], "readableFields":["jurisdiction","label","locked","quantity"], "writableFields":["jurisdiction","label","secret","quantity"], "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] - },{ - "id":"operator-minimal","principalClaim":"registry_principal", - "requiredPurposes":["case-management"], - "operations":["create","patch","batch"], + }] + },{ + "id":"operator-minimal","principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "grants":[{ + "entity":"widget","operations":["create","patch","batch"], "readableFields":["label"], "writableFields":["jurisdiction","label","secret","quantity"], "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] - }], - "events":[ - {"id":"widget-created","trigger":"created","projection":["label"]}, - {"id":"widget-patched","trigger":"patched","projection":["label","quantity"]} - ] + }] }] }"#, ) diff --git a/crates/registry-server/tests/postgres_compiled_schema.rs b/crates/registry-server/tests/postgres_compiled_schema.rs index df6bd73dd0..2d44434d3c 100644 --- a/crates/registry-server/tests/postgres_compiled_schema.rs +++ b/crates/registry-server/tests/postgres_compiled_schema.rs @@ -954,12 +954,22 @@ fn compiled_registry() -> registry_server::CompiledRegistry { {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, {"id":"region","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, {"id":"label","type":"string","minLength":1,"maxLength":128,"required":true,"classification":"internal"} - ], - "accessProfiles":[ + ] + }, + { + "id":"event","route":"events","mutationMode":"create_only", + "fields":[ + {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"} + ] + } + ], + "accessProfiles":[ + { + "id":"writer","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["operations"], + "grants":[ { - "id":"writer","default":true,"principalClaim":"registry_principal", - "requiredPurposes":["operations"], - "operations":["create","get","list","patch"], + "entity":"entry","operations":["create","get","list","patch"], "readableFields":["tenant","region","label"], "writableFields":["tenant","region","label"], "rowBoundaries":[ @@ -968,27 +978,21 @@ fn compiled_registry() -> registry_server::CompiledRegistry { ] }, { - "id":"reviewer","principalClaim":"registry_principal", - "requiredPurposes":["review"], - "operations":["get","list"], - "readableFields":["tenant","region","label"], - "rowBoundaries":[ - {"field":"tenant","claim":"tenant_claim","operator":"equals"}, - {"field":"region","claim":"region_claim","operator":"in"} - ] + "entity":"event","operations":["create","get","list"], + "readableFields":["tenant"],"writableFields":["tenant"] } ] }, { - "id":"event","route":"events","mutationMode":"create_only", - "fields":[ - {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"} - ], - "accessProfiles":[{ - "id":"writer","default":true,"principalClaim":"registry_principal", - "requiredPurposes":["operations"], - "operations":["create","get","list"], - "readableFields":["tenant"],"writableFields":["tenant"] + "id":"reviewer","principalClaim":"registry_principal", + "requiredPurposes":["review"], + "grants":[{ + "entity":"entry","operations":["get","list"], + "readableFields":["tenant","region","label"], + "rowBoundaries":[ + {"field":"tenant","claim":"tenant_claim","operator":"equals"}, + {"field":"region","claim":"region_claim","operator":"in"} + ] }] } ] @@ -1017,10 +1021,11 @@ fn derived_registry() -> registry_server::CompiledRegistry { {"id":"child-count","type":"int64","classification":"internal"}, {"id":"observed-on","type":"date","classification":"internal"} ] - }], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal", - "operations":["create","get","list"], + }] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal","grants":[{ + "entity":"household","operations":["create","get","list"], "readableFields":["tenant","size","child-count","observed-on"], "writableFields":["tenant","size"], "filterableFields":["child-count"], diff --git a/crates/registry-server/tests/postgres_constraint_races.rs b/crates/registry-server/tests/postgres_constraint_races.rs index 1d808b784f..c8e4576a24 100644 --- a/crates/registry-server/tests/postgres_constraint_races.rs +++ b/crates/registry-server/tests/postgres_constraint_races.rs @@ -474,33 +474,21 @@ fn compiled_registry() -> registry_server::CompiledRegistry { "registry":{"id":"constraint-race-registry","version":"1","defaultLanguage":"en"}, "entities":[{ "id":"parent","route":"parents","mutationMode":"create_only","classification":"public", - "fields":[{"id":"name","type":"string","maxLength":64,"required":true,"classification":"public"}], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","requiredPurposes":["operations"], - "operations":["create","get"],"readableFields":["name"],"writableFields":["name"] - }] + "fields":[{"id":"name","type":"string","maxLength":64,"required":true,"classification":"public"}] },{ "id":"child","route":"children","mutationMode":"create_only","classification":"public", "fields":[ {"id":"parent","type":"reference","target":"parent","onDelete":"restrict","required":true,"classification":"public"}, {"id":"alternate-parent","type":"reference","target":"parent","onDelete":"restrict","classification":"public"}, {"id":"name","type":"string","maxLength":64,"required":true,"classification":"public"} - ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","requiredPurposes":["operations"], - "operations":["create","get"],"readableFields":["parent","alternate-parent","name"],"writableFields":["parent","alternate-parent","name"] - }] + ] },{ "id":"unique-entry","route":"unique-entries","mutationMode":"create_only","classification":"public", "fields":[ {"id":"scope","type":"string","maxLength":64,"required":true,"classification":"public"}, {"id":"code","type":"string","maxLength":64,"required":true,"classification":"public"} ], - "constraints":[{"kind":"unique","fields":["scope","code"]}], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","requiredPurposes":["operations"], - "operations":["create","get"],"readableFields":["scope","code"],"writableFields":["scope","code"] - }] + "constraints":[{"kind":"unique","fields":["scope","code"]}] },{ "id":"period","route":"periods","mutationMode":"create_only","classification":"public", "fields":[ @@ -512,10 +500,18 @@ fn compiled_registry() -> registry_server::CompiledRegistry { "constraints":[{ "kind":"temporal-non-overlap","scopeFields":["scope"], "startField":"valid-from","endField":"valid-to" - }], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","requiredPurposes":["operations"], - "operations":["create","get"], + }] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","requiredPurposes":["operations"], + "grants":[{ + "entity":"parent","operations":["create","get"],"readableFields":["name"],"writableFields":["name"] + },{ + "entity":"child","operations":["create","get"],"readableFields":["parent","alternate-parent","name"],"writableFields":["parent","alternate-parent","name"] + },{ + "entity":"unique-entry","operations":["create","get"],"readableFields":["scope","code"],"writableFields":["scope","code"] + },{ + "entity":"period","operations":["create","get"], "readableFields":["scope","valid-from","valid-to"], "writableFields":["scope","valid-from","valid-to"] }] @@ -557,6 +553,7 @@ fn create_request<'a>( .iter() .map(|field| (*field).to_owned()) .collect::>(), + correlation: registry_server::correlation::RequestCorrelation::server_created(), } } diff --git a/crates/registry-server/tests/postgres_data_export.rs b/crates/registry-server/tests/postgres_data_export.rs index d21cdf60fe..e3ff150675 100644 --- a/crates/registry-server/tests/postgres_data_export.rs +++ b/crates/registry-server/tests/postgres_data_export.rs @@ -359,10 +359,13 @@ fn compiled_registry() -> registry_server::CompiledRegistry { "classification":"restricted"} ], "constraints":[{"kind":"unique","fields":["code"]}], - "events":[{"id":"entry-created","trigger":"created","projection":["code"]}], - "accessProfiles":[{ - "id":PROFILE, "principalClaim":"registry_principal", + "events":[{"id":"entry-created","trigger":"created","projection":["code"]}] + }], + "accessProfiles":[{ + "id":PROFILE, "principalClaim":"registry_principal", "requiredPurposes":["data-export"], + "grants":[{ + "entity":"entry", "operations":["create","batch","list"], "readableFields":["code"], "writableFields":["code","jurisdiction","secret"], diff --git a/crates/registry-server/tests/postgres_fixture_journeys.rs b/crates/registry-server/tests/postgres_fixture_journeys.rs index 817f73caff..650abc002c 100644 --- a/crates/registry-server/tests/postgres_fixture_journeys.rs +++ b/crates/registry-server/tests/postgres_fixture_journeys.rs @@ -664,7 +664,9 @@ impl PackageFixture { fs::write( &path, format!( - r#"listener: + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +listener: bind: 127.0.0.1:9 trustedProxy: direct identity: diff --git a/crates/registry-server/tests/postgres_mutation.rs b/crates/registry-server/tests/postgres_mutation.rs index f894f198ba..309662cd99 100644 --- a/crates/registry-server/tests/postgres_mutation.rs +++ b/crates/registry-server/tests/postgres_mutation.rs @@ -321,6 +321,7 @@ async fn real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable() &mut client, MutationRequest { response_fields: BTreeSet::from(["label".to_owned()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), ..create_request( &create_plan, "positive-key", @@ -544,6 +545,7 @@ async fn real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable() value: json!(42), }]), response_fields: BTreeSet::from(["label".to_owned()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), }, ) .await; @@ -569,6 +571,7 @@ async fn real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable() expected_etag: Some(&patched_etag), body: MutationBody::Patch(Vec::new()), response_fields: BTreeSet::from(["label".to_owned()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), }, ) .await; @@ -623,6 +626,7 @@ async fn real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable() }, ]), response_fields: BTreeSet::from(["label".to_owned(), "quantity".to_owned()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), }, ) .await; @@ -837,9 +841,39 @@ async fn real_postgres_http_mutations_are_guarded_and_exactly_replayable() { assert!(openapi["paths"]["/v1/records/widgets"] .get("post") .is_some()); + assert_eq!( + openapi["paths"]["/v1/records/widgets"]["post"]["security"], + json!([{"bearerAuth": []}]) + ); + assert_eq!( + query_parameter_names(&openapi["paths"]["/v1/records/widgets"]["post"]["parameters"]), + ["Idempotency-Key", "accessProfile"] + ); + assert!( + openapi["paths"]["/v1/records/widgets"]["post"]["responses"]["201"]["headers"] + .get("Location") + .is_some() + ); assert!(openapi["paths"]["/v1/records/widgets/{record_id}"] .get("patch") .is_some()); + assert_eq!( + query_parameter_names( + &openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["parameters"] + ), + ["Idempotency-Key", "If-Match", "accessProfile", "record_id"] + ); + assert!( + openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["requestBody"]["content"] + .get("application/json-patch+json") + .is_some() + ); + assert!( + openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["responses"]["428"]["content"] + ["application/problem+json"]["schema"] + .get("$ref") + .is_some() + ); assert!(openapi["paths"]["/v1/records/widgets/{record_id}"] .get("delete") .is_some()); @@ -1928,32 +1962,6 @@ fn compiled_registry() -> registry_server::CompiledRegistry { {"id":"note","type":"string","maxLength":128,"required":false,"classification":"public"}, {"id":"quantity","type":"int64","required":true,"classification":"public"} ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal", - "requiredPurposes":["case-management","case-review"], - "operations":["create","get","list","patch","tombstone"], - "readableFields":["jurisdiction","label","note","quantity"], - "writableFields":["jurisdiction","label","note","quantity"], - "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] - },{ - "id":"review-operator","principalClaim":"registry_principal", - "requiredPurposes":["case-management"], - "operations":["create","get","list","patch","tombstone"], - "readableFields":["jurisdiction","label","note","quantity"], - "writableFields":["jurisdiction","label","note","quantity"], - "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] - },{ - "id":"anonymous-reader","anonymous":true, - "operations":["get","list"], - "readableFields":["label"] - },{ - "id":"label-editor","principalClaim":"registry_principal", - "requiredPurposes":["case-management"], - "operations":["get","patch"], - "readableFields":["label"], - "writableFields":["label"], - "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] - }], "events":[ {"id":"widget-created","trigger":"created","projection":["label"]}, {"id":"widget-patched","trigger":"patched","projection":["label","quantity"]}, @@ -1964,25 +1972,57 @@ fn compiled_registry() -> registry_server::CompiledRegistry { "fields":[ {"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}, {"id":"message","type":"string","maxLength":128,"required":true,"classification":"public"} - ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal", - "requiredPurposes":["case-management"], - "operations":["create","get","list"], - "readableFields":["jurisdiction","message"], - "writableFields":["jurisdiction","message"], - "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] - }] + ] },{ "id":"archive","route":"archives","mutationMode":"mutable","classification":"public", "fields":[ {"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}, {"id":"name","type":"string","maxLength":128,"required":true,"classification":"public"} - ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal", - "requiredPurposes":["case-management"], - "operations":["create","get","list","patch"], + ] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management","case-review"], + "grants":[{ + "entity":"widget","operations":["create","get","list","patch","tombstone"], + "readableFields":["jurisdiction","label","note","quantity"], + "writableFields":["jurisdiction","label","note","quantity"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }] + },{ + "id":"review-operator","principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "grants":[{ + "entity":"widget","operations":["create","get","list","patch","tombstone"], + "readableFields":["jurisdiction","label","note","quantity"], + "writableFields":["jurisdiction","label","note","quantity"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }] + },{ + "id":"anonymous-reader","anonymous":true, + "grants":[{ + "entity":"widget","operations":["get","list"], + "readableFields":["label"] + }] + },{ + "id":"label-editor","principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "grants":[{ + "entity":"widget","operations":["get","patch"], + "readableFields":["label"], + "writableFields":["label"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }] + },{ + "id":"case-operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "grants":[{ + "entity":"log","operations":["create","get","list"], + "readableFields":["jurisdiction","message"], + "writableFields":["jurisdiction","message"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + },{ + "entity":"archive","operations":["create","get","list","patch"], "readableFields":["jurisdiction","name"], "writableFields":["jurisdiction","name"], "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] @@ -2040,6 +2080,7 @@ fn create_request<'a>( expected_etag: None, body: MutationBody::Create(data), response_fields: BTreeSet::from(["label".to_owned(), "quantity".to_owned()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), } } @@ -2062,6 +2103,7 @@ fn patch_request<'a>( value: Value::String(label.to_owned()), }]), response_fields: BTreeSet::from(["label".to_owned(), "quantity".to_owned()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), } } @@ -2418,6 +2460,29 @@ async fn assert_journals_are_minimized_and_chained( assert!(audit_text.contains("principalReference")); assert!(audit_text.contains("recordReference")); + for (position, envelope) in ordered.iter().enumerate() { + if envelope.record["schema"] != "registry-server-audit/v1" { + continue; + } + let request_id = envelope.record["requestId"] + .as_str() + .expect("HTTP audit carries requestId"); + uuid::Uuid::parse_str(request_id).expect("requestId is a server UUID"); + let trace_id = envelope.record["traceId"] + .as_str() + .expect("HTTP audit carries traceId"); + assert_eq!(trace_id.len(), 32); + if envelope.record["phase"] == "terminal" { + let operation_id = &envelope.record["operationId"]; + assert!(ordered[..position].iter().any(|candidate| { + candidate.record["phase"] == "attempt" + && candidate.record["operationId"] == *operation_id + && candidate.record["requestId"] == request_id + && candidate.record["traceId"] == trace_id + })); + } + } + for table_and_column in [ ("registry_revisions", "snapshot"), ("registry_outbox", "payload"), @@ -2536,3 +2601,19 @@ fn compiled_fixture_exposes_create_patch_and_configured_tombstone_plans() { .iter() .any(|route| { route.entity_id == "archive" && route.operation == Operation::Tombstone })); } + +fn query_parameter_names(parameters: &Value) -> Vec { + let mut names = parameters + .as_array() + .expect("parameters are an array") + .iter() + .map(|parameter| { + parameter["name"] + .as_str() + .expect("parameter has a name") + .to_owned() + }) + .collect::>(); + names.sort(); + names +} diff --git a/crates/registry-server/tests/postgres_package.rs b/crates/registry-server/tests/postgres_package.rs index 2b05d4d365..696b80ae2b 100644 --- a/crates/registry-server/tests/postgres_package.rs +++ b/crates/registry-server/tests/postgres_package.rs @@ -25,7 +25,7 @@ use registry_server::migration::{ }; use registry_server::package::{ derive_package_revision, load_package, prepare_package, PackageBuildRequest, PackageEnvelope, - PackageError, PackageFileRole, PackageIntent, PackageLoadContext, PackageManifest, + PackageError, PackageFile, PackageFileRole, PackageIntent, PackageLoadContext, PackageManifest, PackageMigrationPlanInput, PackageModuleSource, PackageSignature, PackageSourceFile, PackageTrustAnchor, SignaturePolicy, TrustAnchorKey, MAX_PACKAGE_SOURCE_FILE_BYTES, TRUST_ANCHOR_API_VERSION, @@ -126,20 +126,20 @@ fn package_builder_refuses_successor_without_prior_compiled_registry() { } #[test] -fn package_layout_contract_required_manifest_projection_is_in_prepared_closure() { +fn package_layout_contract_conditional_manifest_projection_is_in_projected_closure() { let layout = fs::read_to_string(concat!( env!("CARGO_MANIFEST_DIR"), "/../../products/registry-server/contracts/package-layout.yaml" )) .expect("package layout contract reads"); assert!( - layout.contains("manifest/registry-manifest.json") - && layout.contains("lossy-manifest-projection"), - "package-layout.yaml requires a lossy manifest projection" + layout.contains("path: manifest/registry-manifest.json, role: lossy-manifest-projection, required: false"), + "package-layout.yaml makes the lossy manifest projection conditional" ); assert!( - layout.contains("manifest/dcat.jsonld") && layout.contains("dcat-catalog-projection"), - "package-layout.yaml requires a DCAT catalog projection" + layout + .contains("path: manifest/dcat.jsonld, role: dcat-catalog-projection, required: false"), + "package-layout.yaml makes the DCAT catalog projection conditional" ); let module_bytes = module_bytes(PlanChoice::Schema); @@ -228,6 +228,142 @@ fn package_layout_contract_required_manifest_projection_is_in_prepared_closure() ); } +#[test] +fn projection_free_package_omits_manifest_projection_from_signed_closure_and_loads() { + let module_bytes = module_bytes(PlanChoice::Schema); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); + let project_bytes = project_bytes_without_manifest("local", 1, &module_digest(&module)); + let request = build_request(BuildRequestParts { + environment: "local", + sequence: 1, + prior_revision: None, + schema_fingerprint: fingerprint(1), + project_bytes, + module_bytes, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + }); + + let first = prepare_package(request.clone()).expect("projection-free package prepares"); + let second = prepare_package(request).expect("projection-free package prepares again"); + assert_eq!(first.manifest(), second.manifest()); + assert_eq!( + first.canonical_signed_bytes(), + second.canonical_signed_bytes() + ); + assert_eq!(first.file_bytes(), second.file_bytes()); + assert!(first.registry().manifest_projection().is_none()); + assert!(first.manifest().files.iter().all(|entry| !matches!( + entry.role, + PackageFileRole::LossyManifestProjection | PackageFileRole::DcatCatalogProjection + ))); + assert!(!first + .file_bytes() + .contains_key("manifest/registry-manifest.json")); + assert!(!first.file_bytes().contains_key("manifest/dcat.jsonld")); + + let exact_paths = first + .file_bytes() + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + exact_paths, + BTreeSet::from([ + "database/ddl.sql", + "database/migration-plan.json", + "effective-model.json", + "inventories/access.json", + "inventories/events.json", + "inventories/physical-names.json", + "inventories/queries.json", + "inventories/routes.json", + "metadata/registry.json", + "openapi/openapi.json", + "schemas/neutral-record.schema.json", + "source/modules/core/module.yaml", + "source/registry.yaml", + "tests/journeys.yaml", + ]) + ); + + let root = TempRoot::create(); + first + .publish_to_directory(root.path(), Vec::new()) + .expect("projection-free package publishes"); + let loaded = load_package( + root.path(), + &local_context(PackageIntent::InitialActivation), + ) + .expect("projection-free package loads through full closure rederivation"); + assert!(loaded.registry().manifest_projection().is_none()); +} + +#[test] +fn projection_free_package_refuses_claimed_manifest_artifacts() { + let module_bytes = module_bytes(PlanChoice::Schema); + let module = parse_module_yaml(&module_bytes).expect("fixture module parses"); + let package = prepare_package(build_request(BuildRequestParts { + environment: "local", + sequence: 1, + prior_revision: None, + schema_fingerprint: fingerprint(1), + project_bytes: project_bytes_without_manifest("local", 1, &module_digest(&module)), + module_bytes, + migration_plan: PackageMigrationPlanInput::InitialCompiledDdl, + signature_policy: SignaturePolicy { + threshold: 0, + key_ids: Vec::new(), + }, + })) + .expect("projection-free package prepares"); + let root = TempRoot::create(); + package + .publish_to_directory(root.path(), Vec::new()) + .expect("projection-free package publishes"); + + let registry_manifest = br#"{"schema_version":"registry-manifest/v1"}"#; + let dcat = br#"{"@context":"https://www.w3.org/ns/dcat.jsonld"}"#; + let manifest_dir = root.path().join("manifest"); + fs::create_dir(&manifest_dir).expect("manifest directory creates"); + fs::write( + manifest_dir.join("registry-manifest.json"), + registry_manifest, + ) + .expect("claimed manifest writes"); + fs::write(manifest_dir.join("dcat.jsonld"), dcat).expect("claimed DCAT writes"); + rewrite_unsigned(root.path(), |manifest| { + manifest.files.extend([ + PackageFile { + path: "manifest/registry-manifest.json".to_owned(), + role: PackageFileRole::LossyManifestProjection, + size: registry_manifest.len() as u64, + sha256: format!("sha256:{}", hex(&Sha256::digest(registry_manifest))), + }, + PackageFile { + path: "manifest/dcat.jsonld".to_owned(), + role: PackageFileRole::DcatCatalogProjection, + size: dcat.len() as u64, + sha256: format!("sha256:{}", hex(&Sha256::digest(dcat))), + }, + ]); + manifest + .files + .sort_by(|left, right| left.path.cmp(&right.path)); + }); + + assert_eq!( + load_error( + root.path(), + &local_context(PackageIntent::InitialActivation), + ), + PackageError::Derivation + ); +} + #[test] fn fixture_journeys_are_required_at_the_fixed_path_and_change_the_package_revision() { let module_bytes = module_bytes(PlanChoice::Schema); @@ -2590,6 +2726,17 @@ fn project_bytes(environment: &str, sequence: u64, module_digest: &str) -> Vec Vec { + format!( + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en"}},"package":{{"environment":"{environment}","instanceId":"{INSTANCE}","sequence":{sequence},"sourceRevision":"{SOURCE_REVISION}"}},"modules":[{{"id":"core","version":"1","digest":"{module_digest}"}}]}}"# + ) + .into_bytes() +} + fn module_bytes(plan: PlanChoice) -> Vec { let second = if matches!( plan, @@ -2913,7 +3060,8 @@ impl EventDestinationCompatibilityFixture { path: &str, ) -> EventDestinationCompatibilityInventory { let raw = format!( - r#" + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig listener: bind: 127.0.0.1:8080 trustedProxy: direct diff --git a/crates/registry-server/tests/postgres_partial_unique.rs b/crates/registry-server/tests/postgres_partial_unique.rs index 17d1474848..0b39bbcf94 100644 --- a/crates/registry-server/tests/postgres_partial_unique.rs +++ b/crates/registry-server/tests/postgres_partial_unique.rs @@ -39,9 +39,11 @@ async fn real_postgres_partial_unique_index_enforces_only_the_closed_predicate() {"kind":"field_is_null","field":"ended-on"}, {"kind":"active_lifecycle"} ] - }], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","operations":["get"],"readableFields":["code","status","ended-on"] + }] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "entity":"entry","operations":["get"],"readableFields":["code","status","ended-on"] }] }], "vocabularies":[{"id":"status","values":["active","closed"]}] diff --git a/crates/registry-server/tests/postgres_read.rs b/crates/registry-server/tests/postgres_read.rs index b9ff55035a..4f9f494b7a 100644 --- a/crates/registry-server/tests/postgres_read.rs +++ b/crates/registry-server/tests/postgres_read.rs @@ -1277,30 +1277,7 @@ fn registry_source() -> String { {"id":"ordinal","type":"int64","required":true,"classification":"internal"}, {"id":"rank","type":"int64","required":false,"classification":"internal"}, {"id":"internal-code","apiName":"publicCode","type":"string","required":false,"maxLength":32,"classification":"internal"} - ], - "accessProfiles":[{ - "id":"operator", - "default":true, - "principalClaim":"registry_principal", - "requiredScopes":["registry.read"], - "requiredPurposes":["case-management","audit-review"], - "operations":["create","get","list","tombstone"], - "readableFields":["label","secret","amount","jurisdiction","ordinal","rank","internal-code"], - "writableFields":["label","secret","amount","jurisdiction","ordinal","rank"], - "filterableFields":["jurisdiction","label","ordinal","rank"], - "sortableFields":["ordinal","label","rank"], - "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] - },{ - "id":"auditor", - "principalClaim":"registry_principal", - "requiredScopes":["registry.read"], - "requiredPurposes":["case-management"], - "operations":["get","list"], - "readableFields":["label","jurisdiction","internal-code"], - "filterableFields":["label"], - "sortableFields":["label"], - "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] - }] + ] },{ "id":"assignment", "route":"assignments", @@ -1323,21 +1300,45 @@ fn registry_source() -> String { "scopeFields":["label"], "startField":"valid-from", "endField":"valid-to" - }], - "accessProfiles":[{ - "id":"operator", - "default":true, - "principalClaim":"registry_principal", - "requiredScopes":["registry.read"], - "requiredPurposes":["case-management","audit-review"], + }] + }], + "accessProfiles":[{ + "id":"operator", + "default":true, + "principalClaim":"registry_principal", + "requiredScopes":["registry.read"], + "requiredPurposes":["case-management","audit-review"], + "grants":[{ + "entity":"widget", + "operations":["create","get","list","tombstone"], + "readableFields":["label","secret","amount","jurisdiction","ordinal","rank","internal-code"], + "writableFields":["label","secret","amount","jurisdiction","ordinal","rank"], + "filterableFields":["jurisdiction","label","ordinal","rank"], + "sortableFields":["ordinal","label","rank"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] + },{ + "entity":"assignment", "operations":["create","get","list"], "readableFields":["label","jurisdiction","valid-from","valid-to"], "writableFields":["label","jurisdiction","valid-from","valid-to"], "filterableFields":["label","jurisdiction"], "sortableFields":["label"], "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] - }] - }] + }] + },{ + "id":"auditor", + "principalClaim":"registry_principal", + "requiredScopes":["registry.read"], + "requiredPurposes":["case-management"], + "grants":[{ + "entity":"widget", + "operations":["get","list"], + "readableFields":["label","jurisdiction","internal-code"], + "filterableFields":["label"], + "sortableFields":["label"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] + }] + }] }"# .to_owned() } diff --git a/crates/registry-server/tests/postgres_revision_http.rs b/crates/registry-server/tests/postgres_revision_http.rs index 6981fecc8a..da2989e9db 100644 --- a/crates/registry-server/tests/postgres_revision_http.rs +++ b/crates/registry-server/tests/postgres_revision_http.rs @@ -568,10 +568,13 @@ fn compiled_registry() -> registry_server::CompiledRegistry { {"id":"jurisdiction","type":"string","required":true,"maxLength":32,"classification":"internal"}, {"id":"label","type":"string","required":true,"maxLength":100,"classification":"internal"}, {"id":"secret","type":"string","required":true,"maxLength":100,"classification":"restricted"} - ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal", - "requiredScopes":["history.read"],"requiredPurposes":["case-review"], + ] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredScopes":["history.read"],"requiredPurposes":["case-review"], + "grants":[{ + "entity":"widget", "operations":["revisions"],"revisionAccess":true, "readableFields":["label"], "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] diff --git a/crates/registry-server/tests/postgres_startup.rs b/crates/registry-server/tests/postgres_startup.rs index 9b0d7eeb56..8368f55d28 100644 --- a/crates/registry-server/tests/postgres_startup.rs +++ b/crates/registry-server/tests/postgres_startup.rs @@ -1026,7 +1026,8 @@ impl StartupFixture { fs::write( &path, format!( - r#" + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig listener: bind: {listener} trustedProxy: direct diff --git a/crates/registry-server/tests/postgres_tombstone_revision.rs b/crates/registry-server/tests/postgres_tombstone_revision.rs index c300d1d574..ee2f9b5a01 100644 --- a/crates/registry-server/tests/postgres_tombstone_revision.rs +++ b/crates/registry-server/tests/postgres_tombstone_revision.rs @@ -558,25 +558,24 @@ fn compiled_registry(tombstone: bool) -> registry_server::CompiledRegistry { {{"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}}, {{"id":"label","type":"string","maxLength":128,"required":true,"classification":"public"}}, {{"id":"quantity","type":"int64","required":true,"classification":"public"}} - ], - "accessProfiles":[{{ - "id":"operator","default":true,"principalClaim":"registry_principal", - "requiredPurposes":["case-management"], - "operations":[{operations}], - "readableFields":["jurisdiction","label","quantity"], - "writableFields":["jurisdiction","label","quantity"], - "rowBoundaries":[{{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}}] - }}]{events} + ]{events} }},{{ "id":"log","route":"logs","mutationMode":"create_only","classification":"public", "fields":[ {{"id":"jurisdiction","type":"string","maxLength":32,"required":true,"classification":"public"}}, {{"id":"message","type":"string","maxLength":128,"required":true,"classification":"public"}} - ], - "accessProfiles":[{{ - "id":"operator","default":true,"principalClaim":"registry_principal", - "requiredPurposes":["case-management"], - "operations":["create","get","list"], + ] + }}], + "accessProfiles":[{{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "grants":[{{ + "entity":"widget","operations":[{operations}], + "readableFields":["jurisdiction","label","quantity"], + "writableFields":["jurisdiction","label","quantity"], + "rowBoundaries":[{{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}}] + }},{{ + "entity":"log","operations":["create","get","list"], "readableFields":["jurisdiction","message"], "writableFields":["jurisdiction","message"], "rowBoundaries":[{{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}}] @@ -634,6 +633,7 @@ fn create_request<'a>( ("quantity".to_owned(), json!(7)), ])), response_fields: response_fields(), + correlation: registry_server::correlation::RequestCorrelation::server_created(), } } @@ -656,6 +656,7 @@ fn patch_request<'a>( value: Value::String(label.to_owned()), }]), response_fields: response_fields(), + correlation: registry_server::correlation::RequestCorrelation::server_created(), } } @@ -674,6 +675,7 @@ fn tombstone_request<'a>( expected_etag: Some(expected_etag), body: MutationBody::Tombstone, response_fields: response_fields(), + correlation: registry_server::correlation::RequestCorrelation::server_created(), } } diff --git a/crates/registry-server/tests/postgres_webhook_delivery.rs b/crates/registry-server/tests/postgres_webhook_delivery.rs index 3efdd8b3bb..abebb6955f 100644 --- a/crates/registry-server/tests/postgres_webhook_delivery.rs +++ b/crates/registry-server/tests/postgres_webhook_delivery.rs @@ -981,6 +981,7 @@ async fn create_event( "label".to_owned(), "restricted_note".to_owned(), ]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), }, ) .await @@ -1367,20 +1368,22 @@ fn compiled_registry() -> registry_server::CompiledRegistry { {"id":"label","type":"string","maxLength":64,"required":true,"classification":"internal"}, {"id":"restricted_note","type":"string","maxLength":64,"required":true,"classification":"restricted"} ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal", - "requiredPurposes":["case-management"], - "operations":["create","get","list"], - "readableFields":["jurisdiction","label","restricted_note"], - "writableFields":["jurisdiction","label","restricted_note"], - "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] - }], "events":[{ "id":"case-created","trigger":"created","projection":["label","restricted_note"], "webhook":{ "destinationId":"case-operations" } }] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "grants":[{ + "entity":"case","operations":["create","get","list"], + "readableFields":["jurisdiction","label","restricted_note"], + "writableFields":["jurisdiction","label","restricted_note"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }] }] }"#, ) @@ -1488,7 +1491,8 @@ impl DestinationFixture { compiled: ®istry_server::CompiledRegistry, ) -> ActivatedEventDestinationRegistry { let raw = format!( - r#" + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig listener: bind: 127.0.0.1:8080 trustedProxy: direct diff --git a/crates/registry-server/tests/postgres_webhook_outbox.rs b/crates/registry-server/tests/postgres_webhook_outbox.rs index bc56ef3199..91576025bb 100644 --- a/crates/registry-server/tests/postgres_webhook_outbox.rs +++ b/crates/registry-server/tests/postgres_webhook_outbox.rs @@ -607,14 +607,6 @@ fn compiled_registry() -> registry_server::CompiledRegistry { {"id":"label","type":"string","maxLength":64,"required":true,"classification":"internal"}, {"id":"restricted_note","type":"string","maxLength":64,"required":true,"classification":"restricted"} ], - "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal", - "requiredPurposes":["case-management"], - "operations":["create","patch","get","list"], - "readableFields":["jurisdiction","label","restricted_note"], - "writableFields":["jurisdiction","label","restricted_note"], - "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] - }], "events":[{ "id":"case-created","trigger":"created","projection":["label","restricted_note"], "webhook":{ @@ -630,6 +622,16 @@ fn compiled_registry() -> registry_server::CompiledRegistry { }, "webhook":{"destinationId":"case-operations"} }] + }], + "accessProfiles":[{ + "id":"operator","default":true,"principalClaim":"registry_principal", + "requiredPurposes":["case-management"], + "grants":[{ + "entity":"case","operations":["create","patch","get","list"], + "readableFields":["jurisdiction","label","restricted_note"], + "writableFields":["jurisdiction","label","restricted_note"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }] }] }"#, ) @@ -713,6 +715,7 @@ fn create_request<'a>( "label".to_owned(), "restricted_note".to_owned(), ]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), } } @@ -739,6 +742,7 @@ fn patch_request<'a>( "label".to_owned(), "restricted_note".to_owned(), ]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), } } @@ -1172,7 +1176,8 @@ impl DestinationFixture { compiled: ®istry_server::CompiledRegistry, ) -> ActivatedEventDestinationRegistry { let raw = format!( - r#" + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig listener: bind: 127.0.0.1:8080 trustedProxy: direct diff --git a/crates/registry-server/tests/runtime_config.rs b/crates/registry-server/tests/runtime_config.rs index d55867f023..a47dd500f4 100644 --- a/crates/registry-server/tests/runtime_config.rs +++ b/crates/registry-server/tests/runtime_config.rs @@ -22,6 +22,7 @@ use registry_server::event_destination::EventDestinationActivationError; use registry_server::runtime_config::{ load_runtime_config, load_runtime_config_with_env, parse_runtime_config, parse_runtime_config_with_env, RuntimeConfigError, TrustedProxyPosture, + RUNTIME_CONFIG_API_VERSION, RUNTIME_CONFIG_KIND, }; use serde_json::{json, Value}; @@ -36,6 +37,8 @@ const STATIC_JWKS_ENV: &str = "REGISTRY_SERVER_RUNTIME_CONFIG_STATIC_JWKS"; fn valid_runtime(secret_root: &Path, package_root: &Path, trust_anchor: &Path) -> String { format!( r#" +apiVersion: {api_version} +kind: {kind} listener: bind: 127.0.0.1:8080 trustedProxy: direct @@ -47,7 +50,7 @@ identity: secretProviders: environment: {{}} file: - root: {} + root: {secret_root} database: runtimeUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL migrationUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_MIGRATION_DATABASE_URL @@ -60,8 +63,8 @@ database: migration: registry_migration runtime: registry_runtime package: - root: {} - trustAnchorPath: {} + root: {package_root} + trustAnchorPath: {trust_anchor} compilerSourceRevision: source-revision-1 activeRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa activeSequence: 1 @@ -100,9 +103,11 @@ operationalTimeouts: migrationLockMilliseconds: 30000 migrationStatementMilliseconds: 60000 "#, - secret_root.display(), - package_root.display(), - trust_anchor.display() + api_version = RUNTIME_CONFIG_API_VERSION, + kind = RUNTIME_CONFIG_KIND, + secret_root = secret_root.display(), + package_root = package_root.display(), + trust_anchor = trust_anchor.display() ) } @@ -289,6 +294,250 @@ fn strict_runtime_file_loads_and_constructs_existing_runtime_inputs() { .expect("cursor codec builds from protected file secret"); } +#[test] +fn runtime_document_identity_is_required_and_exact() { + let fixture = RuntimeFixture::new(); + let base = valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ); + + assert_eq!( + parse_runtime_config_with_env( + &base.replace( + RUNTIME_CONFIG_API_VERSION, + "registry.registrystack.org/server-runtime/v2" + ), + env_lookup + ) + .expect_err("unsupported apiVersion refused"), + RuntimeConfigError::InvalidApiVersion + ); + assert_eq!( + parse_runtime_config_with_env( + &base.replace(RUNTIME_CONFIG_KIND, "RegistryProject"), + env_lookup + ) + .expect_err("unsupported kind refused"), + RuntimeConfigError::InvalidKind + ); + assert_eq!( + parse_runtime_config_with_env( + &base.replace(&format!("apiVersion: {RUNTIME_CONFIG_API_VERSION}\n"), ""), + env_lookup + ) + .expect_err("missing apiVersion refused by strict document shape"), + RuntimeConfigError::Document + ); + assert_eq!( + parse_runtime_config_with_env( + &base.replace(&format!("kind: {RUNTIME_CONFIG_KIND}\n"), ""), + env_lookup + ) + .expect_err("missing kind refused by strict document shape"), + RuntimeConfigError::Document + ); +} + +#[test] +fn operational_defaults_materialize_without_defaulting_authority() { + let fixture = RuntimeFixture::new(); + let base = valid_runtime( + &fixture.secret_root, + &fixture.package_root, + &fixture.trust_anchor, + ); + let raw = base + .clone() + .replace( + " waitTimeoutMilliseconds: 1000\n createTimeoutMilliseconds: 1000\n recycleTimeoutMilliseconds: 1000\n", + "", + ) + .replace( + " jwksCache:\n cacheTtlSeconds: 600\n negativeCacheTtlSeconds: 60\n refreshCooldownSeconds: 30\n maxDocumentBytes: 65536\n requestTimeoutMilliseconds: 5000\n outageToleranceSeconds: 900\n", + "", + ) + .replace(" maxAgeSeconds: 300\n", "") + .replace( + "operationalTimeouts:\n httpRequestMilliseconds: 10000\n shutdownGraceMilliseconds: 30000\n recordLockMilliseconds: 5000\n migrationLockMilliseconds: 30000\n migrationStatementMilliseconds: 60000\n", + "", + ); + + let config = parse_runtime_config_with_env(&raw, env_lookup) + .expect("safe operational defaults materialize"); + + assert_eq!(config.database().pool_bounds().max_size, 4); + assert_eq!( + config.database().pool_bounds().wait_timeout, + Duration::from_secs(30) + ); + assert_eq!( + config.database().pool_bounds().create_timeout, + Duration::from_secs(30) + ); + assert_eq!( + config.database().pool_bounds().recycle_timeout, + Duration::from_secs(30) + ); + let jwks = config.authentication().oidc().jwks_fetcher_config(); + assert_eq!(jwks.cache_ttl, Duration::from_secs(600)); + assert_eq!(jwks.negative_cache_ttl, Duration::from_secs(60)); + assert_eq!(jwks.refresh_cooldown, Duration::from_secs(30)); + assert_eq!(jwks.max_doc_bytes, 65_536); + assert_eq!(jwks.request_timeout, Duration::from_secs(5)); + assert_eq!(jwks.outage_tolerance, Duration::from_secs(900)); + assert_eq!(config.cursor().max_age(), Duration::from_secs(300)); + assert_eq!( + config.event_delivery().payload_retention(), + Duration::from_secs(7 * 24 * 60 * 60) + ); + assert_eq!( + config.operational_timeouts().http_request, + Duration::from_secs(10) + ); + assert_eq!( + config.operational_timeouts().shutdown_grace, + Duration::from_secs(30) + ); + assert_eq!( + config.operational_timeouts().record_lock, + Duration::from_secs(5) + ); + assert_eq!( + config.operational_timeouts().migration_lock, + Duration::from_secs(30) + ); + assert_eq!( + config.operational_timeouts().migration_statement, + Duration::from_secs(60) + ); + + let partial_raw = base + .replace( + " jwksCache:\n cacheTtlSeconds: 600\n negativeCacheTtlSeconds: 60\n refreshCooldownSeconds: 30\n maxDocumentBytes: 65536\n requestTimeoutMilliseconds: 5000\n outageToleranceSeconds: 900\n", + " jwksCache:\n requestTimeoutMilliseconds: 5000\n", + ) + .replace( + "operationalTimeouts:\n httpRequestMilliseconds: 10000\n shutdownGraceMilliseconds: 30000\n recordLockMilliseconds: 5000\n migrationLockMilliseconds: 30000\n migrationStatementMilliseconds: 60000\n", + "operationalTimeouts:\n httpRequestMilliseconds: 10000\n", + ); + let partial = parse_runtime_config_with_env(&partial_raw, env_lookup) + .expect("partial operational sections receive safe field defaults"); + let jwks = partial.authentication().oidc().jwks_fetcher_config(); + assert_eq!(jwks.cache_ttl, Duration::from_secs(600)); + assert_eq!(jwks.negative_cache_ttl, Duration::from_secs(60)); + assert_eq!(jwks.refresh_cooldown, Duration::from_secs(30)); + assert_eq!(jwks.max_doc_bytes, 65_536); + assert_eq!(jwks.request_timeout, Duration::from_secs(5)); + assert_eq!(jwks.outage_tolerance, Duration::from_secs(900)); + assert_eq!( + partial.operational_timeouts().http_request, + Duration::from_secs(10) + ); + assert_eq!( + partial.operational_timeouts().shutdown_grace, + Duration::from_secs(30) + ); + assert_eq!( + partial.operational_timeouts().record_lock, + Duration::from_secs(5) + ); + assert_eq!( + partial.operational_timeouts().migration_lock, + Duration::from_secs(30) + ); + assert_eq!( + partial.operational_timeouts().migration_statement, + Duration::from_secs(60) + ); + + for required_authority in [ + ("identity:\n", RuntimeConfigError::Document), + ("secretProviders:\n", RuntimeConfigError::Document), + ("database:\n", RuntimeConfigError::Document), + ("package:\n", RuntimeConfigError::Document), + ("authentication:\n", RuntimeConfigError::Document), + ("audit:\n", RuntimeConfigError::Document), + ("cursor:\n", RuntimeConfigError::Document), + ( + " runtimeUrlRef: secret:env/REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL\n", + RuntimeConfigError::Document, + ), + (" roles:\n", RuntimeConfigError::Document), + ( + " issuer: https://issuer.example\n", + RuntimeConfigError::Document, + ), + ( + " hashKeyRef: secret:file/audit-key\n", + RuntimeConfigError::Document, + ), + ( + " secretRef: secret:file/cursor-key\n", + RuntimeConfigError::Document, + ), + ] { + let (line, expected) = required_authority; + assert_eq!( + parse_runtime_config_with_env(&raw.replace(line, ""), env_lookup) + .expect_err("authority-bearing runtime member is never defaulted"), + expected + ); + } +} + +#[test] +fn runtime_config_errors_expose_stable_value_free_metadata() { + let cases = [ + ( + RuntimeConfigError::InvalidApiVersion, + "runtime_config.invalid_api_version", + "/apiVersion", + ), + ( + RuntimeConfigError::InvalidKind, + "runtime_config.invalid_kind", + "/kind", + ), + ( + RuntimeConfigError::InvalidDatabase, + "runtime_config.invalid_database", + "/database", + ), + ( + RuntimeConfigError::InvalidOidc, + "runtime_config.invalid_oidc", + "/authentication/oidc", + ), + ( + RuntimeConfigError::InvalidEventDestination, + "runtime_config.invalid_event_destination", + "/eventDestinations", + ), + (RuntimeConfigError::Secret, "runtime_config.secret", "/"), + ]; + + for (error, code, path) in cases { + let metadata = error.metadata(); + assert_eq!(error.code(), code); + assert_eq!(error.path(), path); + assert_eq!(metadata.code(), code); + assert_eq!(metadata.path(), path); + let rendered = format!("{error:?} {error} {metadata:?}"); + for canary in [ + DATABASE_URL_CANARY, + MIGRATION_DATABASE_URL_CANARY, + AUDIT_KEY_CANARY, + "REGISTRY_SERVER_RUNTIME_CONFIG_DATABASE_URL", + "registry_runtime", + "https://issuer.example", + ] { + assert!(!rendered.contains(canary), "{code} leaked {canary}"); + } + } +} + #[test] fn webhook_payload_retention_is_deployment_selected_and_capped_at_thirty_days() { let fixture = RuntimeFixture::new(); diff --git a/crates/registry-server/tests/schema_fingerprint_rehearsal.rs b/crates/registry-server/tests/schema_fingerprint_rehearsal.rs index 3f9e7ce506..be42d82f49 100644 --- a/crates/registry-server/tests/schema_fingerprint_rehearsal.rs +++ b/crates/registry-server/tests/schema_fingerprint_rehearsal.rs @@ -266,7 +266,8 @@ fn runtime_config_with_roles( runtime_role: &str, ) -> RuntimeConfig { parse_runtime_config(&format!( - r#" + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig listener: bind: 127.0.0.1:8080 trustedProxy: direct @@ -405,10 +406,13 @@ fn project_bytes(environment: &str, instance_id: &str, source_revision: &str) -> "type": "string", "maxLength": 32, "classification": "internal" - }}], - "accessProfiles": [{{ - "id": "reader", - "principalClaim": "principal", + }}] + }}], + "accessProfiles": [{{ + "id": "reader", + "principalClaim": "principal", + "grants": [{{ + "entity": "case", "operations": ["get", "list"], "readableFields": ["code"] }}] diff --git a/crates/registry-server/tests/startup_http.rs b/crates/registry-server/tests/startup_http.rs index 2fc14b35fb..3a00846892 100644 --- a/crates/registry-server/tests/startup_http.rs +++ b/crates/registry-server/tests/startup_http.rs @@ -60,15 +60,20 @@ entities: classification: public fields: - {id: label, type: string, required: true, maxLength: 80, classification: public} - accessProfiles: - - id: public - default: true - anonymous: true +accessProfiles: + - id: public + default: true + anonymous: true + grants: + - entity: public-record operations: [list] readableFields: [label] "#; -struct NoopRecords; +#[derive(Default)] +struct NoopRecords { + correlations: Mutex>, +} impl RecordReadService for NoopRecords { fn get( @@ -80,8 +85,15 @@ impl RecordReadService for NoopRecords { fn list( &self, - _request: RecordReadRequest, + request: RecordReadRequest, ) -> ServiceFuture<'_, Result> { + self.correlations + .lock() + .expect("correlation capture") + .push(( + request.correlation.request_id(), + request.correlation.trace_id().as_str().to_owned(), + )); Box::pin(async { HeldReadResponse::from_json(&json!({"items": []})) .map_err(|_| ReadServiceError::Unavailable) @@ -115,7 +127,7 @@ async fn request_timeout_returns_value_free_problem() { package_revision: "package-startup-http".to_owned(), schema_fingerprint: "schema-startup-http".to_owned(), }, - Arc::new(NoopRecords), + Arc::new(NoopRecords::default()), Arc::new(SlowReadiness), Arc::new( CursorCodec::new(Zeroizing::new(vec![0x45; 32]), Duration::from_secs(300)) @@ -128,6 +140,10 @@ async fn request_timeout_returns_value_free_problem() { .oneshot( Request::builder() .uri("/ready") + .header( + "traceparent", + "00-11111111111111111111111111111111-2222222222222222-01", + ) .body(Body::empty()) .expect("request builds"), ) @@ -135,12 +151,186 @@ async fn request_timeout_returns_value_free_problem() { .expect("router responds"); assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + let traceparent = response + .headers() + .get("traceparent") + .expect("timeout response carries traceparent") + .to_str() + .expect("traceparent is ASCII") + .to_owned(); + assert_eq!( + traceparent, + "00-11111111111111111111111111111111-2222222222222222-01" + ); let body = to_bytes(response.into_body(), 1024 * 1024) .await .expect("timeout body reads"); let text = std::str::from_utf8(&body).expect("timeout body is utf-8"); assert!(text.contains("request.timeout")); assert!(!text.contains("startup-http")); + let problem: Value = serde_json::from_slice(&body).expect("timeout problem is JSON"); + assert_eq!(problem["traceId"], trace_id(&traceparent)); +} + +#[tokio::test] +async fn trace_transport_health_aliases_and_request_ids_are_correlated() { + const INBOUND: &str = "00-11111111111111111111111111111111-2222222222222222-01"; + const SECOND: &str = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"; + + let records = Arc::new(NoopRecords::default()); + let service = Arc::new(HttpService::new( + compiled_registry(), + ReadRuntimeIdentity { + package_revision: "package-startup-http".to_owned(), + schema_fingerprint: "schema-startup-http".to_owned(), + }, + records.clone(), + Arc::new(SlowReadiness), + Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x45; 32]), Duration::from_secs(300)) + .expect("test cursor key is valid"), + ), + )); + let app = router(service); + + let mut valid_request = Request::builder() + .uri("/health") + .body(Body::empty()) + .expect("valid trace request builds"); + valid_request + .headers_mut() + .insert("traceparent", HeaderValue::from_static(INBOUND)); + valid_request.headers_mut().insert( + "tracestate", + HeaderValue::from_static("registry=caller-controlled"), + ); + let valid = app + .clone() + .oneshot(valid_request) + .await + .expect("valid trace responds"); + assert_eq!(valid.status(), StatusCode::OK); + assert_eq!(valid.headers()["traceparent"], INBOUND); + assert!(valid.headers().get("tracestate").is_none()); + let health_body = to_bytes(valid.into_body(), 1024) + .await + .expect("health body reads"); + + let healthz = app + .clone() + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .expect("healthz request builds"), + ) + .await + .expect("healthz responds"); + assert_eq!(healthz.status(), StatusCode::OK); + assert!(healthz.headers().get("traceparent").is_some()); + assert_eq!( + to_bytes(healthz.into_body(), 1024) + .await + .expect("healthz body reads"), + health_body + ); + + for inbound in [None, Some("invalid")] { + let mut request = Request::builder() + .uri("/health") + .body(Body::empty()) + .expect("replacement trace request builds"); + if let Some(inbound) = inbound { + request.headers_mut().insert( + "traceparent", + HeaderValue::from_str(inbound).expect("test header is valid"), + ); + } + let response = app + .clone() + .oneshot(request) + .await + .expect("replacement trace responds"); + assert_canonical_server_trace(response.headers()["traceparent"].to_str().unwrap()); + } + + let mut duplicate = Request::builder() + .uri("/health") + .body(Body::empty()) + .expect("duplicate trace request builds"); + duplicate + .headers_mut() + .append("traceparent", HeaderValue::from_static(INBOUND)); + duplicate + .headers_mut() + .append("traceparent", HeaderValue::from_static(SECOND)); + let duplicate = app + .clone() + .oneshot(duplicate) + .await + .expect("duplicate trace responds"); + let effective = duplicate.headers()["traceparent"].to_str().unwrap(); + assert_canonical_server_trace(effective); + assert_ne!(effective, INBOUND); + assert_ne!(effective, SECOND); + + let mut unmatched_request = Request::builder() + .uri("/does-not-exist") + .body(Body::empty()) + .expect("unmatched request builds"); + unmatched_request + .headers_mut() + .insert("traceparent", HeaderValue::from_static(INBOUND)); + let unmatched = app + .clone() + .oneshot(unmatched_request) + .await + .expect("unmatched request responds"); + assert_eq!(unmatched.status(), StatusCode::NOT_FOUND); + assert_eq!(unmatched.headers()["traceparent"], INBOUND); + let problem: Value = serde_json::from_slice( + &to_bytes(unmatched.into_body(), 1024 * 1024) + .await + .expect("unmatched problem reads"), + ) + .expect("unmatched problem is JSON"); + assert_eq!(problem["traceId"], trace_id(INBOUND)); + + for _ in 0..2 { + let mut request = Request::builder() + .uri("/v1/records/public-records") + .body(Body::empty()) + .expect("list request builds"); + request + .headers_mut() + .insert("traceparent", HeaderValue::from_static(INBOUND)); + let response = app + .clone() + .oneshot(request) + .await + .expect("list request responds"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["traceparent"], INBOUND); + } + let correlations = records.correlations.lock().expect("correlation capture"); + assert_eq!(correlations.len(), 2); + assert_ne!(correlations[0].0, correlations[1].0); + assert_eq!(correlations[0].1, trace_id(INBOUND)); + assert_eq!(correlations[1].1, trace_id(INBOUND)); +} + +fn trace_id(traceparent: &str) -> &str { + traceparent + .split('-') + .nth(1) + .expect("canonical traceparent carries trace ID") +} + +fn assert_canonical_server_trace(traceparent: &str) { + assert_eq!(traceparent.len(), 55); + assert!(traceparent.starts_with("00-")); + assert!(traceparent.is_ascii()); + assert_ne!(trace_id(traceparent), "00000000000000000000000000000000"); } #[test] @@ -188,6 +378,80 @@ impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturedOperation } } +#[tokio::test(flavor = "current_thread")] +async fn request_operational_log_has_only_closed_value_free_fields() { + const INBOUND: &str = "00-11111111111111111111111111111111-2222222222222222-01"; + let writer = CapturedOperationalLogs::default(); + let subscriber = tracing_subscriber::fmt() + .json() + .with_target(false) + .with_current_span(false) + .with_span_list(false) + .with_writer(writer.clone()) + .finish(); + let _subscriber = tracing::subscriber::set_default(subscriber); + let service = Arc::new(HttpService::new( + compiled_registry(), + ReadRuntimeIdentity { + package_revision: "package-startup-http".to_owned(), + schema_fingerprint: "schema-startup-http".to_owned(), + }, + Arc::new(NoopRecords::default()), + Arc::new(SlowReadiness), + Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x45; 32]), Duration::from_secs(300)) + .expect("test cursor key is valid"), + ), + )); + let mut request = Request::builder() + .uri(format!("/health?private={QUERY_VALUE_CANARY}")) + .body(Body::empty()) + .expect("request builds"); + request + .headers_mut() + .insert("traceparent", HeaderValue::from_static(INBOUND)); + request.headers_mut().insert( + "authorization", + HeaderValue::from_static("Bearer operational-log-token-canary"), + ); + let response = router(service) + .oneshot(request) + .await + .expect("request responds"); + assert_eq!(response.status(), StatusCode::OK); + + let output = writer.text(); + assert_forbidden_values_absent(&output); + assert!(!output.contains("operational-log-token-canary")); + assert!(!output.contains("/health")); + let rendered: Value = serde_json::from_str(output.trim()).expect("request log is JSON"); + let fields = rendered["fields"] + .as_object() + .expect("request log fields are an object"); + assert_eq!( + fields.keys().map(String::as_str).collect::>(), + BTreeSet::from([ + "duration_ms", + "message", + "method", + "problem_code", + "request_id", + "status", + "trace_id", + ]) + ); + assert_eq!(fields["method"], "GET"); + assert_eq!(fields["status"], "success"); + assert_eq!(fields["problem_code"], "none"); + assert_eq!(fields["trace_id"], trace_id(INBOUND)); + uuid::Uuid::parse_str( + fields["request_id"] + .as_str() + .expect("request log carries request_id"), + ) + .expect("request_id is a UUID"); +} + fn startup_errors() -> [StartupError; 12] { [ StartupError::RuntimeConfig, @@ -391,7 +655,7 @@ async fn provenance_operational_logs_metrics_and_traces_are_separate_closed_and_ package_revision: "package-startup-http".to_owned(), schema_fingerprint: "schema-startup-http".to_owned(), }, - Arc::new(NoopRecords), + Arc::new(NoopRecords::default()), Arc::new(SlowReadiness), Arc::new( CursorCodec::new(Zeroizing::new(vec![0x45; 32]), Duration::from_secs(300)) @@ -474,21 +738,23 @@ async fn provenance_operational_logs_metrics_and_traces_are_separate_closed_and_ .await .expect("canary request responds"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!(response.headers().get("traceparent").is_none()); + assert_eq!( + response.headers()["traceparent"], + "00-11111111111111111111111111111111-2222222222222222-01" + ); assert!(response.headers().get("tracestate").is_none()); let mut rendered_response = response .headers() .iter() .map(|(name, value)| format!("{}:{}\n", name, value.to_str().unwrap_or(""))) .collect::(); - rendered_response.push_str( - std::str::from_utf8( - &to_bytes(response.into_body(), 1024 * 1024) - .await - .expect("canary response reads"), - ) - .expect("canary response is UTF-8"), - ); + let response_body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("canary response reads"); + let problem: Value = serde_json::from_slice(&response_body).expect("canary problem is JSON"); + assert_eq!(problem["traceId"], "11111111111111111111111111111111"); + rendered_response + .push_str(std::str::from_utf8(&response_body).expect("canary response is UTF-8")); assert_forbidden_values_absent(&rendered_response); for uri in ["/metrics", "/v1/metrics"] { @@ -666,7 +932,9 @@ fn assert_forbidden_values_absent(text: &str) { fn canary_runtime_document() -> String { format!( - r#"telemetry: + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +telemetry: rawPrincipal: {RAW_PRINCIPAL_CANARY} recordId: {RECORD_ID_CANARY} queryValue: {QUERY_VALUE_CANARY} @@ -685,7 +953,9 @@ fn canary_runtime_document() -> String { fn runtime_without_telemetry(root: &Path) -> String { format!( - r#"listener: + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +listener: bind: 127.0.0.1:8080 trustedProxy: direct identity: diff --git a/crates/registry-server/tests/startup_ordering.rs b/crates/registry-server/tests/startup_ordering.rs index 3cec1cebe3..dbb7fdee57 100644 --- a/crates/registry-server/tests/startup_ordering.rs +++ b/crates/registry-server/tests/startup_ordering.rs @@ -84,7 +84,8 @@ impl StartupFixture { fs::write( &path, format!( - r#" + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig listener: bind: 127.0.0.1:9 trustedProxy: direct diff --git a/crates/registry-server/tests/support/pilot_acceptance_harness.rs b/crates/registry-server/tests/support/pilot_acceptance_harness.rs index a0bf0741a4..a3868df26e 100644 --- a/crates/registry-server/tests/support/pilot_acceptance_harness.rs +++ b/crates/registry-server/tests/support/pilot_acceptance_harness.rs @@ -570,7 +570,9 @@ fn write_runtime_config( fs::write( &path, format!( - r#"listener: + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +listener: bind: 127.0.0.1:9 trustedProxy: direct identity: diff --git a/crates/registry-serverctl/README.md b/crates/registry-serverctl/README.md index 5fd4ba7b82..0035338db7 100644 --- a/crates/registry-serverctl/README.md +++ b/crates/registry-serverctl/README.md @@ -8,6 +8,16 @@ rather than defining parallel semantics. AI-assisted tools may invoke this CLI, but receive no separate authority to sign or apply production changes. +`registry-serverctl project lock PROJECT` computes the compiler-enforced +digests for discovered `modules//module.yaml` sources and their declared +SQL assets, then rewrites only `PROJECT/registry.yaml`. `--check` performs the +same deterministic comparison without writing and fails when locks are stale. + +`registry-serverctl explain queries PROJECT [--production]` renders query +operations using HTTP API field names as the primary copyable identifiers. +Every filterable, sortable, and selector field also includes its logical field +id so authors can trace the output back to source. + `registry-serverctl package PROJECT --database-id ID --schema-fingerprint SHA256 --output BUILD` always recompiles with the production profile. It writes the exact canonical `BUILD/signing-input.json`. diff --git a/crates/registry-serverctl/src/apply_lifecycle.rs b/crates/registry-serverctl/src/apply_lifecycle.rs index c641ee2978..48464e2bcb 100644 --- a/crates/registry-serverctl/src/apply_lifecycle.rs +++ b/crates/registry-serverctl/src/apply_lifecycle.rs @@ -16,7 +16,7 @@ use registry_server::runtime_config::{load_runtime_config, RuntimeConfigError}; #[derive(Debug)] pub(crate) enum ApplyLifecycleError { RuntimeConfigPath, - RuntimeConfig, + RuntimeConfig(RuntimeConfigError), TargetPackagePath, CurrentPackage(PackageError), TargetPackage(PackageError), @@ -53,8 +53,8 @@ pub(crate) fn run( return Err(ApplyLifecycleError::TargetPackagePath); } let backup_arguments = parse_backup_arguments(request.backups)?; - let config = load_runtime_config(request.runtime_config) - .map_err(|_error: RuntimeConfigError| ApplyLifecycleError::RuntimeConfig)?; + let config = + load_runtime_config(request.runtime_config).map_err(ApplyLifecycleError::RuntimeConfig)?; let current_package = if request.initial { None diff --git a/crates/registry-serverctl/src/data_lifecycle.rs b/crates/registry-serverctl/src/data_lifecycle.rs index f97378c5dd..9481574b04 100644 --- a/crates/registry-serverctl/src/data_lifecycle.rs +++ b/crates/registry-serverctl/src/data_lifecycle.rs @@ -863,10 +863,13 @@ mod tests { "fields": [ {"id": "code", "type": "string", "minLength": 2, "maxLength": 16, "required": true, "classification": "internal"} - ], - "accessProfiles": [{ - "id": PROFILE, - "principalClaim": "principal", + ] + }], + "accessProfiles": [{ + "id": PROFILE, + "principalClaim": "principal", + "grants": [{ + "entity": ENTITY, "operations": ["create", "batch", "list"], "readableFields": ["code"], "writableFields": ["code"], diff --git a/crates/registry-serverctl/src/lib.rs b/crates/registry-serverctl/src/lib.rs index b00b7f7028..bf808be461 100644 --- a/crates/registry-serverctl/src/lib.rs +++ b/crates/registry-serverctl/src/lib.rs @@ -14,7 +14,8 @@ use std::process::ExitCode; use std::sync::atomic::{AtomicU64, Ordering}; use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum}; -use registry_server::contract::ModuleAssetSource; +use registry_server::compiler::module_digest_with_assets; +use registry_server::contract::{FieldTypeSource, ModuleAssetSource, ModuleLockSource}; use registry_server::migration_plan::ReviewedMigrationRecovery; use registry_server::package::{ inspect_package_integrity, CompiledRegistryChangeClass, MigrationInspectionPlanKind, @@ -84,6 +85,8 @@ enum Command { Init(InitArgs), /// Validate a Registry Server authoring project without opening a database. Check(CheckArgs), + /// Maintain deterministic authoring project metadata. + Project(ProjectArgs), /// Write selected compiler artifacts to a new directory. Generate(GenerateArgs), /// Explain compiled model, access, route, or event inventories. @@ -126,6 +129,29 @@ struct CheckArgs { production: bool, } +#[derive(Debug, Args)] +struct ProjectArgs { + #[command(subcommand)] + command: ProjectCommand, +} + +#[derive(Debug, Subcommand)] +enum ProjectCommand { + /// Compute and write module source digests in registry.yaml. + Lock(ProjectLockArgs), +} + +#[derive(Debug, Args)] +struct ProjectLockArgs { + /// Registry Server project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, + + /// Refuse when registry.yaml is not already locked instead of rewriting it. + #[arg(long)] + check: bool, +} + #[derive(Debug, Args)] struct GenerateArgs { /// Artifact family to write. @@ -622,6 +648,7 @@ enum SuggestedAction { SelectAvailableArtifact, RetryArtifactGeneration, RetryInventoryExplanation, + UpdateModuleLocks, CorrectRuntimeConfiguration, VerifyPackagePath, VerifyPackagePermissions, @@ -994,6 +1021,9 @@ where let result = match cli.command { Command::Init(args) => init(&args.destination), Command::Check(args) => check(&args.project, profile(args.production)), + Command::Project(args) => match args.command { + ProjectCommand::Lock(args) => project_lock(&args.project, args.check), + }, Command::Generate(args) => generate( args.artifact, &args.project, @@ -1710,6 +1740,12 @@ fn package_lifecycle_failure(error: PackageLifecycleError) -> FailureReport { } fn test_lifecycle_failure(error: TestLifecycleError) -> FailureReport { + let error = match error { + TestLifecycleError::RuntimeConfig(error) => { + return runtime_config_failure("test", "test", error) + } + error => error, + }; let (code, path, message, artifact, action) = match error { TestLifecycleError::RuntimeConfigPath => ( "test.runtime_config.path_invalid", @@ -1718,20 +1754,7 @@ fn test_lifecycle_failure(error: TestLifecycleError) -> FailureReport { DiagnosticArtifact::RuntimeConfiguration, SuggestedAction::CorrectRuntimeConfiguration, ), - TestLifecycleError::RuntimeConfig(RuntimeConfigError::UnsafeFile) => ( - "test.runtime_config.path_invalid", - "runtimeConfig", - "the runtime configuration path is unsafe", - DiagnosticArtifact::RuntimeConfiguration, - SuggestedAction::CorrectRuntimeConfiguration, - ), - TestLifecycleError::RuntimeConfig(_) => ( - "test.runtime_config.refused", - "runtimeConfig", - "the runtime configuration was refused", - DiagnosticArtifact::RuntimeConfiguration, - SuggestedAction::CorrectRuntimeConfiguration, - ), + TestLifecycleError::RuntimeConfig(_) => unreachable!("handled before match"), TestLifecycleError::Candidate => ( "test.candidate.refused", "candidate", @@ -1801,6 +1824,12 @@ fn test_lifecycle_failure(error: TestLifecycleError) -> FailureReport { } fn apply_lifecycle_failure(error: ApplyLifecycleError) -> FailureReport { + let error = match error { + ApplyLifecycleError::RuntimeConfig(error) => { + return runtime_config_failure("apply", "apply", error); + } + error => error, + }; let (code, path, message, artifact, action) = match error { ApplyLifecycleError::RuntimeConfigPath => ( "apply.runtime_config.path_invalid", @@ -1809,13 +1838,7 @@ fn apply_lifecycle_failure(error: ApplyLifecycleError) -> FailureReport { DiagnosticArtifact::RuntimeConfiguration, SuggestedAction::CorrectRuntimeConfiguration, ), - ApplyLifecycleError::RuntimeConfig => ( - "apply.runtime_config.refused", - "runtimeConfig", - "the runtime configuration was refused", - DiagnosticArtifact::RuntimeConfiguration, - SuggestedAction::CorrectRuntimeConfiguration, - ), + ApplyLifecycleError::RuntimeConfig(_) => unreachable!("handled before match"), ApplyLifecycleError::TargetPackagePath => ( "apply.package.path_invalid", "package", @@ -2014,6 +2037,9 @@ fn inspection_failure( prefix: &'static str, error: RuntimePackageInspectionError, ) -> FailureReport { + if let RuntimePackageInspectionError::RuntimeConfig(error) = error { + return runtime_config_failure(command, prefix, error); + } let (code, path, message, artifact, action) = match error { RuntimePackageInspectionError::RuntimeConfigPath => ( format!("{prefix}.runtime_config.path_invalid"), @@ -2022,20 +2048,7 @@ fn inspection_failure( DiagnosticArtifact::RuntimeConfiguration, SuggestedAction::CorrectRuntimeConfiguration, ), - RuntimePackageInspectionError::RuntimeConfig(RuntimeConfigError::UnsafeFile) => ( - format!("{prefix}.runtime_config.path_invalid"), - "runtimeConfig", - "the runtime configuration path is unsafe", - DiagnosticArtifact::RuntimeConfiguration, - SuggestedAction::CorrectRuntimeConfiguration, - ), - RuntimePackageInspectionError::RuntimeConfig(_) => ( - format!("{prefix}.runtime_config.refused"), - "runtimeConfig", - "the runtime configuration was refused", - DiagnosticArtifact::RuntimeConfiguration, - SuggestedAction::CorrectRuntimeConfiguration, - ), + RuntimePackageInspectionError::RuntimeConfig(_) => unreachable!("handled before match"), RuntimePackageInspectionError::Package(error) => { let (suffix, action) = match error { PackageError::UnsafePath => ("path_refused", SuggestedAction::VerifyPackagePath), @@ -2079,17 +2092,39 @@ fn inspection_failure( } fn runtime_config_diff_failure(error: RuntimeConfigError) -> FailureReport { - match error { - RuntimeConfigError::UnsafeFile => diff_failure( - "diff.runtime_config.path_invalid", - "runtimeConfig", - "the runtime configuration path is unsafe", - ), - _ => diff_failure( - "diff.runtime_config.refused", - "runtimeConfig", - "the runtime configuration was refused", - ), + let detail = runtime_config_diagnostic("diff", error); + diff_failure(&detail.code, detail.path, &detail.message) +} + +struct RuntimeConfigDiagnostic { + code: String, + path: &'static str, + message: String, +} + +fn runtime_config_diagnostic(prefix: &str, error: RuntimeConfigError) -> RuntimeConfigDiagnostic { + let metadata = error.metadata(); + RuntimeConfigDiagnostic { + code: format!("{prefix}.{}", metadata.code()), + path: metadata.path(), + message: error.to_string(), + } +} + +fn runtime_config_failure( + command: &'static str, + prefix: &str, + error: RuntimeConfigError, +) -> FailureReport { + let detail = runtime_config_diagnostic(prefix, error); + FailureReport { + ok: false, + command, + diagnostics: vec![tool_diagnostic( + diagnostic(&detail.code, detail.path, &detail.message), + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + )], } } @@ -2240,6 +2275,114 @@ fn check(project_path: &Path, profile: ProfileArg) -> Result Result { + let mut source = capture_project_source_for_lock(project_path).map_err(|diagnostic| { + source_failure( + "project lock", + diagnostic, + DiagnosticArtifact::RegistryProject, + SuggestedAction::CorrectAuthoringSource, + ) + })?; + let current_locks = source + .project + .modules + .iter() + .map(|lock| (lock.id.as_str(), lock)) + .collect::>(); + let mut next_locks = Vec::new(); + let mut reports = Vec::new(); + for module in &source.modules { + let assets = module + .assets + .iter() + .map(|asset| ModuleAssetSource { + module: Some(module.id.clone()), + path: asset.path.clone(), + bytes: asset.bytes.clone(), + }) + .collect::>(); + let digest = module_digest_with_assets(&module.module, &assets); + let status = match current_locks.get(module.id.as_str()) { + Some(lock) + if lock.version == module.module.version + && lock.digest.as_ref() == Some(&digest) => + { + "unchanged" + } + Some(_) => "updated", + None => "added", + }; + next_locks.push(ModuleLockSource { + id: module.id.clone(), + version: module.module.version.clone(), + digest: Some(digest.clone()), + }); + reports.push(json!({ + "id": &module.id, + "version": &module.module.version, + "digest": digest, + "status": status, + })); + } + next_locks.sort_by(|left, right| left.id.cmp(&right.id)); + let changed = source.project.modules != next_locks; + if check_only && changed { + return Err(FailureReport { + ok: false, + command: "project lock", + diagnostics: vec![tool_diagnostic( + diagnostic( + "module.lock.stale", + "project.modules", + "the project module locks are not up to date", + ), + DiagnosticArtifact::RegistryProject, + SuggestedAction::UpdateModuleLocks, + )], + }); + } + let artifacts = if changed { + source.project.modules = next_locks; + let updated = + render_project_with_module_locks(&source.project_bytes, &source.project.modules) + .map_err(|diagnostic| { + source_failure( + "project lock", + diagnostic, + DiagnosticArtifact::RegistryProject, + SuggestedAction::UpdateModuleLocks, + ) + })?; + write_project_registry(project_path, &source.project_bytes, &updated).map_err( + |diagnostic| { + source_failure( + "project lock", + diagnostic, + DiagnosticArtifact::RegistryProject, + SuggestedAction::UpdateModuleLocks, + ) + }, + )?; + vec![artifact_report("registry.yaml", "text/yaml", &updated)] + } else { + Vec::new() + }; + let compiled = compile(project_path, ProfileArg::Authoring, "project lock")?; + Ok(SuccessReport { + ok: true, + command: "project lock", + profile: ProfileArg::Authoring, + revision: compiled.revision().to_owned(), + findings: compiler_findings(&compiled), + artifacts, + explanation: Some(json!({ + "changed": changed, + "modules": reports, + })), + }) +} + fn generate( selector: ArtifactSelector, project_path: &Path, @@ -2431,6 +2574,64 @@ fn capture_project_source(project_path: &Path) -> Result Result { + validate_project_directory(project_path)?; + let project_bytes = read_bounded_regular_file( + &project_path.join("registry.yaml"), + "source.project.missing", + AUTHORED_SOURCE_REDERIVATION_MAX_BYTES, + )?; + let project = parse_project_yaml(&project_bytes).map_err(first_diagnostic)?; + let mut locked = BTreeSet::new(); + for lock in &project.modules { + if !locked.insert(lock.id.as_str()) { + return Err(diagnostic( + "module.lock.duplicate", + "project.modules", + "module lock identifiers must be unique", + )); + } + } + let modules = discover_module_files(project_path)? + .into_iter() + .map(|(directory_id, bytes)| { + let module = parse_module_yaml(&bytes).map_err(first_diagnostic)?; + if module.id != directory_id { + return Err(diagnostic( + "source.module.id_mismatch", + &format!("modules/{directory_id}/module.yaml"), + "the module source id must match its directory name", + )); + } + let assets = load_module_asset_files(project_path, &directory_id, &module)?; + Ok(CapturedModuleSource { + id: directory_id, + module, + bytes, + assets, + }) + }) + .collect::, Diagnostic>>()?; + let discovered = modules + .iter() + .map(|module| module.id.as_str()) + .collect::>(); + if locked.iter().any(|id| !discovered.contains(id)) { + return Err(diagnostic( + "module.lock.source_missing", + "project.modules", + "every module lock must have a discovered module source", + )); + } + Ok(CapturedProjectSource { + project, + project_bytes, + modules, + }) +} + fn load_module_files( project_path: &Path, project: &RegistryProject, @@ -2517,6 +2718,75 @@ fn load_module_files( .collect() } +fn discover_module_files(project_path: &Path) -> Result)>, Diagnostic> { + let modules_directory = project_path.join("modules"); + match fs::symlink_metadata(&modules_directory) { + Ok(_) => validate_directory(&modules_directory, "source.modules.invalid")?, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(_) => { + return Err(diagnostic( + "source.modules.unreadable", + "modules", + "module sources cannot be read", + )); + } + } + let mut module_paths = Vec::new(); + for entry in fs::read_dir(&modules_directory).map_err(|_| { + diagnostic( + "source.modules.unreadable", + "modules", + "module sources cannot be read", + ) + })? { + let entry = entry.map_err(|_| { + diagnostic( + "source.modules.unreadable", + "modules", + "module sources cannot be read", + ) + })?; + let file_type = entry.file_type().map_err(|_| { + diagnostic( + "source.modules.unreadable", + "modules", + "module sources cannot be read", + ) + })?; + if entry.file_name() == ".DS_Store" && file_type.is_file() { + continue; + } + if file_type.is_symlink() || !file_type.is_dir() { + return Err(diagnostic( + "source.modules.invalid", + "modules", + "module sources must be directories and must not be symbolic links", + )); + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + return Err(diagnostic( + "source.modules.invalid", + "modules", + "module source names must be valid UTF-8 identifiers", + )); + }; + module_paths.push((name.to_owned(), entry.path().join("module.yaml"))); + } + module_paths.sort_by(|left, right| left.0.cmp(&right.0)); + module_paths + .into_iter() + .map(|(id, path)| { + let bytes = read_bounded_regular_file( + &path, + "source.module.missing", + AUTHORED_SOURCE_REDERIVATION_MAX_BYTES, + )?; + Ok((id, bytes)) + }) + .collect() +} + fn load_module_asset_files( project_path: &Path, module_id: &str, @@ -2617,21 +2887,6 @@ registry: id: generic-registry version: 0.1.0 defaultLanguage: en -manifestProjection: - accessProfile: operator - classificationCeiling: internal - catalog: - baseUrl: https://registry.example.test - title: Generic Registry Catalog - publisher: - name: Registry Operator - dataset: - title: Generic Registry Dataset - owner: Registry Operator - status: active -modules: - - id: core - version: 0.1.0 entities: - id: record route: records @@ -2653,19 +2908,13 @@ entities: accessProfiles: - id: operator principalClaim: registry_principal - purposes: [registry-operations] + requiredScopes: [registry:generic:operate] + requiredPurposes: [registry-operations] grants: - entity: record - actions: [create, get, list, patch] + operations: [create, get, list, patch] readableFields: [code, label] writableFields: [code, label] -"# - .to_vec(), - ), - ( - "modules/core/module.yaml".to_owned(), - br#"id: core -version: 0.1.0 "# .to_vec(), ), @@ -2679,7 +2928,8 @@ journeys: entity: record accessProfile: operator claims: &operator_claims - principal: fixture-operator + principal: generic-registry-operator + scopes: [registry:generic:operate] purpose: registry-operations request: operation: create @@ -2710,6 +2960,178 @@ journeys: ]) } +fn render_project_with_module_locks( + original: &[u8], + locks: &[ModuleLockSource], +) -> Result, Diagnostic> { + let original = std::str::from_utf8(original).map_err(|_| { + diagnostic( + "module.lock.render_failed", + "registry.yaml", + "the project module locks could not be rendered", + ) + })?; + let mut rendered = replace_top_level_modules_block(original, &module_locks_yaml(locks)); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + parse_project_yaml(rendered.as_bytes()).map_err(|_| { + diagnostic( + "module.lock.render_failed", + "registry.yaml", + "the project module locks could not be rendered", + ) + })?; + Ok(rendered.into_bytes()) +} + +fn replace_top_level_modules_block(source: &str, replacement: &str) -> String { + let lines = source.split_inclusive('\n').collect::>(); + let start = lines + .iter() + .position(|line| top_level_key(line) == Some("modules")); + let Some(start) = start else { + let mut rendered = source.trim_end_matches('\n').to_owned(); + if !rendered.is_empty() { + rendered.push_str("\n\n"); + } + rendered.push_str(replacement); + return rendered; + }; + let end = lines + .iter() + .enumerate() + .skip(start + 1) + .find(|(_, line)| top_level_key(line).is_some()) + .map(|(index, _)| index) + .unwrap_or(lines.len()); + let mut rendered = String::new(); + rendered.push_str(&lines[..start].concat()); + rendered.push_str(replacement); + if end < lines.len() { + if !rendered.ends_with("\n\n") { + rendered.push('\n'); + } + rendered.push_str(&lines[end..].concat()); + } + rendered +} + +fn top_level_key(line: &str) -> Option<&str> { + if line.starts_with(char::is_whitespace) || line.starts_with('#') { + return None; + } + let trimmed = line.trim_end(); + let (key, _) = trimmed.split_once(':')?; + if key.is_empty() + || key + .bytes() + .any(|byte| !(byte.is_ascii_alphanumeric() || byte == b'_')) + { + return None; + } + Some(key) +} + +fn module_locks_yaml(locks: &[ModuleLockSource]) -> String { + let mut rendered = String::from("modules:\n"); + for lock in locks { + rendered.push_str(" - id: "); + rendered.push_str(&yaml_string(&lock.id)); + rendered.push_str("\n version: "); + rendered.push_str(&yaml_string(&lock.version)); + rendered.push_str("\n digest: "); + rendered.push_str(&yaml_string( + lock.digest + .as_deref() + .expect("project lock always writes module digests"), + )); + rendered.push('\n'); + } + rendered +} + +fn yaml_string(value: &str) -> String { + serde_json::to_string(value).expect("string serialization cannot fail") +} + +fn write_project_registry( + project_path: &Path, + original: &[u8], + updated: &[u8], +) -> Result<(), Diagnostic> { + let registry_path = project_path.join("registry.yaml"); + let current = read_bounded_regular_file( + ®istry_path, + "source.project.missing", + AUTHORED_SOURCE_REDERIVATION_MAX_BYTES, + )?; + if current != original { + return Err(diagnostic( + "module.lock.concurrent_change", + "registry.yaml", + "the project source changed before module locks could be written", + )); + } + let parent = registry_path.parent().ok_or_else(|| { + diagnostic( + "module.lock.write_failed", + "registry.yaml", + "the project module locks could not be written", + ) + })?; + validate_directory_for( + parent, + "module.lock.write_failed", + "registry.yaml", + "the project directory is not available", + "the project directory must be a directory and must not be a symbolic link", + )?; + let temporary = parent.join(format!( + ".registry-serverctl-lock-{}-{}.tmp", + std::process::id(), + STAGING_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let write_result = (|| { + let mut file = File::options() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|_| { + diagnostic( + "module.lock.write_failed", + "registry.yaml", + "the project module locks could not be written", + ) + })?; + file.write_all(updated).map_err(|_| { + diagnostic( + "module.lock.write_failed", + "registry.yaml", + "the project module locks could not be written", + ) + })?; + file.sync_all().map_err(|_| { + diagnostic( + "module.lock.write_failed", + "registry.yaml", + "the project module locks could not be written", + ) + })?; + fs::rename(&temporary, ®istry_path).map_err(|_| { + diagnostic( + "module.lock.write_failed", + "registry.yaml", + "the project module locks could not be written", + ) + }) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&temporary); + } + write_result +} + fn selected_artifacts( artifacts: &GeneratedArtifacts, selector: ArtifactSelector, @@ -2784,20 +3206,67 @@ fn explain_queries(compiled: &CompiledRegistry) -> serde_json::Result { .projection_fields .iter() .filter_map(|field_id| { - query_field_summary( - field_id, - entity - .stored_fields - .iter() - .find(|field| field.logical.id == *field_id) - .map(|field| (&field.logical.api_name, "stored")) - .or_else(|| { - entity - .derived_fields - .get(field_id) - .map(|field| (&field.logical.api_name, "derived")) - }), - ) + query_field_summary(field_id, query_field_identity(entity, field_id)) + }) + .collect::>() + }) + .unwrap_or_default(); + let filterable = entity + .map(|entity| { + operation + .filter_fields + .iter() + .filter_map(|field| { + let identity = query_field_summary( + &field.field, + query_field_identity(entity, &field.field), + )?; + Some(json!({ + "apiName": identity["apiName"], + "field": &field.field, + "fieldType": identity["fieldType"], + "operators": &field.operators, + "wireOperators": wire_filter_operators(&field.operators), + "examples": filter_examples( + identity["apiName"].as_str().expect("api name is a string"), + query_field_identity(entity, &field.field) + .expect("field identity was already resolved") + .field_type, + &field.operators, + ), + })) + }) + .collect::>() + }) + .unwrap_or_default(); + let sortable = entity + .map(|entity| { + operation + .sort_fields + .iter() + .filter_map(|field| { + let identity = query_field_summary( + &field.field, + query_field_identity(entity, &field.field), + )?; + Some(json!({ + "apiName": identity["apiName"], + "field": &field.field, + "fieldType": identity["fieldType"], + "directions": &field.directions, + "examples": [format!("$orderby={}", identity["apiName"].as_str().expect("api name is a string"))], + })) + }) + .collect::>() + }) + .unwrap_or_default(); + let selectors = entity + .map(|entity| { + operation + .selector_fields + .iter() + .filter_map(|field| { + query_field_summary(field, query_field_identity(entity, field)) }) .collect::>() }) @@ -2809,13 +3278,30 @@ fn explain_queries(compiled: &CompiledRegistry) -> serde_json::Result { "entity": operation.entity_id, "kind": operation.kind, "apiFields": api_fields, - "filterable": operation.filter_fields, - "sortable": operation.sort_fields, + "filterable": filterable, + "sortable": sortable, "allowCount": operation.allow_count, - "selectors": operation.selector_fields, + "selectors": selectors, "readPath": operation.read_path, + "wire": { + "select": "$select", + "filter": "$filter", + "orderBy": "$orderby", + "pageSize": "$top", + "count": "$count", + "cursor": "$skiptoken", + "accessProfile": "accessProfile", + "asOf": "asOf", + }, "bounds": { - "maxPageSize": operation.max_page_size + "maxPageSize": operation.max_page_size, + "maxTop": registry_server::query::MAX_TOP, + "maxSelectedFields": registry_server::query::MAX_SELECTED_FIELDS, + "maxFilterPayloadBytes": registry_server::query::MAX_QUERY_PAYLOAD_BYTES, + "maxFilterDepth": registry_server::query::MAX_FILTER_DEPTH, + "maxFilterNodes": registry_server::query::MAX_FILTER_NODES, + "maxFilterPredicates": registry_server::query::MAX_FILTER_PREDICATES, + "maxInValues": registry_server::query::MAX_IN_VALUES, } }) }) @@ -2823,15 +3309,276 @@ fn explain_queries(compiled: &CompiledRegistry) -> serde_json::Result { serde_json::to_value(json!({ "operations": operations })) } -fn query_field_summary(field_id: &str, resolved: Option<(&String, &str)>) -> Option { - let (api_name, source_kind) = resolved?; +fn query_field_identity<'a>( + entity: &'a registry_server::model::CompiledEntity, + field_id: &str, +) -> Option> { + entity + .stored_fields + .iter() + .find(|field| field.logical.id == field_id) + .map(|field| QueryFieldIdentity { + api_name: &field.logical.api_name, + source_kind: "stored", + field_type: &field.logical.field_type, + }) + .or_else(|| { + entity + .derived_fields + .get(field_id) + .map(|field| QueryFieldIdentity { + api_name: &field.logical.api_name, + source_kind: "derived", + field_type: &field.logical.field_type, + }) + }) +} + +struct QueryFieldIdentity<'a> { + api_name: &'a str, + source_kind: &'static str, + field_type: &'a FieldTypeSource, +} + +fn query_field_summary(field_id: &str, resolved: Option>) -> Option { + let resolved = resolved?; Some(json!({ "field": field_id, - "apiName": api_name, - "sourceKind": source_kind, + "apiName": resolved.api_name, + "sourceKind": resolved.source_kind, + "fieldType": resolved.field_type, })) } +fn wire_filter_operators( + operators: &[registry_server::model::CompiledQueryFilterOperator], +) -> Vec<&'static str> { + let mut wire = BTreeSet::new(); + for operator in operators { + match operator { + registry_server::model::CompiledQueryFilterOperator::Equals => { + wire.insert("eq"); + wire.insert("ne"); + } + registry_server::model::CompiledQueryFilterOperator::In => { + wire.insert("in"); + } + registry_server::model::CompiledQueryFilterOperator::Range => { + wire.insert("ge"); + wire.insert("gt"); + wire.insert("le"); + wire.insert("lt"); + } + registry_server::model::CompiledQueryFilterOperator::IsNull => { + wire.insert("eq null"); + } + registry_server::model::CompiledQueryFilterOperator::IsNotNull => { + wire.insert("ne null"); + } + registry_server::model::CompiledQueryFilterOperator::Prefix => { + wire.insert("startswith"); + } + registry_server::model::CompiledQueryFilterOperator::Contains => { + wire.insert("contains"); + } + } + } + wire.into_iter().collect() +} + +fn filter_examples( + api_name: &str, + field_type: &FieldTypeSource, + operators: &[registry_server::model::CompiledQueryFilterOperator], +) -> Vec { + operators + .iter() + .filter_map(|operator| filter_example(api_name, field_type, *operator)) + .collect() +} + +fn filter_example( + api_name: &str, + field_type: &FieldTypeSource, + operator: registry_server::model::CompiledQueryFilterOperator, +) -> Option { + match operator { + registry_server::model::CompiledQueryFilterOperator::Equals => { + let first = filter_literal(field_type)?; + Some(format!("$filter={api_name} eq {first}")) + } + registry_server::model::CompiledQueryFilterOperator::In => { + let first = filter_literal(field_type)?; + let second = alternate_filter_literal(field_type)?; + Some(format!("$filter={api_name} in ({first},{second})")) + } + registry_server::model::CompiledQueryFilterOperator::Range => { + let first = filter_literal(field_type)?; + Some(format!("$filter={api_name} ge {first}")) + } + registry_server::model::CompiledQueryFilterOperator::IsNull => { + Some(format!("$filter={api_name} eq null")) + } + registry_server::model::CompiledQueryFilterOperator::IsNotNull => { + Some(format!("$filter={api_name} ne null")) + } + registry_server::model::CompiledQueryFilterOperator::Prefix => { + let first = filter_literal(field_type)?; + Some(format!("$filter=startswith({api_name},{first})")) + } + registry_server::model::CompiledQueryFilterOperator::Contains => { + let first = filter_literal(field_type)?; + Some(format!("$filter=contains({api_name},{first})")) + } + } +} + +fn filter_literal(field_type: &FieldTypeSource) -> Option { + match field_type { + FieldTypeSource::Boolean => Some("true".to_owned()), + FieldTypeSource::String { + min_length, + max_length, + } => quoted_example_string(*min_length, *max_length), + FieldTypeSource::Text { max_length } => quoted_example_string(0, *max_length), + FieldTypeSource::Int64 => Some("1".to_owned()), + FieldTypeSource::Decimal { + precision, + scale, + minimum, + maximum, + } => decimal_example_literal(*precision, *scale, minimum.as_deref(), maximum.as_deref()), + FieldTypeSource::Date => Some("'2026-01-02'".to_owned()), + FieldTypeSource::Timestamp => Some("'2026-01-02T03:04:05Z'".to_owned()), + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => { + Some("'00000000-0000-4000-8000-000000000000'".to_owned()) + } + FieldTypeSource::VocabularyCode { values, .. } => { + values.first().map(|value| quote_filter_string(value)) + } + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => None, + } +} + +fn alternate_filter_literal(field_type: &FieldTypeSource) -> Option { + match field_type { + FieldTypeSource::Boolean => Some("false".to_owned()), + FieldTypeSource::String { + min_length, + max_length, + } => quoted_alternate_string(*min_length, *max_length), + FieldTypeSource::Text { max_length } => quoted_alternate_string(0, *max_length), + FieldTypeSource::Int64 => Some("2".to_owned()), + FieldTypeSource::Decimal { + precision, + scale, + minimum, + maximum, + } => decimal_alternate_literal(*precision, *scale, minimum.as_deref(), maximum.as_deref()), + FieldTypeSource::Date => Some("'2026-01-03'".to_owned()), + FieldTypeSource::Timestamp => Some("'2026-01-02T03:04:06Z'".to_owned()), + FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => { + Some("'00000000-0000-4000-8000-000000000001'".to_owned()) + } + FieldTypeSource::VocabularyCode { values, .. } => { + values.get(1).map(|value| quote_filter_string(value)) + } + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => None, + } +} + +fn quoted_example_string(min_length: u32, max_length: u32) -> Option { + if max_length == 0 { + return Some("''".to_owned()); + } + if min_length <= 7 && max_length >= 7 { + return Some("'example'".to_owned()); + } + let length = usize::try_from(min_length.max(1).min(max_length)).ok()?; + Some(quote_filter_string(&"a".repeat(length))) +} + +fn quoted_alternate_string(min_length: u32, max_length: u32) -> Option { + if min_length <= 6 && max_length >= 6 { + return Some("'sample'".to_owned()); + } + if max_length == 0 { + return None; + } + let length = usize::try_from(min_length.max(1).min(max_length)).ok()?; + Some(quote_filter_string(&"b".repeat(length))) +} + +fn quote_filter_string(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn decimal_example_literal( + precision: u8, + scale: u8, + minimum: Option<&str>, + maximum: Option<&str>, +) -> Option { + if let Some(minimum) = minimum { + return Some(minimum.to_owned()); + } + let zero = zero_decimal_literal(precision, scale)?; + match maximum { + Some(maximum) + if decimal_literal_order(maximum, &zero) == Some(std::cmp::Ordering::Less) => + { + Some(maximum.to_owned()) + } + _ => Some(zero), + } +} + +fn decimal_alternate_literal( + precision: u8, + scale: u8, + minimum: Option<&str>, + maximum: Option<&str>, +) -> Option { + let first = decimal_example_literal(precision, scale, minimum, maximum)?; + let candidate = decimal_one_literal(precision, scale)?; + if Some(std::cmp::Ordering::Greater) == decimal_literal_order(&candidate, &first) + && maximum.is_none_or(|maximum| { + decimal_literal_order(&candidate, maximum) != Some(std::cmp::Ordering::Greater) + }) + { + return Some(candidate); + } + None +} + +fn zero_decimal_literal(precision: u8, scale: u8) -> Option { + if !(1..=38).contains(&precision) || scale > precision { + return None; + } + Some(if scale == 0 { + "0".to_owned() + } else { + format!("0.{}", "0".repeat(usize::from(scale))) + }) +} + +fn decimal_one_literal(precision: u8, scale: u8) -> Option { + if !(1..=38).contains(&precision) || scale > precision || precision == scale { + return None; + } + Some(if scale == 0 { + "1".to_owned() + } else { + format!("1.{}", "0".repeat(usize::from(scale))) + }) +} + +fn decimal_literal_order(left: &str, right: &str) -> Option { + let left = left.parse::().ok()?; + let right = right.parse::().ok()?; + left.partial_cmp(&right) +} + fn validate_project_directory(project_path: &Path) -> Result<(), Diagnostic> { if project_path.as_os_str().is_empty() || has_parent_component(project_path) { return Err(diagnostic( @@ -3966,6 +4713,7 @@ mod tests { [ "init", "check", + "project", "generate", "explain", "diff", @@ -4061,10 +4809,10 @@ entities: accessProfiles: - id: operator principalClaim: registry_principal - purposes: [operations] + requiredPurposes: [operations] grants: - entity: record - actions: [create, get, list, patch] + operations: [create, get, list, patch] readableFields: [code] writableFields: [code] "#, diff --git a/crates/registry-serverctl/tests/cli.rs b/crates/registry-serverctl/tests/cli.rs index 9e943c1672..8250e29916 100644 --- a/crates/registry-serverctl/tests/cli.rs +++ b/crates/registry-serverctl/tests/cli.rs @@ -590,28 +590,27 @@ fn init_creates_a_domain_neutral_project_that_checks_immediately() { assert!(output.status.success(), "{output:?}"); assert!(destination.join("registry.yaml").is_file()); - assert!(destination.join("modules/core/module.yaml").is_file()); + assert!(!destination.join("modules").exists()); assert!(destination.join("tests/journeys.yaml").is_file()); + let registry = + fs::read_to_string(destination.join("registry.yaml")).expect("initialized project reads"); + assert!(!registry.contains("manifestProjection")); + assert!(!registry.contains("modules:")); let journeys = fs::read_to_string(destination.join("tests/journeys.yaml")) .expect("initialized fixture journeys read"); assert!(journeys.contains("entity: record")); assert!(journeys.contains("accessProfile: operator")); + assert!(journeys.contains("scopes: [registry:generic:operate]")); assert!(journeys.contains("purpose: registry-operations")); assert!(!journeys.contains("token")); let initialized_project = parse_project_yaml( - &fs::read(destination.join("registry.yaml")).expect("initialized project reads"), + &fs::read(destination.join("registry.yaml")).expect("initialized project bytes read"), ) .expect("initialized project parses"); - let initialized_module = parse_module_yaml( - &fs::read(destination.join("modules/core/module.yaml")).expect("initialized module reads"), - ) - .expect("initialized module parses"); - let compiled = compile_project( - &initialized_project, - &[initialized_module], - CompileProfile::Authoring, - ) - .expect("initialized project compiles"); + assert!(initialized_project.manifest_projection.is_none()); + assert!(initialized_project.modules.is_empty()); + let compiled = compile_project(&initialized_project, &[], CompileProfile::Authoring) + .expect("initialized project compiles"); validate_fixture_journeys(journeys.as_bytes(), &compiled) .expect("initialized fixture journeys resolve against the compiled project"); let report = json_stdout(&output); @@ -633,6 +632,152 @@ fn init_creates_a_domain_neutral_project_that_checks_immediately() { assert_eq!(json_stdout(&check)["ok"], true); } +#[test] +fn project_lock_writes_module_digests_and_is_idempotent() { + let project = TestProject::from_registry_source(modular_project_without_locks()); + let module_directory = project.path().join("modules/core"); + fs::create_dir_all(&module_directory).expect("module directory creates"); + fs::write( + module_directory.join("module.yaml"), + modular_project_module(), + ) + .expect("module source writes"); + let module = parse_module_yaml(modular_project_module()).expect("module parses"); + let expected_digest = module_digest(&module); + + let locked = registry_serverctl(&["--format", "json", "project", "lock", path(project.path())]); + + assert!(locked.status.success(), "{locked:?}"); + assert!(locked.stderr.is_empty()); + let report = json_stdout(&locked); + assert_eq!(report["command"], "project lock"); + assert_eq!(report["explanation"]["changed"], true); + assert_eq!(report["explanation"]["modules"][0]["id"], "core"); + assert_eq!(report["explanation"]["modules"][0]["status"], "added"); + assert_eq!( + report["explanation"]["modules"][0]["digest"], + expected_digest + ); + assert_eq!(report["artifacts"][0]["path"], "registry.yaml"); + let project_source = fs::read(project.path().join("registry.yaml")).expect("project reads"); + let parsed = parse_project_yaml(&project_source).expect("locked project parses"); + assert_eq!(parsed.modules.len(), 1); + assert_eq!(parsed.modules[0].id, "core"); + assert_eq!(parsed.modules[0].version, "1"); + assert_eq!( + parsed.modules[0].digest.as_deref(), + Some(expected_digest.as_str()) + ); + + let check = registry_serverctl(&["--format", "json", "check", path(project.path())]); + assert!(check.status.success(), "{check:?}"); + + let second = registry_serverctl(&["--format", "json", "project", "lock", path(project.path())]); + assert!(second.status.success(), "{second:?}"); + let second_report = json_stdout(&second); + assert_eq!(second_report["explanation"]["changed"], false); + assert_eq!( + second_report["explanation"]["modules"][0]["status"], + "unchanged" + ); + assert!(second_report.get("artifacts").is_none()); + + let check_only = registry_serverctl(&[ + "--format", + "json", + "project", + "lock", + path(project.path()), + "--check", + ]); + assert!(check_only.status.success(), "{check_only:?}"); + assert_eq!(json_stdout(&check_only)["explanation"]["changed"], false); +} + +#[test] +fn project_lock_check_refuses_stale_digest_without_rewriting() { + let project = TestProject::from_registry_source(modular_project_without_locks()); + let module_directory = project.path().join("modules/core"); + fs::create_dir_all(&module_directory).expect("module directory creates"); + fs::write( + module_directory.join("module.yaml"), + modular_project_module(), + ) + .expect("module source writes"); + let locked = registry_serverctl(&["--format", "json", "project", "lock", path(project.path())]); + assert!(locked.status.success(), "{locked:?}"); + let locked_project = fs::read(project.path().join("registry.yaml")).expect("project reads"); + + fs::write( + module_directory.join("module.yaml"), + String::from_utf8(modular_project_module().to_vec()) + .expect("module is UTF-8") + .replace("maxLength: 16", "maxLength: 17"), + ) + .expect("module source changes"); + let stale = registry_serverctl(&[ + "--format", + "json", + "project", + "lock", + path(project.path()), + "--check", + ]); + + assert_eq!(stale.status.code(), Some(1), "{stale:?}"); + assert!(stale.stderr.is_empty()); + let report = json_stdout(&stale); + assert_eq!(report["diagnostics"][0]["code"], "module.lock.stale"); + assert_tool_diagnostic( + &report["diagnostics"][0], + "registry_project", + "update_module_locks", + ); + assert_eq!( + fs::read(project.path().join("registry.yaml")).expect("project rereads"), + locked_project + ); +} + +#[test] +fn project_lock_refuses_missing_locked_source_without_rendering_values() { + const MODULE_CANARY: &str = "missing-module-canary"; + let project = TestProject::from_registry_source( + format!( + r#"apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: modular-lock-missing + version: 1 + defaultLanguage: en +modules: + - id: {MODULE_CANARY} + version: 1 + digest: sha256:1111111111111111111111111111111111111111111111111111111111111111 +"# + ) + .as_bytes(), + ); + let original = fs::read(project.path().join("registry.yaml")).expect("project reads"); + + let refused = + registry_serverctl(&["--format", "json", "project", "lock", path(project.path())]); + + assert_eq!(refused.status.code(), Some(1), "{refused:?}"); + assert!(refused.stderr.is_empty()); + let rendered = String::from_utf8(refused.stdout).expect("diagnostic is UTF-8"); + assert!(!rendered.contains(MODULE_CANARY)); + let report: Value = serde_json::from_str(&rendered).expect("diagnostic JSON parses"); + assert_eq!( + report["diagnostics"][0]["code"], + "module.lock.source_missing" + ); + assert_eq!( + fs::read(project.path().join("registry.yaml")).expect("project rereads"), + original + ); +} + #[test] fn init_and_generate_missing_output_parents_have_exact_logical_diagnostics() { const PATH_CANARY: &str = "registry-serverctl-missing-parent-canary"; @@ -887,7 +1032,10 @@ fn explain_reports_are_derived_from_compiled_inventories() { assert_eq!(planner_list["profile"], "site-planner"); assert_eq!(planner_list["routeId"], "records.asset-item.list"); assert_eq!(planner_list["apiFields"][0]["apiName"], "assetCode"); + assert_eq!(planner_list["apiFields"][0]["field"], "asset-code"); assert_eq!(planner_list["apiFields"][0]["sourceKind"], "stored"); + assert_eq!(planner_list["filterable"][0]["apiName"], "assetCode"); + assert_eq!(planner_list["filterable"][0]["field"], "asset-code"); assert_eq!( planner_list["filterable"][0]["operators"], json!([ @@ -899,12 +1047,120 @@ fn explain_reports_are_derived_from_compiled_inventories() { "contains" ]) ); + assert_eq!( + planner_list["filterable"][0]["wireOperators"], + json!([ + "contains", + "eq", + "eq null", + "in", + "ne", + "ne null", + "startswith" + ]) + ); + assert!(planner_list["filterable"][0]["examples"] + .as_array() + .expect("filter examples are an array") + .iter() + .any(|example| example == "$filter=assetCode eq 'example'")); + assert!(planner_list["sortable"] + .as_array() + .expect("sortable is an array") + .is_empty()); + assert_eq!(planner_list["wire"]["filter"], "$filter"); + assert_eq!(planner_list["wire"]["orderBy"], "$orderby"); assert_eq!(planner_list["bounds"]["maxPageSize"], 100); + assert_eq!(planner_list["bounds"]["maxInValues"], 100); assert!(!String::from_utf8(queries.stdout) .expect("queries JSON is UTF-8") .contains("registry_data")); } +#[test] +fn explain_query_filter_examples_match_field_types() { + let project = TestProject::from_registry_source( + br#"apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: typed-query-examples + version: 1 + defaultLanguage: en +entities: + - id: typed-record + route: typed-records + mutationMode: create_only + fields: + - id: label + type: string + maxLength: 64 + classification: internal + - id: score + type: int64 + classification: internal + - id: enabled + type: boolean + classification: internal + - id: observed-on + type: date + classification: internal + - id: observed-at + type: timestamp + classification: internal +accessProfiles: + - id: reader + principalClaim: principal + grants: + - entity: typed-record + operations: [list] + readableFields: [label, score, enabled, observed-on, observed-at] + filterableFields: [label, score, enabled, observed-on, observed-at] +"#, + ); + + let output = registry_serverctl(&[ + "--format", + "json", + "explain", + "queries", + path(project.path()), + ]); + + assert!(output.status.success(), "{output:?}"); + let report = json_stdout(&output); + let operation = report["explanation"]["operations"] + .as_array() + .expect("operations are an array") + .iter() + .find(|operation| operation["id"] == "records.typed-record.reader.list") + .expect("typed query operation is explained"); + assert_filter_example(operation, "label", "$filter=label eq 'example'"); + assert_filter_example(operation, "score", "$filter=score eq 1"); + assert_filter_example(operation, "score", "$filter=score ge 1"); + assert_filter_example(operation, "enabled", "$filter=enabled eq true"); + assert_filter_example(operation, "enabled", "$filter=enabled in (true,false)"); + assert_filter_example( + operation, + "observedOn", + "$filter=observedOn eq '2026-01-02'", + ); + assert_filter_example( + operation, + "observedOn", + "$filter=observedOn ge '2026-01-02'", + ); + assert_filter_example( + operation, + "observedAt", + "$filter=observedAt eq '2026-01-02T03:04:05Z'", + ); + assert_filter_example( + operation, + "observedAt", + "$filter=observedAt ge '2026-01-02T03:04:05Z'", + ); +} + #[test] fn check_reports_derived_sql_module_path_without_sql_values() { let project = TestProject::from_registry_source( @@ -2807,6 +3063,36 @@ fn package_project_bytes(module_digest: &str) -> Vec { .into_bytes() } +fn modular_project_without_locks() -> &'static [u8] { + br#"apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: modular-lock-fixture + version: 1 + defaultLanguage: en +"# +} + +fn modular_project_module() -> &'static [u8] { + br#"id: core +version: 1 +entities: + - id: record + route: records + mutationMode: create_only + fields: + - id: code + type: string + maxLength: 16 + classification: internal + accessProfiles: + - id: reader + principalClaim: principal + operations: [get, list] + readableFields: [code] +"# +} + fn package_module_bytes() -> Vec { br#"{"id":"core","version":"1","entities":[{"id":"record","route":"records","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":16,"classification":"internal"}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["get","list"],"readableFields":["code"]}]}]}"# .to_vec() @@ -2898,7 +3184,9 @@ fn write_runtime_config( fs::write( &path, format!( - r#"listener: + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +listener: bind: {bind} trustedProxy: direct identity: @@ -3042,6 +3330,20 @@ fn assert_inspection_refusal( assert_tool_diagnostic(&report["diagnostics"][0], artifact, action); } +fn assert_filter_example(operation: &Value, api_name: &str, example: &str) { + let field = operation["filterable"] + .as_array() + .expect("filterable is an array") + .iter() + .find(|field| field["apiName"] == api_name) + .unwrap_or_else(|| panic!("{api_name} filter field is present")); + assert!(field["examples"] + .as_array() + .expect("examples is an array") + .iter() + .any(|candidate| candidate == example)); +} + fn path(path: &Path) -> &str { path.to_str().expect("test path is UTF-8") } diff --git a/crates/registry-serverctl/tests/diff.rs b/crates/registry-serverctl/tests/diff.rs index edeb8f5c53..292029f3b0 100644 --- a/crates/registry-serverctl/tests/diff.rs +++ b/crates/registry-serverctl/tests/diff.rs @@ -433,8 +433,9 @@ fn diff_help_and_selector_usage_preserve_the_closed_command_inventory_and_exit_c let report = json_stdout(&refused_runtime); assert_eq!( report["diagnostics"][0]["code"], - "diff.runtime_config.refused" + "diff.runtime_config.document" ); + assert_eq!(report["diagnostics"][0]["path"], "/"); assert_tool_diagnostic( &report["diagnostics"][0], "runtime_configuration", @@ -585,7 +586,9 @@ fn write_runtime_config(parent: &Path, package: &PublishedPackage, trust_anchor: fs::write( &path, format!( - r#"listener: + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +listener: bind: 127.0.0.1:1 trustedProxy: direct identity: diff --git a/products/registry-server/DECISIONS.md b/products/registry-server/DECISIONS.md index d2dd546520..5ee4258e16 100644 --- a/products/registry-server/DECISIONS.md +++ b/products/registry-server/DECISIONS.md @@ -8,6 +8,13 @@ - Packages contain governed model and generated artifacts. Runtime configuration binds deployment-specific values and secrets and is not part of the signed model. +- Runtime configuration is an explicitly versioned document. Authority, + credentials, package identity, and database roles remain required; bounded + operational tuning uses reviewed defaults so a safe starter file stays + readable. +- A project authors reusable access profiles once at the project top level. + Modules may contribute profiles while composing an entity, but root project + entities do not carry a second access-profile vocabulary. - Domain semantics are optional configuration overlays. Registry Server does not hardcode Person, Household, GroupMembership, or any other domain model. An overlay may add localized labels, concept URIs, identifiers, relationship @@ -19,8 +26,14 @@ source, association entity, target, and target field capability set. - Registry Manifest remains the owner of standards-oriented metadata and DCAT rendering. Registry Server emits a one-way, lossy Manifest source plus its - DCAT JSON-LD projection in the governed package. It does not maintain a - second catalogue model or claim conformance that was not explicitly authored. + DCAT JSON-LD projection when `manifestProjection` is authored. The projection + is optional, so a basic registry is not forced to invent catalogue metadata. + The server does not maintain a second catalogue model or claim conformance + that was not explicitly authored. +- `registry-serverctl init` emits one small inline entity and no empty module. + Module locks are discovered and refreshed explicitly with `project lock`; + lock digests still bind every module source and declared SQL asset before a + production package is compiled. - Evidence and Relay integrations use their published protocol surfaces and platform primitives. Registry Server does not depend on their product crates or duplicate their policy, disclosure, credential, or publication engines. diff --git a/products/registry-server/README.md b/products/registry-server/README.md index d8d33a99f6..b998c3e688 100644 --- a/products/registry-server/README.md +++ b/products/registry-server/README.md @@ -22,6 +22,49 @@ AI-assisted authoring remains outside the production authority boundary. It may propose configuration and run deterministic checks, but it cannot bypass package review, signature policy, or the separate migration database role. +## First-hour local quickstart + +For a generic, domain-neutral local path, run: + +```bash +products/registry-server/quickstart/run.sh +``` + +The quickstart uses `registry-serverctl init` to create a small generic +Registry project, adds only local package identity for the disposable package, +checks it, starts disposable PostgreSQL and Registry Mint on loopback, activates +an unsigned local package, obtains a short-lived Mint token, POSTs one record, +and GETs that record back. Generated configuration, keys, tokens, package +artifacts, logs, and database URLs stay under +`products/registry-server/quickstart/.run/`, which is ignored by Git and +created owner-only. + +Leave the quickstart terminal running, then use the printed record id in a +second terminal: + +```bash +products/registry-server/quickstart/query.sh get +``` + +The query helper reads the bearer token from an owner-only token file. It does +not put the token on the command line or print it. For a non-interactive local +smoke, run: + +```bash +products/registry-server/quickstart/run.sh --smoke +``` + +To verify only the checked quickstart structure without Docker or network, run: + +```bash +products/registry-server/quickstart/self-test.sh +``` + +This route is intentionally local-only: Mint's supervised local-development +profile, loopback HTTP, disposable PostgreSQL, and an unsigned local package. +It is the first-hour learning path, not a shortcut around production package +signing, operated database roles, TLS, migration review, or secret custody. + ## Pilot operator lifecycle The pilot lifecycle uses the published `registry-serverctl` and @@ -79,6 +122,13 @@ identifier policy, and key shape. It is resolved once when the verifier is constructed, so rotation requires a reviewed configuration change and process restart. +Runtime files set `apiVersion` to +`registry.registrystack.org/server-runtime/v1alpha1` and `kind` to +`RegistryServerRuntimeConfig`. The generated JSON Schema at +`generated/runtime/runtime.schema.json` is suitable for editor validation. It +documents bounded defaults for operational tuning while keeping package, +database, authority, role, and secret-reference fields explicit. + ## Scope Registry Server owns typed configured storage, generated REST contracts, diff --git a/products/registry-server/acceptance/asset-site-placement/registry.yaml b/products/registry-server/acceptance/asset-site-placement/registry.yaml index 4c66b177f6..a343edf33a 100644 --- a/products/registry-server/acceptance/asset-site-placement/registry.yaml +++ b/products/registry-server/acceptance/asset-site-placement/registry.yaml @@ -72,19 +72,19 @@ accessProfiles: - id: asset-operator default: true principalClaim: registry_principal - purposes: [asset-management] + requiredPurposes: [asset-management] grants: - - {entity: asset-item, actions: [create, get, list, patch, batch], readableFields: [asset-code, label, asset-class], writableFields: [asset-code, label, asset-class]} - - {entity: asset-site, actions: [create, get, list, patch], readableFields: [site-code, label], writableFields: [site-code, label]} - - {entity: asset-placement, actions: [create, get, list, patch], readableFields: [asset, site, valid-from, valid-to], writableFields: [asset, site, valid-from, valid-to]} - - {entity: inspection-event, actions: [create, get, list], readableFields: [asset, observed-at, result], writableFields: [asset, observed-at, result]} + - {entity: asset-item, operations: [create, get, list, patch, batch], readableFields: [asset-code, label, asset-class], writableFields: [asset-code, label, asset-class]} + - {entity: asset-site, operations: [create, get, list, patch], readableFields: [site-code, label], writableFields: [site-code, label]} + - {entity: asset-placement, operations: [create, get, list, patch], readableFields: [asset, site, valid-from, valid-to], writableFields: [asset, site, valid-from, valid-to]} + - {entity: inspection-event, operations: [create, get, list], readableFields: [asset, observed-at, result], writableFields: [asset, observed-at, result]} - id: site-planner principalClaim: registry_principal - purposes: [site-planning] + requiredPurposes: [site-planning] grants: - - {entity: asset-item, actions: [get, list], readableFields: [asset-code, label], filterableFields: [asset-code]} - - {entity: asset-site, actions: [get, list], readableFields: [site-code, label], filterableFields: [site-code]} - - {entity: asset-placement, actions: [create, get, list, patch], readableFields: [asset, site, valid-from, valid-to], writableFields: [asset, site, valid-from, valid-to], filterableFields: [asset, site, valid-from]} + - {entity: asset-item, operations: [get, list], readableFields: [asset-code, label], filterableFields: [asset-code]} + - {entity: asset-site, operations: [get, list], readableFields: [site-code, label], filterableFields: [site-code]} + - {entity: asset-placement, operations: [create, get, list, patch], readableFields: [asset, site, valid-from, valid-to], writableFields: [asset, site, valid-from, valid-to], filterableFields: [asset, site, valid-from]} vocabularies: - {id: asset-classification, values: [equipment, vehicle, furniture]} - {id: inspection-result, values: [passed, failed]} diff --git a/products/registry-server/acceptance/business/registry.yaml b/products/registry-server/acceptance/business/registry.yaml index ebec1c4c8d..2d1fc44432 100644 --- a/products/registry-server/acceptance/business/registry.yaml +++ b/products/registry-server/acceptance/business/registry.yaml @@ -44,14 +44,6 @@ entities: - {id: internal-case-note, type: text, required: false, maxLength: 4000, classification: restricted} constraints: - {kind: unique, fields: [jurisdiction-code, registration-number]} - accessProfiles: - - id: public-register - default: true - anonymous: true - operations: [get, list] - readableFields: [jurisdiction-code, registration-number, legal-name, entity-status, public-service-address] - filterableFields: [jurisdiction-code, entity-status] - sortableFields: [legal-name] - id: filing route: filings mutationMode: create_only @@ -91,22 +83,28 @@ entities: - {kind: field_is_null, field: effective-to} - {kind: active_lifecycle} - {kind: temporal-non-overlap, scopeFields: [legal-entity, officer-code], startField: effective-from, endField: effective-to} - accessProfiles: - - id: public-register - default: true - anonymous: true +accessProfiles: + - id: public-register + default: true + anonymous: true + grants: + - entity: legal-entity + operations: [get, list] + readableFields: [jurisdiction-code, registration-number, legal-name, entity-status, public-service-address] + filterableFields: [jurisdiction-code, entity-status] + sortableFields: [legal-name] + - entity: officer-appointment operations: [get, list] readableFields: [legal-entity, officer-name, officer-role, effective-from, effective-to] filterableFields: [legal-entity, officer-role, effective-from] sortableFields: [effective-from] -accessProfiles: - id: business-registrar principalClaim: registry_principal - purposes: [business-registry] + requiredPurposes: [business-registry] grants: - - {entity: legal-entity, actions: [create, get, list, patch], readableFields: [jurisdiction-code, registration-number, legal-name, entity-status, public-service-address, protected-contact, protected-ownership-reference, internal-case-note], writableFields: [jurisdiction-code, registration-number, legal-name, entity-status, public-service-address, protected-contact, protected-ownership-reference, internal-case-note], filterableFields: [jurisdiction-code, registration-number, entity-status]} - - {entity: filing, actions: [create, get, list], readableFields: [legal-entity, filing-number, filing-type, filed-date, source-system, source-record-id, correction-of, provenance-note], writableFields: [legal-entity, filing-number, filing-type, filed-date, source-system, source-record-id, correction-of, provenance-note], filterableFields: [legal-entity, filing-type, filed-date, source-system]} - - {entity: officer-appointment, actions: [create, get, list, patch], readableFields: [legal-entity, officer-code, officer-name, officer-role, effective-from, effective-to, protected-officer-id], writableFields: [legal-entity, officer-code, officer-name, officer-role, effective-from, effective-to, protected-officer-id], filterableFields: [legal-entity, officer-code, officer-role, effective-from]} + - {entity: legal-entity, operations: [create, get, list, patch], readableFields: [jurisdiction-code, registration-number, legal-name, entity-status, public-service-address, protected-contact, protected-ownership-reference, internal-case-note], writableFields: [jurisdiction-code, registration-number, legal-name, entity-status, public-service-address, protected-contact, protected-ownership-reference, internal-case-note], filterableFields: [jurisdiction-code, registration-number, entity-status]} + - {entity: filing, operations: [create, get, list], readableFields: [legal-entity, filing-number, filing-type, filed-date, source-system, source-record-id, correction-of, provenance-note], writableFields: [legal-entity, filing-number, filing-type, filed-date, source-system, source-record-id, correction-of, provenance-note], filterableFields: [legal-entity, filing-type, filed-date, source-system]} + - {entity: officer-appointment, operations: [create, get, list, patch], readableFields: [legal-entity, officer-code, officer-name, officer-role, effective-from, effective-to, protected-officer-id], writableFields: [legal-entity, officer-code, officer-name, officer-role, effective-from, effective-to, protected-officer-id], filterableFields: [legal-entity, officer-code, officer-role, effective-from]} vocabularies: - {id: entity-status, values: [active, dissolved, suspended]} - {id: filing-type, values: [incorporation, annual-return, correction]} diff --git a/products/registry-server/acceptance/disability/registry.yaml b/products/registry-server/acceptance/disability/registry.yaml index 6dcd2a8f5c..2554585c2c 100644 --- a/products/registry-server/acceptance/disability/registry.yaml +++ b/products/registry-server/acceptance/disability/registry.yaml @@ -98,11 +98,11 @@ entities: accessProfiles: - id: disability-caseworker principalClaim: registry_principal - purposes: [disability-assessment] + requiredPurposes: [disability-assessment] grants: - - {entity: assessment-episode, actions: [create, get, list, patch], readableFields: [episode-code, subject-code, opened-on, closed-on, assessment-source], writableFields: [episode-code, subject-code, opened-on, closed-on, assessment-source], filterableFields: [episode-code, subject-code]} - - {entity: functioning-observation, actions: [create, get, list], readableFields: [assessment-episode, observed-at, functioning-domain, severity-score, observation-schema-metadata, observation-note], writableFields: [assessment-episode, observed-at, functioning-domain, severity-score, observation-schema-metadata, observation-note], filterableFields: [assessment-episode, functioning-domain]} - - {entity: certification, actions: [create, get, list], readableFields: [certification-code, assessment-episode, certification-status, valid-from, valid-to, corrected-certification, correction-reason, validity-source, provenance-note], writableFields: [certification-code, assessment-episode, certification-status, valid-from, valid-to, corrected-certification, correction-reason, validity-source, provenance-note], filterableFields: [assessment-episode, certification-status, valid-from]} + - {entity: assessment-episode, operations: [create, get, list, patch], readableFields: [episode-code, subject-code, opened-on, closed-on, assessment-source], writableFields: [episode-code, subject-code, opened-on, closed-on, assessment-source], filterableFields: [episode-code, subject-code]} + - {entity: functioning-observation, operations: [create, get, list], readableFields: [assessment-episode, observed-at, functioning-domain, severity-score, observation-schema-metadata, observation-note], writableFields: [assessment-episode, observed-at, functioning-domain, severity-score, observation-schema-metadata, observation-note], filterableFields: [assessment-episode, functioning-domain]} + - {entity: certification, operations: [create, get, list], readableFields: [certification-code, assessment-episode, certification-status, valid-from, valid-to, corrected-certification, correction-reason, validity-source, provenance-note], writableFields: [certification-code, assessment-episode, certification-status, valid-from, valid-to, corrected-certification, correction-reason, validity-source, provenance-note], filterableFields: [assessment-episode, certification-status, valid-from]} vocabularies: - {id: functioning-domain, values: [mobility, cognition, self-care, communication]} - {id: certification-status, values: [draft, active, corrected, withdrawn]} diff --git a/products/registry-server/acceptance/farmer/registry.yaml b/products/registry-server/acceptance/farmer/registry.yaml index cf62be2af3..48d9ce417e 100644 --- a/products/registry-server/acceptance/farmer/registry.yaml +++ b/products/registry-server/acceptance/farmer/registry.yaml @@ -94,12 +94,12 @@ entities: accessProfiles: - id: farmer-operator principalClaim: registry_principal - purposes: [farmer-registry] + requiredPurposes: [farmer-registry] grants: - - {entity: farmer, actions: [create, get, list, patch], readableFields: [farmer-code, display-name, administrative-boundary], writableFields: [farmer-code, display-name, administrative-boundary], filterableFields: [farmer-code, administrative-boundary], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} - - {entity: holding, actions: [create, get, list, patch], readableFields: [holding-code, farmer, tenure-type, tenure-start, tenure-end, administrative-boundary, import-source, source-record-id], writableFields: [holding-code, farmer, tenure-type, tenure-start, tenure-end, administrative-boundary, import-source, source-record-id], filterableFields: [farmer, administrative-boundary, import-source, tenure-start], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} - - {entity: plot, actions: [create, get, list, patch, batch], readableFields: [plot-code, holding, administrative-boundary, centroid, area-value, area-unit, import-source, source-record-id], writableFields: [plot-code, holding, administrative-boundary, centroid, area-value, area-unit, import-source, source-record-id], filterableFields: [holding, administrative-boundary, import-source], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} - - {entity: seasonal-activity, actions: [create, get, list, patch], readableFields: [plot, administrative-boundary, activity-type, season-start, season-end, quantity-value, quantity-unit], writableFields: [plot, administrative-boundary, activity-type, season-start, season-end, quantity-value, quantity-unit], filterableFields: [plot, administrative-boundary, activity-type, season-start], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} + - {entity: farmer, operations: [create, get, list, patch], readableFields: [farmer-code, display-name, administrative-boundary], writableFields: [farmer-code, display-name, administrative-boundary], filterableFields: [farmer-code, administrative-boundary], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} + - {entity: holding, operations: [create, get, list, patch], readableFields: [holding-code, farmer, tenure-type, tenure-start, tenure-end, administrative-boundary, import-source, source-record-id], writableFields: [holding-code, farmer, tenure-type, tenure-start, tenure-end, administrative-boundary, import-source, source-record-id], filterableFields: [farmer, administrative-boundary, import-source, tenure-start], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} + - {entity: plot, operations: [create, get, list, patch, batch], readableFields: [plot-code, holding, administrative-boundary, centroid, area-value, area-unit, import-source, source-record-id], writableFields: [plot-code, holding, administrative-boundary, centroid, area-value, area-unit, import-source, source-record-id], filterableFields: [holding, administrative-boundary, import-source], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} + - {entity: seasonal-activity, operations: [create, get, list, patch], readableFields: [plot, administrative-boundary, activity-type, season-start, season-end, quantity-value, quantity-unit], writableFields: [plot, administrative-boundary, activity-type, season-start, season-end, quantity-value, quantity-unit], filterableFields: [plot, administrative-boundary, activity-type, season-start], rowBoundaries: [{field: administrative-boundary, claim: administrative_boundaries, operator: in}]} vocabularies: - {id: administrative-boundary, values: [north-district, south-district, central-district]} - {id: tenure-type, values: [owned, leased, communal]} diff --git a/products/registry-server/acceptance/publicschema-household/registry.yaml b/products/registry-server/acceptance/publicschema-household/registry.yaml index 27e2c92d70..1d9297e7c4 100644 --- a/products/registry-server/acceptance/publicschema-household/registry.yaml +++ b/products/registry-server/acceptance/publicschema-household/registry.yaml @@ -127,11 +127,11 @@ accessProfiles: default: true principalClaim: registry_principal requiredScopes: [registry:household:operate] - purposes: [household-administration] + requiredPurposes: [household-administration] grants: - - {entity: person, actions: [create, get, list, patch], readableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status, preferred-language], writableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status, preferred-language], filterableFields: [person-code, person-sex, residency-status], sortableFields: [person-code]} + - {entity: person, operations: [create, get, list, patch], readableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status, preferred-language], writableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status, preferred-language], filterableFields: [person-code, person-sex, residency-status], sortableFields: [person-code]} - entity: household - actions: [create, get, lookup, list, patch] + operations: [create, get, lookup, list, patch] readableFields: [household-code, local-household-number, household-name, administrative-area, household-type, head-count, child-count, child-under-5-count, elderly-count, single-headed, woman-headed] writableFields: [household-code, local-household-number, household-name, administrative-area, household-type] filterableFields: [household-code, local-household-number, administrative-area, household-type, head-count, child-count, child-under-5-count, elderly-count, single-headed, woman-headed] @@ -146,14 +146,14 @@ accessProfiles: filterableFields: [person-sex, residency-status] sortableFields: [person-code] allowCount: true - - {entity: group-membership, actions: [create, get, list, patch], readableFields: [person, household, relationship, valid-from, valid-to], writableFields: [person, household, relationship, valid-from, valid-to], filterableFields: [person, household, relationship, valid-from]} + - {entity: group-membership, operations: [create, get, list, patch], readableFields: [person, household, relationship, valid-from, valid-to], writableFields: [person, household, relationship, valid-from, valid-to], filterableFields: [person, household, relationship, valid-from]} - id: household-viewer principalClaim: registry_principal requiredScopes: [registry:household:view] - purposes: [household-view] + requiredPurposes: [household-view] grants: - entity: household - actions: [get, lookup] + operations: [get, lookup] readableFields: [household-code, local-household-number, household-name, administrative-area, household-type] rowBoundaries: - {field: id, claim: household_id, operator: equals} diff --git a/products/registry-server/contracts/artifact-inventory.yaml b/products/registry-server/contracts/artifact-inventory.yaml index 490120d00b..6f55ff5c00 100644 --- a/products/registry-server/contracts/artifact-inventory.yaml +++ b/products/registry-server/contracts/artifact-inventory.yaml @@ -19,7 +19,9 @@ artifacts: - {path: acceptance/farmer, kind: authored-fixture, state: authored} - {path: acceptance/business, kind: authored-fixture, state: authored} - {path: demo, kind: local-mint-server-demo, state: authored} + - {path: quickstart, kind: generic-first-hour-quickstart, state: authored} - {path: generated/authoring/registry-project.schema.json, kind: generated-authoring-schema, state: authored} + - {path: generated/runtime/runtime.schema.json, kind: generated-runtime-schema, state: authored} - {path: generated/asset-site-placement, kind: generated-baseline, state: authored} - {path: generated/publicschema-household, kind: generated-baseline, state: authored} - {path: scripts/check-generated.sh, kind: generated-artifact-gate, state: authored} diff --git a/products/registry-server/contracts/definition-of-done.yaml b/products/registry-server/contracts/definition-of-done.yaml index 4dfedc3008..f6452f9747 100644 --- a/products/registry-server/contracts/definition-of-done.yaml +++ b/products/registry-server/contracts/definition-of-done.yaml @@ -28,8 +28,8 @@ requirements: - {id: RS-V1-04, phase: W1, state: enforced, doneWhen: "Valid time supports ordered, open-ended current and as-of reads with scoped non-overlap.", journeys: [RS-J01, RS-J03, RS-J06], evidence: [{path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}, {path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: crates/registry-server/src/postgres/read.rs, name: temporal_query_instant_uses_utc_calendar_dates_without_session_timezone_dependence}]} - {id: RS-V1-05, phase: W1, state: enforced, doneWhen: "A closed typed constraint grammar leaves concurrent uniqueness and references authoritative in PostgreSQL.", journeys: [RS-J06, RS-J08], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: closed_constraint_grammar_compiles_typed_checks_and_refuses_expression_escape_hatches}, {path: crates/registry-server/tests/compiler_contract.rs, name: temporal_non_overlap_refuses_structured_and_crs84_point_scope_fields}, {path: crates/registry-server/tests/compiler_contract.rs, name: temporal_non_overlap_accepts_every_btree_gist_equality_scalar_scope_type}, {path: crates/registry-server/tests/postgres_partial_unique.rs, name: real_postgres_partial_unique_index_enforces_only_the_closed_predicate}, {path: crates/registry-server/tests/postgres_constraint_races.rs, name: real_postgres_reference_and_temporal_races_leave_no_dangling_or_overlapping_records}]} - {id: RS-V1-06, phase: W1, state: enforced, doneWhen: "Additive modules merge deterministically and incompatible changes require migration treatment.", journeys: [RS-J02], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: independent_additive_modules_are_order_independent}, {path: crates/registry-server/tests/package_change_plan.rs, name: complete_extension_surface_modules_are_order_independent}, {path: crates/registry-server/tests/package_change_plan.rs, name: non_additive_changes_are_classified_and_cannot_create_applicable_plans}, {path: crates/registry-server/tests/package_change_plan.rs, name: metadata_only_reviewed_migration_covers_non_sql_surface_without_dummy_sql}, {path: crates/registry-server/tests/package_change_plan.rs, name: reference_target_change_can_be_reviewed_through_compiler_owned_fk_constraint}, {path: crates/registry-server/tests/compiler_webhook.rs, name: additive_modules_add_nonconflicting_subscriptions_deterministically_and_refuse_conflicts}]} - - {id: RS-V1-07, phase: W1, state: enforced, doneWhen: "One compiler emits the complete governed, database, API, metadata, package, and Manifest artifact set.", journeys: [RS-J01, RS-J02], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: public_asset_fixture_compiles_to_coherent_deterministic_inventories}, {path: crates/registry-server/tests/compiler_contract.rs, name: compiled_metadata_inventory_is_bijective_canonical_schema_bound_and_deterministic}, {path: crates/registry-server/tests/http_read_only.rs, name: caller_filtered_discovery_conceals_counts_vocabularies_events_queries_and_every_metadata_surface}, {path: crates/registry-server/tests/postgres_package.rs, name: package_layout_contract_required_manifest_projection_is_in_prepared_closure}, {path: crates/registry-server/tests/postgres_package.rs, name: package_rederivation_refuses_rehashed_substituted_caller_safe_metadata}, {path: crates/registry-serverctl/tests/cli.rs, name: generation_is_byte_stable_and_reports_the_exact_artifact_inventory}, {path: products/registry-server/scripts/check-generated.sh, name: check-generated.sh}]} - - {id: RS-V1-08, phase: W1, state: enforced, doneWhen: "Router, OpenAPI, metadata, authorization, and migration planning consume shared inventories.", journeys: [RS-J01, RS-J02], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: generated_openapi_routes_and_physical_names_share_one_compiled_inventory}, {path: crates/registry-serverctl/tests/cli.rs, name: explain_reports_are_derived_from_compiled_inventories}, {path: crates/registry-server/tests/package_change_plan.rs, name: new_entity_plan_uses_complete_candidate_ddl_in_dependency_order}]} + - {id: RS-V1-07, phase: W1, state: enforced, doneWhen: "One compiler emits the complete governed, database, API, metadata, and package artifact set, plus the Manifest projection set when configured.", journeys: [RS-J01, RS-J02], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: public_asset_fixture_compiles_to_coherent_deterministic_inventories}, {path: crates/registry-server/tests/compiler_contract.rs, name: compiled_metadata_inventory_is_bijective_canonical_schema_bound_and_deterministic}, {path: crates/registry-server/tests/http_read_only.rs, name: caller_filtered_discovery_conceals_counts_vocabularies_events_queries_and_every_metadata_surface}, {path: crates/registry-server/tests/postgres_package.rs, name: package_layout_contract_conditional_manifest_projection_is_in_projected_closure}, {path: crates/registry-server/tests/postgres_package.rs, name: projection_free_package_omits_manifest_projection_from_signed_closure_and_loads}, {path: crates/registry-server/tests/postgres_package.rs, name: package_rederivation_refuses_rehashed_substituted_caller_safe_metadata}, {path: crates/registry-serverctl/tests/cli.rs, name: generation_is_byte_stable_and_reports_the_exact_artifact_inventory}, {path: products/registry-server/scripts/check-generated.sh, name: check-generated.sh}]} + - {id: RS-V1-08, phase: W1, state: enforced, doneWhen: "Router, complete OpenAPI, metadata, authorization, and migration planning consume shared inventories.", journeys: [RS-J01, RS-J02], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: generated_openapi_routes_and_physical_names_share_one_compiled_inventory}, {path: crates/registry-server/tests/http_read_only.rs, name: runtime_openapi_contract_is_filtered_to_the_selected_acceptance_profile}, {path: crates/registry-serverctl/tests/cli.rs, name: explain_reports_are_derived_from_compiled_inventories}, {path: crates/registry-server/tests/package_change_plan.rs, name: new_entity_plan_uses_complete_candidate_ddl_in_dependency_order}]} - {id: RS-V1-09, phase: W1, state: enforced, doneWhen: "Canonical inputs generate byte-identical artifacts and committed drift is rejected.", journeys: [RS-J01, RS-J02], evidence: [{path: crates/registry-serverctl/tests/cli.rs, name: generation_is_byte_stable_and_reports_the_exact_artifact_inventory}, {path: products/registry-server/scripts/check-generated.sh, name: check-generated.sh}, {path: products/registry-server/scripts/test_generated_gates.py, name: test_comparator_rejects_a_missing_committed_artifact}]} - {id: RS-V1-10, phase: W1, state: enforced, doneWhen: "The Manifest adapter receives only a classified one-way lossy projection.", journeys: [RS-J01], evidence: [{path: crates/registry-server/tests/compiler_contract.rs, name: manifest_projection_filters_by_selected_profile_and_classification_ceiling}, {path: crates/registry-server/tests/compiler_contract.rs, name: manifest_projection_omits_physical_runtime_and_security_terms}, {path: crates/registry-serverctl/tests/cli.rs, name: manifest_selector_requires_the_compiled_manifest_projection}]} - {id: RS-V1-11, phase: W2, state: enforced, doneWhen: "Typed tables use compiler-owned identifiers while runtime values remain parameters.", journeys: [RS-J08], evidence: [{path: crates/registry-server/tests/postgres_compiled_schema.rs, name: compiled_postgres_schema_enforces_context_rls_and_exact_catalog}, {path: crates/registry-server/src/postgres/roles.rs, name: governed_identifier_grammar_refuses_sql_syntax_and_case_folding}, {path: crates/registry-server/src/mutation.rs, name: mutation_scalar_validation_refuses_invalid_lexical_values_before_sql}]} @@ -49,7 +49,7 @@ requirements: - {id: RS-V1-25, phase: W3, state: enforced, doneWhen: "Provenance stays distinct from minimized value-free audit, logs, metrics, and traces.", journeys: [RS-J12], evidence: [{path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_read_is_authorized_bounded_minimized_and_audit_gated}, {path: crates/registry-server/tests/postgres_tombstone_revision.rs, name: tombstone_revisions_survive_package_upgrade_and_replay_exactly}, {path: crates/registry-server/tests/startup_http.rs, name: operational_log_level_is_a_closed_vocabulary}, {path: crates/registry-server/tests/startup_http.rs, name: every_operational_event_renders_exact_closed_value_free_json_fields}, {path: crates/registry-server/tests/startup_http.rs, name: provenance_operational_logs_metrics_and_traces_are_separate_closed_and_value_free}]} - {id: RS-V1-26, phase: W3, state: enforced, doneWhen: "Protected responses release only after successful attempt and terminal audit gates.", journeys: [RS-J12], evidence: [{path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_read_is_authorized_bounded_minimized_and_audit_gated}, {path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}]} - {id: RS-V1-27, phase: W3, state: enforced, doneWhen: "Transactions use platform audit envelopes and atomically update the PostgreSQL chain head.", journeys: [RS-J10, RS-J12], evidence: [{path: crates/registry-server/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}, {path: crates/registry-server/tests/postgres_read.rs, name: real_postgres_read_is_authorized_bounded_minimized_and_audit_gated}]} - - {id: RS-V1-28, phase: W4, state: enforced, doneWhen: "Production packages capture the governed closure and sign exact canonical bytes with monotonic identity.", journeys: [RS-J14], evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: package_builder_is_deterministic_and_local_publication_loads}, {path: crates/registry-server/tests/postgres_package.rs, name: production_package_requires_exact_trust_anchor_threshold_and_signature}, {path: crates/registry-server/tests/postgres_package.rs, name: package_layout_contract_required_manifest_projection_is_in_prepared_closure}]} + - {id: RS-V1-28, phase: W4, state: enforced, doneWhen: "Production packages capture the governed closure and sign exact canonical bytes with monotonic identity.", journeys: [RS-J14], evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: package_builder_is_deterministic_and_local_publication_loads}, {path: crates/registry-server/tests/postgres_package.rs, name: production_package_requires_exact_trust_anchor_threshold_and_signature}, {path: crates/registry-server/tests/postgres_package.rs, name: package_layout_contract_conditional_manifest_projection_is_in_projected_closure}, {path: crates/registry-server/tests/postgres_package.rs, name: projection_free_package_omits_manifest_projection_from_signed_closure_and_loads}, {path: crates/registry-server/tests/postgres_package.rs, name: projection_free_package_refuses_claimed_manifest_artifacts}]} - {id: RS-V1-29, phase: W4, state: enforced, doneWhen: "Activation verifies trust, identity, inventory, filesystem safety, artifacts, and schema before readiness.", journeys: [RS-J14], evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: package_binding_refuses_wrong_environment_instance_database_sequence_and_prior}, {path: crates/registry-server/tests/postgres_package.rs, name: package_refuses_symlinks_and_production_writable_permissions}, {path: crates/registry-server/tests/postgres_package.rs, name: package_manifest_refuses_ddl_checksum_path_and_canonical_json_tampering}, {path: crates/registry-server/tests/postgres_package.rs, name: signed_schema_fingerprint_mismatch_is_durably_failed_and_never_ready}]} - {id: RS-V1-30, phase: W4, state: enforced, doneWhen: "Apply retains the lock through migrations, catalog verification, activation, and maintenance clearing.", journeys: [RS-J13, RS-J15], evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: real_postgres_package_startup_apply_failure_and_old_process_are_closed}, {path: crates/registry-server/tests/postgres_migration.rs, name: real_postgres_backfill_and_destructive_recovery_are_bounded_resumable_and_activation_closed}]} - {id: RS-V1-31, phase: W4, state: enforced, doneWhen: "Failed apply stays unavailable until exact fix-forward or restore reconciliation.", journeys: [RS-J13, RS-J15], evidence: [{path: crates/registry-server/tests/postgres_package.rs, name: real_postgres_package_startup_apply_failure_and_old_process_are_closed}, {path: crates/registry-server/tests/postgres_package.rs, name: signed_schema_fingerprint_mismatch_is_durably_failed_and_never_ready}, {path: crates/registry-server/tests/postgres_migration.rs, name: real_postgres_backfill_and_destructive_recovery_are_bounded_resumable_and_activation_closed}]} @@ -65,4 +65,4 @@ requirements: - {id: RS-V1-41, phase: W5, state: enforced, doneWhen: "The farmer project proves bounded CRS84, units, temporal tenure or activity, resumable import, and finite boundaries without PostGIS or domain runtime code.", journeys: [RS-J05], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: crates/registry-server/tests/postgres_data_farmer.rs, name: real_postgres_farmer_import_is_authenticated_chunked_resumable_and_race_safe}]} - {id: RS-V1-42, phase: W5, state: enforced, doneWhen: "The business project proves identifiers, filings, appointments, temporal constraints, and public/protected processing.", journeys: [RS-J06], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}]} - {id: RS-V1-43, phase: W5, state: enforced, doneWhen: "Five projects use the same binaries and Production compiler while absent fixtures remove routes and planted identifiers fail.", journeys: [RS-J07], evidence: [{path: crates/registry-server/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: products/registry-server/scripts/check-source-neutrality.sh, name: check-source-neutrality.sh}, {path: products/registry-server/scripts/test_check_source_neutrality.py, name: test_bare_domain_rust_type_identifier_is_rejected}]} - - {id: RS-V1-44, phase: W5, state: enforced, doneWhen: "A clean adopter checks, diffs, packages, applies, serves, upgrades, and recovers without Rust edits.", journeys: [RS-J02, RS-J17], evidence: [{path: crates/registry-serverctl/tests/cli.rs, name: init_creates_a_domain_neutral_project_that_checks_immediately}, {path: crates/registry-serverctl/tests/diff.rs, name: diff_inventory_is_deterministic_and_classification_direction_is_exact}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}]} + - {id: RS-V1-44, phase: W5, state: enforced, doneWhen: "A clean adopter creates a generic project, checks, tests, packages, applies, serves, queries, upgrades, and recovers without Rust edits.", journeys: [RS-J02, RS-J17], evidence: [{path: crates/registry-serverctl/tests/cli.rs, name: init_creates_a_domain_neutral_project_that_checks_immediately}, {path: crates/registry-serverctl/tests/diff.rs, name: diff_inventory_is_deterministic_and_classification_direction_is_exact}, {path: products/registry-server/scripts/test_quickstart.py, name: test_offline_self_test_passes_without_network}, {path: products/registry-server/quickstart/run.sh, name: run.sh}, {path: products/registry-server/scripts/test-adopter-workflow.sh, name: test-adopter-workflow.sh}]} diff --git a/products/registry-server/contracts/package-layout.yaml b/products/registry-server/contracts/package-layout.yaml index 5d016321a4..8b586ece9f 100644 --- a/products/registry-server/contracts/package-layout.yaml +++ b/products/registry-server/contracts/package-layout.yaml @@ -14,8 +14,10 @@ entries: - {path: database/migration-plan.json, role: migration-plan, required: true} - {path: openapi/openapi.json, role: generated-openapi, required: true} - {path: schemas, role: entity-json-schemas, required: true} - - {path: manifest/registry-manifest.json, role: lossy-manifest-projection, required: true} - - {path: manifest/dcat.jsonld, role: dcat-catalog-projection, required: true} + # These two artifacts form one conditional pair. A package includes both + # exactly when its governed project configures manifestProjection. + - {path: manifest/registry-manifest.json, role: lossy-manifest-projection, required: false} + - {path: manifest/dcat.jsonld, role: dcat-catalog-projection, required: false} - {path: source/modules//, role: source-module-asset, required: false} - {path: tests/journeys.yaml, role: fixture-journeys, required: true} - {path: signatures, role: package-signatures, required: false} diff --git a/products/registry-server/demo/support/demo.py b/products/registry-server/demo/support/demo.py index 94f21a609b..618c6d9cb3 100755 --- a/products/registry-server/demo/support/demo.py +++ b/products/registry-server/demo/support/demo.py @@ -228,7 +228,9 @@ def _runtime_config( """ else: event_destinations = "eventDestinations: {}\n" - return f"""listener: + return f"""apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +listener: bind: {bind} trustedProxy: direct identity: diff --git a/products/registry-server/demo/support/test_demo.py b/products/registry-server/demo/support/test_demo.py index 21ac082f53..db8feab816 100755 --- a/products/registry-server/demo/support/test_demo.py +++ b/products/registry-server/demo/support/test_demo.py @@ -77,6 +77,8 @@ def test_prepare_binds_mint_authority_static_jwks_and_secret_database_urls(self) self.assertNotIn("registry_purpose", no_purpose) runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") + self.assertIn("apiVersion: registry.registrystack.org/server-runtime/v1alpha1", runtime) + self.assertIn("kind: RegistryServerRuntimeConfig", runtime) self.assertIn("accessTokenType: at+jwt", runtime) self.assertIn("kind: static", runtime) self.assertIn("documentRef: secret:file/mint-jwks", runtime) diff --git a/products/registry-server/generated/asset-site-placement/generated/openapi.json b/products/registry-server/generated/asset-site-placement/generated/openapi.json index adc77706e3..bcbdcea12f 100644 --- a/products/registry-server/generated/asset-site-placement/generated/openapi.json +++ b/products/registry-server/generated/asset-site-placement/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"asset-item":{"$id":"urn:registry-server:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-placement":{"$id":"urn:registry-server:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"format":"date","type":"string"}},"required":["asset","site","validFrom"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site":{"$id":"urn:registry-server:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"siteCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["siteCode","label"],"type":"object","x-registry-mutationMode":"mutable"},"inspection-event":{"$id":"urn:registry-server:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result"],"type":"object","x-registry-mutationMode":"create_only"}}},"info":{"title":"asset-site-placement","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/assets":{"get":{"operationId":"records.asset-item.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-item.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"create"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset-item.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"get"},"patch":{"operationId":"records.asset-item.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"patch"}},"/v1/records/assets:batch":{"post":{"operationId":"records.asset-item.batch","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"items":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"operation":{"const":"create"}},"required":["operation","data"],"type":"object"},{"additionalProperties":false,"properties":{"ifMatch":{"type":"string"},"operation":{"const":"patch"},"patch":{"maxItems":128,"minItems":1,"type":"array"},"recordId":{"format":"uuid","type":"string"}},"required":["operation","recordId","ifMatch","patch"],"type":"object"}]},"maxItems":4,"minItems":1,"type":"array"}},"required":["items"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"results":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"etag":{"type":"string"},"id":{"format":"uuid","type":"string"},"operation":{"enum":["create","patch"]},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["operation","id","revision","etag","data"],"type":"object"},"maxItems":4,"minItems":1,"type":"array"}},"required":["results"],"type":"object"}}},"description":"Atomic batch committed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-maximumBytes":16384,"x-registry-maximumItems":4,"x-registry-operation":"batch"}},"/v1/records/inspections":{"get":{"operationId":"records.inspection-event.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.inspection-event.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"create"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.inspection-event.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"get"}},"/v1/records/placements":{"get":{"operationId":"records.asset-placement.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-placement.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"create"}},"/v1/records/placements/{record_id}":{"get":{"operationId":"records.asset-placement.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"get"},"patch":{"operationId":"records.asset-placement.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"patch"}},"/v1/records/placements:as-of":{"get":{"operationId":"records.asset-placement.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/placements:current":{"get":{"operationId":"records.asset-placement.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/sites":{"get":{"operationId":"records.asset-site.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.asset-site.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"create"}},"/v1/records/sites/{record_id}":{"get":{"operationId":"records.asset-site.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"get"},"patch":{"operationId":"records.asset-site.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"patch"}}}} \ No newline at end of file +{"components":{"schemas":{"Problem":{"additionalProperties":false,"properties":{"code":{"enum":["authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"asset-item":{"$id":"urn:registry-server:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-item-batch-input":{"$id":"urn:registry-server:entity:asset-item:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-item-create-input":{"$id":"urn:registry-server:entity:asset-item:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-placement":{"$id":"urn:registry-server:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"format":"date","type":"string"}},"required":["asset","site","validFrom"],"type":"object","x-registry-mutationMode":"mutable"},"asset-placement-create-input":{"$id":"urn:registry-server:entity:asset-placement:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"format":"date","type":"string"}},"required":["asset","site","validFrom"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site":{"$id":"urn:registry-server:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"siteCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["siteCode","label"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site-create-input":{"$id":"urn:registry-server:entity:asset-site:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"siteCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["siteCode","label"],"type":"object","x-registry-mutationMode":"mutable"},"inspection-event":{"$id":"urn:registry-server:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result"],"type":"object","x-registry-mutationMode":"create_only"},"inspection-event-create-input":{"$id":"urn:registry-server:entity:inspection-event:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result"],"type":"object","x-registry-mutationMode":"create_only"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"asset-site-placement","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/assets":{"get":{"operationId":"records.asset-item.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["assetClass","assetCode","label"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"assetCode"}],"kind":"list","maxPageSize":100,"profile":"site-planner","selectableProperties":["assetCode","label"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-item"},"post":{"operationId":"records.asset-item.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record created","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"create","x-registry-responseEntity":"asset-item"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset-item.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record returned","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"get","x-registry-responseEntity":"asset-item"},"patch":{"operationId":"records.asset-item.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":true,"properties":{"from":{"maxLength":1024,"type":"string"},"op":{"enum":["add","remove","replace","move","copy","test"],"type":"string"},"path":{"maxLength":1024,"type":"string"},"value":true},"required":["op","path"],"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record patched","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"patch","x-registry-responseEntity":"asset-item"}},"/v1/records/assets:batch":{"post":{"operationId":"records.asset-item.batch","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"items":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item-batch-input"},"operation":{"const":"create"}},"required":["operation","data"],"type":"object"},{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":6,"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"operation":{"const":"patch"},"patch":{"items":{"additionalProperties":true,"properties":{"from":{"maxLength":1024,"type":"string"},"op":{"enum":["add","remove","replace","move","copy","test"],"type":"string"},"path":{"maxLength":1024,"type":"string"},"value":true},"required":["op","path"],"type":"object"},"maxItems":128,"minItems":1,"type":"array"},"recordId":{"format":"uuid","type":"string"}},"required":["operation","recordId","ifMatch","patch"],"type":"object"}]},"maxItems":4,"minItems":1,"type":"array"}},"required":["items"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"results":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"etag":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"id":{"format":"uuid","type":"string"},"operation":{"enum":["create","patch"]},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["operation","id","revision","etag","data"],"type":"object"},"maxItems":4,"minItems":1,"type":"array"}},"required":["results"],"type":"object"}}},"description":"Atomic batch committed","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-maximumBytes":16384,"x-registry-maximumItems":4,"x-registry-operation":"batch","x-registry-responseEntity":"asset-item"}},"/v1/records/inspections":{"get":{"operationId":"records.inspection-event.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/inspection-event"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","observedAt","result"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"inspection-event"},"post":{"operationId":"records.inspection-event.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/inspection-event-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/inspection-event"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record created","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"create","x-registry-responseEntity":"inspection-event"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.inspection-event.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/inspection-event"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record returned","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"get","x-registry-responseEntity":"inspection-event"}},"/v1/records/placements":{"get":{"operationId":"records.asset-placement.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-placement"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null"],"property":"site"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"list","maxPageSize":100,"profile":"site-planner","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-placement"},"post":{"operationId":"records.asset-placement.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-placement-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-placement"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record created","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"create","x-registry-responseEntity":"asset-placement"}},"/v1/records/placements/{record_id}":{"get":{"operationId":"records.asset-placement.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-placement"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record returned","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"get","x-registry-responseEntity":"asset-placement"},"patch":{"operationId":"records.asset-placement.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":true,"properties":{"from":{"maxLength":1024,"type":"string"},"op":{"enum":["add","remove","replace","move","copy","test"],"type":"string"},"path":{"maxLength":1024,"type":"string"},"value":true},"required":["op","path"],"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-placement"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record patched","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"patch","x-registry-responseEntity":"asset-placement"}},"/v1/records/placements:as-of":{"get":{"operationId":"records.asset-placement.as-of","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-placement"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"as_of","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"as_of","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","scopeProperties":["asset"],"semantics":"start_inclusive_end_exclusive","startProperty":"validFrom"}},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null"],"property":"site"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"as_of","maxPageSize":100,"profile":"site-planner","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","scopeProperties":["asset"],"semantics":"start_inclusive_end_exclusive","startProperty":"validFrom"}}},"x-registry-responseEntity":"asset-placement"}},"/v1/records/placements:current":{"get":{"operationId":"records.asset-placement.current","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-placement"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"current","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"current","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","scopeProperties":["asset"],"semantics":"start_inclusive_end_exclusive","startProperty":"validFrom"}},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null"],"property":"site"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"current","maxPageSize":100,"profile":"site-planner","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","scopeProperties":["asset"],"semantics":"start_inclusive_end_exclusive","startProperty":"validFrom"}}},"x-registry-responseEntity":"asset-placement"}},"/v1/records/sites":{"get":{"operationId":"records.asset-site.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-site"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["label","siteCode"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"siteCode"}],"kind":"list","maxPageSize":100,"profile":"site-planner","selectableProperties":["label","siteCode"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-site"},"post":{"operationId":"records.asset-site.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-site-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-site"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record created","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"create","x-registry-responseEntity":"asset-site"}},"/v1/records/sites/{record_id}":{"get":{"operationId":"records.asset-site.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-site"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record returned","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"get","x-registry-responseEntity":"asset-site"},"patch":{"operationId":"records.asset-site.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":true,"properties":{"from":{"maxLength":1024,"type":"string"},"op":{"enum":["add","remove","replace","move","copy","test"],"type":"string"},"path":{"maxLength":1024,"type":"string"},"value":true},"required":["op","path"],"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-site"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record patched","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"patch","x-registry-responseEntity":"asset-site"}}}} \ No newline at end of file diff --git a/products/registry-server/generated/authoring/registry-project.schema.json b/products/registry-server/generated/authoring/registry-project.schema.json index cc1e5d5f32..dfdbe8d46b 100644 --- a/products/registry-server/generated/authoring/registry-project.schema.json +++ b/products/registry-server/generated/authoring/registry-project.schema.json @@ -3,13 +3,6 @@ "AccessGrantSource": { "additionalProperties": false, "properties": { - "actions": { - "items": { - "$ref": "#/$defs/Operation" - }, - "type": "array", - "uniqueItems": true - }, "allowCount": { "type": "boolean" }, @@ -34,89 +27,6 @@ }, "type": "array" }, - "readPaths": { - "items": { - "$ref": "#/$defs/ReadPathGrantSource" - }, - "type": "array" - }, - "readableFields": { - "default": [], - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true - }, - "revisionAccess": { - "default": false, - "type": "boolean" - }, - "rowBoundaries": { - "default": [], - "items": { - "$ref": "#/$defs/RowBoundarySource" - }, - "type": "array" - }, - "sortableFields": { - "default": [], - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true - }, - "writableFields": { - "default": [], - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true - } - }, - "required": [ - "entity", - "actions" - ], - "type": "object" - }, - "AccessProfileSource": { - "additionalProperties": false, - "properties": { - "allowCount": { - "type": "boolean" - }, - "allowDataExport": { - "default": false, - "type": "boolean" - }, - "anonymous": { - "default": false, - "type": "boolean" - }, - "default": { - "default": false, - "type": "boolean" - }, - "filterableFields": { - "default": [], - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true - }, - "id": { - "type": "string" - }, - "lookups": { - "items": { - "$ref": "#/$defs/LookupGrantSource" - }, - "type": "array" - }, "operations": { "items": { "$ref": "#/$defs/Operation" @@ -124,13 +34,6 @@ "type": "array", "uniqueItems": true }, - "principalClaim": { - "default": null, - "type": [ - "string", - "null" - ] - }, "readPaths": { "items": { "$ref": "#/$defs/ReadPathGrantSource" @@ -145,22 +48,6 @@ "type": "array", "uniqueItems": true }, - "requiredPurposes": { - "default": [], - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true - }, - "requiredScopes": { - "default": [], - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true - }, "revisionAccess": { "default": false, "type": "boolean" @@ -190,7 +77,7 @@ } }, "required": [ - "id", + "entity", "operations" ], "type": "object" @@ -982,13 +869,6 @@ "EntitySource": { "additionalProperties": false, "properties": { - "accessProfiles": { - "default": [], - "items": { - "$ref": "#/$defs/AccessProfileSource" - }, - "type": "array" - }, "batch": { "anyOf": [ { @@ -1875,6 +1755,10 @@ "ProjectAccessProfileSource": { "additionalProperties": false, "properties": { + "anonymous": { + "default": false, + "type": "boolean" + }, "default": { "default": false, "type": "boolean" @@ -1890,9 +1774,13 @@ "type": "string" }, "principalClaim": { - "type": "string" + "default": null, + "type": [ + "string", + "null" + ] }, - "purposes": { + "requiredPurposes": { "default": [], "items": { "type": "string" @@ -1910,8 +1798,7 @@ } }, "required": [ - "id", - "principalClaim" + "id" ], "type": "object" }, diff --git a/products/registry-server/generated/publicschema-household/generated/openapi.json b/products/registry-server/generated/publicschema-household/generated/openapi.json index 3703798410..76980a5622 100644 --- a/products/registry-server/generated/publicschema-household/generated/openapi.json +++ b/products/registry-server/generated/publicschema-household/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"group-membership":{"$id":"urn:registry-server:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"validFrom":{"format":"date","type":"string"},"validTo":{"format":"date","type":"string"}},"required":["person","household","relationship","validFrom"],"type":"object","x-registry-mutationMode":"mutable"},"household":{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"childCount":{"format":"int64","readOnly":true,"type":"integer"},"childUnder5Count":{"format":"int64","readOnly":true,"type":"integer"},"elderlyCount":{"format":"int64","readOnly":true,"type":"integer"},"headCount":{"format":"int64","readOnly":true,"type":"integer"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"},"singleHeaded":{"readOnly":true,"type":"boolean"},"womanHeaded":{"readOnly":true,"type":"boolean"}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:registry-server:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"dateOfBirth":{"format":"date","type":"string"},"familyName":{"maxLength":120,"minLength":0,"type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferredLanguage":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["personCode","legalName","personSex","residencyStatus"],"type":"object","x-registry-mutationMode":"mutable"}}},"info":{"title":"publicschema-household","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.group-membership.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"create"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get"},"patch":{"operationId":"records.group-membership.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"patch"}},"/v1/records/group-memberships:as-of":{"get":{"operationId":"records.group-membership.as-of","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"as_of"}},"/v1/records/group-memberships:current":{"get":{"operationId":"records.group-membership.current","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"current"}},"/v1/records/households":{"get":{"operationId":"records.household.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.household.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"get"},"patch":{"operationId":"records.household.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"patch"}},"/v1/records/households/{record_id}/people":{"get":{"operationId":"records.household.path.people","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list"}},"/v1/records/households:lookup":{"post":{"operationId":"records.household.lookup","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"lookup"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Select one compiled access profile.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request a total count when the compiled operation allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list"},"post":{"operationId":"records.person.create","responses":{"201":{"description":"Record created"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"create"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get"},"patch":{"operationId":"records.person.patch","responses":{"200":{"description":"Operation completed"}},"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"patch"}}}} \ No newline at end of file +{"components":{"schemas":{"Problem":{"additionalProperties":false,"properties":{"code":{"enum":["authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"group-membership":{"$id":"urn:registry-server:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"validFrom":{"format":"date","type":"string"},"validTo":{"format":"date","type":"string"}},"required":["person","household","relationship","validFrom"],"type":"object","x-registry-mutationMode":"mutable"},"group-membership-create-input":{"$id":"urn:registry-server:entity:group-membership:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"validFrom":{"format":"date","type":"string"},"validTo":{"format":"date","type":"string"}},"required":["person","household","relationship","validFrom"],"type":"object","x-registry-mutationMode":"mutable"},"household":{"$id":"urn:registry-server:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"childCount":{"format":"int64","readOnly":true,"type":"integer"},"childUnder5Count":{"format":"int64","readOnly":true,"type":"integer"},"elderlyCount":{"format":"int64","readOnly":true,"type":"integer"},"headCount":{"format":"int64","readOnly":true,"type":"integer"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"},"singleHeaded":{"readOnly":true,"type":"boolean"},"womanHeaded":{"readOnly":true,"type":"boolean"}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-mutationMode":"mutable"},"household-create-input":{"$id":"urn:registry-server:entity:household:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:registry-server:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"dateOfBirth":{"format":"date","type":"string"},"familyName":{"maxLength":120,"minLength":0,"type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferredLanguage":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["personCode","legalName","personSex","residencyStatus"],"type":"object","x-registry-mutationMode":"mutable"},"person-create-input":{"$id":"urn:registry-server:entity:person:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"dateOfBirth":{"format":"date","type":"string"},"familyName":{"maxLength":120,"minLength":0,"type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferredLanguage":{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["personCode","legalName","personSex","residencyStatus"],"type":"object","x-registry-mutationMode":"mutable"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"publicschema-household","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/group-membership"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"relationship"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["household","person","relationship","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"group-membership"},"post":{"operationId":"records.group-membership.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/group-membership-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/group-membership"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record created","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"create","x-registry-responseEntity":"group-membership"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/group-membership"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record returned","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get","x-registry-responseEntity":"group-membership"},"patch":{"operationId":"records.group-membership.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":true,"properties":{"from":{"maxLength":1024,"type":"string"},"op":{"enum":["add","remove","replace","move","copy","test"],"type":"string"},"path":{"maxLength":1024,"type":"string"},"value":true},"required":["op","path"],"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/group-membership"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record patched","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"patch","x-registry-responseEntity":"group-membership"}},"/v1/records/group-memberships:as-of":{"get":{"operationId":"records.group-membership.as-of","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/group-membership"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"as_of","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"relationship"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"as_of","maxPageSize":100,"profile":"household-operator","selectableProperties":["household","person","relationship","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","scopeProperties":["person"],"semantics":"start_inclusive_end_exclusive","startProperty":"validFrom"}}},"x-registry-responseEntity":"group-membership"}},"/v1/records/group-memberships:current":{"get":{"operationId":"records.group-membership.current","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/group-membership"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"current","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"relationship"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"current","maxPageSize":100,"profile":"household-operator","selectableProperties":["household","person","relationship","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","scopeProperties":["person"],"semantics":"start_inclusive_end_exclusive","startProperty":"validFrom"}}},"x-registry-responseEntity":"group-membership"}},"/v1/records/households":{"get":{"operationId":"records.household.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/household"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":true,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"administrativeArea"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childUnder5Count"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"elderlyCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"headCount"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdType"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"localHouseholdNumber"},{"operators":["equals","in","is_null","is_not_null"],"property":"singleHeaded"},{"operators":["equals","in","is_null","is_not_null"],"property":"womanHeaded"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["administrativeArea","childCount","childUnder5Count","elderlyCount","headCount","householdCode","householdName","householdType","localHouseholdNumber","singleHeaded","womanHeaded"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"childCount"},{"directions":["asc"],"property":"householdCode"},{"directions":["asc"],"property":"localHouseholdNumber"}],"temporal":null}},"x-registry-responseEntity":"household"},"post":{"operationId":"records.household.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/household-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/household"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record created","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create","x-registry-responseEntity":"household"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/household"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record returned","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"get","x-registry-responseEntity":"household"},"patch":{"operationId":"records.household.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":true,"properties":{"from":{"maxLength":1024,"type":"string"},"op":{"enum":["add","remove","replace","move","copy","test"],"type":"string"},"path":{"maxLength":1024,"type":"string"},"value":true},"required":["op","path"],"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/household"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record patched","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"patch","x-registry-responseEntity":"household"}},"/v1/records/households/{record_id}/people":{"get":{"operationId":"records.household.path.people","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":true,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"personSex"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"residencyStatus"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["dateOfBirth","familyName","legalName","personCode","personSex","residencyStatus"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"personCode"}],"temporal":null}},"x-registry-responseEntity":"person"}},"/v1/records/households:lookup":{"post":{"operationId":"records.household.lookup","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"selector":{"maxLength":128,"type":"string"},"values":{"additionalProperties":{"oneOf":[{"maxLength":1024,"type":"string"},{"format":"int64","type":"integer"},{"type":"boolean"}]},"maxProperties":16,"type":"object"}},"required":["selector"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/household"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Lookup resolved to one record","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"lookup.unresolved":{"value":{"code":"lookup.unresolved","detail":"The lookup did not resolve exactly one record.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:lookup.unresolved"}},"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"lookup","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"administrativeArea"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childUnder5Count"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"elderlyCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"headCount"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdType"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"localHouseholdNumber"},{"operators":["equals","in","is_null","is_not_null"],"property":"singleHeaded"},{"operators":["equals","in","is_null","is_not_null"],"property":"womanHeaded"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["administrativeArea","childCount","childUnder5Count","elderlyCount","headCount","householdCode","householdName","householdType","localHouseholdNumber","singleHeaded","womanHeaded"],"selectorProperties":["householdCode"],"sortableProperties":[{"directions":["asc"],"property":"childCount"},{"directions":["asc"],"property":"householdCode"},{"directions":["asc"],"property":"localHouseholdNumber"}],"temporal":null},"household-viewer":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"household-viewer","selectableProperties":["administrativeArea","householdCode","householdName","householdType","localHouseholdNumber"],"selectorProperties":["householdCode"],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"household"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"},"type":"array"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Always no-store for caller-bound read collections, lookup results, and revision history.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:query.invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"personCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"personSex"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"residencyStatus"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["dateOfBirth","familyName","legalName","personCode","personSex","preferredLanguage","residencyStatus"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"personCode"}],"temporal":null}},"x-registry-responseEntity":"person"},"post":{"operationId":"records.person.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record created","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"create","x-registry-responseEntity":"person"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record returned","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get","x-registry-responseEntity":"person"},"patch":{"operationId":"records.person.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Omit to use the route default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":true,"properties":{"from":{"maxLength":1024,"type":"string"},"op":{"enum":["add","remove","replace","move","copy","test"],"type":"string"},"path":{"maxLength":1024,"type":"string"},"value":true},"required":["op","path"],"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person"},"id":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["id","revision","data"],"type":"object"}}},"description":"Record patched","headers":{"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:authentication.refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:resource.not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:idempotency.conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:mutation.conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:unsupported.media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:precondition.required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:source.unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"urn:registry-server:problem:request.timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"patch","x-registry-responseEntity":"person"}}}} \ No newline at end of file diff --git a/products/registry-server/generated/runtime/runtime.schema.json b/products/registry-server/generated/runtime/runtime.schema.json new file mode 100644 index 0000000000..93c730f98d --- /dev/null +++ b/products/registry-server/generated/runtime/runtime.schema.json @@ -0,0 +1,849 @@ +{ + "$defs": { + "Classification": { + "enum": [ + "public", + "internal", + "restricted" + ], + "type": "string" + }, + "EventDestinationDnsFamily": { + "enum": [ + "dualStackStrict", + "ipv4Only" + ], + "type": "string" + }, + "EventDestinationNetworkProfile": { + "enum": [ + "productionHttps", + "loopbackDevelopmentHttp" + ], + "type": "string" + }, + "OidcAlgorithm": { + "enum": [ + "EdDSA", + "ES256", + "ES384", + "RS256", + "RS384" + ], + "type": "string" + }, + "RawAuditConfig": { + "additionalProperties": false, + "properties": { + "hashKeyRef": { + "maxLength": 140, + "minLength": 1, + "pattern": "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": "string" + } + }, + "required": [ + "hashKeyRef" + ], + "type": "object" + }, + "RawAuthenticationConfig": { + "additionalProperties": false, + "properties": { + "authorityClaims": { + "$ref": "#/$defs/RawAuthorityClaimsConfig" + }, + "oidc": { + "$ref": "#/$defs/RawOidcVerifierConfig" + } + }, + "required": [ + "oidc", + "authorityClaims" + ], + "type": "object" + }, + "RawAuthorityClaimsConfig": { + "additionalProperties": false, + "properties": { + "principal": { + "maxLength": 128, + "minLength": 1, + "not": { + "enum": [ + "iss", + "aud", + "exp", + "iat", + "nbf", + "sub", + "client_id", + "azp", + "jti", + "cnf" + ] + }, + "pattern": "^[\\x21-\\x7E]+$", + "type": "string" + }, + "purpose": { + "maxLength": 128, + "minLength": 1, + "not": { + "enum": [ + "iss", + "aud", + "exp", + "iat", + "nbf", + "sub", + "client_id", + "azp", + "jti", + "cnf" + ] + }, + "pattern": "^[\\x21-\\x7E]+$", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "principal" + ], + "type": "object" + }, + "RawCursorConfig": { + "additionalProperties": false, + "properties": { + "maxAgeSeconds": { + "default": 300, + "description": "Defaults to the bounded cursor validity lifetime.", + "format": "uint64", + "maximum": 86400, + "minimum": 1, + "type": "integer" + }, + "secretRef": { + "maxLength": 140, + "minLength": 1, + "pattern": "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": "string" + } + }, + "required": [ + "secretRef" + ], + "type": "object" + }, + "RawDatabaseConfig": { + "additionalProperties": false, + "properties": { + "migrationUrlRef": { + "maxLength": 140, + "minLength": 1, + "pattern": "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": "string" + }, + "pool": { + "$ref": "#/$defs/RawPoolBounds" + }, + "roles": { + "$ref": "#/$defs/RawSqlRoles" + }, + "runtimeUrlRef": { + "maxLength": 140, + "minLength": 1, + "pattern": "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": "string" + } + }, + "required": [ + "runtimeUrlRef", + "migrationUrlRef", + "pool", + "roles" + ], + "type": "object" + }, + "RawDeploymentIdentity": { + "additionalProperties": false, + "properties": { + "databaseId": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1F\\x7F](?:[^\\x00-\\x1F\\x7F]*[^\\s\\x00-\\x1F\\x7F])?$", + "type": "string" + }, + "databaseInitializationEnvironment": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1F\\x7F](?:[^\\x00-\\x1F\\x7F]*[^\\s\\x00-\\x1F\\x7F])?$", + "type": "string" + }, + "environment": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1F\\x7F](?:[^\\x00-\\x1F\\x7F]*[^\\s\\x00-\\x1F\\x7F])?$", + "type": "string" + }, + "instanceId": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1F\\x7F](?:[^\\x00-\\x1F\\x7F]*[^\\s\\x00-\\x1F\\x7F])?$", + "type": "string" + } + }, + "required": [ + "environment", + "instanceId", + "databaseId", + "databaseInitializationEnvironment" + ], + "type": "object" + }, + "RawEnvironmentSecretProviderConfig": { + "additionalProperties": false, + "type": "object" + }, + "RawEventDeliveryConfig": { + "additionalProperties": false, + "properties": { + "payloadRetentionDays": { + "default": 7, + "description": "Defaults to the bounded retained payload lifetime for pending or dead-letter webhook work.", + "format": "uint8", + "maximum": 30, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "RawEventDestinationConfig": { + "additionalProperties": false, + "properties": { + "allowedPrivateCidrs": { + "items": { + "type": "string" + }, + "maxItems": 16, + "type": "array", + "uniqueItems": true + }, + "classificationCeiling": { + "$ref": "#/$defs/Classification" + }, + "deliveryCeilings": { + "$ref": "#/$defs/RawEventDestinationDeliveryCeilings" + }, + "dnsFamily": { + "$ref": "#/$defs/EventDestinationDnsFamily" + }, + "hmacSha256KeyRef": { + "maxLength": 140, + "minLength": 1, + "pattern": "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": "string" + }, + "networkProfile": { + "$ref": "#/$defs/EventDestinationNetworkProfile" + }, + "origin": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "path": { + "maxLength": 4096, + "minLength": 1, + "pattern": "^/[\\x20-\\x22\\x24\\x26-\\x3E\\x40-\\x5B\\x5D-\\x7E]*$", + "type": "string" + }, + "tls": { + "anyOf": [ + { + "$ref": "#/$defs/RawEventDestinationTlsConfig" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "origin", + "path", + "networkProfile", + "dnsFamily", + "allowedPrivateCidrs", + "hmacSha256KeyRef", + "classificationCeiling", + "deliveryCeilings" + ], + "type": "object" + }, + "RawEventDestinationDeliveryCeilings": { + "additionalProperties": false, + "properties": { + "attemptTimeoutMilliseconds": { + "format": "uint32", + "maximum": 5000, + "minimum": 100, + "type": "integer" + }, + "maximumAttempts": { + "format": "uint8", + "maximum": 5, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "attemptTimeoutMilliseconds", + "maximumAttempts" + ], + "type": "object" + }, + "RawEventDestinationTlsConfig": { + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "caBundleRef": { + "maxLength": 140, + "minLength": 1, + "pattern": "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": "string" + } + }, + "required": [ + "caBundleRef" + ] + }, + { + "properties": { + "clientIdentityRef": { + "maxLength": 140, + "minLength": 1, + "pattern": "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": "string" + } + }, + "required": [ + "clientIdentityRef" + ] + } + ], + "properties": { + "caBundleRef": { + "maxLength": 140, + "minLength": 1, + "pattern": "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": [ + "string", + "null" + ] + }, + "clientIdentityRef": { + "maxLength": 140, + "minLength": 1, + "pattern": "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "RawFileSecretProviderConfig": { + "additionalProperties": false, + "properties": { + "root": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "root" + ], + "type": "object" + }, + "RawJwksCacheConfig": { + "additionalProperties": false, + "properties": { + "cacheTtlSeconds": { + "default": 600, + "description": "Defaults to the bounded JWKS cache time-to-live.", + "format": "uint64", + "maximum": 86400, + "minimum": 1, + "type": "integer" + }, + "maxDocumentBytes": { + "default": 65536, + "description": "Defaults to the bounded maximum JWKS document size.", + "format": "uint64", + "maximum": 1048576, + "minimum": 1, + "type": "integer" + }, + "negativeCacheTtlSeconds": { + "default": 60, + "description": "Defaults to the bounded JWKS negative-cache time-to-live.", + "format": "uint64", + "maximum": 3600, + "minimum": 1, + "type": "integer" + }, + "outageToleranceSeconds": { + "default": 900, + "description": "Defaults to the bounded cached-key outage tolerance.", + "format": "uint64", + "maximum": 86400, + "minimum": 0, + "type": "integer" + }, + "refreshCooldownSeconds": { + "default": 30, + "description": "Defaults to the bounded JWKS refresh cooldown.", + "format": "uint64", + "maximum": 3600, + "minimum": 1, + "type": "integer" + }, + "requestTimeoutMilliseconds": { + "default": 5000, + "description": "Defaults to the bounded JWKS fetch timeout.", + "format": "uint64", + "maximum": 30000, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "RawListenerConfig": { + "additionalProperties": false, + "properties": { + "bind": { + "type": "string" + }, + "trustedProxy": { + "$ref": "#/$defs/TrustedProxyPosture" + } + }, + "required": [ + "bind", + "trustedProxy" + ], + "type": "object" + }, + "RawOidcJwksSource": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "discovery", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "documentRef": { + "maxLength": 140, + "minLength": 1, + "pattern": "^(secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": "string" + }, + "kind": { + "const": "static", + "type": "string" + } + }, + "required": [ + "kind", + "documentRef" + ], + "type": "object" + } + ] + }, + "RawOidcVerifierConfig": { + "additionalProperties": false, + "properties": { + "accessTokenType": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1F\\x7F](?:[^\\x00-\\x1F\\x7F]*[^\\s\\x00-\\x1F\\x7F])?$", + "type": "string" + }, + "allowedAlgorithm": { + "$ref": "#/$defs/OidcAlgorithm" + }, + "allowedClients": { + "items": { + "maxLength": 512, + "minLength": 1, + "pattern": "^[^\\x00-\\x20\\x7F]+$", + "type": "string" + }, + "maxItems": 128, + "type": "array", + "uniqueItems": true + }, + "audience": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1F\\x7F](?:[^\\x00-\\x1F\\x7F]*[^\\s\\x00-\\x1F\\x7F])?$", + "type": "string" + }, + "deniedKids": { + "items": { + "maxLength": 512, + "minLength": 1, + "pattern": "^[^\\x00-\\x20\\x7F]+$", + "type": "string" + }, + "maxItems": 128, + "type": "array", + "uniqueItems": true + }, + "issuer": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1F\\x7F](?:[^\\x00-\\x1F\\x7F]*[^\\s\\x00-\\x1F\\x7F])?$", + "type": "string" + }, + "jwksCache": { + "$ref": "#/$defs/RawJwksCacheConfig", + "default": { + "cacheTtlSeconds": 600, + "maxDocumentBytes": 65536, + "negativeCacheTtlSeconds": 60, + "outageToleranceSeconds": 900, + "refreshCooldownSeconds": 30, + "requestTimeoutMilliseconds": 5000 + }, + "description": "Optional JWKS fetch and cache tuning. Defaults to bounded cache behavior." + }, + "jwksSource": { + "anyOf": [ + { + "$ref": "#/$defs/RawOidcJwksSource" + }, + { + "type": "null" + } + ] + }, + "leewayMilliseconds": { + "format": "uint64", + "maximum": 300000, + "minimum": 0, + "type": "integer" + }, + "maxTokenLifetimeSeconds": { + "format": "uint64", + "maximum": 3600, + "minimum": 1, + "type": "integer" + }, + "scopeClaim": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + "type": "string" + }, + "scopeSeparator": { + "maxLength": 1, + "minLength": 1, + "pattern": "^[^A-Za-z0-9\\x00-\\x1F\\x7F]$", + "type": "string" + } + }, + "required": [ + "issuer", + "audience", + "allowedAlgorithm", + "accessTokenType", + "scopeClaim", + "scopeSeparator", + "maxTokenLifetimeSeconds", + "leewayMilliseconds" + ], + "type": "object" + }, + "RawOperationalTimeouts": { + "additionalProperties": false, + "properties": { + "httpRequestMilliseconds": { + "default": 10000, + "description": "Defaults to the bounded per-request HTTP timeout.", + "format": "uint64", + "maximum": 60000, + "minimum": 1, + "type": "integer" + }, + "migrationLockMilliseconds": { + "default": 30000, + "description": "Defaults to the bounded migration lock timeout.", + "format": "uint64", + "maximum": 300000, + "minimum": 1, + "type": "integer" + }, + "migrationStatementMilliseconds": { + "default": 60000, + "description": "Defaults to the bounded migration statement timeout.", + "format": "uint64", + "maximum": 3600000, + "minimum": 1, + "type": "integer" + }, + "recordLockMilliseconds": { + "default": 5000, + "description": "Defaults to the bounded record lock timeout.", + "format": "uint64", + "maximum": 30000, + "minimum": 1, + "type": "integer" + }, + "shutdownGraceMilliseconds": { + "default": 30000, + "description": "Defaults to the bounded graceful-shutdown timeout.", + "format": "uint64", + "maximum": 300000, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "RawPackageConfig": { + "additionalProperties": false, + "properties": { + "activeRevision": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1F\\x7F](?:[^\\x00-\\x1F\\x7F]*[^\\s\\x00-\\x1F\\x7F])?$", + "type": "string" + }, + "activeSequence": { + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "compilerSourceRevision": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1F\\x7F](?:[^\\x00-\\x1F\\x7F]*[^\\s\\x00-\\x1F\\x7F])?$", + "type": "string" + }, + "root": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "trustAnchorPath": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "root", + "trustAnchorPath", + "compilerSourceRevision", + "activeRevision", + "activeSequence" + ], + "type": "object" + }, + "RawPoolBounds": { + "additionalProperties": false, + "properties": { + "createTimeoutMilliseconds": { + "default": 30000, + "description": "Defaults to the bounded PostgreSQL pool connection-creation timeout.", + "format": "uint64", + "maximum": 60000, + "minimum": 1, + "type": "integer" + }, + "maxSize": { + "format": "uint", + "maximum": 128, + "minimum": 1, + "type": "integer" + }, + "recycleTimeoutMilliseconds": { + "default": 30000, + "description": "Defaults to the bounded PostgreSQL pool connection-recycle timeout.", + "format": "uint64", + "maximum": 60000, + "minimum": 1, + "type": "integer" + }, + "waitTimeoutMilliseconds": { + "default": 30000, + "description": "Defaults to the bounded PostgreSQL pool wait timeout.", + "format": "uint64", + "maximum": 60000, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "maxSize" + ], + "type": "object" + }, + "RawSecretProvidersConfig": { + "additionalProperties": false, + "properties": { + "environment": { + "anyOf": [ + { + "$ref": "#/$defs/RawEnvironmentSecretProviderConfig" + }, + { + "type": "null" + } + ] + }, + "file": { + "anyOf": [ + { + "$ref": "#/$defs/RawFileSecretProviderConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "RawSqlRoles": { + "additionalProperties": false, + "properties": { + "migration": { + "maxLength": 63, + "minLength": 1, + "pattern": "^[_a-z][_a-z0-9]{0,62}$", + "type": "string" + }, + "runtime": { + "maxLength": 63, + "minLength": 1, + "pattern": "^[_a-z][_a-z0-9]{0,62}$", + "type": "string" + } + }, + "required": [ + "migration", + "runtime" + ], + "type": "object" + }, + "TrustedProxyPosture": { + "enum": [ + "direct", + "operator-controlled-upstream" + ], + "type": "string" + } + }, + "$id": "https://id.registrystack.org/schemas/registry-server/runtime/runtime.v1alpha1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "apiVersion": { + "const": "registry.registrystack.org/server-runtime/v1alpha1", + "type": "string" + }, + "audit": { + "$ref": "#/$defs/RawAuditConfig" + }, + "authentication": { + "$ref": "#/$defs/RawAuthenticationConfig" + }, + "cursor": { + "$ref": "#/$defs/RawCursorConfig" + }, + "database": { + "$ref": "#/$defs/RawDatabaseConfig" + }, + "eventDelivery": { + "$ref": "#/$defs/RawEventDeliveryConfig", + "default": { + "payloadRetentionDays": 7 + }, + "description": "Optional event-delivery tuning. Defaults to the server's bounded retention policy." + }, + "eventDestinations": { + "additionalProperties": { + "$ref": "#/$defs/RawEventDestinationConfig" + }, + "maxProperties": 128, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_-]{0,63}$", + "type": "string" + }, + "type": "object" + }, + "identity": { + "$ref": "#/$defs/RawDeploymentIdentity" + }, + "kind": { + "const": "RegistryServerRuntimeConfig", + "type": "string" + }, + "listener": { + "$ref": "#/$defs/RawListenerConfig" + }, + "operationalTimeouts": { + "$ref": "#/$defs/RawOperationalTimeouts", + "default": { + "httpRequestMilliseconds": 10000, + "migrationLockMilliseconds": 30000, + "migrationStatementMilliseconds": 60000, + "recordLockMilliseconds": 5000, + "shutdownGraceMilliseconds": 30000 + }, + "description": "Optional operational request, shutdown, locking, and migration timeout tuning." + }, + "package": { + "$ref": "#/$defs/RawPackageConfig" + }, + "secretProviders": { + "$ref": "#/$defs/RawSecretProvidersConfig" + } + }, + "required": [ + "apiVersion", + "kind", + "listener", + "identity", + "secretProviders", + "database", + "package", + "authentication", + "audit", + "cursor" + ], + "title": "Registry Server runtime configuration", + "type": "object" +} diff --git a/products/registry-server/quickstart/.gitignore b/products/registry-server/quickstart/.gitignore new file mode 100644 index 0000000000..1b6128f158 --- /dev/null +++ b/products/registry-server/quickstart/.gitignore @@ -0,0 +1 @@ +.run/ diff --git a/products/registry-server/quickstart/README.md b/products/registry-server/quickstart/README.md new file mode 100644 index 0000000000..d2abd0009b --- /dev/null +++ b/products/registry-server/quickstart/README.md @@ -0,0 +1,61 @@ +# Registry Server Generic Quickstart + +This is the shortest local adopter path for Registry Server. It starts a +domain-neutral registry from `registry-serverctl init`, checks it, runs +disposable PostgreSQL and Registry Mint on loopback, obtains a short-lived Mint +token, posts one record, and reads that record back from Registry Server. + +The launcher adds only local package identity to the initialized project before +`check`, `test`, and `package`. It does not add a manifest projection or a +domain model. + +Prerequisites are Docker, Cargo, OpenSSL, Python 3, and `uv`. + +```bash +products/registry-server/quickstart/run.sh +``` + +The first run builds `registry-server`, `registry-serverctl`, and `mint`, then +pulls the pinned PostgreSQL image if Docker does not already have it. When the +launcher prints `Registry Server generic quickstart is ready`, leave that +terminal running. + +In another terminal, read the created record: + +```bash +products/registry-server/quickstart/query.sh get +``` + +Or create and read another generic record: + +```bash +products/registry-server/quickstart/query.sh all +``` + +The helper reads the bearer token from `quickstart/.run/secrets/operator-token`. +It does not put the token on the command line or print it. The launcher writes +the local runtime configuration it used to `.run/runtime.yaml`. + +For a non-interactive check of the full local path, run: + +```bash +products/registry-server/quickstart/run.sh --smoke +``` + +For the offline structural self-test, which does not start Docker or use the +network, run: + +```bash +products/registry-server/quickstart/self-test.sh +``` + +All generated configuration, keys, tokens, logs, package artifacts, and +database URLs live under `quickstart/.run/`, which is ignored by Git and created +owner-only. A new run replaces only that quickstart-owned directory after +checking that it is not a symbolic link. + +This is deliberately a local-development route. It uses Mint's supervised +local-development profile, loopback HTTP, disposable PostgreSQL, and an unsigned +local package. Production pilots still require the separate package-signing, +database-role, migration, TLS, and operational lifecycle described in the +product README. diff --git a/products/registry-server/quickstart/query.sh b/products/registry-server/quickstart/query.sh new file mode 100755 index 0000000000..6eaec8d6d0 --- /dev/null +++ b/products/registry-server/quickstart/query.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +quickstart_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +run_dir="$quickstart_dir/.run" +action="${1:-list}" + +case "$action" in + create) + shift + if [[ $# -gt 2 ]]; then + printf '%s\n' 'usage: products/registry-server/quickstart/query.sh create [CODE [LABEL]]' >&2 + exit 2 + fi + code="${1:-QS-$(date +%s)}" + label="${2:-Quickstart record $code}" + python3 "$quickstart_dir/support/quickstart.py" request \ + --root "$run_dir" \ + --action create \ + --code "$code" \ + --label "$label" + ;; + get) + shift + if [[ $# -ne 1 ]]; then + printf '%s\n' 'usage: products/registry-server/quickstart/query.sh get RECORD_ID' >&2 + exit 2 + fi + python3 "$quickstart_dir/support/quickstart.py" request \ + --root "$run_dir" \ + --action get \ + --record-id "$1" + ;; + list) + shift + if [[ $# -ne 0 ]]; then + printf '%s\n' 'usage: products/registry-server/quickstart/query.sh list' >&2 + exit 2 + fi + python3 "$quickstart_dir/support/quickstart.py" request \ + --root "$run_dir" \ + --action list + ;; + all) + shift + if [[ $# -ne 0 ]]; then + printf '%s\n' 'usage: products/registry-server/quickstart/query.sh all' >&2 + exit 2 + fi + created_id=$(python3 "$quickstart_dir/support/quickstart.py" request \ + --root "$run_dir" \ + --action create \ + --code "QS-$(date +%s)" \ + --label "Quickstart record") + python3 "$quickstart_dir/support/quickstart.py" request \ + --root "$run_dir" \ + --action get \ + --record-id "$created_id" + ;; + *) + printf '%s\n' 'usage: products/registry-server/quickstart/query.sh [list|all|create [CODE [LABEL]]|get RECORD_ID]' >&2 + exit 2 + ;; +esac diff --git a/products/registry-server/quickstart/run.sh b/products/registry-server/quickstart/run.sh new file mode 100755 index 0000000000..cde5af9e08 --- /dev/null +++ b/products/registry-server/quickstart/run.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +set -euo pipefail + +quickstart_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +product_dir=$(cd -- "$quickstart_dir/.." && pwd) +repository_root=$(cd -- "$product_dir/../.." && pwd) +support="$quickstart_dir/support/quickstart.py" +run_dir="$quickstart_dir/.run" +mint_key_material="$repository_root/crates/registry-mint/demo/support/key_material.py" +postgres_image='postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675' +mode=serve + +for argument in "$@"; do + case "$argument" in + --smoke) + if [[ "$mode" == smoke ]]; then + printf '%s\n' 'the --smoke option may be supplied only once.' >&2 + exit 2 + fi + mode=smoke + ;; + *) + printf '%s\n' 'usage: products/registry-server/quickstart/run.sh [--smoke]' >&2 + exit 2 + ;; + esac +done + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf '%s\n' "$1 is required for the Registry Server generic quickstart." >&2 + exit 2 + fi +} + +for command in cargo docker openssl python3 uv; do + require_command "$command" +done + +case "$run_dir" in + "$quickstart_dir/.run") ;; + *) + printf '%s\n' 'quickstart run directory escaped its owned location.' >&2 + exit 2 + ;; +esac +if [[ -L "$run_dir" ]]; then + printf '%s\n' 'quickstart run directory must not be a symbolic link.' >&2 + exit 2 +fi +if [[ -d "$run_dir" ]]; then + rm -rf -- "$run_dir" +elif [[ -e "$run_dir" ]]; then + printf '%s\n' 'quickstart run path exists and is not a directory.' >&2 + exit 2 +fi +umask 077 +mkdir -m 700 "$run_dir" "$run_dir/secrets" "$run_dir/keys" "$run_dir/logs" "$run_dir/tls" + +mint_pid="" +server_pid="" +postgres_container="registry-server-quickstart-${PPID}-$$" +cleanup() { + if [[ -n "${server_pid:-}" ]]; then + kill "$server_pid" >/dev/null 2>&1 || true + wait "$server_pid" >/dev/null 2>&1 || true + fi + if [[ -n "${mint_pid:-}" ]]; then + kill "$mint_pid" >/dev/null 2>&1 || true + wait "$mint_pid" >/dev/null 2>&1 || true + fi + docker rm -f "$postgres_container" >/dev/null 2>&1 || true +} +trap cleanup EXIT HUP INT TERM + +ports=$(python3 "$support" ports) +read -r database_port mint_port server_port </dev/null + +registry_server="$repository_root/target/debug/registry-server" +registry_serverctl="$repository_root/target/debug/registry-serverctl" +mint="$repository_root/target/debug/mint" + +printf '%s\n' '== Initializing and checking a generic Registry project' +"$registry_serverctl" --format json init "$run_dir/project" >"$run_dir/init-report.json" +python3 "$support" assert-canonical-project --project "$run_dir/project" +python3 "$support" enrich-local-package --project "$run_dir/project" +"$registry_serverctl" --format json check "$run_dir/project" >"$run_dir/check-report.json" + +printf '%s\n' '== Generating disposable local keys and configuration' +uv run --quiet "$mint_key_material" p256 \ + --private-out "$run_dir/keys/mint/signing-p256-private-jwk" \ + --public-out "$run_dir/keys/mint-public.jwk.json" +uv run --quiet "$mint_key_material" p256 \ + --private-out "$run_dir/keys/operator/signing-p256-private-jwk" \ + --public-out "$run_dir/keys/operator-public.jwk.json" +uv run --quiet "$mint_key_material" secret-hex \ + --out "$run_dir/keys/mint/audit-hmac-key" +uv run --quiet "$mint_key_material" secret-hex \ + --out "$run_dir/secrets/audit-key" +uv run --quiet "$mint_key_material" secret-hex \ + --out "$run_dir/secrets/cursor-key" +openssl rand -hex 24 >"$run_dir/secrets/database-password" +chmod 600 "$run_dir/secrets/database-password" + +python3 "$support" prepare \ + --root "$run_dir" \ + --database-port "$database_port" \ + --mint-port "$mint_port" \ + --server-port "$server_port" + +openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 2 \ + -subj '/CN=Registry Server generic quickstart CA' \ + -keyout "$run_dir/tls/ca.key" -out "$run_dir/tls/ca.pem" >/dev/null 2>&1 +openssl req -new -nodes -newkey rsa:2048 \ + -subj '/CN=localhost' \ + -keyout "$run_dir/tls/server.key" -out "$run_dir/tls/server.csr" >/dev/null 2>&1 +printf '%s\n' 'subjectAltName=DNS:localhost' >"$run_dir/tls/server.ext" +openssl x509 -req -sha256 -days 2 \ + -in "$run_dir/tls/server.csr" \ + -CA "$run_dir/tls/ca.pem" \ + -CAkey "$run_dir/tls/ca.key" \ + -CAcreateserial \ + -extfile "$run_dir/tls/server.ext" \ + -out "$run_dir/tls/server.crt" >/dev/null 2>&1 +chmod 600 "$run_dir/tls/ca.key" "$run_dir/tls/server.key" +chmod 644 "$run_dir/tls/ca.pem" "$run_dir/tls/server.crt" + +printf '%s\n' '== Starting disposable PostgreSQL 17 with TLS' +docker run --detach --name "$postgres_container" \ + --env-file "$run_dir/database/postgres.env" \ + --publish "127.0.0.1:${database_port}:5432" \ + "$postgres_image" >"$run_dir/postgres-container-id" + +for attempt in $(seq 1 120); do + if [[ "$(docker exec "$postgres_container" cat /proc/1/comm)" == postgres ]] && + docker exec "$postgres_container" pg_isready -q -U postgres; then + break + fi + if [[ "$attempt" -eq 120 ]]; then + printf '%s\n' "PostgreSQL did not become ready; see $run_dir/logs." >&2 + exit 1 + fi + sleep 0.25 +done + +postgres_data_directory=$(docker exec "$postgres_container" sh -c 'printf %s "$PGDATA"') +case "$postgres_data_directory" in + /var/lib/postgresql/*) ;; + *) + printf '%s\n' 'PostgreSQL reported an unsafe data directory.' >&2 + exit 1 + ;; +esac +if [[ "$postgres_data_directory" == *..* ]]; then + printf '%s\n' 'PostgreSQL data directory contains parent traversal.' >&2 + exit 1 +fi +docker cp "$run_dir/tls/server.crt" "$postgres_container:$postgres_data_directory/server.crt" +docker cp "$run_dir/tls/server.key" "$postgres_container:$postgres_data_directory/server.key" +docker exec --user root "$postgres_container" sh -eu -c ' + chown postgres:postgres "$1/server.crt" "$1/server.key" + chmod 644 "$1/server.crt" + chmod 600 "$1/server.key" + printf "\nssl = on\nssl_cert_file = '\''server.crt'\''\nssl_key_file = '\''server.key'\''\n" >> "$1/postgresql.conf" + sed -i "s/^host /hostssl /" "$1/pg_hba.conf" +' sh "$postgres_data_directory" +docker exec --user postgres "$postgres_container" \ + pg_ctl -D "$postgres_data_directory" reload >/dev/null + +docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d postgres \ + <"$run_dir/database/bootstrap.sql" +docker exec "$postgres_container" createdb -U postgres registry_quickstart_test +docker exec "$postgres_container" createdb -U postgres registry_quickstart +docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d registry_quickstart_test \ + <"$run_dir/database/initialize.sql" +docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d registry_quickstart \ + <"$run_dir/database/initialize-runtime.sql" + +printf '%s\n' '== Starting Registry Mint for local schema-test credentials' +"$mint" serve --config "$run_dir/mint/mint.yaml" >"$run_dir/logs/mint.log" 2>&1 & +mint_pid=$! +python3 "$support" wait-http --url "http://127.0.0.1:${mint_port}/ready" --timeout 30 + +"$mint" token \ + --url "http://127.0.0.1:${mint_port}/token" \ + --client-id generic-quickstart \ + --key "$run_dir/keys/operator/signing-p256-private-jwk" | + python3 "$support" store-token --out "$run_dir/secrets/schema-test-token" + +printf '%s\n' '== Testing, packaging, and activating the local Registry' +export SSL_CERT_FILE="$run_dir/tls/ca.pem" +"$registry_serverctl" --format json test "$run_dir/project" \ + --runtime-config "$run_dir/runtime-test.yaml" \ + --credentials "$run_dir/schema-test-credentials.yaml" \ + --database-id generic-registry-local-db \ + --output "$run_dir/schema-test-receipt.json" \ + >"$run_dir/test-report.json" +schema_fingerprint=$(python3 "$support" json-field --path "$run_dir/test-report.json" --field schemaFingerprint) + +"$registry_serverctl" --format json package "$run_dir/project" \ + --database-id generic-registry-local-db \ + --schema-fingerprint "$schema_fingerprint" \ + --test-receipt "$run_dir/schema-test-receipt.json" \ + --output "$run_dir/build" \ + >"$run_dir/package-report.json" +package_revision=$(python3 "$support" json-field --path "$run_dir/package-report.json" --field packageRevision) +python3 "$support" render-runtime --root "$run_dir" --revision "$package_revision" + +"$registry_serverctl" apply \ + --runtime-config "$run_dir/runtime.yaml" \ + --package "$run_dir/build/package" \ + --initial >/dev/null +"$registry_serverctl" verify --runtime-config "$run_dir/runtime.yaml" >/dev/null + +printf '%s\n' '== Starting Registry Server on loopback' +REGISTRY_SERVER_LOG=error "$registry_server" --config "$run_dir/runtime.yaml" \ + >"$run_dir/logs/registry-server.log" 2>&1 & +server_pid=$! +python3 "$support" wait-http --url "http://127.0.0.1:${server_port}/ready" --timeout 30 + +printf '%s\n' '== Obtaining a short-lived operator token from Registry Mint' +"$mint" token \ + --url "http://127.0.0.1:${mint_port}/token" \ + --client-id generic-quickstart \ + --key "$run_dir/keys/operator/signing-p256-private-jwk" | + python3 "$support" store-token --out "$run_dir/secrets/operator-token" + +printf '%s\n' '== Posting and reading one generic record' +created_id=$(python3 "$support" request --root "$run_dir" --action create --code QS-001 --label 'Quickstart example record') +python3 "$support" request --root "$run_dir" --action get --record-id "$created_id" >"$run_dir/created-record.json" + +printf '\n%s\n' 'Registry Server generic quickstart is ready.' +printf ' Registry Server: http://127.0.0.1:%s\n' "$server_port" +printf ' Registry Mint: http://127.0.0.1:%s\n' "$mint_port" +printf ' Project: %s\n' "$run_dir/project" +printf ' Runtime config: %s\n' "$run_dir/runtime.yaml" +printf ' Operator token: %s\n' "$run_dir/secrets/operator-token" +printf ' Created record: %s\n' "$created_id" +printf ' GET helper: %s get %s\n' "$quickstart_dir/query.sh" "$created_id" +printf ' Logs: %s\n' "$run_dir/logs" + +if [[ "$mode" == smoke ]]; then + printf '%s\n' 'Registry Server generic quickstart smoke passed.' + exit 0 +fi + +printf '\n%s\n' 'Leave this terminal running. Press Ctrl-C to stop the services.' +while kill -0 "$mint_pid" >/dev/null 2>&1 && kill -0 "$server_pid" >/dev/null 2>&1; do + sleep 1 +done +printf '%s\n' "A quickstart service stopped unexpectedly; inspect $run_dir/logs." >&2 +exit 1 diff --git a/products/registry-server/quickstart/self-test.sh b/products/registry-server/quickstart/self-test.sh new file mode 100755 index 0000000000..7ebf5fecc0 --- /dev/null +++ b/products/registry-server/quickstart/self-test.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +quickstart_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) + +bash -n "$quickstart_dir/run.sh" +bash -n "$quickstart_dir/query.sh" +python3 -m py_compile "$quickstart_dir/support/quickstart.py" +python3 "$quickstart_dir/support/quickstart.py" self-test --quickstart-dir "$quickstart_dir" + +printf '%s\n' 'Registry Server generic quickstart self-test passed' diff --git a/products/registry-server/quickstart/support/quickstart.py b/products/registry-server/quickstart/support/quickstart.py new file mode 100755 index 0000000000..12b3337b05 --- /dev/null +++ b/products/registry-server/quickstart/support/quickstart.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +"""Small helpers for the generic Registry Server local quickstart.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import stat +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +AUDIENCE = "urn:registry-server:quickstart" +CLIENT_ID = "generic-quickstart" +DATABASE_ID = "generic-registry-local-db" +INSTANCE_ID = "generic_registry_local" +RUNTIME_DATABASE = "registry_quickstart" +TEST_DATABASE = "registry_quickstart_test" +MIGRATION_ROLE = "registry_quickstart_migration" +RUNTIME_ROLE = "registry_quickstart_runtime" +SOURCE_REVISION = "quickstart-source" +OPERATOR_PURPOSE = "registry-operations" + + +class QuickstartError(RuntimeError): + pass + + +def _write_new(path: Path, content: str, mode: int = 0o644) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(content) + path.chmod(mode) + + +def _write_json(path: Path, value: Any, mode: int = 0o644) -> None: + _write_new(path, json.dumps(value, sort_keys=True, separators=(",", ":")), mode) + + +def _read_json_object(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise QuickstartError(f"{path.name} must contain one JSON object") + return value + + +def _require_root(root: Path) -> Path: + if root.is_symlink(): + raise QuickstartError("quickstart root must not be a symbolic link") + resolved = root.resolve() + if not resolved.is_dir(): + raise QuickstartError("quickstart root must be an existing ordinary directory") + return resolved + + +def reserve_ports() -> tuple[int, int, int]: + listeners: list[socket.socket] = [] + try: + for _ in range(3): + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listeners.append(listener) + return tuple(listener.getsockname()[1] for listener in listeners) # type: ignore[return-value] + finally: + for listener in listeners: + listener.close() + + +def _mint_client(public_key: dict[str, Any]) -> str: + claims = { + "registry_principal": "generic-registry-operator", + "registry_purpose": OPERATOR_PURPOSE, + } + rendered_claims = "".join( + f" {name}: {json.dumps(value, ensure_ascii=True, separators=(',', ':'))}\n" + for name, value in sorted(claims.items()) + ) + return ( + f"clientId: {CLIENT_ID}\n" + f"principal: urn:registry-server:quickstart:{CLIENT_ID}\n" + "authorization:\n" + ' scopes: ["registry:generic:operate"]\n' + " claims:\n" + f"{rendered_claims}" + f"keys: [{json.dumps(public_key, sort_keys=True, separators=(',', ':'))}]\n" + ) + + +def _template_text(root: Path, revision: str, package_root: Path, runtime_database: bool) -> str: + origin = urllib.parse.urlparse((root / "server-origin").read_text(encoding="ascii").strip()) + mint_origin = (root / "mint-origin").read_text(encoding="ascii").strip() + if origin.scheme != "http" or origin.hostname != "127.0.0.1" or origin.port is None: + raise QuickstartError("server origin must be exact loopback HTTP") + if not revision.startswith("sha256:") or len(revision) != 71: + raise QuickstartError("package revision must be one SHA-256 identifier") + runtime_ref = "secret:file/runtime-database-url" + migration_ref = "secret:file/migration-database-url" + if not runtime_database: + runtime_ref = "secret:file/test-runtime-database-url" + migration_ref = "secret:file/test-migration-database-url" + return f"""apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +listener: + bind: 127.0.0.1:{origin.port} + trustedProxy: direct +identity: + environment: local + instanceId: {INSTANCE_ID} + databaseId: {DATABASE_ID} + databaseInitializationEnvironment: local +secretProviders: + file: + root: {root / 'secrets'} +database: + runtimeUrlRef: {runtime_ref} + migrationUrlRef: {migration_ref} + pool: + maxSize: 4 + roles: + migration: {MIGRATION_ROLE} + runtime: {RUNTIME_ROLE} +package: + root: {package_root} + trustAnchorPath: {root / 'trust-anchor.json'} + compilerSourceRevision: {SOURCE_REVISION} + activeRevision: {revision} + activeSequence: 1 +authentication: + oidc: + issuer: {mint_origin} + audience: {AUDIENCE} + allowedAlgorithm: ES256 + accessTokenType: at+jwt + scopeClaim: scope + scopeSeparator: " " + allowedClients: [{CLIENT_ID}] + deniedKids: [] + maxTokenLifetimeSeconds: 300 + leewayMilliseconds: 30000 + jwksSource: + kind: static + documentRef: secret:file/mint-jwks + authorityClaims: + principal: registry_principal + purpose: registry_purpose +audit: + hashKeyRef: secret:file/audit-key +cursor: + secretRef: secret:file/cursor-key +eventDestinations: {{}} +""" + + +def _journey_credentials(root: Path, token_name: str) -> str: + path = root / "project/tests/journeys.yaml" + source = path.read_text(encoding="utf-8") + journey = None + steps: list[str] = [] + for line in source.splitlines(): + stripped = line.strip() + if stripped.startswith("- id: ") and journey is None: + journey = stripped.removeprefix("- id: ").strip() + elif stripped.startswith("- id: ") and journey is not None: + steps.append(stripped.removeprefix("- id: ").strip()) + if not journey or not {"create-record", "get-record", "list-records"}.issubset(set(steps)): + raise QuickstartError("registry-serverctl init changed its generic journey shape") + bindings = "\n".join( + f" - {{journeyId: {journey}, stepId: {step}, credential: {{type: bearer, tokenRef: secret:file/{token_name}}}}}" + for step in steps + ) + return ( + "apiVersion: registry.registrystack.org/server-schema-test-credentials/v1\n" + "kind: SchemaTestCredentials\n" + "bindings:\n" + f"{bindings}\n" + ) + + +def prepare(root: Path, database_port: int, mint_port: int, server_port: int) -> None: + root = _require_root(root) + project = root / "project" + if not (project / "registry.yaml").is_file(): + raise QuickstartError("registry-serverctl init did not create registry.yaml") + password = (root / "secrets/database-password").read_text(encoding="ascii").strip() + if not password or any(character not in "0123456789abcdef" for character in password): + raise QuickstartError("database password must be non-empty lowercase hexadecimal") + mint_public = _read_json_object(root / "keys/mint-public.jwk.json") + operator_public = _read_json_object(root / "keys/operator-public.jwk.json") + kid = mint_public.get("kid") + if not isinstance(kid, str) or not kid: + raise QuickstartError("Mint public JWK must carry a key identifier") + mint_origin = f"http://127.0.0.1:{mint_port}" + server_origin = f"http://127.0.0.1:{server_port}" + _write_new(root / "mint-origin", mint_origin + "\n") + _write_new(root / "server-origin", server_origin + "\n") + _write_json(root / "secrets/mint-jwks", {"keys": [mint_public]}, 0o600) + _write_json(root / f"mint/public-keys/{kid}.jwk.json", mint_public) + _write_new(root / f"mint/clients/{CLIENT_ID}.yaml", _mint_client(operator_public)) + _write_new( + root / "mint/mint.yaml", + f"""version: 1 +validationMode: supervised-local-development +issuer: {mint_origin} +listener: {{address: 127.0.0.1, port: {mint_port}}} +signing: + algorithm: ES256 + activePublicJwkFile: public-keys/{kid}.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: local-jwk + privateKeyRef: secret:file/signing-p256-private-jwk +secretProviders: + file: {{root: {root / 'keys/mint'}}} +audit: + path: audit/mint.jsonl + maximumFileBytes: 10485760 + hashKeyRef: secret:file/audit-hmac-key + hashKeyVersion: 1 +accessTokens: + audiences: [{AUDIENCE}] + lifetimeSeconds: 300 +clientAssertion: + audience: {mint_origin}/token + maximumLifetimeSeconds: 120 + algorithms: [ES256] +clients: + directory: clients +""", + ) + encoded_password = urllib.parse.quote(password, safe="") + base = f"localhost:{database_port}" + _write_new( + root / "secrets/test-runtime-database-url", + f"postgresql://{RUNTIME_ROLE}:{encoded_password}@{base}/{TEST_DATABASE}", + 0o600, + ) + _write_new( + root / "secrets/test-migration-database-url", + f"postgresql://{MIGRATION_ROLE}:{encoded_password}@{base}/{TEST_DATABASE}", + 0o600, + ) + _write_new( + root / "secrets/runtime-database-url", + f"postgresql://{RUNTIME_ROLE}:{encoded_password}@{base}/{RUNTIME_DATABASE}", + 0o600, + ) + _write_new( + root / "secrets/migration-database-url", + f"postgresql://{MIGRATION_ROLE}:{encoded_password}@{base}/{RUNTIME_DATABASE}", + 0o600, + ) + _write_new( + root / "database/postgres.env", + f"POSTGRES_USER=postgres\nPOSTGRES_PASSWORD={password}\nPOSTGRES_DB=postgres\n", + 0o600, + ) + _write_new( + root / "database/bootstrap.sql", + f"""CREATE ROLE {MIGRATION_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}'; +CREATE ROLE {RUNTIME_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}'; +""", + 0o600, + ) + _write_new( + root / "database/initialize.sql", + f"""CREATE EXTENSION IF NOT EXISTS btree_gist; +REVOKE ALL ON DATABASE {TEST_DATABASE} FROM PUBLIC; +GRANT CONNECT ON DATABASE {TEST_DATABASE} TO {MIGRATION_ROLE}, {RUNTIME_ROLE}; +CREATE SCHEMA registry_internal AUTHORIZATION {MIGRATION_ROLE}; +CREATE SCHEMA registry_data AUTHORIZATION {MIGRATION_ROLE}; +CREATE SCHEMA registry_source AUTHORIZATION {MIGRATION_ROLE}; +CREATE SCHEMA registry_derived AUTHORIZATION {MIGRATION_ROLE}; +CREATE SCHEMA registry_context AUTHORIZATION {MIGRATION_ROLE}; +REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context FROM PUBLIC; +""", + ) + _write_new( + root / "database/initialize-runtime.sql", + (root / "database/initialize.sql").read_text(encoding="utf-8").replace(TEST_DATABASE, RUNTIME_DATABASE), + ) + _write_new(root / "trust-anchor.json", "{}") + (root / "empty-package").mkdir(mode=0o755) + _write_new( + root / "runtime-test.yaml", + _template_text(root, "sha256:" + "1" * 64, root / "empty-package", False), + ) + _write_new(root / "schema-test-credentials.yaml", _journey_credentials(root, "schema-test-token")) + + +def assert_canonical_project(project: Path) -> None: + if project.is_symlink() or not project.is_dir(): + raise QuickstartError("project must be an ordinary directory") + path = project / "registry.yaml" + source = path.read_text(encoding="utf-8") + if " purposes: " in source or " actions: " in source: + raise QuickstartError( + "registry-serverctl init emitted legacy access-profile keys; expected requiredPurposes and operations" + ) + if " requiredPurposes: [registry-operations]\n" not in source: + raise QuickstartError("registry-serverctl init output is missing requiredPurposes") + if " requiredScopes: [registry:generic:operate]\n" not in source: + raise QuickstartError("registry-serverctl init output is missing requiredScopes") + if " operations: [create, get, list, patch]\n" not in source: + raise QuickstartError("registry-serverctl init output is missing grant operations") + + +def enrich_local_package(project: Path) -> None: + if project.is_symlink() or not project.is_dir(): + raise QuickstartError("project must be an ordinary directory") + path = project / "registry.yaml" + source = path.read_text(encoding="utf-8") + if "\npackage:\n" in f"\n{source}": + raise QuickstartError("registry-serverctl init output already has package identity") + if "\nmanifestProjection:\n" in f"\n{source}": + raise QuickstartError("registry-serverctl init output must not include manifestProjection") + marker = "kind: RegistryProject\n" + if source.count(marker) != 1: + raise QuickstartError("registry-serverctl init output has an unexpected document header") + package = ( + "package:\n" + " environment: local\n" + f" instanceId: {INSTANCE_ID}\n" + " sequence: 1\n" + f" sourceRevision: {SOURCE_REVISION}\n" + ) + path.write_text(source.replace(marker, marker + package, 1), encoding="utf-8") + + +def render_runtime(root: Path, revision: str) -> None: + root = _require_root(root) + _write_new(root / "runtime.yaml", _template_text(root, revision, root / "build/package", True)) + + +def store_token(path: Path, source: bytes) -> None: + if len(source) > 64 * 1024: + raise QuickstartError("Mint returned an oversized token") + try: + value = source.decode("ascii").rstrip("\r\n") + except UnicodeDecodeError as error: + raise QuickstartError("Mint returned a non-ASCII token") from error + if value.count(".") != 2 or any(character.isspace() for character in value): + raise QuickstartError("Mint did not return one compact JWT") + _write_new(path, value, 0o600) + + +def wait_http(url: str, timeout_seconds: float) -> None: + deadline = time.monotonic() + timeout_seconds + last: int | None = None + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(url, timeout=2) as response: + last = response.status + except urllib.error.HTTPError as error: + last = error.code + except Exception: + last = None + if last == 200: + return + time.sleep(0.25) + raise QuickstartError(f"{url} did not become ready; last status was {last}") + + +def json_field(path: Path, field: str) -> None: + value: Any = _read_json_object(path) + for part in field.split("."): + if not isinstance(value, dict): + raise QuickstartError(f"{field} did not resolve to a scalar") + value = value[part] + if not isinstance(value, (str, int, float, bool)): + raise QuickstartError(f"{field} did not resolve to a scalar") + print(value) + + +def _token(root: Path) -> str: + path = root / "secrets/operator-token" + if not path.is_file() or path.is_symlink() or stat.S_IMODE(path.stat().st_mode) & 0o077: + raise QuickstartError("operator token must be an owner-only regular file") + value = path.read_text(encoding="ascii").strip() + if value.count(".") != 2: + raise QuickstartError("operator token does not contain one compact JWT") + return value + + +def _request( + root: Path, + method: str, + path: str, + body: dict[str, Any] | None, + idempotency_key: str | None = None, + expected: int = 200, +) -> dict[str, Any]: + origin = (root / "server-origin").read_text(encoding="ascii").strip() + headers = {"Accept": "application/json", "Authorization": f"Bearer {_token(root)}"} + data = None + if body is not None: + data = json.dumps(body, sort_keys=True, separators=(",", ":")).encode() + headers["Content-Type"] = "application/json" + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + request = urllib.request.Request(origin + path, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=10) as response: + response_bytes = response.read() + status = response.status + except urllib.error.HTTPError as error: + response_bytes = error.read() + status = error.code + if status != expected: + raise QuickstartError(f"{method} {path} returned {status}, expected {expected}") + document = json.loads(response_bytes) if response_bytes else {} + if not isinstance(document, dict): + raise QuickstartError(f"{method} {path} returned a non-object JSON response") + return document + + +def request(root: Path, action: str, code: str | None, label: str | None, record_id: str | None) -> None: + root = _require_root(root) + if action == "create": + if not code or not label: + raise QuickstartError("create requires code and label") + document = _request( + root, + "POST", + "/v1/records/records?accessProfile=operator", + {"data": {"code": code, "label": label}}, + f"quickstart-{code}", + 201, + ) + identifier = document.get("id") + if not isinstance(identifier, str): + raise QuickstartError("created record has no id") + print(identifier) + elif action == "get": + if not record_id: + raise QuickstartError("get requires a record id") + document = _request( + root, + "GET", + f"/v1/records/records/{urllib.parse.quote(record_id, safe='')}?accessProfile=operator", + None, + ) + print(json.dumps(document, indent=2, sort_keys=True)) + elif action == "list": + document = _request(root, "GET", "/v1/records/records?accessProfile=operator&$top=10", None) + print(json.dumps(document, indent=2, sort_keys=True)) + else: + raise QuickstartError("unknown request action") + + +def self_test(quickstart_dir: Path) -> None: + quickstart_dir = quickstart_dir.resolve() + required = [ + "run.sh", + "query.sh", + "self-test.sh", + ".gitignore", + "support/quickstart.py", + ] + for relative in required: + if not (quickstart_dir / relative).is_file(): + raise QuickstartError(f"missing quickstart file: {relative}") + run_source = (quickstart_dir / "run.sh").read_text(encoding="utf-8") + query_source = (quickstart_dir / "query.sh").read_text(encoding="utf-8") + readme_source = (quickstart_dir / "README.md").read_text(encoding="utf-8") + helper_source = (quickstart_dir / "support/quickstart.py").read_text(encoding="utf-8") + checks = [ + ('"$registry_serverctl" --format json init "$run_dir/project"', run_source), + ('assert-canonical-project --project "$run_dir/project"', run_source), + ('enrich-local-package --project "$run_dir/project"', run_source), + ('"$registry_serverctl" --format json check "$run_dir/project"', run_source), + ('"$mint" token', run_source), + ('store-token --out "$run_dir/secrets/operator-token"', run_source), + ('Authorization: Bearer ${', run_source), + ("TOKEN=", run_source), + ("databaseInitializationEnvironment: local", helper_source), + ("apiVersion: registry.registrystack.org/server-runtime/v1alpha1", helper_source), + ("kind: RegistryServerRuntimeConfig", helper_source), + ('INSTANCE_ID = "generic_registry_local"', helper_source), + ('SOURCE_REVISION = "quickstart-source"', helper_source), + ("requiredPurposes", helper_source), + ("operations", helper_source), + ('--action get', query_source), + ] + for needle, haystack in checks[:6] + checks[8:]: + if needle not in haystack: + raise QuickstartError(f"quickstart structure is missing {needle!r}") + for forbidden, haystack in checks[6:8]: + if forbidden in haystack: + raise QuickstartError(f"quickstart leaks token material through {forbidden!r}") + if (quickstart_dir / ".gitignore").read_text(encoding="utf-8").strip() != ".run/": + raise QuickstartError("quickstart disposable state must stay ignored") + removed_references = ( + "canonical" + "ize-project", + "sample" + "-records.jsonl", + "runtime" + "-config.template.yaml", + ) + for removed in removed_references: + if removed in run_source or removed in query_source or removed in readme_source: + raise QuickstartError(f"quickstart still references removed artifact or command: {removed}") + removed_defaults = ( + "jwks" + "Cache:", + "maxAge" + "Seconds:", + "operational" + "Timeouts:", + "wait" + "TimeoutMilliseconds:", + ) + for removed in removed_defaults: + if removed in helper_source: + raise QuickstartError(f"runtime renderer should rely on the default for {removed}") + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser() + commands = result.add_subparsers(dest="command", required=True) + commands.add_parser("ports") + prepare_parser = commands.add_parser("prepare") + prepare_parser.add_argument("--root", required=True, type=Path) + prepare_parser.add_argument("--database-port", required=True, type=int) + prepare_parser.add_argument("--mint-port", required=True, type=int) + prepare_parser.add_argument("--server-port", required=True, type=int) + canonical_project_parser = commands.add_parser("assert-canonical-project") + canonical_project_parser.add_argument("--project", required=True, type=Path) + package_parser = commands.add_parser("enrich-local-package") + package_parser.add_argument("--project", required=True, type=Path) + runtime_parser = commands.add_parser("render-runtime") + runtime_parser.add_argument("--root", required=True, type=Path) + runtime_parser.add_argument("--revision", required=True) + wait_parser = commands.add_parser("wait-http") + wait_parser.add_argument("--url", required=True) + wait_parser.add_argument("--timeout", required=True, type=float) + token_parser = commands.add_parser("store-token") + token_parser.add_argument("--out", required=True, type=Path) + field_parser = commands.add_parser("json-field") + field_parser.add_argument("--path", required=True, type=Path) + field_parser.add_argument("--field", required=True) + request_parser = commands.add_parser("request") + request_parser.add_argument("--root", required=True, type=Path) + request_parser.add_argument("--action", choices=("create", "get", "list"), required=True) + request_parser.add_argument("--code") + request_parser.add_argument("--label") + request_parser.add_argument("--record-id") + self_test_parser = commands.add_parser("self-test") + self_test_parser.add_argument("--quickstart-dir", required=True, type=Path) + return result + + +def main() -> int: + args = parser().parse_args() + try: + if args.command == "ports": + print(" ".join(str(port) for port in reserve_ports())) + elif args.command == "prepare": + prepare(args.root, args.database_port, args.mint_port, args.server_port) + elif args.command == "assert-canonical-project": + assert_canonical_project(args.project) + elif args.command == "enrich-local-package": + enrich_local_package(args.project) + elif args.command == "render-runtime": + render_runtime(args.root, args.revision) + elif args.command == "wait-http": + wait_http(args.url, args.timeout) + elif args.command == "store-token": + store_token(args.out, sys.stdin.buffer.read()) + elif args.command == "json-field": + json_field(args.path, args.field) + elif args.command == "request": + request(args.root, args.action, args.code, args.label, args.record_id) + elif args.command == "self-test": + self_test(args.quickstart_dir) + else: # pragma: no cover + raise AssertionError(args.command) + except (OSError, KeyError, json.JSONDecodeError, QuickstartError) as error: + print(error, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/registry-server/scripts/check-contracts.sh b/products/registry-server/scripts/check-contracts.sh index cf4369182a..bb4933a29b 100755 --- a/products/registry-server/scripts/check-contracts.sh +++ b/products/registry-server/scripts/check-contracts.sh @@ -10,6 +10,7 @@ python3 -m unittest \ "$script_dir/test_validate_product.py" \ "$script_dir/test_check_source_neutrality.py" \ "$script_dir/test_generated_gates.py" \ + "$script_dir/test_quickstart.py" \ "$script_dir/../demo/support/test_demo.py" echo "Registry Server product contracts passed" diff --git a/products/registry-server/scripts/check-generated.sh b/products/registry-server/scripts/check-generated.sh index 88cd1dbe0c..f191e7c39a 100755 --- a/products/registry-server/scripts/check-generated.sh +++ b/products/registry-server/scripts/check-generated.sh @@ -5,6 +5,7 @@ script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) repository_root=$(cd -- "$script_dir/../../.." && pwd) fixtures=(asset-site-placement publicschema-household) authoring_baseline="$repository_root/products/registry-server/generated/authoring" +runtime_baseline="$repository_root/products/registry-server/generated/runtime" temporary_root="" cleanup() { @@ -30,12 +31,17 @@ export CARGO_PROFILE_TEST_DEBUG=0 export RUSTC_WRAPPER="${RUSTC_WRAPPER-}" authoring_candidate="$temporary_root/authoring" +runtime_candidate="$temporary_root/runtime" mkdir "$authoring_candidate" +mkdir "$runtime_candidate" ( cd "$temporary_root" cargo run --manifest-path "$repository_root/Cargo.toml" --locked --quiet \ -p registry-server --features schema --example authoring-schema -- \ --output "$authoring_candidate" + cargo run --manifest-path "$repository_root/Cargo.toml" --locked --quiet \ + -p registry-server --features runtime,schema --example runtime-schema -- \ + --output "$runtime_candidate" for fixture_name in "${fixtures[@]}"; do candidate="$temporary_root/$fixture_name" mkdir "$candidate" @@ -56,6 +62,13 @@ if ! diff -ru "$authoring_baseline" "$authoring_candidate"; then exit 1 fi +if ! diff -ru "$runtime_baseline" "$runtime_candidate"; then + printf '%s\n' 'Registry Server runtime schema differs from the committed artifact.' >&2 + printf '%s\n' 'Regenerate it, then review the complete diff:' >&2 + printf '%s\n' ' cargo run -p registry-server --features runtime,schema --example runtime-schema -- --output products/registry-server/generated/runtime' >&2 + exit 1 +fi + for fixture_name in "${fixtures[@]}"; do python3 "$script_dir/compare-generated-tree.py" \ "$repository_root/products/registry-server/generated/$fixture_name" \ diff --git a/products/registry-server/scripts/test-adopter-workflow.sh b/products/registry-server/scripts/test-adopter-workflow.sh index 9f6f72cbd3..ef401bf866 100755 --- a/products/registry-server/scripts/test-adopter-workflow.sh +++ b/products/registry-server/scripts/test-adopter-workflow.sh @@ -255,6 +255,8 @@ render_runtime_config() { local listener=$8 local compiler_source_revision=$9 cat >"$output" < No "REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH", 'export SSL_CERT_FILE="$adopter_tls_ca_pem_path"', "umask 077", + "apiVersion: registry.registrystack.org/server-runtime/v1alpha1", + "kind: RegistryServerRuntimeConfig", "maxTokenLifetimeSeconds: 3600", '"exp": now + 3600', "jwksSource:", diff --git a/products/registry-server/scripts/test_quickstart.py b/products/registry-server/scripts/test_quickstart.py new file mode 100755 index 0000000000..a424593475 --- /dev/null +++ b/products/registry-server/scripts/test_quickstart.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import subprocess +import unittest +from pathlib import Path + + +PRODUCT_ROOT = Path(__file__).resolve().parents[1] +QUICKSTART = PRODUCT_ROOT / "quickstart" + + +class RegistryServerQuickstartTests(unittest.TestCase): + def test_offline_self_test_passes_without_network(self) -> None: + result = subprocess.run( + [str(QUICKSTART / "self-test.sh")], + cwd=PRODUCT_ROOT.parents[1], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertIn("self-test passed", result.stdout) + + def test_readme_keeps_local_and_production_paths_separate(self) -> None: + readme = (QUICKSTART / "README.md").read_text(encoding="utf-8") + self.assertIn("registry-serverctl init", readme) + self.assertIn("adds only local package identity", readme) + self.assertIn("quickstart/.run/secrets/operator-token", readme) + self.assertIn("does not put the token on the command line", readme) + self.assertIn("unsigned", readme) + self.assertIn("local package", readme) + self.assertIn("Production pilots still require", readme) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/registry-server/scripts/validate_product.py b/products/registry-server/scripts/validate_product.py index 7735139990..e20899dc43 100644 --- a/products/registry-server/scripts/validate_product.py +++ b/products/registry-server/scripts/validate_product.py @@ -54,8 +54,8 @@ ("database/migration-plan.json", "migration-plan", True), ("openapi/openapi.json", "generated-openapi", True), ("schemas", "entity-json-schemas", True), - ("manifest/registry-manifest.json", "lossy-manifest-projection", True), - ("manifest/dcat.jsonld", "dcat-catalog-projection", True), + ("manifest/registry-manifest.json", "lossy-manifest-projection", False), + ("manifest/dcat.jsonld", "dcat-catalog-projection", False), ("source/modules//", "source-module-asset", False), ("tests/journeys.yaml", "fixture-journeys", True), ("signatures", "package-signatures", False), From 85af495a09fa8df241f9101ad055104fd4fddc78 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 31 Aug 2026 01:43:27 +0700 Subject: [PATCH 11/19] fix(ci): configure registry server test environment Signed-off-by: Jeremi Joslin --- .github/scripts/test_ci_changes.py | 17 +++++++++++++---- .github/workflows/ci.yml | 22 +++++++++++++++------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 4c382b90b7..9f855da803 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -914,19 +914,27 @@ def test_registry_server_contracts_pin_postgresql_and_use_the_product_entry_poin registry_server_job, ) self.assertIn( - "REGISTRY_SERVER_TEST_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server", + "DATABASE_PORT: ${{ job.services.postgres.ports['5432'] }}", registry_server_job, ) self.assertIn( - "REGISTRY_SERVER_TEST_TLS_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server", + "REGISTRY_SERVER_TEST_DATABASE_URL=postgresql://registry_server:registry_server_test@localhost:${DATABASE_PORT}/registry_server", registry_server_job, ) self.assertIn( - "REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL: postgresql://registry_server:registry_server_test@127.0.0.1:${{ job.services.postgres.ports[5432] }}/registry_server", + "REGISTRY_SERVER_TEST_TLS_DATABASE_URL=postgresql://registry_server:registry_server_test@localhost:${DATABASE_PORT}/registry_server", registry_server_job, ) self.assertIn( - "REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }}", + "REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL=postgresql://registry_server:registry_server_test@127.0.0.1:${DATABASE_PORT}/registry_server", + registry_server_job, + ) + self.assertIn( + "POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }}", + registry_server_job, + ) + self.assertIn( + "TLS_CA_PEM_PATH: ${{ runner.temp }}/registry-server-postgres-trusted-ca.pem", registry_server_job, ) for entry_point in ( @@ -934,6 +942,7 @@ def test_registry_server_contracts_pin_postgresql_and_use_the_product_entry_poin "products/registry-server/scripts/test-postgres.sh", "products/registry-server/scripts/test-postgres-tls.sh", "products/registry-server/scripts/test-adopter-workflow.sh", + "products/registry-server/quickstart/run.sh --smoke", ): with self.subTest(entry_point=entry_point): self.assertIn(entry_point, registry_server_job) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d53dd5316c..d004abd560 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -614,13 +614,6 @@ jobs: --health-retries 5 ports: - 5432/tcp - env: - REGISTRY_SERVER_TEST_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server - REGISTRY_SERVER_TEST_TLS_DATABASE_HOST: localhost - REGISTRY_SERVER_TEST_TLS_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server - REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL: postgresql://registry_server:registry_server_test@127.0.0.1:${{ job.services.postgres.ports[5432] }}/registry_server - REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }} - REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH: ${{ runner.temp }}/registry-server-postgres-trusted-ca.pem steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -628,6 +621,21 @@ jobs: persist-credentials: false submodules: false + - name: Configure Registry Server test environment + env: + DATABASE_PORT: ${{ job.services.postgres.ports['5432'] }} + POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }} + TLS_CA_PEM_PATH: ${{ runner.temp }}/registry-server-postgres-trusted-ca.pem + run: | + { + echo "REGISTRY_SERVER_TEST_DATABASE_URL=postgresql://registry_server:registry_server_test@localhost:${DATABASE_PORT}/registry_server" + echo "REGISTRY_SERVER_TEST_TLS_DATABASE_HOST=localhost" + echo "REGISTRY_SERVER_TEST_TLS_DATABASE_URL=postgresql://registry_server:registry_server_test@localhost:${DATABASE_PORT}/registry_server" + echo "REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL=postgresql://registry_server:registry_server_test@127.0.0.1:${DATABASE_PORT}/registry_server" + echo "REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID=${POSTGRES_CONTAINER_ID}" + echo "REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH=${TLS_CA_PEM_PATH}" + } >> "${GITHUB_ENV}" + - name: Cache Cargo registry uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 with: From 38445efc40755f0a81e3d2b5acf1de503e0c6086 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 31 Aug 2026 01:47:14 +0700 Subject: [PATCH 12/19] fix(registry-server): close schema identifier catalog Signed-off-by: Jeremi Joslin --- .../content/docs/explanation/threat-model.mdx | 2 +- .../identifiers/contracts/catalog-source.json | 16 ++++++++ .../identifiers/generated/catalog.v1.json | 38 ++++++++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/docs/site/src/content/docs/explanation/threat-model.mdx b/docs/site/src/content/docs/explanation/threat-model.mdx index 5f6259dd79..ea8710874a 100644 --- a/docs/site/src/content/docs/explanation/threat-model.mdx +++ b/docs/site/src/content/docs/explanation/threat-model.mdx @@ -362,7 +362,7 @@ through a governed read, and privacy regressions that expose raw subject identif allowance rather than by construction: only a `local` assurance bundle may declare it, only at a canonical numeric-loopback origin, and production and evidence-grade bundles reject that kind. {/* Evidence: valid_secret_reference(), crates/registry-relay-v2/src/contract.rs; - parse_reference(), validate_file_metadata(), and the tests + SecretReference, validate_file_metadata(), and the tests references_use_only_the_two_exact_contract_grammars, file_secret_accepts_only_owner_read_and_optional_owner_write_modes, and file_secret_rejects_every_name_for_a_hard_link in diff --git a/products/identifiers/contracts/catalog-source.json b/products/identifiers/contracts/catalog-source.json index 27d4fff3f7..d2ae20bc99 100644 --- a/products/identifiers/contracts/catalog-source.json +++ b/products/identifiers/contracts/catalog-source.json @@ -107,6 +107,22 @@ "status": "active", "compatibilityLine": "v2alpha1", "description": "Relay V2 authoring JSON Schema." + }, + { + "glob": "products/registry-server/generated/authoring/*.schema.json", + "sourcePath": "crates/registry-server/src/schema.rs", + "owner": "registry-server", + "status": "active", + "compatibilityLine": "v1alpha1", + "description": "Registry Server project authoring JSON Schema." + }, + { + "glob": "products/registry-server/generated/runtime/*.schema.json", + "sourcePath": "crates/registry-server/src/schema.rs", + "owner": "registry-server", + "status": "active", + "compatibilityLine": "v1alpha1", + "description": "Registry Server runtime configuration JSON Schema." } ], "records": [ diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index e15eef4161..2566cf4298 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -12,7 +12,7 @@ "description": "JSON-LD namespace for Registry Manifest terms.", "source": { "path": "crates/registry-manifest-core/src/lib.rs", - "sha256": "ac1ef80a34aa0e477702fd5c5b61f715fd4f4cd8f4982c5e914ec8665ef6bf92" + "sha256": "0db88429a6353a5a097a76434f5a5e7009556bf80e5066e2df6b643315d4cb81" } }, { @@ -847,6 +847,42 @@ "mediaType": "application/schema+json" } }, + { + "uri": "https://id.registrystack.org/schemas/registry-server/authoring/registry-project.v1alpha1.schema.json", + "kind": "schema", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "registry-server", + "title": "Registry Server authored project", + "description": "Registry Server project authoring JSON Schema.", + "source": { + "path": "crates/registry-server/src/schema.rs", + "sha256": "b8adb47a1ae445395121e3d51f4d7dac93e5465032cef5c55b47430ed4f3ee80" + }, + "artifact": { + "path": "products/registry-server/generated/authoring/registry-project.schema.json", + "sha256": "d553fe4ce669a36018a520ae7f16cf7c33837a4c7000027b831598d744fc7187", + "mediaType": "application/schema+json" + } + }, + { + "uri": "https://id.registrystack.org/schemas/registry-server/runtime/runtime.v1alpha1.schema.json", + "kind": "schema", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "registry-server", + "title": "Registry Server runtime configuration", + "description": "Registry Server runtime configuration JSON Schema.", + "source": { + "path": "crates/registry-server/src/schema.rs", + "sha256": "b8adb47a1ae445395121e3d51f4d7dac93e5465032cef5c55b47430ed4f3ee80" + }, + "artifact": { + "path": "products/registry-server/generated/runtime/runtime.schema.json", + "sha256": "76ab2845bff3f137c6c8640725d74879332d991544543bdb8c86821613653b9b", + "mediaType": "application/schema+json" + } + }, { "uri": "https://id.registrystack.org/vocab/codelist", "kind": "vocabulary-term", From 268c78308ecb29dfaf25a2c026f3bda8d3beabfd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 31 Aug 2026 02:06:05 +0700 Subject: [PATCH 13/19] fix(registry-server): close repository gates Signed-off-by: Jeremi Joslin --- crates/registry-server/src/compiler.rs | 5 +---- crates/registry-server/src/data.rs | 8 ++++---- crates/registry-server/src/runtime_config.rs | 4 ++-- crates/registry-server/tests/migration_plan.rs | 12 ++++++++++-- crates/registry-server/tests/package_change_plan.rs | 12 ++++++++++-- crates/registry-server/tests/postgres_migration.rs | 12 ++++++++++-- docs/site/src/data/contracts.yaml | 2 +- docs/site/src/data/generated/contracts.json | 2 +- products/registry-server/scripts/validate_product.py | 11 ++++++----- release/scripts/test_registry_release.py | 1 + 10 files changed, 46 insertions(+), 23 deletions(-) diff --git a/crates/registry-server/src/compiler.rs b/crates/registry-server/src/compiler.rs index 9d58d8baa9..ac604ee4b2 100644 --- a/crates/registry-server/src/compiler.rs +++ b/crates/registry-server/src/compiler.rs @@ -2934,10 +2934,7 @@ fn asset_map<'a>( ) -> BTreeMap<(Option, String), &'a [u8]> { let mut map = BTreeMap::new(); for asset in assets { - if !asset - .module - .as_deref() - .is_none_or(|module| !module.is_empty()) + if asset.module.as_deref().is_some_and(str::is_empty) || !valid_relative_sql_path(&asset.path) || asset.bytes.is_empty() || asset.bytes.len() > MAX_DERIVED_SQL_BYTES diff --git a/crates/registry-server/src/data.rs b/crates/registry-server/src/data.rs index 1920c8b6e8..2ae3f55e41 100644 --- a/crates/registry-server/src/data.rs +++ b/crates/registry-server/src/data.rs @@ -1722,7 +1722,7 @@ fn validate_import_response( .ok_or(DataError::InvalidResponse)?; if result["operation"].as_str() != Some(expected_operation) || !result["id"].as_str().is_some_and(valid_uuid) - || !result["revision"].as_u64().is_some_and(|value| value > 0) + || result["revision"].as_u64().is_none_or(|value| value == 0) || !result["etag"].as_str().is_some_and(valid_strong_etag) { return Err(DataError::InvalidResponse); @@ -1762,9 +1762,9 @@ fn validate_export_response( return Err(DataError::InvalidResponse); } if object.get("count").is_some_and(|count| { - !count + count .as_u64() - .is_some_and(|count| count >= items.len() as u64) + .is_none_or(|count| count < items.len() as u64) }) { return Err(DataError::InvalidResponse); } @@ -1785,7 +1785,7 @@ fn validate_export_response( require_exact_keys(item, &["id", "revision", "data"]) .map_err(|_| DataError::InvalidResponse)?; if !item["id"].as_str().is_some_and(valid_uuid) - || !item["revision"].as_u64().is_some_and(|value| value > 0) + || item["revision"].as_u64().is_none_or(|value| value == 0) { return Err(DataError::InvalidResponse); } diff --git a/crates/registry-server/src/runtime_config.rs b/crates/registry-server/src/runtime_config.rs index b6eb7fa0ae..f0c9b19bb2 100644 --- a/crates/registry-server/src/runtime_config.rs +++ b/crates/registry-server/src/runtime_config.rs @@ -311,9 +311,9 @@ fn contains_governed_member(value: &serde_norway::Value) -> bool { // Destination-map keys are compiler-issued logical ids. Do not // reinterpret an id such as `events` as a governed field; the // strict destination value type rejects every undeployed key. - || (!key + || (key .as_str() - .is_some_and(|key| key == "eventDestinations") + .is_none_or(|key| key != "eventDestinations") && contains_governed_member(value)) }), serde_norway::Value::Sequence(values) => values.iter().any(contains_governed_member), diff --git a/crates/registry-server/tests/migration_plan.rs b/crates/registry-server/tests/migration_plan.rs index a04c46ec17..be0dc87927 100644 --- a/crates/registry-server/tests/migration_plan.rs +++ b/crates/registry-server/tests/migration_plan.rs @@ -90,7 +90,11 @@ fn reviewed_migration_plan_closes_ast_sql_and_bound_evidence() { let root = tempfile::Builder::new() .prefix("registry-migration-plan-") - .tempdir_in("/private/tmp") + .tempdir_in( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) .expect("temporary package parent"); let package = root.path().join("package"); prepared @@ -427,7 +431,11 @@ fn reviewed_migration_plan_rejects_uncovered_changes_forbidden_sql_and_unbound_e .expect("valid reviewed package prepares before tamper"); let root = tempfile::Builder::new() .prefix("registry-migration-plan-") - .tempdir_in("/private/tmp") + .tempdir_in( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) .expect("temporary package parent"); let package = root.path().join("package"); prepared diff --git a/crates/registry-server/tests/package_change_plan.rs b/crates/registry-server/tests/package_change_plan.rs index 5516e35f80..2abe84872c 100644 --- a/crates/registry-server/tests/package_change_plan.rs +++ b/crates/registry-server/tests/package_change_plan.rs @@ -725,7 +725,11 @@ fn tampered_package_never_returns_a_migration_summary_or_canary() { .expect("initial package prepares"); let root = tempfile::Builder::new() .prefix("registry-package-summary-tamper-") - .tempdir_in("/private/tmp") + .tempdir_in( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) .expect("temporary package parent creates"); let package = root.path().join("package"); prepared @@ -1560,7 +1564,11 @@ fn inspect_prepared( ) -> registry_server::package::IntegrityInspectedPackage { let root = tempfile::Builder::new() .prefix("registry-package-summary-") - .tempdir_in("/private/tmp") + .tempdir_in( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) .expect("temporary package parent creates"); let package = root.path().join("package"); prepared diff --git a/crates/registry-server/tests/postgres_migration.rs b/crates/registry-server/tests/postgres_migration.rs index deb649f53d..14bac98e20 100644 --- a/crates/registry-server/tests/postgres_migration.rs +++ b/crates/registry-server/tests/postgres_migration.rs @@ -212,7 +212,11 @@ async fn real_postgres_backfill_and_destructive_recovery_are_bounded_resumable_a assert!(!view_transition[0].sql.contains("CASCADE")); let backup_root = tempfile::Builder::new() .prefix("registry-backup-canary-") - .tempdir_in("/private/tmp") + .tempdir_in( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) .expect("backup temporary directory creates"); let backup_path = backup_root.path().join("path-record-sql-canary.backup"); fs::write(&backup_path, &backup_bytes).expect("restorable backup artifact writes"); @@ -836,7 +840,11 @@ fn publish_and_load( ) -> VerifiedPackage { let root = tempfile::Builder::new() .prefix("registry-reviewed-runtime-") - .tempdir_in("/private/tmp") + .tempdir_in( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) .expect("package temporary directory creates"); let package = root.path().join("package"); prepared diff --git a/docs/site/src/data/contracts.yaml b/docs/site/src/data/contracts.yaml index d699b9e74e..06593c54f5 100644 --- a/docs/site/src/data/contracts.yaml +++ b/docs/site/src/data/contracts.yaml @@ -35,7 +35,7 @@ surface: Portable `metadata.yaml` documents, compiled metadata model, public services, forms, policies, requirements, evidence type lists, evidence offering metadata, and evaluation profile metadata. source_of_truth: label: Registry Manifest core - url: https://github.com/registrystack/registry-stack/blob/v0.23.0/crates/registry-manifest-core/src/lib.rs + url: https://github.com/registrystack/registry-stack/blob/b26275aad1a85a14b99e0898abda401ab938872d/crates/registry-manifest-core/src/lib.rs consumer_note: Runtime source paths, scopes, table or view names, endpoint locations, credentials, and other deployment bindings belong in consuming service configuration, not manifests. - id: registry-manifest.cpsv-ap-service-catalogue name: CPSV-AP Service Catalogue Render Contract diff --git a/docs/site/src/data/generated/contracts.json b/docs/site/src/data/generated/contracts.json index 250f652260..471c11023c 100644 --- a/docs/site/src/data/generated/contracts.json +++ b/docs/site/src/data/generated/contracts.json @@ -31,7 +31,7 @@ "surface": "Portable `metadata.yaml` documents, compiled metadata model, public services, forms, policies, requirements, evidence type lists, evidence offering metadata, and evaluation profile metadata.", "source_of_truth": { "label": "Registry Manifest core", - "url": "https://github.com/registrystack/registry-stack/blob/v0.23.0/crates/registry-manifest-core/src/lib.rs" + "url": "https://github.com/registrystack/registry-stack/blob/b26275aad1a85a14b99e0898abda401ab938872d/crates/registry-manifest-core/src/lib.rs" }, "consumer_note": "Runtime source paths, scopes, table or view names, endpoint locations, credentials, and other deployment bindings belong in consuming service configuration, not manifests." }, diff --git a/products/registry-server/scripts/validate_product.py b/products/registry-server/scripts/validate_product.py index e20899dc43..56bc460283 100644 --- a/products/registry-server/scripts/validate_product.py +++ b/products/registry-server/scripts/validate_product.py @@ -495,11 +495,12 @@ def validate_postgres_tls_entrypoint(errors: list[str]) -> None: workflow = CI_WORKFLOW.read_text(encoding="utf-8") required_workflow_fragments = ( "ports:\n - 5432/tcp", - "REGISTRY_SERVER_TEST_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server", - "REGISTRY_SERVER_TEST_TLS_DATABASE_URL: postgresql://registry_server:registry_server_test@localhost:${{ job.services.postgres.ports[5432] }}/registry_server", - "REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL: postgresql://registry_server:registry_server_test@127.0.0.1:${{ job.services.postgres.ports[5432] }}/registry_server", - "REGISTRY_SERVER_TEST_TLS_POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }}", - "REGISTRY_SERVER_TEST_TLS_CA_PEM_PATH: ${{ runner.temp }}/registry-server-postgres-trusted-ca.pem", + "DATABASE_PORT: ${{ job.services.postgres.ports['5432'] }}", + "REGISTRY_SERVER_TEST_DATABASE_URL=postgresql://registry_server:registry_server_test@localhost:${DATABASE_PORT}/registry_server", + "REGISTRY_SERVER_TEST_TLS_DATABASE_URL=postgresql://registry_server:registry_server_test@localhost:${DATABASE_PORT}/registry_server", + "REGISTRY_SERVER_TEST_TLS_HOSTNAME_MISMATCH_DATABASE_URL=postgresql://registry_server:registry_server_test@127.0.0.1:${DATABASE_PORT}/registry_server", + "POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }}", + "TLS_CA_PEM_PATH: ${{ runner.temp }}/registry-server-postgres-trusted-ca.pem", "run: products/registry-server/scripts/test-postgres-tls.sh", ) for fragment in required_workflow_fragments: diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index e6a8d20a05..1c42b527b9 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -825,6 +825,7 @@ def test_required_rust_context_aggregates_path_gated_shards(self) -> None: "discovery-contracts", "evidence-contracts", "identifiers", + "registry-server-contracts", "relay-client-contracts", "relay-v2-contracts", }, From 13d0db727c0396c606ad18fe59de28a022c1a80f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 31 Aug 2026 02:18:26 +0700 Subject: [PATCH 14/19] test(registry-server): align mutation discovery contract Signed-off-by: Jeremi Joslin --- .../tests/postgres_mutation.rs | 117 +++++++++++++----- 1 file changed, 88 insertions(+), 29 deletions(-) diff --git a/crates/registry-server/tests/postgres_mutation.rs b/crates/registry-server/tests/postgres_mutation.rs index 309662cd99..53be32634c 100644 --- a/crates/registry-server/tests/postgres_mutation.rs +++ b/crates/registry-server/tests/postgres_mutation.rs @@ -826,7 +826,7 @@ async fn real_postgres_http_mutations_are_guarded_and_exactly_replayable() { BTreeSet::from(["rs-sec-13-scope-canary".to_owned()]), ); - let openapi = body_json( + let operator_openapi = body_json( send( &app, Method::GET, @@ -838,60 +838,86 @@ async fn real_postgres_http_mutations_are_guarded_and_exactly_replayable() { .await, ) .await; - assert!(openapi["paths"]["/v1/records/widgets"] + assert!(operator_openapi["paths"]["/v1/records/widgets"] .get("post") .is_some()); assert_eq!( - openapi["paths"]["/v1/records/widgets"]["post"]["security"], + operator_openapi["paths"]["/v1/records/widgets"]["post"]["security"], json!([{"bearerAuth": []}]) ); assert_eq!( - query_parameter_names(&openapi["paths"]["/v1/records/widgets"]["post"]["parameters"]), - ["Idempotency-Key", "accessProfile"] + query_parameter_names( + &operator_openapi["paths"]["/v1/records/widgets"]["post"]["parameters"] + ), + ["Idempotency-Key", "accessProfile", "traceparent"] ); assert!( - openapi["paths"]["/v1/records/widgets"]["post"]["responses"]["201"]["headers"] + operator_openapi["paths"]["/v1/records/widgets"]["post"]["responses"]["201"]["headers"] .get("Location") .is_some() ); - assert!(openapi["paths"]["/v1/records/widgets/{record_id}"] + assert!(operator_openapi["paths"]["/v1/records/widgets/{record_id}"] .get("patch") .is_some()); assert_eq!( query_parameter_names( - &openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["parameters"] + &operator_openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["parameters"] ), - ["Idempotency-Key", "If-Match", "accessProfile", "record_id"] + [ + "Idempotency-Key", + "If-Match", + "accessProfile", + "record_id", + "traceparent" + ] ); assert!( - openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["requestBody"]["content"] + operator_openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["requestBody"] + ["content"] .get("application/json-patch+json") .is_some() ); assert!( - openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["responses"]["428"]["content"] - ["application/problem+json"]["schema"] + operator_openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["responses"]["428"] + ["content"]["application/problem+json"]["schema"] .get("$ref") .is_some() ); - assert!(openapi["paths"]["/v1/records/widgets/{record_id}"] + assert!(operator_openapi["paths"]["/v1/records/widgets/{record_id}"] .get("delete") .is_some()); - assert!(openapi["paths"]["/v1/records/logs"].get("post").is_some()); - assert!(openapi["paths"] + assert!(operator_openapi["paths"].get("/v1/records/logs").is_none()); + + let case_openapi = body_json( + send( + &app, + Method::GET, + "/openapi.json?accessProfile=case-operator", + Some(claims.clone()), + &[], + Vec::new(), + ) + .await, + ) + .await; + assert!(case_openapi["paths"]["/v1/records/logs"] + .get("post") + .is_some()); + assert!(case_openapi["paths"] .get("/v1/records/logs/{record_id}") .and_then(|path| path.get("patch")) .is_none()); - assert!(openapi["paths"] + assert!(case_openapi["paths"] .get("/v1/records/logs/{record_id}") .and_then(|path| path.get("delete")) .is_none()); - assert!(openapi["paths"] + assert!(case_openapi["paths"] .get("/v1/records/archives/{record_id}") .and_then(|path| path.get("delete")) .is_none()); + assert!(case_openapi["paths"].get("/v1/records/widgets").is_none()); - let metadata = body_json( + let operator_metadata = body_json( send( &app, Method::GET, @@ -903,21 +929,47 @@ async fn real_postgres_http_mutations_are_guarded_and_exactly_replayable() { .await, ) .await; - let metadata_entities = metadata["entities"].as_array().expect("metadata entities"); - let metadata_operations = |entity_id: &str| { - metadata_entities + let operator_entities = operator_metadata["entities"] + .as_array() + .expect("operator metadata entities"); + let operator_operations = |entity_id: &str| { + operator_entities .iter() .find(|entity| entity["id"] == entity_id) .and_then(|entity| entity["operations"].as_array()) - .expect("entity metadata operations") + .expect("operator entity metadata operations") }; - assert!(metadata_operations("widget") + assert!(operator_operations("widget") .iter() .any(|operation| operation["operation"] == "tombstone")); - assert!(!metadata_operations("log") + assert!(!operator_entities.iter().any(|entity| entity["id"] == "log")); + + let case_metadata = body_json( + send( + &app, + Method::GET, + "/v1/registry?accessProfile=case-operator", + Some(claims.clone()), + &[], + Vec::new(), + ) + .await, + ) + .await; + let case_entities = case_metadata["entities"] + .as_array() + .expect("case metadata entities"); + let case_operations = |entity_id: &str| { + case_entities + .iter() + .find(|entity| entity["id"] == entity_id) + .and_then(|entity| entity["operations"].as_array()) + .expect("case entity metadata operations") + }; + assert!(!case_operations("log") .iter() .any(|operation| operation["operation"] == "tombstone")); - assert!(!metadata_operations("archive") + assert!(!case_operations("archive") .iter() .any(|operation| operation["operation"] == "tombstone")); @@ -2223,10 +2275,16 @@ async fn assert_unique_violation_conflict_is_value_free( ); assert!(headers.get("etag").is_none()); assert!(headers.get("location").is_none()); - assert_eq!( - body.as_slice(), - br#"{"type":"urn:registry-server:problem:mutation.conflict","title":"Conflict","status":409,"detail":"The mutation conflicts with current state.","code":"mutation.conflict"}"# - ); + let traceparent = headers + .get("traceparent") + .and_then(|value| value.to_str().ok()) + .expect("problem response carries traceparent"); + let trace_id = traceparent + .split('-') + .nth(1) + .expect("canonical traceparent carries trace ID"); + assert_eq!(trace_id.len(), 32); + assert_ne!(trace_id, "00000000000000000000000000000000"); let problem: Value = serde_json::from_slice(&body).expect("problem JSON is valid"); assert_eq!( problem, @@ -2236,6 +2294,7 @@ async fn assert_unique_violation_conflict_is_value_free( "status": 409, "detail": "The mutation conflicts with current state.", "code": "mutation.conflict", + "traceId": trace_id, }) ); assert_eq!( From 9f38af0ba6ee4cb27f0020f4e644cedd183317f8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 31 Aug 2026 02:30:27 +0700 Subject: [PATCH 15/19] fix(registry-server): validate correlated fixture problems Signed-off-by: Jeremi Joslin --- crates/registry-server/src/fixtures.rs | 49 ++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/registry-server/src/fixtures.rs b/crates/registry-server/src/fixtures.rs index 4b9ad88d9d..627d2250ff 100644 --- a/crates/registry-server/src/fixtures.rs +++ b/crates/registry-server/src/fixtures.rs @@ -17,6 +17,7 @@ use axum::http::header::{AUTHORIZATION, CONTENT_TYPE, ETAG, IF_MATCH}; use axum::http::{Method, Request, Response, StatusCode}; use axum::Router; use registry_platform_canonical_json::{canonicalize_json, parse_json_strict}; +use registry_platform_httpsec::{response_trace_id, TraceId}; use registry_platform_oidc::{JwksFetcher, TokenVerifier}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; @@ -1815,6 +1816,13 @@ async fn accept_response( .map_err(|_| FixtureError::ResponseTooLarge)?; let document = parse_json_strict(&bytes).map_err(|_| FixtureError::ResponseShapeRefused)?; assert_response(step, status, &document)?; + if matches!(step.expect.outcome, ExpectedOutcome::Refusal) { + let header_trace = + response_trace_id(&headers).map_err(|_| FixtureError::ResponseShapeRefused)?; + if document.get("traceId").and_then(Value::as_str) != Some(header_trace.as_str()) { + return Err(FixtureError::ResponseShapeRefused); + } + } if let Some(capture) = step.capture.as_ref() { let record_id = document .get("id") @@ -1855,7 +1863,15 @@ fn assert_response( .problem_code .as_deref() .ok_or(FixtureError::ResponseShapeRefused)?; - let object = exact_object(document, &["type", "title", "status", "detail", "code"])?; + let object = exact_object( + document, + &["type", "title", "status", "detail", "code", "traceId"], + )?; + let trace_id = object + .get("traceId") + .and_then(Value::as_str) + .ok_or(FixtureError::ResponseShapeRefused)?; + TraceId::parse(trace_id).map_err(|_| FixtureError::ResponseShapeRefused)?; let expected_type = format!("urn:registry-server:problem:{code}"); if object.get("type").and_then(Value::as_str) != Some(expected_type.as_str()) || object.get("title").and_then(Value::as_str) != Some(title) @@ -3339,6 +3355,7 @@ mod tests { "status": 404, "detail": "The requested resource was not found.", "code": "resource.not_found", + "traceId": "11111111111111111111111111111111", "canaryDetail": "protected" }); assert_eq!( @@ -3350,12 +3367,37 @@ mod tests { "title": "Not Found", "status": 404, "detail": "protected canary", - "code": "resource.not_found" + "code": "resource.not_found", + "traceId": "11111111111111111111111111111111" }); assert_eq!( assert_response(refusal, StatusCode::NOT_FOUND, &changed_detail), Err(FixtureError::ExpectationMismatch) ); + let correlated_refusal = json!({ + "type": "urn:registry-server:problem:resource.not_found", + "title": "Not Found", + "status": 404, + "detail": "The requested resource was not found.", + "code": "resource.not_found", + "traceId": "11111111111111111111111111111111" + }); + let mismatched_trace = Response::builder() + .status(StatusCode::NOT_FOUND) + .header( + "traceparent", + "00-22222222222222222222222222222222-3333333333333333-01", + ) + .body(Body::from( + serde_json::to_vec(&correlated_refusal).expect("problem response serializes"), + )) + .expect("problem response builds"); + let mut observations = BTreeMap::new(); + assert_eq!( + accept_response(refusal, mismatched_trace, &mut observations).await, + Err(FixtureError::ResponseShapeRefused), + "the fixture executor refuses disagreement between body and header trace IDs" + ); let malformed_list = json!({ "items": [{"id": identifier, "revision": 1, "data": {"record_id": "canary"}}], @@ -3664,7 +3706,8 @@ mod tests { "query.invalid" } else { "resource.not_found" - } + }, + "traceId":"11111111111111111111111111111111" }), None, ), From 8dc81d563a2617fc77b7745fef74f7c20dbad5c4 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 31 Aug 2026 02:46:11 +0700 Subject: [PATCH 16/19] test(registry-server): stabilize request log capture Signed-off-by: Jeremi Joslin --- crates/registry-server/tests/startup_http.rs | 45 ++++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/crates/registry-server/tests/startup_http.rs b/crates/registry-server/tests/startup_http.rs index 3a00846892..c3a8da90b5 100644 --- a/crates/registry-server/tests/startup_http.rs +++ b/crates/registry-server/tests/startup_http.rs @@ -7,7 +7,7 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use axum::body::{to_bytes, Body}; @@ -44,6 +44,7 @@ const WEBHOOK_SECRET_CANARY: &str = "rs-v1-25-webhook-secret-canary"; const WEBHOOK_PAYLOAD_CANARY: &str = "rs-v1-25-webhook-payload-canary"; const UPSTREAM_DETAIL_CANARY: &str = "rs-v1-25-upstream-detail-canary"; const TRACESTATE_CANARY: &str = "registry=rs-v1-25-tracestate-canary"; +static TRACING_CAPTURE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); const PROJECT: &str = r#" apiVersion: registry.registrystack.org/v1alpha1 @@ -378,18 +379,28 @@ impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturedOperation } } +fn captured_request_logs() -> &'static CapturedOperationalLogs { + static WRITER: OnceLock = OnceLock::new(); + WRITER.get_or_init(|| { + let writer = CapturedOperationalLogs::default(); + let subscriber = tracing_subscriber::fmt() + .json() + .with_target(false) + .with_current_span(false) + .with_span_list(false) + .with_writer(writer.clone()) + .finish(); + tracing::subscriber::set_global_default(subscriber) + .expect("request log subscriber installs once for this test binary"); + writer + }) +} + #[tokio::test(flavor = "current_thread")] async fn request_operational_log_has_only_closed_value_free_fields() { + let _capture_guard = TRACING_CAPTURE.lock().await; const INBOUND: &str = "00-11111111111111111111111111111111-2222222222222222-01"; - let writer = CapturedOperationalLogs::default(); - let subscriber = tracing_subscriber::fmt() - .json() - .with_target(false) - .with_current_span(false) - .with_span_list(false) - .with_writer(writer.clone()) - .finish(); - let _subscriber = tracing::subscriber::set_default(subscriber); + let writer = captured_request_logs(); let service = Arc::new(HttpService::new( compiled_registry(), ReadRuntimeIdentity { @@ -424,7 +435,12 @@ async fn request_operational_log_has_only_closed_value_free_fields() { assert_forbidden_values_absent(&output); assert!(!output.contains("operational-log-token-canary")); assert!(!output.contains("/health")); - let rendered: Value = serde_json::from_str(output.trim()).expect("request log is JSON"); + let expected_trace_id = trace_id(INBOUND); + let rendered = output + .lines() + .map(|line| serde_json::from_str::(line).expect("request log is JSON")) + .find(|record| record["fields"]["trace_id"] == expected_trace_id) + .expect("correlated request log is present"); let fields = rendered["fields"] .as_object() .expect("request log fields are an object"); @@ -443,7 +459,7 @@ async fn request_operational_log_has_only_closed_value_free_fields() { assert_eq!(fields["method"], "GET"); assert_eq!(fields["status"], "success"); assert_eq!(fields["problem_code"], "none"); - assert_eq!(fields["trace_id"], trace_id(INBOUND)); + assert_eq!(fields["trace_id"], expected_trace_id); uuid::Uuid::parse_str( fields["request_id"] .as_str() @@ -561,8 +577,9 @@ fn operational_level_name(level: OperationalLogLevel) -> &'static str { } } -#[test] -fn every_operational_event_renders_exact_closed_value_free_json_fields() { +#[tokio::test(flavor = "current_thread")] +async fn every_operational_event_renders_exact_closed_value_free_json_fields() { + let _capture_guard = TRACING_CAPTURE.lock().await; let mut events = vec![ OperationalEvent::StartupBegan, OperationalEvent::Listening, From d6b5e1361b513b259857e13d2160146e111be65a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 31 Aug 2026 02:48:30 +0700 Subject: [PATCH 17/19] test(registry-server): bind captured request log Signed-off-by: Jeremi Joslin --- crates/registry-server/tests/startup_http.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/registry-server/tests/startup_http.rs b/crates/registry-server/tests/startup_http.rs index c3a8da90b5..1b97141282 100644 --- a/crates/registry-server/tests/startup_http.rs +++ b/crates/registry-server/tests/startup_http.rs @@ -122,6 +122,7 @@ impl ReadinessProbe for SlowReadiness { #[tokio::test] async fn request_timeout_returns_value_free_problem() { + let _request_logs = captured_request_logs(); let service = Arc::new(HttpService::new( compiled_registry(), ReadRuntimeIdentity { @@ -175,6 +176,7 @@ async fn request_timeout_returns_value_free_problem() { #[tokio::test] async fn trace_transport_health_aliases_and_request_ids_are_correlated() { + let _request_logs = captured_request_logs(); const INBOUND: &str = "00-11111111111111111111111111111111-2222222222222222-01"; const SECOND: &str = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"; @@ -399,7 +401,7 @@ fn captured_request_logs() -> &'static CapturedOperationalLogs { #[tokio::test(flavor = "current_thread")] async fn request_operational_log_has_only_closed_value_free_fields() { let _capture_guard = TRACING_CAPTURE.lock().await; - const INBOUND: &str = "00-11111111111111111111111111111111-2222222222222222-01"; + const INBOUND: &str = "00-99999999999999999999999999999999-8888888888888888-01"; let writer = captured_request_logs(); let service = Arc::new(HttpService::new( compiled_registry(), @@ -436,11 +438,13 @@ async fn request_operational_log_has_only_closed_value_free_fields() { assert!(!output.contains("operational-log-token-canary")); assert!(!output.contains("/health")); let expected_trace_id = trace_id(INBOUND); - let rendered = output + let matching_records = output .lines() .map(|line| serde_json::from_str::(line).expect("request log is JSON")) - .find(|record| record["fields"]["trace_id"] == expected_trace_id) - .expect("correlated request log is present"); + .filter(|record| record["fields"]["trace_id"] == expected_trace_id) + .collect::>(); + assert_eq!(matching_records.len(), 1); + let rendered = &matching_records[0]; let fields = rendered["fields"] .as_object() .expect("request log fields are an object"); @@ -663,6 +667,7 @@ async fn every_operational_event_renders_exact_closed_value_free_json_fields() { #[tokio::test] async fn provenance_operational_logs_metrics_and_traces_are_separate_closed_and_value_free() { + let _request_logs = captured_request_logs(); let directory = TestDirectory::create(); let registry = compiled_registry(); let registry_revision = registry.revision().to_owned(); From 6a53a96fde33bb743fef210ebfb64ba0043d4f22 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 31 Aug 2026 03:07:00 +0700 Subject: [PATCH 18/19] test(registry-server): repair adopter lifecycle Signed-off-by: Jeremi Joslin --- .../scripts/test-adopter-workflow.sh | 15 +++++++++------ .../scripts/test_generated_gates.py | 6 ++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/products/registry-server/scripts/test-adopter-workflow.sh b/products/registry-server/scripts/test-adopter-workflow.sh index ef401bf866..000d3fb4dc 100755 --- a/products/registry-server/scripts/test-adopter-workflow.sh +++ b/products/registry-server/scripts/test-adopter-workflow.sh @@ -458,7 +458,10 @@ provision_adopter_database() { -c "GRANT CONNECT ON DATABASE \"$database\" TO \"$adopter_migration_role\", \"$adopter_runtime_role\", \"$adopter_author_role\";" \ -c "CREATE SCHEMA registry_internal AUTHORIZATION \"$adopter_migration_role\";" \ -c "CREATE SCHEMA registry_data AUTHORIZATION \"$adopter_migration_role\";" \ - -c "REVOKE ALL ON SCHEMA registry_internal, registry_data FROM PUBLIC;" >/dev/null + -c "CREATE SCHEMA registry_source AUTHORIZATION \"$adopter_migration_role\";" \ + -c "CREATE SCHEMA registry_derived AUTHORIZATION \"$adopter_migration_role\";" \ + -c "CREATE SCHEMA registry_context AUTHORIZATION \"$adopter_migration_role\";" \ + -c "REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context FROM PUBLIC;" >/dev/null } select_free_listener() { @@ -707,7 +710,7 @@ server_pid=$! wait_ready_status "${server_url}ready" 200 cat >"$temporary_root/assets-create.jsonl" <<'EOF' -{"operation":"create","data":{"asset-code":"ASSET-PUBLIC-001","label":"Synthetic public workflow asset","asset-class":"equipment"}} +{"operation":"create","data":{"assetCode":"ASSET-PUBLIC-001","label":"Synthetic public workflow asset","assetClass":"equipment"}} EOF run_json "$temporary_root/data-validate-v1.json" data validate \ --package "$temporary_root/build-v1/package" \ @@ -734,7 +737,7 @@ import json import sys document = json.load(open(sys.argv[1], encoding="utf-8")) items = document.get("items", []) -if not any(item.get("data", {}).get("asset-code") == "ASSET-PUBLIC-001" for item in items): +if not any(item.get("data", {}).get("assetCode") == "ASSET-PUBLIC-001" for item in items): raise SystemExit("authorized public data read did not include the created record") PY @@ -745,7 +748,7 @@ from pathlib import Path path = Path(sys.argv[1]) source = path.read_text(encoding="utf-8") source = source.replace(" sequence: 1\n", " sequence: 2\n", 1) -needle = " - {id: label, type: string, required: true, maxLength: 200, classification: internal}\n" +needle = " - {id: asset-class, type: vocabulary-code, vocabulary: asset-classification, required: true, classification: internal}\n" replacement = needle + " - {id: placement-review-note, type: string, required: false, maxLength: 120, classification: restricted}\n" if needle not in source: raise SystemExit("asset item field insertion point was not found") @@ -909,10 +912,10 @@ python3 - "$temporary_root/assets-list-v2.json" <<'PY' import json import sys document = json.load(open(sys.argv[1], encoding="utf-8")) -matching = [item for item in document.get("items", []) if item.get("data", {}).get("asset-code") == "ASSET-PUBLIC-001"] +matching = [item for item in document.get("items", []) if item.get("data", {}).get("assetCode") == "ASSET-PUBLIC-001"] if not matching: raise SystemExit("created record did not survive successor activation") -if any("placement-review-note" in item.get("data", {}) for item in matching): +if any("placementReviewNote" in item.get("data", {}) for item in matching): raise SystemExit("restricted successor field was disclosed") PY diff --git a/products/registry-server/scripts/test_generated_gates.py b/products/registry-server/scripts/test_generated_gates.py index d6c103a016..757b2088c7 100755 --- a/products/registry-server/scripts/test_generated_gates.py +++ b/products/registry-server/scripts/test_generated_gates.py @@ -68,6 +68,10 @@ def test_adopter_workflow_uses_public_binaries_database_and_recovery(self) -> No "REGISTRY_SERVER_TEST_DATABASE_URL", "CREATE ROLE", "CREATE DATABASE", + "CREATE SCHEMA registry_source", + "CREATE SCHEMA registry_derived", + "CREATE SCHEMA registry_context", + "REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context FROM PUBLIC", "adopter_schema_test_v1_database", "adopter_schema_test_v2_database", "adopter_production_database", @@ -101,6 +105,8 @@ def test_adopter_workflow_uses_public_binaries_database_and_recovery(self) -> No '"$registry_server" --config', "data validate", "data import", + '"assetCode":"ASSET-PUBLIC-001"', + '"assetClass":"equipment"', "entity_list_path", "http_get_json", "authorized public data read", From f806b222f628717c0e6c5d2c14fc0d2bbf7a96f1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 31 Aug 2026 03:17:12 +0700 Subject: [PATCH 19/19] ci(registry-server): provision quickstart tooling Signed-off-by: Jeremi Joslin --- .github/scripts/test_ci_changes.py | 5 +++++ .github/workflows/ci.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 9f855da803..ec19a6707e 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -937,6 +937,11 @@ def test_registry_server_contracts_pin_postgresql_and_use_the_product_entry_poin "TLS_CA_PEM_PATH: ${{ runner.temp }}/registry-server-postgres-trusted-ca.pem", registry_server_job, ) + self.assertIn( + "uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d", + registry_server_job, + ) + self.assertIn('version: "0.11.16"', registry_server_job) for entry_point in ( "products/registry-server/scripts/check-contracts.sh", "products/registry-server/scripts/test-postgres.sh", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d004abd560..7462210d58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -643,6 +643,11 @@ jobs: cache-targets: false save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.11.16" + - name: Registry Server contract consistency run: products/registry-server/scripts/check-contracts.sh