diff --git a/credential-change-requests/CCR-2026-0021-approval-engine-audit.yaml b/credential-change-requests/CCR-2026-0021-approval-engine-audit.yaml new file mode 100644 index 0000000..0c5f0a3 --- /dev/null +++ b/credential-change-requests/CCR-2026-0021-approval-engine-audit.yaml @@ -0,0 +1,110 @@ +id: CCR-2026-0021 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: approval-engine load-bearing audit sender custody and delivery +status: proposed +created: '2026-09-11' +updated: '2026-09-11' +requester: + agent: codex + reason: AUDIT-WP-0009-T09 and HFACT-WP-0001-T03 require the existing exact sender declaration. This + request supplies the missing platform custody and ESO delivery; receiver compatibility and service + rollout remain explicit gates. +review: + required: true + required_approvers: + - platform-operator + - audit-core-owner + - approval-engine-owner + comments: [] +target: + domain: financials + tenant: platform + workload: approval-engine + environment: production + purpose: Provide only the approval-engine sender with source=approval-engine, tenants=[tenant:platform], + may_write=true, may_read=false, evidence_kind=load-bearing, secret_policy=redact. Preserve the + existing receiver registry and every other sender. +openbao: + mount: platform + kv_path: platform/workloads/approval-engine/audit-sender + fields: + - AUDIT_TOKEN + - CUSTODY_REQUEST + policy_name: workload-kv-read-approval-engine-audit + policy_file: openbao/policies/workload-kv-read-approval-engine-audit.hcl + metadata_read: false + auth: + method: kubernetes + mount: kubernetes + role: external-secrets-approval-engine-audit + bound_claims: + service_account_names: + - external-secrets + service_account_namespaces: + - external-secrets + bound_claims_confirmed: true + policies: + - workload-kv-read-approval-engine-audit + ttl: 15m +access_frontdoor: + type: external-secrets + catalog_id: approval-engine-audit + readiness: pending-review + resolvable: false +delivery: + surface: external-secrets + target: 'ClusterSecretStore openbao-approval-engine-audit restricted to namespace approval-engine; + ExternalSecret and Secret approval-engine/approval-engine-audit, key audit-token. Source: manifests/factory-audit-senders.yaml. + A missing namespace stays a workload-owner prerequisite; this packet creates none.' +risk: + classification: high + notes: + - A bearer permits append for its exact sender and tenant, never reading or changing stored evidence. + - The registry update reads existing sender credentials inside the attended platform process only. + No registry is delivered to a producer. + - OpenBao login TTL limits the ESO reader session, not the audit bearer. Revocation must remove + the sender token from the receiver registry and prove refusal. + - New paths extend the existing coding-agent deny boundary. No existing high-risk grant is widened. +verification: + positive: + - Independent CAS=0 token creation and exact sender merge with registry-version compare-and-set; + repeated runs preserve both tokens and other registry fields. + - ESO delivers only AUDIT_TOKEN into approval-engine/approval-engine-audit:audit-token; source and + receiver copies agree without displaying values. + - Compatible deployed receiver accepts and deduplicates a declared synthetic event for the exact + sender and tenant, retaining load-bearing/redact declarations. + negative: + - Sibling sender path, full registry and parent listing are denied to each workload reader. Wrong + service account/namespace and disallowed store namespace fail. + - Receiver denies sibling source, wrong tenant, every read route and revoked token. No existing + sender is removed or re-scoped. + - Proposed or altered CCR, legacy receiver, malformed/duplicate registry identity, token collision, + stale registry version or partial/conflicting custody refuses without overwriting. + activation_conditions: + - All three named owner reviews are approved before any native credential mutation. + - A current image supporting evidence_kind is published/admitted by audit-core and deployed with + its source scope and network policy; current c2fe39a image fails compatibility. + - Use the Warden attended platform-admin login envelope; preserve independent receipt and revoke + the session on exit. + - Apply reviewed policy/auth metadata and ESO projection only after receiver compatibility and namespace + readiness. An interrupted seed resumes from durable KV values, never blindly rotates or deletes. + - Record native positive/negative evidence before declaring verified or active. Custody alone does + not admit UI, human approval or factory execution. +lifecycle: + deactivate: Stop the exact producer; remove only its admitted token from the registry using CAS + and reload/verify receiver refusal. Then detach its reader policy and remove its ExternalSecret/projection, + retaining KV versions for investigation. Do not delete audit events or other sender entries. + rotate: 'Reviewed overlap-first rotation: append a replacement to this sender only, deliver it, + prove acceptance, then remove the predecessor and prove refusal. The first-provision helper refuses + rotation and unexpected existing values.' + compromised: Stop affected producer and revoke the exact receiver token first; inspect affected + source/tenant events, rotate through a separate reviewed action, and preserve the independent + audit trail. +state_hub: + workplan_id: RPF-WP-0035 + task_id: RPF-WP-0035-T08 + related_workplan: AUDIT-WP-0009-T09 + decision_id: 2c9fe9f0-034a-41d7-9d49-b99df488fdc8 + decision_api_url: http://127.0.0.1:8000/decisions/2c9fe9f0-034a-41d7-9d49-b99df488fdc8 diff --git a/credential-change-requests/CCR-2026-0022-informed-decision-audit.yaml b/credential-change-requests/CCR-2026-0022-informed-decision-audit.yaml new file mode 100644 index 0000000..5fb71c0 --- /dev/null +++ b/credential-change-requests/CCR-2026-0022-informed-decision-audit.yaml @@ -0,0 +1,110 @@ +id: CCR-2026-0022 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: informed-decision load-bearing audit sender custody and delivery +status: proposed +created: '2026-09-11' +updated: '2026-09-11' +requester: + agent: codex + reason: AUDIT-WP-0009-T11 and HFACT-WP-0001-T03 require the existing exact sender declaration. This + request supplies the missing platform custody and ESO delivery; receiver compatibility and service + rollout remain explicit gates. +review: + required: true + required_approvers: + - platform-operator + - audit-core-owner + - informed-decision-owner + comments: [] +target: + domain: financials + tenant: platform + workload: informed-decision + environment: production + purpose: Provide only the informed-decision sender with source=informed-decision, tenants=[tenant:platform], + may_write=true, may_read=false, evidence_kind=load-bearing, secret_policy=redact. Preserve the + existing receiver registry and every other sender. +openbao: + mount: platform + kv_path: platform/workloads/informed-decision/audit-sender + fields: + - AUDIT_TOKEN + - CUSTODY_REQUEST + policy_name: workload-kv-read-informed-decision-audit + policy_file: openbao/policies/workload-kv-read-informed-decision-audit.hcl + metadata_read: false + auth: + method: kubernetes + mount: kubernetes + role: external-secrets-informed-decision-audit + bound_claims: + service_account_names: + - external-secrets + service_account_namespaces: + - external-secrets + bound_claims_confirmed: true + policies: + - workload-kv-read-informed-decision-audit + ttl: 15m +access_frontdoor: + type: external-secrets + catalog_id: informed-decision-audit + readiness: pending-review + resolvable: false +delivery: + surface: external-secrets + target: 'ClusterSecretStore openbao-informed-decision-audit restricted to namespace informed-decision; + ExternalSecret and Secret informed-decision/informed-decision-audit, key token. Source: manifests/factory-audit-senders.yaml. + A missing namespace stays a workload-owner prerequisite; this packet creates none.' +risk: + classification: high + notes: + - A bearer permits append for its exact sender and tenant, never reading or changing stored evidence. + - The registry update reads existing sender credentials inside the attended platform process only. + No registry is delivered to a producer. + - OpenBao login TTL limits the ESO reader session, not the audit bearer. Revocation must remove + the sender token from the receiver registry and prove refusal. + - New paths extend the existing coding-agent deny boundary. No existing high-risk grant is widened. +verification: + positive: + - Independent CAS=0 token creation and exact sender merge with registry-version compare-and-set; + repeated runs preserve both tokens and other registry fields. + - ESO delivers only AUDIT_TOKEN into informed-decision/informed-decision-audit:token; source and + receiver copies agree without displaying values. + - Compatible deployed receiver accepts and deduplicates a declared synthetic event for the exact + sender and tenant, retaining load-bearing/redact declarations. + negative: + - Sibling sender path, full registry and parent listing are denied to each workload reader. Wrong + service account/namespace and disallowed store namespace fail. + - Receiver denies sibling source, wrong tenant, every read route and revoked token. No existing + sender is removed or re-scoped. + - Proposed or altered CCR, legacy receiver, malformed/duplicate registry identity, token collision, + stale registry version or partial/conflicting custody refuses without overwriting. + activation_conditions: + - All three named owner reviews are approved before any native credential mutation. + - A current image supporting evidence_kind is published/admitted by audit-core and deployed with + its source scope and network policy; current c2fe39a image fails compatibility. + - Use the Warden attended platform-admin login envelope; preserve independent receipt and revoke + the session on exit. + - Apply reviewed policy/auth metadata and ESO projection only after receiver compatibility and namespace + readiness. An interrupted seed resumes from durable KV values, never blindly rotates or deletes. + - Record native positive/negative evidence before declaring verified or active. Custody alone does + not admit UI, human approval or factory execution. +lifecycle: + deactivate: Stop the exact producer; remove only its admitted token from the registry using CAS + and reload/verify receiver refusal. Then detach its reader policy and remove its ExternalSecret/projection, + retaining KV versions for investigation. Do not delete audit events or other sender entries. + rotate: 'Reviewed overlap-first rotation: append a replacement to this sender only, deliver it, + prove acceptance, then remove the predecessor and prove refusal. The first-provision helper refuses + rotation and unexpected existing values.' + compromised: Stop affected producer and revoke the exact receiver token first; inspect affected + source/tenant events, rotate through a separate reviewed action, and preserve the independent + audit trail. +state_hub: + workplan_id: RPF-WP-0035 + task_id: RPF-WP-0035-T08 + related_workplan: AUDIT-WP-0009-T11 + decision_id: ee4ff001-256a-4406-9cb8-51be2cd5d31b + decision_api_url: http://127.0.0.1:8000/decisions/ee4ff001-256a-4406-9cb8-51be2cd5d31b diff --git a/docs/credential-lane-designs/factory-audit-senders-review.md b/docs/credential-lane-designs/factory-audit-senders-review.md new file mode 100644 index 0000000..8ef9cf0 --- /dev/null +++ b/docs/credential-lane-designs/factory-audit-senders-review.md @@ -0,0 +1,121 @@ +# Factory audit senders: concrete custody review + +CCR-2026-0021 and CCR-2026-0022 are **proposed**. RPF-WP-0035-T08 owns the +platform work; AUDIT-WP-0009-T09/T11 retain receiver admission. This packet +requires the named platform operator, Audit Core owner and each producer owner +to approve its request before native credential mutation. + +## Scope to approve + +| Request | Producer / exact source | OpenBao path | Producer Secret / key | +| --- | --- | --- | --- | +| CCR-2026-0021 | approval-engine | platform/workloads/approval-engine/audit-sender | approval-engine/approval-engine-audit / audit-token | +| CCR-2026-0022 | informed-decision | platform/workloads/informed-decision/audit-sender | informed-decision/informed-decision-audit / token | + +Each sender is limited to `tenant:platform`, write true, read false, +`evidence_kind: load-bearing`, `secret_policy: redact`. The producer receives +only its own `AUDIT_TOKEN`. `CUSTODY_REQUEST` is a non-secret provenance marker +stored beside that token, enabling an interrupted first provision to identify +its own version-1 value. It is not projected to the producer. + +Each lane has its own exact-path policy, Kubernetes auth role and namespace- +restricted ClusterSecretStore. Only `external-secrets/external-secrets` may +log in to the reader role (15-minute session). Neither producer can read the +full sender registry or its sibling's token. The coding-agent boundary gains +exact denies on the two new data/metadata paths; existing rules are preserved. + +The approved first-provision operation would generate two independent values +in the attended platform process, store them with CAS=0, and append the exact +identities to `platform/workloads/audit-core/senders` with compare-and-set on +its observed KV version. That process necessarily reads the existing registry +in memory; it never emits its credentials or gives it to a producer. No token +passes through Git, a Hub record, command arguments or stdout/stderr. + +## Blocking receiver finding + +On 2026-09-11 the native receiver is 1/1 Ready on +`forgejo.coulomb.social/coulomb/audit-core@sha256:c2fe39a0185b99be3fc0cb14d2de69772b8e66e20490097c9d11d90cc39719a6`. +Its `SenderIdentity` has **no evidence_kind support**. A synthetic registration +probe in the native pod fails the load-bearing contract; the same probe against +current Audit Core source passes. Readiness did not detect this contract gap. + +`factory_audit_custody.py receiver-check` now enforces that contract before any +credential access. An approved CCR alone cannot bypass it. Audit Core must +build, publish, admit and roll out its current implementation and source scope +first. Its existing AUDIT-WP-0009-T09/T11 tasks retain that work; no completed +source task is reopened. No receiver restart or deployment was done here. + +Approval Engine's namespace is also absent. Three of four ESO objects passed +server dry-run in their intended namespaces; its ExternalSecret was refused +because that namespace does not exist. This packet creates no namespace, +workload or network permission. Its owner must supply that prerequisite. + +## Executable sequence + +Review the source and exact policies: + +```sh +python3 scripts/credential-change.py render CCR-2026-0021 +python3 scripts/credential-change.py render CCR-2026-0022 +python3 scripts/factory_audit_custody.py plan +``` + +After a compatible receiver release, the credential-free check is: + +```sh +python3 scripts/factory_audit_custody.py receiver-check \ + --kubeconfig /operator/railiance-kubeconfig \ + --expected-receiver-image forgejo.coulomb.social/coulomb/audit-core@sha256:ADMITTED_DIGEST \ + --receipt /operator/unique-receiver-receipt.json +``` + +The seed command requires approved CCR files with all named owner reviews, +the expected image, exact Railiance cluster UID, ready/current pod, both target +namespaces and the Warden attended platform-admin login envelope. Route via +`warden route show openbao-platform-admin-login --json` before arranging access. +Use its installed attended wrapper; do not copy a token into the command. + +Inside that envelope, invoke: + +```sh +python3 scripts/factory_audit_custody.py seed \ + --kubeconfig /operator/railiance-kubeconfig \ + --expected-receiver-image forgejo.coulomb.social/coulomb/audit-core@sha256:ADMITTED_DIGEST \ + --confirm 'PROVISION CCR-2026-0021 CCR-2026-0022' \ + --receipt /operator/unique-custody-receipt.json +``` + +A successful seed deliberately reports +`custody_seeded_pending_delivery_and_receiver_reload`. It applies the exact +reader metadata and seeds custody, but does not apply ESO objects, reload the +shared receiver or claim live ingestion. Preserve that receipt. After an +interruption, review it and use `--resume` with a new receipt: only exact +request-owned version-1 custody is reused. Changed tokens, source scopes, +provenance, unknown duplicate identities and concurrent registry writes refuse. +No automatic deletion or rotation is attempted on failure. + +Under the same reviewed lane, the subsequent attended delivery uses +`manifests/factory-audit-senders.yaml`; verify both stores/ExternalSecrets and +exact token equality inside the protected process. Sync the existing registry +ExternalSecret, verify its update, and reload Audit Core through its owner +runbook. Then run each real producer's accepted/duplicate and wrong-source, +wrong-tenant/read-refusal checks and retrieve evidence with the independent +operator reader. The script does not yet automate these owner acceptance steps. +Do not mark either CCR verified/active until those receipts exist. + +## Revocation and limits + +ESO login expiry does **not** expire an audit bearer. Stop the affected producer, +remove only its token from the receiver registry using CAS, reload the receiver +and prove refusal, then disable its reader/projection. Retain stored events and +KV provenance. Rotation is separately reviewed and overlap-first. Existing +senders and reader permissions remain intact. + +`warden route show audit-core-senders` still points to the older Mason bootstrap. +The receiver runbook records the later OpenBao migration; this packet uses that +current authority and the platform's existing attended custody mechanics. It +creates no second registry or generic credential broker. + +Factory policy/caller admission, registered human binding, CCR-2026-0019's +operator group, native runtime delivery and spending/operating acceptance stay +with their existing records. Sender approval does not admit any of those acts. diff --git a/docs/evidence/2026-09-11-factory-audit-custody.json b/docs/evidence/2026-09-11-factory-audit-custody.json new file mode 100644 index 0000000..86c0a56 --- /dev/null +++ b/docs/evidence/2026-09-11-factory-audit-custody.json @@ -0,0 +1,81 @@ +{ + "schema": "platform.factory-audit-custody-preparation.v1", + "observed_at": "2026-09-11T00:00:15.014528+00:00", + "platform_base_commit": "d3a502b45cebc0a658d0ee7499f1cfcf2a5902a4", + "owner_task": "RPF-WP-0035-T08", + "source_records": [ + "CCR-2026-0021", + "CCR-2026-0022", + "AUDIT-WP-0009-T09", + "AUDIT-WP-0009-T11" + ], + "status": "proposed_custody_with_receiver_release_prerequisite", + "verification": { + "new_tests_passed": 17, + "existing_credential_change_tests_passed": 53, + "tests_skipped": 0, + "local_openbao_test_cases": 11, + "actual_receiver_source_contract_passed": true, + "all_22_ccrs_valid": true, + "server_dry_run": { + "objects": 4, + "exact_namespace_passed": 3, + "refusal": "approval-engine namespace absent", + "cluster_changes_applied": 0 + } + }, + "native_receiver": { + "image": "forgejo.coulomb.social/coulomb/audit-core@sha256:c2fe39a0185b99be3fc0cb14d2de69772b8e66e20490097c9d11d90cc39719a6", + "ready_replicas": 1, + "replicas": 1, + "evidence_kind_supported": false, + "probe": "synthetic SenderRegistry contract in exact deployed pod; no environment registry read", + "entry_refusal": "receiver_lacks_sender_contract", + "credential_reads": 0, + "source_contract_commit": "5c0ad522fb36092aa7ec2e8d72f63a5a91853b5b" + }, + "recovery_proven": [ + "interruption after first token write reuses the same value", + "lost registry write reply reconciles without another write", + "concurrent registry change is preserved by CAS refusal then deliberate resume", + "wrong provenance/scope/duplicate names/token collisions refuse", + "each exact read policy denies sibling data, full registry and metadata", + "all named approvals and exact credential coordinates are enforced" + ], + "routing_finding": { + "catalog": "audit-core-senders", + "status": "draft", + "catalog_points_to": "ops-mason bootstrap", + "current_authority": "platform/workloads/audit-core/senders per audit-core operator runbook", + "execution_owner": "railiance-platform attended custody; existing platform helper pattern" + }, + "pending_decisions": [ + { + "ccr": "CCR-2026-0021", + "status": 201, + "decision_id": "2c9fe9f0-034a-41d7-9d49-b99df488fdc8" + }, + { + "ccr": "CCR-2026-0022", + "status": 201, + "decision_id": "ee4ff001-256a-4406-9cb8-51be2cd5d31b" + } + ], + "source_sha256": { + "scripts/factory_audit_custody.py": "77b5bd7bd1b27a642d87d73d799b8896ebca1808b7c95fc47dd603ba74d8d30e", + "tests/test_factory_audit_custody.py": "aef55cb98862dd62e2202fe4d2348aa8915f23d059331fd07d5644598452dc9f", + "manifests/factory-audit-senders.yaml": "3d682dfd517b516dcb20c40373aae2557e4d8d2b49b3392d75c2cb36cd80785d", + "openbao/policies/agent-high-risk-boundary.hcl": "9d863886c815740e65458417949c73a616b6e038594979b343ea07d08c585f64", + "openbao/policies/workload-kv-read-approval-engine-audit.hcl": "a527c3bdfe7bf356fcd19f499ebc3ad75a71256a22c19882ad0dfdd09e0f0eb9", + "openbao/policies/workload-kv-read-informed-decision-audit.hcl": "389e952546d32692e87b4f249752a9bee0fafa9fc58bf50ab8904110bf6b3780", + "credential-change-requests/CCR-2026-0021-approval-engine-audit.yaml": "33f3bb5d32f2dbada864e1b89cdb47fcb9742dfe313eb391ca2f342be6bc119a", + "credential-change-requests/CCR-2026-0022-informed-decision-audit.yaml": "7656519a7be847e6cf1b4e3e03200c68f5a36d87db8c81b84a16511b5dfe5d3a", + "docs/credential-lane-designs/factory-audit-senders-review.md": "70e7a3568821672c84e0ce0388f7b8bc7d1b920354b38153995ec45022c53e52" + }, + "native_credentials_created": 0, + "native_secret_reads": 0, + "native_policy_writes": 0, + "native_deployments": 0, + "factory_attempts": 0, + "paid_model_calls": 0 +} diff --git a/manifests/factory-audit-senders.yaml b/manifests/factory-audit-senders.yaml new file mode 100644 index 0000000..803a869 --- /dev/null +++ b/manifests/factory-audit-senders.yaml @@ -0,0 +1,94 @@ +# CCR-2026-0021/0022. Review only; no credentials or namespaces. +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: openbao-approval-engine-audit + annotations: + railiance.io/credential-change: CCR-2026-0021 + railiance.io/admission: proposed +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: external-secrets-approval-engine-audit + serviceAccountRef: + name: external-secrets + namespace: external-secrets + conditions: + - namespaces: + - approval-engine +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: approval-engine-audit + namespace: approval-engine + annotations: + railiance.io/credential-change: CCR-2026-0021 + railiance.io/admission: proposed +spec: + refreshInterval: 5m + secretStoreRef: + kind: ClusterSecretStore + name: openbao-approval-engine-audit + target: + name: approval-engine-audit + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: audit-token + remoteRef: + key: workloads/approval-engine/audit-sender + property: AUDIT_TOKEN +--- +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: openbao-informed-decision-audit + annotations: + railiance.io/credential-change: CCR-2026-0022 + railiance.io/admission: proposed +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: external-secrets-informed-decision-audit + serviceAccountRef: + name: external-secrets + namespace: external-secrets + conditions: + - namespaces: + - informed-decision +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: informed-decision-audit + namespace: informed-decision + annotations: + railiance.io/credential-change: CCR-2026-0022 + railiance.io/admission: proposed +spec: + refreshInterval: 5m + secretStoreRef: + kind: ClusterSecretStore + name: openbao-informed-decision-audit + target: + name: informed-decision-audit + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: token + remoteRef: + key: workloads/informed-decision/audit-sender + property: AUDIT_TOKEN diff --git a/openbao/policies/agent-high-risk-boundary.hcl b/openbao/policies/agent-high-risk-boundary.hcl index 926962e..0940498 100644 --- a/openbao/policies/agent-high-risk-boundary.hcl +++ b/openbao/policies/agent-high-risk-boundary.hcl @@ -129,3 +129,9 @@ path "platform/data/workloads/secrets-engine/approval-client" { capabilities = [ path "platform/data/workloads/approval-engine/operator-client" { capabilities = ["deny"] } path "platform/metadata/workloads/secrets-engine/approval-client" { capabilities = ["deny"] } path "platform/metadata/workloads/approval-engine/operator-client" { capabilities = ["deny"] } + +# Proposed factory audit sender custody: CCR-2026-0021 / CCR-2026-0022. +path "platform/data/workloads/approval-engine/audit-sender" { capabilities = ["deny"] } +path "platform/metadata/workloads/approval-engine/audit-sender" { capabilities = ["deny"] } +path "platform/data/workloads/informed-decision/audit-sender" { capabilities = ["deny"] } +path "platform/metadata/workloads/informed-decision/audit-sender" { capabilities = ["deny"] } diff --git a/openbao/policies/workload-kv-read-approval-engine-audit.hcl b/openbao/policies/workload-kv-read-approval-engine-audit.hcl new file mode 100644 index 0000000..4c713da --- /dev/null +++ b/openbao/policies/workload-kv-read-approval-engine-audit.hcl @@ -0,0 +1,3 @@ +path "platform/data/workloads/approval-engine/audit-sender" { + capabilities = ["read"] +} diff --git a/openbao/policies/workload-kv-read-informed-decision-audit.hcl b/openbao/policies/workload-kv-read-informed-decision-audit.hcl new file mode 100644 index 0000000..61119a0 --- /dev/null +++ b/openbao/policies/workload-kv-read-informed-decision-audit.hcl @@ -0,0 +1,3 @@ +path "platform/data/workloads/informed-decision/audit-sender" { + capabilities = ["read"] +} diff --git a/scripts/factory_audit_custody.py b/scripts/factory_audit_custody.py new file mode 100644 index 0000000..c7faa68 --- /dev/null +++ b/scripts/factory_audit_custody.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Attended first-provision custody for the two factory audit senders. + +Plan and receiver-check never read credential values. Seed needs the reviewed +CCRs and a compatible receiver; it does not deploy or reload any workload. +""" +from __future__ import annotations + +import argparse +import copy +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import secrets +from datetime import datetime, timezone + +from state_hub_preflight_lane import ROOT, LaneError, assert_cluster, bao, command, data +from keycape_approval_custody import read_optional, role_matches + +REGISTRY = 'platform/data/workloads/audit-core/senders' +BOUNDARY = 'sys/policies/acl/agent-high-risk-boundary' +LANES = (('CCR-2026-0021', 'approval-engine', 'audit-token'), + ('CCR-2026-0022', 'informed-decision', 'token')) +CONFIRM = 'PROVISION CCR-2026-0021 CCR-2026-0022' +# This probes synthetic identities only; no environment-backed registry is read. +RECEIVER_PROBE = '''import json +from audit_core.senders import SenderRegistry +entry={"name":"compatibility-fixture","tokens":["fixture-only"],"sources":["compatibility-fixture"],"tenants":["tenant:platform"],"may_write":True,"may_read":False,"evidence_kind":"load-bearing","secret_policy":"redact"} +r=SenderRegistry.from_env({"AUDIT_CORE_SENDERS":json.dumps([entry])}) +s=r.authenticate("Bearer fixture-only") +print(json.dumps({"load_bearing":getattr(s,"evidence_kind",None)=="load-bearing","redact":s.secret_policy=="redact","write_only":s.may_write and not s.may_read,"source_exact":s.permits_source("compatibility-fixture") and not s.permits_source("sibling"),"tenant_exact":s.permits_tenant("tenant:platform") and not s.permits_tenant("tenant:sibling")})) +''' + + +def require(condition, code): + if not condition: + raise LaneError(code) + + +def credential_module(): + spec = importlib.util.spec_from_file_location('factory_ccr', ROOT / 'scripts/credential-change.py') + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + return module + + +def contracts(*, approved=False): + module = credential_module() + result = [] + for ref, name, secret_key in LANES: + path = module.resolve_ccr(ref) + ccr, errors, _ = module.validate_ccr(path) + require(not errors, 'invalid_credential_request') + if approved: + require(ccr['status'] in {'approved', 'applied', 'verified', 'active'}, 'approved_ccrs_required') + for owner in ('platform-operator', 'audit-core-owner', name + '-owner'): + require(any(x.get('decision') == 'approved' and owner in x.get('reviewer', '') + for x in ccr['review']['comments']), 'named_owner_reviews_required') + suffix = name + '-audit'; policy = 'workload-kv-read-' + suffix + expected_role = {'bound_service_account_names':['external-secrets'], + 'bound_service_account_namespaces':['external-secrets'], + 'policies':policy,'ttl':'15m'} + expected_path = f'platform/workloads/{name}/audit-sender' + require(ccr['openbao']['kv_path'] == expected_path + and ccr['openbao']['fields'] == ['AUDIT_TOKEN', 'CUSTODY_REQUEST'] + and ccr['openbao']['policy_name'] == policy + and ccr['openbao']['metadata_read'] is False + and ccr['openbao']['auth']['role'] == 'external-secrets-' + suffix + and ccr['openbao']['auth']['mount'] == 'kubernetes' + and ccr['openbao']['auth']['bound_claims_confirmed'] is True + and module.auth_payload(ccr) == expected_role, 'exact_contract_required') + hcl = module.generated_policy_hcl(ccr) + require((ROOT / ccr['openbao']['policy_file']).read_text() == hcl, 'policy_source_drift') + result.append({'ccr':ref,'name':name,'kv':expected_path.replace('platform/','platform/data/',1), + 'metadata':expected_path.replace('platform/','platform/metadata/',1), + 'policy':policy,'hcl':hcl,'role':'external-secrets-'+suffix, + 'role_payload':expected_role,'store':'openbao-'+suffix,'secret':suffix, + 'secret_key':secret_key,'source_sha256':hashlib.sha256(path.read_bytes()).hexdigest()}) + return result + + +def receiver_check(kube, expected_image): + require(re.fullmatch(r'forgejo\.coulomb\.social/coulomb/audit-core@sha256:[0-9a-f]{64}', expected_image), 'receiver_digest_required') + assert_cluster(kube) + dep = data(command(kube + ['-n','audit-core','get','deployment','audit-core','-o','json'])) + require(dep['spec']['replicas'] == 1 and dep.get('status',{}).get('readyReplicas') == 1 + and dep['status'].get('observedGeneration',0) >= dep['metadata']['generation'], 'receiver_not_ready') + containers = dep['spec']['template']['spec']['containers'] + require(len(containers) == 1 and containers[0]['image'] == expected_image, 'receiver_image_mismatch') + pods = data(command(kube + ['-n','audit-core','get','pods','-l','app.kubernetes.io/name=audit-core','-o','json']))['items'] + require(len(pods) == 1 and not pods[0]['metadata'].get('deletionTimestamp'), 'receiver_rollout_in_progress') + pod = pods[0] + require(pod['spec']['containers'][0]['image'] == expected_image + and all(x.get('ready') for x in pod.get('status',{}).get('containerStatuses',[])) + and pod.get('status',{}).get('containerStatuses'), 'receiver_pod_not_current') + proof = data(command(kube + ['-n','audit-core','exec',pod['metadata']['name'],'-c',containers[0]['name'], + '--','python','-c',RECEIVER_PROBE])) + require(set(proof) == {'load_bearing','redact','write_only','source_exact','tenant_exact'} + and all(v is True for v in proof.values()), 'receiver_lacks_sender_contract') + return {'image':expected_image,'deployment_uid':dep['metadata']['uid'], + 'deployment_resource_version':dep['metadata']['resourceVersion'],'pod_uid':pod['metadata']['uid'], + 'capabilities':proof,'synthetic_probe_only':True,'credential_reads':0} + + +def snapshot(): + raw = data(bao(['read','-format=json',REGISTRY]))['data'] + require(type(raw['metadata']['version']) is int and raw['metadata']['version'] > 0, 'registry_version_missing') + body = raw['data']; require(isinstance(body,dict) and isinstance(body.get('senders.json'),str), 'registry_shape_invalid') + try: + rows = json.loads(body['senders.json']) + except (ValueError, TypeError): + raise LaneError('registry_shape_invalid') from None + require(isinstance(rows,list) and bool(rows), 'registry_must_preserve_existing_senders') + names = set(); tokens = set() + for row in rows: + require(isinstance(row,dict) and isinstance(row.get('name'),str) and row['name'] + and row['name'] not in names, 'registry_identity_invalid_or_duplicate') + names.add(row['name']) + values = row.get('tokens') or ([row['token']] if row.get('token') else []) + require(isinstance(values,list) and bool(values), 'registry_tokens_invalid') + for value in values: + require(isinstance(value,str) and bool(value) and value not in tokens, 'registry_token_collision') + tokens.add(value) + return raw['metadata']['version'], body, rows, tokens + + +def desired(lane, token): + return {'name':lane['name'],'tokens':[token],'sources':[lane['name']], + 'tenants':['tenant:platform'],'may_write':True,'may_read':False, + 'evidence_kind':'load-bearing','secret_policy':'redact'} + + +def seed(lanes, receipt, save, *, resume=False): + version, body, rows, used_tokens = snapshot() + originals = copy.deepcopy(rows) + found = {r['name']:r for r in rows} + values = {}; pending = [] + # Validate every existing custody object before any write. Only this exact + # request's version-1 values can be reused after interruption. + for lane in lanes: + old = read_optional(lane['metadata']) + if old is None: + require(lane['name'] not in found, 'registry_sender_without_custody') + pending.append(lane) + else: + require(resume and old['current_version'] == 1 + and not old['versions']['1'].get('destroyed') + and not old['versions']['1'].get('deletion_time'), 'existing_custody_requires_reviewed_resume') + existing = data(bao(['read','-format=json',lane['kv']]))['data'] + require(existing['metadata']['version'] == 1 + and set(existing['data']) == {'AUDIT_TOKEN','CUSTODY_REQUEST'} + and existing['data']['CUSTODY_REQUEST'] == lane['ccr'], 'custody_provenance_mismatch') + token = existing['data']['AUDIT_TOKEN'] + require(isinstance(token,str) and re.fullmatch(r'[A-Za-z0-9_-]{64}',token), 'custody_token_shape') + require(token not in values.values(), 'sender_token_collision') + if lane['name'] in found: + require(found[lane['name']] == desired(lane,token), 'registered_sender_drift') + else: + require(token not in used_tokens, 'sender_token_collision') + values[lane['name']] = token + receipt.update(registry_version_before=version,phase='custody_seed') + save() + for lane in pending: + token = secrets.token_urlsafe(48) + require(token not in used_tokens and token not in values.values(), 'sender_token_collision') + result = data(bao(['write','-format=json',lane['kv'],'-'],payload={ + 'options':{'cas':0},'data':{'AUDIT_TOKEN':token,'CUSTODY_REQUEST':lane['ccr']}})) + require(result['data']['version'] == 1,'initial_custody_version_mismatch') + values[lane['name']] = token + receipt.setdefault('seeded',[]).append({'ccr':lane['ccr'],'version':1,'request_id':result.get('request_id')}) + save() + updated = list(rows) + for lane in lanes: + if lane['name'] not in found: + updated.append(desired(lane,values[lane['name']])) + if updated != rows: + receipt['phase']='registry_cas'; save() + payload = dict(body, **{'senders.json':json.dumps(updated,separators=(',',':'))}) + result = data(bao(['write','-format=json',REGISTRY,'-'],payload={'options':{'cas':version},'data':payload})) + receipt['registry_write_request_id']=result.get('request_id') + # Readback also catches a concurrent removal rather than claiming a success. + after_version, after_body, after_rows, _ = snapshot() + require(all(x in after_rows for x in originals) + and all(desired(lane,values[lane['name']]) in after_rows for lane in lanes) + and all(after_body.get(k)==v for k,v in body.items() if k!='senders.json'), 'registry_readback_mismatch') + receipt.update(registry_version_after=after_version,unchanged_other_senders=True, + unchanged_other_registry_fields=True,credentials_reused=resume, + status='custody_seeded_pending_delivery_and_receiver_reload') + save() + + +def prepare_metadata(lanes): + # Avoid overwriting an unrelated policy or role. Do all drift checks first. + for lane in lanes: + policy=read_optional('sys/policies/acl/'+lane['policy']) + role=read_optional('auth/kubernetes/role/'+lane['role']) + require(policy is None or policy['policy']==lane['hcl'],'existing_policy_drift') + require(role is None or role_matches(role,lane),'existing_role_drift') + boundary=read_optional(BOUNDARY); require(boundary is not None,'coding_agent_boundary_required') + current=boundary['policy']; additions='' + for lane in lanes: + for path in (lane['kv'],lane['metadata']): + if '"'+path+'"' in current: + require(re.search(r'path\s+"'+re.escape(path)+r'"\s*\{\s*capabilities\s*=\s*\["deny"\]\s*\}',current),'boundary_path_drift') + else: + additions+=f'path "{path}" {{ capabilities = ["deny"] }}\n' + if additions: + require(read_optional(BOUNDARY)['policy']==current,'boundary_revision_changed') + bao(['write',BOUNDARY,'-'],payload={'policy':current+'\n'+additions}) + require(read_optional(BOUNDARY)['policy']==current+'\n'+additions,'boundary_readback_failed') + for lane in lanes: + bao(['write','sys/policies/acl/'+lane['policy'],'-'],payload={'policy':lane['hcl']}) + bao(['write','auth/kubernetes/role/'+lane['role'],'-'],payload=lane['role_payload']) + require(read_optional('sys/policies/acl/'+lane['policy'])['policy']==lane['hcl'] + and role_matches(read_optional('auth/kubernetes/role/'+lane['role']),lane),'metadata_readback_failed') + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('action',choices=['plan','receiver-check','seed']) + parser.add_argument('--kubeconfig'); parser.add_argument('--server') + parser.add_argument('--expected-receiver-image'); parser.add_argument('--confirm') + parser.add_argument('--receipt',type=Path); parser.add_argument('--resume',action='store_true') + args=parser.parse_args() + receipt={'schema':'platform.factory-audit-custody.v1','status':'refused','credential_values_emitted':False, + 'started_at':datetime.now(timezone.utc).isoformat()} + fd=None + try: + lanes=contracts(approved=args.action=='seed') + receipt['lanes']=[{k:v for k,v in x.items() if k in ('ccr','name','kv','store','secret','secret_key','source_sha256')} for x in lanes] + if args.action=='plan': + receipt['status']='proposed'; print(json.dumps(receipt,indent=2)); return 0 + require(args.kubeconfig and args.expected_receiver_image and args.receipt,'receiver_inputs_required') + fd=os.open(args.receipt,os.O_RDWR|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + def save(): + os.lseek(fd,0,os.SEEK_SET);os.ftruncate(fd,0) + os.write(fd,(json.dumps(receipt,indent=2)+'\n').encode());os.fsync(fd) + kube=['kubectl','--kubeconfig',args.kubeconfig,'--request-timeout=20s'] + if args.server:kube+=['--server',args.server] + receipt['receiver']=receiver_check(kube,args.expected_receiver_image) + if args.action=='receiver-check': + receipt['status']='receiver_contract_supported';save();return 0 + require(args.confirm==CONFIRM,'exact_confirmation_required') + require(Path.home().parent.name=='.warden-attended-login' + and not os.environ.get('BAO_TOKEN') and not os.environ.get('VAULT_TOKEN'),'attended_warden_envelope_required') + identity=data(bao(['token','lookup','-format=json']))['data'] + require('platform-admin' in identity['policies'] and 'root' not in identity['policies'],'attended_platform_admin_required') + for lane in lanes: + command(kube+['get','namespace',lane['name'],'-o','name']) + # Refuse malformed registry before changing policy/auth metadata. + snapshot() + receipt['phase']='metadata';save();prepare_metadata(lanes) + seed(lanes,receipt,save,resume=args.resume) + return 0 + except Exception as exc: + receipt['status']='refused' + receipt['error']=str(exc) if isinstance(exc,LaneError) else 'contained_operation_failed' + if fd is not None: + save() + print(json.dumps({'status':'refused','error':receipt['error']})) + return 1 + finally: + if fd is not None:os.close(fd) + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/tests/test_factory_audit_custody.py b/tests/test_factory_audit_custody.py new file mode 100644 index 0000000..b9ee3d7 --- /dev/null +++ b/tests/test_factory_audit_custody.py @@ -0,0 +1,227 @@ +"""Disposable OpenBao and exact receiver compatibility tests; no native credentials.""" +import copy +import importlib.util +import json +import os +from pathlib import Path +import secrets +import shutil +import socket +import subprocess +import sys +import time +import unittest +from unittest.mock import patch +import yaml + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/'scripts')) +import factory_audit_custody as lane + + +class Contracts(unittest.TestCase): + def test_proposed_requests_cannot_seed(self): + self.assertEqual(len(lane.contracts()),2) + with patch.object(lane,'bao',side_effect=AssertionError('must not contact Bao')): + with self.assertRaisesRegex(lane.LaneError,'approved_ccrs_required'): + lane.contracts(approved=True) + + def test_each_named_owner_approval_is_required(self): + module=lane.credential_module() + original=module.validate_ccr + for missing in ['platform-operator','audit-core-owner','approval-engine-owner','informed-decision-owner']: + def candidate(path): + c,errors,warnings=original(path);c=copy.deepcopy(c);c['status']='approved' + owners=['platform-operator','audit-core-owner',c['target']['workload']+'-owner'] + c['review']['comments']=[{'reviewer':owner,'decision':'approved'} for owner in owners if owner!=missing] + return c,errors,warnings + with patch.object(module,'validate_ccr',side_effect=candidate),patch.object(lane,'credential_module',return_value=module): + with self.assertRaisesRegex(lane.LaneError,'named_owner_reviews_required'):lane.contracts(approved=True) + + def test_altered_credential_coordinates_refuse_even_with_approvals(self): + module=lane.credential_module();original=module.validate_ccr + def candidate(path): + c,errors,warnings=original(path);c=copy.deepcopy(c);c['status']='approved' + c['review']['comments']=[{'reviewer':owner,'decision':'approved'} for owner in ['platform-operator','audit-core-owner',c['target']['workload']+'-owner']] + c['openbao']['kv_path']='platform/workloads/audit-core/senders' + return c,errors,warnings + with patch.object(module,'validate_ccr',side_effect=candidate),patch.object(lane,'credential_module',return_value=module): + with self.assertRaisesRegex(lane.LaneError,'exact_contract_required'):lane.contracts(approved=True) + + def test_projection_only_delivers_one_field_per_exact_namespace(self): + objects=list(yaml.safe_load_all((ROOT/'manifests/factory-audit-senders.yaml').read_text())) + self.assertEqual([x['kind'] for x in objects],['ClusterSecretStore','ExternalSecret']*2) + for contract,store,es in zip(lane.contracts(),objects[::2],objects[1::2]): + self.assertEqual(store['spec']['conditions'],[{'namespaces':[contract['name']]}]) + self.assertEqual(es['metadata']['namespace'],contract['name']) + self.assertEqual(es['spec']['data'],[{'secretKey':contract['secret_key'],'remoteRef':{'key':contract['kv'].removeprefix('platform/data/'),'property':'AUDIT_TOKEN'}}]) + self.assertEqual(es['spec']['secretStoreRef']['name'],store['metadata']['name']) + self.assertNotIn('senders.json',json.dumps(es)) + + def test_current_receiver_source_supports_exact_contract(self): + source=os.environ.get('AUDIT_CORE_SOURCE') + if not source:self.skipTest('set AUDIT_CORE_SOURCE for actual receiver contract') + result=subprocess.run([sys.executable,'-c',lane.RECEIVER_PROBE],cwd=source,capture_output=True,text=True,check=True) + self.assertTrue(all(json.loads(result.stdout).values())) + + def test_legacy_receiver_is_refused_before_credentials(self): + dep={'metadata':{'uid':'d','generation':1,'resourceVersion':'1'},'spec':{'replicas':1,'template':{'spec':{'containers':[{'name':'audit-core','image':'forgejo.coulomb.social/coulomb/audit-core@sha256:'+'a'*64}]}}},'status':{'observedGeneration':1,'readyReplicas':1}} + pod={'metadata':{'name':'audit-core-test','uid':'p'},'spec':dep['spec']['template']['spec'],'status':{'containerStatuses':[{'ready':True}]}} + responses=[dep,{'items':[pod]},{'load_bearing':False,'redact':True,'write_only':True,'source_exact':True,'tenant_exact':True}] + with patch.object(lane,'assert_cluster'),patch.object(lane,'command',side_effect=[subprocess.CompletedProcess([],0,json.dumps(x).encode(),b'') for x in responses]),patch.object(lane,'bao',side_effect=AssertionError('no credentials')): + with self.assertRaisesRegex(lane.LaneError,'receiver_lacks_sender_contract'): + lane.receiver_check(['kubectl'],dep['spec']['template']['spec']['containers'][0]['image']) + + +class OpenBaoExercise(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not shutil.which('bao'):raise unittest.SkipTest('local OpenBao binary required') + with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1] + token=secrets.token_urlsafe(32) + env=dict(os.environ,BAO_ADDR=f'http://127.0.0.1:{port}',VAULT_ADDR=f'http://127.0.0.1:{port}',BAO_TOKEN=token,VAULT_TOKEN=token,BAO_DEV_ROOT_TOKEN_ID=token) + cls.server=subprocess.Popen(['bao','server','-dev','-dev-no-store-token',f'-dev-listen-address=127.0.0.1:{port}'],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + cls.environment=patch.dict(os.environ,env,clear=True);cls.environment.start() + try: + for _ in range(60): + if lane.bao(['status'],allow_failure=True).returncode==0:break + time.sleep(.1) + else:raise RuntimeError('local server not ready') + lane.bao(['secrets','enable','-path=platform','kv-v2']) + lane.bao(['auth','enable','kubernetes']) + cls.lanes=lane.contracts() + except BaseException: + cls.environment.stop();cls.server.terminate();cls.server.wait(timeout=10);raise + + @classmethod + def tearDownClass(cls): + cls.environment.stop();cls.server.terminate();cls.server.wait(timeout=10) + + def setUp(self): + # These deletes target only the test's own loopback dev server. + for item in self.lanes:lane.bao(['delete',item['metadata']],allow_failure=True) + lane.bao(['delete',lane.REGISTRY.replace('/data/','/metadata/')],allow_failure=True) + self.original=[{'name':'existing','tokens':[secrets.token_urlsafe(48)],'sources':['existing'],'tenants':['*'],'may_read':True,'may_write':False,'secret_policy':'reject'}] + self.body={'senders.json':json.dumps(self.original),'unrelated-metadata':'preserve me'} + lane.bao(['write',lane.REGISTRY,'-'],payload={'options':{'cas':0},'data':self.body}) + self.receipt={};self.saved=[] + + def run_seed(self,**kwargs): + lane.seed(self.lanes,self.receipt,lambda:self.saved.append(copy.deepcopy(self.receipt)),**kwargs) + + def token(self,item): + return lane.data(lane.bao(['read','-format=json',item['kv']]))['data']['data']['AUDIT_TOKEN'] + + def test_first_provision_and_idempotent_resume(self): + self.run_seed() + before=[self.token(x) for x in self.lanes];self.assertNotEqual(*before) + version,body,rows,_=lane.snapshot() + self.assertEqual(rows[0],self.original[0]);self.assertEqual(body['unrelated-metadata'],'preserve me') + self.assertEqual(len(rows),3) + self.run_seed(resume=True) + self.assertEqual(lane.snapshot()[0],version) + self.assertEqual([self.token(x) for x in self.lanes],before) + self.assertFalse(any(t in json.dumps(self.saved) for t in before)) + with self.assertRaisesRegex(lane.LaneError,'existing_custody_requires_reviewed_resume'):self.run_seed() + + def test_crash_after_first_write_reuses_value(self): + real=lane.bao + def fail_second(args,**kwargs): + if args[:2]==['write','-format=json'] and args[2]==self.lanes[1]['kv']:raise lane.LaneError('injected_interrupt') + return real(args,**kwargs) + with patch.object(lane,'bao',side_effect=fail_second): + with self.assertRaisesRegex(lane.LaneError,'injected_interrupt'):self.run_seed() + first=self.token(self.lanes[0]);self.assertEqual(lane.snapshot()[0],1) + self.run_seed(resume=True) + self.assertEqual(self.token(self.lanes[0]),first);self.assertEqual(len(lane.snapshot()[2]),3) + + def test_lost_registry_write_reply_reconciles_without_rewriting(self): + real=lane.bao + def lost_reply(args,**kwargs): + result=real(args,**kwargs) + if args[:3]==['write','-format=json',lane.REGISTRY]:raise lane.LaneError('reply_lost') + return result + with patch.object(lane,'bao',side_effect=lost_reply): + with self.assertRaisesRegex(lane.LaneError,'reply_lost'):self.run_seed() + before=[self.token(x) for x in self.lanes];version=lane.snapshot()[0] + self.run_seed(resume=True) + self.assertEqual(lane.snapshot()[0],version);self.assertEqual([self.token(x) for x in self.lanes],before) + + def test_cas_conflict_preserves_concurrent_writer(self): + real=lane.bao + extra={'name':'concurrent','tokens':[secrets.token_urlsafe(48)],'sources':['concurrent']} + def race(args,**kwargs): + if args[:3]==['write','-format=json',lane.REGISTRY]: + body=dict(self.body,**{'senders.json':json.dumps(self.original+[extra])}) + real(['write',lane.REGISTRY,'-'],payload={'options':{'cas':1},'data':body}) + return real(args,**kwargs) + with patch.object(lane,'bao',side_effect=race): + with self.assertRaises(lane.LaneError):self.run_seed() + self.assertEqual(lane.snapshot()[2],self.original+[extra]) + self.run_seed(resume=True);self.assertIn(extra,lane.snapshot()[2]) + + def test_wrong_provenance_refuses_resume(self): + x=self.lanes[0] + lane.bao(['write',x['kv'],'-'],payload={'options':{'cas':0},'data':{'AUDIT_TOKEN':secrets.token_urlsafe(48),'CUSTODY_REQUEST':'different-request'}}) + with self.assertRaisesRegex(lane.LaneError,'custody_provenance_mismatch'):self.run_seed(resume=True) + self.assertIsNone(lane.read_optional(self.lanes[1]['metadata'])) + self.assertEqual(lane.snapshot()[0],1) + + def test_scope_drift_is_never_overwritten(self): + self.run_seed();version,body,rows,_=lane.snapshot();rows[-1]['may_read']=True + lane.bao(['write',lane.REGISTRY,'-'],payload={'options':{'cas':version},'data':dict(body,**{'senders.json':json.dumps(rows)})}) + with self.assertRaisesRegex(lane.LaneError,'registered_sender_drift'):self.run_seed(resume=True) + self.assertTrue(lane.snapshot()[2][-1]['may_read']) + + def test_duplicate_names_refuse_before_token_creation(self): + lane.bao(['write',lane.REGISTRY,'-'],payload={'options':{'cas':1},'data':{'senders.json':json.dumps(self.original*2)}}) + with self.assertRaisesRegex(lane.LaneError,'registry_identity_invalid_or_duplicate'):self.run_seed() + self.assertTrue(all(lane.read_optional(x['metadata']) is None for x in self.lanes)) + + def test_existing_cross_sender_token_collision_refused(self): + row=dict(self.original[0],name='different') + lane.bao(['write',lane.REGISTRY,'-'],payload={'options':{'cas':1},'data':{'senders.json':json.dumps(self.original+[row])}}) + with self.assertRaisesRegex(lane.LaneError,'registry_token_collision'):self.run_seed() + + def test_metadata_preserves_boundary_and_deny_overrides_reader(self): + original='path "protected/existing" { capabilities = ["deny"] }\n' + lane.bao(['write',lane.BOUNDARY,'-'],payload={'policy':original}) + lane.prepare_metadata(self.lanes) + boundary=lane.read_optional(lane.BOUNDARY)['policy'] + self.assertTrue(boundary.startswith(original)) + for item in self.lanes: + self.assertTrue(lane.role_matches(lane.read_optional('auth/kubernetes/role/'+item['role']),item)) + self.assertIn(item['kv'],boundary);self.assertIn(item['metadata'],boundary) + self.run_seed() + item=self.lanes[0] + token=lane.data(lane.bao(['token','create','-format=json','-policy='+item['policy'],'-policy=agent-high-risk-boundary','-ttl=60s']))['auth']['client_token'] + try: + result=lane.bao(['read',item['kv']],token=token,allow_failure=True) + self.assertNotEqual(result.returncode,0);self.assertIn(b'403',result.stderr) + finally:lane.bao(['write','auth/token/revoke','-'],payload={'token':token}) + lane.prepare_metadata(self.lanes) + self.assertEqual(lane.read_optional(lane.BOUNDARY)['policy'],boundary) + + def test_metadata_policy_drift_refuses_before_boundary_change(self): + item=self.lanes[0];original=lane.read_optional(lane.BOUNDARY) + lane.bao(['write','sys/policies/acl/'+item['policy'],'-'],payload={'policy':'path "unrelated/data" { capabilities = ["read"] }'}) + try: + with self.assertRaisesRegex(lane.LaneError,'existing_policy_drift'):lane.prepare_metadata(self.lanes) + self.assertEqual(lane.read_optional(lane.BOUNDARY),original) + self.assertTrue(all(lane.read_optional(x['metadata']) is None for x in self.lanes)) + finally:lane.bao(['delete','sys/policies/acl/'+item['policy']]) + + def test_exact_read_policies_exclude_sibling_and_registry(self): + self.run_seed() + for item in self.lanes: + lane.bao(['write','sys/policies/acl/'+item['policy'],'-'],payload={'policy':item['hcl']}) + token=lane.data(lane.bao(['token','create','-format=json','-policy='+item['policy'],'-ttl=60s']))['auth']['client_token'] + try: + self.assertEqual(lane.bao(['read',item['kv']],token=token).returncode,0) + targets=[lane.REGISTRY,item['metadata']]+[x['kv'] for x in self.lanes if x!=item] + for target in targets: + result=lane.bao(['read',target],token=token,allow_failure=True) + self.assertNotEqual(result.returncode,0);self.assertIn(b'403',result.stderr) + finally:lane.bao(['write','auth/token/revoke','-'],payload={'token':token}) + +if __name__=='__main__':unittest.main() diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md index fdc3228..3937b38 100644 --- a/workplans/RPF-WP-0035-credential-lane-implementation.md +++ b/workplans/RPF-WP-0035-credential-lane-implementation.md @@ -7,7 +7,7 @@ repo: railiance-platform status: blocked owner: codex created: "2026-09-05" -updated: "2026-09-10" +updated: "2026-09-11" related: - RPF-WP-0032 - RPF-WP-0033 @@ -393,6 +393,35 @@ receipts to HFACT-WP-0001-T03, SECRETS-WP-0009-T03 and APPROVAL-WP-0002-T01/T05. Audit receiver/sender custody remains AUDIT-WP-0009-T09 and must not be folded into this client-identity grant. +## Admit the two factory audit-sender custody lanes + +```task +id: RPF-WP-0035-T08 +status: progress +priority: high +assignee: railiance-platform +needs_human: true +intervention_note: "CCR-2026-0021/0022 need named platform-operator, audit-core-owner and producer-owner reviews. Receiver upgrade and namespace readiness remain separate preconditions; approval alone does not admit factory execution." +``` + +Consume AUDIT-WP-0009-T09/T11's exact declarations for approval-engine and +informed-decision: tenant:platform, write-only, load-bearing, redact. Source +preparation supplies two proposed CCRs, independent exact-path policies/ESO +projections, and the attended `factory_audit_custody.py` first-provision helper. +Its CAS and request-provenance checks preserve unrelated senders and recover +interrupted writes without generating replacements. Native reviews and +provision/delivery/receiver-ingestion acceptance remain open. + +The 2026-09-11 native read-only check found an additional deployment dependency: +receiver image c2fe39a is 1/1 Ready but does not support evidence_kind. The +synthetic native probe now refuses before any credential read. Current receiver +source passes. AUDIT T09/T11 retain compatible release/source-manifest rollout; +Approval Engine's missing namespace remains its owner prerequisite. + +[Review packet](../docs/credential-lane-designs/factory-audit-senders-review.md). +[Preparation evidence](../docs/evidence/2026-09-11-factory-audit-custody.json). +Do not fold these senders into CCR-2026-0019 or the cancelled CCR-2026-0020. + ## Dependency review — 2026-09-06 ### 2026-09-09 tenant question resolved — no change needed