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..ec19a6707e 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,64 @@ 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( + "DATABASE_PORT: ${{ job.services.postgres.ports['5432'] }}", + registry_server_job, + ) + self.assertIn( + "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_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:${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, + ) + 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", + "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) + 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..7462210d58 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,76 @@ 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 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + 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: + shared-key: workspace-registry + 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 + + - 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 + + - name: Registry Server generic quickstart + run: products/registry-server/quickstart/run.sh --smoke + identifiers: name: Public identifier catalog needs: changes @@ -627,6 +698,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..7e75f02908 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,81 @@ 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", + "uuid", + "zeroize", ] [[package]] @@ -3833,7 +4272,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 +4342,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 +4360,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 +4384,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 +4406,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 +4716,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 +4868,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 +4973,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 +4988,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 +5111,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 +5159,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 +5473,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 +5672,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 +5690,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 +5792,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 +5845,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 +6014,7 @@ checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", "base64", - "deadpool", + "deadpool 0.12.3", "futures", "http", "http-body-util", @@ -5420,6 +6041,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-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-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..945b71335e 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,42 @@ pub type DataDestinationRequestTemplate = BoundedDestinationRequestTemplate; +/// Closed event-delivery request template. +pub type EventDestinationRequestTemplate = BoundedDestinationRequestTemplate; + +/// 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 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 delivery_time: &'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_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_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; /// Closed OAuth 2.0 client-credentials request-body encoding. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1448,6 +1506,119 @@ 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::Exact { + name: "ce-specversion", + value: b"1.0", + }, + HeaderTemplateInput::Dynamic { + name: "ce-id", + max_value_bytes: MAX_EVENT_ID_HEADER_BYTES, + }, + HeaderTemplateInput::Dynamic { + 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, + }, + HeaderTemplateInput::Dynamic { + name: "x-registry-delivery-attempt", + max_value_bytes: MAX_EVENT_ATTEMPT_HEADER_BYTES, + }, + HeaderTemplateInput::Dynamic { + name: "x-registry-delivery-time", + max_value_bytes: MAX_EVENT_DELIVERY_TIME_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.id, + headers.source, + headers.event_type, + headers.time, + headers.dataschema, + headers.generation, + headers.attempt, + headers.delivery_time, + headers.idempotency_key, + headers.signature, + ], + None, + Some(body), + ) + } +} + impl BoundedDestinationRequestTemplate { /// Compile the maximum request authority available to one reviewed script allow rule. /// @@ -1520,7 +1691,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 +1710,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 +1942,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 +1982,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 +2019,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 +2054,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 +2088,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 +2106,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 +2332,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 +2373,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 +2471,62 @@ fn closed_oauth_headers( accept && content_type } +fn closed_event_headers(headers: &[HeaderTemplateInput<'_>]) -> bool { + const EXACT: [(&str, usize, Option<&[u8]>); 13] = [ + ("accept", 0, Some(b"application/json")), + ("content-type", 0, Some(b"application/json")), + ("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, + None, + ), + ( + "x-registry-delivery-attempt", + MAX_EVENT_ATTEMPT_HEADER_BYTES, + None, + ), + ( + "x-registry-delivery-time", + MAX_EVENT_DELIVERY_TIME_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 +2852,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 +2915,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 +3082,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 +3151,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 +3391,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 +4126,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::*; @@ -3859,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") } @@ -3882,6 +4165,22 @@ mod tests { .expect("production policy validates") } + fn event_headers<'a>() -> EventDeliveryHeaders<'a> { + EventDeliveryHeaders { + 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", + delivery_time: b"2026-08-30T02:03:05Z", + 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 +6162,401 @@ 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, + 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", + "case-approved-v1", + "example-instance", + "0123456789abcdef", + "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 + ); + 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]) + .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", + "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", + ]; + 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("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("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( + "urn:registry:schema:example-registry:case-approved-v1:sha256:0123456789abcdef", + ) + && 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-delivery-time") + .and_then(|value| value.to_str().ok()) + == Some("2026-08-30T02:03:05Z") + && 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("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())); + 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, + 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()) + .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, + 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, + 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, + 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, + 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..6a9ceba508 --- /dev/null +++ b/crates/registry-server/Cargo.toml @@ -0,0 +1,198 @@ +[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 = "pilot_acceptance_fixtures" +path = "tests/pilot_acceptance_fixtures.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: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 +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/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/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..eca93340b4 --- /dev/null +++ b/crates/registry-server/src/api/mod.rs @@ -0,0 +1,3653 @@ +// 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::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}; +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, CompiledLookupSelector, CompiledReadQuery, ConditionalMutationInput, + 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}; +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, + CursorQueryScope, +}; +use crate::idempotency::{HeldResponse, PermittedResponseHeader}; +use crate::model::{ + CompiledEntity, CompiledMetadataEntity, CompiledMetadataEntry, CompiledQueryKind, + CompiledQueryOperation, CompiledQuerySortDirection, CompiledReadPath, CompiledRevisionKind, + CompiledRoute, MAX_REVISION_HISTORY_RECORDS, +}; +use crate::mutation::{parse_json_patch_document, BatchMutationItem, MutationError}; +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; +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(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)) + .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::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())), + ), + 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(middleware::from_fn(crate::correlation::observe)) + .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(); + let mut writable_by_input_schema: 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 request_schema_ref = + openapi_input_schema_id(&surface.entity.id, surface.route.operation); + methods.insert( + method_name(surface.route.method).to_owned(), + 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()) + .and_modify(|fields| { + 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 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": openapi_components(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 (_, entry) in visible { + let Some(response_entity) = service.registry.entities().get(&entry.response_entity_id) + else { + return concealed(); + }; + entities + .entry(response_entity.id.clone()) + .and_modify(|metadata| { + metadata + .operations + .insert(entry.operation, entry.access_profile.clone()); + metadata + .readable_fields + .extend(entry.readable_fields.iter().cloned()); + }) + .or_insert_with(|| MetadataEntity { + 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: format!("/v1/schemas/{}", response_entity.id), + }); + } + 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.response_entity.id == entity_id) + .collect::>(); + let Some(first) = surfaces.first() else { + return concealed(); + }; + let readable = + surfaces + .iter() + .skip(1) + .fold(first.readable_fields.clone(), |mut fields, surface| { + fields.extend(surface.readable_fields.iter().cloned()); + fields + }); + 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, + Extension(correlation): 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(), + &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"), + &correlation, + ) + .await; + return response; + }; + + match route.operation { + Operation::Get => { + if surface.read_path.is_some() || options.has_non_projection_query_members() { + return audited_read_refusal( + &service, + &route, + &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(), + &correlation, + ) + .await; + }; + if !valid_canonical_record_uuid(record_id) { + return audited_read_refusal( + &service, + &route, + &surface, + Some(record_id), + concealed(), + &correlation, + ) + .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(), + &correlation, + ) + .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, + correlation: correlation.clone(), + }; + 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 => { + 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(), + &correlation, + ) + .await; + } + let query = match read_query( + &service, + &route, + &surface, + &options, + path.get("record_id").map(String::as_str), + ) + .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(), + &correlation, + ) + .await; + } + Err(ReadQueryError::CursorInvalid) => { + return audited_read_refusal( + &service, + &route, + &surface, + path.get("record_id"), + cursor_invalid(), + &correlation, + ) + .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, + &surface, + path.get("record_id"), + concealed(), + &correlation, + ) + .await; + } + 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, + correlation: correlation.clone(), + }; + 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, + Extension(correlation): 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(), + &correlation, + ) + .await; + } + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + 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(), + &correlation, + ) + .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(), + &correlation, + ) + .await; + } + }; + if !single_content_type(&headers, "application/json") { + 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(), + &correlation, + ) + .await; + }; + let body = match parse_lookup_body(&body) { + Ok(body) => body, + Err(()) => { + 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(), + &correlation, + ) + .await; + } + Err(LookupResolutionError::Unresolved) => { + 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(), &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(), + &correlation, + ) + .await; + }; + if !readable_fields + .iter() + .all(|field| operation.projection_fields.contains(field)) + { + return audited_read_refusal(&service, &route, &surface, None, concealed(), &correlation) + .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::Lookup { selector }, + maximum_records: 2, + correlation: correlation.clone(), + }; + 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(), + } +} + +async fn revision_dispatch( + State(service): State>, + Extension(route): Extension, + Extension(correlation): 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(), + &correlation, + ) + .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"), + &correlation, + ) + .await; + }; + let Some(record_id) = path.get("record_id") else { + 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, + 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(), + &correlation, + ) + .await; + }; + Some(value) + } + _ => { + return audited_revision_refusal( + revisions.as_ref(), + &route, + &surface, + Some(record_id), + concealed(), + &correlation, + ) + .await; + } + }; + if !valid_canonical_record_uuid(record_id) { + return audited_revision_refusal( + revisions.as_ref(), + &route, + &surface, + Some(record_id), + concealed(), + &correlation, + ) + .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, + correlation: correlation.clone(), + }; + 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, + correlation: &RequestCorrelation, +) -> 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(), + correlation: correlation.clone(), + }) + .await + { + Ok(()) => response, + Err(_) => unavailable(), + } +} + +async fn audited_revision_refusal( + revisions: &dyn RevisionReadService, + route: &CompiledRoute, + surface: &AuthorizedSurface<'_>, + target_record: Option<&String>, + response: Response, + correlation: &RequestCorrelation, +) -> 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(), + correlation: correlation.clone(), + }) + .await + { + Ok(()) => response, + Err(_) => unavailable(), + } +} + +async fn audited_revision_concealment( + revisions: &dyn RevisionReadService, + route: &CompiledRoute, + options: &QueryOptions, + claims: &VerifiedRequestClaims, + target_record: Option<&String>, + correlation: &RequestCorrelation, +) -> Response { + let selected_access_profile = options.access_profile().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(), + correlation: correlation.clone(), + }) + .await + { + Ok(()) => concealed(), + Err(_) => unavailable(), + } +} + +async fn audited_known_read_refusal( + service: &HttpService, + route: &CompiledRoute, + claims: &VerifiedRequestClaims, + target_record: Option<&String>, + response: Response, + correlation: &RequestCorrelation, +) -> 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(), + correlation: correlation.clone(), + }) + .await + { + Ok(()) => response, + Err(_) => unavailable(), + } +} + +async fn audited_read_refusal( + service: &HttpService, + route: &CompiledRoute, + surface: &AuthorizedSurface<'_>, + target_record: Option<&String>, + response: Response, + correlation: &RequestCorrelation, +) -> 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(), + correlation: correlation.clone(), + }) + .await + { + Ok(()) => response, + Err(_) => unavailable(), + } +} + +async fn audited_read_concealment( + service: &HttpService, + route: &CompiledRoute, + options: &QueryOptions, + claims: &VerifiedRequestClaims, + target_record: Option<&String>, + correlation: &RequestCorrelation, +) -> Response { + let selected_access_profile = options.access_profile().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(), + correlation: correlation.clone(), + }) + .await + { + Ok(()) => concealed(), + Err(_) => unavailable(), + } +} + +async fn create_dispatch( + State(service): State>, + Extension(route): Extension, + Extension(correlation): 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, + &correlation, + ) + .await; + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + 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( + mutations, + &route, + &surface.context, + None, + invalid_request(), + &correlation, + ) + .await; + }; + if !valid_idempotency_key(idempotency_key) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + &correlation, + ) + .await; + } + if !single_content_type(&headers, "application/json") { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + unsupported_media_type(), + &correlation, + ) + .await; + } + let Ok(body) = bounded_body(body).await else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + &correlation, + ) + .await; + }; + let Ok(data) = parse_create_body(&body) else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + &correlation, + ) + .await; + }; + match mutations + .create(CreateMutationInput { + route_id: &route.id, + idempotency_key, + context: &surface.context, + entity_id: &route.entity_id, + data, + response_fields: surface.readable_fields, + correlation: &correlation, + }) + .await + { + Ok(outcome) => exact_mutation(outcome.response()), + Err(error) => mutation_problem(error), + } +} + +#[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>, + 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()), + &correlation, + ) + .await; + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + return audited_mutation_concealment( + mutations, + &route, + &options, + &claims, + Some(record_id.as_str()), + &correlation, + ) + .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(), + &correlation, + ) + .await; + }; + if !valid_idempotency_key(idempotency_key) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + &correlation, + ) + .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(), + &correlation, + ) + .await; + }; + if !valid_if_match(if_match) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + precondition_failed(), + &correlation, + ) + .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(), + &correlation, + ) + .await; + } + let Ok(body) = bounded_body(body).await else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + &correlation, + ) + .await; + }; + let Ok(document) = parse_json_strict(&body) else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + &correlation, + ) + .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(), + &correlation, + ) + .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, + correlation: &correlation, + }, + patch, + ) + .await + { + Ok(outcome) => exact_mutation(outcome.response()), + Err(error) => mutation_problem(error), + } +} + +async fn batch_dispatch( + State(service): State>, + Extension(route): Extension, + Extension(correlation): 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, + &correlation, + ) + .await; + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + return audited_mutation_concealment( + mutations, + &route, + &options, + &claims, + None, + &correlation, + ) + .await; + }; + let Some(batch) = surface.entity.batch.as_ref() else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + &correlation, + ) + .await; + }; + let Some(idempotency_key) = single_header(&headers, "idempotency-key") else { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + &correlation, + ) + .await; + }; + if !valid_idempotency_key(idempotency_key) || headers.contains_key(IF_MATCH) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + invalid_request(), + &correlation, + ) + .await; + } + if !single_content_type(&headers, "application/json") { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + None, + unsupported_media_type(), + &correlation, + ) + .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(), + &correlation, + ) + .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(), + &correlation, + ) + .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(), + correlation: &correlation, + }) + .await + { + Ok(outcome) => exact_mutation(outcome.response()), + Err(error) => mutation_problem(error), + } +} + +#[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>, + 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()), + &correlation, + ) + .await; + }; + let Some(surface) = authorize_route(&service, &route, &claims, &options) else { + return audited_mutation_concealment( + mutations, + &route, + &options, + &claims, + Some(record_id.as_str()), + &correlation, + ) + .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(), + &correlation, + ) + .await; + }; + if !valid_idempotency_key(idempotency_key) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + &correlation, + ) + .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(), + &correlation, + ) + .await; + }; + if !valid_if_match(if_match) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + precondition_failed(), + &correlation, + ) + .await; + } + if headers.contains_key(CONTENT_TYPE) { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + unsupported_media_type(), + &correlation, + ) + .await; + } + if !body_is_empty(body).await { + return audited_mutation_refusal( + mutations, + &route, + &surface.context, + Some(record_id.as_str()), + invalid_request(), + &correlation, + ) + .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, + correlation: &correlation, + }) + .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, + correlation: &RequestCorrelation, +) -> Response { + match mutations + .record_refusal(crate::audit::HttpRefusalAudit { + method: route.method, + operation_id: &route.id, + target_record, + principal: context.principal(), + selected_access_profile: Some(context.selected_profile()), + purpose_present: context.purpose().is_some(), + correlation, + }) + .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>, + 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(crate::audit::HttpRefusalAudit { + method: route.method, + operation_id: &route.id, + target_record, + principal: claims.principal(), + selected_access_profile: selected_profile, + purpose_present: claims.purpose().is_some(), + correlation, + }) + .await + { + Ok(()) => concealed(), + Err(_) => mutation_problem(MutationError::Unavailable), + } +} + +async fn not_found() -> Response { + concealed() +} + +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>( + 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() + && entry.response_entity_id == surface.response_entity.id + && entry.readable_fields == surface.readable_fields + })?; + Some((entity, entry)) +} + +fn authorize_route<'a>( + service: &'a HttpService, + route: &'a CompiledRoute, + claims: &VerifiedRequestClaims, + options: &QueryOptions, +) -> Option> { + 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() + .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)?; + 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, + 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), + selected_profile.to_owned(), + 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() + && 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, + root_id: Option<&str>, +) -> 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); + }; + 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| { + 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() + || 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); + } + let fields = payload + .binding + .selected_fields + .iter() + .cloned() + .collect::>(); + 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.response_entity, + operation, + filter.as_ref(), + order.as_ref(), + payload.binding.page_size, + ) + .map_err(|_| CursorError::Mismatch)?; + cursor_binding( + service, + route, + surface, + operation, + CursorBindingQuery { + selected_fields: &fields, + 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, + }, + ) + }) + .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 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(), + 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 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, + }; + 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); + } + validate_query_shape( + surface.response_entity, + operation, + filter.as_ref(), + order.as_ref(), + page_size, + )?; + let temporal_instant = temporal_instant_for(kind, options)?; + let binding = cursor_binding( + service, + route, + surface, + operation, + CursorBindingQuery { + selected_fields: &fields, + 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, + projection, + filter, + order, + include_count, + 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 == surface.response_entity.id + && operation.profile_id == surface.context.selected_profile() + && operation.kind == kind + }) +} + +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); + } + Ok(CursorQueryScope::Relationship { + path_id: read_path.id.clone(), + root_id: root_id.to_owned(), + }) + } + 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()); + } + } + } + if fields.is_empty() { + Ok(None) + } else { + Ok(Some(fields)) + } +} + +fn resolve_data_field_id<'a>(entity: &'a CompiledEntity, api_name: &str) -> Option<&'a str> { + 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.as_str()) +} + +fn projection_plan( + entity: &CompiledEntity, + selected_fields: &BTreeSet, +) -> Result, ReadQueryError> { + selected_fields + .iter() + .map(|field_id| { + let field_type = data_field_type(entity, field_id).ok_or(ReadQueryError::Invalid)?; + Ok(ReadProjectionField { + field_id: field_id.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, + 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_type = data_field_type(entity, 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_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_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_type = data_field_type(entity, 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_type.clone(), + direction: CompiledQuerySortDirection::Asc, + })) +} + +fn validate_query_shape( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + filter: Option<&ReadFilterExpr>, + order: Option<&ReadOrderClause>, + page_size: u16, +) -> Result<(), ReadQueryError> { + if page_size == 0 || page_size > operation.max_page_size { + return Err(ReadQueryError::Invalid); + } + 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); + } + } + 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 + || data_field_type(entity, &order.field_id) != 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_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_type != &predicate.field_type + || !capability + .operators + .contains(&predicate.operator.compiled_capability()) + { + return Err(ReadQueryError::Invalid); + } + 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_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_type) + .map_err(|_| ReadQueryError::Invalid)?; + } + } + ReadFilterOperator::IsNull | ReadFilterOperator::IsNotNull => { + if predicate.values.as_slice() != ["true"] { + return Err(ReadQueryError::Invalid); + } + } + } + Ok(()) + } + } +} + +fn temporal_instant_for( + kind: CompiledQueryKind, + options: &QueryOptions, +) -> Result, ReadQueryError> { + match kind { + CompiledQueryKind::List => { + if options.parsed.as_of.is_some() { + return Err(ReadQueryError::Invalid); + } + Ok(None) + } + CompiledQueryKind::Current => { + if options.parsed.as_of.is_some() { + return Err(ReadQueryError::Invalid); + } + OffsetDateTime::now_utc() + .format(&Rfc3339) + .map(Some) + .map_err(|_| ReadQueryError::Invalid) + } + CompiledQueryKind::AsOf => { + 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())) + } + } +} + +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-v3", principal.as_bytes()) + }) + .transpose()?; + let purpose_reference = surface + .context + .purpose() + .map(|purpose| { + service + .cursors + .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-v3", + &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-v3", + &json!({"projection": query.projection.iter().map(projection_field_value).collect::>()}), + )?; + let query_reference = service.cursors.binding_digest( + 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-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(), + 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, + 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, + }) +} + +struct CursorBindingQuery<'a> { + selected_fields: &'a BTreeSet, + 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 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(|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::, _>>()?; + if actual != expected { + return Err(ReadQueryError::CursorInvalid); + } + Ok(actual) +} + +fn read_order_clause_from_cursor( + entity: &CompiledEntity, + operation: &CompiledQueryOperation, + order: &CursorOrderClause, +) -> Result { + 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 + .directions + .contains(&CompiledQuerySortDirection::Asc) + }); + if !sortable + || 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_type = data_field_type(entity, &predicate.field_id) + .ok_or(ReadQueryError::CursorInvalid)?; + if 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 read_operator_from_cursor(operator: CursorFilterOperator) -> ReadFilterOperator { + match operator { + 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() + .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); + } + selector + .fields + .iter() + .map(|field_id| { + let field = entity + .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(api_name) + .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, + readable_fields: &BTreeSet, +) -> Option { + let entity = service.registry.entities().get(entity_id)?; + let readable_api_names = entity + .stored_fields + .iter() + .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)?; + 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_api_names.contains(field.as_str())); + 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_api_names.contains(field)) + }); + } + Some(schema) +} + +struct MetadataEntity { + id: String, + route: String, + operations: BTreeMap, + readable_fields: BTreeSet, + schema_path: String, +} + +struct QueryOptions { + parsed: strict_query::ParsedReadQuery, +} + +impl QueryOptions { + fn parse(raw: Option<&str>, allow_read_query: bool) -> Result { + let Some(raw) = raw else { + return Ok(Self::default()); + }; + if raw.is_empty() || raw.len() > MAX_RAW_QUERY_BYTES { + return Err(QueryParseError::Invalid); + } + 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)?; + pairs.push((name, value)); + } + 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 access_profile(&self) -> Option<&String> { + self.parsed.access_profile.as_ref() + } + + 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 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(), + ), + }, + } + } +} + +#[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 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", + Operation::Batch => "batch", + Operation::Revisions => "revisions", + } +} + +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 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) + .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() +} + +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 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 { + crate::correlation::problem_response( + status, + format!("urn:registry-server:problem:{code}"), + status.canonical_reason().unwrap_or("Request failed"), + detail, + code, + ) +} diff --git a/crates/registry-server/src/api/service.rs b/crates/registry-server/src/api/service.rs new file mode 100644 index 0000000000..849f9b4b13 --- /dev/null +++ b/crates/registry-server/src/api/service.rs @@ -0,0 +1,575 @@ +// 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::contract::FieldTypeSource; +use crate::correlation::RequestCorrelation; +use crate::cursor::{CursorBinding, CursorCodec, CursorContinuation, CursorQuery}; +use crate::model::{ + CompiledQueryFilterOperator, CompiledQueryKind, CompiledQuerySortDirection, 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 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, + 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, + pub correlation: &'a RequestCorrelation, +} + +/// 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, + pub correlation: &'a RequestCorrelation, +} + +#[derive(Clone)] +pub struct RecordReadRequest { + pub entity_id: String, + pub operation_id: String, + pub method: HttpMethod, + 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 kind: RecordReadKind, + /// 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 { + 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("context", &"") + .field("selected_fields", &self.selected_fields) + .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, + pub query_operation_id: String, + pub kind: CompiledQueryKind, + pub cursor_binding: CursorBinding, + pub cursor_query: CursorQuery, + pub projection: Vec, + pub filter: Option, + pub order: Option, + pub include_count: bool, + 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("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", + &self.temporal_instant.as_ref().map(|_| ""), + ) + .field( + "continuation", + &self.continuation.as_ref().map(|_| ""), + ) + .finish() + } +} + +#[derive(Clone, Eq, PartialEq)] +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 ReadFilterPredicate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .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 => CompiledQueryFilterOperator::Prefix, + Self::Contains => CompiledQueryFilterOperator::Contains, + } + } +} + +#[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, + pub correlation: RequestCorrelation, +} + +#[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, + pub correlation: RequestCorrelation, +} + +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, + pub correlation: RequestCorrelation, +} + +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 lookup( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>>; + + 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..08609e0611 --- /dev/null +++ b/crates/registry-server/src/artifacts.rs @@ -0,0 +1,1698 @@ +// 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::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::contract::{ + EventSource, EventTrigger, FieldTypeSource, ManifestProjectionSource, MutationMode, Operation, + PackageIdentitySource, +}; +use crate::diagnostics::Diagnostic; +use crate::generated_ddl::DdlInventory; +use crate::manifest_adapter::project_manifest_artifacts; +use crate::model::{ + CompiledAccessInventory, CompiledEntity, CompiledEventDeliveryInventory, + CompiledMetadataInventory, CompiledModuleIdentity, CompiledQueryInventory, CompiledQueryKind, + CompiledQueryOperation, CompiledRevisionKind, CompiledRoute, CompiledRouteInventory, + HttpMethod, +}; +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 { + 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); + } + 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, 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)?; + insert_bytes( + &mut artifacts, + "generated/manifest/registry-manifest.json", + "application/json", + projected.manifest, + ); + insert_bytes( + &mut artifacts, + "generated/manifest/dcat.jsonld", + "application/ld+json", + projected.dcat, + ); + } + 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(); + for field in &entity.stored_fields { + 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())); + } + } + 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.api_name.clone(), schema); + } + 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", + } + }) +} + +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"}), + 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, + query: &CompiledQueryInventory, + schemas: &BTreeMap, +) -> Value { + let mut paths = Map::new(); + let mut input_schemas = Map::new(); + for route in &routes.routes { + 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 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_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 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": 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_parameter() -> Value { + query_parameter( + "accessProfile", + false, + false, + 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( + schema_ref: &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/{schema_ref}")}, + } + })); + } + 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", "minLength": 6, "maxLength": 256, "pattern": "^\\\"rs-[\\x21\\x23-\\x7E]+\\\"$"}, + "patch": json_patch_array_schema(), + } + })); + } + 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 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, +) -> Value { + let operations = [ + allow_create.then_some("create"), + allow_patch.then_some("patch"), + ] + .into_iter() + .flatten() + .collect::>(); + json!({ + "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}")}, + } + } + } + } + }) +} + +fn revision_response_schema(schema_ref: &str, kind: Option) -> Value { + let item = json!({ + "type": "object", + "additionalProperties": false, + "required": [ + "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 + } + } + }) + } +} + +#[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.", + }], + ), + ( + "401", + vec![ProblemExample { + code: "authentication.refused", + detail: "The bearer credential is missing or refused.", + }], + ), + ( + "404", + vec![ProblemExample { + code: "resource.not_found", + detail: "The requested resource was not found.", + }], + ), + ( + "503", + vec![ProblemExample { + code: "source.unavailable", + detail: "The Registry data service is unavailable.", + }], + ), + ( + "504", + vec![ProblemExample { + code: "request.timeout", + detail: "The request timed out.", + }], + ), + ]); + 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.", + }], + ); + } + responses +} + +fn problem_schema() -> Value { + json!({ + "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 problem_example(status: &str, code: &str, detail: &str) -> Value { + json!({ + "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", + CompiledQueryKind::Current => "current", + CompiledQueryKind::AsOf => "as_of", + } +} + +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", + 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..9eefa305d9 --- /dev/null +++ b/crates/registry-server/src/audit.rs @@ -0,0 +1,640 @@ +// 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::correlation::RequestCorrelation; +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 correlation: &'a RequestCorrelation, +} + +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, + pub correlation: &'a RequestCorrelation, +} + +#[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, + pub correlation: RequestCorrelation, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TerminalAuditOutcome { + Committed, + Replayed, + Returned, + Empty, + Unresolved, + 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, + PayloadExpired, + WorkerInterrupted, + ReplayRequested, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WebhookAuditDisposition { + Leased, + Delivered, + RetryPending, + DeadLettered, + Expired, + 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, + "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(), + "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()), + ), + ( + "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()), + ), + ( + "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::Terminal, + WebhookAuditOutcome::PayloadExpired, + WebhookAuditDisposition::Expired, + ) => 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::PayloadExpired => "payload_expired", + 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::Expired => "expired", + 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::Unresolved => "unresolved", + TerminalAuditOutcome::Refused => "refused", + } + .to_owned(), + ), + ), + ( + "method".to_owned(), + Value::String(method_name(terminal.method).to_owned()), + ), + ( + "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(), + 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..fc5cc6390c --- /dev/null +++ b/crates/registry-server/src/auth.rs @@ -0,0 +1,518 @@ +// 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_oidc::{Audience, JwksFetcher, TokenVerifier, TokenVerifierConfig}; +use serde_json::Value; +use thiserror::Error; + +use crate::api::{VerifiedClaimValue, VerifiedRequestClaims}; +use crate::contract::{BoundaryOperator, FieldTypeSource, LookupValueOrigin}; +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", +]; + +/// Direct claims that may become Registry authority after OIDC verification. +#[derive(Clone, Eq, PartialEq)] +pub struct AuthorityClaimConfig { + principal_claim: String, + purpose_claim: Option, +} + +impl AuthorityClaimConfig { + #[must_use] + pub fn new(principal_claim: impl Into, purpose_claim: Option) -> Self { + Self { + principal_claim: principal_claim.into(), + purpose_claim, + } + } +} + +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) + .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() + } +} + +/// 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, + direct_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 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, + direct_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 + .direct_claims + .iter() + .filter_map(|(name, expectation)| { + claims.get(name).map(|value| { + mapped_claim(value, expectation).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("direct_claims", &self.direct_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 expected_direct_claims = 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 field_type = compiled_authority_field_type(entity, &boundary.field) + .ok_or(AuthenticationConfigError::CompiledAuthorityMismatch)?; + let expectation = DirectClaimExpectation { + field_type, + multi_value: boundary.operator == BoundaryOperator::In, + }; + 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, + }, + )?; + } + } + } + } + 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(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, + 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 { + !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, + expectation: &DirectClaimExpectation, +) -> Result { + 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 { + crate::correlation::problem_response( + StatusCode::UNAUTHORIZED, + "urn:registry-server:problem:authentication.refused", + "Unauthorized", + "The bearer credential is missing or refused.", + "authentication.refused", + ) +} diff --git a/crates/registry-server/src/compiler.rs b/crates/registry-server/src/compiler.rs new file mode 100644 index 0000000000..ac604ee4b2 --- /dev/null +++ b/crates/registry-server/src/compiler.rs @@ -0,0 +1,4351 @@ +// 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::{event_data_schema_binding, generate_artifacts}; +use crate::contract::{ + parsed_bbox, valid_decimal_bounds, valid_structured_schema, AccessProfileSource, + Classification, ConstraintSource, DerivedExecutionSource, DerivedFieldSource, + EntityExtensionSource, EntitySource, EventConditionSource, EventScalarValue, EventTrigger, + FieldSource, FieldTypeSource, LookupValueOrigin, ManifestProjectionTextSource, + ModuleAssetSource, MutationMode, Operation, ReadPathGrantSource, RegistryModule, + RegistryProject, UniqueWhenPredicate, ValidTimeRole, WebhookAuthenticationProfile, + 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, 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, CompiledWebhookRetryProfile, 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; +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; +pub const WEBHOOK_BACKOFF_MULTIPLIER: u8 = 2; + +type CollectedEntities = (BTreeMap, DerivedOriginMap); + +#[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 { + 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, + assets, + profile, + &mut diagnostics, + &mut findings, + ); + let (module_order, module_map) = order_modules(project, modules, &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, + &mut derived_origins, + &module_order, + &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); + 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, &derived_origins, assets)?; + 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(&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)); + } + 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) => {} + (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, + ); + validate_projection_text( + &projection.catalog.title, + "project.manifestProjection.catalog.title", + errors, + ); + nonempty( + &projection.catalog.publisher.name, + "project.manifestProjection.catalog.publisher.name", + "manifest_projection.catalog.publisher.name_empty", + errors, + ); + if let Some(description) = projection.catalog.description.as_ref() { + validate_projection_text( + description, + "project.manifestProjection.catalog.description", + errors, + ); + } + if let Some(description) = projection.dataset.description.as_ref() { + validate_projection_text( + description, + "project.manifestProjection.dataset.description", + errors, + ); + } + validate_projection_text( + &projection.dataset.title, + "project.manifestProjection.dataset.title", + 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", + )); + } + 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, + ); + } + } + } + + 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", + )); + } + + 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( + 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], + assets: &[ModuleAssetSource], + 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_with_assets(module, assets); + 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 { + 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 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())) +} + +type DerivedOriginMap = BTreeMap<(String, String), Option>; + +fn collect_entities( + project: &RegistryProject, + module_order: &[String], + modules: &BTreeMap, + errors: &mut Vec, +) -> CollectedEntities { + let mut entities = BTreeMap::new(); + let mut derived_origins = BTreeMap::new(); + for entity in &project.entities { + 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, + &mut derived_origins, + entity, + Some(module.id.clone()), + "modules[].entities[].id", + errors, + ); + } + } + } + (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, +) { + 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", + )); + return; + } + for derived in &entity.derived { + derived_origins.insert((entity.id.clone(), derived.id.clone()), module.clone()); + } +} + +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, + derived_origins: &mut BTreeMap<(String, String), Option>, + 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, + Some(module.id.clone()), + derived_origins, + errors, + ); + } + } +} + +fn merge_extension( + entity: &mut EntitySource, + extension: &EntityExtensionSource, + module: Option, + derived_origins: &mut BTreeMap<(String, String), Option>, + 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, + ); + 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, + |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, + ); + 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 + .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 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, + 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", + )); + } + 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()) { + 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: profile.anonymous, + principal_claim: profile.principal_claim.clone(), + required_scopes: profile.required_scopes.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(), + 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, + }); + } + } +} + +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, + 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); + 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_derived(entity, errors); + validate_logical_names(entity, errors); + validate_constraints(entity, errors); + validate_indexes(entity, errors); + validate_selector_profiles(entity, errors); + validate_read_paths(entity, entities, errors); + validate_profiles(entity, entities, errors); + validate_events(entity, profile, &mut event_ids, errors); + } + validate_read_path_cycles(entities, 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 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", + "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_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 + .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 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 + .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_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() + .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); + 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 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 read_processed.iter().any(|field| { + !fields.contains_key(field.as_str()) && !derived.contains_key(field.as_str()) + }) || stored_processed + .iter() + .any(|field| field != "id" && !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 + || 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( + "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 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", + "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", + )); + } + } + 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 + .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", + )); + } + } + 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, + profile: CompileProfile, + registry_event_ids: &mut BTreeSet, + 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", + )); + } 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( + "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(&entity.id, &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", + )); + } + 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 + .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", + )); + } + } +} + +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.when.field_unknown", + "entities[].events[].when.changed", + "an event condition refers to an unknown field", + )); + } + } + 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", + )); + } + } + } +} + +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>( + 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 { + // 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) +} + +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, + // 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( + registry_id: &str, + entities: &BTreeMap, +) -> Result { + let mut deliveries = entities + .values() + .flat_map(|entity| { + entity.events.values().filter_map(move |event| { + event + .webhook + .as_ref() + .map(|webhook| (entity, event, webhook)) + }) + }) + .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)); + 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 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_some_and(str::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) + .map(|_| { + let current = delay; + delay = delay + .saturating_mul(u32::from(WEBHOOK_BACKOFF_MULTIPLIER)) + .min(maximum_ms); + current + }) + .collect() +} + +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 stored_fields = Vec::new(); + for field in source.fields.clone() { + 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()); + 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 { + 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 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 { + 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), + canonical_id, + stored_fields, + derived_fields, + derived_relations, + source_relation, + selector_profiles, + read_paths, + 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 { + 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)) + }); + 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_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() + .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_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()); + }; + 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 (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() + .push(CompiledMetadataEntry { + route_id: route.id.clone(), + operation: route.operation, + access_profile: profile_id.clone(), + response_entity_id: response_entity.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 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", + "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) { + if let Some(operation) = query_operation( + 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); + } + if let Some(temporal) = &entity.temporal { + let binding = temporal_binding(temporal); + if let Some(operation) = query_operation( + 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( + 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); + } + } + } + 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( + 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); + } + } + } + } + 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); + } + } + } + } + operations.sort_by(|left, right| left.id.cmp(&right.id)); + 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, + }) +} + +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 + .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 (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 (field_type, _) = compiled_field_type(entity, field)?; + query_sort_field(field_type, field, errors) + }) + .collect::>(); + 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(), + kind, + max_page_size: 100, + 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, + }) +} + +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); + operators.push(CompiledQueryFilterOperator::Contains); + } + 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 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, + 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::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}}")), + Operation::Batch => (HttpMethod::Post, format!("{base}:batch")), + Operation::Revisions => (HttpMethod::Get, format!("{base}/{{record_id}}/revisions")), + } +} + +fn all_operations() -> [Operation; 8] { + [ + Operation::Create, + Operation::Get, + Operation::Lookup, + 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::Lookup => "lookup", + 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 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( + 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..dd7171eeb1 --- /dev/null +++ b/crates/registry-server/src/contract.rs @@ -0,0 +1,1802 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, 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, + #[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))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManifestProjectionCatalogSource { + pub base_url: String, + pub title: ManifestProjectionTextSource, + #[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, + #[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))] +#[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 { + #[serde(default, skip_serializing_if = "Option::is_none")] + 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))] +#[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, +} + +#[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")] +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, + /// 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, + #[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))] +#[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, skip_serializing_if = "Vec::is_empty")] + pub derived: Vec, + #[serde(default)] + 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, + #[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))] +#[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 +} + +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)] + 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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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(default)] + api_name: Option, + #[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 = parse_field_type::(&raw)?; + Ok(Self { + id: raw.id, + api_name: raw.api_name, + field_type, + required: raw.required, + classification: raw.classification, + valid_time_role: raw.valid_time_role, + }) + } +} + +#[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)] + 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, +} + +#[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 { + 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, 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, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Operation { + Create, + Get, + Lookup, + 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 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 +/// 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, +} + +#[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, 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, + #[serde(default)] + pub anonymous: bool, + #[serde(default)] + pub principal_claim: Option, + #[serde(default)] + pub required_scopes: BTreeSet, + #[serde(default)] + pub required_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 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, 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")] +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/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/cursor.rs b/crates/registry-server/src/cursor.rs new file mode 100644 index 0000000000..ecb0f2b1b8 --- /dev/null +++ b/crates/registry-server/src/cursor.rs @@ -0,0 +1,705 @@ +// 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::contract::FieldTypeSource; +use crate::model::{CompiledQueryKind, CompiledQuerySortDirection}; + +const WIRE_VERSION: u8 = 3; +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-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; + +#[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) 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, +} + +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("scope_reference", &"") + .field("page_size", &self.page_size) + .field("include_count", &self.include_count) + .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) 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 { + 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 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 CursorFilterPredicate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .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 { + 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(), + 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()], + } + } + + fn payload() -> CursorPayload { + CursorPayload { + version: WIRE_VERSION, + issued_at_unix_seconds: 1_000, + expires_at_unix_seconds: 1_060, + binding: binding(), + query: CursorQuery { + projection: vec![CursorProjectionField { + field_id: "label".to_owned(), + field_type: FieldTypeSource::String { + min_length: 0, + max_length: 80, + }, + }], + 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(), + 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.scope_reference = "hmac-sha256:other-scope".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..2ae3f55e41 --- /dev/null +++ b/crates/registry-server/src/data.rs @@ -0,0 +1,1912 @@ +// 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, CompiledStoredField, 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() + .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(), + }) + } + + 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 + .stored_fields + .iter() + .any(|field| field.required && !data.contains_key(&field.logical.api_name)) + { + return Err(DataError::InvalidItem); + } + 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) + { + 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 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) + || 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 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); + } + 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_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 + && 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_api_names + .iter() + .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_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, + }) + } + + 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()), + ("$skiptoken", cursor), + ], + ) + } else { + query_path( + &plan.route_path, + &[ + ("accessProfile", plan.profile_id.as_str()), + ("$select", fields.as_str()), + ("$top", 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_none_or(|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)?; + if !(object.len() == 2 || object.len() == 3) + || !object.contains_key("items") + || !object.contains_key("pageInfo") + || (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); + } + if object.get("count").is_some_and(|count| { + count + .as_u64() + .is_none_or(|count| count < items.len() as u64) + }) { + 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_none_or(|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/derived_sql.rs b/crates/registry-server/src/derived_sql.rs new file mode 100644 index 0000000000..6bd3c49490 --- /dev/null +++ b/crates/registry-server/src/derived_sql.rs @@ -0,0 +1,237 @@ +// 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 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) => { + 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; + } + } + 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).is_none_or(|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/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..545177e533 --- /dev/null +++ b/crates/registry-server/src/event_destination.rs @@ -0,0 +1,683 @@ +// 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::contract::Classification; +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, + 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 { + 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 + || delivery.classification_ceiling > config.classification_ceiling + }) { + 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, + 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 { + &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) + } + + /// 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 { + 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()) + .field("payload_retention", &self.payload_retention) + .finish() + } +} + +/// One activated logical event destination. +pub struct ActivatedEventDestination { + binding_digest: String, + request_target: 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 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()) + } + + /// 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("request_target", &"[REDACTED]") + .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, + classification_ceiling: Classification, + 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, + classification_ceiling: raw.classification_ceiling, + 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)?, + request_target: self.path.clone(), + 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(), + "classificationCeiling": self.classification_ceiling, + "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, + }) + } +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +enum EventDestinationNetworkProfile { + ProductionHttps, + LoopbackDevelopmentHttp, + #[cfg(feature = "postgres-test")] + PinnedLoopbackHttpsTest, +} + +impl EventDestinationNetworkProfile { + fn platform(self) -> DestinationProfile { + match self { + Self::ProductionHttps => DestinationProfile::ProductionHttps, + Self::LoopbackDevelopmentHttp => DestinationProfile::LoopbackDevelopmentHttp, + #[cfg(feature = "postgres-test")] + Self::PinnedLoopbackHttpsTest => DestinationProfile::PinnedLoopbackHttpsTest, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::ProductionHttps => "productionHttps", + Self::LoopbackDevelopmentHttp => "loopbackDevelopmentHttp", + #[cfg(feature = "postgres-test")] + Self::PinnedLoopbackHttpsTest => "pinnedLoopbackHttpsTest", + } + } +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[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; + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[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, + classification_ceiling: Classification, + #[serde(default)] + tls: Option, + delivery_ceilings: RawEventDestinationDeliveryCeilings, +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawEventDestinationTlsConfig { + #[serde(default)] + ca_bundle_ref: Option, + #[serde(default)] + client_identity_ref: Option, +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[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..627d2250ff --- /dev/null +++ b/crates/registry-server/src/fixtures.rs @@ -0,0 +1,3726 @@ +// 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_httpsec::{response_trace_id, TraceId}; +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_with_assets, module_digest_with_assets, CompileProfile}; +use crate::contract::{parse_module_yaml, parse_project_yaml, ModuleAssetSource}; +use crate::contract::{AccessProfileSource, LookupValueOrigin, Operation}; +use crate::derived_sql::MAX_DERIVED_SQL_BYTES; +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 = 15; +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, + Query { + #[serde(default)] + select: BTreeSet, + #[serde(default)] + top: Option, + #[serde(default)] + count: bool, + }, + Lookup { + selector: String, + #[serde(default)] + values: Map, + }, + ReadPath { + path: String, + record_ref: String, + #[serde(default)] + select: BTreeSet, + #[serde(default)] + top: Option, + #[serde(default)] + count: bool, + }, + 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 | Self::Query { .. } | Self::ReadPath { .. } => Operation::List, + Self::Lookup { .. } => Operation::Lookup, + Self::Patch { .. } => Operation::Patch, + 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)] +#[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, + response_readable_fields: BTreeSet, + 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 !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) + }) + .cloned() + .ok_or(FixtureError::LogicalReferenceRefused)?; + 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_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() + .find(|grant| grant.path == *path) + .map(|grant| grant.readable_fields.clone()) + .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, + access_profile: step.access_profile, + claims: step.claims, + route, + profile: profile.clone(), + response_readable_fields, + action, + 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::ReadPath { record_ref, .. } => &[record_ref], + ActionSource::Patch { + record_ref, + etag_ref, + .. + } => &[record_ref, etag_ref], + ActionSource::Create { .. } + | ActionSource::List + | ActionSource::Query { .. } + | ActionSource::Lookup { .. } + | 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, + registry: &CompiledRegistry, + 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::Query { select, top, count } => { + validate_structured_query(registry, entity, profile, None, select, *top, *count) + } + 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 + || !origin_shape_is_valid + || canonical_size(&Value::Object(values.clone()))? > MAX_BODY_BYTES + { + return Err(FixtureError::LogicalReferenceRefused); + } + Ok(()) + } + ActionSource::ReadPath { + 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); + } + 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_structured_query( + registry: &CompiledRegistry, + 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) { + return Err(FixtureError::JourneyBoundsRefused); + } + 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 + || (count && !grant.allow_count) + || !select.is_subset(&grant.readable_fields) + || select + .iter() + .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 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, + 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::Lookup + | 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; + let mut extra_query_options = Vec::new(); + 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::Query { select, top, count } => { + extra_query_options = fixture_query_options(step, None, select, *top, *count)?; + } + 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)?; + } + 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); + 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) + .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 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 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 { + 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 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") + .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", "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) + || 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 | ActionSource::Query { .. } | ActionSource::ReadPath { .. } => { + 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)?; + 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.response_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.response_readable_fields, &Map::new())?; + } + } + ActionSource::Create { .. } + | ActionSource::Get { .. } + | ActionSource::Lookup { .. } + | ActionSource::Patch { .. } => { + assert_record_shape( + document, + &step.response_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 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], + 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, +/// 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); + } + 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 + .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 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()) + { + return Err(FixtureError::CandidateBindingRefused); + } + modules.push(module); + module_assets.extend(assets); + } + + let compiled = compile_project_with_assets( + &project, + &modules, + &module_assets, + 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); + } + 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); + } + 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 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_with_assets(&module, &assets).as_str()) + { + return Err(FixtureError::CandidateBindingRefused); + } + modules.push(module); + module_assets.extend(assets); + } + 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)?; + 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 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, + 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); + 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, + 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); + for asset in &module.assets { + let path = format!("source/modules/{}/{}", module.id, asset); + let bytes = files + .get(&path) + .ok_or(FixtureError::CandidateBindingRefused)?; + digest_part(&mut digest, 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::Lookup | 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::compiler::module_digest; + 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, + assets: &[], + }]; + 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, + assets: &[], + }]; + 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", + "traceId": "11111111111111111111111111111111", + "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", + "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"}}], + "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, + assets: &[], + }]; + 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(), + assets: Vec::new(), + }], + 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(), + assets: Vec::new(), + }], + 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" + }, + "traceId":"11111111111111111111111111111111" + }), + 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..0e530439ae --- /dev/null +++ b/crates/registry-server/src/generated_ddl.rs @@ -0,0 +1,1249 @@ +// 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}; + +const RECORD_ID_COLUMN: &str = "record_id"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DdlStatementKind { + Schema, + Table, + Column, + View, + Function, + Reference, + Constraint, + Index, + RowSecurity, + Policy, + Grant, +} + +#[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 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 { + 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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub views: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub functions: 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 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![ + "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(); + 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, entities); + let policies = policies(entity, entities); + 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.name), + 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, + }); + } + + // Derived SQL may read any compiled registry_source relation. Install the + // complete logical source layer before installing any dependent view. + for entity in entities.values() { + let source_view = quote_identifier(&entity.source_relation.sql_name); + let mut source_columns = vec![format!( + "{RECORD_ID_COLUMN} AS {}", + quote_identifier(&entity.canonical_id.sql_name) + )]; + 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 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); + 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 key = quote_identifier(&relation.key_field.replace('-', "_")); + // `$` 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 {}", + canonical_key, + 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 ( + 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 {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 { + id: format!("entity.{}.derived.{}", entity.id, relation.id), + schema: "registry_derived".to_owned(), + name: derived_view_name, + runtime_privileges: BTreeSet::from([TablePrivilege::Select]), + }); + } + } + + DdlInventory { + requires_btree_gist: entities.values().any(|entity| { + entity + .constraints + .values() + .any(|value| matches!(value, ConstraintSource::TemporalNonOverlap { .. })) + }), + 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")] +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, + entities: &BTreeMap, +) -> 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::Lookup + | Operation::List + | Operation::Batch + | Operation::Revisions + ) + }) || path_select_entities(entities).contains(&entity.id) + { + 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, + entities: &BTreeMap, +) -> 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.extend(read_path_policies_for_table(entity, entities)); + policies +} + +fn profile_supports_command(operations: &BTreeSet, command: PolicyCommand) -> bool { + match command { + PolicyCommand::Select => operations.iter().any(|operation| { + matches!( + operation, + Operation::Get + | Operation::Lookup + | 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 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 target_table = quote_identifier(&target.physical_table); + let target_record_id = + field_name_with_alias(target, &target.canonical_id.id, Some(target_table.as_str())); + 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} = {target_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), '') = {}", + 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_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")); + 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 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", + 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 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 {}", + 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(physical_field_name(entity, field)) +} + +fn physical_field_name<'a>(entity: &'a CompiledEntity, field: &str) -> &'a str { + if field == entity.canonical_id.id { + RECORD_ID_COLUMN + } else { + &entity.fields[field].physical_name + } +} + +fn logical_field_type(entity: &CompiledEntity, field: &str) -> FieldTypeSource { + if field == entity.canonical_id.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 => "<", + ComparisonOperator::LessThanOrEqual => "<=", + ComparisonOperator::GreaterThan => ">", + ComparisonOperator::GreaterThanOrEqual => ">=", + } +} + +pub(crate) 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..fa77e640d4 --- /dev/null +++ b/crates/registry-server/src/lib.rs @@ -0,0 +1,65 @@ +// 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 correlation; +#[cfg(feature = "runtime")] +pub mod cursor; +pub mod data; +pub mod derived_sql; +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 logical_names; +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; +pub mod query; +#[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, compile_project_with_assets, 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/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/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..a7ef6bfa3a --- /dev/null +++ b/crates/registry-server/src/manifest_adapter.rs @@ -0,0 +1,544 @@ +// SPDX-License-Identifier: Apache-2.0 +//! One-way lossy Registry Manifest projection. + +use std::collections::{BTreeMap, BTreeSet}; + +use registry_manifest_core::{ + 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, + ManifestProjectionEntitySource, ManifestProjectionFieldSource, ManifestProjectionSource, + ManifestProjectionTextSource, ManifestProjectionVocabularySource, Operation, +}; +use crate::diagnostics::Diagnostic; +use crate::model::{CompiledEntity, CompiledField}; + +pub(crate) struct ProjectedManifestArtifacts { + pub manifest: Vec, + pub dcat: Vec, +} + +pub(crate) fn project_manifest_artifacts( + registry_id: &str, + projection: &ManifestProjectionSource, + entities: &BTreeMap, +) -> Result { + let manifest = project_manifest(registry_id, projection, entities); + 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())?; + 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( + registry_id: &str, + projection: &ManifestProjectionSource, + 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 { + 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: 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: 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(), + evaluation_profiles: Vec::new(), + ecosystem_bindings: Vec::new(), + requirements: Vec::new(), + evidence_types: Vec::new(), + authorities: Vec::new(), + public_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: 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: 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, + evidence_offerings: Vec::new(), + entities: visible_entities + .iter() + .map(|entity| project_entity(projection, entity, &visible_entities)) + .collect(), + }], + 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()), + } +} + +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 metadata = projection + .entities + .iter() + .find(|metadata| metadata.id == entity.id); + 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(|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, + field_metadata(metadata, &field.id), + &visible_entity_ids, + ) + }) + .collect(); + + registry_manifest_core::EntityManifest { + name: entity.id.clone(), + 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 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 { + 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: 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 { + 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: 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, + 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(), + }, + 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); + 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..b15ad3b0c6 --- /dev/null +++ b/crates/registry-server/src/migration.rs @@ -0,0 +1,737 @@ +// 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::event_destination::EventDestinationCompatibilityInventory; +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>], + event_destination_compatibility_inventory: Option<&'a EventDestinationCompatibilityInventory>, + 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: &[], + event_destination_compatibility_inventory: None, + 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 + } + + /// 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)] + 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) + .enumerate() + .map(|(ordinal, (statement, checksum))| { + Ok(PackageDdlStatement { + sql: &statement.sql, + checksum, + kind: statement.kind, + ordinal: i32::try_from(ordinal).map_err(|_| MigrationError::PackageBinding)?, + }) + }) + .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, + request.event_destination_compatibility_inventory, + ) + .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..02242af0d8 --- /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 + || !(15..=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..fc0714dd1f --- /dev/null +++ b/crates/registry-server/src/model.rs @@ -0,0 +1,509 @@ +// 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, EventConditionSource, + 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 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 { + 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 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>, + 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, + #[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, + /// 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, 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 { + 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 { + #[serde(default)] + pub route_id: String, + 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 response_entity_id: 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, + Contains, +} + +#[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, + #[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, +} + +#[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..44fe08f72c --- /dev/null +++ b/crates/registry-server/src/mutation.rs @@ -0,0 +1,2872 @@ +// 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::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, +}; +use crate::correlation::RequestCorrelation; +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, CompiledWebhookRetryProfile, 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 + 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) + ); + 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), + 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 + 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 + 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, + lease_expires_at timestamptz, + 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, + 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) + ) + ); + 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)?; + // `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!( + "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 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}\"; + 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 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.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.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( + WEBHOOK_INITIAL_BACKOFF_MS, + WEBHOOK_MAXIMUM_BACKOFF_MS, + WEBHOOK_MAXIMUM_ATTEMPTS, + ) + || delivery.dead_letter != crate::contract::WebhookDeadLetterMode::Required + || !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 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 { + crate::compiler::maximum_compiled_event_payload_bytes(entity, event) +} + +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 correlation: RequestCorrelation, +} + +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, + pub correlation: RequestCorrelation, +} + +#[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); + } + let normalized_body = match normalize_mutation_body(&request.plan.entity, &request.body) { + Ok(body) => body, + Err(error) => { + self.record_boundary_audit(client, request, PreIoAuditKind::Refusal) + .await?; + return Err(error); + } + }; + let request = MutationRequest { + plan: request.plan, + idempotency_key: request.idempotency_key, + claims: request.claims, + record_id: request.record_id, + 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) + .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); + } + let normalized_items = match normalize_batch_items(&request.plan.entity, &request.items) { + Ok(items) => items, + Err(error) => { + self.record_batch_boundary_audit(client, request, PreIoAuditKind::Refusal) + .await?; + return Err(error); + } + }; + let request = BatchMutationRequest { + plan: request.plan, + idempotency_key: request.idempotency_key, + claims: request.claims, + 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) + .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, + correlation: &request.correlation, + }, + ) + .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, + correlation: &request.correlation, + }, + ) + .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, + correlation: request.correlation.clone(), + }, + ) + .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_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, + 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?; + 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, + correlation: request.correlation.clone(), + }, + ) + .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, + correlation: request.correlation.clone(), + }, + ) + .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(), + correlation: request.correlation.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_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, + 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?; + 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, + correlation: request.correlation.clone(), + }, + ) + .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 = response_data( + &request.plan.entity, + ¤t.data, + &request.response_fields, + )?; + 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 = response_data( + &request.plan.entity, + ¤t.data, + &request.response_fields, + )?; + 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, + before_data: Option>, + 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 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 => { + 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(), + before_data: Some(current.data.clone()), + 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 normalize_mutation_body( + entity: &CompiledEntity, + body: &MutationBody, +) -> Result { + match body { + MutationBody::Create(data) => { + Ok(MutationBody::Create(normalize_create_data(entity, data)?)) + } + MutationBody::Patch(operations) => Ok(MutationBody::Patch( + operations + .iter() + .map(|operation| normalize_patch_operation(entity, operation)) + .collect::, _>>()?, + )), + MutationBody::Tombstone => Ok(MutationBody::Tombstone), + } +} + +fn normalize_batch_items( + entity: &CompiledEntity, + items: &[BatchMutationItem], +) -> Result, MutationError> { + items + .iter() + .map(|item| match item { + BatchMutationItem::Create(data) => Ok(BatchMutationItem::Create( + normalize_create_data(entity, data)?, + )), + BatchMutationItem::Patch { + record_id, + expected_etag, + patch, + } => Ok(BatchMutationItem::Patch { + record_id: record_id.clone(), + expected_etag: expected_etag.clone(), + patch: patch + .iter() + .map(|operation| normalize_patch_operation(entity, operation)) + .collect::, _>>()?, + }), + }) + .collect() +} + +fn normalize_create_data( + entity: &CompiledEntity, + data: &Map, +) -> Result, MutationError> { + let mut normalized = Map::new(); + for (api_name, value) in data { + let field_id = field_id_for_api_name(entity, api_name)?; + if normalized + .insert(field_id.to_owned(), value.clone()) + .is_some() + { + return Err(MutationError::InvalidRequest); + } + } + Ok(normalized) +} + +fn normalize_patch_operation( + entity: &CompiledEntity, + operation: &PatchOperation, +) -> Result { + let normalized_path = |path: &str| -> Result { + let api_name = patch_field(path)?; + let field_id = field_id_for_api_name(entity, &api_name)?; + Ok(format!("/data/{}", encode_pointer_segment(field_id))) + }; + Ok(match operation { + PatchOperation::Add { path, value } => PatchOperation::Add { + path: normalized_path(path)?, + value: value.clone(), + }, + PatchOperation::Replace { path, value } => PatchOperation::Replace { + path: normalized_path(path)?, + value: value.clone(), + }, + PatchOperation::Remove { path } => PatchOperation::Remove { + path: normalized_path(path)?, + }, + PatchOperation::Test { path, value } => PatchOperation::Test { + path: normalized_path(path)?, + value: value.clone(), + }, + }) +} + +fn field_id_for_api_name<'a>( + entity: &'a CompiledEntity, + api_name: &str, +) -> Result<&'a str, MutationError> { + let mut matches = entity + .stored_fields + .iter() + .filter(|field| field.logical.api_name == api_name); + let field = matches.next().ok_or(MutationError::InvalidRequest)?; + if matches.next().is_some() || !entity.fields.contains_key(&field.logical.id) { + return Err(MutationError::InvalidRequest); + } + Ok(&field.logical.id) +} + +fn response_data( + entity: &CompiledEntity, + data: &Map, + response_fields: &BTreeSet, +) -> Result, MutationError> { + let mut response = Map::new(); + for (field_id, value) in data { + if !response_fields.contains(field_id) { + continue; + } + let field = entity + .stored_fields + .iter() + .find(|field| field.logical.id == *field_id) + .ok_or(MutationError::Unavailable)?; + if response + .insert(field.logical.api_name.clone(), value.clone()) + .is_some() + { + return Err(MutationError::Unavailable); + } + } + Ok(response) +} + +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 encode_pointer_segment(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +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, + before_data: None, + 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(), + correlation: request.correlation.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..313f5afbc6 --- /dev/null +++ b/crates/registry-server/src/outbox.rs @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! 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::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use tokio_postgres::Transaction; +use uuid::Uuid; + +use crate::contract::{ + Classification, EventConditionSource, EventScalarValue, 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_id: &'a str, + pub record_reference: &'a str, + pub record_revision: i64, + pub package_revision: &'a str, + pub schema_fingerprint: &'a str, + pub before: Option<&'a Map>, + pub after: Option<&'a Map>, + pub payload_retention: Duration, +} + +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) + { + if !condition_matches(event.when.as_ref(), mutation.before, mutation.after)? { + continue; + } + 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 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 = snapshot.get(field).ok_or(OutboxError::InvalidProjection)?; + values.insert(field.to_owned(), value.clone()); + } + 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() + > 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 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, + payload_expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, + transaction_timestamp() + $10::bigint * interval '1 millisecond')", + &[ + &event_id, + &event.id, + &trigger_name(mutation.trigger), + &mutation.entity_id, + &mutation.record_reference, + &mutation.record_revision, + &mutation.package_revision, + &mutation.schema_fingerprint, + &payload, + &retention_milliseconds, + ], + ) + .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(()) +} + +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], + 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, + 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, $22)", + &[ + &event_id, + &delivery.id, + &delivery.destination_id, + &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), + &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..504b6c5343 --- /dev/null +++ b/crates/registry-server/src/package.rs @@ -0,0 +1,3345 @@ +// 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_with_assets, CompileProfile}; +use crate::contract::{ + parse_module_yaml, parse_project_yaml, FieldTypeSource, ModuleAssetSource, RegistryModule, + RegistryProject, +}; +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, + 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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assets: Vec, +} + +#[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, + SourceModuleAsset, + FixtureJourneys, + GovernedModel, + PhysicalNameInventory, + RouteInventory, + AccessInventory, + QueryInventory, + EventInventory, + CallerSafeMetadata, + GeneratedDdl, + MigrationPlan, + GeneratedOpenapi, + EntityJsonSchema, + LossyManifestProjection, + DcatCatalogProjection, + 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, + DerivedRelationAdded, + DerivedRelationRemoved, + DerivedRelationChanged, + 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, + DerivedRelation, + 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, + pub assets: 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_derived_relations(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_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, + 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 replacement_view_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")); + } + 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() { + 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( + if replacement_view_statement_ids.contains(statement.id.as_str()) { + replacement_statement(statement) + } else { + 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 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, + 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, + ); + 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, + 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(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 + || 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 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 + .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); + } + 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( + 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, + )?); + 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, + 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) + || 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; + } + 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 { + assets: module.assets.into_iter().map(|asset| asset.path).collect(), + 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, + }) +} + +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)?) + .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(), + )?; + 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; + }; + 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, + 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, + "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, + "manifest/dcat.jsonld" => PackageFileRole::DcatCatalogProjection, + _ => 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::>>()?; + let module_assets = captured_module_assets(manifest, loaded)?; + validate_captured_bindings(manifest, &project, &modules)?; + 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); + } + + let expected_artifacts = expected_artifact_bytes(manifest, &compiled)?; + let packaged_artifacts = manifest + .files + .iter() + .filter(|entry| { + !matches!( + entry.role, + PackageFileRole::SourceProject + | PackageFileRole::SourceModule + | PackageFileRole::SourceModuleAsset + | 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 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>, +) -> 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(); + let mut asset_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); + } + 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 + .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); + } + 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; + } + 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..90c37b399c --- /dev/null +++ b/crates/registry-server/src/postgres/catalog.rs @@ -0,0 +1,1195 @@ +// 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 MANAGED_SCHEMAS: &[&str] = &[ + "registry_internal", + "registry_data", + "registry_source", + "registry_derived", + "registry_context", +]; +const TABLE_OWNER_PRIVILEGES: &[&str] = &[ + "DELETE", + "INSERT", + "MAINTAIN", + "REFERENCES", + "SELECT", + "TRIGGER", + "TRUNCATE", + "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 { + fn as_str(self) -> &'static str { + match self { + Self::Schema => "schema", + Self::Table => "table", + Self::View => "view", + Self::Sequence => "sequence", + Self::Function => "function", + } + } +} + +#[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(), + }); + } + } + 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 + } + + fn base() -> Self { + let mut catalog = Self { + objects: BTreeSet::new(), + policies: BTreeSet::new(), + }; + for schema in MANAGED_SCHEMAS { + catalog.schema(schema); + } + 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 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 { + 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, 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(), + 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 = 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 = ANY($1::text[]) + 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 = 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 = 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 = 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 = 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)) { + 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 = ANY($1::text[]) + UNION ALL + 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 = 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 + .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 = ANY($1::text[]) AND c.relkind = 'r'", + &[&MANAGED_SCHEMAS], + ) + .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 = ANY($1::text[])", + &[&MANAGED_SCHEMAS], + ) + .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 = ANY($2::text[]) + UNION ALL + 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 = 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 + ) + 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(), &MANAGED_SCHEMAS], + ) + .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::View => TABLE_OWNER_PRIVILEGES, + ManagedObjectKind::Sequence => SEQUENCE_OWNER_PRIVILEGES, + ManagedObjectKind::Function => FUNCTION_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 = 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 + .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 = ANY($1::text[]) + ORDER BY n.nspname, c.relname, x.conname", + &[&MANAGED_SCHEMAS], + ) + .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 = ANY($1::text[]) + ORDER BY n.nspname, table_class.relname, index_class.relname", + &[&MANAGED_SCHEMAS], + ) + .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 = 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?; + 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/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 { + 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..001b4b9001 --- /dev/null +++ b/crates/registry-server/src/postgres/context.rs @@ -0,0 +1,793 @@ +// 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_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_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, AccessGrantSource, Classification, EntitySource, FieldSource, + FieldTypeSource, MutationMode, Operation, ProjectAccessProfileSource, 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")); + } + } + + #[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(), + 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"}] + }, + { + "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", + "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"}, + {"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, + 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, + }, + required: true, + classification: Classification::Internal, + valid_time_role: None, + }, + FieldSource { + id: "region".to_owned(), + api_name: None, + 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::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(), + 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, + }], + }, + 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(), + 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, + }], + revision_access: false, + allow_data_export: false, + lookups: Vec::new(), + read_paths: Vec::new(), + allow_count: false, + }], + }, + ], + 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..f6afbafcb3 --- /dev/null +++ b/crates/registry-server/src/postgres/interlock.rs @@ -0,0 +1,1981 @@ +// 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::event_destination::EventDestinationCompatibilityInventory; +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, + pub kind: DdlStatementKind, + pub ordinal: i32, +} + +#[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, + 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() { + 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); + } + } + } + } + } + } + + 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) + } + + 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, + views: bool, + ) -> 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 statement in statements + .iter() + .filter(|statement| (statement.kind == DdlStatementKind::View) == views) + { + validate_statement_checksum(statement)?; + let ledger_step = ledger_step(ledger, 0, statement.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?; + let complete = step_progress(&transaction, ledger, ledger_step) + .await? + .complete; + if complete && !views { + transaction.commit().await?; + continue; + } + transaction + .batch_execute(statement.sql) + .await + .map_err(|_| PostgresKernelError::Connection)?; + 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 {}.{} RESTRICT", + schema.quoted(), + view.quoted() + )) + .await + .map_err(|_| PostgresKernelError::Connection)?; + } + 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, + event_destination_compatibility_inventory: Option<&EventDestinationCompatibilityInventory>, + ) -> 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?; + verify_retained_webhook_delivery_bindings( + &transaction, + event_destination_compatibility_inventory, + ) + .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(()) + } +} + +/// 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') + 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 ( + 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, + 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..bc42a87d30 --- /dev/null +++ b/crates/registry-server/src/postgres/mod.rs @@ -0,0 +1,95 @@ +// 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; +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"))] +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..589ae1ea97 --- /dev/null +++ b/crates/registry-server/src/postgres/mutation.rs @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Concrete PostgreSQL mutation runtime for the compiled HTTP surface. + +use std::sync::Arc; +use std::time::Duration; + +use registry_platform_audit::AuditProfile; + +use crate::api::{ + 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; +#[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(crate) async fn record_refusal( + &self, + event: HttpRefusalAudit<'_>, + ) -> 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, + event, + ) + .await + .map_err(MutationError::from) + } + + pub async fn create( + &self, + input: CreateMutationInput<'_>, + ) -> 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: None, + expected_etag: None, + body: MutationBody::Create(input.data), + response_fields: input.response_fields, + correlation: input.correlation.clone(), + }, + ) + .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, + correlation: input.correlation.clone(), + }; + #[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, + correlation: input.correlation.clone(), + }, + ) + .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..d5b48ddde2 --- /dev/null +++ b/crates/registry-server/src/postgres/read.rs @@ -0,0 +1,2416 @@ +// 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, 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, CursorFilterExpr, CursorFilterOperator, + CursorLogicalOp, CursorOrderClause, CursorProjectionField, CursorQueryScope, +}; +use crate::model::{ + CompiledEntity, CompiledQueryKind, CompiledQueryOperation, CompiledQuerySortDirection, + CompiledReadPath, 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) -> 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() + .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, + ) { + 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, + correlation: &request.correlation, + }, + ) + .await + .map_err(|_| ReadServiceError::Unavailable)?; + return Ok(ReadResult::empty_get()); + } + }; + if operation == Operation::Get && !target_record.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, + correlation: &request.correlation, + }, + ) + .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, + correlation: &request.correlation, + }, + ) + .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 = target_record.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 = match (plan.operation, held.result_count) { + (Operation::Lookup, 0) => TerminalAuditOutcome::Unresolved, + (_, 0) => TerminalAuditOutcome::Empty, + _ => 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(&request.kind) + .map(|query| query.cursor_binding.query_reference.clone()), + row_boundary_reference: request_query(&request.kind) + .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 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() + .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 {} + WHERE {} = $1::text::uuid + LIMIT 1", + relations.from_sql, relations.id_expression + ); + let record_id = + target_record(&request.kind).ok_or(ReadServiceError::Unavailable)?; + transaction + .transaction() + .query(&sql, &[&record_id]) + .await + .map_err(|_| ReadServiceError::Unavailable)? + } + Operation::List => { + let query = query.ok_or(ReadServiceError::Unavailable)?; + let _compiled_query = plan + .query_operation + .as_ref() + .ok_or(ReadServiceError::Unavailable)?; + 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) + .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 = 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] + } else { + rows.as_slice() + }; + let next_cursor = if has_more { + let query = query.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, + total_count, + }) + } + + 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.order.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 = target_record(&request.kind) + .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: (outcome != TerminalAuditOutcome::Unresolved).then_some(result_count), + field_set_reference: Some(field_set_reference), + correlation: request.correlation.clone(), + }) + } +} + +impl RecordReadService for PostgresRecordReadService { + fn get( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(async move { + let result = self.execute(request).await?; + Ok(result.response) + }) + } + + fn list( + &self, + request: RecordReadRequest, + ) -> ServiceFuture<'_, Result> { + Box::pin(async move { + 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, + ) -> 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, + correlation: &request.correlation, + }, + ) + .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 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), + } + } +} + +struct MaterializedRead { + rows: Vec, + next_cursor: Option, + total_count: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RecordEnvelope { + id: String, + revision: u64, + 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 { + fn from_request( + registry: &CompiledRegistry, + expected: &ExpectedRegistryIdentity, + cursors: &CursorCodec, + request: &RecordReadRequest, + ) -> Result { + let operation = request_operation(&request.kind); + let route = registry + .routes() + .routes + .iter() + .find(|route| route.id == request.operation_id) + .ok_or(())?; + 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 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 + } + 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()) + } + }; + 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()) + || (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 + || 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| 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, + 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.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()) + || !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) + || !valid_cursor_reference(&query.cursor_binding.scope_reference) + || !request + .selected_fields + .iter() + .all(|field| operation.projection_fields.contains(field)) + || !operation + .projection_fields + .iter() + .all(|field| compiled_field_type(entity, field).is_some()) + { + 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(()); + } + } + } + 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(()); + } + } + if let Some(order) = &query.order { + let Some(compiled_sort) = operation + .sort_fields + .iter() + .find(|candidate| candidate.field == order.field_id) + else { + return Err(()); + }; + if !compiled_sort + .directions + .contains(&CompiledQuerySortDirection::Asc) + || compiled_field_type(entity, &order.field_id) != Some(&order.field_type) + || order.direction != CompiledQuerySortDirection::Asc + { + return Err(()); + } + } + if let Some(continuation) = &query.continuation { + if !valid_canonical_uuid(&continuation.last_record_id) { + return Err(()); + } + match (&query.order, &continuation.sort_value) { + (Some(order), Some(value)) => { + if validate_field_value(value, &order.field_type).is_err() { + return Err(()); + } + } + (Some(_), None) | (None, None) => {} + (None, Some(_)) => return Err(()), + } + } + if !cursor_query_matches_request(query, request)? { + 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 + || query.cursor_binding.scope_reference != references.scope + { + return Err(()); + } + Ok(()) +} + +struct CursorBindingReferences { + principal: Option, + purpose: Option, + row_boundary: String, + projection: String, + query: String, + sort: String, + scope: 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-v3", value.as_bytes()) + }) + .transpose() + .map_err(|_| ())?; + let purpose = request + .context + .purpose() + .map(|value| { + 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-v3", + &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 projection = cursors + .binding_digest( + 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-v3", + &json!({ + "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-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 { + principal, + purpose, + row_boundary, + projection, + query: query_reference, + sort, + scope, + }) +} + +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, + }), + } +} + +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) +} + +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 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, + }) + } + + fn relationship( + source: &CompiledEntity, + through: &CompiledEntity, + target: &CompiledEntity, + path: &CompiledReadPath, + root_id: &str, + ) -> Result { + if !valid_canonical_uuid(root_id) { + return Err(ReadServiceError::Unavailable); + } + 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) + } +} + +fn source_view_field_expression( + entity: &CompiledEntity, + 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 compiled_api_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) + .filter(|field| field.logical.id == field_id) + .map(|field| field.logical.api_name.as_str()) + }) +} + +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(()); + } + } + ReadFilterOperator::In => { + if predicate.values.is_empty() { + return Err(()); + } + 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(()); + } + } + ReadFilterOperator::IsNull | ReadFilterOperator::IsNotNull => { + if predicate.values.as_slice() != ["true"] { + return Err(()); + } + } + } + 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 + .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 = 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)?; + 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(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, &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 {id} > ${record_parameter}::text::uuid))", + column = field.sql, + id = relations.id_expression + )); + } + None => predicates.push(format!( + "({column} IS NULL AND {id} > ${record_parameter}::text::uuid)", + column = field.sql, + id = relations.id_expression + )), + } + } else { + predicates.push(format!( + "{} > ${record_parameter}::text::uuid", + relations.id_expression + )); + } + } + 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 { + format!("{} ASC", relations.id_expression) + }; + let limit_parameter = values.len() + 1; + Ok(( + format!( + "SELECT {projection} + FROM {} + WHERE {where_sql} + ORDER BY {order} + LIMIT ${limit_parameter}::bigint", + 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, + 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_id) in selected_fields.iter().enumerate() { + let api_name = compiled_api_name(entity, field_id).ok_or(ReadServiceError::Unavailable)?; + if compiled_field_type(entity, field_id).is_none() { + return Err(ReadServiceError::Unavailable); + } + let value = row + .try_get::<_, Option>(index + 2) + .map_err(|_| ReadServiceError::Unavailable)? + .unwrap_or(Value::Null); + if data.insert(api_name.to_owned(), value).is_some() { + return Err(ReadServiceError::Unavailable); + } + } + 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 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 { + 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, ReadFilterExpr, ReadFilterOperator, + ReadFilterPredicate, ReadOrderClause, ReadProjectionField, RecordReadKind, + RecordReadRequest, + }; + use crate::compiler::{compile_project, CompileProfile}; + 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::{ + 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, + "grants":[{ + "entity":"case","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, + context: AuthorizedRequestContext::new(None, None, "public".to_owned(), Vec::new()), + selected_fields: BTreeSet::from(["label".to_owned()]), + kind: RecordReadKind::List { + plan: 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(), + 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, + continuation: None, + }, + }, + maximum_records: 11, + correlation: crate::correlation::RequestCorrelation::server_created(), + }; + assert!(ReadPlan::from_request(®istry, &expected, &cursors, &request).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 = 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).is_err()); + + 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).is_err()); + + let query = query_mut(&mut request); + query.continuation = None; + 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; + query.cursor_binding.scope_reference = references.scope; + assert!(ReadPlan::from_request(®istry, &expected, &cursors, &request).is_ok()); + + query_mut(&mut request).cursor_binding.query_reference = digest(); + assert!( + 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)) + } +} + +#[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..d64ac09271 --- /dev/null +++ b/crates/registry-server/src/postgres/revision_read.rs @@ -0,0 +1,800 @@ +// 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), + correlation: &request.correlation, + }, + ) + .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), + correlation: &request.correlation, + }, + ) + .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), + correlation: request.correlation.clone(), + }, + 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, + correlation: &request.correlation, + }, + ) + .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..93fd2a0587 --- /dev/null +++ b/crates/registry-server/src/postgres/roles.rs @@ -0,0 +1,252 @@ +// 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 managed schemas. +pub async fn provision_managed_schemas( + admin: &impl GenericClient, + migration_role: &SqlIdentifier, +) -> Result<()> { + for schema in [ + "registry_internal", + "registry_data", + "registry_source", + "registry_derived", + "registry_context", + ] { + 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'), + 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', + '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 + WHERE rolname = current_user", + &[&migration_role.as_str()], + ) + .await?; + let forbidden = (1..=13).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_derived", + "registry_context", + "registry_internal", + "registry_source", + ]; + 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() != managed_schemas.len() + || 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..d3bfe4ac64 --- /dev/null +++ b/crates/registry-server/src/postgres/schema.rs @@ -0,0 +1,483 @@ +// 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 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?; + } + + 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, 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(), + )) + .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?; + } + } + 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(()) +} + +/// 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; + DROP SCHEMA registry_source RESTRICT; + DROP SCHEMA registry_derived RESTRICT; + DROP SCHEMA registry_context 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'), + 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', + '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 + WHERE rolname = current_user", + &[&migration_role.as_str()], + ) + .await?; + if row.get::<_, String>(0) != runtime_role.as_str() + || (1..=13).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/query.rs b/crates/registry-server/src/query.rs new file mode 100644 index 0000000000..4d0ecd175b --- /dev/null +++ b/crates/registry-server/src/query.rs @@ -0,0 +1,1548 @@ +// 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())?; + if !valid_config_identifier(value) { + return Err(QueryParseError::InvalidValue); + } + self.access_profile = Some(value.to_owned()); + } + "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_config_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 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('='); + 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_alphabetic() + || 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 governed_lower_camel_api_names_parse_without_widening_identifier_grammar() { + let query = parse([ + ("$select", "householdCode,householdKind"), + ("$filter", "householdKind eq 'single'"), + ("$orderby", "householdCode"), + ]) + .expect("governed lower-camel API property names parse"); + let ParsedReadQueryMode::Query(options) = query.mode else { + panic!("expected query mode"); + }; + assert_eq!( + options + .select + .expect("selection exists") + .fields() + .iter() + .map(ApiIdentifier::as_str) + .collect::>(), + ["householdCode", "householdKind"] + ); + assert_eq!( + options.orderby.expect("ordering exists").field.as_str(), + "householdCode" + ); + + for value in [ + "HouseholdCode", + "householdCode/person", + "householdCode;drop", + "householdCode%", + ] { + assert_eq!( + parse_read_query([("$select", value)]), + Err(QueryParseError::InvalidValue), + "identifier grammar remains closed for {value}" + ); + } + assert_eq!( + parse([("$select", "householdCode,householdCode")]), + Err(QueryParseError::DuplicateOption) + ); + assert_eq!( + parse([("accessProfile", "caseWorker")]), + Err(QueryParseError::InvalidValue), + "config identifiers retain their lowercase-only grammar" + ); + } + + #[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!(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!(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)]), + 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() { + 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) + ); + 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); + } + assert_eq!( + parsed_canonical(&filter("childUnder5Count gt 0")), + "gt(id(16:childUnder5Count),int(1:0))" + ); + } + + #[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/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..f0c9b19bb2 --- /dev/null +++ b/crates/registry-server/src/runtime_config.rs @@ -0,0 +1,2577 @@ +// 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}; +#[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, +}; +use serde::Deserialize; +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, + 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; +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 { + #[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 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")] + 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, +} + +#[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 + } +} + +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_none_or(|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, + event_delivery: EventDeliveryConfig, + operational_timeouts: OperationalTimeouts, +} + +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)?; + 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 event_delivery = EventDeliveryConfig::from_raw(raw.event_delivery)?; + let operational_timeouts = OperationalTimeouts::from_raw(raw.operational_timeouts)?; + Ok(Self { + listener, + identity, + secret_providers, + database, + package, + authentication, + audit, + cursor, + event_destinations, + event_delivery, + 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 fn event_delivery(&self) -> &EventDeliveryConfig { + &self.event_delivery + } + + 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) + .map(|destinations| { + destinations.with_payload_retention(self.event_delivery.payload_retention) + }) + } + + 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("event_delivery", &self.event_delivery) + .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() + } +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[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() + } +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[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, +} + +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)?; + } + 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); + } + } + Ok(Self { + principal: raw.principal, + purpose: raw.purpose, + }) + } + + fn to_platform_config(&self) -> AuthorityClaimConfig { + AuthorityClaimConfig::new(self.principal.clone(), self.purpose.clone()) + } +} + +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(|_| "")) + .finish() + } +} + +#[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, +} + +#[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 { + 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) +} + +#[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, + database: RawDatabaseConfig, + package: RawPackageConfig, + authentication: RawAuthenticationConfig, + audit: RawAuditConfig, + 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 { + bind: String, + trusted_proxy: TrustedProxyPosture, +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawDeploymentIdentity { + environment: String, + instance_id: String, + database_id: String, + database_initialization_environment: String, +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawSecretProvidersConfig { + environment: Option, + 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 { + runtime_url_ref: String, + migration_url_ref: String, + 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 { + migration: String, + runtime: String, +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[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, +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawAuthenticationConfig { + oidc: RawOidcVerifierConfig, + authority_claims: RawAuthorityClaimsConfig, +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[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, + /// 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", + rename_all_fields = "camelCase", + deny_unknown_fields, + tag = "kind" +)] +enum RawOidcJwksSource { + Discovery {}, + 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 { + principal: String, + #[serde(default)] + 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, +} + +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 +} + +#[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() { + 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..b0971c93cc --- /dev/null +++ b/crates/registry-server/src/schema.rs @@ -0,0 +1,779 @@ +// 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; +#[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> { + 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() +} + +/// 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, + 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}; + #[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", + "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") + } + + #[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") + } + + 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_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(); + 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 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")) + .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(), + ); + } + + #[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 new file mode 100644 index 0000000000..fa18684877 --- /dev/null +++ b/crates/registry-server/src/startup.rs @@ -0,0 +1,1134 @@ +// 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_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_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), + 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, + mut request: Request, + next: Next, +) -> Response { + 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 { + crate::correlation::problem_response( + StatusCode::GATEWAY_TIMEOUT, + "urn:registry-server:problem:request.timeout", + "Gateway Timeout", + "The request timed out.", + "request.timeout", + ) +} + +/// 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)?; + crate::postgres::verify_postgres_15_or_newer(&transaction) + .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)?; + crate::postgres::verify_postgres_15_or_newer(&*transaction) + .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'), + 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', + '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 + 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..=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 new file mode 100644 index 0000000000..8142ab41ed --- /dev/null +++ b/crates/registry-server/src/tooling.rs @@ -0,0 +1,345 @@ +// 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::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) + } + 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..adaac69896 --- /dev/null +++ b/crates/registry-server/src/webhook.rs @@ -0,0 +1,1747 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! 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; +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::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; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum WebhookDeliveryError { + #[error("webhook delivery is unavailable")] + 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, + 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 + } + + /// 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') + OR ( + state.state = 'dead_lettered' + AND delivery.operator_replay + ) + ) + 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. + pub async fn replay( + &self, + event_id: Uuid, + compiled_delivery_id: &str, + expected_generation: i64, + ) -> Result { + 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, + 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 outbox.payload IS NOT NULL + AND outbox.payload_expires_at > transaction_timestamp() + FOR UPDATE OF state", + &[&event_id, &compiled_delivery_id], + ) + .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)?; + 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 + || state != "dead_lettered" + || self + .destinations + .lookup(&logical_destination_id) + .is_none_or(|destination| { + destination.binding_digest() != destination_binding_digest + }) + { + 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: &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, + expired_at = NULL, + updated_at = transaction_timestamp() + WHERE event_id = $1 + AND compiled_delivery_id = $2 + AND generation = $3 + AND state = '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)?; + Ok(next_generation) + } + + 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); + } + 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.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 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", + &[], + ) + .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 package_revision = + bounded_text(&row, 7, 256).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: &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, + package_revision, + })) + } + + 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, + 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() + ORDER BY state.lease_expires_at, state.event_id, state.compiled_delivery_id + FOR UPDATE OF state 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 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)?; + 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( + transaction, + &self.audit_profile, + WebhookAudit { + event_id, + compiled_delivery_id: &compiled_delivery_id, + package_revision: &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 = transaction_timestamp() + + $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 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, + ) -> 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 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( + 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 { + id: &event_id, + source: &source, + event_type: &material.event_type, + time: &event_time, + data_schema: &material.data_schema, + generation: &generation, + attempt: &attempt, + delivery_time: &delivery_time, + method: "POST", + request_target: destination.request_target(), + content_type: "application/json", + 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 { + 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(), + delivery_time: delivery_time.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, + 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 + 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() + AND outbox.payload IS NOT NULL + AND outbox.payload_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::<_, 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 = + 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)?; + 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 + .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 != claim.package_revision + || outbox_schema_fingerprint.is_empty() + || 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, + data_schema, + event_time, + }) + } + + 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: &claim.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 = transaction_timestamp() + + $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); + } + 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 + .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, + package_revision: String, +} + +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, + data_schema: String, + event_time: SystemTime, +} + +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))) +} + +#[derive(Clone, Copy)] +struct SignatureFields<'a> { + 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 webhook_signature( + key: &[u8], + fields: SignatureFields<'_>, +) -> Result { + let mut input = Vec::new(); + input.extend_from_slice(SIGNATURE_DOMAIN); + for value in [ + 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.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, + ] { + 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 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 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, + "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") + ); + 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..c185ccaa6d --- /dev/null +++ b/crates/registry-server/tests/compiler_contract.rs @@ -0,0 +1,3795 @@ +// 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, 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, + 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; +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) +} + +fn compile_json_with_assets( + source: &[u8], + assets: Vec, +) -> 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"}, + {"id":"registry-derived-key-cardinality","type":"int64","classification":"restricted"} + ] + }], + "selectorProfiles":[ + {"id":"by-local-reference","fields":["administrative-area","local-household-number"]} + ], + "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"], + "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 + }] + }] + }] + }"#; + 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"; + 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 person = &compiled.entities()["person"]; + let path_policy = compiled + .ddl() + .tables + .iter() + .find(|table| table.entity_id == "person") + .and_then(|table| table.policies.first()) + .and_then(|policy| policy.using_expression.as_deref()) + .expect("target read-path RLS policy is compiled"); + assert!(path_policy.contains(&format!("\"{}\".\"record_id\"", person.physical_table))); + 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() + ); + 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" + ); + let derived_view = &compiled.ddl().statements[first_derived_view].sql; + 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 + .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 canonical_id_row_boundary_targets_the_physical_record_id_column() { + let compiled = compile_json( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"canonical-id-boundary","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"household","route":"households","mutationMode":"mutable", + "fields":[ + {"id":"household-code","type":"string","maxLength":32,"classification":"internal"} + ] + }], + "accessProfiles":[{ + "id":"viewer","principalClaim":"sub","grants":[{ + "entity":"household","operations":["get"], + "readableFields":["household-code"], + "rowBoundaries":[{"field":"id","claim":"household_id","operator":"equals"}] + }] + }] + }"#, + ) + .expect("the canonical id is a valid governed row boundary"); + + let policy = compiled + .ddl() + .tables + .iter() + .find(|table| table.entity_id == "household") + .and_then(|table| { + table + .policies + .iter() + .find(|policy| policy.access_profile == "viewer") + }) + .expect("viewer RLS policy is compiled"); + let expression = policy + .using_expression + .as_deref() + .expect("viewer SELECT policy has a USING expression"); + assert!(expression.contains("\"record_id\" =")); + assert!( + !expression.contains("\"id\" ="), + "logical aliases must not cross into physical table policies" + ); +} + +#[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")); + + 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] +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,"grants":[{{"entity":"household","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| { + 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","grants":[{{ + "entity":"record","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_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"}, + "entities":[{ + "id":"record","route":"records","mutationMode":"create_only", + "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}] + }], + "accessProfiles":[{ + "id":"reader", + "principalClaim":"principal", + "grants":[{ + "entity":"record", + "operations":["get"], + "readableFields":["code"] + }] + }] + }"#, + ) + .expect("source parses"), + &[], + CompileProfile::Production, + ) + .expect("production compilation does not require a manifest projection"); + + 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( + 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(); + let mut assets = Vec::new(); + for lock in &mut project.modules { + let module_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/registry-server/acceptance") + .join(domain) + .join("modules") + .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"); + 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(), + 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_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") + .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}")); + 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" + ); + } + } +} + +#[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"}]}, + {"id":"hidden-target","route":"hidden-targets","mutationMode":"create_only","classification":"restricted", + "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","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"); + 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_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"} + ]}, + {"id":"secret-record","route":"secret-records","mutationMode":"create_only","classification":"restricted", + "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"]}]} + ] + }"#, + ) + .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) + .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","grants":[{ + "entity":"object","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","operations":["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","grants":[{ + "entity":"reading","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","grants":[{{"entity":"reading","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","grants":[{{ + "entity":"reading","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,"grants":[{ + "entity":"entry","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,"grants":[{ + "entity":"record","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.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,"grants":[{ + "entity":"entry","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.operations.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()); + 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) + .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 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( + br#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"logical-schema","version":"1","defaultLanguage":"en"}, + "entities":[{ + "id":"household","route":"households","mutationMode":"mutable", + "fields":[ + {"id":"household-code","type":"string","required":true,"maxLength":64,"classification":"restricted"}, + {"id":"household-kind-code","apiName":"householdKind","type":"vocabulary-code","vocabulary":"household-kind","required":true,"classification":"restricted"}, + {"id":"case-note","apiName":"caseNoteText","type":"text","maxLength":200,"classification":"restricted"} + ] + }], + "vocabularies":[{"id":"household-kind","values":["single","extended"]}] + }"#, + ) + .expect("logical field names compile"); + let artifact = compiled + .artifacts() + .get("generated/schemas/household.schema.json") + .expect("entity schema is generated"); + let schema: Value = parse_json_strict(&artifact.bytes).expect("entity schema is strict JSON"); + + assert_eq!( + schema["properties"], + json!({ + "caseNoteText": {"type": "string", "maxLength": 200}, + "householdCode": {"type": "string", "minLength": 0, "maxLength": 64}, + "householdKind": { + "type": "string", + "enum": ["single", "extended"], + "x-registry-vocabulary": "household-kind" + } + }) + ); + assert_eq!( + schema["required"], + json!(["householdCode", "householdKind"]) + ); + for internal_id in ["household-code", "household-kind-code", "case-note"] { + assert!(schema["properties"].get(internal_id).is_none()); + } +} + +#[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","grants":[{ + "entity":"entry","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", "traceparent"] + ); +} + +#[test] +fn compiler_omits_revision_routes_when_not_configured_or_revision_access_is_false() { + 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#"{{ + "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},{principal_claim}"grants":[{{ + "entity":"entry","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() + .find(|entity| entity.id == "asset-item") + .expect("asset entity exists"); + 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(), + 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, + }], + }); + + 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,"grants":[{ + "entity":"entry","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","grants":[{"entity":"object","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"} + ]}, + {"id":"case_file","route":"case_file_records","mutationMode":"create_only","fields":[ + {"id":"code","type":"string","maxLength":8,"classification":"internal"} + ]} + ], + "accessProfiles":[{"id":"reader","principalClaim":"principal","grants":[ + {"entity":"case-file","operations":["get"],"readableFields":["code"]}, + {"entity":"case_file","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, + [ + "$count", + "$filter", + "$orderby", + "$select", + "$skiptoken", + "$top", + "accessProfile", + "traceparent", + ] + ); + let as_of_parameter_names = query_parameter_names( + &openapi["paths"]["/v1/records/placements:as-of"]["get"]["parameters"], + ); + assert_eq!( + as_of_parameter_names, + [ + "$count", + "$filter", + "$orderby", + "$select", + "$skiptoken", + "$top", + "accessProfile", + "asOf", + "traceparent", + ] + ); + 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"] == "$top") + .expect("$top parameter is rendered"); + assert_eq!( + page_size["schema"], + json!({"type": "integer", "minimum": 1, "maximum": 100}) + ); + 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","grants":[{{ + "entity":"entry","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_stored_field_authoring_changes_revision_but_not_query_inventory() { + 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","grants":[{ + "entity":"entry","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","grants":[{ + "entity":"entry","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_ne!( + left.revision(), + right.revision(), + "stored field authoring order is now part of the compiled model" + ); +} + +#[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"} + ]}, + {"id":"second-record","route":"hidden-route-value","mutationMode":"create_only","fields":[ + {"id":"code","type":"string","maxLength":8,"classification":"internal"} + ]} + ], + "accessProfiles":[{"id":"reader","principalClaim":"principal","grants":[ + {"entity":"first-record","operations":["get"],"readableFields":["code"]}, + {"entity":"second-record","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,"grants":[{ + "entity":"public-entry","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","grants":[{"entity":"object","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..7fbd2c9024 --- /dev/null +++ b/crates/registry-server/tests/compiler_webhook.rs @@ -0,0 +1,576 @@ +// 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, Classification, ModuleLockSource, RegistryModule, + RegistryProject, WebhookAuthenticationProfile, WebhookDeadLetterMode, +}; +use registry_server::diagnostics::CompileFailure; +use registry_server::model::{CompiledWebhookDeliveryMode, CompiledWebhookRetryProfile}; +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"], + "when": { + "kind": "fields", + "afterEquals": {"region": "north"} + }, + "webhook": { + "destinationId": "case-operations" + } + }, { + "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") +} + +#[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.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 + ); + assert_eq!( + 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, [1000, 2000, 4000, 8000]); + assert_eq!(delivery.maximum_payload_bytes, 2288); + 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 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)); + } + + 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 (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)); + 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)); + } + + 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_is_closed_and_classification_is_derived() { + 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 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] + .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", + "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 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 = patched.clone(); + source["entities"][0]["events"][0]["when"] = when; + assert_compile_code(&source, "event.when.field_unknown"); + } + + 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 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(&structured).expect("structured predicate source serializes"), + ) + .expect_err("comparison values are scalar or null"); + 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 event_ids_are_unique_across_entities_for_unambiguous_external_types() { + let mut source = project_value(); + 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 + .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()); + + 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 { + 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 + } + }] + }] + })) + .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..3aa2a52dc8 --- /dev/null +++ b/crates/registry-server/tests/data_operations.rs @@ -0,0 +1,551 @@ +// 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", + "grants": [{ + "entity": ENTITY, + "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") }, + "grants": [{ + "entity": ENTITY, + "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, "operations": ["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","grants":[{ + "entity":ENTITY, + "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_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", + "grants": [{ + "entity": ENTITY, + "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); + 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..f96826fc76 --- /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]} +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] + 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..4cafa8d216 --- /dev/null +++ b/crates/registry-server/tests/http_auth.rs @@ -0,0 +1,752 @@ +// 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, +}; +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 RECORD_ID: &str = "00000000-0000-4000-8000-000000000001"; + +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 + grants: + - entity: case + operations: [get] + readableFields: [label] + - id: caseworker + principalClaim: registry_principal + requiredScopes: [registry.read] + requiredPurposes: [case-management-never-rendered] + grants: + - entity: case + operations: [get] + readableFields: [label, secret] + rowBoundaries: + - {field: jurisdiction, claim: jurisdictions, operator: in} + - {field: tenant, claim: tenant, operator: equals} +"#; + +const CANONICAL_ID_BOUNDARY_PROJECT: &str = r#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: canonical-id-auth-boundary + 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} +accessProfiles: + - id: caseworker + default: true + principalClaim: registry_principal + grants: + - entity: case + operations: [get] + readableFields: [label] + rowBoundaries: + - {field: id, claim: record_id, 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": RECORD_ID, + "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 lookup( + &self, + _request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(None) }) + } +} + +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/00000000-0000-4000-8000-000000000001?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 canonical_id_row_boundary_uses_the_compiled_uuid_claim_type() { + let project = parse_project_yaml(CANONICAL_ID_BOUNDARY_PROJECT.as_bytes()) + .expect("canonical-id boundary project parses"); + let registry = compile_project(&project, &[], CompileProfile::Authoring) + .expect("canonical-id boundary project compiles"); + let idp = MockIdp::start().await; + let authenticator = authenticator( + ®istry, + &idp, + AuthorityClaimConfig::new("registry_principal", None), + ) + .expect("the canonical-id boundary binds to its compiled UUID type"); + + let token = idp.mint_token(json!({ + "aud": AUDIENCE, + "registry_principal": PRINCIPAL, + "record_id": RECORD_ID, + })); + authenticator + .authenticate(&token) + .await + .expect("a canonical UUID claim is accepted"); + + let invalid_token = idp.mint_token(json!({ + "aud": AUDIENCE, + "registry_principal": PRINCIPAL, + "record_id": "invalid-uuid-claim-never-rendered", + })); + let error = authenticator + .authenticate(&invalid_token) + .await + .expect_err("a malformed canonical-id claim is refused"); + assert_eq!(error, AuthenticationError::InvalidClaims); + assert!(!format!("{error:?}").contains("invalid-uuid-claim-never-rendered")); +} + +#[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/00000000-0000-4000-8000-000000000001", + &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/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/00000000-0000-4000-8000-000000000001?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/00000000-0000-4000-8000-000000000001?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/00000000-0000-4000-8000-000000000001", + &[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())), + 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) + .expect_err("unsafe claim mapping is refused"); + assert_eq!(error, AuthenticationConfigError::InvalidClaimMapping); + } + + for claims in [ + 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"); + 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); + } +} + +#[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 + .send( + "/v1/records/cases/00000000-0000-4000-8000-000000000001?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())) +} + +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..38a2be67a2 --- /dev/null +++ b/crates/registry-server/tests/http_read_only.rs @@ -0,0 +1,2327 @@ +// 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::{header::CONTENT_TYPE, Method, Request, StatusCode}; +use registry_platform_canonical_json::parse_json_strict; +use registry_server::api::{ + router, HeldReadResponse, HttpService, ReadRuntimeIdentity, ReadServiceError, ReadinessProbe, + RecordReadKind, RecordReadRequest, RecordReadService, RevisionReadRefusal, RevisionReadRequest, + RevisionReadService, ServiceFuture, VerifiedClaimValue, VerifiedRequestClaims, +}; +use registry_server::artifacts::REGISTRY_METADATA_ARTIFACT_PATH; +use registry_server::contract::{ModuleAssetSource, Operation}; +use registry_server::cursor::CursorCodec; +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; + +#[path = "../src/query.rs"] +mod strict_query; + +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} + - 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] + grants: + - entity: case + operations: [create, get, list, patch, tombstone, batch, revisions] + allowCount: true + readableFields: [label, secret, jurisdiction] + writableFields: [label, secret, jurisdiction] + filterableFields: [label, jurisdiction] + sortableFields: [label] + rowBoundaries: + - {field: jurisdiction, claim: jurisdictions, operator: in} + - entity: protected-note + operations: [create, get, list] + readableFields: [text] + 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} + - 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] + 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 + - 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: + - {field: id, claim: household_id, operator: equals} + lookups: + - selector: by-household-code + valueOrigin: verified_claim + claimMapping: {household-code: household_code} +"#; + +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] + grants: + - entity: benefit-record + operations: [get, list] + readableFields: [label, eligibility-score] +"#; + +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} + - 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 +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] + sortableFields: [valid-from] +vocabularies: + - id: classified-status-vocabulary + values: [sealed-canary-value, retired-canary-value] +"#; + +const LOGICAL_SCHEMA_PROJECT: &str = r#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: logical-schema-surface + version: 1 + defaultLanguage: en +entities: + - id: logical-record + route: logical-records + mutationMode: mutable + classification: public + fields: + - {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 + grants: + - entity: logical-record + operations: [get, list] + readableFields: [household-code, household-kind-code] +vocabularies: + - id: household-kind + values: [single, extended] +"#; + +#[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?$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(&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.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%20approximately%20'a'", + 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 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%20in%20('area-b','area-a')", + Some(caseworker_claims("case-management")), + ) + .await; + assert_eq!(accepted.status(), StatusCode::OK); + 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!( + predicate.values, + vec!["area-a".to_owned(), "area-b".to_owned()] + ); + + let mixed = harness + .send( + Method::GET, + "/v1/records/cases?accessProfile=caseworker&$filter=secret%20eq%20'DO-NOT-LEAK'", + 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 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=householdCode", + operator_claims.clone(), + json!({ + "selector": "by-local-reference", + "values": { + "administrativeArea": "area-a", + "localHouseholdNumber": 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": {"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 + .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":{{"householdCode":"{}"}}}}"#, + "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": {"householdCode": "DO-NOT-LEAK"}}), + ) + .await; + let unknown_body = problem_shape(unknown).await; + let ungranted = harness + .send_json( + Method::POST, + "/v1/records/households:lookup?accessProfile=operator", + operator_claims.clone(), + json!({"selector": "by-private-note", "values": {"privateNote": "DO-NOT-LEAK"}}), + ) + .await; + let ungranted_body = problem_shape(ungranted).await; + assert_eq!(unknown_body, ungranted_body); + 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", + [ + ( + "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": {"householdCode": "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!(problem_shape(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=personCode&$filter=startswith(personCode,'P-')&$orderby=personCode&$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=sensitiveNote" + ), + 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("sensitiveNote")); + 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 = problem_shape(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!(problem_shape(ungranted).await, unknown_path_body); + 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!({ + "personCode": {"type": "string", "minLength": 0, "maxLength": 64}, + "sensitiveNote": {"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("householdCode") + .is_some()); + assert!(household_schema["properties"].get("personCode").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"]["eligibilityScore"], + 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=eligibilityScore", + 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); + let before = harness.records.calls(); + let response = harness + .send( + Method::GET, + "/v1/records/cases?$skiptoken=opaque-token&$select=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%20eq", 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); +} + +#[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) + } + + 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": "00000000-0000-4000-8000-000000000001", + "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; + 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": "00000000-0000-4000-8000-000000000001", + "revision": 1, + "data": { + "label": "Visible label", + "secret": "DO-NOT-LEAK", + "jurisdiction": "area-a" + } + }), + &selected_fields, + )]; + records.truncate(maximum_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, + )))) + }) + } + + 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 { + 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"), + ); + 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") + } + + 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) { + 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/00000000-0000-4000-8000-000000000001?accessProfile=caseworker", + Some(wrong_purpose), + ) + .await; + let unknown_profile = harness + .send( + Method::GET, + "/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/00000000-0000-4000-8000-000000000001?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 = 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")); + 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/00000000-0000-4000-8000-000000000001?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/00000000-0000-4000-8000-000000000001?$select=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?$select=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?$top=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/00000000-0000-4000-8000-000000000001?$select=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/00000000-0000-4000-8000-000000000001?accessProfile=caseworker&$select=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"]), + [ + "$count", + "$filter", + "$orderby", + "$select", + "$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") + .iter() + .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, "maximum": 100}) + ); + 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_eq!( + protected_openapi["paths"]["/v1/records/cases"]["get"]["security"], + json!([{"bearerAuth": []}]) + ); + assert_no_mutation_methods(&protected_openapi); + assert_eq!(harness.records.calls(), 0); +} + +#[tokio::test] +async fn discovery_schema_uses_compiled_api_names_without_widening_disclosure() { + let harness = Harness::from_project(LOGICAL_SCHEMA_PROJECT, true); + + let schema = body_json( + harness + .send(Method::GET, "/v1/schemas/logical-record", None) + .await, + ) + .await; + assert_eq!( + schema["properties"], + json!({ + "householdCode": {"type": "string", "minLength": 0, "maxLength": 64}, + "householdKind": { + "type": "string", + "enum": ["single", "extended"], + "x-registry-vocabulary": "household-kind" + } + }) + ); + assert_eq!( + schema["required"], + json!(["householdCode", "householdKind"]) + ); + + let openapi = body_json(harness.send(Method::GET, "/openapi.json", None).await).await; + assert_eq!(openapi["components"]["schemas"]["logical-record"], schema); + let rendered = openapi.to_string(); + for concealed in [ + "household-code", + "household-kind-code", + "private-canary-field", + "privateCanary", + ] { + assert!( + !rendered.contains(concealed), + "discovery leaked concealed or internal field name {concealed}" + ); + } + assert_eq!(harness.records.calls(), 0); +} + +#[tokio::test] +async fn lower_camel_select_resolves_only_compiled_authorized_api_names() { + let harness = Harness::from_project(LOGICAL_SCHEMA_PROJECT, true); + + let selected = harness + .send( + Method::GET, + "/v1/records/logical-records/00000000-0000-4000-8000-000000000001?$select=householdCode,householdKind", + None, + ) + .await; + assert_eq!(selected.status(), StatusCode::OK); + assert_eq!( + harness.records.last_request().selected_fields, + BTreeSet::from([ + "household-code".to_owned(), + "household-kind-code".to_owned(), + ]) + ); + + let before = harness.records.calls(); + 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}"); + let problem = body_json(refused).await; + assert_eq!(problem["code"], "resource.not_found"); + let rendered = problem.to_string(); + assert!(!rendered.contains("unknownCanary")); + assert!(!rendered.contains("privateCanary")); + assert!(!rendered.contains("household-code")); + } + assert_eq!(harness.records.calls(), before); + + let duplicate = harness + .send( + Method::GET, + "/v1/records/logical-records/00000000-0000-4000-8000-000000000001?$select=householdCode,householdCode", + None, + ) + .await; + assert_eq!(duplicate.status(), StatusCode::BAD_REQUEST); + let duplicate = body_json(duplicate).await; + assert_eq!(duplicate["code"], "query.invalid"); + assert!(!duplicate.to_string().contains("householdCode")); + assert_eq!(harness.records.calls(), before); +} + +#[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") + .keys() + .map(String::as_str) + .collect::>(), + BTreeSet::from(["Problem", "public-record"]) + ); + 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"] + ["classifiedStatus"], + 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"]["classifiedStatus"], + 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 = problem_shape(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!(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!(problem_shape(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&$top=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}?$top=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::Lookup => "lookup", + Operation::Create => "create", + Operation::Patch => "patch", + Operation::Tombstone => "tombstone", + Operation::Batch => "batch", + Operation::Revisions => "revisions", + } +} + +#[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); + let claims = Some(caseworker_claims("case-management")); + for (method, uri) in [ + (Method::POST, "/v1/records/cases"), + ( + 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/00000000-0000-4000-8000-000000000001/revisions", + ), + (Method::POST, "/v1/records/notes"), + ( + 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}"); + assert_eq!(body_json(response).await["code"], "resource.not_found"); + } + assert_eq!(harness.records.calls(), 0); +} + +fn caseworker_claims(purpose: &str) -> VerifiedRequestClaims { + caseworker_claims_with_direct( + purpose, + std::iter::empty::<(&'static str, VerifiedClaimValue)>(), + ) +} + +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, +{ + 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()), + 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() + .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_bytes(response: axum::response::Response) -> Vec { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response body"); + bytes.to_vec() +} + +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(); + 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..be0dc87927 --- /dev/null +++ b/crates/registry-server/tests/migration_plan.rs @@ -0,0 +1,894 @@ +// 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::generated_ddl::DdlStatementKind; +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!(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"] + ); + 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( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) + .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( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) + .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, + assets: Vec::new(), + }], + 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..2abe84872c --- /dev/null +++ b/crates/registry-server/tests/package_change_plan.rs @@ -0,0 +1,1597 @@ +// 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, 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, + 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(), 2); + 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")); + 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 [ + "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", + "entity.asset.source-view", + ] + ); +} + +#[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"],"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","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) + ); + 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_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.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] +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); +} + +#[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() { + 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": 2, + "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": 3, + "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( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) + .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, +} + +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"); + 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 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( + 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"],"webhook":{"destinationId":"package-change-events"}}]"#, + ), + 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"],"webhook":{"destinationId":"package-change-events"}}]"#, + ), + 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, + assets: Vec::new(), + }], + 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( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) + .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..3f227b6586 --- /dev/null +++ b/crates/registry-server/tests/pilot_acceptance_fixtures.rs @@ -0,0 +1,398 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; + +use registry_server::compiler::{compile_project_with_assets, CompileProfile}; +use registry_server::contract::{ + 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_root(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/registry-server/acceptance") + .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 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, assets) = fixture_sources(name); + compile_project_with_assets(&project, &modules, &assets, 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 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"); + 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..76eb787136 --- /dev/null +++ b/crates/registry-server/tests/postgres_batch.rs @@ -0,0 +1,752 @@ +// 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"} + ], + "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"], + "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"], + "grants":[{ + "entity":"widget","operations":["create","patch","batch"], + "readableFields":["label"], + "writableFields":["jurisdiction","label","secret","quantity"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] + }] + }] + }"#, + ) + .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..2d44434d3c --- /dev/null +++ b/crates/registry-server/tests/postgres_compiled_schema.rs @@ -0,0 +1,1050 @@ +// 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, 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, + 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"); + 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 + .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_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 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, + 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", + 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, + 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"} + ] + }, + { + "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":[ + { + "entity":"entry","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"} + ] + }, + { + "entity":"event","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"} + ] + }] + } + ] + }"#, + ) + .expect("compiled PostgreSQL fixture parses"); + 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","grants":[{ + "entity":"household","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 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, + ) + .expect("derived 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..c8e4576a24 --- /dev/null +++ b/crates/registry-server/tests/postgres_constraint_races.rs @@ -0,0 +1,721 @@ +// 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"}] + },{ + "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"} + ] + },{ + "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"]}] + },{ + "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"], + "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"] + }] + }] + }"#, + ) + .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::>(), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + } +} + +fn temporal_data(start: &str, end: &str) -> Map { + Map::from_iter([ + ("scope".to_owned(), Value::String("scope-a".to_owned())), + ("validFrom".to_owned(), Value::String(start.to_owned())), + ("validTo".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..e3ff150675 --- /dev/null +++ b/crates/registry-server/tests/postgres_data_export.rs @@ -0,0 +1,534 @@ +// 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}; +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"], + "grants":[{ + "entity":"entry", + "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())), + ) + .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..a31e7c5e9b --- /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!({ + "farmerCode":"F-DATA", "displayName":"Data import operator", + "administrativeBoundary":"north-district" + }), + ) + .await; + let holding_id = create( + &harness, + &north_token, + "/v1/records/holdings", + "data-holding-seed", + json!({ + "holdingCode":"H-DATA", "farmer":farmer_id, "tenureType":"owned", + "tenureStart":"2026-01-01", "administrativeBoundary":"north-district", + "importSource":"data-seed", "sourceRecordId":"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":{ + "plotCode":plot_code, "holding":holding_id, + "administrativeBoundary":"north-district", + "centroid":{"type":"Point","coordinates":[longitude,-9.5]}, + "areaValue":"1.2500", "areaUnit":"hectare", + "importSource":source, "sourceRecordId":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..650abc002c --- /dev/null +++ b/crates/registry-server/tests/postgres_fixture_journeys.rs @@ -0,0 +1,942 @@ +// 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, + assets: &[], + }]; + 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_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), 0); + assert_eq!(counts.get::<_, i64>(9), 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(), + assets: Vec::new(), + }], + 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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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 +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..14bac98e20 --- /dev/null +++ b/crates/registry-server/tests/postgres_migration.rs @@ -0,0 +1,1745 @@ +// 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 view_transition = &destructive_package.manifest().migration_plan.statements; + assert_eq!(view_transition.len(), 1); + assert!(view_transition[0] + .sql + .starts_with("CREATE VIEW registry_source.")); + assert!(!view_transition[0].sql.contains("CASCADE")); + let backup_root = tempfile::Builder::new() + .prefix("registry-backup-canary-") + .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"); + 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, + assets: Vec::new(), + }], + 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( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) + .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"); + 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, + &ExpectedManagedCatalog::compiled(candidate), + ) + .await + .expect("destructive target fingerprint computes"); + transaction + .rollback() + .await + .expect("destructive target rehearsal rolls back"); + task.abort(); + 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 {}.{} RESTRICT", + 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); + 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 asset_views = registry + .ddl() + .views + .iter() + .filter(|view| view.id.starts_with("entity.asset.")) + .collect::>(); + let mut sql = String::new(); + for schema in ["registry_derived", "registry_source"] { + for view in asset_views.iter().filter(|view| view.schema == schema) { + sql.push_str(&format!( + "DROP VIEW IF EXISTS {}.{} RESTRICT;\n", + quote(&view.schema), + quote(&view.name) + )); + } + } + sql.push_str(&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()) + )); + for view in asset_views { + let privileges = view + .runtime_privileges + .iter() + .map(|privilege| privilege.as_sql()) + .collect::>() + .join(", "); + sql.push_str(&format!( + "REVOKE ALL ON TABLE {}.{} FROM PUBLIC, {};\n", + quote(&view.schema), + quote(&view.name), + quote(runtime_role.as_str()) + )); + if !privileges.is_empty() { + sql.push_str(&format!( + "GRANT {privileges} ON TABLE {}.{} TO {};\n", + quote(&view.schema), + quote(&view.name), + 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..53be32634c --- /dev/null +++ b/crates/registry-server/tests/postgres_mutation.rs @@ -0,0 +1,2678 @@ +// 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_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, 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()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + ..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()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + }, + ) + .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()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + }, + ) + .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()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + }, + ) + .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 operator_openapi = body_json( + send( + &app, + Method::GET, + "/openapi.json?accessProfile=operator", + Some(claims.clone()), + &[], + Vec::new(), + ) + .await, + ) + .await; + assert!(operator_openapi["paths"]["/v1/records/widgets"] + .get("post") + .is_some()); + assert_eq!( + operator_openapi["paths"]["/v1/records/widgets"]["post"]["security"], + json!([{"bearerAuth": []}]) + ); + assert_eq!( + query_parameter_names( + &operator_openapi["paths"]["/v1/records/widgets"]["post"]["parameters"] + ), + ["Idempotency-Key", "accessProfile", "traceparent"] + ); + assert!( + operator_openapi["paths"]["/v1/records/widgets"]["post"]["responses"]["201"]["headers"] + .get("Location") + .is_some() + ); + assert!(operator_openapi["paths"]["/v1/records/widgets/{record_id}"] + .get("patch") + .is_some()); + assert_eq!( + query_parameter_names( + &operator_openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["parameters"] + ), + [ + "Idempotency-Key", + "If-Match", + "accessProfile", + "record_id", + "traceparent" + ] + ); + assert!( + operator_openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["requestBody"] + ["content"] + .get("application/json-patch+json") + .is_some() + ); + assert!( + operator_openapi["paths"]["/v1/records/widgets/{record_id}"]["patch"]["responses"]["428"] + ["content"]["application/problem+json"]["schema"] + .get("$ref") + .is_some() + ); + assert!(operator_openapi["paths"]["/v1/records/widgets/{record_id}"] + .get("delete") + .is_some()); + 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!(case_openapi["paths"] + .get("/v1/records/logs/{record_id}") + .and_then(|path| path.get("delete")) + .is_none()); + 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 operator_metadata = body_json( + send( + &app, + Method::GET, + "/v1/registry?accessProfile=operator", + Some(claims.clone()), + &[], + Vec::new(), + ) + .await, + ) + .await; + 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("operator entity metadata operations") + }; + assert!(operator_operations("widget") + .iter() + .any(|operation| operation["operation"] == "tombstone")); + 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!(!case_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"} + ], + "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"} + ] + },{ + "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","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"}] + }] + }] + }"#, + ) + .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()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + } +} + +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()]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + } +} + +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()); + 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, + json!({ + "type": "urn:registry-server:problem:mutation.conflict", + "title": "Conflict", + "status": 409, + "detail": "The mutation conflicts with current state.", + "code": "mutation.conflict", + "traceId": trace_id, + }) + ); + 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"); + 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(); + 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 (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"), + ] { + 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, + ] { + if table_and_column.0 != "registry_outbox" { + 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 })); +} + +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_mutation_logical_names.rs b/crates/registry-server/tests/postgres_mutation_logical_names.rs new file mode 100644 index 0000000000..c3122ca780 --- /dev/null +++ b/crates/registry-server/tests/postgres_mutation_logical_names.rs @@ -0,0 +1,175 @@ +// 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}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn real_postgres_mutations_use_only_governed_api_field_names() { + let harness = PilotHarness::start("asset-site-placement").await; + let token = harness.token("asset-management", &[]); + + let created = harness + .send_json( + Method::POST, + "/v1/records/assets", + Some(&token), + Some("logical-name-create"), + json!({"data":{ + "assetCode":"A-100", + "label":"Portable pump", + "assetClass":"equipment" + }}), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let etag = created + .headers() + .get("etag") + .expect("create response carries an ETag") + .to_str() + .expect("ETag is ASCII") + .to_owned(); + let created = response_json(created).await; + assert_eq!(created["data"]["assetCode"], "A-100"); + assert_eq!(created["data"]["assetClass"], "equipment"); + assert!(created["data"].get("asset-code").is_none()); + assert!(created["data"].get("asset-class").is_none()); + let record_id = created["id"].as_str().expect("created id is present"); + + let patched = harness + .send( + Method::PATCH, + &format!("/v1/records/assets/{record_id}"), + Some(&token), + &[ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "logical-name-patch"), + ("if-match", &etag), + ], + serde_json::to_vec(&json!([ + {"op":"test","path":"/data/assetCode","value":"A-100"}, + {"op":"replace","path":"/data/assetCode","value":"A-101"} + ])) + .expect("patch JSON serializes"), + ) + .await; + assert_eq!(patched.status(), StatusCode::OK); + let patch_etag = patched + .headers() + .get("etag") + .expect("patch response carries an ETag") + .to_str() + .expect("ETag is ASCII") + .to_owned(); + let patched = response_json(patched).await; + assert_eq!(patched["data"]["assetCode"], "A-101"); + assert!(patched["data"].get("asset-code").is_none()); + + let batch = harness + .send_json( + Method::POST, + "/v1/records/assets:batch", + Some(&token), + Some("logical-name-batch"), + json!({"items":[ + { + "operation":"patch", + "recordId":record_id, + "ifMatch":patch_etag, + "patch":[ + {"op":"test","path":"/data/assetCode","value":"A-101"}, + {"op":"replace","path":"/data/assetCode","value":"A-102"} + ] + }, + { + "operation":"create", + "data":{ + "assetCode":"A-103", + "label":"Batch pump", + "assetClass":"equipment" + } + } + ]}), + ) + .await; + assert_eq!(batch.status(), StatusCode::OK); + let batch = response_json(batch).await; + assert_eq!(batch["results"][0]["data"]["assetCode"], "A-102"); + assert_eq!(batch["results"][1]["data"]["assetCode"], "A-103"); + assert!(batch["results"][0]["data"].get("asset-code").is_none()); + + for (key, invalid_data) in [ + ( + "logical-name-internal-alias", + json!({ + "asset-code":"A-200", + "label":"Alias attempt", + "assetClass":"equipment" + }), + ), + ( + "logical-name-unknown", + json!({ + "assetCode":"A-201", + "label":"Unknown attempt", + "assetClass":"equipment", + "unreviewedValue":"must-not-leak" + }), + ), + ( + "logical-name-required", + json!({"label":"Missing code","assetClass":"equipment"}), + ), + ] { + let refused = harness + .send_json( + Method::POST, + "/v1/records/assets", + Some(&token), + Some(key), + json!({"data":invalid_data}), + ) + .await; + assert_eq!(refused.status(), StatusCode::BAD_REQUEST); + let body = response_bytes(refused).await; + let problem: Value = serde_json::from_slice(&body).expect("problem body is JSON"); + assert_eq!(problem["code"], "request.invalid"); + let rendered = String::from_utf8(body).expect("problem body is UTF-8"); + assert!(!rendered.contains("asset-code")); + assert!(!rendered.contains("unreviewedValue")); + assert!(!rendered.contains("must-not-leak")); + } + + let alias_patch = harness + .send( + Method::PATCH, + &format!("/v1/records/assets/{record_id}"), + Some(&token), + &[ + ("content-type", "application/json-patch+json"), + ("idempotency-key", "logical-name-alias-patch"), + ("if-match", "\"rs-valid-shape\""), + ], + serde_json::to_vec(&json!([ + {"op":"replace","path":"/data/asset-code","value":"must-not-leak"} + ])) + .expect("alias patch JSON serializes"), + ) + .await; + assert_eq!(alias_patch.status(), StatusCode::BAD_REQUEST); + let alias_patch_body = response_bytes(alias_patch).await; + assert!(!String::from_utf8(alias_patch_body) + .expect("problem body is UTF-8") + .contains("must-not-leak")); + + harness.finish().await; +} diff --git a/crates/registry-server/tests/postgres_package.rs b/crates/registry-server/tests/postgres_package.rs new file mode 100644 index 0000000000..696b80ae2b --- /dev/null +++ b/crates/registry-server/tests/postgres_package.rs @@ -0,0 +1,3368 @@ +// 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, module_digest_with_assets, CompileProfile, +}; +use registry_server::contract::{parse_module_yaml, parse_project_yaml, ModuleAssetSource}; +use registry_server::event_destination::EventDestinationCompatibilityInventory; +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, PackageFile, 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::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"; +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_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("path: manifest/registry-manifest.json, role: lossy-manifest-projection, required: false"), + "package-layout.yaml makes the lossy manifest projection conditional" + ); + assert!( + 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); + 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 == "manifest/dcat.jsonld" && entry.role == PackageFileRole::DcatCatalogProjection + })); + assert!(package.file_bytes().contains_key("manifest/dcat.jsonld")); + 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/dcat.jsonld", + "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 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); + 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 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 = + 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"); + 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) + .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"); + } + 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) + .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; +} + +#[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"); + let replayable_dead_letter_event = Uuid::new_v4(); + insert_upgrade_webhook_delivery( + &database, + &active, + replayable_dead_letter_event, + "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"); + reconcile_view_acl_for_fingerprint( + &transaction, + provisional_second.registry(), + database.runtime_role.as_str(), + ) + .await; + 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 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, + 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; +} + +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, + 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, + } +} + +fn canonical_sequence_for_plan(plan: PlanChoice) -> u64 { + match plan { + PlanChoice::Schema => 1, + PlanChoice::SecondTable => 2, + PlanChoice::ThirdTable => 3, + PlanChoice::WebhookSchema => 1, + PlanChoice::WebhookSecondTable => 2, + } +} + +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 project_bytes_without_manifest( + 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}"}},"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 | 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 { + "" + }; + 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 { + "" + }; + 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"]}}]{events}}}{second}{third}]}}"# + ) + .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, + 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, + assets: Vec::new(), + }], + 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 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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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 +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, +) -> ( + 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..0b39bbcf94 --- /dev/null +++ b/crates/registry-server/tests/postgres_partial_unique.rs @@ -0,0 +1,172 @@ +// 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","grants":[{ + "entity":"entry","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..fdd03e29fe --- /dev/null +++ b/crates/registry-server/tests/postgres_pilot_acceptance.rs @@ -0,0 +1,1069 @@ +// 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("assetClass") + .is_none() + ); + + let asset = create_record( + harness, + "/v1/records/assets", + &token, + "asset-create", + json!({"assetCode":"A-100","label":"Portable pump","assetClass":"equipment"}), + ) + .await; + let old_site = create_record( + harness, + "/v1/records/sites", + &token, + "site-old-create", + json!({"siteCode":"S-OLD","label":"Old depot"}), + ) + .await; + let current_site = create_record( + harness, + "/v1/records/sites", + &token, + "site-current-create", + json!({"siteCode":"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, + "validFrom":"2020-01-01", + "validTo":"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, + "validFrom":"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, + "validFrom":"2020-06-01", + "validTo":"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, + "validFrom":"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, + "observedAt":"2026-08-30T10:00:00Z", + "result":"passed" + }), + ) + .await; + assert_create_only_refuses_patch_and_tombstone( + harness, + "/v1/records/inspections", + &inspection, + &token, + ) + .await; +} + +async fn household_journey(harness: &PilotHarness) { + 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"]["residencyStatus"] + ["x-registry-vocabulary"], + "residency-status" + ); + assert_eq!( + openapi["components"]["schemas"]["person"]["properties"]["preferredLanguage"] + ["x-registry-vocabulary"], + "preferred-language" + ); + + let person = create_record( + harness, + "/v1/records/persons", + &token, + "person-create", + json!({ + "personCode":"P-100", + "legalName":"Ada North", + "familyName":"North", + "dateOfBirth":"1990-04-03", + "personSex":"female", + "residencyStatus":"usual-resident", + "preferredLanguage":"en" + }), + ) + .await; + assert_eq!(person.body["data"]["residencyStatus"], "usual-resident"); + assert_eq!(person.body["data"]["preferredLanguage"], "en"); + let old_household = create_record( + harness, + "/v1/records/households", + &token, + "household-old-create", + json!({ + "householdCode":"H-OLD", + "localHouseholdNumber":100, + "householdName":"Old household", + "administrativeArea":"north", + "householdType":"private" + }), + ) + .await; + let current_household = create_record( + harness, + "/v1/records/households", + &token, + "household-current-create", + json!({ + "householdCode":"H-CURRENT", + "localHouseholdNumber":101, + "householdName":"Current household", + "administrativeArea":"north", + "householdType":"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", + "validFrom":"2019-01-01", + "validTo":"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", + "validFrom":"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 household = harness + .send( + Method::GET, + &format!( + "/v1/records/households/{}?accessProfile=household-operator&$select=householdCode,headCount,singleHeaded,womanHeaded", + current_household.id + ), + Some(&token), + &[], + Vec::new(), + ) + .await; + assert_eq!(household.status(), StatusCode::OK); + let household = response_json(household).await; + 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=personCode,personSex&$filter=personSex%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"]["personCode"], "P-100"); + assert_eq!(people["items"][0]["data"]["personSex"], "female"); + + 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", + "validFrom":"2021-06-01", + "validTo":"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"] + ["observationSchemaMetadata"]["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!({ + "episodeCode":"EP-100", + "subjectCode":"SUBJECT-100", + "openedOn":"2026-01-10", + "assessmentSource":"case-management" + }), + ) + .await; + let invalid_range = harness + .send_json( + Method::POST, + "/v1/records/functioning-observations", + Some(&token), + Some("observation-invalid-range"), + json!({"data":{ + "assessmentEpisode":assessment.id, + "observedAt":"2026-01-11T09:00:00Z", + "functioningDomain":"mobility", + "severityScore":5, + "observationSchemaMetadata":{ + "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":{ + "assessmentEpisode":assessment.id, + "observedAt":"2026-01-11T09:00:00Z", + "functioningDomain":"mobility", + "severityScore":3, + "observationSchemaMetadata":{ + "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!({ + "assessmentEpisode":assessment.id, + "observedAt":"2026-01-11T09:00:00Z", + "functioningDomain":"mobility", + "severityScore":3, + "observationSchemaMetadata":{ + "schemaVersion":"1","vocabularyRelease":"2026-01","scoringScale":"zero-to-four" + } + }), + ) + .await; + assert_eq!(observation.body["data"]["severityScore"], 3); + + let original = create_record( + harness, + "/v1/records/certifications", + &token, + "certification-original", + json!({ + "certificationCode":"CERT-100", + "assessmentEpisode":assessment.id, + "certificationStatus":"corrected", + "validFrom":"2026-01-01", + "validTo":"2026-07-01", + "validitySource":"review-board" + }), + ) + .await; + let correction = create_record( + harness, + "/v1/records/certifications", + &token, + "certification-correction", + json!({ + "certificationCode":"CERT-101", + "assessmentEpisode":assessment.id, + "certificationStatus":"active", + "validFrom":"2026-07-01", + "correctedCertification":original.id, + "correctionReason":"reviewed correction", + "validitySource":"review-board", + "provenanceNote":"signed review packet" + }), + ) + .await; + assert_eq!( + correction.body["data"]["correctedCertification"], + original.id + ); + assert!(correction.body["data"]["correctionReason"].is_string()); + assert!(correction.body["data"]["provenanceNote"].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!({ + "farmerCode":"F-NORTH","displayName":"North operator", + "administrativeBoundary":"north-district" + }), + ) + .await; + let south_farmer = create_record( + harness, + "/v1/records/farmers", + &south_token, + "south-farmer", + json!({ + "farmerCode":"F-SOUTH","displayName":"South operator", + "administrativeBoundary":"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!({ + "holdingCode":"H-NORTH","farmer":north_farmer.id,"tenureType":"leased", + "tenureStart":"2020-01-01","tenureEnd":"2024-01-01", + "administrativeBoundary":"north-district","importSource":"survey-a", + "sourceRecordId":"holding-old" + }), + ) + .await; + let current_holding = create_record( + harness, + "/v1/records/holdings", + &north_token, + "holding-current", + json!({ + "holdingCode":"H-NORTH","farmer":north_farmer.id,"tenureType":"owned", + "tenureStart":"2024-01-01","administrativeBoundary":"north-district", + "importSource":"survey-a","sourceRecordId":"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":{ + "plotCode":"P-BAD-POINT","holding":current_holding.id, + "administrativeBoundary":"north-district", + "centroid":{"type":"Point","coordinates":[32.0,-9.5]}, + "areaValue":"1.2500","areaUnit":"hectare", + "importSource":"survey-a","sourceRecordId":"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":{ + "plotCode":"P-BAD-DECIMAL","holding":current_holding.id, + "administrativeBoundary":"north-district", + "centroid":{"type":"Point","coordinates":[30.5,-9.5]}, + "areaValue":"01.2500","areaUnit":"hectare", + "importSource":"survey-a","sourceRecordId":"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!({ + "plotCode":"P-NORTH","holding":current_holding.id, + "administrativeBoundary":"north-district", + "centroid":{"type":"Point","coordinates":[30.5,-9.5]}, + "areaValue":"1.2500","areaUnit":"hectare", + "importSource":"survey-a","sourceRecordId":"plot-primary" + }), + ) + .await; + assert_eq!(plot.body["data"]["areaValue"], "1.2500"); + assert_eq!(plot.body["data"]["areaUnit"], "hectare"); + + let old_activity = create_record( + harness, + "/v1/records/seasonal-activities", + &north_token, + "activity-old", + json!({ + "plot":plot.id,"administrativeBoundary":"north-district", + "activityType":"planting","seasonStart":"2024-01-01","seasonEnd":"2024-07-01", + "quantityValue":"12.500","quantityUnit":"kilogram" + }), + ) + .await; + let current_activity = create_record( + harness, + "/v1/records/seasonal-activities", + &north_token, + "activity-current", + json!({ + "plot":plot.id,"administrativeBoundary":"north-district", + "activityType":"planting","seasonStart":"2024-07-01", + "quantityValue":"8.250","quantityUnit":"kilogram" + }), + ) + .await; + assert_eq!(current_activity.body["data"]["quantityValue"], "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":{ + "plotCode":"P-BATCH","holding":current_holding.id, + "administrativeBoundary":"north-district", + "centroid":{"type":"Point","coordinates":[30.75,-9.25]}, + "areaValue":"2.0000","areaUnit":"hectare", + "importSource":"survey-b","sourceRecordId":"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":{ + "plotCode":"P-DUPLICATE","holding":current_holding.id, + "administrativeBoundary":"north-district", + "centroid":{"type":"Point","coordinates":[30.8,-9.2]}, + "areaValue":"3.0000","areaUnit":"hectare", + "importSource":"survey-b","sourceRecordId":"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!({ + "jurisdictionCode":"XY","registrationNumber":"10001", + "legalName":"Example Cooperative","entityStatus":"active", + "publicServiceAddress":"Public office", + "protectedContact":"protected-contact@example.test", + "protectedOwnershipReference":"ownership-10001", + "internalCaseNote":"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"]["legalName"], "Example Cooperative"); + assert!(public["data"].get("protectedContact").is_none()); + assert!(public["data"].get("protectedOwnershipReference").is_none()); + assert!(public["data"].get("internalCaseNote").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"]["protectedContact"].is_string()); + assert!(protected["data"]["protectedOwnershipReference"].is_string()); + assert!(protected["data"]["internalCaseNote"].is_string()); + + let duplicate_identifier = harness + .send_json( + Method::POST, + "/v1/records/legal-entities", + Some(&token), + Some("legal-entity-duplicate"), + json!({"data":{ + "jurisdictionCode":"XY","registrationNumber":"10001", + "legalName":"Duplicate","entityStatus":"active" + }}), + ) + .await; + assert_eq!(duplicate_identifier.status(), StatusCode::CONFLICT); + + let filing = create_record( + harness, + "/v1/records/filings", + &token, + "filing-create", + json!({ + "legalEntity":legal_entity.id,"filingNumber":"F-100", + "filingType":"incorporation","filedDate":"2020-01-01", + "sourceSystem":"registrar","sourceRecordId":"filing-100", + "provenanceNote":"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!({ + "legalEntity":legal_entity.id,"officerCode":"OFFICER-A", + "officerName":"First Director","officerRole":"director", + "effectiveFrom":"2020-01-01","effectiveTo":"2022-01-01", + "protectedOfficerId":"protected-a" + }), + ) + .await; + let current = create_record( + harness, + "/v1/records/officer-appointments", + &token, + "appointment-current", + json!({ + "legalEntity":legal_entity.id,"officerCode":"OFFICER-A", + "officerName":"First Director","officerRole":"director", + "effectiveFrom":"2022-01-01","protectedOfficerId":"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":{ + "legalEntity":legal_entity.id,"officerCode":"OFFICER-B", + "officerName":"Second Director","officerRole":"director", + "effectiveFrom":"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":{ + "legalEntity":legal_entity.id,"officerCode":"OFFICER-A", + "officerName":"First Director","officerRole":"secretary", + "effectiveFrom":"2021-01-01","effectiveTo":"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, + "compiled OpenAPI is visible to the {profile} profile" + ); + 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/provenanceNote","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..4f9f494b7a --- /dev/null +++ b/crates/registry-server/tests/postgres_read.rs @@ -0,0 +1,1344 @@ +// 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}?$select=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 governed_names = send( + &app, + "/v1/records/widgets/00000000-0000-4000-8000-000000000001?accessProfile=auditor", + Some(read_claims(["zone-a"])), + ) + .await; + assert_eq!(governed_names.status(), StatusCode::OK); + let governed_names = body_json(governed_names).await; + assert_eq!(governed_names["data"]["publicCode"], Value::Null); + assert_eq!(governed_names["data"]["label"], "label-001"); + assert_eq!(governed_names["data"]["jurisdiction"], "zone-a"); + assert!(governed_names["data"].get("internal-code").is_none()); + + let decimal = send( + &app, + &format!("/v1/records/widgets/{VISIBLE_RECORD}?$select=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?$select=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?$select=label&$filter=jurisdiction%20in%20('zone-b','zone-a')&$top=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?$select=label&$filter=startswith(label,'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?$skiptoken={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?$skiptoken={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}?$select=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}?$select=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/{}?$select=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}?$select=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?$select=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?$select=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", + 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"]["validFrom"].is_string()); + assert_eq!(current["items"][0]["data"]["validTo"], Value::Null); + assert!(current["items"][0]["data"].get("valid-from").is_none()); + assert!(current["items"][0]["data"].get("valid-to").is_none()); + assert!(!current.to_string().contains(TEMPORAL_OTHER_BOUNDARY_RECORD)); + + let sorted_first = send( + &app, + "/v1/records/widgets?$select=label,rank&$filter=startswith(label,'sort-key-')&$orderby=rank&$top=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?$skiptoken={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?$skiptoken={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?$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?$skiptoken={replay_cursor}"), + read_claims_with(PRINCIPAL_CANARY, "audit-review", ["zone-a"]), + ), + ( + format!("/v1/records/widgets?$skiptoken={replay_cursor}"), + read_claims_with("other-principal-value", "case-management", ["zone-a"]), + ), + ( + format!("/v1/records/widgets?$skiptoken={replay_cursor}"), + read_claims_with(PRINCIPAL_CANARY, "case-management", ["zone-b"]), + ), + ( + format!("/v1/records/widgets?accessProfile=auditor&$skiptoken={replay_cursor}"), + read_claims(["zone-a"]), + ), + ( + format!("/v1/records/assignments?$skiptoken={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?$skiptoken={replay_cursor}"), + Some(read_claims(["zone-a"])), + ) + .await; + + let projection_cursor = next_cursor( + &app, + "/v1/records/widgets?$select=label,amount&$filter=startswith(label,'label-')&$orderby=ordinal&$top=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?$skiptoken={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?$skiptoken={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?$select=label&$orderby=ordinal&$top=1", + Some(read_claims(["zone-a"])), + ) + .await; + assert_cursor_invalid( + &expiring_app, + &format!("/v1/records/widgets?$skiptoken={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")), + ("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"], 1); + assert_eq!(records[7]["resultCount"], 100); + assert_eq!(records[9]["resultCount"], 2); + assert_eq!(records[11]["resultCount"], 1); + assert_eq!(records[13]["resultCount"], 3); + assert_eq!(records[16]["resultCount"], 0); + assert_eq!(records[18]["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[7].get("fieldSetReference").is_some()); + assert!(records[7].get("queryReference").is_some()); + assert!(records[7].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"}, + {"id":"internal-code","apiName":"publicCode","type":"string","required":false,"maxLength":32,"classification":"internal"} + ] + },{ + "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"], + "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 new file mode 100644 index 0000000000..da2989e9db --- /dev/null +++ b/crates/registry-server/tests/postgres_revision_http.rs @@ -0,0 +1,588 @@ +// 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"], + "grants":[{ + "entity":"widget", + "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..8368f55d28 --- /dev/null +++ b/crates/registry-server/tests/postgres_startup.rs @@ -0,0 +1,1288 @@ +// 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"); + 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, + &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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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, + assets: Vec::new(), + }], + 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..ee2f9b5a01 --- /dev/null +++ b/crates/registry-server/tests/postgres_tombstone_revision.rs @@ -0,0 +1,1024 @@ +// 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, &record_id).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"}} + ]{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"], + "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"}}] + }}] + }}] + }}"# + ) + .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(), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + } +} + +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(), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + } +} + +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(), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + } +} + +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, record_id: &str) { + 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:")); + 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) == expected_payload.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..abebb6955f --- /dev/null +++ b/crates/registry-server/tests/postgres_webhook_delivery.rs @@ -0,0 +1,1806 @@ +// 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_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::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, WebhookDeliveryStatusKind, 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 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"; +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"); + 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; + 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; + 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!( + 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(1_020)).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!(!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, + &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(1_020)).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::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 before claiming 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(1_020)).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; + + 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, + &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(1_020)).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; + 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( + 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" + ); + 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, + &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(1_020)).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; +} + +#[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( + 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)), + ("restrictedNote".to_owned(), json!(RECORD_VALUE_CANARY)), + ])), + response_fields: BTreeSet::from([ + "jurisdiction".to_owned(), + "label".to_owned(), + "restricted_note".to_owned(), + ]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + }, + ) + .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, + 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 + ORDER BY outbox.outbox_id DESC + LIMIT 1", + &[], + ) + .await + .expect("administrator can inspect the newest capture identity"); + 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( + 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 outbox_payload_available(database: &TestDatabase, event: &CapturedEvent) -> bool { + database + .admin + .query_one( + "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"); + 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) { + 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", + &event.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") +} + +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, "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, "ce-time"), + OffsetDateTime::from(event.created_at) + .format(&Rfc3339) + .expect("captured event time formats") + ); + 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(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); +} + +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 [ + 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); + } + 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"} + ], + "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"}] + }] + }] + }"#, + ) + .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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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 +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} + classificationCeiling: restricted + 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..91576025bb --- /dev/null +++ b/crates/registry-server/tests/postgres_webhook_outbox.rs @@ -0,0 +1,1271 @@ +// 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_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::{ + install_mutation_schema, MutationBody, MutationCoordinator, MutationError, MutationFaultPoint, + MutationPlan, MutationRequest, PatchOperation, +}; +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 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 = + 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_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; + 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); + + 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); + drop(pool); + 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#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"webhook-outbox-registry","version":"1","defaultLanguage":"en"}, + "entities":[{ + "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"}, + {"id":"restricted_note","type":"string","maxLength":64,"required":true,"classification":"restricted"} + ], + "events":[{ + "id":"case-created","trigger":"created","projection":["label","restricted_note"], + "webhook":{ + "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"} + }] + }], + "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"}] + }] + }] + }"#, + ) + .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)), + ("restrictedNote".to_owned(), json!(RESTRICTED_CANARY)), + ])), + response_fields: BTreeSet::from([ + "jurisdiction".to_owned(), + "label".to_owned(), + "restricted_note".to_owned(), + ]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + } +} + +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(), + ]), + correlation: registry_server::correlation::RequestCorrelation::server_created(), + } +} + +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, + 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, + data_schema: 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, + created_at: SystemTime, + payload_expires_at: SystemTime, +} + +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), + 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), + } + } +} + +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.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, + 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, 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 + 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.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"); + 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); + 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(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( + 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", + "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", + "created_at", + ] + ); +} + +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( + "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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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 +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} + classificationCeiling: restricted + 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..a47dd500f4 --- /dev/null +++ b/crates/registry-server/tests/runtime_config.rs @@ -0,0 +1,2105 @@ +// 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, + parse_runtime_config_with_env, RuntimeConfigError, TrustedProxyPosture, + RUNTIME_CONFIG_API_VERSION, RUNTIME_CONFIG_KIND, +}; +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#" +apiVersion: {api_version} +kind: {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: {secret_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: {package_root} + trustAnchorPath: {trust_anchor} + 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 +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 +"#, + 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() + ) +} + +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} + classificationCeiling: restricted + 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))| { + 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", + "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"}, + {"id": "eligibility", "type": "string", "maxLength": 32, "classification": "restricted"} + ], + "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 { + 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", + delivery_time: b"2026-08-30T00:00:01Z", + 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.event_delivery().payload_retention(), + Duration::from_secs(7 * 24 * 60 * 60) + ); + 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 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(); + 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(); + 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_destination_classification_ceiling_cannot_widen_compiled_event_disclosure() { + 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) + ); + + 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] +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..be42d82f49 --- /dev/null +++ b/crates/registry-server/tests/schema_fingerprint_rehearsal.rs @@ -0,0 +1,423 @@ +// 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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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 +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", + "grants": [{{ + "entity": "case", + "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..1b97141282 --- /dev/null +++ b/crates/registry-server/tests/startup_http.rs @@ -0,0 +1,1188 @@ +// 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, OnceLock}; +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"; +static TRACING_CAPTURE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +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 + grants: + - entity: public-record + operations: [list] + readableFields: [label] +"#; + +#[derive(Default)] +struct NoopRecords { + correlations: Mutex>, +} + +impl RecordReadService for NoopRecords { + fn get( + &self, + _request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(async { Ok(None) }) + } + + fn list( + &self, + 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) + }) + } + + fn lookup( + &self, + _request: RecordReadRequest, + ) -> ServiceFuture<'_, Result, ReadServiceError>> { + Box::pin(async { Ok(None) }) + } +} + +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 _request_logs = captured_request_logs(); + 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 app = with_request_timeout_for_test(router(service), Duration::from_millis(10)); + + let response = app + .oneshot( + Request::builder() + .uri("/ready") + .header( + "traceparent", + "00-11111111111111111111111111111111-2222222222222222-01", + ) + .body(Body::empty()) + .expect("request builds"), + ) + .await + .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() { + let _request_logs = captured_request_logs(); + 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] +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 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-99999999999999999999999999999999-8888888888888888-01"; + let writer = captured_request_logs(); + 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 expected_trace_id = trace_id(INBOUND); + let matching_records = output + .lines() + .map(|line| serde_json::from_str::(line).expect("request log is JSON")) + .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"); + 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"], expected_trace_id); + 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, + 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", + } +} + +#[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, + 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 _request_logs = captured_request_logs(); + 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::default()), + 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), + ) + .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_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::(); + 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"] { + 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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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 +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..dbb7fdee57 --- /dev/null +++ b/crates/registry-server/tests/startup_ordering.rs @@ -0,0 +1,294 @@ +// 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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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, + assets: Vec::new(), + }], + 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..a3868df26e --- /dev/null +++ b/crates/registry-server/tests/support/pilot_acceptance_harness.rs @@ -0,0 +1,717 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; +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_with_assets, CompileProfile}; +use registry_server::contract::{ + parse_module_yaml, parse_project_yaml, ModuleAssetSource, 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 { + 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(); + } + 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)>, + module_assets: 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 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, + } + } +} + +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(), + 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 { + 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 path = root.join("runtime.yaml"); + fs::write( + &path, + format!( + r#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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 +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 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..e3adee6203 --- /dev/null +++ b/crates/registry-serverctl/Cargo.toml @@ -0,0 +1,37 @@ +[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"] } +uuid.workspace = true +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..0035338db7 --- /dev/null +++ b/crates/registry-serverctl/README.md @@ -0,0 +1,80 @@ +# 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 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`. +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 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 +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..48464e2bcb --- /dev/null +++ b/crates/registry-serverctl/src/apply_lifecycle.rs @@ -0,0 +1,217 @@ +// 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(RuntimeConfigError), + TargetPackagePath, + CurrentPackage(PackageError), + TargetPackage(PackageError), + EventDestinations, + 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(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 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() + .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) + .with_event_destination_compatibility_inventory(&event_destination_compatibility); + 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..9481574b04 --- /dev/null +++ b/crates/registry-serverctl/src/data_lifecycle.rs @@ -0,0 +1,1490 @@ +// 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", + "grants": [{ + "entity": ENTITY, + "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..bf808be461 --- /dev/null +++ b/crates/registry-serverctl/src/lib.rs @@ -0,0 +1,4863 @@ +// 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, BTreeSet}; +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::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, + 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_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}; + +mod apply_lifecycle; +mod data_lifecycle; +mod doctor; +mod package_inspection; +mod package_lifecycle; +mod test_lifecycle; +mod webhook_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}; +use webhook_lifecycle::{ + WebhookLifecycleError, WebhookListOutcome, WebhookReplayOutcome, WebhookSampleOutcome, +}; + +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; +const MAX_DERIVED_SQL_ASSET_BYTES: u64 = 256 * 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), + /// 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. + 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), + /// Inspect and operate configured webhook deliveries. + Webhook(WebhookArgs), +} + +#[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 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. + #[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, 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. + 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, + Queries, + 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, + WebhookSample, + WebhookOperations, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum SuggestedAction { + CorrectCommandUsage, + CorrectAuthoringSource, + ReviewAuthoringFinding, + ChooseSafeOutputDirectory, + SelectAvailableArtifact, + RetryArtifactGeneration, + RetryInventoryExplanation, + UpdateModuleLocks, + CorrectRuntimeConfiguration, + VerifyPackagePath, + VerifyPackagePermissions, + VerifyPackageTrust, + VerifyPackageBinding, + VerifyPackageIntegrity, + ReviewCompiledDiff, + CorrectPackageBuild, + ReviewSigningInput, + SupplySchemaTestReceipt, + CorrectSchemaTestCandidate, + CorrectFixtureJourneys, + SupplySchemaTestCredentials, + PrepareSchemaTestDatabase, + RecreateDisposableDatabase, + ChooseSchemaTestOutput, + SupplyExternalSignatures, + VerifyMigrationAuthority, + ReconcileFailedMigration, + VerifyStartupDependencies, + CorrectDataBinding, + CorrectDataInput, + VerifyDataCheckpoint, + VerifyDataTransport, + SelectWebhookEvent, + VerifyWebhookOperation, +} + +#[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(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 { + 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, + assets: Vec, +} + +#[derive(Debug)] +struct CapturedModuleAssetSource { + path: String, + 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::Project(args) => match args.command { + ProjectCommand::Lock(args) => project_lock(&args.project, args.check), + }, + 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), + }; + } + }, + 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 { + Ok(report) => write_success(&report, format, stdout, stderr), + Err(failure) => write_failure(&failure, format, stdout, stderr), + } +} + +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, + 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, + assets: module + .assets + .into_iter() + .map(|asset| PackageSourceFile { + path: asset.path, + bytes: asset.bytes, + }) + .collect(), + }) + .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 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", + "runtimeConfig", + "the runtime configuration path must be absolute", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + TestLifecycleError::RuntimeConfig(_) => unreachable!("handled before match"), + 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 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", + "runtimeConfig", + "the runtime configuration path must be absolute", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + ApplyLifecycleError::RuntimeConfig(_) => unreachable!("handled before match"), + 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::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", + "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 { + 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"), + "runtimeConfig", + "the runtime configuration path must be absolute", + DiagnosticArtifact::RuntimeConfiguration, + SuggestedAction::CorrectRuntimeConfiguration, + ), + RuntimePackageInspectionError::RuntimeConfig(_) => unreachable!("handled before match"), + 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 { + 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, + )], + } +} + +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 project_lock(project_path: &Path, check_only: bool) -> 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, + 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::Queries => explain_queries(&compiled), + 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::>(); + 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::>(); + 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 { + 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)?; + let assets = load_module_asset_files(project_path, &id, &module)?; + Ok(CapturedModuleSource { + id, + module, + bytes, + assets, + }) + }) + .collect::, Diagnostic>>()?; + Ok(CapturedProjectSource { + project, + project_bytes, + modules, + }) +} + +fn capture_project_source_for_lock( + 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 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, +) -> 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", + ) + })?; + // 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", + "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 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, + 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([ + ( + "registry.yaml".to_owned(), + br#"apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: + id: generic-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 + - id: label + type: string + required: true + maxLength: 200 + classification: internal + constraints: + - kind: unique + fields: [code] +accessProfiles: + - id: operator + principalClaim: registry_principal + requiredScopes: [registry:generic:operate] + requiredPurposes: [registry-operations] + grants: + - entity: record + operations: [create, get, list, patch] + readableFields: [code, label] + writableFields: [code, label] +"# + .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: generic-registry-operator + scopes: [registry:generic:operate] + 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 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, +) -> 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.starts_with("generated/manifest/"), + 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 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, 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::>() + }) + .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": filterable, + "sortable": sortable, + "allowCount": operation.allow_count, + "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, + "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, + } + }) + }) + .collect::>(); + serde_json::to_value(json!({ "operations": operations })) +} + +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": 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( + "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 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, + 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 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", + 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::*; + + use registry_server::compile_project; + + 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", + "project", + "generate", + "explain", + "diff", + "package", + "test", + "apply", + "doctor", + "verify", + "migration", + "data", + "webhook" + ] + ); + } + + #[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 + requiredPurposes: [operations] + grants: + - entity: record + operations: [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/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 new file mode 100644 index 0000000000..8250e29916 --- /dev/null +++ b/crates/registry-serverctl/tests/cli.rs @@ -0,0 +1,3414 @@ +// 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, + assets: Vec::new(), + }], + 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, + assets: Vec::new(), + }], + 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").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 bytes read"), + ) + .expect("initialized project parses"); + 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); + 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 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"; + + 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()); + 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!( + json_stdout(&refused)["diagnostics"][0]["code"], + "source.modules.invalid" + ); +} + +#[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"), + ]); + 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" + ); + assert_eq!( + json_stdout(&access)["explanation"]["entries"][0]["entityId"], + "asset-item" + ); + assert_eq!( + 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]["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!([ + "equals", + "in", + "is_null", + "is_not_null", + "prefix", + "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( + 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] +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 +"#, + ); + 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", + "dataSchema", + "dataSchemaArtifactPath", + "dataSchemaFingerprint", + "deadLetter", + "deliveryMode", + "destinationId", + "entityId", + "eventId", + "exponentialBackoffMultiplier", + "id", + "initialBackoffMs", + "maximumAttempts", + "maximumBackoffMs", + "maximumPayloadBytes", + "operatorReplay", + "projectionFields", + "retryDelaysMs", + "retryProfile", + "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", "webhook"] { + assert!(rendered + .lines() + .any(|line| line.trim_start().starts_with(available))); + } + + 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 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() +} + +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, + assets: Vec::new(), + }], + 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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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 +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 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") +} + +#[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..292029f3b0 --- /dev/null +++ b/crates/registry-serverctl/tests/diff.rs @@ -0,0 +1,751 @@ +// 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.document" + ); + assert_eq!(report["diagnostics"][0]["path"], "/"); + 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, + assets: Vec::new(), + }], + 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#"apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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 +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/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/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/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/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", diff --git a/products/registry-server/ACCEPTANCE-JOURNEYS.md b/products/registry-server/ACCEPTANCE-JOURNEYS.md new file mode 100644 index 0000000000..36e0a1e701 --- /dev/null +++ b/products/registry-server/ACCEPTANCE-JOURNEYS.md @@ -0,0 +1,34 @@ +# 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 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 +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..5ee4258e16 --- /dev/null +++ b/products/registry-server/DECISIONS.md @@ -0,0 +1,63 @@ +# 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 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. +- 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 + 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 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. +- 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..29e6a86f08 --- /dev/null +++ b/products/registry-server/DEFINITION-OF-DONE.md @@ -0,0 +1,31 @@ +# 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. 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/EVENTS-AND-WEBHOOKS.md b/products/registry-server/EVENTS-AND-WEBHOOKS.md new file mode 100644 index 0000000000..77ae375ba2 --- /dev/null +++ b/products/registry-server/EVENTS-AND-WEBHOOKS.md @@ -0,0 +1,237 @@ +# Events and webhooks + +**Status:** Version 1 implemented + +## 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 +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 + +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. + +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: + +- `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 immutable mutation capture time recorded in the committing + transaction +- `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 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 + 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. Its smoke journey + demonstrates automatic retry, dead-letter inspection, operator replay, and + eventual authenticated success 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/IMPLEMENTATION.md b/products/registry-server/IMPLEMENTATION.md new file mode 100644 index 0000000000..12af7a23f4 --- /dev/null +++ b/products/registry-server/IMPLEMENTATION.md @@ -0,0 +1,21 @@ +# 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, + 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 +of future needs. diff --git a/products/registry-server/README.md b/products/registry-server/README.md new file mode 100644 index 0000000000..b998c3e688 --- /dev/null +++ b/products/registry-server/README.md @@ -0,0 +1,229 @@ +# 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. + +## 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 +`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. + +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, +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. + +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. + +## 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. + +## 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. 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: + +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 \ + --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. + +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 +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 +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..a343edf33a --- /dev/null +++ b/products/registry-server/acceptance/asset-site-placement/registry.yaml @@ -0,0 +1,90 @@ +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} +accessProfiles: + - id: asset-operator + default: true + principalClaim: registry_principal + requiredPurposes: [asset-management] + grants: + - {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 + requiredPurposes: [site-planning] + grants: + - {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/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..2d1fc44432 --- /dev/null +++ b/products/registry-server/acceptance/business/registry.yaml @@ -0,0 +1,111 @@ +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]} + - 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 + 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] + - id: business-registrar + principalClaim: registry_principal + requiredPurposes: [business-registry] + grants: + - {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]} + - {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..2554585c2c --- /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 + requiredPurposes: [disability-assessment] + grants: + - {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/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..48d9ce417e --- /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 + requiredPurposes: [farmer-registry] + grants: + - {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]} + - {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..e52c8b303a --- /dev/null +++ b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-core/module.yaml @@ -0,0 +1,50 @@ +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} + - {id: person-sex, type: vocabulary-code, vocabulary: person-sex, required: true, 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: 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 + 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..28d92fc27b --- /dev/null +++ b/products/registry-server/acceptance/publicschema-household/modules/publicschema-household-demographics/module.yaml @@ -0,0 +1,21 @@ +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} + - 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 new file mode 100644 index 0000000000..1d9297e7c4 --- /dev/null +++ b/products/registry-server/acceptance/publicschema-household/registry.yaml @@ -0,0 +1,169 @@ +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: + 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: + 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: person-sex, concepts: [https://publicschema.org/sex]} + - 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: 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} + 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 + - 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:b13240f2ce7d17b18e8cde04289a341a6517d7952f64adaf7a5341eca4aa340b + - id: publicschema-household-demographics + version: 0.1.0 + digest: sha256:cc40de1b9ede8cb727980022f966da5ced9a26e56853ca3a5b3355ce8aff37d5 +accessProfiles: + - id: household-operator + default: true + principalClaim: registry_principal + requiredScopes: [registry:household:operate] + requiredPurposes: [household-administration] + grants: + - {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 + 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] + 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, 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] + requiredPurposes: [household-view] + grants: + - entity: household + operations: [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 new file mode 100644 index 0000000000..39a6f0aa9c --- /dev/null +++ b/products/registry-server/acceptance/publicschema-household/tests/journeys.yaml @@ -0,0 +1,277 @@ +apiVersion: registry.registrystack.org/server-journeys/v1 +journeys: + - id: household-person-lifecycle + steps: + - id: create-single-headed-head + 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: Omar Example + family-name: Example + date-of-birth: 1986-02-22 + person-sex: male + residency-status: usual-resident + preferred-language: en + expect: + outcome: success + status: 201 + fields: + person-code: PERSON-SYNTH-001 + 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: 2023-03-14 + person-sex: female + residency-status: usual-resident + preferred-language: en + 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: 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: 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: 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: 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 + request: + operation: create + data: + household-code: HOUSEHOLD-SYNTH-001 + 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 + 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 + 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: 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: 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 + 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 + 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: single-headed-head} + 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..328b0d3254 --- /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, 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 + 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: "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." + 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..6f55ff5c00 --- /dev/null +++ b/products/registry-server/contracts/artifact-inventory.yaml @@ -0,0 +1,31 @@ +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: 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} + - {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: 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} + - {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..f6452f9747 --- /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, 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}]} + - {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_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}]} + - {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: "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 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}]} + - {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 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/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..8b586ece9f --- /dev/null +++ b/products/registry-server/contracts/package-layout.yaml @@ -0,0 +1,27 @@ +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} + # 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} + +# 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..83d22d9dca --- /dev/null +++ b/products/registry-server/contracts/security-invariant-matrix.yaml @@ -0,0 +1,24 @@ +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 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}} + - {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 new file mode 100644 index 0000000000..6ac031bea9 --- /dev/null +++ b/products/registry-server/contracts/security-test-traceability.yaml @@ -0,0 +1,24 @@ +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}} + - {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/.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..a51ccb0239 --- /dev/null +++ b/products/registry-server/demo/README.md @@ -0,0 +1,110 @@ +# 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 +eight synthetic people, three households, and eight effective-dated memberships +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 + +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 all sample reads: + +```bash +products/registry-server/demo/query.sh all +``` + +Or focus on one access profile: + +```bash +products/registry-server/demo/query.sh operator +products/registry-server/demo/query.sh viewer +``` + +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. + +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. + +Use `--smoke` to run the full setup, seed and query assertions, then stop +without waiting: + +```bash +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. + +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. 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 + +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..c984b4ee81 --- /dev/null +++ b/products/registry-server/demo/query.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +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" --suite "$suite" diff --git a/products/registry-server/demo/run.sh b/products/registry-server/demo/run.sh new file mode 100755 index 0000000000..c9b4bafbb6 --- /dev/null +++ b/products/registry-server/demo/run.sh @@ -0,0 +1,387 @@ +#!/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 +webhook=false + +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 + 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="" +receiver_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 + 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 + +if [[ "$webhook" == true ]]; then + ports=$(python3 "$support" ports --count 4) + read -r database_port mint_port server_port receiver_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" +if [[ "$webhook" == true ]]; then + uv run --quiet "$mint_key_material" secret-hex \ + --out "$run_dir/secrets/webhook-key" +fi +openssl rand -hex 24 >"$run_dir/secrets/database-password" +chmod 600 "$run_dir/secrets/database-password" + +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' \ + -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 + # 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 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_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") +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" \ + --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 +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" + +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 + +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 ' 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" +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 + 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 + 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 +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..618c6d9cb3 --- /dev/null +++ b/products/registry-server/demo/support/demo.py @@ -0,0 +1,1184 @@ +#!/usr/bin/env python3 +"""Provision and exercise the disposable Registry Server household demo.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import http.server +import json +import os +import shutil +import socket +import stat +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from datetime import datetime, timezone +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" +VIEWER_CLIENT = "household-demo-viewer" +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}", +} +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_ENTITY_INSERTION = " - entity: household\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): + 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(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(count): + 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, webhook: bool) -> 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) + 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 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.replace( + WEBHOOK_ENTITY_INSERTION, + WEBHOOK_MODULE_SOURCE + WEBHOOK_ENTITY_INSERTION, + 1, + ), + 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") + + +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" + f" scopes: [{rendered_scopes}]\n" + " claims:\n" + f"{rendered_claims}" + f"keys: [{json.dumps(public_key, sort_keys=True, separators=(',', ':'))}]\n" + ) + + +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"""apiVersion: registry.registrystack.org/server-runtime/v1alpha1 +kind: RegistryServerRuntimeConfig +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}, {VIEWER_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 +{event_destinations}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, + webhook: bool = False, + receiver_port: int | None = None, +) -> 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") + + 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") + 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") + 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( + root / f"mint/clients/{OPERATOR_CLIENT}.yaml", + _mint_client( + OPERATOR_CLIENT, + operator_public, + ["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, + no_purpose_public, + ["registry:household:operate"], + {"registry_principal": "synthetic-household-operator"}, + ), + ) + _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) + dummy_revision = "sha256:" + "1" * 64 + 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( + "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-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: 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}}}} +""", + ) + + +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, webhook), + ) + + +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 _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)) + 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( + 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") + 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( + 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 = [ + {"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 = [ + {"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 = [ + {"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 + + +def seed(root: Path) -> None: + root = _require_root(root) + people, households, memberships = seed_spec() + person_ids = { + person["personCode"]: _create(root, "/v1/records/persons", person["personCode"].lower(), person) + for person in people + } + household_ids = { + household["householdCode"]: _create( + root, "/v1/records/households", household["householdCode"].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["personCode"]], + "household": household_ids[membership["householdCode"]], + "relationship": membership["relationship"], + "validFrom": membership["validFrom"], + }, + ) + _write_json(root / "seed-record-ids.json", {"people": person_ids, "households": household_ids}) + people_response, _ = _request( + root, + "GET", + "/v1/records/persons?accessProfile=household-operator&$top=20", + "operator-token", + ) + household_response, _ = _request( + root, + "GET", + "/v1/records/households?accessProfile=household-operator&$top=20", + "operator-token", + ) + membership_response, _ = _request( + root, + "GET", + "/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)] != [8, 3, 8]: + raise DemoError("seeded list counts did not match the expected 8 people, 3 households, and 8 memberships") + _request( + root, + "GET", + f"/v1/records/persons/{person_ids['PERSON-DEMO-001']}?accessProfile=household-operator", + "no-purpose-token", + expected=404, + ) + print("Seeded 8 synthetic people, 3 households, and 8 current memberships.") + + +def _bound_household(root: Path) -> tuple[str, str]: + seed_ids = _read_json_object(root / "seed-record-ids.json") + households = seed_ids.get("households") + 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") + 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("householdCode") != 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=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") + _print_query(label, response) + operator_lookup, _ = _request( + root, + "POST", + "/v1/records/households:lookup?accessProfile=household-operator", + "operator-token", + { + "selector": "by-local-reference", + "values": { + "administrativeArea": "north-demo", + "localHouseholdNumber": 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: + 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) + 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) + 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) + 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 + + +def main() -> int: + args = parser().parse_args() + try: + if args.command == "ports": + print(*reserve_ports(args.count)) + elif args.command == "prepare": + 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, 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 == "configure-viewer": + configure_viewer(args.root) + elif args.command == "query": + query(args.root, args.suite) + 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)) + 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: + 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..db8feab816 --- /dev/null +++ b/products/registry-server/demo/support/test_demo.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +import os +import re +import sys +import tempfile +import unittest +import uuid +from datetime import datetime, timezone +from pathlib import Path +from unittest import mock + + +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", "viewer"): + (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("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) + 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"], + "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) + 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) + 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_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.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 + 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_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( + 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["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["localHouseholdNumber"] for household in households], + [1001, 1002, 1003], + ) + 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["personSex"] for person in people}, + {"female", "male"}, + ) + self.assertEqual( + sum(person["residencyStatus"] == "usual-resident" for person in people), + 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": {"householdCode": "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": {"householdCode": "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") + + 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": { + "administrativeArea": "north-demo", + "localHouseholdNumber": 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() + (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/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/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..e9c0c2f47b --- /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"],"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/asset-site-placement/generated/openapi.json b/products/registry-server/generated/asset-site-placement/generated/openapi.json new file mode 100644 index 0000000000..bcbdcea12f --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/openapi.json @@ -0,0 +1 @@ +{"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/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..fab0b310b0 --- /dev/null +++ b/products/registry-server/generated/asset-site-placement/generated/postgres/schema.sql @@ -0,0 +1,67 @@ +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); +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'); +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" + 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 new file mode 100644 index 0000000000..15f500029f --- /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":{"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 new file mode 100644 index 0000000000..83651a836d --- /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"},"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 new file mode 100644 index 0000000000..983304b8b9 --- /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"},"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 new file mode 100644 index 0000000000..2e86aa638e --- /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"},"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/authoring/registry-project.schema.json b/products/registry-server/generated/authoring/registry-project.schema.json new file mode 100644 index 0000000000..dfdbe8d46b --- /dev/null +++ b/products/registry-server/generated/authoring/registry-project.schema.json @@ -0,0 +1,2519 @@ +{ + "$defs": { + "AccessGrantSource": { + "additionalProperties": false, + "properties": { + "allowCount": { + "type": "boolean" + }, + "allowDataExport": { + "default": false, + "type": "boolean" + }, + "entity": { + "type": "string" + }, + "filterableFields": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "lookups": { + "items": { + "$ref": "#/$defs/LookupGrantSource" + }, + "type": "array" + }, + "operations": { + "items": { + "$ref": "#/$defs/Operation" + }, + "type": "array", + "uniqueItems": true + }, + "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", + "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": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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" + }, + "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": { + "batch": { + "anyOf": [ + { + "$ref": "#/$defs/BatchSource" + }, + { + "type": "null" + } + ], + "default": null + }, + "classification": { + "$ref": "#/$defs/Classification", + "default": "internal" + }, + "constraints": { + "default": [], + "items": { + "$ref": "#/$defs/ConstraintSource" + }, + "type": "array" + }, + "derived": { + "items": { + "$ref": "#/$defs/DerivedSource" + }, + "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" + }, + "readPaths": { + "items": { + "$ref": "#/$defs/ReadPathSource" + }, + "type": "array" + }, + "route": { + "type": "string" + }, + "selectorProfiles": { + "items": { + "$ref": "#/$defs/SelectorProfileSource" + }, + "type": "array" + }, + "temporal": { + "anyOf": [ + { + "$ref": "#/$defs/TemporalSource" + }, + { + "type": "null" + } + ], + "default": null + }, + "tombstone": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "id", + "route", + "mutationMode" + ], + "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": { + "id": { + "type": "string" + }, + "projection": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "trigger": { + "$ref": "#/$defs/EventTrigger" + }, + "webhook": { + "anyOf": [ + { + "$ref": "#/$defs/WebhookSource" + }, + { + "type": "null" + } + ] + }, + "when": { + "anyOf": [ + { + "$ref": "#/$defs/EventConditionSource" + }, + { + "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": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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" + }, + "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": { + "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": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionTextSource" + }, + { + "type": "null" + } + ] + }, + "participantId": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "$ref": "#/$defs/ManifestProjectionPublisherSource" + }, + "standards": { + "$ref": "#/$defs/ManifestProjectionStandardsSource", + "default": {} + }, + "title": { + "$ref": "#/$defs/ManifestProjectionTextSource" + } + }, + "required": [ + "baseUrl", + "title", + "publisher" + ], + "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" + ] + }, + "owner": { + "type": [ + "string", + "null" + ] + }, + "spatialCoverage": { + "type": [ + "string", + "null" + ] + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestProjectionDatasetStatus" + }, + { + "type": "null" + } + ] + }, + "title": { + "$ref": "#/$defs/ManifestProjectionTextSource" + } + }, + "required": [ + "title" + ], + "type": "object" + }, + "ManifestProjectionDatasetStatus": { + "enum": [ + "under_development", + "active", + "completed", + "deprecated", + "withdrawn" + ], + "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": { + "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" + }, + "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": [ + "accessProfile", + "classificationCeiling", + "catalog", + "dataset" + ], + "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": { + "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", + "lookup", + "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": { + "anonymous": { + "default": false, + "type": "boolean" + }, + "default": { + "default": false, + "type": "boolean" + }, + "grants": { + "default": [], + "items": { + "$ref": "#/$defs/AccessGrantSource" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "principalClaim": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "requiredPurposes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "requiredScopes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "id" + ], + "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" + ], + "type": "string" + }, + "ReferenceFieldKindSchema": { + "enum": [ + "reference" + ], + "type": "string" + }, + "ReferenceFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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" + }, + "SelectorProfileSource": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "fields" + ], + "type": "object" + }, + "StringFieldKindSchema": { + "enum": [ + "string" + ], + "type": "string" + }, + "StringFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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": { + "apiName": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "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" + }, + "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": { + "destinationId": { + "type": "string" + } + }, + "required": [ + "destinationId" + ], + "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/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..12fae17609 --- /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"],"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 new file mode 100644 index 0000000000..76980a5622 --- /dev/null +++ b/products/registry-server/generated/publicschema-household/generated/openapi.json @@ -0,0 +1 @@ +{"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/publicschema-household/generated/postgres/schema.sql b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql new file mode 100644 index 0000000000..c55911b06b --- /dev/null +++ b/products/registry-server/generated/publicschema-household/generated/postgres/schema.sql @@ -0,0 +1,142 @@ +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) + )); +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_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'); +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."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" + 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 "__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 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' + 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 + ) AS canonical_derived + ) AS checked_derived + WHERE CASE + 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; 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..6140e5a3d2 --- /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"},"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 new file mode 100644 index 0000000000..1237f97d39 --- /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":{"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 new file mode 100644 index 0000000000..79d7ac42c8 --- /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":{"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 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 new file mode 100755 index 0000000000..bb4933a29b --- /dev/null +++ b/products/registry-server/scripts/check-contracts.sh @@ -0,0 +1,16 @@ +#!/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/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 new file mode 100755 index 0000000000..f191e7c39a --- /dev/null +++ b/products/registry-server/scripts/check-generated.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +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() { + 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-}" + +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" + 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 +) + +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 + +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" \ + "$temporary_root/$fixture_name" +done 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..cb1960986d --- /dev/null +++ b/products/registry-server/scripts/check_source_neutrality.py @@ -0,0 +1,359 @@ +#!/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 = ( + "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..9b7fbad64b --- /dev/null +++ b/products/registry-server/scripts/compare-generated-tree.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Compare frozen Registry Server generated baselines without traversing links.""" + +from __future__ import annotations + +import stat +import sys +from pathlib import Path + + +ASSET_SITE_PLACEMENT_PATHS = ( + "generated/manifest/registry-manifest.json", + "generated/manifest/dcat.jsonld", + "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", +) +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]: + 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_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) + 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: + 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(f"generated tree matches the committed {Path(arguments[0]).name} 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..000d3fb4dc --- /dev/null +++ b/products/registry-server/scripts/test-adopter-workflow.sh @@ -0,0 +1,928 @@ +#!/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 "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() { + 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":{"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" \ + --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("assetCode") == "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: 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") +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("assetCode") == "ASSET-PUBLIC-001"] +if not matching: + raise SystemExit("created record did not survive successor activation") +if any("placementReviewNote" 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..be502c13ae --- /dev/null +++ b/products/registry-server/scripts/test_check_source_neutrality.py @@ -0,0 +1,196 @@ +#!/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_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) + 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..757b2088c7 --- /dev/null +++ b/products/registry-server/scripts/test_generated_gates.py @@ -0,0 +1,156 @@ +#!/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("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) + self.assertIn("products/registry-server/generated/authoring", generated_gate) + self.assertIn("runtime_baseline", generated_gate) + self.assertIn("--features runtime,schema --example runtime-schema", generated_gate) + self.assertIn("products/registry-server/generated/runtime", 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", + "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", + "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", + "apiVersion: registry.registrystack.org/server-runtime/v1alpha1", + "kind: RegistryServerRuntimeConfig", + "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", + '"assetCode":"ASSET-PUBLIC-001"', + '"assetClass":"equipment"', + "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_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/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..56bc460283 --- /dev/null +++ b/products/registry-server/scripts/validate_product.py @@ -0,0 +1,670 @@ +#!/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", False), + ("manifest/dcat.jsonld", "dcat-catalog-projection", False), + ("source/modules//", "source-module-asset", False), + ("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", + "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: + 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, 22)}: + 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: 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", },