diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f08dcb81a..b117a5524 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,3 +7,5 @@ /product/ @auths-dev/product-maintainers @auths-dev/security /bindings/ @auths-dev/binding-maintainers @auths-dev/security /demos/ @auths-dev/product-maintainers + +/deployment/ @auths-dev/product-maintainers @auths-dev/security diff --git a/.github/ci/phase-ownership.toml b/.github/ci/phase-ownership.toml index c6d0ec7e8..7c8060a74 100644 --- a/.github/ci/phase-ownership.toml +++ b/.github/ci/phase-ownership.toml @@ -155,6 +155,14 @@ kind = "prefix" value = "qualification/" phases = ["authoritative", "compliance", "secrets"] +# Gateway deployment and packaged operator assets include the PostgreSQL +# recovery boundary and release handoff, without changing other providers. +[[rules]] +id = "gateway-deployment" +kind = "prefix" +value = "deployment/gateway/" +phases = ["authoritative", "compliance", "secrets", "postgresql_live", "release"] + [[rules]] id = "interoperability-fixtures" kind = "prefix" diff --git a/.github/workflows/gateway-operator-package.yml b/.github/workflows/gateway-operator-package.yml new file mode 100644 index 000000000..d866b672c --- /dev/null +++ b/.github/workflows/gateway-operator-package.yml @@ -0,0 +1,126 @@ +name: Gateway operator package + +on: + pull_request: + paths: + - 'deployment/gateway/**' + - 'product/runtime/auths-gateway/**' + - 'product/qualification/**' + - 'docs/operations/GATEWAY_PRODUCTION_RUNBOOK.md' + - '.github/workflows/gateway-operator-package.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: ./.github/actions/setup-rust-cache + with: + toolchain: 1.97.1 + - name: Build shipped operator binaries without test features + run: cargo build --locked --release -p auths-gateway -p auths-recipe-qualification-issuance --bin auths-gateway --bin auths-qualification + - name: Make the source-free handoff + run: | + python3 deployment/gateway/tools/package.py \ + --gateway target/release/auths-gateway \ + --qualification target/release/auths-qualification \ + --commit "$(git rev-parse HEAD)" \ + --output target/gateway-operator-package.tgz + - name: Verify the packaged payload and command surfaces + run: | + mkdir -p "$RUNNER_TEMP/operator" + tar xzf target/gateway-operator-package.tgz -C "$RUNNER_TEMP/operator" + cd "$RUNNER_TEMP/operator/auths-gateway-operator" + python3 - <<'PY' + import hashlib, json + from pathlib import Path + manifest = json.loads(Path('manifest.json').read_text()) + assert manifest['schema'] == 'auths.gateway-operator-package/1' + for item in manifest['files']: + assert hashlib.sha256(Path(item['path']).read_bytes()).hexdigest() == item['sha256'] + assert not list(Path('.').rglob('*.rs')) + assert (Path('reference') / '../docs/GATEWAY_PRODUCTION_RUNBOOK.md').is_file() + assert '../docs/GATEWAY_PRODUCTION_RUNBOOK.md' in Path('reference/README.md').read_text() + PY + bin/auths-gateway disable --help | grep -- --store-only + bin/auths-gateway rotate-prepare --help | grep -- --operator-process + bin/auths-gateway doctor --help + bin/auths-qualification --help + - name: Verify systemd unit syntax + run: | + sudo install -D "$RUNNER_TEMP/operator/auths-gateway-operator/bin/auths-gateway" /opt/auths/bin/auths-gateway + sudo install -D "$RUNNER_TEMP/operator/auths-gateway-operator/bin/run-with-postgres-url" /opt/auths/bin/run-with-postgres-url + systemd-analyze verify deployment/gateway/systemd/*.service deployment/gateway/systemd/*.timer + - name: Exercise the extracted release binary with disposable installations + env: + AUTHS_GATEWAY_OPERATOR_TEST_BINARY: ${{ runner.temp }}/operator/auths-gateway-operator/bin/auths-gateway + run: | + cargo test --locked -p auths-gateway --test operator_plane --test support_bundle + cargo test --locked -p auths-gateway --test isolated_process -- --ignored + - name: Prepare public inputs for the operator simulation + run: | + mkdir -p target/operator-simulation-kit + cp deployment/gateway/tools/operator-simulation.py target/operator-simulation-kit/ + cp bindings/fixtures/gateway/airtable/recipe.json target/operator-simulation-kit/ + cp bindings/fixtures/gateway/airtable/profile.lock.json target/operator-simulation-kit/ + cp core/fixtures/v1/denied/untrusted-root.context.cbor target/operator-simulation-kit/trusted.context.cbor + cp bindings/fixtures/gateway/custody-hostile.json target/operator-simulation-kit/ + cd target/operator-simulation-kit + sha256sum operator-simulation.py recipe.json profile.lock.json trusted.context.cbor custody-hostile.json > SHA256SUMS + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: gateway-operator-simulation-kit + path: target/operator-simulation-kit/ + if-no-files-found: error + retention-days: 30 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: gateway-operator-package + path: | + target/gateway-operator-package.tgz + target/gateway-operator-package.tgz.sha256 + if-no-files-found: error + retention-days: 30 + + operator-simulation: + name: source-free operator simulation + needs: package + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # No checkout, source build, repository import, or provider secret in this job. + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: gateway-operator-package + path: package + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: gateway-operator-simulation-kit + path: kit + - name: Verify the simulation kit + working-directory: kit + run: sha256sum -c SHA256SUMS + - name: Rehearse the documented operator commands and measure friction + env: + AUTHS_SIMULATION_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + sudo python3 kit/operator-simulation.py \ + --archive "$GITHUB_WORKSPACE/package/gateway-operator-package.tgz" \ + --inputs "$GITHUB_WORKSPACE/kit" \ + --expected-commit "$AUTHS_SIMULATION_COMMIT" \ + --report "$GITHUB_WORKSPACE/reports/operator-simulation.json" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: always() + with: + name: gateway-operator-simulation-report + path: reports/operator-simulation.json + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/postgres-lifecycle.yml b/.github/workflows/postgres-lifecycle.yml index 9cc872da3..781fe2469 100644 --- a/.github/workflows/postgres-lifecycle.yml +++ b/.github/workflows/postgres-lifecycle.yml @@ -8,6 +8,7 @@ on: - "product/stores/auths-stores/**" - "bindings/fixtures/gateway/**" - ".github/workflows/postgres-lifecycle.yml" + - "deployment/gateway/**" workflow_dispatch: permissions: @@ -40,6 +41,8 @@ jobs: run: | cargo test -p auths-gateway --lib -- --ignored postgres cargo test -p auths-gateway --features testkit-production-plaintext,testkit-production-unqualified --test operator_plane -- --ignored postgres + - name: Rehearse a physical backup and point-in-time restore + run: deployment/gateway/tools/exercise-postgres-restore.sh - name: Stop fixture if: always() run: docker compose -f product/stores/auths-stores/tests/postgres_tls/compose.yaml down -v diff --git a/bindings/fixtures/gateway/production-codes.json b/bindings/fixtures/gateway/production-codes.json index 936860779..41c40edaf 100644 --- a/bindings/fixtures/gateway/production-codes.json +++ b/bindings/fixtures/gateway/production-codes.json @@ -1,6 +1,14 @@ { "schema": "auths.gateway-production-codes/1", "codes": [ + { + "code": "gateway.admin.credential-journal-unavailable", + "owner": "operator", + "stage": "before-custody", + "status": "implemented", + "epic": 4, + "case": null + }, { "code": "gateway.attempt.replay", "owner": "engine", @@ -23,6 +31,14 @@ "id": "lease-generation-not-held" } }, + { + "code": "gateway.connection.restore-rollback", + "owner": "connection", + "stage": "before-lease", + "status": "implemented", + "epic": 4, + "case": null + }, { "code": "gateway.credential.adapter-unsupported", "owner": "credential-store", @@ -156,7 +172,47 @@ "code": "gateway.readiness.connection-disabled", "owner": "readiness", "stage": "doctor", - "status": "new", + "status": "implemented", + "epic": 4, + "case": null + }, + { + "code": "gateway.readiness.observer-unavailable", + "owner": "readiness", + "stage": "doctor", + "status": "implemented", + "epic": 4, + "case": null + }, + { + "code": "gateway.readiness.recipe-drift", + "owner": "readiness", + "stage": "doctor", + "status": "implemented", + "epic": 4, + "case": null + }, + { + "code": "gateway.readiness.store-unavailable", + "owner": "readiness", + "stage": "doctor", + "status": "implemented", + "epic": 4, + "case": null + }, + { + "code": "gateway.readiness.transport-unavailable", + "owner": "readiness", + "stage": "doctor", + "status": "implemented", + "epic": 4, + "case": null + }, + { + "code": "gateway.readiness.trust-unavailable", + "owner": "readiness", + "stage": "doctor", + "status": "implemented", "epic": 4, "case": null }, @@ -167,6 +223,30 @@ "status": "existing", "epic": null, "case": null + }, + { + "code": "gateway.support.attempts-unavailable", + "owner": "support", + "stage": "support-bundle", + "status": "implemented", + "epic": 4, + "case": null + }, + { + "code": "gateway.support.connection-unavailable", + "owner": "support", + "stage": "support-bundle", + "status": "implemented", + "epic": 4, + "case": null + }, + { + "code": "gateway.support.unavailable", + "owner": "support", + "stage": "support-bundle", + "status": "implemented", + "epic": 4, + "case": null } ] } diff --git a/bindings/python/docs/INTEGRATION_RECIPES.md b/bindings/python/docs/INTEGRATION_RECIPES.md index ad1c3bc1c..b63be8204 100644 --- a/bindings/python/docs/INTEGRATION_RECIPES.md +++ b/bindings/python/docs/INTEGRATION_RECIPES.md @@ -37,3 +37,21 @@ Identity, custody, reservation, and bounded-transport extension contracts have their own conformance suites. Passing one of those suites qualifies only the named mechanism. It does not qualify a new provider domain, domain errors, reconciliation behavior, or receipt semantics. + + +## Production diagnostics and recovery + +The operator's `auths-gateway doctor` reports `auths.gateway-readiness/1`, +with every required check and the optional observer state; any failed or +unmade check gives a nonzero exit. Keep this output on the operator plane. +The application receives `not-entered` with the gateway's exact stable code +when qualification or a restore floor prevents a lease. It needs operator +repair of the connection or signed inputs before another authorized operation +can proceed. An `unknown` write retains its operation identity and is +reconciled by read-only `reobserve`, without issuing another provider write. + +Signed observations require a configured observer. Production may be ready +with no observer; that deployment reports signed outcomes unavailable. +The [production runbook](../../../docs/operations/GATEWAY_PRODUCTION_RUNBOOK.md) +covers disable/revoke during custody outages, exact-generation collection, +rotation, qualification expiry/revocation and restore-floor recovery. diff --git a/bindings/python/tests/test_gateway_client.py b/bindings/python/tests/test_gateway_client.py index 69dd3efb8..487e46961 100644 --- a/bindings/python/tests/test_gateway_client.py +++ b/bindings/python/tests/test_gateway_client.py @@ -21,6 +21,7 @@ GatewayClient, GatewayEndpoint, GatewayObservationRefused, + GatewayNotEntered, GatewayObserved, GatewayObservedByProvider, GatewayPreEntryObservations, @@ -105,6 +106,25 @@ def test_client_rejects_invalid_endpoint_and_oversized_input(socket_dir) -> None asyncio.run(client.submit(proof=b"", action=b"action")) +@pytest.mark.parametrize("code", [ + "gateway.qualification.missing", + "gateway.qualification.expired", + "gateway.qualification.revoked", + "gateway.qualification.digest-mismatch", + "gateway.qualification.target-mismatch", + "gateway.qualification.unavailable", + "gateway.qualification.revocation-stale", + "gateway.qualification.clock-untrusted", + "gateway.connection.restore-rollback", +]) +def test_operator_gate_refusals_remain_not_entered_with_the_native_code(code) -> None: + from auths.gateway import _parse_result + + result = _parse_result(json.dumps({"outcome": "not-entered", "code": code}).encode()) + assert result == GatewayNotEntered(code) + assert result.outcome == "not-entered" + + def test_result_parser_does_not_infer_effect() -> None: from auths.gateway import _parse_result diff --git a/bindings/typescript/README.md b/bindings/typescript/README.md index 73f414819..9ced86a87 100644 --- a/bindings/typescript/README.md +++ b/bindings/typescript/README.md @@ -50,6 +50,19 @@ The [Stripe refund example](../../examples/stripe-refund-approval/README.md) runs the whole journey: a grant with limits, a 2-of-3 approval quorum, gateway submission, and an offline audit. +Keep the exact `code` on a `not-entered` result. Qualification refusals and +`gateway.connection.restore-rollback` mean this submission stopped before +provider entry; they do not establish the outcome of any earlier attempt. +An `unknown` result means the write may have happened. Retain its operation +identifier and ask the operator to reconcile it with `reobserve`; do not +resubmit it as a new operation. Signed observations require a separately +configured observer. An absent observer does not establish a signed outcome. + +The operator's `doctor` report lists each required production check and +returns nonzero if any check fails. Use the +[production operations runbook](../../docs/operations/GATEWAY_PRODUCTION_RUNBOOK.md) +for diagnosis, emergency stop, rotation and recovery. + ## Author the proof `@auths-dev/sdk/self-hosted` generates an exact MCP-shaped tool from a diff --git a/bindings/typescript/test/unit/gateway-client.test.js b/bindings/typescript/test/unit/gateway-client.test.js index b93430530..fd3482028 100644 --- a/bindings/typescript/test/unit/gateway-client.test.js +++ b/bindings/typescript/test/unit/gateway-client.test.js @@ -296,3 +296,16 @@ test("gateway client refuses unbounded observation requests before connecting", await assert.rejects(client.observePreEntry(operation), TypeError); } }); + + +test("operator gate refusals remain not-entered with the native code", { skip: process.platform === "win32" }, async () => { + for (const code of [ + "gateway.qualification.missing", "gateway.qualification.expired", + "gateway.qualification.revoked", "gateway.qualification.digest-mismatch", + "gateway.qualification.target-mismatch", "gateway.qualification.unavailable", + "gateway.qualification.revocation-stale", "gateway.qualification.clock-untrusted", + "gateway.connection.restore-rollback", + ]) { + assert.deepEqual(await replyOnce(JSON.stringify({ outcome: "not-entered", code })), { outcome: "not-entered", code }); + } +}); diff --git a/compliance.toml b/compliance.toml index d6ab9198b..c6581ec06 100644 --- a/compliance.toml +++ b/compliance.toml @@ -198,8 +198,8 @@ principal_families = [] signature_families = [] profiles = [] transports = [] -configuration_inputs = ["deployment-namespace"] -security_state = ["credential-generation", "credential-reference", "exact-secret-version"] +configuration_inputs = ["deployment-namespace", "runtime-workload-identity", "operator-workload-identity"] +security_state = ["credential-generation", "credential-reference", "exact-secret-version", "separate-runtime-reader-and-operator-writer"] [packages.auths-credentials-aws-secrets-manager.claims] independent-semantic-implementation = [ @@ -207,6 +207,7 @@ independent-semantic-implementation = [ "product/integrations/auths-credentials-aws-secrets-manager/src/names.rs#frozen_version_references_are_classified", "product/integrations/auths-credentials-aws-secrets-manager/src/store.rs#hostile_answers_never_reach_the_caller", "product/integrations/auths-credentials-aws-secrets-manager/src/store.rs#rotation_keeps_the_old_generation_until_it_is_revoked", + "product/integrations/auths-credentials-aws-secrets-manager/src/api.rs#operator_administration_uses_separate_permissions_without_fallback", "product/integrations/auths-credentials-aws-secrets-manager/src/sigv4.rs#the_published_plain_get_vector_is_reproduced", ] @@ -1578,7 +1579,7 @@ kind = "cargo" layer = "product" path = "product/runtime/auths-gateway" core_apis = ["auths-author", "auths-codec", "auths-did-keri", "auths-did-key", "auths-model", "auths-multikey", "auths-ports", "auths-raw-key", "auths-raw-key-core", "auths-registries", "auths-signature", "auths-verifier"] -protocol_versions = ["auths.gateway-installation/5", "auths.recipe-qualification/1", "auths.recipe-qualification-attestation/1", "auths.qualification-trust-root/1", "auths.qualification-signer-certificate/1", "auths.qualification-revocation-list/1", "auths.qualification-release-index/1", "auths.gateway-semantic-closure/1", "auths.qualification-verification-vectors/1", "auths.gateway-operator-attestation/1", "auths.gateway-admin-request/1", "auths.gateway-admin-response/1", "auths.gateway-connection/1", "auths.provider-connection/2", "auths.gateway-key-identity/1", "auths.lifecycle.transactional-store/4", "auths.gateway-recipe-source/2", "auths.gateway-compiled-recipe/2", "auths.gateway-recipe-review/2", "auths.gateway-recovery-capability/1", "auths.gateway-connection-descriptor/1", "auths.gateway-attempt/3", "auths.gateway-pre-entry/1", "auths.lifecycle.postgresql/5", "auths.gateway-echo/1", "auths.gateway-idempotency-key/1", "auths.gateway-observe/2", "auths.gateway-outcome/2", "auths.gateway-readback/1", "auths.self-hosted-profile-lock/1", "mcp-arguments-v1", "auths.gateway-audit-bundle/2", "auths.gateway-audit-report/3", "auths.gateway-counter-set/1", "auths.gateway-echo-verification/1", "auths.gateway-codes/1", "auths.gateway-production-codes/1", "auths.gateway-custody-vectors/1", "auths.gateway-outcome-vectors/1", "bounded-policy-commitment-v1", "auths.approval-response/1", "auths.gateway-bounded-count/2", "auths.gateway-bounded-sum/1", "auths.gateway.argument-ceiling-window-count/2", "auths.gateway.argument-ceiling-policy/2"] +protocol_versions = ["auths.gateway-credential-journal/1", "auths.gateway-credential-collection/1", "auths.gateway-readiness/1", "auths.gateway-support-bundle/1", "auths.gateway-generation-floor/1", "auths.gateway-emergency-stop/1", "auths.gateway-installation/5", "auths.recipe-qualification/1", "auths.recipe-qualification-attestation/1", "auths.qualification-trust-root/1", "auths.qualification-signer-certificate/1", "auths.qualification-revocation-list/1", "auths.qualification-release-index/1", "auths.gateway-semantic-closure/1", "auths.qualification-verification-vectors/1", "auths.gateway-operator-attestation/1", "auths.gateway-admin-request/1", "auths.gateway-admin-response/1", "auths.gateway-connection/1", "auths.provider-connection/2", "auths.gateway-key-identity/1", "auths.lifecycle.transactional-store/4", "auths.gateway-recipe-source/2", "auths.gateway-compiled-recipe/2", "auths.gateway-recipe-review/2", "auths.gateway-recovery-capability/1", "auths.gateway-connection-descriptor/1", "auths.gateway-attempt/3", "auths.gateway-pre-entry/1", "auths.lifecycle.postgresql/5", "auths.gateway-echo/1", "auths.gateway-idempotency-key/1", "auths.gateway-observe/2", "auths.gateway-outcome/2", "auths.gateway-readback/1", "auths.self-hosted-profile-lock/1", "mcp-arguments-v1", "auths.gateway-audit-bundle/2", "auths.gateway-audit-report/3", "auths.gateway-counter-set/1", "auths.gateway-echo-verification/1", "auths.gateway-codes/1", "auths.gateway-production-codes/1", "auths.gateway-custody-vectors/1", "auths.gateway-outcome-vectors/1", "bounded-policy-commitment-v1", "auths.approval-response/1", "auths.gateway-bounded-count/2", "auths.gateway-bounded-sum/1", "auths.gateway.argument-ceiling-window-count/2", "auths.gateway.argument-ceiling-policy/2"] wire_objects = ["CompiledRecipe", "ClosedProviderRequest", "ClosedCredentialReads", "RecipeReview", "RecoveryCapability", "GatewayAttemptSnapshot", "GatewayPreEntry", "GatewayRecordEntry", "GatewayConnectionDescriptor", "ConnectionRecord", "OperatorAttestation", "OperatorStatement", "GatewayAdminStatus", "GatewayEvidenceSummary", "GatewayObserveRequest", "GatewayObserveResult", "GatewayProviderEvidence", "GatewaySubmitResult", "SignedObservation", "ArgumentCeilingPolicy", "BoundedPolicyCommitment", "AuditReport", "ProviderResult", "AuditedPreEntry", "EchoVerification", "AdminRequestCommand", "ListedValues"] fixture_suites = ["bindings/fixtures/approval", "bindings/fixtures/gateway", "bindings/fixtures/qualification"] principal_families = ["raw-key-v1", "did-key-v1", "did-keri-v1"] @@ -1586,9 +1587,19 @@ signature_families = ["ed25519-v1", "p256-sha256-v1"] profiles = ["auths.mcp/2"] transports = ["bounded-https", "postgresql-tls", "loopback-http-development"] configuration_inputs = ["operator-approved-recipe", "profile-lock", "independently-provisioned-trust", "connection-binding", "observer-anchor", "observer-signing-key", "deployment-kind", "operator-attestation", "app-capacity", "development-shared-file-store", "postgresql-store-configuration", "audit-trust-and-observer-pins", "qualification-policy", "recipe-family", "provider-contract-id", "qualification-release-inputs", "deployment-clock"] -security_state = ["recipe-digest", "logical-operation-claims", "credential-reference-generation", "shared-connection-record", "credential-generation", "in-flight-entry-count", "echo-bound-provider-evidence", "observer-signing-seed", "custody-held-observer-key", "principal-separation", "key-identity-separation", "pre-entry-evidence", "relative-ceiling-basis", "gateway-record-batches", "link-subject-count-and-sum-slots", "verified-qualification-index", "remembered-revocations", "accepted-revocation-sequence", "gateway-semantic-closure"] +security_state = ["durable-credential-cleanup-notes", "host-generation-floor", "recipe-digest", "logical-operation-claims", "credential-reference-generation", "shared-connection-record", "credential-generation", "in-flight-entry-count", "echo-bound-provider-evidence", "observer-signing-seed", "custody-held-observer-key", "principal-separation", "key-identity-separation", "pre-entry-evidence", "relative-ceiling-basis", "gateway-record-batches", "link-subject-count-and-sum-slots", "verified-qualification-index", "remembered-revocations", "accepted-revocation-sequence", "gateway-semantic-closure"] [packages.auths-gateway.claims] +operational-diagnostics = [ + "product/runtime/auths-gateway/src/qualification.rs#synchronization_marker_requires_a_recent_nonfuture_sample", + "product/runtime/auths-gateway/src/qualification.rs#synchronization_marker_refuses_links_and_untrusted_write_permissions", + "product/runtime/auths-gateway/src/credential_journal.rs#notes_survive_restart_and_refuse_missing_corrupt_full_or_foreign_state", + "product/runtime/auths-gateway/src/engine.rs#durable_cleanup_retires_abandoned_generations_and_keeps_active_and_future_secrets", + "product/runtime/auths-gateway/src/readiness.rs#the_projection_names_every_check_and_retains_precise_qualification_failure", + "product/runtime/auths-gateway/src/engine.rs#readiness_and_emergency_stop_make_no_credential_lease", + "product/runtime/auths-gateway/src/engine.rs#a_restored_store_below_the_host_floor_cannot_lease_after_restart", + "product/runtime/auths-gateway/tests/support_bundle.rs#a_support_bundle_holds_no_planted_canary", +] configuration-compiler = [ "product/runtime/auths-gateway/src/recipe/tests.rs#three_independent_recipes_compile_without_provider_code", "product/runtime/auths-gateway/src/recipe/tests.rs#hostile_recipe_corpus_fails_with_exact_codes", diff --git a/deployment/gateway/README.md b/deployment/gateway/README.md new file mode 100644 index 000000000..501eff307 --- /dev/null +++ b/deployment/gateway/README.md @@ -0,0 +1,122 @@ +# Production reference: two Ubuntu hosts, systemd, PostgreSQL TLS, AWS workload identity + +This reference runs one connection per gateway process on two Ubuntu hosts, +with a shared TLS PostgreSQL lifecycle store and distinct local installation +and monotonic-floor directories. The application talks through a Unix socket; +provider and secret-manager traffic uses bounded HTTPS. There is no external +HTTP gateway listener to terminate. Install exact release binaries and package +checksums under `/opt/auths/bin`; neither source nor a compiler belongs on the +hosts. Current production builds refuse writes until the qualification root +ceremony and both live qualification gates have completed. + +The `Gateway operator package` workflow supplies a source-free archive with +both binaries, these units, a bounded file-digest manifest and the runbook. +Verify the artifact checksum and each payload digest before using it in the +independent trial. A PR artifact is a trial input, not a signed release. + +## Host setup + +Create group `auths-app-socket` (GID 62000), user `auths-gateway` (UID 62001), +and application user (UID 62002). The gateway's primary group is +`auths-app-socket`. Create `/var/lib/auths/gateway`, owned by UID 62001, mode +0700, and `/run/auths-app`, owned by UID 62001 and GID 62000, mode 0750. +Recreate the latter with a systemd tmpfiles rule at each boot. The application's +only group access is the 0660 app socket; it cannot reach the gateway state, +admin socket, workload token, operator identity or PostgreSQL client identity. +Keep operator credentials in `/run/auths-identity/operator` and runtime +credentials in `/run/auths-identity/runtime`, each mode 0700. Provision +short-lived, audience-bound web identity tokens through the deployment's +identity issuer. The runtime role has GetSecretValue and the one encryption +key's Decrypt; the operator role has CreateSecret, DeleteSecret +and GenerateDataKey, with no secret-read permission. Neither role can list secrets or alter versions. The +operator executes administration as UID 62001 with its separate token path. +Its process uses the explicit runtime-role/token settings in `operator.conf.example` +for read confirmation, and the operator identity for creates and deletes. +Missing identities refuse the operation; neither falls back to the other. +The application receives neither identity directory nor runtime environment. + +Copy `systemd/`, `run-with-postgres-url` and the reviewed public runtime configuration. +The protected `/etc/auths/postgres-url` file holds the PostgreSQL connection +string; systemd delivers it in its service credential directory. The wrapper +passes it only to the gateway process, whose doctor probe clears its environment. Never place a +provider token, static AWS access key or release signer in an environment file. +Use a dedicated PostgreSQL password delivered through systemd LoadCredential, +not an application environment or command argument. The maintained TLS client +authenticates the server with the expected name and reviewed CA; it does not +provide TLS client-certificate authentication. +Enable `synchronous_commit`, durable WAL and continuous archive to independent +storage. Let the shipped store apply its current schema; obsolete schemas are +refused rather than translated. Take and restore a database snapshot in the +preproduction exercise before admitting traffic. + +Install the reviewed recipe, profile lock, trusted context, distinct operator +attestation and production credential store using `auths-gateway install`. +Pipe the disposable credential from the operator's secret source into stdin; +never put it in argv or a shell literal. The first install uses the write-only +operator role. A second host's `install --join` confirms the existing secret +under the read-only runtime role; it creates no secret. Copy the signed qualification inputs +with `qualification-import`; run `doctor` as root, then use `enable` only +after all other required checks pass. While disabled, the connection check +correctly fails: keep app ingress isolated, enable, rerun doctor, and only +admit app traffic after the full report passes. `doctor` prints `auths.gateway-readiness/1` and +returns nonzero on any failed check. Development installations always fail +production readiness. Follow the [operations runbook](../../docs/operations/GATEWAY_PRODUCTION_RUNBOOK.md). + +## Network and privilege policy + +Apply host firewall rules by UID/cgroup before starting the gateway. Permit +the application only its own explicit business endpoints and Unix socket; +deny it provider and AWS credential endpoints. Permit the gateway only DNS +to the host resolver, TCP 443 to the recipe's reviewed provider IP set and +regional Secrets Manager and STS IP sets, and TLS PostgreSQL to its fixed +address/port. Resolve and review endpoint changes before updating the allowlist; +a changed address is an outage until reviewed. Deny metadata/container +credential endpoints because this reference uses web identity. Deny all other +egress. No broad HTTPS proxy or arbitrary destination is permitted. Check the +application UID's denied egress from the host firewall separately: doctor +checks process/socket isolation and pinned transport construction, not an +unconfigured external firewall. A successful readiness report is not proof +that an application lacks an independently obtained provider token. + +Install `timesyncd/50-auths-gateway.conf` as +`/etc/systemd/timesyncd.conf.d/50-auths-gateway.conf`, configure the reviewed +time sources and restart systemd-timesyncd. The drop-in caps polling at five +minutes. Its fixed synchronization marker is the clock trust input; samples +older than fifteen minutes, future-dated samples, missing/nonregular markers +and group/world-writable markers disable required recipes. This bounds the +trust retained after synchronization stops; it does not establish that an +untrusted time source is correct. Permit the synchronization service's separate +UID only the reviewed time endpoints, and alert before the sample-age limit. +The optional observer is absent in this +reference, explicitly reported as unavailable for signed outcomes. To add one, +provision the reviewed distinct custody-backed signing identity and restrict +its endpoint; never use a software observer in production. + +## Health, limits and alerts + +The unit bounds memory, CPU, processes, descriptors and shutdown time. The +process is live while its independently limited app/admin listeners answer; +provider, database and credential outages must not cause a liveness restart +loop. Use status/support-bundle for bounded attempt stage counts and in-flight +counts; export numeric metrics from these reports, never labels derived from +provider bodies or account/resource names. Alert on failed doctor checks, +qualification expiry/revocation-list next_update within 24 hours, untrusted +clock, unknown/attempting backlog, missing slot sweep, incomplete shutdown, +and credential deletion failure. Preserve the last report and stable codes. +A SIGTERM stops listeners, waits up to 25 seconds for admitted sessions, removes +socket paths, and reports incomplete drainage rather than success on timeout. + +## Evidence and human trial + +The PostgreSQL workflow also runs `tools/exercise-postgres-restore.sh`: it +takes a physical backup, archives WAL, restores to a named point, confirms +pre-target records survive and post-target records do not. This is a disposable +database rehearsal and explicitly makes no replay-continuity claim. + +Hosted PostgreSQL and isolation workflows exercise the packaged command's +store, multi-host admin, actual application UID denial and restore-floor +refusal. They use disposable custody and do not establish production workload +identity, firewall or production point-in-time restore operation. Record those live +reference exercises before declaring Epic 4 complete. Use the trial protocol +in the runbook with a person unfamiliar with the implementation; an automated +or simulated onboarding run cannot close that gate. diff --git a/deployment/gateway/evidence/operator-simulation-2026-10-06.json b/deployment/gateway/evidence/operator-simulation-2026-10-06.json new file mode 100644 index 000000000..ffc77fb99 --- /dev/null +++ b/deployment/gateway/evidence/operator-simulation-2026-10-06.json @@ -0,0 +1,353 @@ +{ + "archive_sha256": "70b585794b4883a9f85897446bf13ccc334d711570b0dee3148413788f577cd4", + "command_count": 47, + "elapsed_seconds": 5.848, + "friction": [ + { + "code": "operator.platform-linux-only", + "resolution": "rehearse on Ubuntu CI" + }, + { + "code": "operator.development-custody-snapshot", + "resolution": "drain development hosts before separate-process file rotation; restart both afterward" + }, + { + "code": "operator.production-inputs-unprovisioned", + "resolution": "owner supplies offline public root and protected live inputs" + } + ], + "independent_human_trial": false, + "not_exercised": [ + "live AWS workload identity", + "live provider writes and unknown recovery", + "production qualification import", + "production PostgreSQL PITR", + "upgrade to a distinct attested candidate" + ], + "participant": "agent-simulation", + "passed": true, + "production_qualification": false, + "schema": "auths.operator-simulation/1", + "source_commit": "f1f92c3fb2d3d6f30f86547e69fa3958a9426a8a", + "steps": [ + { + "command": "review", + "exit_code": 0, + "passed": true, + "seconds": 0.114, + "step": "review-recipe" + }, + { + "command": "install", + "exit_code": 0, + "passed": true, + "seconds": 0.138, + "step": "install" + }, + { + "command": "install", + "exit_code": 0, + "passed": true, + "seconds": 0.142, + "step": "join-second-host" + }, + { + "command": "support-bundle", + "exit_code": 0, + "passed": true, + "seconds": 0.234, + "step": "offline-support" + }, + { + "code": "gateway.credential.production-plaintext-refused", + "command": "install", + "exit_code": 1, + "passed": true, + "seconds": 0.076, + "step": "refuse-production-plaintext" + }, + { + "command": "serve", + "passed": true, + "seconds": 0.17, + "step": "start-first" + }, + { + "command": "serve", + "passed": true, + "seconds": 0.17, + "step": "start-second" + }, + { + "command": "status", + "exit_code": 0, + "passed": true, + "seconds": 0.105, + "step": "status-first" + }, + { + "command": "status", + "exit_code": 0, + "passed": true, + "seconds": 0.102, + "step": "status-second" + }, + { + "code": "gateway.doctor.not-ready", + "command": "doctor", + "exit_code": 1, + "passed": true, + "seconds": 0.379, + "step": "diagnose-development-first" + }, + { + "command": "disable", + "exit_code": 0, + "passed": true, + "seconds": 0.099, + "step": "disable-first" + }, + { + "command": "status", + "exit_code": 0, + "passed": true, + "seconds": 0.093, + "step": "status-second" + }, + { + "command": "enable", + "exit_code": 0, + "passed": true, + "seconds": 0.096, + "step": "enable-second" + }, + { + "command": "status", + "exit_code": 0, + "passed": true, + "seconds": 0.093, + "step": "status-first" + }, + { + "command": "SIGTERM", + "passed": true, + "seconds": 0.021, + "step": "drain-first" + }, + { + "command": "SIGTERM", + "passed": true, + "seconds": 0.018, + "step": "drain-second" + }, + { + "command": "serve", + "passed": true, + "seconds": 0.164, + "step": "start-first" + }, + { + "command": "serve", + "passed": true, + "seconds": 0.164, + "step": "start-second" + }, + { + "command": "disable", + "exit_code": 0, + "passed": true, + "seconds": 0.107, + "step": "disable-first" + }, + { + "command": "enable", + "exit_code": 0, + "passed": true, + "seconds": 0.105, + "step": "enable-second" + }, + { + "command": "status", + "exit_code": 0, + "passed": true, + "seconds": 0.096, + "step": "status-first" + }, + { + "command": "status", + "exit_code": 0, + "passed": true, + "seconds": 0.093, + "step": "status-second" + }, + { + "command": "SIGTERM", + "passed": true, + "seconds": 0.02, + "step": "drain-first" + }, + { + "command": "SIGTERM", + "passed": true, + "seconds": 0.021, + "step": "drain-second" + }, + { + "code": "gateway.admin.connection-unavailable", + "command": "rotate-prepare", + "exit_code": 1, + "passed": true, + "seconds": 0.175, + "step": "refuse-stale-restore" + }, + { + "command": "rotate-prepare", + "exit_code": 0, + "passed": true, + "seconds": 0.2, + "step": "rotate-prepare-first" + }, + { + "command": "rotate-commit", + "exit_code": 0, + "passed": true, + "seconds": 0.19, + "step": "rotate-commit-first" + }, + { + "command": "rotate", + "exit_code": 0, + "passed": true, + "seconds": 0.196, + "step": "rotate-second" + }, + { + "command": "serve", + "passed": true, + "seconds": 0.166, + "step": "start-first" + }, + { + "command": "serve", + "passed": true, + "seconds": 0.169, + "step": "start-second" + }, + { + "command": "status", + "exit_code": 0, + "passed": true, + "seconds": 0.101, + "step": "status-first" + }, + { + "command": "status", + "exit_code": 0, + "passed": true, + "seconds": 0.1, + "step": "status-second" + }, + { + "command": "support-bundle", + "exit_code": 0, + "passed": true, + "seconds": 0.226, + "step": "redacted-support" + }, + { + "code": "gateway.reobserve.not-observable", + "command": "reobserve", + "exit_code": 1, + "passed": true, + "seconds": 0.077, + "step": "reobserve-first" + }, + { + "command": "qualification-status", + "exit_code": 0, + "passed": true, + "seconds": 0.168, + "step": "deployment-tuple" + }, + { + "command": "stage-trust", + "exit_code": 0, + "passed": true, + "seconds": 0.096, + "step": "disposable-signer-rotation-and-freshness" + }, + { + "command": "SIGTERM", + "passed": true, + "seconds": 0.019, + "step": "drain-first" + }, + { + "command": "serve", + "passed": true, + "seconds": 0.162, + "step": "start-first" + }, + { + "command": "status", + "exit_code": 0, + "passed": true, + "seconds": 0.1, + "step": "status-first" + }, + { + "command": "status", + "exit_code": 0, + "passed": true, + "seconds": 0.091, + "step": "status-second" + }, + { + "command": "SIGTERM", + "passed": true, + "seconds": 0.019, + "step": "drain-first" + }, + { + "command": "SIGTERM", + "passed": true, + "seconds": 0.019, + "step": "drain-second" + }, + { + "command": "install", + "exit_code": 0, + "passed": true, + "seconds": 0.14, + "step": "install-outage-fixture" + }, + { + "command": "disable", + "exit_code": 0, + "passed": true, + "seconds": 0.101, + "step": "outage-disable" + }, + { + "command": "disable", + "exit_code": 0, + "passed": true, + "seconds": 0.096, + "step": "outage-disable" + }, + { + "command": "revoke", + "exit_code": 0, + "passed": true, + "seconds": 0.101, + "step": "outage-revoke" + }, + { + "command": "disable", + "exit_code": 0, + "passed": true, + "seconds": 0.098, + "step": "outage-disable" + } + ] +} diff --git a/deployment/gateway/operator.conf.example b/deployment/gateway/operator.conf.example new file mode 100644 index 000000000..8c6561e27 --- /dev/null +++ b/deployment/gateway/operator.conf.example @@ -0,0 +1,9 @@ +# Operator reads and administers custody; runtime role only reads it. +# Supply AUTHS_POSTGRES_URL from the protected operator secret source. +AUTHS_POSTGRES_CA_PEM=/etc/auths/postgres-ca.pem +AUTHS_POSTGRES_SERVER_NAME=postgres.internal +AWS_ROLE_ARN=arn:aws:iam::ACCOUNT:role/auths-operator +AWS_WEB_IDENTITY_TOKEN_FILE=/run/auths-identity/operator/token +# Read confirmation during rotation uses the separately provisioned runtime identity. +AUTHS_GATEWAY_RUNTIME_ROLE_ARN=arn:aws:iam::ACCOUNT:role/auths-runtime +AUTHS_GATEWAY_RUNTIME_TOKEN_FILE=/run/auths-identity/runtime/token diff --git a/deployment/gateway/run-with-postgres-url b/deployment/gateway/run-with-postgres-url new file mode 100755 index 000000000..8345a257d --- /dev/null +++ b/deployment/gateway/run-with-postgres-url @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu +: "${CREDENTIALS_DIRECTORY:?systemd credential directory is required}" +AUTHS_POSTGRES_URL=$(cat "$CREDENTIALS_DIRECTORY/postgres-url") +export AUTHS_POSTGRES_URL +exec "$@" diff --git a/deployment/gateway/runtime.conf.example b/deployment/gateway/runtime.conf.example new file mode 100644 index 000000000..acfc869cb --- /dev/null +++ b/deployment/gateway/runtime.conf.example @@ -0,0 +1,5 @@ +# Public configuration only. Web identity tokens are short-lived files, never env values. +AUTHS_POSTGRES_CA_PEM=/etc/auths/postgres-ca.pem +AUTHS_POSTGRES_SERVER_NAME=postgres.internal +AWS_ROLE_ARN=arn:aws:iam::ACCOUNT:role/auths-runtime +AWS_WEB_IDENTITY_TOKEN_FILE=/run/auths-identity/runtime/token diff --git a/deployment/gateway/systemd/auths-gateway-doctor.service b/deployment/gateway/systemd/auths-gateway-doctor.service new file mode 100644 index 000000000..872eda223 --- /dev/null +++ b/deployment/gateway/systemd/auths-gateway-doctor.service @@ -0,0 +1,20 @@ +[Unit] +Description=Check Auths production readiness and emit the bounded report +After=auths-gateway.service + +[Service] +Type=oneshot +User=root +UMask=0077 +EnvironmentFile=/etc/auths/runtime.conf +LoadCredential=postgres-url:/etc/auths/postgres-url +ExecStart=/opt/auths/bin/run-with-postgres-url /opt/auths/bin/auths-gateway doctor --state-dir /var/lib/auths/gateway --app-socket /run/auths-app/gateway.sock --app-uid 62002 --app-gid 62000 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ReadWritePaths=/var/lib/auths/gateway +ReadOnlyPaths=/run/auths-identity/runtime +TimeoutStartSec=60 +StandardOutput=journal +StandardError=journal diff --git a/deployment/gateway/systemd/auths-gateway-doctor.timer b/deployment/gateway/systemd/auths-gateway-doctor.timer new file mode 100644 index 000000000..48766efee --- /dev/null +++ b/deployment/gateway/systemd/auths-gateway-doctor.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Periodic Auths readiness check + +[Timer] +OnBootSec=60 +OnUnitActiveSec=60 +Unit=auths-gateway-doctor.service + +[Install] +WantedBy=timers.target diff --git a/deployment/gateway/systemd/auths-gateway.service b/deployment/gateway/systemd/auths-gateway.service new file mode 100644 index 000000000..2912915c7 --- /dev/null +++ b/deployment/gateway/systemd/auths-gateway.service @@ -0,0 +1,38 @@ +[Unit] +Description=Auths production gateway +After=network-online.target systemd-timesyncd.service +Wants=network-online.target + +[Service] +Type=simple +User=auths-gateway +Group=auths-app-socket +UMask=0077 +EnvironmentFile=/etc/auths/runtime.conf +LoadCredential=postgres-url:/etc/auths/postgres-url +ExecStart=/opt/auths/bin/run-with-postgres-url /opt/auths/bin/auths-gateway serve --state-dir /var/lib/auths/gateway --app-socket /run/auths-app/gateway.sock +Restart=on-failure +RestartSec=5 +TimeoutStopSec=35 +KillSignal=SIGTERM +LimitNOFILE=512 +MemoryMax=512M +CPUQuota=200% +TasksMax=128 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +ReadWritePaths=/var/lib/auths/gateway /run/auths-app +ReadOnlyPaths=/run/auths-identity/runtime /run/systemd/timesync +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/deployment/gateway/timesyncd/50-auths-gateway.conf b/deployment/gateway/timesyncd/50-auths-gateway.conf new file mode 100644 index 000000000..6b0e88fa6 --- /dev/null +++ b/deployment/gateway/timesyncd/50-auths-gateway.conf @@ -0,0 +1,5 @@ +# Install as /etc/systemd/timesyncd.conf.d/50-auths-gateway.conf. +# The gateway refuses synchronization samples older than fifteen minutes. +[Time] +PollIntervalMinSec=32s +PollIntervalMaxSec=5min diff --git a/deployment/gateway/tools/exercise-postgres-restore.sh b/deployment/gateway/tools/exercise-postgres-restore.sh new file mode 100755 index 000000000..c89ab6e03 --- /dev/null +++ b/deployment/gateway/tools/exercise-postgres-restore.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# A real physical backup plus WAL restore to a named point on the disposable +# PostgreSQL fixture. This rehearses the database mechanics, not replay continuity. +set -euo pipefail +fixture=product/stores/auths-stores/tests/postgres_tls/compose.yaml +source_id=$(docker compose -f "$fixture" ps -q postgres) +: "${source_id:?start the PostgreSQL fixture first}" +: "${RUNNER_TEMP:?a disposable runner directory is required}" +work=$(mktemp -d "$RUNNER_TEMP/auths-pitr.XXXXXXXX") +restore_name="auths-pitr-$(basename "$work")" +cleanup() { + docker rm -f "$restore_name" >/dev/null 2>&1 || true + sudo rm -rf -- "$work" +} +trap cleanup EXIT +sql() { docker exec -u postgres "$source_id" psql -U auths -d auths_lifecycle -v ON_ERROR_STOP=1 -Atc "$1"; } +sql "ALTER SYSTEM SET archive_mode = 'on'" >/dev/null +sql "ALTER SYSTEM SET archive_command = 'cp %p /var/lib/postgresql/data/operator-wal/%f'" >/dev/null +docker exec -u postgres "$source_id" mkdir -p /var/lib/postgresql/data/operator-wal +docker restart "$source_id" >/dev/null +for attempt in {1..60}; do + if docker exec "$source_id" pg_isready -U auths -d auths_lifecycle >/dev/null; then break; fi + sleep 1 +done +sql 'CREATE TABLE auths_operator_restore_marker (id integer PRIMARY KEY); INSERT INTO auths_operator_restore_marker VALUES (1)' >/dev/null +docker exec -u postgres "$source_id" pg_basebackup -U auths -D /tmp/operator-base -Fp -Xs -c fast +sql 'INSERT INTO auths_operator_restore_marker VALUES (2)' >/dev/null +sql "SELECT pg_create_restore_point('auths-before-cutover')" >/dev/null +sql 'INSERT INTO auths_operator_restore_marker VALUES (3)' >/dev/null +wal=$(sql 'SELECT pg_walfile_name(pg_current_wal_lsn())') +sql 'SELECT pg_switch_wal()' >/dev/null +for attempt in {1..60}; do + if docker exec "$source_id" test -f "/var/lib/postgresql/data/operator-wal/$wal"; then break; fi + sleep 1 +done +docker exec "$source_id" test -f "/var/lib/postgresql/data/operator-wal/$wal" +docker cp "$source_id:/tmp/operator-base" "$work/base" +docker cp "$source_id:/var/lib/postgresql/data/operator-wal" "$work/archive" +cat >> "$work/base/postgresql.auto.conf" <<'CONFIG' +restore_command = 'cp /archive/%f %p' +recovery_target_name = 'auths-before-cutover' +recovery_target_action = 'promote' +archive_mode = 'off' +CONFIG +touch "$work/base/recovery.signal" +# The disposable image's postgres user is UID/GID 999. Preserve ownership of +# copied PostgreSQL files; these are fixture database bytes, never provider secrets. +sudo chown -R 999:999 "$work/base" "$work/archive" +sudo chmod 700 "$work/base" +docker run -d --name "$restore_name" \ + -v "$work/base:/var/lib/postgresql/data" \ + -v "$work/archive:/archive:ro" \ + -v "$(pwd)/product/stores/auths-stores/tests/postgres_tls:/fixture:ro" \ + postgres:17.10-bookworm >/dev/null +for attempt in {1..60}; do + if docker exec "$restore_name" psql -U auths -d auths_lifecycle -Atc 'SELECT NOT pg_is_in_recovery()' 2>/dev/null | grep -qx t; then break; fi + sleep 1 +done +docker exec "$restore_name" psql -U auths -d auths_lifecycle -v ON_ERROR_STOP=1 -Atc 'SELECT NOT pg_is_in_recovery()' | grep -qx t +observed=$(docker exec "$restore_name" psql -U auths -d auths_lifecycle -v ON_ERROR_STOP=1 -Atc "SELECT string_agg(id::text, ', ' ORDER BY id) FROM auths_operator_restore_marker") +[[ "$observed" == '1, 2' ]] +printf '%s\n' '{"schema":"auths.gateway-restore-exercise/1","backup":"physical-with-wal","target":"named-restore-point","before_target_retained":true,"after_target_excluded":true,"replay_continuity_claim":false}' diff --git a/deployment/gateway/tools/operator-simulation.py b/deployment/gateway/tools/operator-simulation.py new file mode 100644 index 000000000..590a156bf --- /dev/null +++ b/deployment/gateway/tools/operator-simulation.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""A labeled operator rehearsal using downloaded binaries, never a source build.""" +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import selectors +import shutil +import signal +import subprocess +import tarfile +import tempfile +import time + + +class Refusal(Exception): + """Only a bounded, non-secret diagnostic reaches the public report.""" + + +def require(condition, code): + if not condition: + raise Refusal(code) + + +def private_tree(path, uid, gid): + for item in [path, *path.rglob("*")]: + require(not item.is_symlink(), "simulation.unexpected-symlink") + os.chown(item, uid, gid) + + +class Operator: + def __init__(self, package, inputs, work, sockets): + self.binary = package / "bin/auths-gateway" + self.qualification = package / "bin/auths-qualification" + self.inputs, self.work, self.sockets = inputs, work, sockets + self.steps, self.servers = [], {} + self.uid, self.gid, self.app_uid = 62001, 62000, 62002 + fixture = json.loads((inputs / "custody-hostile.json").read_text()) + self.sources = [{"source": entry["source"], + "canary": "synthetic-simulation-" + os.urandom(16).hex()} + for entry in fixture["redaction"]["canaries"]["sources"]] + require(len(self.sources) == 11, "simulation.canary-inventory") + self.canaries = [entry["canary"].encode() for entry in self.sources] + self.secret = next(e["canary"].encode() for e in self.sources if e["source"] == "credential") + self.account = next(e["canary"] for e in self.sources if e["source"] == "provider-account-id") + self.rotated = ("synthetic-simulation-rotation-" + os.urandom(16).hex()).encode() + self.canaries.append(self.rotated) + + def scan(self, data): + for canary in self.canaries: + needles = [canary, canary.hex().encode(), canary.hex().upper().encode()] + for padding in range(3): + # Exclude sextets containing padding bytes or trailing partial bits. + start = (padding * 8 + 5) // 6 + end = ((padding + len(canary)) * 8) // 6 + for encode in (base64.b64encode, base64.urlsafe_b64encode): + needles.append(encode(b"\0" * padding + canary)[start:end]) + require(not any(needle in data for needle in needles), "simulation.canary-exposure") + + def run(self, label, args, stdin=b"", success=True, code=None, root=False, env=None, binary=None): + started = time.monotonic() + record = {"step": label, "command": args[0], "seconds": None, "passed": False} + self.steps.append(record) + options = {} if root else {"user": self.uid, "group": self.gid, "extra_groups": []} + result = subprocess.run( + [str(binary or self.binary), *map(str, args)], input=stdin, + capture_output=True, timeout=65, cwd=self.work, + env={"PATH": "/usr/bin:/bin", **(env or {})}, **options, + ) + record["seconds"] = round(time.monotonic() - started, 3) + record["exit_code"] = result.returncode + self.scan(result.stdout + result.stderr) + require(len(result.stdout) + len(result.stderr) <= 1024 * 1024, "simulation.output-bound") + require((result.returncode == 0) == success, "simulation.command-exit") + if code: + require(code.encode() in result.stderr or code.encode() in result.stdout, "simulation.expected-code") + record["code"] = code + record["passed"] = True + return result.stdout + + def json(self, label, args, **kwargs): + return json.loads(self.run(label, args, **kwargs)) + + def install(self, state, store=None, join=False, secret=None, extra=(), label="install"): + args = ["install", "--state-dir", state, "--recipe", self.inputs / "recipe.json", + "--profile-lock", self.inputs / "profile.lock.json", + "--trusted-context", self.inputs / "trusted.context.cbor", + "--approve-digest", self.digest, "--provider", "airtable", + "--alias", "simulation", "--credential-stdin", + "--recipe-family", "operator-simulation-v1", + "--provider-contract-id", "3" * 64] + args += ["--join"] if join else ["--account-label", self.account] + if store: + args += ["--attempt-store", store] + return self.run(label, [*args, *extra], stdin=(secret or self.secret) + b"\n") + + def start(self, state): + started = time.monotonic() + record = {"step": "start-" + state.name, "command": "serve", "passed": False} + self.steps.append(record) + app = self.sockets / (state.name + ".sock") + log = tempfile.TemporaryFile() + process = subprocess.Popen( + [str(self.binary), "serve", "--state-dir", str(state), "--app-socket", str(app)], + stdout=subprocess.PIPE, stderr=log, cwd=self.work, + env={"PATH": "/usr/bin:/bin"}, user=self.uid, group=self.gid, extra_groups=[], + ) + self.servers[state] = (process, log, app) + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + require(bool(selector.select(15)), "simulation.start-timeout") + ready = process.stdout.readline(65537) + self.scan(ready) + require(ready.startswith(b"app socket ready"), "simulation.start-refused") + record.update(passed=True, seconds=round(time.monotonic() - started, 3)) + + def stop(self, state): + started = time.monotonic() + record = {"step": "drain-" + state.name, "command": "SIGTERM", "passed": False} + self.steps.append(record) + process, log, app = self.servers.pop(state) + process.send_signal(signal.SIGTERM) + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise Refusal("simulation.shutdown-timeout") + self.scan(process.stdout.read(65537)) + log.seek(0) + self.scan(log.read(65537)) + log.close() + process.stdout.close() + require(process.returncode == 0, "simulation.shutdown-refused") + require(not app.exists() and not (state / "admin.sock").exists(), "simulation.socket-not-removed") + record.update(passed=True, seconds=round(time.monotonic() - started, 3)) + + def admin(self, state, command, label=None, extra=(), stdin=b"", success=True, code=None): + result = self.json(label or command + "-" + state.name, + [command, "--state-dir", state, *extra], + stdin=stdin, success=success, code=code) + require(result["ok"] == success, "simulation.admin-verdict") + return result + + def status(self, state): + return self.admin(state, "status")["status"] + + def doctor(self, state): + app = self.servers[state][2] + report = self.json("diagnose-development-" + state.name, + ["doctor", "--state-dir", state, "--app-socket", app, + "--app-uid", str(self.app_uid), "--app-gid", str(self.gid)], + root=True, success=False, code="gateway.doctor.not-ready") + require(report["schema"] == "auths.gateway-readiness/1" and not report["ready"], + "simulation.false-production-readiness") + checks = {row["check"]: row for row in report["checks"]} + require(len(checks) == 10, "simulation.diagnostic-inventory") + require(checks["operator-plane-isolation"]["ready"], "simulation.isolation-refused") + require(not checks["provider-secret-custody"]["ready"], "simulation.plaintext-ready") + require(checks["observer-custody"]["state"] == "not-configured", "simulation.optional-observer") + return report + + def exercise(self): + review = self.json("review-recipe", ["review", "--recipe", self.inputs / "recipe.json", + "--profile-lock", self.inputs / "profile.lock.json"]) + self.digest = review["recipe_digest"] + first, second, store = self.work / "first", self.work / "second", self.work / "store" + self.install(first, store) + self.install(second, store, join=True, label="join-second-host") + for source in self.sources: + (first / (source["source"] + ".captured")).write_text(source["canary"]) + private_tree(first, self.uid, self.gid) + self.json("offline-support", ["support-bundle", "--state-dir", first]) + # These are shipped builds: neither test transport nor plaintext production is allowed. + args = ["install", "--state-dir", self.work / "production", "--recipe", + self.inputs / "recipe.json", "--profile-lock", self.inputs / "profile.lock.json", + "--trusted-context", self.inputs / "trusted.context.cbor", "--approve-digest", + self.digest, "--provider", "airtable", "--alias", "simulation", + "--account-label", self.account, "--credential-stdin", "--deployment", "production"] + self.run("refuse-production-plaintext", args, stdin=self.secret + b"\n", success=False, + code="gateway.credential.production-plaintext-refused") + self.start(first) + self.start(second) + require(self.status(first)["credential_held"] and self.status(second)["credential_held"], + "simulation.joined-custody") + self.doctor(first) + self.admin(first, "disable") + require(self.status(second)["state"] == "disabled", "simulation.shared-disable") + self.admin(second, "enable") + require(self.status(first)["state"] == "active", "simulation.shared-enable") + self.stop(first) + self.stop(second) + snapshot = self.work / "old-store" + shutil.copytree(store, snapshot) + self.start(first) + self.start(second) + self.admin(first, "disable") + self.admin(second, "enable") + self.status(first) + self.status(second) + self.stop(first) + self.stop(second) + current = self.work / "current-store" + shutil.copytree(store, current) + floor = (first / "connection-floor.json").read_bytes() + shutil.rmtree(store) + shutil.copytree(snapshot, store) + private_tree(store, self.uid, self.gid) + self.run("refuse-stale-restore", + ["rotate-prepare", "--state-dir", first, "--operator-process", "--credential-stdin"], + stdin=self.rotated + b"\n", success=False, code="gateway.admin.connection-unavailable") + require((first / "connection-floor.json").read_bytes() == floor, "simulation.floor-replaced") + shutil.rmtree(store) + shutil.copytree(current, store) + private_tree(store, self.uid, self.gid) + # Development file custody is a process-local snapshot. Keep both hosts + # drained while separate operator processes update those files; + # production AWS readers resolve the published exact version instead. + prepared = self.admin(first, "rotate-prepare", extra=["--operator-process", "--credential-stdin"], + stdin=self.rotated + b"\n") + self.admin(first, "rotate-commit", extra=["--operator-process", "--commitment", prepared["commitment"]]) + # The development stores are host-local: the second host must adopt the same secret. + self.admin(second, "rotate", extra=["--operator-process", "--credential-stdin"], + stdin=self.rotated + b"\n") + self.start(first) + self.start(second) + one, two = self.status(first), self.status(second) + require(one["credential_generation"] == two["credential_generation"] + and one["credential_held"] and two["credential_held"], "simulation.rotation-diverged") + self.json("redacted-support", ["support-bundle", "--state-dir", first], + env={"AUTHS_SIMULATION_CANARY": self.secret.decode()}) + self.admin(first, "reobserve", extra=["--operation-id", "simulation-no-such-operation"], + success=False, code="gateway.reobserve.not-observable") + tuple_bytes = self.run("deployment-tuple", ["qualification-status", "--state-dir", first, "--tuple"]) + tuple_path = self.work / "tuple.json" + tuple_path.write_bytes(tuple_bytes) + os.chown(tuple_path, self.uid, self.gid) + self.run("disposable-signer-rotation-and-freshness", + ["stage-trust", "--tuple", tuple_path, "--out", self.work / "trust-stage"], + binary=self.qualification) + # Rolling restart retains the shared binding; there is no new candidate or live attestation. + self.stop(first) + self.start(first) + require(self.status(first)["credential_generation"] == self.status(second)["credential_generation"], + "simulation.restart-diverged") + self.stop(first) + self.stop(second) + emergency = self.work / "emergency" + self.install(emergency, label="install-outage-fixture") + (emergency / "credentials.cbor").unlink() + (emergency / "qualification-state.json").write_bytes(b"unavailable") + for command, state in [("disable", "disabled"), ("disable", "disabled"), + ("revoke", "revoked"), ("disable", "revoked")]: + result = self.json("outage-" + command, + [command, "--state-dir", emergency, "--store-only"]) + require(result["state"] == state and result["drainage"] == "not-checked" + and result["credential_deletion"] == "not-attempted", "simulation.false-outage-claim") + + def cleanup(self): + for state in list(self.servers): + process, log, _ = self.servers.pop(state) + if process.poll() is None: + process.kill() + process.wait(timeout=10) + process.stdout.close() + log.close() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--archive", type=Path, required=True) + parser.add_argument("--inputs", type=Path, required=True) + parser.add_argument("--expected-commit", required=True) + parser.add_argument("--report", type=Path, required=True) + args = parser.parse_args() + report = {"schema": "auths.operator-simulation/1", "participant": "agent-simulation", + "independent_human_trial": False, "production_qualification": False, + "source_commit": args.expected_commit, "passed": False, "steps": [], + "not_exercised": ["live AWS workload identity", "live provider writes and unknown recovery", + "production qualification import", "production PostgreSQL PITR", + "upgrade to a distinct attested candidate"], + "friction": [{"code": "operator.platform-linux-only", "resolution": "rehearse on Ubuntu CI"}, + {"code": "operator.development-custody-snapshot", + "resolution": "drain development hosts before separate-process file rotation; restart both afterward"}, + {"code": "operator.production-inputs-unprovisioned", + "resolution": "owner supplies offline public root and protected live inputs"}]} + started, operator = time.monotonic(), None + try: + require(os.geteuid() == 0, "simulation.root-required") + expected = Path(str(args.archive) + ".sha256").read_text().split() + require(len(expected) == 2 and expected[1] == args.archive.name, "simulation.checksum-format") + with args.archive.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + require(digest == expected[0], "simulation.archive-digest") + report["archive_sha256"] = digest + with tempfile.TemporaryDirectory(prefix="auths-op-", dir="/var/tmp") as temporary: + root = Path(temporary) + os.chmod(root, 0o755) + with tarfile.open(args.archive, "r:gz") as archive: + members = archive.getmembers() + names = [member.name for member in members] + require(len(names) == len(set(names)) <= 64, "simulation.archive-members") + for member in members: + path = PurePosixPath(member.name) + require(member.isfile() and not path.is_absolute() and ".." not in path.parts + and path.parts[0] == "auths-gateway-operator" and path.suffix != ".rs", + "simulation.archive-path") + archive.extractall(root, filter="data") + package = root / "auths-gateway-operator" + manifest = json.loads((package / "manifest.json").read_text()) + require(manifest["schema"] == "auths.gateway-operator-package/1" + and manifest["source_commit"] == args.expected_commit, "simulation.package-identity") + require(set(names) == {"auths-gateway-operator/" + item["path"] for item in manifest["files"]} + | {"auths-gateway-operator/manifest.json"}, "simulation.package-inventory") + for item in manifest["files"]: + require(hashlib.sha256((package / item["path"]).read_bytes()).hexdigest() == item["sha256"], + "simulation.payload-digest") + work, sockets, inputs = root / "work", root / "sockets", root / "inputs" + work.mkdir(mode=0o700) + sockets.mkdir(mode=0o750) + shutil.copytree(args.inputs, inputs) + os.chmod(inputs, 0o700) + os.chown(work, 62001, 62000) + os.chown(sockets, 62001, 62000) + private_tree(inputs, 62001, 62000) + operator = Operator(package, inputs, work, sockets) + operator.exercise() + report["passed"] = True + except Exception as error: + report["failure_code"] = str(error) if isinstance(error, Refusal) else "simulation." + type(error).__name__ + if operator and operator.steps: + operator.steps[-1].update(passed=False, failure_code=report["failure_code"]) + finally: + if operator: + try: + operator.cleanup() + except Exception: + report.update(passed=False, failure_code="simulation.cleanup-failed") + report["steps"] = operator.steps + report["elapsed_seconds"] = round(time.monotonic() - started, 3) + report["command_count"] = len(report["steps"]) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + print(json.dumps({"passed": report["passed"], "commands": report["command_count"], + "seconds": report["elapsed_seconds"], "failure_code": report.get("failure_code")})) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deployment/gateway/tools/package.py b/deployment/gateway/tools/package.py new file mode 100644 index 000000000..eafad0f4e --- /dev/null +++ b/deployment/gateway/tools/package.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Make the operator handoff from built binaries and public deployment files.""" +import argparse +import gzip +import hashlib +import io +import json +from pathlib import Path +import tarfile + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--gateway", type=Path, required=True) + parser.add_argument("--qualification", type=Path, required=True) + parser.add_argument("--commit", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if len(args.commit) != 40 or any(c not in "0123456789abcdef" for c in args.commit): + parser.error("commit must be a full lowercase SHA") + root = Path(__file__).resolve().parents[3] + files = { + "bin/auths-gateway": (args.gateway.read_bytes(), 0o755), + "bin/auths-qualification": (args.qualification.read_bytes(), 0o755), + "bin/run-with-postgres-url": ((root / "deployment/gateway/run-with-postgres-url").read_bytes(), 0o755), + "docs/GATEWAY_PRODUCTION_RUNBOOK.md": ((root / "docs/operations/GATEWAY_PRODUCTION_RUNBOOK.md").read_bytes(), 0o644), + } + reference = root / "deployment/gateway" + for path in sorted(reference.rglob("*")): + if path.is_file() and "tools" not in path.relative_to(reference).parts and path.name != "run-with-postgres-url": + data = path.read_bytes() + if path == reference / "README.md": + data = data.replace(b"../../docs/operations/GATEWAY_PRODUCTION_RUNBOOK.md", b"../docs/GATEWAY_PRODUCTION_RUNBOOK.md") + files["reference/" + path.relative_to(reference).as_posix()] = (data, 0o644) + manifest = { + "schema": "auths.gateway-operator-package/1", + "source_commit": args.commit, + "qualification_claim": "none; verify exact signed inputs separately", + "files": [{"path": name, "sha256": hashlib.sha256(data).hexdigest()} for name, (data, _) in sorted(files.items())], + } + files["manifest.json"] = ((json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n").encode(), 0o644) + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open("wb") as destination: + with gzip.GzipFile(fileobj=destination, mode="wb", filename="", mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as archive: + for name, (data, mode) in sorted(files.items()): + info = tarfile.TarInfo("auths-gateway-operator/" + name) + info.size, info.mode, info.mtime = len(data), mode, 0 + archive.addfile(info, io.BytesIO(data)) + digest = hashlib.sha256(args.output.read_bytes()).hexdigest() + args.output.with_suffix(args.output.suffix + ".sha256").write_text(digest + " " + args.output.name + "\n") + print(json.dumps({"archive": args.output.name, "sha256": digest, "files": len(files)})) + + +if __name__ == "__main__": + main() diff --git a/docs/PROGRAM_BOARD.md b/docs/PROGRAM_BOARD.md index 8b202cb14..f3d647fac 100644 --- a/docs/PROGRAM_BOARD.md +++ b/docs/PROGRAM_BOARD.md @@ -342,3 +342,75 @@ executable corpus runner and measured-observation contract, workflow wiring, CLI/script-pipeline tests, and direct credential-lease counters. Hosted verification is pending. Production family corpora, the owner's root ceremony, live qualifications, operator trial and launch acceptance remain unclaimed. + + +### AP-SPEC-066 unattended continuation (2026-10-06) + +Owner direction: finish Epic 4, then Epic 5, then publish the Python SDK +release candidate `0.0.1-rc1` and run a simulated onboarding pilot across all +recipes with measured friction and a backlog. Epic 3 PR #204 is merged. +Epic 4 implementation is on `gateway-operator-polish`; acceptance is still +open. The simulated pilot does not replace the independent operator trial or +human release review. Signing environment metadata contains no secret and +neither live recipe environment exists; the offline owner root ceremony and +live-provider qualification remain unclaimed. Python packaging must normalize +the requested semver to PEP 440 `0.0.1rc1`, preserving `0.0.1-rc1` as the +release/tag identifier. No publication or qualified launch is claimed yet. + +Owner clarification (2026-10-06): publish the RC as a GitHub prerelease with +downloadable Python wheel assets; PyPI publication is not required. Download +those published assets, verify their checksums and install them in clean +environments for the simulated onboarding pilot. Local checkout installs do +not demonstrate that release handoff. + +Epic 4 code at `f1f92c3f` passed every job in +[main CI](https://github.com/auths-dev/auths-proof/actions/runs/37421643170), +including authoritative tests, Lean, Kani, formal evidence and CI qualification. +The six operator-package, isolation, PostgreSQL, recipe and SDK-package +workflows also passed. The +[operator package](https://github.com/auths-dev/auths-proof/actions/runs/37421643189) +was downloaded and verified: archive SHA-256 +`70b585794b4883a9f85897446bf13ccc334d711570b0dee3148413788f577cd4` +and all eleven payload digests match. Frozen evidence uses freeze version 369, +manifest-byte version 58 and release-metadata version 369. Protocol identities +and proof statements are unchanged. This is engineering evidence; +[protected live custody](https://github.com/auths-dev/auths-proof/actions/runs/37421643146) +still awaits environment approval, and live/human acceptance remains open. + +Owner clarification (2026-10-06): merge each epic PR when that epic is complete +and CI is green. This authorizes those merges without another confirmation; +it does not waive the spec's live or independent-human acceptance gates. + +Owner correction (2026-10-06): the agent simulates being an operator, fixes +the observed friction and reruns the packaged rehearsal; the owner handles +real users offline. This supersedes the earlier requirement for the agent to +wait for an independent participant before continuing engineering. The +simulation is labeled as such and cannot qualify live providers or establish +independent human adoption. A separate source-free Ubuntu job downloads the +operator archive and simulation kit, runs the documented CLI against +disposable development installations, and records command timing and friction. +Its hosted result is pending. Continue with Epic 5 engineering after this +rehearsal; protected live inputs and release-signing keys remain explicit +external prerequisites for signed qualification. + +The first local Docker rehearsal used the verified `f1f92c3f` archive without +a source mount or network. It passed 31 steps, then found that separate-process +rotation does not reload development file custody in running hosts. The +rehearsal and runbook now keep those hosts drained during file rotation and +restart both before checking the committed generation. The defect rerun is +pending; no production AWS rotation result is inferred from this procedure. +The next run passed rotation on both restarted hosts, then refused tuple +export because the development install omitted a family and contract. The +rehearsal now declares an explicitly synthetic family/contract at install; +those identifiers carry no production qualification claim. + +The corrected Docker rehearsal passed all 47 steps in 5.848 seconds against +the downloaded `f1f92c3f` archive. The container mounted only that archive, +public inputs and the report directory; networking was disabled, with a +synthetic public-address DNS entry solely for transport construction. No +provider request or TLS session was exercised. The sanitized result is +`deployment/gateway/evidence/operator-simulation-2026-10-06.json`. It covers +shared controls, retained-floor restore refusal, drained file rotation, +privilege-dropped diagnostics, support redaction, disposable trust stages, +restart and store-only outage controls. Exact-current-commit hosted rehearsal +and CI remain pending. Live production and human adoption claims remain open. diff --git a/docs/assurance/PROOF_COVERAGE.md b/docs/assurance/PROOF_COVERAGE.md index 9ef778ee9..5b207aeb5 100644 --- a/docs/assurance/PROOF_COVERAGE.md +++ b/docs/assurance/PROOF_COVERAGE.md @@ -7,7 +7,7 @@ At the commit that contains this document, 110 of 1,031 decision-scope functions (10.7%) and 1,289 of 11,353 code lines (11.4%) are translated to Lean and reached from audited theorem statements, and 15 of 82 kernel check sites map only to such functions. The rest of the decision path, including the staged verifier control flow, the composition evaluator, the registry handlers, the codec, and the signature suites, is tested, not proved. - Revision: the commit that contains this document and `formal/proof-coverage-v1.json`; a release binds it in its assurance evidence. -- Measurement inputs: sha256 `aa2ed884c2a3f36b66103057966c4d6ec0610647696c4f8b163225bba4026b76` over the 89 files listed in `formal/proof-coverage-v1.json`. +- Measurement inputs: sha256 `56f5f8df74c31a2554725ec967451d36bfa6e7c4a49004b3c83bb5cd32c35f52` over the 89 files listed in `formal/proof-coverage-v1.json`. - Tool: `cargo xtask formal coverage`, version 1. - Toolchain pins: `formal/lean-toolchain` = `leanprover/lean4:v4.31.0`; `formal/translation-toolchain.lock` sha256 `0c45a8a08ac06e313d775e669749097bd3a0abfb8e3395c69416ba0238770d53`. - Role table: `formal/coverage-scope-v1.toml`, sha256 `053f0be216b0003768d422d9ccc92a692726488874284fa80615657b30a495b5`. Figures computed under a different role table are not comparable. @@ -52,7 +52,7 @@ Files in scoped crates with a non-decision role (every other scoped file is deci | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | decision | 1,031 | 110 (10.7%) | 56 (5.4%) | 110 (10.7%) | 11,353 | 1,289 (11.4%) | 856 (7.5%) | 1,289 (11.4%) | | verifier-core | 785 | 86 (11.0%) | 41 (5.2%) | 86 (11.0%) | 8,802 | 907 (10.3%) | 558 (6.3%) | 907 (10.3%) | -| translated-crates | 1,138 | 210 (18.5%) | 70 (6.2%) | 210 (18.5%) | 10,666 | 2,508 (23.5%) | 1,024 (9.6%) | 2,508 (23.5%) | +| translated-crates | 1,138 | 210 (18.5%) | 70 (6.2%) | 210 (18.5%) | 10,662 | 2,508 (23.5%) | 1,024 (9.6%) | 2,508 (23.5%) | | verifier-closure | 1,083 | 86 (7.9%) | 41 (3.8%) | 86 (7.9%) | 13,762 | 907 (6.6%) | 558 (4.1%) | 907 (6.6%) | | decision-nontrivial | 443 | 81 (18.3%) | 44 (9.9%) | 81 (18.3%) | 9,589 | 1,202 (12.5%) | 820 (8.6%) | 1,202 (12.5%) | diff --git a/docs/operations/GATEWAY_PRODUCTION_RUNBOOK.md b/docs/operations/GATEWAY_PRODUCTION_RUNBOOK.md new file mode 100644 index 000000000..6c88061b0 --- /dev/null +++ b/docs/operations/GATEWAY_PRODUCTION_RUNBOOK.md @@ -0,0 +1,199 @@ +# Production gateway operations + +Use the exact installed `auths-gateway` and `auths-qualification` binaries and +verified package checksums. Run operator commands as the gateway state owner, +with operator workload identity for credential mutations; the serving process +keeps its read-only runtime identity. Root is required only for doctor's +privilege-drop test. Application processes receive neither identity nor state. + +## Diagnose and stop + +For a loadable installation whose isolation probes complete, +`doctor --state-dir ... --app-socket ... --app-uid ... --app-gid ...` prints +all nine required typed checks and the optional observer state. If installation +loading or isolation checks cannot complete, it refuses with a stable top-level +code before emitting that report; retain the code for diagnosis. It checks the +actual lifecycle record, recipe binding, current credential-store confirmation, +qualification, clock and process isolation. Any failed or unmade check yields +nonzero status. Keep the JSON report. A missing observer explicitly means +signed outcomes are unavailable. `status` reports the serving process's local +in-flight count; it is not a cluster-wide drainage claim. + +If provider, custody or qualification inputs are unavailable, use +`disable --state-dir ... --store-only`. This needs only installation metadata +and the lifecycle store, so it also works with the gateway stopped. It is +idempotent, retains attempts unchanged and claims no drainage. Use +`revoke --state-dir ... --store-only` for permanent revocation. Later run the +`credential-collect` with healthy operator custody on every host that recorded +credential generations to delete the exact credentials; +a deletion error does not undo revocation. Never turn unknown into failure or +submit the operation again. Database failure means no durable stop can be +claimed; isolate app ingress at the host firewall and retain the incident. + +## Provider-secret rotation + +For the web-identity reference, the operator configuration must explicitly +provide `AUTHS_GATEWAY_RUNTIME_ROLE_ARN` and `AUTHS_GATEWAY_RUNTIME_TOKEN_FILE` +for read confirmation. `AWS_ROLE_ARN` and `AWS_WEB_IDENTITY_TOKEN_FILE` name +the write-only operator identity. The operator process reads with the former +and creates/deletes with the latter; the serving process keeps only its +read-only identity. Neither role needs broader permissions. + +Use the operator identity and `rotate-prepare --operator-process +--credential-stdin --state-dir ...`, piping the new provider secret. It runs +the recipe's fixed credential checks and stores a successor without publishing +it. Retain only the returned reference commitment. Then use `rotate-commit +--operator-process --state-dir ... --commitment ...` to atomically publish it. +Check status on both hosts; each must see the same shared generation and hold +the exact committed credential generation. The old generation remains for at +least the fixed 20-second retirement delay. Run `credential-collect --state-dir +...` under operator identity on each host that created a generation; it waits +20 seconds after observing the record, requires that record unchanged, then +deletes at most sixteen obsolete exact generations. It keeps the active and +future prepared generations. Rerun after a conflict or deletion failure. +Durable `credential-journal.json` notes are written before storing a candidate, +so partial setup or a process exit does not lose its cleanup obligation. +An abandoned future generation becomes collectable after disabling advances +the shared generation past it. Collect before preparing another successor. Repeat the exercise with a stop +between prepare and commit; a stale commitment must fail, leaving the old +record authoritative. For emergency cutover: disable, wait the fixed 20 seconds for entries on +both hosts, rotate while disabled, collect old generations under operator +identity, verify both hosts, then enable. Never retain a new secret in terminal history. +Operator-process drainage describes only that short-lived process; wait for +all serving hosts separately before retiring external credentials. + +## Qualification signer, expiry and revocation + +The offline owner certifies a new protected software release signer with the +ceremony tool. Publish the new certificate, current signed revocation list, +release index and exact attestations together. On each host, run +`auths-gateway qualification-import --state-dir ... --from ...`; add +`--admin-socket ...` when serving uses a nondefault socket. Import verifies and +stores the inputs, then automatically requests `qualification-reload` from +the serving operator socket. If the gateway is stopped, it reads them at its +next start. There is no separate `qualification-reload` CLI command. Inspect +`auths-gateway qualification-status --state-dir ...` and the serving `status` +response after import, so a failed reload cannot be mistaken for acceptance. +The production clock requires a synchronization sample at most fifteen minutes +old, with no future timestamp; stopping time synchronization must make +`gateway.qualification.clock-untrusted` appear once that bound is exceeded. +Restore synchronization through the reviewed time service, not by touching its +marker. The reference polling interval is at most five minutes. +Do not extend validity by editing files or the clock. Rehearse signer rotation, +signer revocation, stale revocation inputs and untrusted-clock refusal with +`auths-qualification stage-trust --tuple ... --out ...`, using the candidate's +public tuple. That tool uses disposable test signing material and proves the +trust mechanism; it does not rotate a production root or prove a gateway's +lease boundary. Separately exercise expired attestations, expired certificates, +revoked qualifications and those other signed input faults on the disposable +gateway deployment: every required recipe must stop before lease. Keep the +signed input digests, +closed codes and zero-entry/lease witnesses; no private key enters support +bundles or gateway hosts. Publish a root-signed revocation list at least every +24 hours (72 hours is the hard maximum), including when nothing is revoked. +Treat a missed update as an incident that disables required recipes. + +A root replacement is a new reviewed gateway build pin and new qualification +run for its exact build/closure, not an operator override. Stop writes, retain +revocation floors, install the newly pinned candidate and import its new-root +inputs. Old-root inputs must refuse. Only resume after exact candidate evidence +and the human boundary review. No root private key is created on a gateway or +in an online agent workspace. + +## Backup, point-in-time restore and restart + +Archive PostgreSQL WAL continuously and take encrypted snapshots. Back up +public installation files, operator attestation, qualification inputs, credential +journals and host floors. Keep `connection-floor.json` and qualification verifier floors in an +independent current recovery record; never restore older floors together with +an older database. Before an exercise, record connection and credential +generations, recipe/lock digests, qualification issue-time/revocation floors +and attempt counts on both hosts. Disable app ingress and new entries; stop +both binaries and retain unknown/response-recorded operations. + +For a self-hosted PostgreSQL server, take the physical backup with the +maintained PostgreSQL client: `pg_basebackup --host postgres.internal +--username auths_backup --pgdata /secure/backups/candidate --format plain +--wal-method stream --checkpoint fast`. Supply the password with an owner-only +PGPASSFILE and require `PGSSLMODE=verify-full` with the reviewed CA. Test WAL +archiving before the backup. Create a named point with +`SELECT pg_create_restore_point('reviewed_restore_point')` and retain its WAL. +On the isolated replacement server, restore the physical backup into an empty +private data directory, create `recovery.signal`, and set `restore_command` +to copy exact archived WAL files, `recovery_target_name` to that reviewed point +and `recovery_target_action` to `promote`. Start with the replacement's own TLS +certificate and hostname. Verify records committed before the target exist +and those committed after it do not; retain the closed exercise report. +The disposable hosted exercise automates these mechanics. It does not make a +claim about missing replay rows or external provider effects. + +Restore snapshot plus WAL to a new PostgreSQL endpoint, validate its TLS name +and schema, and keep original hosts stopped. Start each replacement from its +current independently retained floor files and reviewed installed inputs. +A restored connection generation below the host floor, or different bytes at +the same generation, refuses before lease. Recipe drift, unavailable exact +credential generation and rolled-back qualification inputs also refuse. +Never delete floors to make readiness pass. If the store lost committed replay +or budget state, continuity is unproven: quarantine the restored deployment +and rebuild authority/connection state under owner review; do not serve old +operations as fresh. A newly created host without a floor cannot prove that a +database is current; recover its floors before starting it. + +For each response-recorded or unknown operation, use `reobserve --state-dir +... --operation-id ...` once. It uses the stored plan and a fresh read-only +check under the declared capability. Reobserve requires an enabled connection +because it leases a credential; a disabled or revoked connection refuses even +this read. Only when the restored state passes the continuity and retained-floor +checks, keep app ingress blocked on both hosts, require every other readiness +check to pass, enable and rerun full doctor before reconciling. Do not enable +a quarantined restore to work around a refusal. Confirmed evidence may advance the +record; missing or delayed evidence stays unknown. Reobserve again after +visibility returns; never issue a new provider write as recovery. Keep the +opaque operation digest and stage only in support evidence. + +## Rolling upgrade + +Require attestations for each candidate's exact binary/semantic closure and +platform tuple. Disable new writes, drain each serving host, capture bounded +support bundles and floors, and upgrade one host. It must derive the same +recipe and connection generations; the old host must refuse a changed binding +rather than choosing a stale credential. A binary without a current exact +tuple attestation stays disabled. Validate doctor and read-only reconciliation +with app ingress blocked on both hosts: while disabled, the connection check +must fail and every other required check must pass. Enable within that isolated +window, rerun full doctor and reconcile without submitting a new operation. +Disable again before replacing the second host. Check its candidate the same +way, then enable, require full doctor on both hosts and only then admit app +traffic. Do not mix obsolete store schemas or interpret restore as replay +continuity. Keep expiry and revocation updates running during the upgrade. + +## Independent operator trial + +The owner directed the agent on 2026-10-06 to simulate an operator and will +handle real users offline. The `Gateway operator package` workflow therefore +also runs a labeled rehearsal in a separate Ubuntu job with no source checkout, +compiler invocation or repository imports. It downloads the archive and public +simulation kit, checks the exact commit and all payload digests, and records +actual command counts, timings, stable refusals and friction in +`operator-simulation.json`. Shipped binaries retain their production guards. +The rehearsal uses disposable development custody and a shared file store; +its report explicitly lists unexercised production/live operations. It is +agent simulation evidence, not an independent person's result or a production +qualification. Any failing command must be corrected and the rehearsal rerun. + +Development file custody is loaded into each serving process at startup. For +this rehearsal, drain both hosts before rotating their host-local files with +`--operator-process`, then restart both and require the same credential +generation with `credential_held: true`. Updating a file beside a running +process does not reload that process's custody snapshot. This development +procedure does not demonstrate the production AWS reader's live rotation. + +Give a person unfamiliar with this implementation only the verified packaged +binaries, packages, public configuration and this runbook. Record participant +identity/role, artifact digests, dates, timings, attempted commands, redacted +reports and defects. They deploy both hosts, diagnose an unavailable credential, +rotate, disable during outages, restore with retained floors, reject a stale +restore and reconcile unknown without a write. Plant canaries in excluded +sources and scan the actual support archive. Fix every blocking defect and +have that participant rerun its failing step. The implementer cannot supply +this participant result; a simulated SDK onboarding pilot is separate evidence. diff --git a/docs/specs/0066-production-gateway-polish-and-recipe-qualification.md b/docs/specs/0066-production-gateway-polish-and-recipe-qualification.md index 1df15df06..05f0911f0 100644 --- a/docs/specs/0066-production-gateway-polish-and-recipe-qualification.md +++ b/docs/specs/0066-production-gateway-polish-and-recipe-qualification.md @@ -887,7 +887,12 @@ Tasks: Done when a clean operator can deploy, diagnose, rotate, disable, restore and reconcile without source checkout or secret exposure; every diagnostic phrase corresponds to an actual typed check; and the trial's defects are fixed and -rerun. The implementer MUST NOT simulate the unfamiliar-operator trial. +rerun. The implementer MUST NOT represent a simulated trial as an independent +human result. Owner clarification (2026-10-06): the agent's deliverable is a +labeled operator simulation from packaged artifacts; the owner handles real +users offline. The simulation must fix and rerun its defects and explicitly +report which production/live operations its inputs do not exercise. Human +adoption and production qualification claims still require their own evidence. ### Epic 5 — Qualify two recipes and close the launch gate @@ -1601,3 +1606,44 @@ submission-driver, persistent-store, credential-lease and counting-provider observations into replay and lost-response recovery stages. Hosted verification of these additions is pending; no live qualification or owner trust ceremony is claimed. + + +## 21. Epic 4 implementation and remaining acceptance (2026-10-06) + +The operator-polish branch adds a bounded canary-scanned support bundle, +typed production doctor report, store-only emergency disable/revoke, separate +operator-process rotation under its own workload identity, graceful listener +shutdown and a durable host generation floor. The floor rejects an older +restored connection or changed bytes at an accepted generation before lease. +The maintained systemd deployment reference is `deployment/gateway/`; the +operations and independent trial protocol are in +`docs/operations/GATEWAY_PRODUCTION_RUNBOOK.md`. Code at `f1f92c3f` passed +[all main CI jobs](https://github.com/auths-dev/auths-proof/actions/runs/37421643170) +and the six operator-package, isolation, PostgreSQL, recipe and SDK-package +workflows. The downloaded operator archive and all eleven payload digests +were verified against that commit. Protected live custody is still awaiting +environment approval; these automated checks do not close the live or human +acceptance gates below. + +The production clock adapter now rejects a synchronization marker older than +fifteen minutes, a future timestamp, a symlink/nonregular file or an unsafe +write mode. Mere existence did not bound trust after synchronization stopped. +The packaged reference caps systemd-timesyncd polling at five minutes. This is +a fixed adapter bound, not an operator override of signed validity windows or +a claim that a compromised time source is trustworthy. + +This is not Epic 4 acceptance yet. A live two-host reference exercise of +workload identity, firewall, PostgreSQL point-in-time restore and the runbooks +remains. The owner now assigns the agent a labeled operator simulation and +handles real users offline; no independent human result is claimed. The +source-free packaged simulation passed 47 steps in Docker against the verified +`f1f92c3f` package after fixing development file-rotation and tuple-declaration +friction. The sanitized report is +`deployment/gateway/evidence/operator-simulation-2026-10-06.json`. +Exact-current-commit hosted verification remains pending; no live provider, +production AWS rotation or production PostgreSQL PITR is claimed by this run. +Epic 5 +also lacks the owner's offline root ceremony, protected signer secret, +two protected provider environments and human release review. On 2026-10-06, +GitHub environment/secret metadata confirmed no qualification signer secret +and neither family live environment. No production recipe is qualified. diff --git a/formal/assurance-manifest-v1.toml b/formal/assurance-manifest-v1.toml index b9583c6c9..f1195d0ed 100644 --- a/formal/assurance-manifest-v1.toml +++ b/formal/assurance-manifest-v1.toml @@ -8733,7 +8733,7 @@ sha256 = "2bb401ffca0136622cd79bb14a5a38852061649a06cc46100503870c22d300c7" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-055" @@ -8854,7 +8854,7 @@ sha256 = "2bb401ffca0136622cd79bb14a5a38852061649a06cc46100503870c22d300c7" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-056" @@ -8989,7 +8989,7 @@ sha256 = "2bb401ffca0136622cd79bb14a5a38852061649a06cc46100503870c22d300c7" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-104" @@ -10463,7 +10463,7 @@ sha256 = "ba7aea214063b8a698fbd2c9c8cd95a717bf0ec0eaef70a92844444bf7e9f22e" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-263" @@ -10559,7 +10559,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-264" @@ -10658,7 +10658,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-186" @@ -10918,7 +10918,7 @@ sha256 = "ba7aea214063b8a698fbd2c9c8cd95a717bf0ec0eaef70a92844444bf7e9f22e" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-237" @@ -11014,7 +11014,7 @@ sha256 = "ba7aea214063b8a698fbd2c9c8cd95a717bf0ec0eaef70a92844444bf7e9f22e" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-238" @@ -11113,7 +11113,7 @@ sha256 = "ba7aea214063b8a698fbd2c9c8cd95a717bf0ec0eaef70a92844444bf7e9f22e" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-175" @@ -15048,7 +15048,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-211" @@ -15143,7 +15143,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-212" @@ -15239,7 +15239,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-213" @@ -15335,7 +15335,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-214" @@ -15431,7 +15431,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-232" @@ -15530,7 +15530,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-233" @@ -15626,7 +15626,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-234" @@ -15722,7 +15722,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-235" @@ -15818,7 +15818,7 @@ sha256 = "c8c86db058f313ab46ee86370d79ee70130c1ead3860dd7dabd49f2a89af1636" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-249" @@ -16485,7 +16485,7 @@ sha256 = "dfa1998e3506f06dce142222dc383116d0952155517edc399f5d94e688e22912" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-257" @@ -16584,7 +16584,7 @@ sha256 = "dfa1998e3506f06dce142222dc383116d0952155517edc399f5d94e688e22912" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-258" @@ -16683,7 +16683,7 @@ sha256 = "dfa1998e3506f06dce142222dc383116d0952155517edc399f5d94e688e22912" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-259" @@ -16779,7 +16779,7 @@ sha256 = "dfa1998e3506f06dce142222dc383116d0952155517edc399f5d94e688e22912" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-260" @@ -16878,7 +16878,7 @@ sha256 = "dfa1998e3506f06dce142222dc383116d0952155517edc399f5d94e688e22912" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-070" @@ -23876,7 +23876,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-189" @@ -23972,7 +23972,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-190" @@ -24068,7 +24068,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-191" @@ -24164,7 +24164,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-192" @@ -24259,7 +24259,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-193" @@ -24358,7 +24358,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-194" @@ -24457,7 +24457,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-195" @@ -24558,7 +24558,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-196" @@ -24662,7 +24662,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-197" @@ -24767,7 +24767,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-198" @@ -24863,7 +24863,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-199" @@ -24968,7 +24968,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-200" @@ -25063,7 +25063,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-201" @@ -25158,7 +25158,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-271" @@ -25254,7 +25254,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-272" @@ -25350,7 +25350,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-273" @@ -25449,7 +25449,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-274" @@ -25548,7 +25548,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-275" @@ -25649,7 +25649,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-276" @@ -25749,7 +25749,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-277" @@ -25848,7 +25848,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-278" @@ -25948,7 +25948,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-279" @@ -26047,7 +26047,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-280" @@ -26148,7 +26148,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-281" @@ -26248,7 +26248,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-282" @@ -26348,7 +26348,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-283" @@ -26448,7 +26448,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-284" @@ -26547,7 +26547,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-285" @@ -26649,7 +26649,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-306" @@ -26745,7 +26745,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-307" @@ -26844,7 +26844,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-308" @@ -26940,7 +26940,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-309" @@ -27039,7 +27039,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-310" @@ -27139,7 +27139,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-311" @@ -27238,7 +27238,7 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" [[claims]] claim_id = "AP-FORMAL-RICH-312" @@ -27339,4 +27339,4 @@ sha256 = "d4d36b72718a4076b7c0561abfa2edc006a1f9a4491762979f86c3dd99a71fdc" [[claims.evidence]] kind = "source-closure" artifact = "formal/qualification/aeneas/source-closure.json" -sha256 = "01aea09dd874fe1aab4088257f031dbc33702b554c7021184cae74f924c33348" +sha256 = "9751d4995d89245a06ad2713c49edf2f2578b4c2d4f23e80e5006548671c3ee7" diff --git a/formal/proof-coverage-functions-v1.tsv b/formal/proof-coverage-functions-v1.tsv index bddcfc0aa..9e3656230 100644 --- a/formal/proof-coverage-functions-v1.tsv +++ b/formal/proof-coverage-functions-v1.tsv @@ -1205,49 +1205,49 @@ product/runtime/auths-connections/src/credential.rs 145 147 3 StoredSecretLease: product/runtime/auths-connections/src/credential.rs 182 187 6 CredentialStoreKind::parse product/runtime/auths-connections decision 0 0 0 0 0 0 product/runtime/auths-connections/src/credential.rs 191 196 6 CredentialStoreKind::as_str product/runtime/auths-connections decision 0 0 0 0 0 0 product/runtime/auths-connections/src/credential.rs 201 206 6 CredentialStoreKind::is_production product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 319 324 6 StoredSecret::holds product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 333 342 10 InMemoryCredentialStore::new product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 344 346 3 InMemoryCredentialStore::total_bytes product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 351 381 31 InMemoryCredentialStore::install product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 383 397 15 InMemoryCredentialStore::lease_secret product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 399 440 42 InMemoryCredentialStore::replace product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 442 456 15 InMemoryCredentialStore::revoke product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 458 464 7 InMemoryCredentialStore::holds product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 466 504 39 InMemoryCredentialStore::confirm product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 506 521 16 InMemoryCredentialStore::retire_superseded product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 523 535 13 InMemoryCredentialStore::delete_connection product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 561 567 7 PersistentCredentialStore::open product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 575 604 30 PersistentCredentialStore::open_with_limits product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 606 621 16 PersistentCredentialStore::mutate product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 623 625 3 PersistentCredentialStore::total_bytes product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 640 652 13 PersistentCredentialStore::retained_commitment product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 661 672 12 PersistentCredentialStore::stored_generations product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 688 702 15 PersistentCredentialStore::lease_for_record product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 710 719 10 PersistentCredentialStore::holds_record_credential product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 739 775 37 PersistentCredentialStore::store_confirmed product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 791 796 6 PersistentCredentialStore::revoke_connection product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 813 838 26 PersistentCredentialStore::retain_generations product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 840 861 22 PersistentCredentialStore::delete_generations product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 866 876 11 retained_entry product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 884 904 21 binding_entry product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 908 931 24 record_entry product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 933 941 9 connection_generations product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 945 973 29 PersistentCredentialStore::install product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 975 989 15 PersistentCredentialStore::lease_secret product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 991 1035 45 PersistentCredentialStore::replace product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1037 1050 13 PersistentCredentialStore::revoke product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1052 1058 7 PersistentCredentialStore::holds product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1060 1072 13 PersistentCredentialStore::confirm product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1074 1087 14 PersistentCredentialStore::retire_superseded product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1089 1095 7 PersistentCredentialStore::delete_connection product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1098 1111 14 validate_parent product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1113 1128 16 validate_secret_file product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1130 1161 32 persist_entries product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1166 1177 12 encode_persistent_entries product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1179 1201 23 write_persistent_entries product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1209 1212 4 EncodedLength::write_all product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1215 1309 95 decode_persistent_entries product/runtime/auths-connections decision 0 0 0 0 0 0 -product/runtime/auths-connections/src/credential.rs 1340 1351 12 credential_commitment product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 320 325 6 StoredSecret::holds product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 334 343 10 InMemoryCredentialStore::new product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 345 347 3 InMemoryCredentialStore::total_bytes product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 352 382 31 InMemoryCredentialStore::install product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 384 398 15 InMemoryCredentialStore::lease_secret product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 400 441 42 InMemoryCredentialStore::replace product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 443 455 13 InMemoryCredentialStore::revoke product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 457 463 7 InMemoryCredentialStore::holds product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 465 503 39 InMemoryCredentialStore::confirm product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 505 520 16 InMemoryCredentialStore::retire_superseded product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 522 534 13 InMemoryCredentialStore::delete_connection product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 560 566 7 PersistentCredentialStore::open product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 574 603 30 PersistentCredentialStore::open_with_limits product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 605 620 16 PersistentCredentialStore::mutate product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 622 624 3 PersistentCredentialStore::total_bytes product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 639 651 13 PersistentCredentialStore::retained_commitment product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 660 671 12 PersistentCredentialStore::stored_generations product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 687 701 15 PersistentCredentialStore::lease_for_record product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 709 718 10 PersistentCredentialStore::holds_record_credential product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 738 774 37 PersistentCredentialStore::store_confirmed product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 790 795 6 PersistentCredentialStore::revoke_connection product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 812 837 26 PersistentCredentialStore::retain_generations product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 839 860 22 PersistentCredentialStore::delete_generations product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 865 875 11 retained_entry product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 883 903 21 binding_entry product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 907 930 24 record_entry product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 932 940 9 connection_generations product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 944 972 29 PersistentCredentialStore::install product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 974 988 15 PersistentCredentialStore::lease_secret product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 990 1034 45 PersistentCredentialStore::replace product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1036 1047 11 PersistentCredentialStore::revoke product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1049 1055 7 PersistentCredentialStore::holds product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1057 1069 13 PersistentCredentialStore::confirm product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1071 1084 14 PersistentCredentialStore::retire_superseded product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1086 1092 7 PersistentCredentialStore::delete_connection product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1095 1108 14 validate_parent product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1110 1125 16 validate_secret_file product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1127 1158 32 persist_entries product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1163 1174 12 encode_persistent_entries product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1176 1198 23 write_persistent_entries product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1206 1209 4 EncodedLength::write_all product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1212 1306 95 decode_persistent_entries product/runtime/auths-connections decision 0 0 0 0 0 0 +product/runtime/auths-connections/src/credential.rs 1337 1348 12 credential_commitment product/runtime/auths-connections decision 0 0 0 0 0 0 product/runtime/auths-connections/src/kernel.rs 29 31 3 next_generation product/runtime/auths-connections decision 0 0 1 1 1 1 product/runtime/auths-connections/src/kernel.rs 36 44 9 state_change product/runtime/auths-connections decision 0 0 1 1 1 1 product/runtime/auths-connections/src/kernel.rs 49 57 9 rotation product/runtime/auths-connections decision 0 0 1 1 1 1 diff --git a/formal/proof-coverage-v1.json b/formal/proof-coverage-v1.json index cb9799148..49af70735 100644 --- a/formal/proof-coverage-v1.json +++ b/formal/proof-coverage-v1.json @@ -16,7 +16,7 @@ "translation_toolchain_lock_sha256": "0c45a8a08ac06e313d775e669749097bd3a0abfb8e3395c69416ba0238770d53" }, "scope_definition_sha256": "053f0be216b0003768d422d9ccc92a692726488874284fa80615657b30a495b5", - "inputs_sha256": "aa2ed884c2a3f36b66103057966c4d6ec0610647696c4f8b163225bba4026b76", + "inputs_sha256": "56f5f8df74c31a2554725ec967451d36bfa6e7c4a49004b3c83bb5cd32c35f52", "inputs": { "Cargo.toml": "sha256:6e0240aa432bcdb5b4f855618994582d2e428afe5ee4e4f6a3f8d21a518f8d24", "core/crates/auths-algebra-kernel/Cargo.toml": "sha256:0cb078915287b8c47d20fb97b6fa14135ed1c61c4985c4de2753855563c27bba", @@ -84,7 +84,7 @@ "product/policy/auths-bounded-policy/src/lib.rs": "sha256:76a18638b810e62e594d6e951504679305ce21841388e0c282ad51cd7841b91c", "product/policy/auths-bounded-policy/src/receipt.rs": "sha256:3b9eae82f349956d3e7f29b57b254bff5ce67cc703e6cf43445ce2827676d1ad", "product/policy/auths-bounded-policy/src/registry.rs": "sha256:4e251ea5833559841f8ee0bd9b66c0c01c39fa5e3a21720a19c4e8adbfcb99d0", - "product/runtime/auths-connections/src/credential.rs": "sha256:4d532c221d2fe7a2b47c8720be94c26d0c86a0448c3f66f479ce3b12adf44f25", + "product/runtime/auths-connections/src/credential.rs": "sha256:82921413bb053f0f6c7931fbd4e6a86c8cc1d8c6b51debd9c2cef33a8d5b1c51", "product/runtime/auths-connections/src/kernel.rs": "sha256:a456af19526ef7b19278551fd8d97606b6e7aa5a195e70bdd85d1e0786090ce8", "product/runtime/auths-connections/src/lib.rs": "sha256:79209249a0acba0e854d9937255ea0e30a9a6f8e1c2baad77ab83646d392de22", "product/runtime/auths-connections/src/model.rs": "sha256:de34a089cac9702acb889b7602b3e4732306c9e61029ec892582b7e02b73fc77", @@ -246,8 +246,8 @@ "scope": "translated-crates", "minimum_code_lines": 0, "functions": 1138, - "span_lines": 10685, - "code_lines": 10666, + "span_lines": 10681, + "code_lines": 10662, "by_level": { "translated": { "functions": 210, @@ -255,7 +255,7 @@ "span_lines": 2519, "span_line_share": 0.2358, "code_lines": 2508, - "code_line_share": 0.2351 + "code_line_share": 0.2352 }, "direct": { "functions": 70, @@ -271,7 +271,7 @@ "span_lines": 2519, "span_line_share": 0.2358, "code_lines": 2508, - "code_line_share": 0.2351 + "code_line_share": 0.2352 } } }, @@ -549,7 +549,7 @@ }, "product/runtime/auths-connections": { "decision_functions": 153, - "decision_code_lines": 1891, + "decision_code_lines": 1887, "translated": 5, "refined_closure": 5, "refined_closure_code_lines": 51 diff --git a/formal/qualification/aeneas/source-closure.json b/formal/qualification/aeneas/source-closure.json index 5516e2872..029640dd5 100644 --- a/formal/qualification/aeneas/source-closure.json +++ b/formal/qualification/aeneas/source-closure.json @@ -1,6 +1,6 @@ { "schema": "auths-proof-translation-source-closure/v2", - "digest": "2bca9ace40adf13142803e2d37730f8bb9b4dd622a537ab2e0eb28779561952a", + "digest": "f3b8cc9336cc95cfabab6629e583f58717601435d678375a62416160a2efb461", "files": [ { "path": ".cargo/config.toml", @@ -157,7 +157,7 @@ }, { "path": "product/runtime/auths-connections/src/credential.rs", - "sha256": "4d532c221d2fe7a2b47c8720be94c26d0c86a0448c3f66f479ce3b12adf44f25" + "sha256": "82921413bb053f0f6c7931fbd4e6a86c8cc1d8c6b51debd9c2cef33a8d5b1c51" }, { "path": "product/runtime/auths-connections/src/kernel.rs", @@ -273,7 +273,7 @@ }, { "path": "xtask/ci-plan/src/lib.rs", - "sha256": "b1cc615464d99787ff3259678f98a7644ee3715ce20c6bd308f391c4822dd7cb" + "sha256": "aeae292998e347454a2396933d4e4968460c70c165f88b7a6586985582a0463c" }, { "path": "xtask/ci-plan/src/main.rs", diff --git a/product/integrations/auths-credentials-aws-secrets-manager/src/api.rs b/product/integrations/auths-credentials-aws-secrets-manager/src/api.rs index 196c5b612..fb1830716 100644 --- a/product/integrations/auths-credentials-aws-secrets-manager/src/api.rs +++ b/product/integrations/auths-credentials-aws-secrets-manager/src/api.rs @@ -53,3 +53,154 @@ pub trait SecretsApi: Send + Sync { /// Deletes the secret named `name` at once, with no recovery window. async fn delete(&self, name: &str, deadline: Instant) -> Result<(), SecretsApiError>; } + +/// An operator process's separate read and write workload identities. +/// Reads use only `reader`; creates and exact deletes use only `writer`. +/// No failure falls back to the other identity or changes the request. +pub struct AdministrativeSecretsApi { + reader: R, + writer: W, +} + +impl AdministrativeSecretsApi { + /// Combines independently configured clients for one reviewed deployment. + #[must_use] + pub const fn new(reader: R, writer: W) -> Self { + Self { reader, writer } + } +} + +#[async_trait] +impl SecretsApi for AdministrativeSecretsApi { + async fn create( + &self, + name: &str, + version: &str, + secret: &[u8], + deadline: Instant, + ) -> Result<(), SecretsApiError> { + self.writer.create(name, version, secret, deadline).await + } + + async fn get( + &self, + name: &str, + version: &str, + deadline: Instant, + ) -> Result { + self.reader.get(name, version, deadline).await + } + + async fn delete(&self, name: &str, deadline: Instant) -> Result<(), SecretsApiError> { + self.writer.delete(name, deadline).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + enum Role { + Reader, + Writer, + } + + struct RoleClient { + role: Role, + unavailable: bool, + } + + #[async_trait] + impl SecretsApi for RoleClient { + async fn create( + &self, + name: &str, + version: &str, + secret: &[u8], + deadline: Instant, + ) -> Result<(), SecretsApiError> { + assert_eq!( + (name, version, secret), + ("exact-name", "exact-version", b"synthetic-value".as_slice()) + ); + assert!(deadline > Instant::now()); + assert!(matches!(self.role, Role::Writer), "reader cannot create"); + if self.unavailable { + Err(SecretsApiError::Unavailable) + } else { + Ok(()) + } + } + + async fn get( + &self, + name: &str, + version: &str, + deadline: Instant, + ) -> Result { + assert_eq!((name, version), ("exact-name", "exact-version")); + assert!(deadline > Instant::now()); + assert!(matches!(self.role, Role::Reader), "writer cannot read"); + if self.unavailable { + Err(SecretsApiError::Unavailable) + } else { + Ok(FetchedSecret { + version: version.to_owned(), + bytes: Zeroizing::new(b"synthetic-value".to_vec()), + }) + } + } + + async fn delete(&self, name: &str, deadline: Instant) -> Result<(), SecretsApiError> { + assert_eq!(name, "exact-name"); + assert!(deadline > Instant::now()); + assert!(matches!(self.role, Role::Writer), "reader cannot delete"); + if self.unavailable { + Err(SecretsApiError::Unavailable) + } else { + Ok(()) + } + } + } + + #[tokio::test] + async fn operator_administration_uses_separate_permissions_without_fallback() { + for reader_failed in [false, true] { + for writer_failed in [false, true] { + let api = AdministrativeSecretsApi::new( + RoleClient { + role: Role::Reader, + unavailable: reader_failed, + }, + RoleClient { + role: Role::Writer, + unavailable: writer_failed, + }, + ); + let deadline = Instant::now() + std::time::Duration::from_secs(5); + let write = if writer_failed { + Err(SecretsApiError::Unavailable) + } else { + Ok(()) + }; + assert_eq!( + api.create("exact-name", "exact-version", b"synthetic-value", deadline) + .await, + write + ); + assert_eq!(api.delete("exact-name", deadline).await, write); + match api.get("exact-name", "exact-version", deadline).await { + Ok(secret) => { + assert!(!reader_failed); + assert_eq!(secret.version, "exact-version"); + assert_eq!(&*secret.bytes, b"synthetic-value"); + } + Err(error) => { + assert!(reader_failed); + assert_eq!(error, SecretsApiError::Unavailable); + } + } + } + } + } +} diff --git a/product/integrations/auths-credentials-aws-secrets-manager/src/lib.rs b/product/integrations/auths-credentials-aws-secrets-manager/src/lib.rs index e35d8052c..57945cc15 100644 --- a/product/integrations/auths-credentials-aws-secrets-manager/src/lib.rs +++ b/product/integrations/auths-credentials-aws-secrets-manager/src/lib.rs @@ -25,7 +25,7 @@ mod names; mod sigv4; mod store; -pub use api::{FetchedSecret, SecretsApi, SecretsApiError}; +pub use api::{AdministrativeSecretsApi, FetchedSecret, SecretsApi, SecretsApiError}; pub use http::{HttpSecretsApi, InvalidDeployment, Region}; pub use identity::{ ContainerEndpoint, InstanceMetadata, SessionCredentials, WebIdentity, WorkloadIdentity, diff --git a/product/integrations/auths-credentials-aws-secrets-manager/src/store.rs b/product/integrations/auths-credentials-aws-secrets-manager/src/store.rs index 725ea506e..c97640677 100644 --- a/product/integrations/auths-credentials-aws-secrets-manager/src/store.rs +++ b/product/integrations/auths-credentials-aws-secrets-manager/src/store.rs @@ -123,10 +123,14 @@ impl ConnectionCredentialStore for AwsSecretsManagerStore { generation: NonZeroU64, ) -> Result<(), CredentialStoreError> { let name = secret_name(&self.namespace, connection_id, generation); - self.api + match self + .api .delete(&name, Instant::now() + ADMINISTRATION_DEADLINE) .await - .map_err(|_| CredentialStoreError::Unavailable) + { + Ok(()) | Err(SecretsApiError::NotFound) => Ok(()), + Err(_) => Err(CredentialStoreError::Unavailable), + } } async fn holds(&self, binding: &CredentialBinding) -> Result<(), CredentialStoreError> { @@ -478,6 +482,16 @@ mod tests { ); } + #[test] + fn deleting_an_exact_generation_is_idempotent_for_cleanup_retries() { + let double = Double::new(); + let store = store(&double); + ready(store.install(&connection(), generation(1), secret(b"first"))).expect("install"); + assert_eq!(ready(store.revoke(&connection(), generation(1))), Ok(())); + assert_eq!(ready(store.revoke(&connection(), generation(1))), Ok(())); + assert_eq!(ready(store.revoke(&connection(), generation(2))), Ok(())); + } + #[test] fn confirming_checks_and_stores_nothing() { let double = Double::new(); diff --git a/product/integrations/auths-credentials-aws-secrets-manager/tests/live.rs b/product/integrations/auths-credentials-aws-secrets-manager/tests/live.rs index fdc93f8f1..62ef25f30 100644 --- a/product/integrations/auths-credentials-aws-secrets-manager/tests/live.rs +++ b/product/integrations/auths-credentials-aws-secrets-manager/tests/live.rs @@ -12,8 +12,8 @@ use auths_connections::{ CredentialStoreError, ProviderKind, SecretBytes, SemanticId, }; use auths_credentials_aws_secrets_manager::{ - AwsSecretsManagerStore, DeploymentNamespace, HttpSecretsApi, Region, WebIdentity, - WorkloadIdentity as _, + AdministrativeSecretsApi, AwsSecretsManagerStore, DeploymentNamespace, HttpSecretsApi, Region, + WebIdentity, WorkloadIdentity as _, }; use std::num::NonZeroU64; use std::time::{Duration, Instant}; @@ -22,7 +22,7 @@ fn setting(name: &str) -> String { std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set")) } -fn store(role: &str) -> AwsSecretsManagerStore> { +fn api(role: &str) -> HttpSecretsApi { let region = Region::parse(setting("AUTHS_CUSTODY_LIVE_REGION")).expect("region"); let identity = WebIdentity::new( ®ion, @@ -31,10 +31,13 @@ fn store(role: &str) -> AwsSecretsManagerStore> { ) .expect("identity"); let key = std::env::var("AUTHS_CUSTODY_LIVE_KMS_KEY").ok(); - let api = HttpSecretsApi::new(region, key, identity).expect("client"); + HttpSecretsApi::new(region, key, identity).expect("client") +} + +fn store(role: &str) -> AwsSecretsManagerStore> { let namespace = DeploymentNamespace::parse(setting("AUTHS_CUSTODY_LIVE_NAMESPACE")).expect("namespace"); - AwsSecretsManagerStore::new(api, namespace) + AwsSecretsManagerStore::new(api(role), namespace) } fn generation(value: u64) -> NonZeroU64 { @@ -119,6 +122,13 @@ async fn the_store_holds_its_contract_against_the_service() { } let operator = store("AUTHS_CUSTODY_LIVE_OPERATOR_ROLE"); let runtime = store("AUTHS_CUSTODY_LIVE_RUNTIME_ROLE"); + let administrative = AwsSecretsManagerStore::new( + AdministrativeSecretsApi::new( + api("AUTHS_CUSTODY_LIVE_RUNTIME_ROLE"), + api("AUTHS_CUSTODY_LIVE_OPERATOR_ROLE"), + ), + DeploymentNamespace::parse(setting("AUTHS_CUSTODY_LIVE_NAMESPACE")).expect("namespace"), + ); let connection = ConnectionId::generate().expect("connection"); // Generation 9 is the write the runtime role must be refused; it is // listed so that a role that was wrongly allowed leaves nothing behind. @@ -143,6 +153,7 @@ async fn the_store_holds_its_contract_against_the_service() { b"auths-live-first-not-a-secret" ); runtime.holds(&serving).await?; + administrative.holds(&serving).await?; // Another commitment names another version, which does not exist. let other = CredentialReferenceCommitment::of(&connection, generation(1), b"other"); @@ -181,7 +192,7 @@ async fn the_store_holds_its_contract_against_the_service() { // Rotation: the old generation is kept until it is revoked, and a // revoked generation is never answered by its successor. - let second = operator + let second = administrative .replace( &connection, generation(1), @@ -191,6 +202,7 @@ async fn the_store_holds_its_contract_against_the_service() { .await?; let old = binding(&connection, 1, 1, first); let new = binding(&connection, 2, 2, second); + administrative.holds(&new).await?; assert_eq!( leased(&runtime, &old).await?, b"auths-live-first-not-a-secret" @@ -199,7 +211,7 @@ async fn the_store_holds_its_contract_against_the_service() { leased(&runtime, &new).await?, b"auths-live-second-not-a-secret" ); - operator.revoke(&connection, generation(1)).await?; + administrative.revoke(&connection, generation(1)).await?; assert_eq!( leased(&runtime, &old).await, Err(CredentialStoreError::Unavailable) diff --git a/product/runtime/auths-connections/src/credential.rs b/product/runtime/auths-connections/src/credential.rs index 1c93f8114..744c4bc84 100644 --- a/product/runtime/auths-connections/src/credential.rs +++ b/product/runtime/auths-connections/src/credential.rs @@ -248,7 +248,8 @@ pub trait ConnectionCredentialStore: Send + Sync { secret: SecretBytes, ) -> Result; - /// Revokes one exact generation. + /// Revokes one exact generation. An already absent generation succeeds, + /// so durable cleanup obligations can be retried after a lost response. async fn revoke( &self, connection_id: &ConnectionId, @@ -449,9 +450,7 @@ impl ConnectionCredentialStore for InMemoryCredentialStore { .entries .write() .map_err(|_| CredentialStoreError::Unavailable)?; - entries - .remove(&key) - .ok_or(CredentialStoreError::Unavailable)?; + entries.remove(&key); Ok(()) } @@ -1042,9 +1041,7 @@ impl ConnectionCredentialStore for PersistentCredentialStore { let key = (connection_id.as_str().to_owned(), generation.get()); self.mutate(|entries| { // Dropping the removed entry zeroizes its secret. - entries - .remove(&key) - .ok_or(CredentialStoreError::Unavailable)?; + entries.remove(&key); Ok(()) }) } @@ -1440,6 +1437,51 @@ mod tests { ); } + #[test] + fn exact_revocation_can_be_retried_after_a_lost_cleanup_response() { + let directory = tempfile::tempdir().expect("directory"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)) + .expect("private directory"); + } + let memory = InMemoryCredentialStore::new(4, 1024).expect("memory"); + let file = PersistentCredentialStore::open_with_limits( + directory.path().join("credentials.cbor"), + 4, + 1024, + ) + .expect("file"); + let record = record(); + for store in [ + &memory as &dyn ConnectionCredentialStore, + &file as &dyn ConnectionCredentialStore, + ] { + futures_lite_for_tests(store.install( + record.connection_id(), + NonZeroU64::MIN, + SecretBytes::new(b"cleanup-fixture".to_vec()).expect("secret"), + )) + .expect("install"); + assert_eq!( + futures_lite_for_tests(store.revoke(record.connection_id(), NonZeroU64::MIN)), + Ok(()) + ); + assert_eq!( + futures_lite_for_tests(store.revoke(record.connection_id(), NonZeroU64::MIN)), + Ok(()) + ); + assert_eq!( + futures_lite_for_tests(store.revoke( + record.connection_id(), + NonZeroU64::new(2).expect("generation") + )), + Ok(()) + ); + } + } + #[test] fn persistent_store_reopens_exact_generation_without_exposing_secret() { let directory = tempfile::tempdir().unwrap(); diff --git a/product/runtime/auths-gateway/semantic-closure.json b/product/runtime/auths-gateway/semantic-closure.json index 30d891a49..560b8240e 100644 --- a/product/runtime/auths-gateway/semantic-closure.json +++ b/product/runtime/auths-gateway/semantic-closure.json @@ -1 +1 @@ -{"files":[{"path":"Cargo.lock","sha256":"c94ed342774d5e2823f01ecf74b8a4812c5f9a6b0cf811d31f2e2cecb671a275"},{"path":"Cargo.toml","sha256":"6e0240aa432bcdb5b4f855618994582d2e428afe5ee4e4f6a3f8d21a518f8d24"},{"path":"core/adapters/auths-did-keri/Cargo.toml","sha256":"86ba3e3f9a0a70d970568bfb3113b7ba7bbdd81c3eebbb52743dd574a0c0ecaa"},{"path":"core/adapters/auths-did-keri/src/lib.rs","sha256":"a75674842f8a7142004369ccd206ab43e476459293f0ff4bed4d4e659031b3e3"},{"path":"core/adapters/auths-did-key/Cargo.toml","sha256":"d5a3ae07ecde83f91eed4c3785051dd0f44eab6c891754f3309b6b40596707d7"},{"path":"core/adapters/auths-did-key/src/lib.rs","sha256":"b76a3f4528a725df53cfa9242e6e414e1d26caa48468aaed497e28b121f4497c"},{"path":"core/adapters/auths-raw-key/Cargo.toml","sha256":"f49616bc18c19e6aaa7e6a02ccebcdb9f5c1422c1278ef9869e0dbf22ceda7be"},{"path":"core/adapters/auths-raw-key/src/lib.rs","sha256":"45eec1b295dac3bed65cd79c942870b926b06b3f5d38eaebaeb6140ac69315fa"},{"path":"core/crates/auths-algebra-kernel/Cargo.toml","sha256":"0cb078915287b8c47d20fb97b6fa14135ed1c61c4985c4de2753855563c27bba"},{"path":"core/crates/auths-algebra-kernel/src/generated.rs","sha256":"ae4bcbef42a408c7ffa0c6a74a42f7de177a6f14479e51d205acf830bda05960"},{"path":"core/crates/auths-algebra-kernel/src/lib.rs","sha256":"6654667eeb9b353d6648954c9fd2e8838462a82ce6b2803eb49e1366892c68c6"},{"path":"core/crates/auths-assurance/Cargo.toml","sha256":"afcb12a97ddd09d7a264b9ecd3fcaefc865ff2c9b4539ee85b00883420ba333a"},{"path":"core/crates/auths-assurance/src/lib.rs","sha256":"0fb2ec844d674b25a9e20b1601452c89c2454901fa506adfa9bb38ef9a7fa213"},{"path":"core/crates/auths-author/Cargo.toml","sha256":"1e6f0e3bc079ddb558decf0db26b291c6e72fc47327b6df1b7650d41689b215f"},{"path":"core/crates/auths-author/src/lib.rs","sha256":"e14475f2f49715a1869d5fa6b11ff1fbb72497e39097e09f833b0bae7e5b3f2b"},{"path":"core/crates/auths-author/src/observations.rs","sha256":"dda7d6bfa6f4bd7e95a480ca283c2c0e996f69c5c15cb0c7197af328131a7066"},{"path":"core/crates/auths-authority/Cargo.toml","sha256":"4f68212938029f79ebd857b4f3038494eb85d0163d49f07e51376715cf959815"},{"path":"core/crates/auths-authority/src/lib.rs","sha256":"da88d5194de4e17449e69972c0fb042afa3c39798a8f570f30771e77c384f1eb"},{"path":"core/crates/auths-codec/Cargo.toml","sha256":"e20e51826e17814effd9d8925d18d5fbdd6c0d37bf50231e743ec2611314722c"},{"path":"core/crates/auths-codec/src/approval.rs","sha256":"fbff43bf4d57f69f8b7738a8647751cde75c5a932dee65d924a1fe9c7202f91f"},{"path":"core/crates/auths-codec/src/bounded_policy.rs","sha256":"d21f6728be2d8a04b6d94921f2b683f7e61cbb42e0ac6866f1c88a49caa534c3"},{"path":"core/crates/auths-codec/src/decode.rs","sha256":"ba2999584cdf53b1b517ecb9c7ea50d39efee17c666a9a38e2ad1ac94379f778"},{"path":"core/crates/auths-codec/src/encode.rs","sha256":"8491192053e2e15d62b4d2a491de22405b7487ead69ab6f6bca40669ce7ca5f7"},{"path":"core/crates/auths-codec/src/error.rs","sha256":"fecb0737c16deb42d859f1dc7727c698438d9ac12064fd99e0024629642c390c"},{"path":"core/crates/auths-codec/src/hash.rs","sha256":"f88e111f1dbb2cef6b7b5e9c948cb45ff92e366710c8a5f5c636138aa748479e"},{"path":"core/crates/auths-codec/src/lib.rs","sha256":"3b0e5c94c6f34f750b88a9333e1d5c8145746d845d3a23e0d2f61807cf592ea7"},{"path":"core/crates/auths-codec/src/observation.rs","sha256":"38328b0c71ff97a4196eeade8b4faa889527ff1f80bb168468bcd49082a3987e"},{"path":"core/crates/auths-composition/Cargo.toml","sha256":"331e13025eb82588b5269ed3975929eb41c823d588c49e4c296273d85c6fe80e"},{"path":"core/crates/auths-composition/src/lib.rs","sha256":"e42e3b0b6a9985b73c533aeb23799c638979aa27a9ce7d0f10a8d60340570fb1"},{"path":"core/crates/auths-model/Cargo.toml","sha256":"565c15302b0cc9270db1e28efa3862804846850ba933cc17aea8abcec4a65987"},{"path":"core/crates/auths-model/src/approval.rs","sha256":"54cf44a9ef747ec21546a4086aecfb130bc7803b0d646430e6082df51e6e305f"},{"path":"core/crates/auths-model/src/bounded.rs","sha256":"f90496abc0a06c028b4d850af6ddab7d850bfafeaa3d042f1b1246a41d8bbbc9"},{"path":"core/crates/auths-model/src/bounded_policy.rs","sha256":"306a8f049cc7cfc7635fe0e4de47eb7813eed63d3c14c3be2b51586aa4eea450"},{"path":"core/crates/auths-model/src/lib.rs","sha256":"e7cfe6bb9574c6b016aa63e70658c31e0b90903ec6479a49f4a2ceb64f5bad97"},{"path":"core/crates/auths-model/src/observation.rs","sha256":"b3ed3e3cc090afca2896a7d3de7988afccc1ee1ea0ebd91b51a8bc0f20ad8dbb"},{"path":"core/crates/auths-multikey/Cargo.toml","sha256":"858756c1197befcada06134742bffe8d58cf674cadd6c046bf710570e96efaf2"},{"path":"core/crates/auths-multikey/src/lib.rs","sha256":"f5177fffb11e40eeaaa665ba886c541fc1f24d5eacdd6983fb03dc20148733f0"},{"path":"core/crates/auths-ports/Cargo.toml","sha256":"596a9b0f5af3717c16a6d13f7054d8a106bcd9f01f5f72c2235dd2afcf0ab16c"},{"path":"core/crates/auths-ports/src/binding.rs","sha256":"a16c012d40aa4249e3ec54372b9611006d0bfe8c7dc3c741911c2194ad17736d"},{"path":"core/crates/auths-ports/src/diagnostics.rs","sha256":"aa596572503b5a65a918bb2ff38281c144ff53821ee914e9325c852828d852df"},{"path":"core/crates/auths-ports/src/lib.rs","sha256":"8da1debf86f9532df0660155d04298beb1ba3a1204508f8586e2e67cd2fb3449"},{"path":"core/crates/auths-ports/src/path.rs","sha256":"b68fc547b4e4d97a60cbbc1cc1939fc6b34a8233b81f840bd396914a707e3ea1"},{"path":"core/crates/auths-raw-key-core/Cargo.toml","sha256":"98c0bc8527a537512e43a74dd6ba9658c6f0cdc16610cf938e3f9b43785d836d"},{"path":"core/crates/auths-raw-key-core/src/lib.rs","sha256":"98c0500c41697aae28708ad0bf970b9a97583d96a3d2c18d23e7fa351e0f7d12"},{"path":"core/crates/auths-registries/Cargo.toml","sha256":"590e49e58b0622d9d6274fea46796dcdb07dec9550eff295cce6a6427ffa51e2"},{"path":"core/crates/auths-registries/src/lib.rs","sha256":"450d3886d85ee333b7e046317fb5b47cde62483d5f3e41d46265e2caf4c373ec"},{"path":"core/crates/auths-registries/src/template.rs","sha256":"1224039dd2b3ce6fa8357613d8cafbae40400493da7176da202b9ade62fa05e4"},{"path":"core/crates/auths-signature-core/Cargo.toml","sha256":"6bee2230eb30af1be0bd5c2d57bd3e1903497a70028d47290d4ac6e9e0e8bca0"},{"path":"core/crates/auths-signature-core/src/lib.rs","sha256":"6e3f9311ded5e49ea50d445eb21e6d8fa12442aa4362a73e0cb6fda15b7cd865"},{"path":"core/crates/auths-signature/Cargo.toml","sha256":"7f7b5682466ba439d8c59579022cb61068cad9fb4bd2d73e1cc21327c902b450"},{"path":"core/crates/auths-signature/src/lib.rs","sha256":"b3d9ac4cc3ba0797b9e1c09f2d6ac05089b25dac4e285a5af28c0165b33d925b"},{"path":"core/crates/auths-verifier/Cargo.toml","sha256":"c43358e06a33324b41a8b93e5cc7279c55f7d21f1f6cfaa539a3fc7b4c27a38f"},{"path":"core/crates/auths-verifier/src/approval.rs","sha256":"c3cae6a49e169051eb555bb092b1069a4d422516d6a2114623a89e5f31ff923f"},{"path":"core/crates/auths-verifier/src/causal.rs","sha256":"9635230f33b13a263573d319ac8a1976addbcc07677514a43e3c7bf28dc04587"},{"path":"core/crates/auths-verifier/src/lib.rs","sha256":"f22dec38dfa47ced8da4f605390da8453273bee1da010f57fd1fdfcb10e0b389"},{"path":"core/crates/auths-verifier/src/observation.rs","sha256":"0bb07f06a0f9524c88427df1c972b1de8ba038a9eeeb15d3134c25125ae84b1e"},{"path":"core/crates/auths-verifier/src/trace.rs","sha256":"2213d72023a1d9e0413a2cf2c51c3dad9e04775d86033a0caaa9d29a8f0036f8"},{"path":"exchange/crates/auths-proof-exchange-model/Cargo.toml","sha256":"34b2db64f93a288e942b5a3918d28cdcbef906e0dc9074ce848018d3d5f7a1fa"},{"path":"exchange/crates/auths-proof-exchange-model/src/lib.rs","sha256":"49fac5aff2339d3a57c41cb8bb2a82852538e8f77ecc4b157c88e264ff31fb55"},{"path":"exchange/crates/auths-proof-exchange-port/Cargo.toml","sha256":"7ff660f1b7579dc570c791090aeb450404fbbf44c92781e289f5ccdde746d739"},{"path":"exchange/crates/auths-proof-exchange-port/src/lib.rs","sha256":"b408fb40c6432edc1facb3d8b3e9433a033f8dc73fe2e0ba5a88c972ca675021"},{"path":"product/config/auths-config/Cargo.toml","sha256":"e4b568ab9f6d84f7d7e6d66443fe825f360db9c27bc7c31d6ddc0647ba3ab304"},{"path":"product/config/auths-config/src/lib.rs","sha256":"fecf00ea65cef9018fda9af2063326d7065b869266d0d9e73a4f06816f1102cc"},{"path":"product/errors/auths-errors/Cargo.toml","sha256":"3b3a199b6460ba75bda84475bb5accd35e1485448a3981038db85bf4772dd350"},{"path":"product/errors/auths-errors/src/lib.rs","sha256":"7feeb9069fa4003f94f1f07c1c94619cf1c883e0dbaaf2187f04d0f742264bdd"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/Cargo.toml","sha256":"716f1f2a117d6d69d8effe9ee9057e24ba3502f20af6a602b06facb638cdabed"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/api.rs","sha256":"b3c4321e0737835ecc4f17235d7589b7f3d62180954ebb98ea9449252300245a"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/clock.rs","sha256":"73f0a6e0f5903883329fa6683f5888288f759512ac69e5c87efd2b6b87f7d79f"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/http.rs","sha256":"14c1fce14e24c984b2a275563a29b6326fb08367922b6e8d37ba38b9cb81aa2b"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/identity.rs","sha256":"186de11230c8b33375951c4368609f8976bf9795f171af3fd7fac26207a94361"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/lib.rs","sha256":"881ad9ccca369b6eaa6bfd9ec03b1585bff07b042da38942ef57154839449d3e"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/names.rs","sha256":"41bb23572d6002e23ee103d6fe70ecd68764861c6f2e48a1c53af998270a4afa"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/sigv4.rs","sha256":"39fed883f4802e68d3b41a68fea0aba0737810996b33d13577de70faba004c29"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/store.rs","sha256":"32bb4b7ff3e7769d0ff46d374f80e4a2c44d7f8e8e82bbea58c9676690db45bb"},{"path":"product/integrations/auths-custody/Cargo.toml","sha256":"1e14034185ad1ec24a977fd5f41d0abee219606245c046ce2a82287fadef448b"},{"path":"product/integrations/auths-custody/src/conformance.rs","sha256":"8ed1afd58f18fc6becc268908aa6419c9aa404c79da0197963eb2599df44acf6"},{"path":"product/integrations/auths-custody/src/key.rs","sha256":"70cbb0fd21b6f5652157845a22c35be2d0f6c05b3d138fc4b9bb6dbdc3d81c40"},{"path":"product/integrations/auths-custody/src/lib.rs","sha256":"8ee593c1f59b0fd2126bdda9138026f5bc531beb64d4489a99785842e7b98b43"},{"path":"product/operations/auths-operations/Cargo.toml","sha256":"73dde1e914ba5e1386f8d49c0d730423cc72ae77ec44c6554a6a75cc8e8922b1"},{"path":"product/operations/auths-operations/src/explanation.rs","sha256":"b4a39bcbd5712cb9e735ee7deeb281f3fa6af5ddca973735bf3d87dce427ef11"},{"path":"product/operations/auths-operations/src/lib.rs","sha256":"3f401a56555113b98f57bca172a2660eaecedf76247b83a3fdbb089c133a29cd"},{"path":"product/operations/auths-operations/src/render/json.rs","sha256":"2ae042639960afee123730e945d373bc5dda1d2b3af20ae16c939a7e99b9e418"},{"path":"product/operations/auths-operations/src/render/mod.rs","sha256":"b3b7f6fe473f49227ace5a68bfc3d8dc14b5db8dd9888fd9e207070337b1b8bf"},{"path":"product/operations/auths-operations/src/render/text.rs","sha256":"f216cf3bdb00e264523271b16e7059535ac6b1aabf271cebd7935af155e4bfec"},{"path":"product/policy/auths-bounded-policy/Cargo.toml","sha256":"babd5d200923137ead71d8c2528f847cea323ce98da942a1320b054436612c47"},{"path":"product/policy/auths-bounded-policy/src/arithmetic.rs","sha256":"75e43c5d4eab8d1f71c1daf53839f5f9fe45a400afc1f63a30c6f55ce3632627"},{"path":"product/policy/auths-bounded-policy/src/commitment.rs","sha256":"c5706f2d583454e1a7f570f7ada138d4d864bef0205dfae7c1296774f396bb3f"},{"path":"product/policy/auths-bounded-policy/src/eligibility.rs","sha256":"c9acdfb793074e687fc36efad506be27dbd99ffbf40387bc66c8de39ef238432"},{"path":"product/policy/auths-bounded-policy/src/identifier.rs","sha256":"0006a32bae9eb16c4178cf797df418d2b0a781f2bc6474b741dc1a908141831c"},{"path":"product/policy/auths-bounded-policy/src/kernel.rs","sha256":"99187c28b161ac70bee1afed60711f7f645adb4d1671a01863f9849b369811d5"},{"path":"product/policy/auths-bounded-policy/src/lib.rs","sha256":"76a18638b810e62e594d6e951504679305ce21841388e0c282ad51cd7841b91c"},{"path":"product/policy/auths-bounded-policy/src/receipt.rs","sha256":"3b9eae82f349956d3e7f29b57b254bff5ce67cc703e6cf43445ce2827676d1ad"},{"path":"product/policy/auths-bounded-policy/src/registry.rs","sha256":"4e251ea5833559841f8ee0bd9b66c0c01c39fa5e3a21720a19c4e8adbfcb99d0"},{"path":"product/profiles/auths-profile-api/Cargo.toml","sha256":"7ae85761f58625b5d678f961fd052eebf3e000ef565c7312e3ac857763c0017f"},{"path":"product/profiles/auths-profile-api/src/lib.rs","sha256":"a194b82b20bf8aa864c2334ef6c6aec30b2154ded972862252d5dcefa488ab95"},{"path":"product/profiles/auths-profile-mcp/Cargo.toml","sha256":"db264fc25242f7b7e4133cbe5782545b3107b406c87168ae072683cfb59eac66"},{"path":"product/profiles/auths-profile-mcp/src/arguments.rs","sha256":"a5de696586d507e2aee4a49fee99ef1ef4c51a2e46603f8838f1af6d9009833f"},{"path":"product/profiles/auths-profile-mcp/src/lib.rs","sha256":"d0f8b733a09ef8408336cdebc8948d6a5861012ce6878598b11be7084dfd4280"},{"path":"product/profiles/auths-profile-mcp/src/session.rs","sha256":"166c281fa66333388e4760d6098dad02d3359e1d5de4b8f93312c014f97abb03"},{"path":"product/qualification/auths-recipe-qualification/Cargo.toml","sha256":"b7b15b30b9a3d96d4f089d514266d3a892de6608d30707aa44be31cca89ca9fe"},{"path":"product/qualification/auths-recipe-qualification/src/canonical.rs","sha256":"30a9b50777f0dcb4b606abfee2467d5a975d9f6ad941ab6c13ca3df5b359f667"},{"path":"product/qualification/auths-recipe-qualification/src/closure.rs","sha256":"0ad6aee9bd981e2863fa725f209ec84031db2ff12f4c7ac3aaf5120f0bb86039"},{"path":"product/qualification/auths-recipe-qualification/src/error.rs","sha256":"52d5cf720a1e4579008fdda90a3ac2c31f0b8db57985f121f65e54b4da2b7dd1"},{"path":"product/qualification/auths-recipe-qualification/src/evidence.rs","sha256":"1a4cb862f725b02f9efd827db99637ea8178a5236aa020ec271a363edbe7b362"},{"path":"product/qualification/auths-recipe-qualification/src/ids.rs","sha256":"e81a906d571497ab2da753e1532d4443706410e0b75fdd59a7d2d788c7aec403"},{"path":"product/qualification/auths-recipe-qualification/src/lib.rs","sha256":"195b19f46e1e94a0f3594ad5958e5715bc5e3ad415bdfe6428dfc1b6dd3f4631"},{"path":"product/qualification/auths-recipe-qualification/src/model.rs","sha256":"3484eb0ec287b23d4682ade7b1b0f8d1335985b3fb180f06fc28716076957d5f"},{"path":"product/qualification/auths-recipe-qualification/src/release.rs","sha256":"d816b53e2275e902c750ad03803061844e259e28560ab2d1c77e8a43981a4d6a"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/mod.rs","sha256":"7f15b68e05a04f202bcb1fb80de1d9a9a4fda8bfd6af2cd4e5c660fc75d96fe5"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/schemas.rs","sha256":"c59feec2805621f341a90f81fc005e2c12f7a70f9d264fdeacccb2be4fe1c046"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/verification.rs","sha256":"6a6b5834c5dae10af1ba8939b2deae43b85db71544dc8f077ff63b7fe68ac8a8"},{"path":"product/qualification/auths-recipe-qualification/src/verify.rs","sha256":"edd33589c54fb79bcfccc4cf4e6ccf95bbca088318bb7f8dfc27da684b4fa671"},{"path":"product/receipts/auths-receipts/Cargo.toml","sha256":"6001fa1375899953aafcb07c23a6da0c8f57ea7ec08bb9c950b12a60a8cc6f0f"},{"path":"product/receipts/auths-receipts/src/disclosure.rs","sha256":"ba9ed439be0a320c8a01f5b6e9b51361c1a37e076947490d8e4a484ba6bb119c"},{"path":"product/receipts/auths-receipts/src/lib.rs","sha256":"547a57c4160256719cafb7d0ab972d9fc80b0da14fbbce4148c70c414070ff36"},{"path":"product/runtime/auths-connections/Cargo.toml","sha256":"babeb6e1dda3dfb66291713ce1e43dce5c349833d0b50ea4dccbf986030026b2"},{"path":"product/runtime/auths-connections/src/credential.rs","sha256":"4d532c221d2fe7a2b47c8720be94c26d0c86a0448c3f66f479ce3b12adf44f25"},{"path":"product/runtime/auths-connections/src/kernel.rs","sha256":"a456af19526ef7b19278551fd8d97606b6e7aa5a195e70bdd85d1e0786090ce8"},{"path":"product/runtime/auths-connections/src/lib.rs","sha256":"79209249a0acba0e854d9937255ea0e30a9a6f8e1c2baad77ab83646d392de22"},{"path":"product/runtime/auths-connections/src/model.rs","sha256":"de34a089cac9702acb889b7602b3e4732306c9e61029ec892582b7e02b73fc77"},{"path":"product/runtime/auths-connections/src/registry.rs","sha256":"e57640e003e72ad3235c567c2bb944a262bd64e673f023b62ea091094cc7e946"},{"path":"product/runtime/auths-gateway-kernel/Cargo.toml","sha256":"34f864e67656af4ac7e5c7a347a23f01fc8ab31365b8e23c0ef474483b14a183"},{"path":"product/runtime/auths-gateway-kernel/src/construct.rs","sha256":"e9d65744bae273d65face4285e5d2da75c06a92ab48f7b8333d496604e31c63c"},{"path":"product/runtime/auths-gateway-kernel/src/lib.rs","sha256":"cfda6f8453426564c085475f183b6f790b438768a19668f1b65d33ba59dc8632"},{"path":"product/runtime/auths-gateway-kernel/src/order.rs","sha256":"e0045f877c4b7fe3860c291039853885064ddcdebba7c9b345ab65e81e646dda"},{"path":"product/runtime/auths-gateway-kernel/src/outcome.rs","sha256":"e9785b1afe70788c89b51b18655bcd5acff7b0c8e3d12fb79c6f05a202e67e6b"},{"path":"product/runtime/auths-gateway-kernel/src/ratio.rs","sha256":"7ace22264812e6fc1ef3a62afee402cf43494bb71ae0d3de219ad1b39d690bcb"},{"path":"product/runtime/auths-gateway-kernel/src/recovery.rs","sha256":"9f46dcd1685ab6e4d1ba04a3fc286a9f3afee8fb47829be1fbc670a7926a2dc6"},{"path":"product/runtime/auths-gateway-kernel/src/transition.rs","sha256":"cefb8f01d915880f6921f2501ff881ad94b9de61072c34d9cd19220992f97374"},{"path":"product/runtime/auths-gateway/Cargo.toml","sha256":"00b28be86921dcdc0ad90cce0c62c2961cafefe96ea6c2edf0e926c3d405ccad"},{"path":"product/runtime/auths-gateway/src/admin.rs","sha256":"2d5cc67afac42fbd15f36e2d1693cd1b20cedacf775040dce6ffe04ccc94c9ea"},{"path":"product/runtime/auths-gateway/src/app.rs","sha256":"6f80f63833ea0815bb44fbf3b0cc54624a08e9fb578b035819f2980dc04e8256"},{"path":"product/runtime/auths-gateway/src/audit.rs","sha256":"0edb2037eb7b5d6f13acfce85c54f62a82bf7fdd31bc7e626c4fec183cef61dd"},{"path":"product/runtime/auths-gateway/src/audit_tests.rs","sha256":"990969384a52d75bc2489d9d412a9e7dd814bca1c8f65c701d6205fae62a15d5"},{"path":"product/runtime/auths-gateway/src/bin/auths-gateway-harness.rs","sha256":"12b0c24d36b2e6a3be392d3c081a783b355fdd67dc5b54bacc0410e803116b8c"},{"path":"product/runtime/auths-gateway/src/bin/auths-gateway.rs","sha256":"112969ee1e833d979dc44cfb2f7fc203a495ddbc93913c3d0359f76162a97740"},{"path":"product/runtime/auths-gateway/src/binding.rs","sha256":"663b0ce878319511aa7d05722ab4ab2fa9be18817884ffb2ee9f131ff1e382d1"},{"path":"product/runtime/auths-gateway/src/bounds.rs","sha256":"9cfa0eae4973e5e047a198360b22f041708fa20276b0b90c2ec440f06915d64b"},{"path":"product/runtime/auths-gateway/src/bounds_aggregate_tests.rs","sha256":"ab34f227b7c8692ba1da88ccb32bb3687a66adf8353d8282fae905d4a88eb612"},{"path":"product/runtime/auths-gateway/src/bounds_tests.rs","sha256":"74136bc570da7b9338b5a1a1ed9d3fd892c55135160964f9d3ac0e2cf5741375"},{"path":"product/runtime/auths-gateway/src/connection.rs","sha256":"1f81f5b1305188176c38090c99b6f8f96e01fef80aa4461813be1c893d9caa8e"},{"path":"product/runtime/auths-gateway/src/echo_verify.rs","sha256":"6d453051665f823abd7354d5c50031d9524cf47d6092d2f09080ae814e3b9575"},{"path":"product/runtime/auths-gateway/src/engine.rs","sha256":"03d31841f05b957fa5530a135ab609673db38c14301f088938c4c1d94a78537d"},{"path":"product/runtime/auths-gateway/src/fuzzing.rs","sha256":"ed3b0028487eb546a3b0a91d115aa8f18bcea324b61620bfe8e35a9e6ab18758"},{"path":"product/runtime/auths-gateway/src/harness.rs","sha256":"c87e479f3eb281960f6c9c80d43c7560d7ccb654b0154ec823d3fcec4884ac05"},{"path":"product/runtime/auths-gateway/src/lib.rs","sha256":"25cd21174c49d811471f9b67dd2b5da0f1bed58ddf6f5799cfd9121821556875"},{"path":"product/runtime/auths-gateway/src/listener.rs","sha256":"aa08239bcbc1fcb55e8a212fbdf6a84f12985b0d436dcb51dc58b06e59e435f2"},{"path":"product/runtime/auths-gateway/src/observed_tests.rs","sha256":"a22be071695f89ca2578a4d82fd80d9cb82289213c9bdb8f0470f6b2dc3d9201"},{"path":"product/runtime/auths-gateway/src/observer.rs","sha256":"562ffcaea071c3856e0708dc2fe33516917e145d2438aabb75af0a2b6ba01046"},{"path":"product/runtime/auths-gateway/src/onboarding.rs","sha256":"0dfd7d67e7f02f18a28d1c86ca1b7751d7463f5efcdbe3825de0ad8cbccb3891"},{"path":"product/runtime/auths-gateway/src/operator.rs","sha256":"c3c36954a3e86723df035b47f43b85fc8ab3a6292983522b6258d5c6b4e6c4e4"},{"path":"product/runtime/auths-gateway/src/pending_vectors/attempts.rs","sha256":"47ef3c8cb1c5504d47ae9a0088705ce6715fe86bac1671a34a56fb38a64d0dd3"},{"path":"product/runtime/auths-gateway/src/pending_vectors/bounds.rs","sha256":"e612217307e1da5b0eb5ee6582a7458bb064b8aa8a529fc6555b1b334127ab68"},{"path":"product/runtime/auths-gateway/src/pending_vectors/codes.rs","sha256":"e00670a5ef24eb856cc2c6dcda8e5a263842f55c76bb939c0c27bd9df37e5522"},{"path":"product/runtime/auths-gateway/src/pending_vectors/keys.rs","sha256":"f84c56f2ea9cb34b2b79c72d13fbf2c6585010ec1ed450af58a78c80c1ea1edd"},{"path":"product/runtime/auths-gateway/src/pending_vectors/mod.rs","sha256":"5de8340f0ef71a0b57a09e323fefe473d3160c6ae952b9ba46f714825fbacafc"},{"path":"product/runtime/auths-gateway/src/pending_vectors/outcomes.rs","sha256":"8ec07b9511b48913af36ac999d2aa0e82924289f6acb23ddaeb9f00f99815166"},{"path":"product/runtime/auths-gateway/src/pending_vectors/production.rs","sha256":"c42c1a893ef6e74aef074f0644a8d3e3f9394258c0f3d689abd1f19aa5ddba61"},{"path":"product/runtime/auths-gateway/src/pending_vectors/recipes.rs","sha256":"51693208270231c25c3aef2a32151d5962114c71e86b88cb9df83181c7af83d9"},{"path":"product/runtime/auths-gateway/src/pre_entry.rs","sha256":"06ea78ca140468bfaba17d9ab04912ad68673954c3203c98fed28272ff2a00c0"},{"path":"product/runtime/auths-gateway/src/property_tests.rs","sha256":"939c081e2e154aaf10186754044bdd65608932c899b970a04a71d0afaa999a93"},{"path":"product/runtime/auths-gateway/src/qualification.rs","sha256":"a4398512ccb7bb494000fe38f03e0faa7b82d1f80ef5ee2dfa75698ae4f5a9be"},{"path":"product/runtime/auths-gateway/src/qualification_tests.rs","sha256":"4473add5729b97faf04d7be7fd8cf3bbacd7f545e1b61c7d2782563011906b4d"},{"path":"product/runtime/auths-gateway/src/quorum_tests.rs","sha256":"a78380b019104d688c11a4410082c7e08ded3106aab099954c4f8a1b4ae0fd89"},{"path":"product/runtime/auths-gateway/src/readiness.rs","sha256":"9e98b19d0c68e84c778bf5565bd1e37899bff95ff8d05d329cfb48f00b246dc7"},{"path":"product/runtime/auths-gateway/src/recipe.rs","sha256":"fecf9af67e7afc48d080e9f8f55f8ce28e49d0e08988717a27908bbda461d6b6"},{"path":"product/runtime/auths-gateway/src/recipe/lower.rs","sha256":"8088b8751ed831dbb48247af953c6290ac95e2a438ccd5bd65eff54be06a1fd1"},{"path":"product/runtime/auths-gateway/src/recipe/review.rs","sha256":"c3eb7d9fdd98dacf52750b14b67ad7b7faaecbbdeafff1e0b049f67d0a2d0139"},{"path":"product/runtime/auths-gateway/src/recipe/runtime.rs","sha256":"c286f0d15f974402822c0810d8e97f98411dc1c557755cc23e540d85e2bb42d9"},{"path":"product/runtime/auths-gateway/src/recipe/source.rs","sha256":"1af50298fb1461e1b24cbdf560d43124d6b1c4c529586c911b1c4797214a69ff"},{"path":"product/runtime/auths-gateway/src/recipe/tests.rs","sha256":"aac551a2481bbc60864c64079bc667dcb232e45a0f41a04073ed84ab5dbe1fce"},{"path":"product/runtime/auths-gateway/src/recipe/validate.rs","sha256":"3711b17605cb7b0d639bc1b180449231f5882a306cab20e2c7782155cc63b0d4"},{"path":"product/runtime/auths-gateway/src/scenario_tests.rs","sha256":"e342ade2cb08a686cb00c29a7d6153ae3d8c350d5a7d6a115311d96ecef97c35"},{"path":"product/runtime/auths-gateway/src/separation.rs","sha256":"be9306848ca5d801767cf6aa14247c8618312ef0e202f00ce0c24c4844491061"},{"path":"product/runtime/auths-gateway/src/store.rs","sha256":"786bc4b58391eb4330a3a6c4ba224f0b3c9ff3ad619cb7a27f2699c152f42b92"},{"path":"product/runtime/auths-gateway/src/store_testkit.rs","sha256":"88d82fee42f1b8ef521e8deef0216e938ebeaad822eb2f5020e70b89d145ea70"},{"path":"product/runtime/auths-gateway/src/submit.rs","sha256":"c78dbbba7c45b443febf655056c5edcd635e77be7b21c5b2c318097502415ffe"},{"path":"product/runtime/auths-gateway/src/transport.rs","sha256":"be2c6dc75b8daec110bf8749fedc3823f6067d40b999aa3eb21126896a97d3ad"},{"path":"product/runtime/auths-kernel-runtime/Cargo.toml","sha256":"926bcd0053e5edd703be8f0027ad009e2982db013c8174c1366edbf38b48087d"},{"path":"product/runtime/auths-kernel-runtime/src/lib.rs","sha256":"7108faae9ca4bc32dc093ddaf885ac8f74f3c9b8d317b7c7d0ed00d1d0ab5972"},{"path":"product/runtime/auths-lifecycle/Cargo.toml","sha256":"c5053c705f8d5554b44e432fe75287a2faf11d644e5a2552e45ad6cd94176af5"},{"path":"product/runtime/auths-lifecycle/src/codec.rs","sha256":"51c4b746749150e8bfcd8ff972b3f6c1ef40bec15583448ec1b471d9ad737dc0"},{"path":"product/runtime/auths-lifecycle/src/digest.rs","sha256":"55282cd7d8aba37713c2dcb5357c9877819edb72a30eadefb860d28d7d3a3eb7"},{"path":"product/runtime/auths-lifecycle/src/identifier.rs","sha256":"31620e8c16de68d3d2d3b84d6353c1a5b9d4151d8247f91fa389b906a8e660f1"},{"path":"product/runtime/auths-lifecycle/src/kernel.rs","sha256":"2abb8c094b36d35b364e74d78091d14c5d6f3d1eb6552cca3b5538dbd017ce23"},{"path":"product/runtime/auths-lifecycle/src/lib.rs","sha256":"afe5a345cd309b5a4f08ffba3170a6d7f16ff914892b3f2006d5208ed24ec736"},{"path":"product/runtime/auths-lifecycle/src/model.rs","sha256":"1af12bbeeeaa65ec02ddcebd7d2c8c92ec1e339fa40ae85b45ab9b01a722a4fa"},{"path":"product/runtime/auths-lifecycle/src/operation.rs","sha256":"78e6d05fcf5d7ea4f148032a3fa26fdfcab77eb346019effe927b08e0d64d732"},{"path":"product/runtime/auths-lifecycle/src/registry.rs","sha256":"53495fe6f7a77422593a41623e28716f1c140dcc1e00f87f6b0ffbfb93a5ef90"},{"path":"product/runtime/auths-lifecycle/src/sealed.rs","sha256":"2e0c03b5813c926c159598bb7890d1523825edbe57cd687d42a5dfb3374a0c19"},{"path":"product/runtime/auths-lifecycle/src/test_support.rs","sha256":"221d9a5c5ef8e1f9f8fd66927484eaf813571d0568470278ecd8fa08a5eec8cd"},{"path":"product/runtime/auths-lifecycle/src/transition.rs","sha256":"011805a28efdd541f44ccea54dd0e34a1e58f4004447ee58013703b4fb6b2822"},{"path":"product/runtime/auths-runtime/Cargo.toml","sha256":"dd29d80a8b4bb56c869f036a6c9998f6e6d1c0383ac5dc03fa42b24abe2a7ff7"},{"path":"product/runtime/auths-runtime/src/lib.rs","sha256":"132ca03d58c14905d3a822a4972f42a22a5ad33a45f2ba9ca44f3a57d810b4b0"},{"path":"product/runtime/auths-runtime/src/production.rs","sha256":"fd4eff4270c571c10fcff6cf27df28961ebf074de8a1a028f500fda1ca376820"},{"path":"product/sdk/auths-approval-quorum/Cargo.toml","sha256":"df904971fca87a24194c5816738a331588fe8f164acb5d70839a35dca5f57114"},{"path":"product/sdk/auths-approval-quorum/src/lib.rs","sha256":"72e4086088a1c0912e4e1a9e980f26a5c280bf5ab16ea39c9f0f58d5096ac2ce"},{"path":"product/sdk/auths-approval-quorum/src/remote.rs","sha256":"9fa029c7c63003adf260f8aec55160c545681c60c1bace0d934e7525862aac7b"},{"path":"product/sdk/auths-approval-quorum/src/remote_tests.rs","sha256":"8a09ce17cfee82a035a74edd535f3ebeb3db0e078784740d3997b17dbdb10197"},{"path":"product/sdk/auths-approval-quorum/src/tests.rs","sha256":"0acbf12be3e497dcc6c2d8f29c9af3b1a7ed8e11b75f6399570d4f217a785060"},{"path":"product/stores/auths-stores/Cargo.toml","sha256":"a32c4b76d17b264be5d40d24497ff237e1ed87822e8bab86229b3db93106dbe3"},{"path":"product/stores/auths-stores/src/gateway_attempt.rs","sha256":"7683966d54ceae62dcf68a6f3d4e5fc019ffd3b88216fd30cbcecf980e1944d5"},{"path":"product/stores/auths-stores/src/lib.rs","sha256":"e721262edf8a9ab5568ddc27639176312d4a366203b5f73e2d7c11fde2c554e9"},{"path":"product/stores/auths-stores/src/lifecycle.rs","sha256":"94f64d25bc178a9e60ba3c0ee6915d7fb3af7c4468792d76b4e70f43f4de1e2f"}],"schema":"auths.gateway-semantic-closure/1"} \ No newline at end of file +{"files":[{"path":"Cargo.lock","sha256":"c94ed342774d5e2823f01ecf74b8a4812c5f9a6b0cf811d31f2e2cecb671a275"},{"path":"Cargo.toml","sha256":"6e0240aa432bcdb5b4f855618994582d2e428afe5ee4e4f6a3f8d21a518f8d24"},{"path":"core/adapters/auths-did-keri/Cargo.toml","sha256":"86ba3e3f9a0a70d970568bfb3113b7ba7bbdd81c3eebbb52743dd574a0c0ecaa"},{"path":"core/adapters/auths-did-keri/src/lib.rs","sha256":"a75674842f8a7142004369ccd206ab43e476459293f0ff4bed4d4e659031b3e3"},{"path":"core/adapters/auths-did-key/Cargo.toml","sha256":"d5a3ae07ecde83f91eed4c3785051dd0f44eab6c891754f3309b6b40596707d7"},{"path":"core/adapters/auths-did-key/src/lib.rs","sha256":"b76a3f4528a725df53cfa9242e6e414e1d26caa48468aaed497e28b121f4497c"},{"path":"core/adapters/auths-raw-key/Cargo.toml","sha256":"f49616bc18c19e6aaa7e6a02ccebcdb9f5c1422c1278ef9869e0dbf22ceda7be"},{"path":"core/adapters/auths-raw-key/src/lib.rs","sha256":"45eec1b295dac3bed65cd79c942870b926b06b3f5d38eaebaeb6140ac69315fa"},{"path":"core/crates/auths-algebra-kernel/Cargo.toml","sha256":"0cb078915287b8c47d20fb97b6fa14135ed1c61c4985c4de2753855563c27bba"},{"path":"core/crates/auths-algebra-kernel/src/generated.rs","sha256":"ae4bcbef42a408c7ffa0c6a74a42f7de177a6f14479e51d205acf830bda05960"},{"path":"core/crates/auths-algebra-kernel/src/lib.rs","sha256":"6654667eeb9b353d6648954c9fd2e8838462a82ce6b2803eb49e1366892c68c6"},{"path":"core/crates/auths-assurance/Cargo.toml","sha256":"afcb12a97ddd09d7a264b9ecd3fcaefc865ff2c9b4539ee85b00883420ba333a"},{"path":"core/crates/auths-assurance/src/lib.rs","sha256":"0fb2ec844d674b25a9e20b1601452c89c2454901fa506adfa9bb38ef9a7fa213"},{"path":"core/crates/auths-author/Cargo.toml","sha256":"1e6f0e3bc079ddb558decf0db26b291c6e72fc47327b6df1b7650d41689b215f"},{"path":"core/crates/auths-author/src/lib.rs","sha256":"e14475f2f49715a1869d5fa6b11ff1fbb72497e39097e09f833b0bae7e5b3f2b"},{"path":"core/crates/auths-author/src/observations.rs","sha256":"dda7d6bfa6f4bd7e95a480ca283c2c0e996f69c5c15cb0c7197af328131a7066"},{"path":"core/crates/auths-authority/Cargo.toml","sha256":"4f68212938029f79ebd857b4f3038494eb85d0163d49f07e51376715cf959815"},{"path":"core/crates/auths-authority/src/lib.rs","sha256":"da88d5194de4e17449e69972c0fb042afa3c39798a8f570f30771e77c384f1eb"},{"path":"core/crates/auths-codec/Cargo.toml","sha256":"e20e51826e17814effd9d8925d18d5fbdd6c0d37bf50231e743ec2611314722c"},{"path":"core/crates/auths-codec/src/approval.rs","sha256":"fbff43bf4d57f69f8b7738a8647751cde75c5a932dee65d924a1fe9c7202f91f"},{"path":"core/crates/auths-codec/src/bounded_policy.rs","sha256":"d21f6728be2d8a04b6d94921f2b683f7e61cbb42e0ac6866f1c88a49caa534c3"},{"path":"core/crates/auths-codec/src/decode.rs","sha256":"ba2999584cdf53b1b517ecb9c7ea50d39efee17c666a9a38e2ad1ac94379f778"},{"path":"core/crates/auths-codec/src/encode.rs","sha256":"8491192053e2e15d62b4d2a491de22405b7487ead69ab6f6bca40669ce7ca5f7"},{"path":"core/crates/auths-codec/src/error.rs","sha256":"fecb0737c16deb42d859f1dc7727c698438d9ac12064fd99e0024629642c390c"},{"path":"core/crates/auths-codec/src/hash.rs","sha256":"f88e111f1dbb2cef6b7b5e9c948cb45ff92e366710c8a5f5c636138aa748479e"},{"path":"core/crates/auths-codec/src/lib.rs","sha256":"3b0e5c94c6f34f750b88a9333e1d5c8145746d845d3a23e0d2f61807cf592ea7"},{"path":"core/crates/auths-codec/src/observation.rs","sha256":"38328b0c71ff97a4196eeade8b4faa889527ff1f80bb168468bcd49082a3987e"},{"path":"core/crates/auths-composition/Cargo.toml","sha256":"331e13025eb82588b5269ed3975929eb41c823d588c49e4c296273d85c6fe80e"},{"path":"core/crates/auths-composition/src/lib.rs","sha256":"e42e3b0b6a9985b73c533aeb23799c638979aa27a9ce7d0f10a8d60340570fb1"},{"path":"core/crates/auths-model/Cargo.toml","sha256":"565c15302b0cc9270db1e28efa3862804846850ba933cc17aea8abcec4a65987"},{"path":"core/crates/auths-model/src/approval.rs","sha256":"54cf44a9ef747ec21546a4086aecfb130bc7803b0d646430e6082df51e6e305f"},{"path":"core/crates/auths-model/src/bounded.rs","sha256":"f90496abc0a06c028b4d850af6ddab7d850bfafeaa3d042f1b1246a41d8bbbc9"},{"path":"core/crates/auths-model/src/bounded_policy.rs","sha256":"306a8f049cc7cfc7635fe0e4de47eb7813eed63d3c14c3be2b51586aa4eea450"},{"path":"core/crates/auths-model/src/lib.rs","sha256":"e7cfe6bb9574c6b016aa63e70658c31e0b90903ec6479a49f4a2ceb64f5bad97"},{"path":"core/crates/auths-model/src/observation.rs","sha256":"b3ed3e3cc090afca2896a7d3de7988afccc1ee1ea0ebd91b51a8bc0f20ad8dbb"},{"path":"core/crates/auths-multikey/Cargo.toml","sha256":"858756c1197befcada06134742bffe8d58cf674cadd6c046bf710570e96efaf2"},{"path":"core/crates/auths-multikey/src/lib.rs","sha256":"f5177fffb11e40eeaaa665ba886c541fc1f24d5eacdd6983fb03dc20148733f0"},{"path":"core/crates/auths-ports/Cargo.toml","sha256":"596a9b0f5af3717c16a6d13f7054d8a106bcd9f01f5f72c2235dd2afcf0ab16c"},{"path":"core/crates/auths-ports/src/binding.rs","sha256":"a16c012d40aa4249e3ec54372b9611006d0bfe8c7dc3c741911c2194ad17736d"},{"path":"core/crates/auths-ports/src/diagnostics.rs","sha256":"aa596572503b5a65a918bb2ff38281c144ff53821ee914e9325c852828d852df"},{"path":"core/crates/auths-ports/src/lib.rs","sha256":"8da1debf86f9532df0660155d04298beb1ba3a1204508f8586e2e67cd2fb3449"},{"path":"core/crates/auths-ports/src/path.rs","sha256":"b68fc547b4e4d97a60cbbc1cc1939fc6b34a8233b81f840bd396914a707e3ea1"},{"path":"core/crates/auths-raw-key-core/Cargo.toml","sha256":"98c0bc8527a537512e43a74dd6ba9658c6f0cdc16610cf938e3f9b43785d836d"},{"path":"core/crates/auths-raw-key-core/src/lib.rs","sha256":"98c0500c41697aae28708ad0bf970b9a97583d96a3d2c18d23e7fa351e0f7d12"},{"path":"core/crates/auths-registries/Cargo.toml","sha256":"590e49e58b0622d9d6274fea46796dcdb07dec9550eff295cce6a6427ffa51e2"},{"path":"core/crates/auths-registries/src/lib.rs","sha256":"450d3886d85ee333b7e046317fb5b47cde62483d5f3e41d46265e2caf4c373ec"},{"path":"core/crates/auths-registries/src/template.rs","sha256":"1224039dd2b3ce6fa8357613d8cafbae40400493da7176da202b9ade62fa05e4"},{"path":"core/crates/auths-signature-core/Cargo.toml","sha256":"6bee2230eb30af1be0bd5c2d57bd3e1903497a70028d47290d4ac6e9e0e8bca0"},{"path":"core/crates/auths-signature-core/src/lib.rs","sha256":"6e3f9311ded5e49ea50d445eb21e6d8fa12442aa4362a73e0cb6fda15b7cd865"},{"path":"core/crates/auths-signature/Cargo.toml","sha256":"7f7b5682466ba439d8c59579022cb61068cad9fb4bd2d73e1cc21327c902b450"},{"path":"core/crates/auths-signature/src/lib.rs","sha256":"b3d9ac4cc3ba0797b9e1c09f2d6ac05089b25dac4e285a5af28c0165b33d925b"},{"path":"core/crates/auths-verifier/Cargo.toml","sha256":"c43358e06a33324b41a8b93e5cc7279c55f7d21f1f6cfaa539a3fc7b4c27a38f"},{"path":"core/crates/auths-verifier/src/approval.rs","sha256":"c3cae6a49e169051eb555bb092b1069a4d422516d6a2114623a89e5f31ff923f"},{"path":"core/crates/auths-verifier/src/causal.rs","sha256":"9635230f33b13a263573d319ac8a1976addbcc07677514a43e3c7bf28dc04587"},{"path":"core/crates/auths-verifier/src/lib.rs","sha256":"f22dec38dfa47ced8da4f605390da8453273bee1da010f57fd1fdfcb10e0b389"},{"path":"core/crates/auths-verifier/src/observation.rs","sha256":"0bb07f06a0f9524c88427df1c972b1de8ba038a9eeeb15d3134c25125ae84b1e"},{"path":"core/crates/auths-verifier/src/trace.rs","sha256":"2213d72023a1d9e0413a2cf2c51c3dad9e04775d86033a0caaa9d29a8f0036f8"},{"path":"exchange/crates/auths-proof-exchange-model/Cargo.toml","sha256":"34b2db64f93a288e942b5a3918d28cdcbef906e0dc9074ce848018d3d5f7a1fa"},{"path":"exchange/crates/auths-proof-exchange-model/src/lib.rs","sha256":"49fac5aff2339d3a57c41cb8bb2a82852538e8f77ecc4b157c88e264ff31fb55"},{"path":"exchange/crates/auths-proof-exchange-port/Cargo.toml","sha256":"7ff660f1b7579dc570c791090aeb450404fbbf44c92781e289f5ccdde746d739"},{"path":"exchange/crates/auths-proof-exchange-port/src/lib.rs","sha256":"b408fb40c6432edc1facb3d8b3e9433a033f8dc73fe2e0ba5a88c972ca675021"},{"path":"product/config/auths-config/Cargo.toml","sha256":"e4b568ab9f6d84f7d7e6d66443fe825f360db9c27bc7c31d6ddc0647ba3ab304"},{"path":"product/config/auths-config/src/lib.rs","sha256":"fecf00ea65cef9018fda9af2063326d7065b869266d0d9e73a4f06816f1102cc"},{"path":"product/errors/auths-errors/Cargo.toml","sha256":"3b3a199b6460ba75bda84475bb5accd35e1485448a3981038db85bf4772dd350"},{"path":"product/errors/auths-errors/src/lib.rs","sha256":"7feeb9069fa4003f94f1f07c1c94619cf1c883e0dbaaf2187f04d0f742264bdd"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/Cargo.toml","sha256":"716f1f2a117d6d69d8effe9ee9057e24ba3502f20af6a602b06facb638cdabed"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/api.rs","sha256":"9ea61dd7ddd93282b073b98e453e6b75b26dc49689c04e9782435d06a3a09e48"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/clock.rs","sha256":"73f0a6e0f5903883329fa6683f5888288f759512ac69e5c87efd2b6b87f7d79f"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/http.rs","sha256":"14c1fce14e24c984b2a275563a29b6326fb08367922b6e8d37ba38b9cb81aa2b"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/identity.rs","sha256":"186de11230c8b33375951c4368609f8976bf9795f171af3fd7fac26207a94361"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/lib.rs","sha256":"0b4617d044f5254a5ab22f5a2578f91cc025206a8c1845be39c1c56dcdf7a5df"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/names.rs","sha256":"41bb23572d6002e23ee103d6fe70ecd68764861c6f2e48a1c53af998270a4afa"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/sigv4.rs","sha256":"39fed883f4802e68d3b41a68fea0aba0737810996b33d13577de70faba004c29"},{"path":"product/integrations/auths-credentials-aws-secrets-manager/src/store.rs","sha256":"fc60ee9c49b2f4f063c449b4c78100c5c4872779e94ca168f796603b380ef6e4"},{"path":"product/integrations/auths-custody/Cargo.toml","sha256":"1e14034185ad1ec24a977fd5f41d0abee219606245c046ce2a82287fadef448b"},{"path":"product/integrations/auths-custody/src/conformance.rs","sha256":"8ed1afd58f18fc6becc268908aa6419c9aa404c79da0197963eb2599df44acf6"},{"path":"product/integrations/auths-custody/src/key.rs","sha256":"70cbb0fd21b6f5652157845a22c35be2d0f6c05b3d138fc4b9bb6dbdc3d81c40"},{"path":"product/integrations/auths-custody/src/lib.rs","sha256":"8ee593c1f59b0fd2126bdda9138026f5bc531beb64d4489a99785842e7b98b43"},{"path":"product/operations/auths-operations/Cargo.toml","sha256":"73dde1e914ba5e1386f8d49c0d730423cc72ae77ec44c6554a6a75cc8e8922b1"},{"path":"product/operations/auths-operations/src/explanation.rs","sha256":"b4a39bcbd5712cb9e735ee7deeb281f3fa6af5ddca973735bf3d87dce427ef11"},{"path":"product/operations/auths-operations/src/lib.rs","sha256":"3f401a56555113b98f57bca172a2660eaecedf76247b83a3fdbb089c133a29cd"},{"path":"product/operations/auths-operations/src/render/json.rs","sha256":"2ae042639960afee123730e945d373bc5dda1d2b3af20ae16c939a7e99b9e418"},{"path":"product/operations/auths-operations/src/render/mod.rs","sha256":"b3b7f6fe473f49227ace5a68bfc3d8dc14b5db8dd9888fd9e207070337b1b8bf"},{"path":"product/operations/auths-operations/src/render/text.rs","sha256":"f216cf3bdb00e264523271b16e7059535ac6b1aabf271cebd7935af155e4bfec"},{"path":"product/policy/auths-bounded-policy/Cargo.toml","sha256":"babd5d200923137ead71d8c2528f847cea323ce98da942a1320b054436612c47"},{"path":"product/policy/auths-bounded-policy/src/arithmetic.rs","sha256":"75e43c5d4eab8d1f71c1daf53839f5f9fe45a400afc1f63a30c6f55ce3632627"},{"path":"product/policy/auths-bounded-policy/src/commitment.rs","sha256":"c5706f2d583454e1a7f570f7ada138d4d864bef0205dfae7c1296774f396bb3f"},{"path":"product/policy/auths-bounded-policy/src/eligibility.rs","sha256":"c9acdfb793074e687fc36efad506be27dbd99ffbf40387bc66c8de39ef238432"},{"path":"product/policy/auths-bounded-policy/src/identifier.rs","sha256":"0006a32bae9eb16c4178cf797df418d2b0a781f2bc6474b741dc1a908141831c"},{"path":"product/policy/auths-bounded-policy/src/kernel.rs","sha256":"99187c28b161ac70bee1afed60711f7f645adb4d1671a01863f9849b369811d5"},{"path":"product/policy/auths-bounded-policy/src/lib.rs","sha256":"76a18638b810e62e594d6e951504679305ce21841388e0c282ad51cd7841b91c"},{"path":"product/policy/auths-bounded-policy/src/receipt.rs","sha256":"3b9eae82f349956d3e7f29b57b254bff5ce67cc703e6cf43445ce2827676d1ad"},{"path":"product/policy/auths-bounded-policy/src/registry.rs","sha256":"4e251ea5833559841f8ee0bd9b66c0c01c39fa5e3a21720a19c4e8adbfcb99d0"},{"path":"product/profiles/auths-profile-api/Cargo.toml","sha256":"7ae85761f58625b5d678f961fd052eebf3e000ef565c7312e3ac857763c0017f"},{"path":"product/profiles/auths-profile-api/src/lib.rs","sha256":"a194b82b20bf8aa864c2334ef6c6aec30b2154ded972862252d5dcefa488ab95"},{"path":"product/profiles/auths-profile-mcp/Cargo.toml","sha256":"db264fc25242f7b7e4133cbe5782545b3107b406c87168ae072683cfb59eac66"},{"path":"product/profiles/auths-profile-mcp/src/arguments.rs","sha256":"a5de696586d507e2aee4a49fee99ef1ef4c51a2e46603f8838f1af6d9009833f"},{"path":"product/profiles/auths-profile-mcp/src/lib.rs","sha256":"d0f8b733a09ef8408336cdebc8948d6a5861012ce6878598b11be7084dfd4280"},{"path":"product/profiles/auths-profile-mcp/src/session.rs","sha256":"166c281fa66333388e4760d6098dad02d3359e1d5de4b8f93312c014f97abb03"},{"path":"product/qualification/auths-recipe-qualification/Cargo.toml","sha256":"b7b15b30b9a3d96d4f089d514266d3a892de6608d30707aa44be31cca89ca9fe"},{"path":"product/qualification/auths-recipe-qualification/src/canonical.rs","sha256":"30a9b50777f0dcb4b606abfee2467d5a975d9f6ad941ab6c13ca3df5b359f667"},{"path":"product/qualification/auths-recipe-qualification/src/closure.rs","sha256":"0ad6aee9bd981e2863fa725f209ec84031db2ff12f4c7ac3aaf5120f0bb86039"},{"path":"product/qualification/auths-recipe-qualification/src/error.rs","sha256":"52d5cf720a1e4579008fdda90a3ac2c31f0b8db57985f121f65e54b4da2b7dd1"},{"path":"product/qualification/auths-recipe-qualification/src/evidence.rs","sha256":"1a4cb862f725b02f9efd827db99637ea8178a5236aa020ec271a363edbe7b362"},{"path":"product/qualification/auths-recipe-qualification/src/ids.rs","sha256":"e81a906d571497ab2da753e1532d4443706410e0b75fdd59a7d2d788c7aec403"},{"path":"product/qualification/auths-recipe-qualification/src/lib.rs","sha256":"195b19f46e1e94a0f3594ad5958e5715bc5e3ad415bdfe6428dfc1b6dd3f4631"},{"path":"product/qualification/auths-recipe-qualification/src/model.rs","sha256":"3484eb0ec287b23d4682ade7b1b0f8d1335985b3fb180f06fc28716076957d5f"},{"path":"product/qualification/auths-recipe-qualification/src/release.rs","sha256":"d816b53e2275e902c750ad03803061844e259e28560ab2d1c77e8a43981a4d6a"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/mod.rs","sha256":"7f15b68e05a04f202bcb1fb80de1d9a9a4fda8bfd6af2cd4e5c660fc75d96fe5"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/schemas.rs","sha256":"c59feec2805621f341a90f81fc005e2c12f7a70f9d264fdeacccb2be4fe1c046"},{"path":"product/qualification/auths-recipe-qualification/src/vectors/verification.rs","sha256":"6a6b5834c5dae10af1ba8939b2deae43b85db71544dc8f077ff63b7fe68ac8a8"},{"path":"product/qualification/auths-recipe-qualification/src/verify.rs","sha256":"edd33589c54fb79bcfccc4cf4e6ccf95bbca088318bb7f8dfc27da684b4fa671"},{"path":"product/receipts/auths-receipts/Cargo.toml","sha256":"6001fa1375899953aafcb07c23a6da0c8f57ea7ec08bb9c950b12a60a8cc6f0f"},{"path":"product/receipts/auths-receipts/src/disclosure.rs","sha256":"ba9ed439be0a320c8a01f5b6e9b51361c1a37e076947490d8e4a484ba6bb119c"},{"path":"product/receipts/auths-receipts/src/lib.rs","sha256":"547a57c4160256719cafb7d0ab972d9fc80b0da14fbbce4148c70c414070ff36"},{"path":"product/runtime/auths-connections/Cargo.toml","sha256":"babeb6e1dda3dfb66291713ce1e43dce5c349833d0b50ea4dccbf986030026b2"},{"path":"product/runtime/auths-connections/src/credential.rs","sha256":"82921413bb053f0f6c7931fbd4e6a86c8cc1d8c6b51debd9c2cef33a8d5b1c51"},{"path":"product/runtime/auths-connections/src/kernel.rs","sha256":"a456af19526ef7b19278551fd8d97606b6e7aa5a195e70bdd85d1e0786090ce8"},{"path":"product/runtime/auths-connections/src/lib.rs","sha256":"79209249a0acba0e854d9937255ea0e30a9a6f8e1c2baad77ab83646d392de22"},{"path":"product/runtime/auths-connections/src/model.rs","sha256":"de34a089cac9702acb889b7602b3e4732306c9e61029ec892582b7e02b73fc77"},{"path":"product/runtime/auths-connections/src/registry.rs","sha256":"e57640e003e72ad3235c567c2bb944a262bd64e673f023b62ea091094cc7e946"},{"path":"product/runtime/auths-gateway-kernel/Cargo.toml","sha256":"34f864e67656af4ac7e5c7a347a23f01fc8ab31365b8e23c0ef474483b14a183"},{"path":"product/runtime/auths-gateway-kernel/src/construct.rs","sha256":"e9d65744bae273d65face4285e5d2da75c06a92ab48f7b8333d496604e31c63c"},{"path":"product/runtime/auths-gateway-kernel/src/lib.rs","sha256":"cfda6f8453426564c085475f183b6f790b438768a19668f1b65d33ba59dc8632"},{"path":"product/runtime/auths-gateway-kernel/src/order.rs","sha256":"e0045f877c4b7fe3860c291039853885064ddcdebba7c9b345ab65e81e646dda"},{"path":"product/runtime/auths-gateway-kernel/src/outcome.rs","sha256":"e9785b1afe70788c89b51b18655bcd5acff7b0c8e3d12fb79c6f05a202e67e6b"},{"path":"product/runtime/auths-gateway-kernel/src/ratio.rs","sha256":"7ace22264812e6fc1ef3a62afee402cf43494bb71ae0d3de219ad1b39d690bcb"},{"path":"product/runtime/auths-gateway-kernel/src/recovery.rs","sha256":"9f46dcd1685ab6e4d1ba04a3fc286a9f3afee8fb47829be1fbc670a7926a2dc6"},{"path":"product/runtime/auths-gateway-kernel/src/transition.rs","sha256":"cefb8f01d915880f6921f2501ff881ad94b9de61072c34d9cd19220992f97374"},{"path":"product/runtime/auths-gateway/Cargo.toml","sha256":"00b28be86921dcdc0ad90cce0c62c2961cafefe96ea6c2edf0e926c3d405ccad"},{"path":"product/runtime/auths-gateway/src/admin.rs","sha256":"2d5cc67afac42fbd15f36e2d1693cd1b20cedacf775040dce6ffe04ccc94c9ea"},{"path":"product/runtime/auths-gateway/src/app.rs","sha256":"6f80f63833ea0815bb44fbf3b0cc54624a08e9fb578b035819f2980dc04e8256"},{"path":"product/runtime/auths-gateway/src/audit.rs","sha256":"0edb2037eb7b5d6f13acfce85c54f62a82bf7fdd31bc7e626c4fec183cef61dd"},{"path":"product/runtime/auths-gateway/src/audit_tests.rs","sha256":"990969384a52d75bc2489d9d412a9e7dd814bca1c8f65c701d6205fae62a15d5"},{"path":"product/runtime/auths-gateway/src/bin/auths-gateway-harness.rs","sha256":"12b0c24d36b2e6a3be392d3c081a783b355fdd67dc5b54bacc0410e803116b8c"},{"path":"product/runtime/auths-gateway/src/bin/auths-gateway.rs","sha256":"8851f5e95b70540f64c1965f6f240e7d90968817c6396d59cda49a75e46f85fc"},{"path":"product/runtime/auths-gateway/src/binding.rs","sha256":"663b0ce878319511aa7d05722ab4ab2fa9be18817884ffb2ee9f131ff1e382d1"},{"path":"product/runtime/auths-gateway/src/bounds.rs","sha256":"9cfa0eae4973e5e047a198360b22f041708fa20276b0b90c2ec440f06915d64b"},{"path":"product/runtime/auths-gateway/src/bounds_aggregate_tests.rs","sha256":"ab34f227b7c8692ba1da88ccb32bb3687a66adf8353d8282fae905d4a88eb612"},{"path":"product/runtime/auths-gateway/src/bounds_tests.rs","sha256":"74136bc570da7b9338b5a1a1ed9d3fd892c55135160964f9d3ac0e2cf5741375"},{"path":"product/runtime/auths-gateway/src/connection.rs","sha256":"7b5d049c258a26ed1c93ecf860f51d051a8a65f9b93554af0b1ea93862398ec9"},{"path":"product/runtime/auths-gateway/src/credential_journal.rs","sha256":"7297fccaee5cd8622928e8e0a42bba50da7941970a6f682ecd300c41ee6509da"},{"path":"product/runtime/auths-gateway/src/echo_verify.rs","sha256":"6d453051665f823abd7354d5c50031d9524cf47d6092d2f09080ae814e3b9575"},{"path":"product/runtime/auths-gateway/src/engine.rs","sha256":"f1052aaef43e441b9e0befa3f8ad1e2fd5279a27cb9d63aac0cf9dfdbe85a052"},{"path":"product/runtime/auths-gateway/src/fuzzing.rs","sha256":"ed3b0028487eb546a3b0a91d115aa8f18bcea324b61620bfe8e35a9e6ab18758"},{"path":"product/runtime/auths-gateway/src/generation_floor.rs","sha256":"fe1510ec96ce0f67e9a78edbfa94b33eb7df64c0ac4d0a67901124dcd0164777"},{"path":"product/runtime/auths-gateway/src/harness.rs","sha256":"c87e479f3eb281960f6c9c80d43c7560d7ccb654b0154ec823d3fcec4884ac05"},{"path":"product/runtime/auths-gateway/src/lib.rs","sha256":"b182052835a2cd631d1f801a74788d6ac1ef9cae21ade9ab4ffc270cd6f667ed"},{"path":"product/runtime/auths-gateway/src/listener.rs","sha256":"aa08239bcbc1fcb55e8a212fbdf6a84f12985b0d436dcb51dc58b06e59e435f2"},{"path":"product/runtime/auths-gateway/src/observed_tests.rs","sha256":"a22be071695f89ca2578a4d82fd80d9cb82289213c9bdb8f0470f6b2dc3d9201"},{"path":"product/runtime/auths-gateway/src/observer.rs","sha256":"562ffcaea071c3856e0708dc2fe33516917e145d2438aabb75af0a2b6ba01046"},{"path":"product/runtime/auths-gateway/src/onboarding.rs","sha256":"0dfd7d67e7f02f18a28d1c86ca1b7751d7463f5efcdbe3825de0ad8cbccb3891"},{"path":"product/runtime/auths-gateway/src/operator.rs","sha256":"c3c36954a3e86723df035b47f43b85fc8ab3a6292983522b6258d5c6b4e6c4e4"},{"path":"product/runtime/auths-gateway/src/pending_vectors/attempts.rs","sha256":"47ef3c8cb1c5504d47ae9a0088705ce6715fe86bac1671a34a56fb38a64d0dd3"},{"path":"product/runtime/auths-gateway/src/pending_vectors/bounds.rs","sha256":"e612217307e1da5b0eb5ee6582a7458bb064b8aa8a529fc6555b1b334127ab68"},{"path":"product/runtime/auths-gateway/src/pending_vectors/codes.rs","sha256":"e00670a5ef24eb856cc2c6dcda8e5a263842f55c76bb939c0c27bd9df37e5522"},{"path":"product/runtime/auths-gateway/src/pending_vectors/keys.rs","sha256":"f84c56f2ea9cb34b2b79c72d13fbf2c6585010ec1ed450af58a78c80c1ea1edd"},{"path":"product/runtime/auths-gateway/src/pending_vectors/mod.rs","sha256":"5de8340f0ef71a0b57a09e323fefe473d3160c6ae952b9ba46f714825fbacafc"},{"path":"product/runtime/auths-gateway/src/pending_vectors/outcomes.rs","sha256":"8ec07b9511b48913af36ac999d2aa0e82924289f6acb23ddaeb9f00f99815166"},{"path":"product/runtime/auths-gateway/src/pending_vectors/production.rs","sha256":"f574cabfa4687a4fc846b8f6a234b68ec53b876aed548b11445729cac1a55ede"},{"path":"product/runtime/auths-gateway/src/pending_vectors/recipes.rs","sha256":"51693208270231c25c3aef2a32151d5962114c71e86b88cb9df83181c7af83d9"},{"path":"product/runtime/auths-gateway/src/pre_entry.rs","sha256":"06ea78ca140468bfaba17d9ab04912ad68673954c3203c98fed28272ff2a00c0"},{"path":"product/runtime/auths-gateway/src/property_tests.rs","sha256":"939c081e2e154aaf10186754044bdd65608932c899b970a04a71d0afaa999a93"},{"path":"product/runtime/auths-gateway/src/qualification.rs","sha256":"d5078a81dc89f30958a7d8fdabfe164c3411c441dd7d9015002681a58883b63a"},{"path":"product/runtime/auths-gateway/src/qualification_tests.rs","sha256":"4473add5729b97faf04d7be7fd8cf3bbacd7f545e1b61c7d2782563011906b4d"},{"path":"product/runtime/auths-gateway/src/quorum_tests.rs","sha256":"a78380b019104d688c11a4410082c7e08ded3106aab099954c4f8a1b4ae0fd89"},{"path":"product/runtime/auths-gateway/src/readiness.rs","sha256":"911c3f11892f94641574375e2da4c671c802738f71082f12c39832331052d43d"},{"path":"product/runtime/auths-gateway/src/recipe.rs","sha256":"fecf9af67e7afc48d080e9f8f55f8ce28e49d0e08988717a27908bbda461d6b6"},{"path":"product/runtime/auths-gateway/src/recipe/lower.rs","sha256":"8088b8751ed831dbb48247af953c6290ac95e2a438ccd5bd65eff54be06a1fd1"},{"path":"product/runtime/auths-gateway/src/recipe/review.rs","sha256":"c3eb7d9fdd98dacf52750b14b67ad7b7faaecbbdeafff1e0b049f67d0a2d0139"},{"path":"product/runtime/auths-gateway/src/recipe/runtime.rs","sha256":"c286f0d15f974402822c0810d8e97f98411dc1c557755cc23e540d85e2bb42d9"},{"path":"product/runtime/auths-gateway/src/recipe/source.rs","sha256":"1af50298fb1461e1b24cbdf560d43124d6b1c4c529586c911b1c4797214a69ff"},{"path":"product/runtime/auths-gateway/src/recipe/tests.rs","sha256":"aac551a2481bbc60864c64079bc667dcb232e45a0f41a04073ed84ab5dbe1fce"},{"path":"product/runtime/auths-gateway/src/recipe/validate.rs","sha256":"3711b17605cb7b0d639bc1b180449231f5882a306cab20e2c7782155cc63b0d4"},{"path":"product/runtime/auths-gateway/src/scenario_tests.rs","sha256":"d8271761b12597bfe1233f8eb5ad1d4e5498c5d03388e832e24ab818484a1de4"},{"path":"product/runtime/auths-gateway/src/separation.rs","sha256":"be9306848ca5d801767cf6aa14247c8618312ef0e202f00ce0c24c4844491061"},{"path":"product/runtime/auths-gateway/src/store.rs","sha256":"1e5288345c2cb1421aeda277b3158d07607404b0ec46943222ac107b63dcbb46"},{"path":"product/runtime/auths-gateway/src/store_testkit.rs","sha256":"88d82fee42f1b8ef521e8deef0216e938ebeaad822eb2f5020e70b89d145ea70"},{"path":"product/runtime/auths-gateway/src/submit.rs","sha256":"c78dbbba7c45b443febf655056c5edcd635e77be7b21c5b2c318097502415ffe"},{"path":"product/runtime/auths-gateway/src/support.rs","sha256":"9fd979029793a22b15d9776a7fc172476fd7c126a80d4080c65d720845becbcb"},{"path":"product/runtime/auths-gateway/src/transport.rs","sha256":"be2c6dc75b8daec110bf8749fedc3823f6067d40b999aa3eb21126896a97d3ad"},{"path":"product/runtime/auths-kernel-runtime/Cargo.toml","sha256":"926bcd0053e5edd703be8f0027ad009e2982db013c8174c1366edbf38b48087d"},{"path":"product/runtime/auths-kernel-runtime/src/lib.rs","sha256":"7108faae9ca4bc32dc093ddaf885ac8f74f3c9b8d317b7c7d0ed00d1d0ab5972"},{"path":"product/runtime/auths-lifecycle/Cargo.toml","sha256":"c5053c705f8d5554b44e432fe75287a2faf11d644e5a2552e45ad6cd94176af5"},{"path":"product/runtime/auths-lifecycle/src/codec.rs","sha256":"51c4b746749150e8bfcd8ff972b3f6c1ef40bec15583448ec1b471d9ad737dc0"},{"path":"product/runtime/auths-lifecycle/src/digest.rs","sha256":"55282cd7d8aba37713c2dcb5357c9877819edb72a30eadefb860d28d7d3a3eb7"},{"path":"product/runtime/auths-lifecycle/src/identifier.rs","sha256":"31620e8c16de68d3d2d3b84d6353c1a5b9d4151d8247f91fa389b906a8e660f1"},{"path":"product/runtime/auths-lifecycle/src/kernel.rs","sha256":"2abb8c094b36d35b364e74d78091d14c5d6f3d1eb6552cca3b5538dbd017ce23"},{"path":"product/runtime/auths-lifecycle/src/lib.rs","sha256":"afe5a345cd309b5a4f08ffba3170a6d7f16ff914892b3f2006d5208ed24ec736"},{"path":"product/runtime/auths-lifecycle/src/model.rs","sha256":"1af12bbeeeaa65ec02ddcebd7d2c8c92ec1e339fa40ae85b45ab9b01a722a4fa"},{"path":"product/runtime/auths-lifecycle/src/operation.rs","sha256":"78e6d05fcf5d7ea4f148032a3fa26fdfcab77eb346019effe927b08e0d64d732"},{"path":"product/runtime/auths-lifecycle/src/registry.rs","sha256":"53495fe6f7a77422593a41623e28716f1c140dcc1e00f87f6b0ffbfb93a5ef90"},{"path":"product/runtime/auths-lifecycle/src/sealed.rs","sha256":"2e0c03b5813c926c159598bb7890d1523825edbe57cd687d42a5dfb3374a0c19"},{"path":"product/runtime/auths-lifecycle/src/test_support.rs","sha256":"221d9a5c5ef8e1f9f8fd66927484eaf813571d0568470278ecd8fa08a5eec8cd"},{"path":"product/runtime/auths-lifecycle/src/transition.rs","sha256":"011805a28efdd541f44ccea54dd0e34a1e58f4004447ee58013703b4fb6b2822"},{"path":"product/runtime/auths-runtime/Cargo.toml","sha256":"dd29d80a8b4bb56c869f036a6c9998f6e6d1c0383ac5dc03fa42b24abe2a7ff7"},{"path":"product/runtime/auths-runtime/src/lib.rs","sha256":"132ca03d58c14905d3a822a4972f42a22a5ad33a45f2ba9ca44f3a57d810b4b0"},{"path":"product/runtime/auths-runtime/src/production.rs","sha256":"fd4eff4270c571c10fcff6cf27df28961ebf074de8a1a028f500fda1ca376820"},{"path":"product/sdk/auths-approval-quorum/Cargo.toml","sha256":"df904971fca87a24194c5816738a331588fe8f164acb5d70839a35dca5f57114"},{"path":"product/sdk/auths-approval-quorum/src/lib.rs","sha256":"72e4086088a1c0912e4e1a9e980f26a5c280bf5ab16ea39c9f0f58d5096ac2ce"},{"path":"product/sdk/auths-approval-quorum/src/remote.rs","sha256":"9fa029c7c63003adf260f8aec55160c545681c60c1bace0d934e7525862aac7b"},{"path":"product/sdk/auths-approval-quorum/src/remote_tests.rs","sha256":"8a09ce17cfee82a035a74edd535f3ebeb3db0e078784740d3997b17dbdb10197"},{"path":"product/sdk/auths-approval-quorum/src/tests.rs","sha256":"0acbf12be3e497dcc6c2d8f29c9af3b1a7ed8e11b75f6399570d4f217a785060"},{"path":"product/stores/auths-stores/Cargo.toml","sha256":"a32c4b76d17b264be5d40d24497ff237e1ed87822e8bab86229b3db93106dbe3"},{"path":"product/stores/auths-stores/src/gateway_attempt.rs","sha256":"185da8f4a220fba836d5b78a9311f03204681c43a3c53951008084d135bf4d21"},{"path":"product/stores/auths-stores/src/lib.rs","sha256":"e721262edf8a9ab5568ddc27639176312d4a366203b5f73e2d7c11fde2c554e9"},{"path":"product/stores/auths-stores/src/lifecycle.rs","sha256":"94f64d25bc178a9e60ba3c0ee6915d7fb3af7c4468792d76b4e70f43f4de1e2f"}],"schema":"auths.gateway-semantic-closure/1"} \ No newline at end of file diff --git a/product/runtime/auths-gateway/src/bin/auths-gateway.rs b/product/runtime/auths-gateway/src/bin/auths-gateway.rs index 454904f9a..2f49a1685 100644 --- a/product/runtime/auths-gateway/src/bin/auths-gateway.rs +++ b/product/runtime/auths-gateway/src/bin/auths-gateway.rs @@ -320,6 +320,10 @@ mod unix { /// defaults to `/admin.sock`. #[arg(long)] admin_socket: Option, + /// Commit directly to the shared store without a running gateway. + /// Does not delete credentials or claim in-flight drainage. + #[arg(long, default_value_t = false)] + store_only: bool, }, /// Ask the private operator socket to enable a disabled connection. Enable { @@ -356,6 +360,21 @@ mod unix { #[arg(long, default_value_t = false)] tuple: bool, }, + /// Write a redacted archive for a support request: versions, + /// digests, closed states, stable codes, and the digest and stage of + /// each stored attempt. It holds no proof, action, body, credential, + /// location, account, resource identifier, or header. + SupportBundle { + #[arg(long)] + state_dir: PathBuf, + /// The admin socket `serve` was given with `--admin-socket`; + /// defaults to `/admin.sock`. + #[arg(long)] + admin_socket: Option, + /// Where to write the archive; standard output when absent. + #[arg(long)] + out: Option, + }, /// Ask the private operator socket for the connection state. Status { #[arg(long)] @@ -424,6 +443,16 @@ mod unix { /// defaults to `/admin.sock`. #[arg(long)] admin_socket: Option, + /// Commit directly to the shared store without a running gateway. + /// Does not delete credentials or claim in-flight drainage. + #[arg(long, default_value_t = false)] + store_only: bool, + }, + /// Collect exact superseded or abandoned generations under operator + /// workload identity, retaining the active and future generations. + CredentialCollect { + #[arg(long)] + state_dir: PathBuf, }, /// Rotate the credential via the private operator socket and stdin. Rotate { @@ -435,6 +464,10 @@ mod unix { admin_socket: Option, #[arg(long, default_value_t = false)] credential_stdin: bool, + /// Run under the operator's workload identity in this process. + /// The serving gateway may retain its read-only runtime role. + #[arg(long, default_value_t = false)] + operator_process: bool, }, /// First phase of a two-phase rotation: store the new credential /// from stdin without publishing it, and print its commitment. @@ -447,6 +480,10 @@ mod unix { admin_socket: Option, #[arg(long, default_value_t = false)] credential_stdin: bool, + /// Run under the operator's workload identity in this process. + /// The serving gateway may retain its read-only runtime role. + #[arg(long, default_value_t = false)] + operator_process: bool, }, /// Second phase: publish the prepared credential the commitment /// names to every process sharing the store. @@ -460,6 +497,10 @@ mod unix { /// The commitment `rotate-prepare` printed. #[arg(long)] commitment: String, + /// Run under the operator's workload identity in this process. + /// The serving gateway may retain its read-only runtime role. + #[arg(long, default_value_t = false)] + operator_process: bool, }, /// Operator-only: create the observer signing key in gateway state. ObserverInit { @@ -528,6 +569,12 @@ mod unix { #[arg(long)] app_gid: u32, }, + /// Internal owner-UID runtime checks; isolation is checked by doctor. + #[command(hide = true)] + ReadinessProbe { + #[arg(long)] + state_dir: PathBuf, + }, /// Internal privilege-dropped read/connect probe. No secret is printed. #[command(hide = true)] Probe { @@ -708,10 +755,33 @@ mod unix { settings: &CredentialStoreSettings, deployment: Deployment, unavailable: &'static str, + ) -> Result, &'static str> { + open_credentials_for( + state_dir, + settings, + deployment, + unavailable, + CredentialAccess::Runtime, + ) + } + + #[derive(Clone, Copy, Eq, PartialEq)] + enum CredentialAccess { + Runtime, + Operator, + } + + fn open_credentials_for( + state_dir: &Path, + settings: &CredentialStoreSettings, + deployment: Deployment, + unavailable: &'static str, + access: CredentialAccess, ) -> Result, &'static str> { use auths_credentials_aws_secrets_manager::{ - AwsSecretsManagerStore, ContainerEndpoint, DeploymentNamespace, HttpSecretsApi, - InstanceMetadata, Region, WebIdentity, WorkloadIdentity, + AdministrativeSecretsApi, AwsSecretsManagerStore, ContainerEndpoint, + DeploymentNamespace, HttpSecretsApi, InstanceMetadata, Region, WebIdentity, + WorkloadIdentity, }; let kind = auths_gateway::credential_store_policy(&settings.kind, production_custody(deployment))?; @@ -765,6 +835,32 @@ mod unix { } _ => return Err(unavailable), }; + if access == CredentialAccess::Operator { + // The maintained operator reference is web identity. It + // reads under the runtime role and writes under the + // distinct operator role; neither role is broadened. + if settings.identity.as_deref() != Some("web-identity") { + return Err(unavailable); + } + let reader_role = variable("AUTHS_GATEWAY_RUNTIME_ROLE_ARN")?; + if reader_role == variable("AWS_ROLE_ARN")? { + return Err(unavailable); + } + let reader = WebIdentity::new( + ®ion, + reader_role, + variable("AUTHS_GATEWAY_RUNTIME_TOKEN_FILE")?, + ) + .map_err(|_| unavailable)?; + let reader = HttpSecretsApi::new(region.clone(), None, reader) + .map_err(|_| unavailable)?; + let writer = HttpSecretsApi::new(region, settings.kms_key.clone(), identity) + .map_err(|_| unavailable)?; + return Ok(Arc::new(AwsSecretsManagerStore::new( + AdministrativeSecretsApi::new(reader, writer), + namespace, + ))); + } let api = HttpSecretsApi::new(region, settings.kms_key.clone(), identity) .map_err(|_| unavailable)?; Ok(Arc::new(AwsSecretsManagerStore::new(api, namespace))) @@ -1192,6 +1288,14 @@ mod unix { account_hash.update(account_label.as_bytes()); let account_commitment: [u8; 32] = account_hash.finalize().into(); let timestamp = now()?; + let journal = auths_gateway::CredentialJournal::new(state_dir.clone()); + let id = connection_id.clone(); + tokio::task::spawn_blocking(move || { + journal.initialize(&id)?; + journal.register(&id, NonZeroU64::MIN) + }) + .await + .map_err(|_| "gateway.admin.credential-journal-unavailable")??; let record_id = connection_id.clone(); install_connection( &shared, @@ -1223,6 +1327,26 @@ mod unix { ) .await?; } + let installed = shared + .load() + .await + .map_err(|_| "gateway.install.connection-store-unavailable")? + .ok_or("gateway.install.connection-store-unavailable")?; + if join { + let journal = auths_gateway::CredentialJournal::new(state_dir.clone()); + let record = installed.record().clone(); + tokio::task::spawn_blocking(move || { + journal.initialize(record.connection_id())?; + journal.register(record.connection_id(), record.credential_generation()) + }) + .await + .map_err(|_| "gateway.admin.credential-journal-unavailable")??; + } + let floor = auths_gateway::GenerationFloor::new(state_dir.clone()); + let record = installed.record().clone(); + tokio::task::spawn_blocking(move || floor.initialize(&record)) + .await + .map_err(|_| "gateway.install.connection-store-unavailable")??; private_file(&state_dir.join("recipe.json"), &source)?; private_file(&state_dir.join("profile.lock.json"), &lock)?; private_file(&state_dir.join("trusted.context.cbor"), &trust)?; @@ -1657,6 +1781,13 @@ mod unix { } fn load_engine(state_dir: &Path) -> Result { + load_engine_for(state_dir, CredentialAccess::Runtime) + } + + fn load_engine_for( + state_dir: &Path, + access: CredentialAccess, + ) -> Result { private_root(state_dir)?; let manifest = installation(state_dir)?; let source = read_bounded(&state_dir.join("recipe.json"), 65_536)?; @@ -1716,11 +1847,12 @@ mod unix { .map_err(|_| "gateway.serve.invalid-alias")?, "gateway".to_owned(), profile, - open_credentials( + open_credentials_for( state_dir, &manifest.credential_store, manifest.deployment, "gateway.serve.credential-store-unavailable", + access, )?, open_attempts( manifest.deployment, @@ -1732,7 +1864,12 @@ mod unix { Some(observer) => engine.with_observer(observer), None => engine, }; - let engine = engine.with_qualification(Arc::new(qualification_gate(state_dir, &manifest)?)); + let engine = engine + .with_qualification(Arc::new(qualification_gate(state_dir, &manifest)?)) + .with_generation_floor(auths_gateway::GenerationFloor::new(state_dir.to_path_buf())) + .with_credential_journal(auths_gateway::CredentialJournal::new( + state_dir.to_path_buf(), + )); // Separation is checked against an authenticated operator. A // development installation without one keeps its observer key // outside the trust it installed, as `observer-init` creates it after @@ -2119,9 +2256,11 @@ mod unix { // never take an admin permit. let app_engine = Arc::clone(&engine); let sweep_engine = Arc::clone(&engine); + let app_permits = Arc::new(Semaphore::new(app_capacity)); + let admin_permits = Arc::new(Semaphore::new(ADMIN_CAPACITY)); let app_listener = serve_listener( app, - Arc::new(Semaphore::new(app_capacity)), + Arc::clone(&app_permits), "app", |_: &UnixStream| true, move |stream, permit| { @@ -2135,7 +2274,7 @@ mod unix { let owner = rustix::process::geteuid().as_raw(); let admin_listener = serve_listener( admin, - Arc::new(Semaphore::new(ADMIN_CAPACITY)), + Arc::clone(&admin_permits), "admin", move |stream: &UnixStream| admin_peer_admitted(stream, owner), move |stream, permit| { @@ -2147,21 +2286,58 @@ mod unix { } }, ); - let sweeper = async move { - let mut tick = tokio::time::interval(std::time::Duration::from_secs( - auths_gateway::SLOT_SWEEP_INTERVAL_SECONDS, - )); - loop { - tick.tick().await; - if let Err(code) = sweep_engine.sweep_expired_slots().await { - eprintln!("{code}"); - } - } - }; + let sweeper = sweep_periodically(sweep_engine.as_ref()); + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .map_err(|_| "gateway.serve.signal-unavailable")?; tokio::select! { never = app_listener => match never {}, never = admin_listener => match never {}, never = sweeper => match never {}, + _ = terminate.recv() => {}, + _ = tokio::signal::ctrl_c() => {}, + } + // The listeners have been dropped: no new session can be admitted. + // Waiting for permits includes pre-entry and admin sessions, not + // just transports already counted by the engine. + let drained = drain_sessions(&app_permits, &admin_permits, app_capacity).await; + let _ = fs::remove_file(&app_socket); + let _ = fs::remove_file(admin_path); + drained + } + + async fn sweep_periodically(engine: &GatewayEngine) -> ! { + let mut tick = tokio::time::interval(Duration::from_secs( + auths_gateway::SLOT_SWEEP_INTERVAL_SECONDS, + )); + loop { + tick.tick().await; + if let Err(code) = engine.sweep_expired_slots().await { + eprintln!("{code}"); + } + } + } + + async fn drain_sessions( + app_permits: &Semaphore, + admin_permits: &Semaphore, + app_capacity: usize, + ) -> Result<(), Failure> { + let drained = tokio::time::timeout(Duration::from_secs(25), async { + let _app = app_permits + .acquire_many(u32::try_from(app_capacity).map_err(|_| "gateway.serve.capacity")?) + .await + .map_err(|_| "gateway.serve.capacity")?; + let _admin = admin_permits + .acquire_many(u32::try_from(ADMIN_CAPACITY).map_err(|_| "gateway.serve.capacity")?) + .await + .map_err(|_| "gateway.serve.capacity")?; + Ok::<_, &'static str>(()) + }) + .await; + match drained { + Ok(result) => Ok(result?), + Err(_) => Err("gateway.serve.shutdown-incomplete".into()), } } @@ -2420,6 +2596,22 @@ mod unix { command: serde_json::Value, secret: Option<&[u8]>, ) -> Result<(), Failure> { + let response = admin_exchange(state_dir, admin_socket, command, secret).await?; + println!("{response}"); + if response.get("ok").and_then(serde_json::Value::as_bool) == Some(true) { + Ok(()) + } else { + Err("gateway.admin.refused".into()) + } + } + + /// Sends one admin command and returns the gateway's response. + async fn admin_exchange( + state_dir: &Path, + admin_socket: &AdminSocket, + command: serde_json::Value, + secret: Option<&[u8]>, + ) -> Result { const CODE: &str = "gateway.admin.socket-unavailable"; private_root(state_dir)?; check_admin_socket(admin_socket, &CLIENT_ADMIN_SOCKET)?; @@ -2450,13 +2642,139 @@ mod unix { write_frame(&mut stream, secret).await?; } let bytes = read_frame(&mut stream).await?; - let response: serde_json::Value = - serde_json::from_slice(&bytes).map_err(|_| "gateway.admin.invalid-response")?; - println!("{response}"); - if response.get("ok").and_then(serde_json::Value::as_bool) == Some(true) { + Ok(serde_json::from_slice(&bytes).map_err(|_| "gateway.admin.invalid-response")?) + } + + /// What a serving gateway reports about its connection, or `None` when + /// none is serving or its answer is not a status. + async fn support_connection( + state_dir: &Path, + admin_socket: &AdminSocket, + ) -> Option { + use auths_gateway::SupportConnectionState as State; + let command = serde_json::json!({"command": "status"}); + let response = tokio::time::timeout( + Duration::from_secs(20), + admin_exchange(state_dir, admin_socket, command, None), + ) + .await + .ok()? + .ok()?; + let status = response.get("status")?; + let number = |member: &str| status.get(member).and_then(serde_json::Value::as_u64); + Some(auths_gateway::SupportConnection { + state: match status.get("state")?.as_str()? { + "active" => State::Active, + "disabled" => State::Disabled, + "revoked" => State::Revoked, + _ => return None, + }, + generation: number("generation")?, + credential_generation: number("credential_generation")?, + credential_held: status.get("credential_held")?.as_bool()?, + in_flight: number("in_flight")?, + }) + } + + /// The digests, closed states, and attempt stages of an installation. + /// It blocks, so the caller must not be on an async executor. + fn support_facts( + state_dir: &Path, + connection: Option, + ) -> Result { + const CODE: &str = "gateway.support.unavailable"; + let fixed = |text: &str| { + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(text, &mut bytes) + .map(|()| bytes) + .map_err(|_| CODE) + }; + let manifest = installation(state_dir)?; + let executable = fs::read(std::env::current_exe().map_err(|_| CODE)?).map_err(|_| CODE)?; + let listed = open_attempts( + manifest.deployment, + manifest.attempt_store.as_ref().map(PathBuf::from), + ) + .ok() + .and_then(|attempts| { + tokio::runtime::Builder::new_current_thread() + .build() + .ok()? + .block_on(attempts.stages(auths_gateway::MAX_SUPPORT_ATTEMPTS + 1)) + .ok() + }); + let attempts_truncated = listed + .as_ref() + .is_some_and(|listed| listed.len() > auths_gateway::MAX_SUPPORT_ATTEMPTS); + let attempts = listed + .map(|listed| { + listed + .into_iter() + .take(auths_gateway::MAX_SUPPORT_ATTEMPTS) + .map(|(key, stage)| fixed(&key).map(|key| (key, stage))) + .collect::, _>>() + }) + .transpose()?; + Ok(auths_gateway::SupportFacts { + build_sha256: Sha256::digest(&executable).into(), + recipe_sha256: fixed(&manifest.recipe_digest)?, + profile_lock_sha256: fixed(&manifest.profile_lock_sha256)?, + trusted_context_sha256: fixed(&manifest.trusted_context_sha256)?, + production: manifest.deployment == Deployment::Production, + credential_store_kind: CredentialStoreKind::parse(&manifest.credential_store.kind) + .map_err(|_| CODE)?, + qualification: qualification_gate(state_dir, &manifest)?.status(), + connection, + attempts, + attempts_truncated, + }) + } + + /// The emergency operator path opens only installation metadata and the + /// shared lifecycle store. It never opens qualification or custody inputs. + async fn emergency_stop(state_dir: &Path, revoke: bool) -> Result<(), Failure> { + private_root(state_dir)?; + let manifest = installation(state_dir)?; + let deployment = manifest.deployment; + let store_path = manifest.attempt_store.as_ref().map(PathBuf::from); + let attempts = tokio::task::spawn_blocking(move || open_attempts(deployment, store_path)) + .await + .map_err(|_| "gateway.admin.connection-unavailable")??; + let shared = SharedConnection::new( + attempts.store(), + ProviderKind::parse(manifest.provider).map_err(|_| "gateway.serve.invalid-provider")?, + ConnectionAlias::parse(manifest.alias).map_err(|_| "gateway.serve.invalid-alias")?, + ); + let record = shared.stop(revoke, now()?).await?; + println!( + "{}", + serde_json::json!({ + "schema": "auths.gateway-emergency-stop/1", + "state": if record.state() == ConnectionState::Revoked { "revoked" } else { "disabled" }, + "generation": record.generation().get(), + "drainage": "not-checked", + "credential_deletion": "not-attempted" + }) + ); + Ok(()) + } + + async fn operator_engine(state_dir: &Path) -> Result { + let directory = state_dir.to_path_buf(); + tokio::task::spawn_blocking(move || load_engine_for(&directory, CredentialAccess::Operator)) + .await + .map_err(|_| "gateway.admin.load-failed")? + } + + fn print_admin_response(response: &AdminResponse) -> Result<(), Failure> { + println!( + "{}", + serde_json::to_string(response).map_err(|_| "gateway.output")? + ); + if response.ok { Ok(()) } else { - Err("gateway.admin.refused".into()) + Err(response.code.into()) } } @@ -2465,6 +2783,7 @@ mod unix { admin_socket: &AdminSocket, credential_stdin: bool, command: &str, + operator_process: bool, ) -> Result<(), Failure> { if !credential_stdin || std::io::stdin().is_terminal() { return Err("gateway.admin.credential-must-be-piped-to-stdin".into()); @@ -2485,6 +2804,22 @@ mod unix { { return Err("gateway.admin.invalid-credential".into()); } + if operator_process { + let engine = operator_engine(state_dir).await?; + let response = if command == "rotate-prepare" { + match engine.prepare_rotation(bytes).await { + Ok(commitment) => AdminResponse { + ok: true, + commitment: Some(hex::encode(commitment)), + ..AdminResponse::refused("gateway.admin.rotation-prepared") + }, + Err(code) => AdminResponse::refused(code), + } + } else { + AdminResponse::of(engine.rotate_connection(bytes).await) + }; + return print_admin_response(&response); + } admin_command( state_dir, admin_socket, @@ -2595,7 +2930,93 @@ mod unix { Ok(()) } - fn doctor( + #[derive(Deserialize, Serialize)] + #[serde(deny_unknown_fields)] + struct DoctorRuntime { + required: auths_gateway::RequiredPreconditions, + observer: auths_gateway::ObserverCustodyState, + qualification_code: Option, + } + + #[derive(Clone, Serialize)] + #[serde(into = "String")] + struct DoctorCode(&'static str); + + impl<'de> Deserialize<'de> for DoctorCode { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::try_from(String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } + } + + impl TryFrom for DoctorCode { + type Error = &'static str; + fn try_from(code: String) -> Result { + use auths_recipe_qualification::QualificationRefusal; + [ + QualificationRefusal::Unavailable, + QualificationRefusal::Revoked, + QualificationRefusal::ClockUntrusted, + QualificationRefusal::RevocationStale, + QualificationRefusal::Missing, + QualificationRefusal::Expired, + QualificationRefusal::DigestMismatch, + QualificationRefusal::TargetMismatch, + ] + .into_iter() + .map(auths_gateway::qualification_code) + .find(|known| *known == code) + .map(Self) + .ok_or("gateway.doctor.invalid-runtime-report") + } + } + + impl From for String { + fn from(code: DoctorCode) -> Self { + code.0.to_owned() + } + } + + async fn runtime_doctor(state_dir: &Path) -> Result { + let directory = state_dir.to_path_buf(); + let engine = tokio::task::spawn_blocking(move || load_engine(&directory)) + .await + .map_err(|_| "gateway.doctor.state-unavailable")? + .map_err(|failure| failure.code)?; + let manifest = installation(state_dir).map_err(|_| "gateway.doctor.state-unavailable")?; + let qualification = engine.qualification().status(); + let kind = CredentialStoreKind::parse(&manifest.credential_store.kind) + .map_err(|_| "gateway.credential.adapter-unsupported")?; + let mut checks = engine.readiness_checks(kind).await; + checks.clock = if auths_gateway::DeploymentClock::trust(&SynchronizedHostClock) + == auths_gateway::ClockTrustState::Trusted + { + auths_gateway::PreconditionState::Ready + } else { + auths_gateway::PreconditionState::NotReady + }; + // load_engine verified pins, authenticated operator, separation and + // observer custody. Development is never production-ready. + if manifest.deployment != Deployment::Production { + checks.trust = auths_gateway::PreconditionState::NotReady; + } + let observer = match load_observer(state_dir)? { + None => auths_gateway::ObserverCustodyState::NotConfigured, + Some(key) if key.custody() != ObserverCustody::Software => { + auths_gateway::ObserverCustodyState::Ready + } + Some(_) => auths_gateway::ObserverCustodyState::NotReady, + }; + Ok(DoctorRuntime { + required: checks, + observer, + qualification_code: qualification.code.map(DoctorCode), + }) + } + + async fn doctor( state_dir: &Path, admin_socket: &Path, app_socket: &Path, @@ -2675,29 +3096,46 @@ mod unix { if !status.success() { return Err("gateway.doctor.isolation-not-established"); } - let observer = if state_dir.join(OBSERVER_SEED).exists() { - "configured" - } else { - "not configured (signed outcomes unavailable)" - }; - println!( - "gateway state and admin socket denied to app UID; app socket reachable. Independent token copies and egress policy not checked." - ); - println!("provider credential store: {custody}"); - println!("observer: {observer}"); - let manifest = installation(state_dir).map_err(|_| "gateway.doctor.state-unavailable")?; - let qualification = qualification_gate(state_dir, &manifest) - .map_err(|failure| failure.code)? - .status(); + // Runtime checks run as the actual gateway owner, so root does not + // accidentally bypass file ownership checks or mutate its custody. + let mut checked = runtime_doctor_as_owner(state_dir, state.uid(), state.gid()).await?; + checked.required.operator_plane_isolation = auths_gateway::PreconditionState::Ready; + let readiness = auths_gateway::ProductionReadiness::new(checked.required, checked.observer); println!( - "qualification: policy={} state={} code={}", - qualification.policy.as_str(), - qualification.state.as_str(), - qualification.code.unwrap_or("none") + "{}", + readiness.report(checked.qualification_code.map(|code| code.0)) ); + if !readiness.is_ready() { + return Err("gateway.doctor.not-ready"); + } Ok(()) } + async fn runtime_doctor_as_owner( + state_dir: &Path, + uid: u32, + gid: u32, + ) -> Result { + let binary = std::env::current_exe().map_err(|_| "gateway.doctor.binary-unavailable")?; + let directory = state_dir.to_path_buf(); + let output = tokio::task::spawn_blocking(move || { + std::process::Command::new(binary) + .arg("readiness-probe") + .arg("--state-dir") + .arg(directory) + .uid(uid) + .gid(gid) + .output() + }) + .await + .map_err(|_| "gateway.doctor.probe-unavailable")? + .map_err(|_| "gateway.doctor.probe-unavailable")?; + if !output.status.success() || output.stdout.len() > 64 * 1024 { + return Err("gateway.doctor.runtime-check-unavailable"); + } + serde_json::from_slice(&output.stdout).map_err(|_| "gateway.doctor.invalid-runtime-report") + } + fn probe(state_dir: &Path, admin_socket: &Path, app_socket: &Path) -> Result<(), &'static str> { // Doctor starts the probe with an empty environment. On Linux nothing // adds to it, so any variable was inherited and could carry operator @@ -2793,6 +3231,26 @@ mod unix { let command = serde_json::json!({"command": "qualification-reload"}); admin_command(&state_dir, &admin_socket, command, None).await } + Command::SupportBundle { + state_dir, + admin_socket, + out, + } => { + private_root(&state_dir)?; + let admin_socket = admin_socket_path(&state_dir, admin_socket); + let connection = support_connection(&state_dir, &admin_socket).await; + let facts = + tokio::task::spawn_blocking(move || support_facts(&state_dir, connection)) + .await + .map_err(|_| "gateway.support.unavailable")??; + let archive = auths_gateway::support_bundle(&facts)?; + if let Some(path) = out { + private_file(&path, &archive)?; + } else { + println!("{}", String::from_utf8_lossy(&archive)); + } + Ok(()) + } Command::QualificationStatus { state_dir, tuple } => { tokio::task::spawn_blocking(move || qualification_status(&state_dir, tuple)) .await @@ -2861,7 +3319,11 @@ mod unix { Command::Disable { state_dir, admin_socket, + store_only, } => { + if store_only { + return emergency_stop(&state_dir, false).await; + } let admin_socket = admin_socket_path(&state_dir, admin_socket); let command = serde_json::json!({"command": "disable"}); admin_command(&state_dir, &admin_socket, command, None).await @@ -2877,7 +3339,11 @@ mod unix { Command::Revoke { state_dir, admin_socket, + store_only, } => { + if store_only { + return emergency_stop(&state_dir, true).await; + } let admin_socket = admin_socket_path(&state_dir, admin_socket); let command = serde_json::json!({"command": "revoke"}); admin_command(&state_dir, &admin_socket, command, None).await @@ -2932,17 +3398,35 @@ mod unix { let admin_socket = admin_socket_path(&state_dir, admin_socket); operator_attest(&state_dir, &admin_socket.path, &operator_attestation) } + Command::CredentialCollect { state_dir } => { + let engine = operator_engine(&state_dir).await?; + let deleted = engine.collect_credentials().await?; + println!( + "{}", + serde_json::json!({"schema": "auths.gateway-credential-collection/1", "deleted": deleted}) + ); + Ok(()) + } Command::Rotate { state_dir, admin_socket, + operator_process, credential_stdin, } => { let admin_socket = admin_socket_path(&state_dir, admin_socket); - rotate(&state_dir, &admin_socket, credential_stdin, "rotate").await + rotate( + &state_dir, + &admin_socket, + credential_stdin, + "rotate", + operator_process, + ) + .await } Command::RotatePrepare { state_dir, admin_socket, + operator_process, credential_stdin, } => { let admin_socket = admin_socket_path(&state_dir, admin_socket); @@ -2951,14 +3435,31 @@ mod unix { &admin_socket, credential_stdin, "rotate-prepare", + operator_process, ) .await } Command::RotateCommit { state_dir, admin_socket, + operator_process, commitment, } => { + if operator_process { + let engine = operator_engine(&state_dir).await?; + let mut fixed = [0_u8; 32]; + if commitment.len() != 64 + || !commitment + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err("gateway.admin.invalid-frame".into()); + } + hex::decode_to_slice(&commitment, &mut fixed) + .map_err(|_| "gateway.admin.invalid-frame")?; + let response = AdminResponse::of(engine.commit_rotation(fixed).await); + return print_admin_response(&response); + } let admin_socket = admin_socket_path(&state_dir, admin_socket); admin_command( &state_dir, @@ -3007,7 +3508,16 @@ mod unix { &app_socket, app_uid, app_gid, - )?) + ) + .await?) + } + Command::ReadinessProbe { state_dir } => { + let checked = runtime_doctor(&state_dir).await?; + println!( + "{}", + serde_json::to_string(&checked).map_err(|_| "gateway.output")? + ); + Ok(()) } Command::Probe { state_dir, diff --git a/product/runtime/auths-gateway/src/connection.rs b/product/runtime/auths-gateway/src/connection.rs index 6fed0f0bd..e755d4320 100644 --- a/product/runtime/auths-gateway/src/connection.rs +++ b/product/runtime/auths-gateway/src/connection.rs @@ -25,6 +25,9 @@ const KEY_DOMAIN: &[u8] = b"auths.gateway-connection/1\0"; /// Why the shared connection record could not be read or changed. #[derive(Clone, Copy, Debug, Eq, PartialEq, Error)] pub enum SharedConnectionError { + /// The record is below the host's durable restore floor or has changed at an accepted generation. + #[error("the connection violates its durable generation floor")] + Rollback, /// A record already exists for this provider and alias. #[error("a connection record already exists")] Exists, @@ -82,9 +85,49 @@ pub struct SharedConnection { provider: ProviderKind, alias: ConnectionAlias, key: GatewayAttemptKey, + floor: Option>, } impl SharedConnection { + /// Commits an emergency stop using only the lifecycle store. No recipe, + /// trust input, provider, or credential-store access participates. + /// Existing attempts are untouched. Repeated stops are idempotent and a + /// revoked connection can never become merely disabled. + /// + /// # Errors + /// Returns a stable admin code for missing, corrupt, unavailable, or + /// concurrently changing state. This does not claim in-flight drainage + /// or deletion of the provider credential. + pub async fn stop(&self, revoke: bool, now: u64) -> Result { + for _ in 0..8 { + let current = self + .load() + .await + .map_err(|_| "gateway.admin.connection-unavailable")? + .ok_or("gateway.admin.connection-unavailable")?; + let target = if revoke { + ConnectionState::Revoked + } else { + ConnectionState::Disabled + }; + if current.record().state() == target + || current.record().state() == ConnectionState::Revoked + { + return Ok(current.record().clone()); + } + let next = current + .record() + .transition_state(target, now) + .map_err(|_| "gateway.admin.transition-unavailable")?; + match self.replace(¤t, &next).await { + Ok(committed) => return Ok(committed.record().clone()), + Err(SharedConnectionError::Conflict) => {} + Err(_) => return Err("gateway.admin.transition-unavailable"), + } + } + Err("gateway.admin.generation-conflict") + } + /// Names the record of `provider` and `alias` in `store`. #[must_use] pub fn new( @@ -98,7 +141,31 @@ impl SharedConnection { provider, alias, key, + floor: None, + } + } + + /// Installs the host's durable generation floor. Emergency store-only + /// stops deliberately use a connection without a floor so that even + /// obsolete restored state can be stopped. + #[must_use] + pub fn with_generation_floor(mut self, floor: crate::GenerationFloor) -> Self { + self.floor = Some(Arc::new(floor)); + self + } + + async fn accept_floor( + &self, + loaded: LoadedConnection, + ) -> Result { + if let Some(floor) = self.floor.clone() { + let record = loaded.record().clone(); + tokio::task::spawn_blocking(move || floor.accept(&record)) + .await + .map_err(|_| SharedConnectionError::Rollback)? + .map_err(|_| SharedConnectionError::Rollback)?; } + Ok(loaded) } /// The installed provider. @@ -123,7 +190,10 @@ impl SharedConnection { let key = self.key; let bytes = blocking(move || store.load(crate::GatewayRecordKind::Connection, &key)).await?; - bytes.map(|bytes| self.decode(bytes)).transpose() + match bytes { + Some(bytes) => self.accept_floor(self.decode(bytes)?).await.map(Some), + None => Ok(None), + } } /// Inserts the first record; a record already present is never @@ -170,7 +240,7 @@ impl SharedConnection { store.replace(crate::GatewayRecordKind::Connection, &key, &before, &after) }) .await?; - Ok(loaded) + self.accept_floor(loaded).await } fn encode(&self, record: &ConnectionRecord) -> Result { diff --git a/product/runtime/auths-gateway/src/credential_journal.rs b/product/runtime/auths-gateway/src/credential_journal.rs new file mode 100644 index 000000000..ed1e44571 --- /dev/null +++ b/product/runtime/auths-gateway/src/credential_journal.rs @@ -0,0 +1,200 @@ +//! Durable exact-generation cleanup notes. These contain no credential or +//! external location and are retained independently of database restores. + +use auths_connections::ConnectionId; +use serde::{Deserialize, Serialize}; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read as _, Write as _}, + num::NonZeroU64, + path::PathBuf, +}; + +const MAX_GENERATIONS: usize = 64; +const MAX_BYTES: u64 = 4096; +const CODE: &str = "gateway.admin.credential-journal-unavailable"; + +/// The private host's exact credential-generation cleanup notes. +#[derive(Clone)] +pub struct CredentialJournal { + directory: PathBuf, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Notes { + schema: String, + connection_id: String, + generations: Vec, +} + +impl CredentialJournal { + /// Names a private installation directory validated by the gateway CLI. + #[must_use] + pub const fn new(directory: PathBuf) -> Self { + Self { directory } + } + + /// Initializes a fresh installation's journal before storing a secret. + /// + /// # Errors + /// Refuses an existing journal for another connection or invalid state. + pub fn initialize(&self, id: &ConnectionId) -> Result<(), &'static str> { + self.change(id, true, |_| Ok(())).map(|_| ()) + } + + /// Records an exact generation before a custody mutation can create it. + /// + /// # Errors + /// Missing, malformed, oversized or full journals stop the mutation. + pub fn register(&self, id: &ConnectionId, generation: NonZeroU64) -> Result<(), &'static str> { + self.change(id, false, |notes| { + if !notes.generations.contains(&generation) { + notes.generations.push(generation); + notes.generations.sort_unstable(); + } + if notes.generations.len() > MAX_GENERATIONS { + return Err(CODE); + } + Ok(()) + }) + .map(|_| ()) + } + + /// Reads the bounded set of exact known generations, never a remote list. + /// + /// # Errors + /// Missing or damaged state is refused, not treated as an empty journal. + pub fn generations(&self, id: &ConnectionId) -> Result, &'static str> { + self.change(id, false, |_| Ok(())) + } + + /// Removes a note only after exact deletion succeeded. + /// + /// # Errors + /// An unavailable journal retains the cleanup obligation. + pub fn forget(&self, id: &ConnectionId, generation: NonZeroU64) -> Result<(), &'static str> { + self.change(id, false, |notes| { + notes.generations.retain(|known| *known != generation); + Ok(()) + }) + .map(|_| ()) + } + + #[cfg(unix)] + fn change( + &self, + id: &ConnectionId, + initialize: bool, + update: impl FnOnce(&mut Notes) -> Result<(), &'static str>, + ) -> Result, &'static str> { + let flags = i32::from_ne_bytes(rustix::fs::OFlags::NOFOLLOW.bits().to_ne_bytes()); + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(flags) + .open(self.directory.join("credential-journal.lock")) + .map_err(|_| CODE)?; + rustix::fs::flock(&lock, rustix::fs::FlockOperation::LockExclusive).map_err(|_| CODE)?; + let path = self.directory.join("credential-journal.json"); + let mut notes = match fs::symlink_metadata(&path) { + Ok(metadata) => { + if !metadata.is_file() + || metadata.permissions().mode() & 0o077 != 0 + || metadata.len() > MAX_BYTES + { + return Err(CODE); + } + let mut bytes = Vec::new(); + OpenOptions::new() + .read(true) + .custom_flags(flags) + .open(&path) + .map_err(|_| CODE)? + .take(MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| CODE)?; + if bytes.len() as u64 > MAX_BYTES { + return Err(CODE); + } + serde_json::from_slice::(&bytes).map_err(|_| CODE)? + } + Err(error) if initialize && error.kind() == std::io::ErrorKind::NotFound => Notes { + schema: "auths.gateway-credential-journal/1".to_owned(), + connection_id: id.as_str().to_owned(), + generations: Vec::new(), + }, + Err(_) => return Err(CODE), + }; + if notes.schema != "auths.gateway-credential-journal/1" + || notes.connection_id != id.as_str() + || notes.generations.len() > MAX_GENERATIONS + || !notes.generations.windows(2).all(|pair| pair[0] < pair[1]) + { + return Err(CODE); + } + update(&mut notes)?; + let bytes = serde_json::to_vec(¬es).map_err(|_| CODE)?; + let mut pending = tempfile::NamedTempFile::new_in(&self.directory).map_err(|_| CODE)?; + pending + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| CODE)?; + pending + .write_all(&bytes) + .and_then(|()| pending.as_file().sync_all()) + .map_err(|_| CODE)?; + pending.persist(path).map_err(|_| CODE)?; + File::open(&self.directory) + .and_then(|dir| dir.sync_all()) + .map_err(|_| CODE)?; + Ok(notes.generations) + } + + #[cfg(not(unix))] + fn change( + &self, + _id: &ConnectionId, + _initialize: bool, + _update: impl FnOnce(&mut Notes) -> Result<(), &'static str>, + ) -> Result, &'static str> { + Err(CODE) + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + #[test] + fn notes_survive_restart_and_refuse_missing_corrupt_full_or_foreign_state() { + let directory = tempfile::tempdir().expect("directory"); + let id = ConnectionId::parse("conn_AAAAAAAAAAAAAAAAAAAAAA").expect("id"); + let journal = CredentialJournal::new(directory.path().to_path_buf()); + assert_eq!(journal.generations(&id), Err(CODE)); + journal.initialize(&id).expect("initialize"); + for generation in 1..=64 { + journal + .register(&id, NonZeroU64::new(generation).expect("generation")) + .expect("note"); + } + assert_eq!( + journal.register(&id, NonZeroU64::new(65).expect("generation")), + Err(CODE) + ); + let reopened = CredentialJournal::new(directory.path().to_path_buf()); + assert_eq!(reopened.generations(&id).expect("notes").len(), 64); + let foreign = ConnectionId::parse("conn_AAAAAAAAAAAAAAAAAAAAAQ").expect("foreign id"); + assert_eq!(reopened.generations(&foreign), Err(CODE)); + reopened + .forget(&id, NonZeroU64::MIN) + .expect("deleted generation"); + assert_eq!(reopened.generations(&id).expect("notes").len(), 63); + fs::write(directory.path().join("credential-journal.json"), b"corrupt").expect("corrupt"); + assert_eq!(reopened.generations(&id), Err(CODE)); + } +} diff --git a/product/runtime/auths-gateway/src/engine.rs b/product/runtime/auths-gateway/src/engine.rs index 78054b420..f4e546bfa 100644 --- a/product/runtime/auths-gateway/src/engine.rs +++ b/product/runtime/auths-gateway/src/engine.rs @@ -234,6 +234,7 @@ pub struct GatewayEngine { profile: ConnectionProfile, connection: SharedConnection, credentials: Arc, + credential_journal: Option, /// Whether this deployment's recipe is qualified; consulted before every /// lease. qualification: Arc, @@ -302,6 +303,7 @@ impl GatewayEngine { profile, connection: SharedConnection::new(attempts.store(), provider, alias), credentials, + credential_journal: None, qualification: Arc::new(crate::QualificationGate::unconfigured()), attempts, in_flight: AtomicU64::new(0), @@ -323,6 +325,88 @@ impl GatewayEngine { self } + /// Pins the host's independently retained anti-rollback witness. + #[must_use] + pub fn with_generation_floor(mut self, floor: crate::GenerationFloor) -> Self { + self.connection = self.connection.with_generation_floor(floor); + self + } + + /// Attaches durable cleanup notes recorded before custody writes. + #[must_use] + pub fn with_credential_journal(mut self, journal: crate::CredentialJournal) -> Self { + self.credential_journal = Some(journal); + self + } + + async fn note_credential( + &self, + record: &ConnectionRecord, + next: std::num::NonZeroU64, + ) -> Result<(), &'static str> { + if let Some(journal) = self.credential_journal.clone() { + let id = record.connection_id().clone(); + let current = record.credential_generation(); + tokio::task::spawn_blocking(move || { + journal.register(&id, current)?; + journal.register(&id, next) + }) + .await + .map_err(|_| "gateway.admin.credential-journal-unavailable")??; + } + Ok(()) + } + + /// Collects at most sixteen exact generations from durable notes. It + /// waits the fixed retirement delay after observing the shared record, + /// requires it unchanged, and never deletes its active credential or a + /// future prepared generation. Run under operator workload identity. + /// Existing attempts and provider state are never changed. + /// + /// # Errors + /// A damaged journal, changed connection or failed deletion refuses and + /// retains the obligation for a later operator run. + pub async fn collect_credentials(&self) -> Result { + let journal = self + .credential_journal + .clone() + .ok_or("gateway.admin.credential-journal-unavailable")?; + let current = self.load_for_admin().await?; + let record = current.record().clone(); + let id = record.connection_id().clone(); + let reading = journal.clone(); + let generations = tokio::task::spawn_blocking(move || reading.generations(&id)) + .await + .map_err(|_| "gateway.admin.credential-journal-unavailable")??; + tokio::time::sleep(self.retirement_delay).await; + let latest = self.load_for_admin().await?; + if !latest.unchanged(¤t) { + return Err("gateway.admin.generation-conflict"); + } + let mut deleted = 0; + for generation in generations + .into_iter() + .filter(|generation| { + *generation <= record.generation() + && (record.state() == ConnectionState::Revoked + || *generation != record.credential_generation()) + }) + .take(16) + { + self.credentials + .revoke(record.connection_id(), generation) + .await + .map_err(|_| "gateway.admin.credential-deletion-incomplete")?; + let updating = journal.clone(); + let id = record.connection_id().clone(); + tokio::task::spawn_blocking(move || updating.forget(&id, generation)) + .await + .map_err(|_| "gateway.admin.credential-journal-unavailable")??; + deleted += 1; + } + Ok(deleted) + } + /// Installs the qualification gate the operator plane built for this /// deployment. Without one, every lease is refused as unqualified. #[must_use] @@ -439,18 +523,12 @@ impl GatewayEngine { /// # Errors /// A failed durable transition never reports disabled. pub async fn disable_connection(&self) -> Result { - let disabled = self - .change_state(|record, now| { - if record.state() != ConnectionState::Active { - return Err("gateway.admin.connection-not-active"); - } - record - .transition_state(ConnectionState::Disabled, now) - .map(Some) - .map_err(|_| "gateway.admin.transition-unavailable") - }) + self.connection + .stop( + false, + wall_clock_seconds().ok_or("gateway.admin.clock-unavailable")?, + ) .await?; - self.delete_superseded_credentials(&disabled).await; Ok(self.drained("gateway.admin.disabled").await) } @@ -540,6 +618,7 @@ impl GatewayEngine { let next = auths_connections::kernel::next_generation(record.generation().get()) .and_then(std::num::NonZeroU64::new) .ok_or("gateway.admin.generation-exhausted")?; + self.note_credential(record, next).await?; // Nothing can name a generation the record has not reached, so a // successor left by an earlier failed rotation is discarded, not reused. let _ = self.credentials.revoke(record.connection_id(), next).await; @@ -818,6 +897,52 @@ impl GatewayEngine { }) } + /// Performs the runtime part of production readiness without leasing a + /// credential or contacting a provider. The caller must separately check + /// process isolation, deployment clock and observer policy. This checks + /// the shared record, exact recipe binding, current generation, bounded + /// secret-store confirmation and pinned transport construction. + pub async fn readiness_checks( + &self, + credential_store: auths_connections::CredentialStoreKind, + ) -> crate::RequiredPreconditions { + use crate::PreconditionState::{NotReady, Ready}; + let mut checks = crate::RequiredPreconditions { + trust: Ready, + store: NotReady, + recipe: NotReady, + qualification: if self.qualification.status().state + == auths_recipe_qualification::RecipeQualificationState::Qualified + { + Ready + } else { + NotReady + }, + provider_secret_custody: NotReady, + connection_generation: NotReady, + clock: NotReady, + transport_policy: NotReady, + operator_plane_isolation: NotReady, + }; + if let Ok(Some(loaded)) = self.connection.load().await { + checks.store = Ready; + let record = loaded.record(); + if let Ok(descriptor) = GatewayConnectionDescriptor::from_record(record, &self.recipe) { + checks.recipe = Ready; + if GatewayHttpTransport::prepare(&self.recipe, descriptor.credential()).is_ok() { + checks.transport_policy = Ready; + } + } + if credential_store.is_production() && self.holds(record).await.is_ok() { + checks.provider_secret_custody = Ready; + } + if authorizes_entry(record, &self.workload_id, &self.profile) { + checks.connection_generation = Ready; + } + } + checks + } + /// Performs the operator's read-only re-observation of one stored /// attempt: one read-back from the stored plan under a fresh lease that /// passes every credential check, when the recipe digest is unchanged @@ -916,6 +1041,9 @@ impl GatewayEngine { async fn prepare_entry(&self) -> Result { let loaded = match self.connection.load().await { Ok(Some(loaded)) => loaded, + Err(SharedConnectionError::Rollback) => { + return Err("gateway.connection.restore-rollback"); + } Ok(None) | Err(_) => return Err("gateway.connection.unavailable"), }; let record = loaded.record(); @@ -1824,6 +1952,218 @@ pub(crate) mod tests { ); } + #[tokio::test] + async fn a_restored_store_below_the_host_floor_cannot_lease_after_restart() { + restored_store_floor(crate::store_testkit::Backend::File).await; + } + + #[tokio::test] + #[ignore = "requires the TLS PostgreSQL fixture"] + async fn postgres_restore_below_the_host_floor_cannot_lease_after_restart() { + restored_store_floor(crate::store_testkit::Backend::Postgres).await; + } + + async fn restored_store_floor(backend: crate::store_testkit::Backend) { + let installation = installation_on(backend, 8).await; + let first = &installation.first; + let old = first.record().await; + let directory = tempfile::tempdir().expect("floor directory"); + let floor = crate::GenerationFloor::new(directory.path().to_path_buf()); + floor.initialize(&old).expect("first generation"); + let disabled = first + .engine + .connection + .stop(false, wall_clock_seconds().expect("clock")) + .await + .expect("disable"); + floor.accept(&disabled).expect("new generation"); + let raw = SharedConnection::new( + first.engine.attempts.store(), + old.provider_kind().clone(), + old.alias().clone(), + ); + let current = raw.load().await.expect("load").expect("connection"); + raw.replace(¤t, &old) + .await + .expect("simulate restored database"); + let guarded = raw + .with_generation_floor(crate::GenerationFloor::new(directory.path().to_path_buf())); + assert_eq!(guarded.load().await, Err(SharedConnectionError::Rollback)); + let mut restarted = host(first.engine.attempts.clone(), 8); + restarted.engine.connection = guarded; + let before = restarted.leases.load(Ordering::SeqCst); + assert_eq!( + restarted.entry_refusal().await.as_deref(), + Some("gateway.connection.restore-rollback") + ); + assert_eq!(restarted.leases.load(Ordering::SeqCst), before); + assert_eq!( + floor.accept(&old), + Err("gateway.connection.restore-rollback") + ); + std::fs::write(directory.path().join("connection-floor.json"), b"corrupt") + .expect("corrupt floor"); + assert_eq!( + floor.accept(&disabled), + Err("gateway.connection.restore-rollback") + ); + for (generation, digest) in [ + (0, "00".repeat(32)), + (1, "invalid-digest".to_owned()), + (1, "AA".repeat(32)), + ] { + let damaged = serde_json::to_vec(&serde_json::json!({ + "schema": "auths.gateway-generation-floor/1", + "generation": generation, + "record_sha256": digest, + })) + .expect("damaged floor"); + let path = directory.path().join("connection-floor.json"); + std::fs::write(&path, &damaged).expect("write damaged floor"); + assert_eq!( + floor.accept(&disabled), + Err("gateway.connection.restore-rollback") + ); + assert_eq!(std::fs::read(path).expect("retained floor"), damaged); + assert_eq!( + restarted.entry_refusal().await.as_deref(), + Some("gateway.connection.restore-rollback") + ); + assert_eq!(restarted.leases.load(Ordering::SeqCst), before); + } + } + + #[tokio::test] + async fn durable_cleanup_retires_abandoned_generations_and_keeps_active_and_future_secrets() + { + let mut installation = installation(8).await; + let host = &mut installation.first; + let record = host.record().await; + let journal = crate::CredentialJournal::new( + host.credentials_directory + .parent() + .expect("private root") + .to_path_buf(), + ); + journal.initialize(record.connection_id()).expect("journal"); + journal + .register(record.connection_id(), record.credential_generation()) + .expect("initial note"); + host.engine.credential_journal = Some(journal.clone()); + host.engine + .prepare_rotation(candidate("abandoned-secret")) + .await + .expect("prepare"); + assert_eq!(host.stored(&installation.connection_id), [1, 2]); + host.engine.disable_connection().await.expect("disable"); + // Reopening notes models the operator process exiting after prepare. + host.engine.credential_journal = Some(crate::CredentialJournal::new( + host.credentials_directory + .parent() + .expect("root") + .to_path_buf(), + )); + assert_eq!( + host.engine + .collect_credentials() + .await + .expect("collect abandoned"), + 1 + ); + assert_eq!(host.stored(&installation.connection_id), [1]); + host.engine + .rotate_connection(candidate("published-secret")) + .await + .expect("rotate while disabled"); + host.engine + .collect_credentials() + .await + .expect("collect old"); + assert_eq!(host.stored(&installation.connection_id), [3]); + host.engine + .prepare_rotation(candidate("future-secret")) + .await + .expect("prepare future"); + assert_eq!( + host.engine + .collect_credentials() + .await + .expect("keep future"), + 0 + ); + assert_eq!(host.stored(&installation.connection_id), [3, 4]); + let before = host.leases.load(Ordering::SeqCst); + host.engine + .connection + .stop(true, wall_clock_seconds().expect("clock")) + .await + .expect("store-only revoke"); + host.engine + .collect_credentials() + .await + .expect("collect revoked"); + assert!(host.stored(&installation.connection_id).is_empty()); + assert_eq!(host.leases.load(Ordering::SeqCst), before); + assert!( + journal + .generations(&installation.connection_id) + .expect("notes") + .is_empty() + ); + } + + #[tokio::test] + async fn readiness_and_emergency_stop_make_no_credential_lease() { + let installation = installation(8).await; + let host = &installation.first; + let before = host.leases.load(Ordering::SeqCst); + let checked = host + .engine + .readiness_checks(auths_connections::CredentialStoreKind::LocalFileV1) + .await; + assert_eq!(checked.store, crate::PreconditionState::Ready); + assert_eq!(checked.recipe, crate::PreconditionState::Ready); + assert_eq!( + checked.connection_generation, + crate::PreconditionState::Ready + ); + assert_eq!( + checked.provider_secret_custody, + crate::PreconditionState::NotReady + ); + assert_eq!(checked.qualification, crate::PreconditionState::NotReady); + // Remove custody entirely. The store-only stop is still usable, + // is idempotent, and does not touch or reclassify attempts. + std::fs::remove_dir_all(&host.credentials_directory).expect("remove custody"); + let now = wall_clock_seconds().expect("clock"); + let stopped = host.engine.connection.stop(false, now).await.expect("stop"); + assert_eq!(stopped.state(), ConnectionState::Disabled); + let repeated = host + .engine + .connection + .stop(false, now) + .await + .expect("repeat"); + assert_eq!(stopped.generation(), repeated.generation()); + let revoked = host + .engine + .connection + .stop(true, now) + .await + .expect("revoke"); + assert_eq!(revoked.state(), ConnectionState::Revoked); + assert_eq!( + host.engine + .connection + .stop(false, now) + .await + .expect("stop revoked") + .state(), + ConnectionState::Revoked + ); + assert_eq!(host.leases.load(Ordering::SeqCst), before); + } + #[tokio::test] async fn a_prepared_rotation_changes_nothing_until_it_is_committed() { let installation = installation(8).await; @@ -1882,8 +2222,19 @@ pub(crate) mod tests { #[tokio::test] async fn a_record_that_changed_since_the_prepare_is_not_committed() { - let installation = installation(8).await; - let host = &installation.first; + let mut installation = installation(8).await; + let host = &mut installation.first; + let journal = crate::CredentialJournal::new( + host.credentials_directory + .parent() + .expect("private root") + .to_path_buf(), + ); + journal + .initialize(&installation.connection_id) + .expect("journal"); + host.engine.credential_journal = Some(journal); + let leases = host.leases.load(Ordering::SeqCst); let commitment = host .engine .prepare_rotation(candidate("prepared-secret")) @@ -1895,7 +2246,22 @@ pub(crate) mod tests { "gateway.admin.rotation-not-prepared", "the successor was stored for the generation before the disable" ); - // The emergency flow: rotate while disabled, then enable. + assert_eq!( + host.engine + .prepare_rotation(candidate("prepared-secret")) + .await, + Err("gateway.admin.credential-unavailable"), + "the abandoned successor blocks the current generation until collected" + ); + assert_eq!( + host.engine + .collect_credentials() + .await + .expect("collect abandoned"), + 1 + ); + // The emergency flow collects the abandoned successor before + // preparing another rotation while the connection remains disabled. let again = host .engine .prepare_rotation(candidate("prepared-secret")) @@ -1905,9 +2271,15 @@ pub(crate) mod tests { .commit_rotation(again) .await .expect("commit while disabled"); + assert_eq!(host.record().await.state(), ConnectionState::Disabled); + assert_eq!( + host.entry_refusal().await.as_deref(), + Some("gateway.connection.unavailable") + ); + assert_eq!(host.leases.load(Ordering::SeqCst), leases); host.engine.enable_connection().await.expect("enable"); assert!(host.engine.status().await.expect("status").credential_held); - assert!(host.entry_refusal().await.is_none()); + assert_eq!(host.entry_refusal().await, None); } #[tokio::test] @@ -2034,10 +2406,16 @@ pub(crate) mod tests { .await .expect("repeated revoke"); assert!(host.stored(&installation.connection_id).is_empty()); - assert_eq!( - host.engine.disable_connection().await, - Err("gateway.admin.connection-not-active") - ); + let generation = host.record().await.generation(); + let stopped = host + .engine + .disable_connection() + .await + .expect("stop revoked"); + assert_eq!(stopped.code, "gateway.admin.disabled"); + assert_eq!(host.record().await.state(), ConnectionState::Revoked); + assert_eq!(host.record().await.generation(), generation); + assert!(host.stored(&installation.connection_id).is_empty()); } async fn a_second_host_joins_only_with_the_matching_secret_after_state_changes( diff --git a/product/runtime/auths-gateway/src/generation_floor.rs b/product/runtime/auths-gateway/src/generation_floor.rs new file mode 100644 index 000000000..67c0260ec --- /dev/null +++ b/product/runtime/auths-gateway/src/generation_floor.rs @@ -0,0 +1,145 @@ +//! Host-local anti-rollback witness retained independently of database backups. +//! It records only a generation and the digest of the exact connection record. + +use auths_connections::ConnectionRecord; +use auths_recipe_qualification::Sha256Digest; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read as _, Write as _}, + num::NonZeroU64, + path::PathBuf, +}; + +/// A durable floor for the one connection an installed gateway serves. +/// Retain this file and its qualification floors independently of restores. +#[derive(Clone)] +pub struct GenerationFloor { + directory: PathBuf, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Accepted { + schema: String, + generation: NonZeroU64, + record_sha256: Sha256Digest, +} + +impl GenerationFloor { + /// Names the already-private installation directory. The gateway CLI + /// checks ownership and rejects symlink paths before using it. + #[must_use] + pub const fn new(directory: PathBuf) -> Self { + Self { directory } + } + + /// Creates the floor during a fresh installation. An existing floor is + /// checked rather than replaced; runtime startup never initializes one. + /// + /// # Errors + /// Returns the restore-rollback code if the floor cannot be persisted. + pub fn initialize(&self, record: &ConnectionRecord) -> Result<(), &'static str> { + self.persist(record, true) + .map_err(|()| "gateway.connection.restore-rollback") + } + + /// Accepts an equal or newer exact record, durably recording a newer one + /// before it can authorize a lease. A missing floor refuses; a + /// malformed, inaccessible, older or substituted floor fails closed. + /// + /// # Errors + /// Returns `gateway.connection.restore-rollback`; no secret is leased. + pub fn accept(&self, record: &ConnectionRecord) -> Result<(), &'static str> { + self.persist(record, false) + .map_err(|()| "gateway.connection.restore-rollback") + } + + #[cfg(unix)] + fn persist(&self, record: &ConnectionRecord, initialize: bool) -> Result<(), ()> { + let mut options = OpenOptions::new(); + options + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(i32::from_ne_bytes( + rustix::fs::OFlags::NOFOLLOW.bits().to_ne_bytes(), + )); + let lock = options + .open(self.directory.join("connection-floor.lock")) + .map_err(|_| ())?; + rustix::fs::flock(&lock, rustix::fs::FlockOperation::LockExclusive).map_err(|_| ())?; + let path = self.directory.join("connection-floor.json"); + let digest = Sha256Digest::from_bytes( + Sha256::digest(record.to_canonical_cbor().map_err(|_| ())?).into(), + ); + match fs::symlink_metadata(&path) { + Ok(metadata) => { + if !metadata.is_file() + || metadata.permissions().mode() & 0o077 != 0 + || metadata.len() > 1024 + { + return Err(()); + } + let mut bytes = Vec::new(); + OpenOptions::new() + .read(true) + .custom_flags(i32::from_ne_bytes( + rustix::fs::OFlags::NOFOLLOW.bits().to_ne_bytes(), + )) + .open(&path) + .map_err(|_| ())? + .take(1025) + .read_to_end(&mut bytes) + .map_err(|_| ())?; + if bytes.len() > 1024 { + return Err(()); + } + let old: Accepted = serde_json::from_slice(&bytes).map_err(|_| ())?; + if old.schema != "auths.gateway-generation-floor/1" + || old.generation > record.generation() + { + return Err(()); + } + if old.generation == record.generation() { + return if old.record_sha256 == digest { + Ok(()) + } else { + Err(()) + }; + } + } + Err(error) if initialize && error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(()), + } + let bytes = serde_json::to_vec(&Accepted { + schema: "auths.gateway-generation-floor/1".to_owned(), + generation: record.generation(), + record_sha256: digest, + }) + .map_err(|_| ())?; + let mut pending = tempfile::NamedTempFile::new_in(&self.directory).map_err(|_| ())?; + pending + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| ())?; + pending + .write_all(&bytes) + .and_then(|()| pending.as_file().sync_all()) + .map_err(|_| ())?; + pending.persist(path).map_err(|_| ())?; + File::open(&self.directory) + .and_then(|dir| dir.sync_all()) + .map_err(|_| ()) + } + + #[cfg(not(unix))] + fn persist(&self, _record: &ConnectionRecord, _initialize: bool) -> Result<(), ()> { + Err(()) + } +} diff --git a/product/runtime/auths-gateway/src/lib.rs b/product/runtime/auths-gateway/src/lib.rs index b4b3e3f24..094e32937 100644 --- a/product/runtime/auths-gateway/src/lib.rs +++ b/product/runtime/auths-gateway/src/lib.rs @@ -13,10 +13,12 @@ mod audit; mod binding; mod bounds; mod connection; +mod credential_journal; mod echo_verify; mod engine; #[cfg(feature = "fuzzing")] pub mod fuzzing; +mod generation_floor; #[cfg(unix)] pub mod listener; mod observer; @@ -30,6 +32,7 @@ mod semantic_closure; mod separation; mod store; mod submit; +mod support; mod transport; #[cfg(test)] @@ -68,6 +71,7 @@ pub use connection::{ LoadedConnection, SharedConnection, SharedConnectionError, authorizes_entry, connection_key, install_connection, join_connection, }; +pub use credential_journal::CredentialJournal; pub use echo_verify::{ ECHO_VERIFICATION_NOTE, ECHO_VERIFICATION_SCHEMA, EchoResult, EchoVerification, EchoVerifyError, MAX_ECHO_POINTER_BYTES, MAX_ECHO_RECORD_BYTES, canonical_action_commitment, @@ -78,6 +82,7 @@ pub use engine::{ GatewayEvidenceSummary, GatewayObserveRequest, GatewayObserveResult, GatewaySubmitResult, SLOT_SWEEP_INTERVAL_SECONDS, SLOT_SWEEP_LIMIT, gateway_verifier_configuration, }; +pub use generation_floor::GenerationFloor; pub use observer::{ GatewayObserver, GatewayObserverError, GatewaySignedObservation, OBSERVATION_MEDIA_TYPE, OPERATION_SUBJECT_SCHEME, OUTCOME_SCHEMA, ObserverAnchorTemplate, ObserverCustody, @@ -126,4 +131,8 @@ pub use store::{ PrivateDirectoryError, check_private_directory, check_private_directory_owned_by, pre_entry_digest, }; +pub use support::{ + MAX_SUPPORT_ATTEMPTS, SUPPORT_BUNDLE_SCHEMA, SupportConnection, SupportConnectionState, + SupportFacts, support_bundle, +}; pub use transport::MAX_TRANSPORT_DURATION; diff --git a/product/runtime/auths-gateway/src/pending_vectors/production.rs b/product/runtime/auths-gateway/src/pending_vectors/production.rs index 8fc94149c..69fd3ba26 100644 --- a/product/runtime/auths-gateway/src/pending_vectors/production.rs +++ b/product/runtime/auths-gateway/src/pending_vectors/production.rs @@ -12,9 +12,9 @@ //! that try to select custody or declare qualification, the fixed //! retirement delay, and redacted debug forms. The qualification codes are //! implemented by the gate, whose tests drive every verification vector. -//! The rest wait for the readiness codes and the support bundle. For those -//! this module asserts the shortfall: no product crate defines a readiness -//! code, and the gateway has no support bundle. The secret-name and version +//! The support bundle exists and its own test scans it for every canary of +//! the `redaction` section. Readiness checks and restore floors are implemented +//! and exercised in readiness and engine tests. The secret-name and version //! vectors are generated here from the stated derivation and driven by the //! Secrets Manager store's own tests, so the two agree or one of them fails. Those assertions are expected to fail when the implementing work //! lands, wherever it lands, and that work replaces each with the @@ -63,7 +63,17 @@ const ROWS: &[&str] = &[ "gateway.qualification.unavailable qualification before-lease implemented 3 V:index-signature-forged", "gateway.qualification.revocation-stale qualification before-lease implemented 3 V:revocation-list-past-next-update", "gateway.qualification.clock-untrusted qualification before-lease implemented 3 V:clock-untrusted", - "gateway.readiness.connection-disabled readiness doctor new 4 -", + "gateway.readiness.connection-disabled readiness doctor implemented 4 -", + "gateway.readiness.trust-unavailable readiness doctor implemented 4 -", + "gateway.readiness.store-unavailable readiness doctor implemented 4 -", + "gateway.readiness.recipe-drift readiness doctor implemented 4 -", + "gateway.readiness.transport-unavailable readiness doctor implemented 4 -", + "gateway.readiness.observer-unavailable readiness doctor implemented 4 -", + "gateway.connection.restore-rollback connection before-lease implemented 4 -", + "gateway.admin.credential-journal-unavailable operator before-custody implemented 4 -", + "gateway.support.unavailable support support-bundle implemented 4 -", + "gateway.support.attempts-unavailable support support-bundle implemented 4 -", + "gateway.support.connection-unavailable support support-bundle implemented 4 -", "gateway.attempt.replay engine recorded existing - C:lease-never-precedes-claim", "gateway.connection.credential-generation-missing engine before-claim existing - C:lease-generation-not-held", "gateway.credential.unavailable engine recorded existing - C:lease-commitment-mismatch", @@ -75,7 +85,7 @@ const ROWS: &[&str] = &[ /// pending. Until their epics land, the gateway defines no code under them. /// The qualification family is implemented: the gate's own tests drive /// every verification vector through it. -const NEW_FAMILIES: &[&str] = &["gateway.readiness."]; +const NEW_FAMILIES: &[&str] = &[]; /// Every hostile class the production work must have a vector for. const REQUIRED_CLASSES: &[&str] = &[ @@ -1130,8 +1140,7 @@ fn every_hostile_class_has_a_vector() { /// The inventory is closed over the vectors, every existing code exists, /// and the rest of the production work has not landed: no product crate -/// defines a new code or a code under a new family, and the gateway has no -/// support bundle under any spelling. The secret-name and version vectors +/// defines a new code or a code under a new family. The secret-name and version vectors /// are no longer pending: the Secrets Manager store derives them, and its /// own tests drive `secret_names` and `version_references`. #[test] @@ -1182,12 +1191,4 @@ fn production_codes_await_their_epics() { ); } } - for (path, text) in &gateway { - let folded = text.to_lowercase().replace(['-', '_', ' '], ""); - assert!( - !folded.contains("supportbundle"), - "{} has a support bundle: scan it for every redaction canary", - path.display() - ); - } } diff --git a/product/runtime/auths-gateway/src/qualification.rs b/product/runtime/auths-gateway/src/qualification.rs index 2c22fb7a7..b5be98dcf 100644 --- a/product/runtime/auths-gateway/src/qualification.rs +++ b/product/runtime/auths-gateway/src/qualification.rs @@ -16,7 +16,7 @@ use auths_recipe_qualification::{ }; use sha2::{Digest as _, Sha256}; use std::sync::{Arc, Mutex, PoisonError, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// The install-time refusal of a qualification policy that is unknown or /// that production does not permit. @@ -106,11 +106,13 @@ fn system_seconds() -> Option { .map(|elapsed| elapsed.as_secs()) } -/// The production clock adapter: trusted exactly while the host's time -/// synchronization service has recorded a synchronization. +/// The production clock adapter: trusted while the host's synchronization +/// marker records a sample no more than fifteen minutes old. /// -/// The service records one by creating a fixed marker file. The path is a -/// constant of this adapter and no configuration names another. +/// Missing, future-dated, stale, symlinked or nonregular markers refuse. On +/// Unix, group/world-writable markers also refuse. The service updates the +/// fixed file after each synchronization; mere existence cannot establish +/// freshness. Neither the path nor the age bound is configurable. #[derive(Clone, Copy, Debug, Default)] pub struct SynchronizedHostClock; @@ -122,17 +124,40 @@ impl SynchronizedHostClock { impl sealed::Sealed for SynchronizedHostClock {} +const MAX_SYNCHRONIZATION_AGE: Duration = Duration::from_mins(15); + +fn synchronization_marker_trust(path: &std::path::Path, now: SystemTime) -> ClockTrustState { + let Ok(marker) = std::fs::symlink_metadata(path) else { + return ClockTrustState::Untrusted; + }; + if !marker.is_file() { + return ClockTrustState::Untrusted; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if marker.permissions().mode() & 0o022 != 0 { + return ClockTrustState::Untrusted; + } + } + let age = marker + .modified() + .ok() + .and_then(|sample| now.duration_since(sample).ok()); + if age.is_some_and(|age| age <= MAX_SYNCHRONIZATION_AGE) { + ClockTrustState::Trusted + } else { + ClockTrustState::Untrusted + } +} + impl DeploymentClock for SynchronizedHostClock { fn now(&self) -> Option { system_seconds() } fn trust(&self) -> ClockTrustState { - if std::fs::metadata(Self::MARKER).is_ok_and(|marker| marker.is_file()) { - ClockTrustState::Trusted - } else { - ClockTrustState::Untrusted - } + synchronization_marker_trust(std::path::Path::new(Self::MARKER), SystemTime::now()) } } @@ -539,3 +564,79 @@ pub fn deployment_tuple(facts: &DeploymentFacts<'_>) -> Option &'static str { + match self { + Self::Trust => "gateway.readiness.trust-unavailable", + Self::Store => "gateway.readiness.store-unavailable", + Self::Recipe => "gateway.readiness.recipe-drift", + Self::Qualification => "gateway.qualification.unavailable", + Self::ProviderSecretCustody => "gateway.credential.unavailable", + Self::ConnectionGeneration => "gateway.readiness.connection-disabled", + Self::Clock => "gateway.qualification.clock-untrusted", + Self::TransportPolicy => "gateway.readiness.transport-unavailable", + Self::OperatorPlaneIsolation => "gateway.doctor.isolation-not-established", + Self::ObserverCustody => "gateway.readiness.observer-unavailable", + } + } + /// The preconditions every production deployment requires, in the order /// they are reported. pub const REQUIRED: [Self; 9] = [ @@ -156,7 +174,8 @@ impl ReadinessPrecondition { } /// The outcome of one required check. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] pub enum PreconditionState { /// The check was made and passed. Ready, @@ -165,7 +184,8 @@ pub enum PreconditionState { } /// The state of observer signing custody. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] pub enum ObserverCustodyState { /// No observer is configured. Signed observer outcomes are unavailable, /// which is reported and does not make the deployment unready. @@ -178,7 +198,8 @@ pub enum ObserverCustodyState { /// The state of every required precondition. Each one must be named: there /// is no default, so a check that was never made cannot be read as passed. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct RequiredPreconditions { /// See [`ReadinessPrecondition::Trust`]. pub trust: PreconditionState, @@ -230,6 +251,30 @@ pub struct ProductionReadiness { } impl ProductionReadiness { + /// Secret-free machine-readable diagnostic projection. Every required + /// check is present, including failures; optional observer custody is + /// explicitly reported. A failed qualification retains its precise code. + #[must_use] + pub fn report(&self, qualification_code: Option<&'static str>) -> serde_json::Value { + let mut checks: Vec<_> = ReadinessPrecondition::REQUIRED.into_iter().map(|check| { + let ready = self.required.state(check) == Some(PreconditionState::Ready); + serde_json::json!({ + "check": check.as_str(), "ready": ready, + "code": if ready { None } else if check == ReadinessPrecondition::Qualification { + Some(qualification_code.unwrap_or(check.code())) + } else { Some(check.code()) } + }) + }).collect(); + checks.push( + serde_json::json!({"check": "observer-custody", "state": match self.observer { + ObserverCustodyState::NotConfigured => "not-configured", + ObserverCustodyState::Ready => "ready", + ObserverCustodyState::NotReady => "not-ready", + }}), + ); + serde_json::json!({"schema": "auths.gateway-readiness/1", "ready": self.is_ready(), "checks": checks}) + } + /// Combines the outcome of every check. #[must_use] pub const fn new(required: RequiredPreconditions, observer: ObserverCustodyState) -> Self { @@ -289,6 +334,27 @@ mod tests { } } + #[test] + fn the_projection_names_every_check_and_retains_precise_qualification_failure() { + let readiness = ProductionReadiness::new( + required((1 << 3) | (1 << 5)), + ObserverCustodyState::NotConfigured, + ); + let report = readiness.report(Some("gateway.qualification.revoked")); + assert_eq!(report["ready"], false); + let checks = report["checks"].as_array().expect("checks"); + assert_eq!(checks.len(), 10); + assert_eq!(checks[3]["code"], "gateway.qualification.revoked"); + assert_eq!(checks[5]["code"], "gateway.readiness.connection-disabled"); + assert_eq!(checks[9]["state"], "not-configured"); + for bit in 0..9 { + let report = ProductionReadiness::new(required(1 << bit), ObserverCustodyState::Ready) + .report(None); + assert_eq!(report["ready"], false); + assert!(report["checks"][bit]["code"].is_string()); + } + } + #[test] fn retirement_delay_outlives_entered_transport() { let delay = CredentialRetirementDelay::FIXED.as_duration(); diff --git a/product/runtime/auths-gateway/src/scenario_tests.rs b/product/runtime/auths-gateway/src/scenario_tests.rs index 5948b554c..1bcf85f4b 100644 --- a/product/runtime/auths-gateway/src/scenario_tests.rs +++ b/product/runtime/auths-gateway/src/scenario_tests.rs @@ -94,6 +94,10 @@ impl RecordingStore { } impl GatewayAttemptStore for RecordingStore { + fn attempt_keys(&self, limit: usize) -> Result, GatewayAttemptError> { + self.inner.attempt_keys(limit) + } + fn insert_all( &self, entries: &[GatewayRecordEntry], diff --git a/product/runtime/auths-gateway/src/semantic_closure.rs b/product/runtime/auths-gateway/src/semantic_closure.rs index 8ad00f3d0..94260912d 100644 --- a/product/runtime/auths-gateway/src/semantic_closure.rs +++ b/product/runtime/auths-gateway/src/semantic_closure.rs @@ -10,7 +10,7 @@ /// SHA-256 of the schema, a NUL byte, and `semantic-closure.json`. /// Regenerate through `semantic_closure_is_current` with `AUTHS_UPDATE_FIXTURES=1`. pub const GATEWAY_SEMANTIC_CLOSURE_SHA256: &str = - "92401d5852022a9b5540db598ac6dfb1a8fbb727ff563b66eca7f440176cc057"; + "464c6c17b8b6f969da548b4df9bdebf8bfe58066727a5a2fe5f150b443854c52"; #[cfg(test)] mod tests { diff --git a/product/runtime/auths-gateway/src/store.rs b/product/runtime/auths-gateway/src/store.rs index bdf086179..c18ab29c0 100644 --- a/product/runtime/auths-gateway/src/store.rs +++ b/product/runtime/auths-gateway/src/store.rs @@ -984,6 +984,13 @@ pub trait GatewayAttemptStore: Send + Sync { /// # Errors /// Returns [`GatewayAttemptError::Unavailable`] when storage fails. fn sweep_expired(&self, now: u64, limit: usize) -> Result; + + /// Lists at most `limit` keys of stored attempts, in key order. A key is + /// a digest and names no operation. + /// + /// # Errors + /// Returns [`GatewayAttemptError::Unavailable`] when storage fails. + fn attempt_keys(&self, limit: usize) -> Result, GatewayAttemptError>; } /// Atomic file store for one host. This is not a multi-host store and does @@ -1287,6 +1294,10 @@ fn valid_file_name(name: &str) -> bool { } impl GatewayAttemptStore for FileGatewayAttemptStore { + fn attempt_keys(&self, limit: usize) -> Result, GatewayAttemptError> { + self.claimed_keys(limit) + } + fn insert_all( &self, entries: &[GatewayRecordEntry], @@ -1418,7 +1429,39 @@ impl Drop for PostgresGatewayAttemptStore { } } +impl FileGatewayAttemptStore { + fn claimed_keys(&self, limit: usize) -> Result, GatewayAttemptError> { + let _lock = self.shared()?; + let mut keys = Vec::new(); + for entry in fs::read_dir(&self.root).map_err(|_| GatewayAttemptError::Unavailable)? { + let name = entry + .map_err(|_| GatewayAttemptError::Unavailable)? + .file_name(); + let key = name + .to_str() + .filter(|name| valid_file_name(name)) + .and_then(|name| name.strip_prefix("claim-")) + .and_then(|name| name.strip_suffix(".json")); + if let Some(key) = key { + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(key, &mut bytes).map_err(|_| GatewayAttemptError::Corrupt)?; + keys.push(GatewayAttemptKey(bytes)); + } + } + keys.sort_unstable(); + keys.truncate(limit); + Ok(keys) + } +} + impl GatewayAttemptStore for PostgresGatewayAttemptStore { + fn attempt_keys(&self, limit: usize) -> Result, GatewayAttemptError> { + self.store()? + .list_gateway_record_keys(GatewayRecordKind::Attempt, limit) + .map(|keys| keys.into_iter().map(GatewayAttemptKey).collect()) + .map_err(postgres_error) + } + fn insert_all( &self, entries: &[GatewayRecordEntry], @@ -1581,6 +1624,40 @@ impl GatewayAttempts { }) } + /// Lists at most `limit` stored attempts as the hexadecimal digest that + /// keys each one and its conservative stage. Nothing else of an attempt + /// is returned: no operation identifier, argument, or provider value. + /// + /// # Errors + /// Malformed state is a hard failure. + pub async fn stages( + &self, + limit: usize, + ) -> Result, GatewayAttemptError> { + let store = Arc::clone(&self.store); + blocking(move || { + store + .attempt_keys(limit)? + .into_iter() + .filter_map(|key| { + let stage = store + .load(GatewayRecordKind::Attempt, &key) + .and_then(|bytes| bytes.map(|bytes| decode(&bytes)).transpose()) + .and_then(|record| record.map(|record| record.snapshot(true)).transpose()); + match stage { + Ok(Some(snapshot)) => { + Some(Ok((hex::encode(key.as_bytes()), snapshot.stage()))) + } + // Deleted between the listing and the read. + Ok(None) => None, + Err(error) => Some(Err(error)), + } + }) + .collect() + }) + .await + } + /// Reads a secret-free durable snapshot. An `attempting` stage is /// conservatively projected as `unknown`: another process may hold it, or /// it may have crashed. diff --git a/product/runtime/auths-gateway/src/support.rs b/product/runtime/auths-gateway/src/support.rs new file mode 100644 index 000000000..bdc382932 --- /dev/null +++ b/product/runtime/auths-gateway/src/support.rs @@ -0,0 +1,314 @@ +//! The redacted archive an operator sends when asking for help. +//! +//! The archive is built from [`SupportFacts`], which has no member that can +//! carry text from a request, a provider, or an operator: every member is a +//! digest, a closed state, a stable code, a count, or a bounded integer. A +//! proof, grant, action, request or response body, credential, credential +//! location, key-manager resource name, provider account or resource +//! identifier, or raw header therefore has no member to arrive through. An +//! attempt appears as the digest that keys it and its stage. + +use crate::{GatewayAttemptStage, QualificationStatus}; +use serde::Serialize; +use std::collections::BTreeMap; + +/// The schema of the archive. +pub const SUPPORT_BUNDLE_SCHEMA: &str = "auths.gateway-support-bundle/1"; +/// The most attempts one archive lists. +pub const MAX_SUPPORT_ATTEMPTS: usize = 256; + +/// The closed state of the shared connection record. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SupportConnectionState { + /// Entries may start. + Active, + /// The operator disabled new entries. + Disabled, + /// The connection is revoked. + Revoked, +} + +/// What a serving gateway reported about its connection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SupportConnection { + /// The record's state. + pub state: SupportConnectionState, + /// The record's generation. + pub generation: u64, + /// The credential generation the record seals. + pub credential_generation: u64, + /// Whether the process holds the secret the record commits to. + pub credential_held: bool, + /// The process's in-flight entries. + pub in_flight: u64, +} + +/// Everything an archive is built from. No member holds free text. +#[derive(Clone, Debug)] +pub struct SupportFacts { + /// SHA-256 of the running executable. + pub build_sha256: [u8; 32], + /// The compiled recipe's digest. + pub recipe_sha256: [u8; 32], + /// SHA-256 of the installed profile lock. + pub profile_lock_sha256: [u8; 32], + /// SHA-256 of the installed trusted context. + pub trusted_context_sha256: [u8; 32], + /// Whether the installation is a production deployment. + pub production: bool, + /// The credential store in use. + pub credential_store_kind: auths_connections::CredentialStoreKind, + /// The qualification gate's state. + pub qualification: QualificationStatus, + /// The connection, when a serving gateway answered. + pub connection: Option, + /// Stored attempts as key digest and stage, at most + /// [`MAX_SUPPORT_ATTEMPTS`]; `None` when the store could not be read. + pub attempts: Option>, + /// Whether the store holds more attempts than were listed. + pub attempts_truncated: bool, +} + +const fn stage_token(stage: GatewayAttemptStage) -> &'static str { + match stage { + GatewayAttemptStage::NotEntered => "not-entered", + GatewayAttemptStage::Attempting => "attempting", + GatewayAttemptStage::ResponseRecorded => "response-recorded", + GatewayAttemptStage::Unknown => "unknown", + GatewayAttemptStage::Observed => "observed", + GatewayAttemptStage::ObservedByProvider => "observed-by-provider", + } +} + +#[derive(Serialize)] +struct Qualification { + policy: &'static str, + state: &'static str, + code: Option<&'static str>, +} + +#[derive(Serialize)] +struct Connection { + state: &'static str, + generation: u64, + credential_generation: u64, + credential_held: bool, + in_flight: u64, +} + +#[derive(Serialize)] +struct Attempt { + key_sha256: String, + stage: &'static str, +} + +#[derive(Serialize)] +struct Attempts { + listed: usize, + truncated: bool, + by_stage: BTreeMap<&'static str, u64>, + identifiers: Vec, +} + +#[derive(Serialize)] +struct Archive { + schema: &'static str, + gateway_package: &'static str, + gateway_version: &'static str, + semantic_closure_sha256: &'static str, + build_sha256: String, + recipe_sha256: String, + profile_lock_sha256: String, + trusted_context_sha256: String, + deployment: &'static str, + credential_store_kind: &'static str, + qualification: Qualification, + connection: Option, + attempts: Option, + codes: Vec<&'static str>, +} + +/// Builds the archive: canonical JSON of bounded size. +/// +/// # Errors +/// +/// Returns `gateway.support.unavailable` when the archive cannot be encoded +/// or a supplied diagnostic code is outside the closed qualification set. +pub fn support_bundle(facts: &SupportFacts) -> Result, &'static str> { + use auths_recipe_qualification::QualificationRefusal; + if facts.qualification.code.is_some_and(|code| { + ![ + QualificationRefusal::Unavailable, + QualificationRefusal::Revoked, + QualificationRefusal::ClockUntrusted, + QualificationRefusal::RevocationStale, + QualificationRefusal::Missing, + QualificationRefusal::Expired, + QualificationRefusal::DigestMismatch, + QualificationRefusal::TargetMismatch, + ] + .into_iter() + .any(|refusal| crate::qualification_code(refusal) == code) + }) { + return Err("gateway.support.unavailable"); + } + let attempts = facts.attempts.as_ref().map(|available| { + let listed = &available[..available.len().min(MAX_SUPPORT_ATTEMPTS)]; + let mut by_stage = BTreeMap::new(); + for (_, stage) in listed { + *by_stage.entry(stage_token(*stage)).or_insert(0_u64) += 1; + } + Attempts { + listed: listed.len(), + truncated: facts.attempts_truncated || available.len() > MAX_SUPPORT_ATTEMPTS, + by_stage, + identifiers: listed + .iter() + .map(|(key, stage)| Attempt { + key_sha256: hex::encode(key), + stage: stage_token(*stage), + }) + .collect(), + } + }); + let mut codes: Vec<&'static str> = facts.qualification.code.into_iter().collect(); + if facts.attempts.is_none() { + codes.push("gateway.support.attempts-unavailable"); + } + if facts.connection.is_none() { + codes.push("gateway.support.connection-unavailable"); + } + let archive = Archive { + schema: SUPPORT_BUNDLE_SCHEMA, + gateway_package: env!("CARGO_PKG_NAME"), + gateway_version: env!("CARGO_PKG_VERSION"), + semantic_closure_sha256: crate::GATEWAY_SEMANTIC_CLOSURE_SHA256, + build_sha256: hex::encode(facts.build_sha256), + recipe_sha256: hex::encode(facts.recipe_sha256), + profile_lock_sha256: hex::encode(facts.profile_lock_sha256), + trusted_context_sha256: hex::encode(facts.trusted_context_sha256), + deployment: if facts.production { + "production" + } else { + "development" + }, + credential_store_kind: facts.credential_store_kind.as_str(), + qualification: Qualification { + policy: facts.qualification.policy.as_str(), + state: facts.qualification.state.as_str(), + code: facts.qualification.code, + }, + connection: facts.connection.map(|connection| Connection { + state: match connection.state { + SupportConnectionState::Active => "active", + SupportConnectionState::Disabled => "disabled", + SupportConnectionState::Revoked => "revoked", + }, + generation: connection.generation, + credential_generation: connection.credential_generation, + credential_held: connection.credential_held, + in_flight: connection.in_flight, + }), + attempts, + codes, + }; + serde_json_canonicalizer::to_vec(&archive).map_err(|_| "gateway.support.unavailable") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{QualificationPolicy, QualificationStatus}; + use auths_recipe_qualification::RecipeQualificationState; + + fn facts() -> SupportFacts { + SupportFacts { + build_sha256: [1; 32], + recipe_sha256: [2; 32], + profile_lock_sha256: [3; 32], + trusted_context_sha256: [4; 32], + production: true, + credential_store_kind: auths_connections::CredentialStoreKind::AwsSecretsManagerV1, + qualification: QualificationStatus { + policy: QualificationPolicy::Required, + state: RecipeQualificationState::Stale, + code: Some("gateway.qualification.revocation-stale"), + }, + connection: Some(SupportConnection { + state: SupportConnectionState::Disabled, + generation: 7, + credential_generation: 3, + credential_held: true, + in_flight: 0, + }), + attempts: Some(vec![ + ([9; 32], GatewayAttemptStage::Unknown), + ([8; 32], GatewayAttemptStage::ObservedByProvider), + ([7; 32], GatewayAttemptStage::Unknown), + ]), + attempts_truncated: false, + } + } + + #[test] + fn an_archive_holds_digests_closed_states_codes_and_counts() { + let archive: serde_json::Value = + serde_json::from_slice(&support_bundle(&facts()).expect("archive")).expect("JSON"); + assert_eq!(archive["schema"], SUPPORT_BUNDLE_SCHEMA); + assert_eq!(archive["deployment"], "production"); + assert_eq!(archive["credential_store_kind"], "aws-secrets-manager-v1"); + assert_eq!(archive["qualification"]["state"], "stale"); + assert_eq!(archive["connection"]["state"], "disabled"); + assert_eq!(archive["attempts"]["by_stage"]["unknown"], 2); + assert_eq!( + archive["attempts"]["identifiers"][1]["key_sha256"], + "08".repeat(32) + ); + assert_eq!( + archive["codes"], + serde_json::json!(["gateway.qualification.revocation-stale"]) + ); + } + + #[test] + fn what_could_not_be_read_is_stated_and_the_listing_is_bounded() { + let mut absent = facts(); + absent.connection = None; + absent.attempts = None; + let archive: serde_json::Value = + serde_json::from_slice(&support_bundle(&absent).expect("archive")).expect("JSON"); + assert!(archive["connection"].is_null() && archive["attempts"].is_null()); + assert_eq!( + archive["codes"], + serde_json::json!([ + "gateway.qualification.revocation-stale", + "gateway.support.attempts-unavailable", + "gateway.support.connection-unavailable" + ]) + ); + let mut many = facts(); + many.attempts = Some(vec![([5; 32], GatewayAttemptStage::NotEntered); 400]); + many.attempts_truncated = false; + let bytes = support_bundle(&many).expect("archive"); + let archive: serde_json::Value = serde_json::from_slice(&bytes).expect("JSON"); + assert_eq!(archive["attempts"]["listed"], MAX_SUPPORT_ATTEMPTS); + assert_eq!(archive["attempts"]["truncated"], true); + assert!(bytes.len() < 64 * 1024, "the archive is bounded"); + } + + #[test] + fn arbitrary_diagnostic_text_cannot_enter_the_archive() { + let mut contaminated = facts(); + for code in [ + "synthetic-canary-provider-response", + "gateway.qualification.revoked synthetic-canary-raw-header", + "gateway.qualification.unknown-code", + ] { + contaminated.qualification.code = Some(code); + assert_eq!( + support_bundle(&contaminated), + Err("gateway.support.unavailable") + ); + } + } +} diff --git a/product/runtime/auths-gateway/tests/isolated_process.rs b/product/runtime/auths-gateway/tests/isolated_process.rs index 16c777f42..325c71383 100644 --- a/product/runtime/auths-gateway/tests/isolated_process.rs +++ b/product/runtime/auths-gateway/tests/isolated_process.rs @@ -16,7 +16,10 @@ use std::{ time::{Duration, Instant}, }; -const BIN: &str = env!("CARGO_BIN_EXE_auths-gateway"); +const BIN: &str = match option_env!("AUTHS_GATEWAY_OPERATOR_TEST_BINARY") { + Some(packaged) => packaged, + None => env!("CARGO_BIN_EXE_auths-gateway"), +}; const RECIPE: &[u8] = include_bytes!("../../../../bindings/fixtures/gateway/airtable/recipe.json"); const LOCK: &[u8] = include_bytes!("../../../../bindings/fixtures/gateway/airtable/profile.lock.json"); @@ -73,13 +76,20 @@ fn run_doctor(state: &Path, app_socket: &Path, group: &str, phase: &str) { .output() .expect("run distinct-UID doctor"); assert!( - doctor.status.success(), - "distinct-UID boundary failed during {phase}: {}", - String::from_utf8_lossy(&doctor.stderr) - ); - assert!( - String::from_utf8_lossy(&doctor.stdout).contains("gateway state and admin socket denied") + !doctor.status.success(), + "development is not production-ready during {phase}" ); + assert!(String::from_utf8_lossy(&doctor.stderr).contains("gateway.doctor.not-ready")); + let report: serde_json::Value = + serde_json::from_slice(&doctor.stdout).expect("readiness report"); + assert_eq!(report["ready"], false); + let isolation = report["checks"] + .as_array() + .expect("checks") + .iter() + .find(|check| check["check"] == "operator-plane-isolation") + .expect("isolation check"); + assert_eq!(isolation["ready"], true); } fn doctor_command( @@ -271,10 +281,15 @@ fn distinct_uid_cannot_reach_a_relocated_admin_socket() { let relocated = doctor_command(&state, Some(&admin_socket), &app_socket, group.trim()) .output() .expect("run distinct-UID doctor"); + assert!(!relocated.status.success()); + assert!(String::from_utf8_lossy(&relocated.stderr).contains("gateway.doctor.not-ready")); + let report: serde_json::Value = serde_json::from_slice(&relocated.stdout).expect("report"); assert!( - relocated.status.success(), - "distinct-UID boundary failed with a relocated admin socket: {}", - String::from_utf8_lossy(&relocated.stderr) + report["checks"] + .as_array() + .expect("checks") + .iter() + .any(|check| check["check"] == "operator-plane-isolation" && check["ready"] == true) ); let forgotten = doctor_command(&state, None, &app_socket, group.trim()) diff --git a/product/runtime/auths-gateway/tests/operator_plane.rs b/product/runtime/auths-gateway/tests/operator_plane.rs index 44e4f38e1..64bb5088a 100644 --- a/product/runtime/auths-gateway/tests/operator_plane.rs +++ b/product/runtime/auths-gateway/tests/operator_plane.rs @@ -19,7 +19,10 @@ use std::{ time::{Duration, Instant}, }; -const BIN: &str = env!("CARGO_BIN_EXE_auths-gateway"); +const BIN: &str = match option_env!("AUTHS_GATEWAY_OPERATOR_TEST_BINARY") { + Some(packaged) => packaged, + None => env!("CARGO_BIN_EXE_auths-gateway"), +}; const RECIPE: &[u8] = include_bytes!("../../../../bindings/fixtures/gateway/airtable/recipe.json"); const LOCK: &[u8] = include_bytes!("../../../../bindings/fixtures/gateway/airtable/profile.lock.json"); @@ -589,3 +592,78 @@ fn postgres_production_processes_share_the_connection() { assert_eq!(revoked["code"], "gateway.admin.revoked", "{revoked}"); assert_eq!(status(&second)["state"], "revoked"); } + +#[test] +fn an_emergency_stop_works_with_no_running_gateway_or_readable_custody() { + let (_directory, root) = private_root(); + let state = root.join("emergency"); + let installed = install( + &root, + &state, + &["--account-label", "fixture"], + b"synthetic-token\n", + ); + assert!(installed.status.success(), "{}", stderr(&installed)); + fs::remove_file(state.join("credentials.cbor")).expect("remove custody"); + fs::write(state.join("qualification-state.json"), b"unavailable").expect("bad qualification"); + for (command, expected) in [ + ("disable", "disabled"), + ("disable", "disabled"), + ("revoke", "revoked"), + ("disable", "revoked"), + ] { + let stopped = run( + Command::new(BIN) + .arg(command) + .arg("--state-dir") + .arg(&state) + .arg("--store-only"), + b"", + ); + assert!(stopped.status.success(), "{}", stderr(&stopped)); + let report: serde_json::Value = serde_json::from_slice(&stopped.stdout).expect("report"); + assert_eq!(report["state"], expected); + assert_eq!(report["drainage"], "not-checked"); + assert_eq!(report["credential_deletion"], "not-attempted"); + } +} + +#[test] +fn sigterm_drains_admitted_sessions_and_removes_both_socket_paths() { + let (_directory, root) = private_root(); + let state = root.join("shutdown"); + let installed = install( + &root, + &state, + &["--account-label", "fixture"], + b"synthetic-token\n", + ); + assert!(installed.status.success(), "{}", stderr(&installed)); + let app = root.join("shutdown.sock"); + let mut gateway = serve(&state, &app); + let pending = std::os::unix::net::UnixStream::connect(&app).expect("admitted stream"); + thread::sleep(Duration::from_millis(100)); + let signal = Command::new("kill") + .arg("-TERM") + .arg(gateway.0.id().to_string()) + .status() + .expect("signal"); + assert!(signal.success()); + thread::sleep(Duration::from_millis(100)); + assert!( + gateway.0.try_wait().expect("wait").is_none(), + "an admitted session is still draining" + ); + drop(pending); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = gateway.0.try_wait().expect("wait") { + assert!(status.success()); + break; + } + assert!(Instant::now() < deadline, "shutdown did not complete"); + thread::sleep(Duration::from_millis(20)); + } + assert!(!app.exists()); + assert!(!state.join("admin.sock").exists()); +} diff --git a/product/runtime/auths-gateway/tests/support_bundle.rs b/product/runtime/auths-gateway/tests/support_bundle.rs new file mode 100644 index 000000000..a69cfbb8f --- /dev/null +++ b/product/runtime/auths-gateway/tests/support_bundle.rs @@ -0,0 +1,248 @@ +//! The support bundle of a real installation, scanned for every canary the +//! custody fixture plants: the provider credential, the account label, and +//! files and environment variables holding each other excluded source. + +#![cfg(unix)] +#![allow(clippy::too_many_lines, reason = "one journey reads top to bottom")] + +use auths_gateway::CompiledRecipe; +use auths_recipe_qualification_issuance::stages::{ScanSource, SourceKind, redaction}; +use std::{ + fs::{self, File}, + io::{BufRead as _, BufReader, Write as _}, + os::unix::fs::PermissionsExt as _, + path::Path, + process::{Child, Command, Output, Stdio}, + thread, + time::{Duration, Instant}, +}; + +const BIN: &str = match option_env!("AUTHS_GATEWAY_OPERATOR_TEST_BINARY") { + Some(packaged) => packaged, + None => env!("CARGO_BIN_EXE_auths-gateway"), +}; +const RECIPE: &[u8] = include_bytes!("../../../../bindings/fixtures/gateway/airtable/recipe.json"); +const LOCK: &[u8] = + include_bytes!("../../../../bindings/fixtures/gateway/airtable/profile.lock.json"); +const TRUST: &[u8] = + include_bytes!("../../../../core/fixtures/v1/denied/untrusted-root.context.cbor"); +const CUSTODY: &[u8] = include_bytes!("../../../../bindings/fixtures/gateway/custody-hostile.json"); + +struct Running(Child); + +impl Drop for Running { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// Every canary of the fixture, by the source it is planted in. +fn canaries() -> Vec<(String, String)> { + let fixture: serde_json::Value = serde_json::from_slice(CUSTODY).expect("fixture"); + fixture["redaction"]["canaries"]["sources"] + .as_array() + .expect("sources") + .iter() + .map(|source| { + let text = |member: &str| source[member].as_str().expect("text").to_owned(); + (text("source"), text("canary")) + }) + .collect() +} + +fn run(command: &mut Command, stdin: &[u8]) -> Output { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn"); + child + .stdin + .take() + .expect("stdin") + .write_all(stdin) + .expect("stdin bytes"); + let deadline = Instant::now() + Duration::from_secs(30); + while child.try_wait().expect("poll command").is_none() { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("the support command exceeded its deadline"); + } + thread::sleep(Duration::from_millis(20)); + } + child.wait_with_output().expect("output") +} + +fn bundle(state: &Path, planted: &[(String, String)]) -> Vec { + let mut command = Command::new(BIN); + command.arg("support-bundle").arg("--state-dir").arg(state); + for (source, canary) in planted { + command.env( + format!("AUTHS_TEST_{}", source.replace('-', "_").to_uppercase()), + canary, + ); + } + let output = run(&mut command, b""); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output.stdout +} + +#[test] +fn a_support_bundle_holds_no_planted_canary() { + let planted = canaries(); + assert_eq!(planted.len(), 11); + let canary = |source: &str| { + planted + .iter() + .find(|(name, _)| name == source) + .map(|(_, canary)| canary.clone()) + .expect("canary") + }; + let directory = tempfile::tempdir().expect("directory"); + let root = fs::canonicalize(directory.path()).expect("root"); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).expect("mode"); + fs::write(root.join("recipe.json"), RECIPE).expect("recipe"); + fs::write(root.join("profile.lock.json"), LOCK).expect("lock"); + fs::write(root.join("trusted.context.cbor"), TRUST).expect("trust"); + let state = root.join("state"); + let digest = CompiledRecipe::compile(RECIPE, LOCK) + .expect("recipe") + .digest_hex(); + + // The credential and the provider account label are the two canaries an + // installation takes in directly. + let installed = run( + Command::new(BIN) + .arg("install") + .arg("--state-dir") + .arg(&state) + .arg("--recipe") + .arg(root.join("recipe.json")) + .arg("--profile-lock") + .arg(root.join("profile.lock.json")) + .arg("--trusted-context") + .arg(root.join("trusted.context.cbor")) + .arg("--approve-digest") + .arg(&digest) + .arg("--provider") + .arg("airtable") + .arg("--alias") + .arg("demo") + .arg("--account-label") + .arg(canary("provider-account-id")) + .arg("--credential-stdin"), + format!("{}\n", canary("credential")).as_bytes(), + ); + assert!( + installed.status.success(), + "{}", + String::from_utf8_lossy(&installed.stderr) + ); + // Every other excluded source, left where a careless collector would + // sweep it up: beside the installation and in the environment. + for (source, canary) in &planted { + fs::write(state.join(format!("{source}.captured")), canary).expect("planted file"); + } + + let offline = bundle(&state, &planted); + let archive: serde_json::Value = serde_json::from_slice(&offline).expect("archive"); + assert_eq!(archive["schema"], "auths.gateway-support-bundle/1"); + assert_eq!(archive["recipe_sha256"], digest.as_str()); + assert_eq!(archive["deployment"], "development"); + assert_eq!(archive["attempts"]["listed"], 0); + assert!(archive["connection"].is_null(), "no gateway is serving"); + assert!( + archive["codes"] + .as_array() + .expect("codes") + .iter() + .any(|code| code == "gateway.support.connection-unavailable") + ); + + // A socket can exist and accept connections without ever returning a + // status. Collection must finish with partial, explicitly unavailable + // facts rather than hang or claim that the process is not serving. + let admin_path = state.join("admin.sock"); + let unavailable_admin = std::os::unix::net::UnixListener::bind(&admin_path).expect("socket"); + fs::set_permissions(&admin_path, fs::Permissions::from_mode(0o600)).expect("socket mode"); + let started = Instant::now(); + let unavailable = bundle(&state, &planted); + assert!(started.elapsed() < Duration::from_secs(25)); + let report: serde_json::Value = serde_json::from_slice(&unavailable).expect("report"); + assert!(report["connection"].is_null()); + assert!( + report["codes"] + .as_array() + .expect("codes") + .iter() + .any(|code| code == "gateway.support.connection-unavailable") + ); + drop(unavailable_admin); + fs::remove_file(&admin_path).expect("remove unavailable socket"); + + let mut child = Command::new(BIN) + .arg("serve") + .arg("--state-dir") + .arg(&state) + .arg("--app-socket") + .arg(root.join("app.sock")) + .stdout(Stdio::piped()) + .stderr(File::create(root.join("serve.stderr")).expect("stderr")) + .spawn() + .expect("serve"); + let mut ready = String::new(); + BufReader::new(child.stdout.take().expect("stdout")) + .read_line(&mut ready) + .expect("readiness"); + let _serving = Running(child); + assert!(ready.starts_with("app socket ready"), "{ready}"); + let serving = bundle(&state, &planted); + let archive: serde_json::Value = serde_json::from_slice(&serving).expect("archive"); + assert_eq!(archive["connection"]["state"], "active"); + assert_eq!(archive["connection"]["credential_held"], true); + + let secrets: Vec<&[u8]> = planted + .iter() + .map(|(_, canary)| canary.as_bytes()) + .collect(); + let sources = [ + ScanSource { + kind: SourceKind::SupportBundle, + name: "offline", + bytes: &offline, + }, + ScanSource { + kind: SourceKind::SupportBundle, + name: "serving", + bytes: &serving, + }, + ScanSource { + kind: SourceKind::SupportBundle, + name: "unavailable-admin", + bytes: &unavailable, + }, + ]; + let scanned = redaction(&secrets, &sources).expect("scan"); + assert!(scanned.iter().all(|case| case.passed), "{scanned:?}"); + + // The scan is able to find one: the same bytes with a canary appended + // fail it. + let leaking = [offline.as_slice(), canary("raw-header").as_bytes()].concat(); + let found = redaction( + &secrets, + &[ScanSource { + kind: SourceKind::SupportBundle, + name: "leaking", + bytes: &leaking, + }], + ) + .expect("scan"); + assert!(!found[0].passed); +} diff --git a/product/stores/auths-stores/src/gateway_attempt.rs b/product/stores/auths-stores/src/gateway_attempt.rs index 6d023cd74..b2f85a5ba 100644 --- a/product/stores/auths-stores/src/gateway_attempt.rs +++ b/product/stores/auths-stores/src/gateway_attempt.rs @@ -219,6 +219,38 @@ impl PostgresLifecycleStore { .transpose() } + /// Lists at most `limit` keys of the records of `kind`, in key order. + /// The records themselves are not read. + /// + /// # Errors + /// + /// Returns [`StoreError::LimitExceeded`] for a limit the database cannot + /// hold, [`StoreError::Corrupt`] for a key of another width, and a + /// closed store error when the database is unavailable. + pub fn list_gateway_record_keys( + &self, + kind: GatewayRecordKind, + limit: usize, + ) -> Result, StoreError> { + let limit = i64::try_from(limit).map_err(|_| StoreError::LimitExceeded)?; + let mut client = self.pool.get().map_err(|_| StoreError::PoolExhausted)?; + client + .query( + "SELECT record_key FROM auths_gateway_records + WHERE record_kind = $1 + ORDER BY record_key + LIMIT $2", + &[&kind.as_str(), &limit], + ) + .map_err(|error| map_postgres_error(&error))? + .iter() + .map(|row| { + let key: Vec = row.try_get(0).map_err(|_| StoreError::Corrupt)?; + <[u8; 32]>::try_from(key.as_slice()).map_err(|_| StoreError::Corrupt) + }) + .collect() + } + /// Replaces the record of `kind` under `key` with `next` only while it /// still holds exactly `current`. Slots are insert-only. /// diff --git a/release/semantic-freeze-versions.toml b/release/semantic-freeze-versions.toml index 19f4d261f..e7c30e343 100644 --- a/release/semantic-freeze-versions.toml +++ b/release/semantic-freeze-versions.toml @@ -1,4 +1,4 @@ -freeze_version = 358 +freeze_version = 369 # Semantic identity counters live outside the xtask source tree deliberately. # The formal source closure binds xtask's executable code, while the semantic @@ -16,10 +16,10 @@ freeze_version = 358 "auths.frozen-bytes/core/fixtures/v1/manifest.json" = 15 "auths.frozen-bytes/core/formal-vectors/v1/manifest.json" = 1 "auths.frozen-bytes/demos/benchmarks/profiles/release.toml" = 1 -"auths.frozen-bytes/formal/assurance-manifest-v1.toml" = 57 +"auths.frozen-bytes/formal/assurance-manifest-v1.toml" = 58 "auths.frozen-bytes/formal/qualification/aeneas/generated" = 19 "auths.frozen-bytes/formal/qualification/aeneas/qualification.toml" = 16 -"auths.frozen-bytes/formal/qualification/aeneas/source-closure.json" = 95 +"auths.frozen-bytes/formal/qualification/aeneas/source-closure.json" = 97 "auths.frozen-bytes/product/conformance/v1/mechanism-profile-conformance.json" = 1 "auths.frozen-bytes/product/conformance/v1/simplified-product-waist.json" = 4 "auths.frozen-bytes/product/fixtures/v1/bounded-policy/manifest.json" = 3 @@ -45,7 +45,7 @@ freeze_version = 358 "auths.frozen-bytes/product/integrations/auths-stripe/fixtures/subscription-create/v1/manifest.sha256.json" = 1 "auths.frozen-bytes/product/integrations/auths-stripe/fixtures/subscription-modify/v1/manifest.sha256.json" = 1 "auths.frozen-bytes/product/integrations/auths-stripe/fixtures/v1/manifest.sha256.json" = 1 -"auths.identity.protocol" = 114 +"auths.identity.protocol" = 123 "auths.modular-components" = 20 "auths.portable-abi-bindings" = 121 "auths.product.bounded-domains" = 19 @@ -60,11 +60,11 @@ freeze_version = 358 "auths.product.mcp-closed-execution" = 31 "auths.product.mechanism-profile-conformance" = 12 "auths.product.operations" = 5 -"auths.product.public-sdk-contract" = 111 +"auths.product.public-sdk-contract" = 120 "auths.product.receipts" = 13 "auths.product.release-assurance" = 7 "auths.product.simplified-waist" = 19 -"auths.product.vocabulary" = 23 +"auths.product.vocabulary" = 24 "auths.release.benchmark-contract" = 1 "auths.release.evolution-contract" = 79 -"auths.release.public-surface" = 358 +"auths.release.public-surface" = 369 diff --git a/release/semantic-freeze.json b/release/semantic-freeze.json index be054f4bd..ac0a07006 100644 --- a/release/semantic-freeze.json +++ b/release/semantic-freeze.json @@ -1,6 +1,6 @@ { "schema": "auths.semantic-freeze/1", - "freezeVersion": 358, + "freezeVersion": 369, "publicSurface": { "rustRoots": [ "auths", @@ -202,7 +202,7 @@ }, { "id": "auths.frozen-bytes/formal/assurance-manifest-v1.toml", - "version": 57, + "version": 58, "classification": "frozen-bytes", "categories": [ "canonical-generated-evidence" @@ -210,7 +210,7 @@ "owners": [ "formal/assurance-manifest-v1.toml" ], - "sha256": "c6f930fe3c8aa5e174e140fa92a801b1737f81b27a3fa926065151db51bd2248" + "sha256": "cc87f4480cc9eb559c810eba8534b844d05616e7e74c8732b39b5315616c65c3" }, { "id": "auths.frozen-bytes/formal/qualification/aeneas/generated", @@ -238,7 +238,7 @@ }, { "id": "auths.frozen-bytes/formal/qualification/aeneas/source-closure.json", - "version": 95, + "version": 97, "classification": "frozen-bytes", "categories": [ "canonical-generated-evidence" @@ -246,7 +246,7 @@ "owners": [ "formal/qualification/aeneas/source-closure.json" ], - "sha256": "671652536007842f3a6999e2f1af224ef2580fb83ab02411a15dde3e5323b901" + "sha256": "32b4ba2c0a643b402d2109abc0b4dbe125827a20d4760911654e078dcfe02d29" }, { "id": "auths.frozen-bytes/product/conformance/v1/mechanism-profile-conformance.json", @@ -550,7 +550,7 @@ }, { "id": "auths.identity.protocol", - "version": 114, + "version": 123, "classification": "frozen-meaning", "categories": [ "identity-protocol-versions", @@ -573,7 +573,7 @@ "core/fixtures/identity/v1/vectors.json", "core/spec/identity/v1" ], - "sha256": "e65c35e8f18375a40e644113e800f56e0ed464988a7e198aa33f2b536aea6bde" + "sha256": "c2496ff9803989ecb37dc36cf9c5cb7c1def8fa70789688645bff82dee578953" }, { "id": "auths.modular-components", @@ -871,7 +871,7 @@ }, { "id": "auths.product.public-sdk-contract", - "version": 111, + "version": 120, "classification": "frozen-meaning", "categories": [ "rust-sdk-contract", @@ -889,7 +889,7 @@ "product/runtime/auths-runtime/src", "product/sdk/auths-sdk/src" ], - "sha256": "838d240b4b9de1b40ff6067fdf54bb3c12a2c6abc00dd52dcc268e35bdb0064d" + "sha256": "6fff68d41c5a1a2b40986b62a3310ff05086b68ad8fd23cbcccb1d37f24b2de4" }, { "id": "auths.product.receipts", @@ -947,7 +947,7 @@ }, { "id": "auths.product.vocabulary", - "version": 23, + "version": 24, "classification": "frozen-meaning", "categories": [ "customer-vocabulary", @@ -965,7 +965,7 @@ "product/sdk/auths-sdk/Cargo.toml", "xtask/src/sdk_vocabulary.rs" ], - "sha256": "2d81589c35f1b410a03d66d8d921bbeb3844d3af43f3e20de5fefd4d5bbfd7bb" + "sha256": "58d763d436b6b376d5b36e59a26b7dd4633c6f09950d7d85cc63c88ebd8eee2b" }, { "id": "auths.release.benchmark-contract", @@ -1011,7 +1011,7 @@ }, { "id": "auths.release.public-surface", - "version": 358, + "version": 369, "classification": "release-metadata", "categories": [ "package-names", @@ -1103,7 +1103,7 @@ "xtask/src/release_control.rs", "xtask/src/semantic_freeze.rs" ], - "sha256": "3ba4d57ecaf562394b7fc08528138a741776607cd448706e550b5bb07ad4a28e" + "sha256": "2ec62100d0f39cb3cd4c6786286d1de77c279aefbb34bdce730867e764708039" } ] } diff --git a/xtask/ci-plan/src/lib.rs b/xtask/ci-plan/src/lib.rs index 2814e8a92..1e81bbe63 100644 --- a/xtask/ci-plan/src/lib.rs +++ b/xtask/ci-plan/src/lib.rs @@ -3329,6 +3329,21 @@ serde = "1" assert!(!opentofu.contains("postgresql_live")); assert!(!opentofu.contains("records_api_live")); + assert_eq!( + phases_for("deployment/gateway/systemd/auths-gateway.service"), + BTreeSet::from([ + "authoritative", + "compliance", + "secrets", + "postgresql_live", + "release" + ]) + ); + assert_eq!( + phases_for("deployment/gateway/tools/exercise-postgres-restore.sh"), + phases_for("deployment/gateway/systemd/auths-gateway.service") + ); + let control_plane = phases_for(".github/actions/setup-rust-cache/action.yml"); for phase in &loaded.manifest.phases { assert!(control_plane.contains(phase.id.as_str()));