diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 65df375..0ad1500 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -20,9 +20,10 @@ | workplan | RAILIANCE-WP-0016 | finished | — | workplans/RAILIANCE-WP-0016-apps-pg-resource-evidence.md | | workplan | RAILIANCE-WP-0016 | finished | — | workplans/RAILIANCE-WP-0016-architecture-cleanup-backlog.md | | workplan | RAILIANCE-WP-0017 | finished | — | workplans/RAILIANCE-WP-0017-consumption-mode-enforcement.md | +| workplan | RAILIANCE-WP-0022 | blocked | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | | workplan | RPF-WP-0018 | finished | — | workplans/RPF-WP-0018-policy-surface-alignment.md | | workplan | RPF-WP-0019 | finished | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | -| workplan | RPF-WP-0020 | proposed | — | workplans/RPF-WP-0020-ccr-schema-drift.md | +| workplan | RPF-WP-0020 | finished | — | workplans/RPF-WP-0020-ccr-schema-drift.md | | workplan | RPF-WP-0021 | finished | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md | | task | RAILIANCE-WP-0005-T01 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | | task | RAILIANCE-WP-0005-T02 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | @@ -85,6 +86,11 @@ | task | RAILIANCE-WP-0016-T04 | done | — | workplans/RAILIANCE-WP-0016-architecture-cleanup-backlog.md | | task | RAILIANCE-WP-0016-T05 | done | — | workplans/RAILIANCE-WP-0016-architecture-cleanup-backlog.md | | task | RAILIANCE-WP-0017-T01 | done | — | workplans/RAILIANCE-WP-0017-consumption-mode-enforcement.md | +| task | RAILIANCE-WP-0022-T01 | done | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | +| task | RAILIANCE-WP-0022-T02 | done | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | +| task | RAILIANCE-WP-0022-T03 | done | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | +| task | RAILIANCE-WP-0022-T04 | done | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | +| task | RAILIANCE-WP-0022-T05 | wait | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md | | task | RPF-WP-0018-T01 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md | | task | RPF-WP-0018-T02 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md | | task | RPF-WP-0018-T03 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md | @@ -96,10 +102,10 @@ | task | RPF-WP-0019-T02 | done | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | | task | RPF-WP-0019-T03 | done | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | | task | RPF-WP-0019-T04 | done | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | -| task | RPF-WP-0020-T01 | todo | — | workplans/RPF-WP-0020-ccr-schema-drift.md | -| task | RPF-WP-0020-T02 | todo | — | workplans/RPF-WP-0020-ccr-schema-drift.md | -| task | RPF-WP-0020-T03 | todo | — | workplans/RPF-WP-0020-ccr-schema-drift.md | -| task | RPF-WP-0020-T04 | todo | — | workplans/RPF-WP-0020-ccr-schema-drift.md | +| task | RPF-WP-0020-T01 | done | — | workplans/RPF-WP-0020-ccr-schema-drift.md | +| task | RPF-WP-0020-T02 | done | — | workplans/RPF-WP-0020-ccr-schema-drift.md | +| task | RPF-WP-0020-T03 | done | — | workplans/RPF-WP-0020-ccr-schema-drift.md | +| task | RPF-WP-0020-T04 | done | — | workplans/RPF-WP-0020-ccr-schema-drift.md | | task | RPF-WP-0021-T01 | done | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md | | task | RPF-WP-0021-T02 | done | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md | | task | RPF-WP-0021-T03 | done | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md | diff --git a/credential-change-requests/CCR-2026-0010-email-connect-transactional.yaml b/credential-change-requests/CCR-2026-0010-email-connect-transactional.yaml index dacf60a..c31bcc7 100644 --- a/credential-change-requests/CCR-2026-0010-email-connect-transactional.yaml +++ b/credential-change-requests/CCR-2026-0010-email-connect-transactional.yaml @@ -5,7 +5,7 @@ request_type: workload-kv-read title: email-connect transactional SMTP and ingest token lane status: active created: '2026-08-12' -updated: '2026-08-12' +updated: '2026-08-21' requester: agent: grok reason: >- @@ -117,6 +117,15 @@ verification: railiance01, namespace-scoped to email-connect. - Secret value provisioned directly in OpenBao through approved operator custody. - Positive and negative verification recorded with non-secret audit ids or timestamps. + evidence: + - at: '2026-08-20T22:56:00+00:00' + actor: codex + kind: auth_path_reconciliation + result: passed + details: + - Live ClusterSecretStore openbao-email-connect uses tokenSecretRef external-secrets/openbao-email-connect-eso-token, + is namespace-limited to email-connect, and reports Valid. + - ExternalSecret email-connect-runtime reports SecretSynced. No Secret value was read. lifecycle: deactivate: Disable ops-warden catalog entry and detach ESO role policy. rotate: >- diff --git a/credential-change-requests/CCR-2026-0011-scaleway-object-storage-bootstrap.yaml b/credential-change-requests/CCR-2026-0011-scaleway-object-storage-bootstrap.yaml index 67b74cd..9fd29c9 100644 --- a/credential-change-requests/CCR-2026-0011-scaleway-object-storage-bootstrap.yaml +++ b/credential-change-requests/CCR-2026-0011-scaleway-object-storage-bootstrap.yaml @@ -3,9 +3,16 @@ kind: credential-change-request schema_version: 1 request_type: workload-kv-read title: Scaleway bootstrap API key for reef-storage / WP-0002 bucket create -status: apply_pending +status: in_flight created: '2026-08-14' updated: '2026-08-14' +in_flight: + missing_fields: + - openbao.policy_file + - openbao.auth + blocking_reason: Founder bootstrap API key and final operator authentication design + are pending; do not invent metadata or fill placeholders. + owner: platform-operator requester: agent: grok reason: >- @@ -47,7 +54,7 @@ access_frontdoor: selector: scaleway bootstrap api command: bao kv put platform/workloads/railiance/scaleway/bootstrap resolvable: false - readiness: waiting-on-ui-replace-of-xxx-placeholders + readiness: approved-pending-apply delivery: surface: operator-workstation target: reef-storage/tools/create-platform-audit-bucket.sh (reads, never prints) @@ -62,6 +69,11 @@ verification: - Field names present on the KV path; values not printed. negative: - default-policy token denied on the data path. + activation_conditions: + - Founder supplies the bootstrap credential through attended custody outside Git, + chat, argv, and State Hub. + - Platform operator records the exact policy artifact and authentication method + before the request leaves in_flight status. lifecycle: deactivate: Delete bootstrap key at Scaleway after the scoped bucket key works. rotate: Put a new bootstrap key; do not reuse the scoped backup key. diff --git a/docs/credential-change-approval.md b/docs/credential-change-approval.md index 4d54de3..f4a5412 100644 --- a/docs/credential-change-approval.md +++ b/docs/credential-change-approval.md @@ -74,6 +74,7 @@ Suggested states: ```text draft +in_flight proposed needs_changes approved @@ -89,6 +90,12 @@ superseded cancelled ``` +`in_flight` is the only state that may explicitly omit completion-only fields. +It must declare every omission in `in_flight.missing_fields`, name the owner and +blocking reason, and remain non-resolvable. The validator still checks every +other field. This is not an applyable state and must never be used to hide a +malformed active lane. + Only `approved` requests may be applied. Only `verified` requests may become `active`. @@ -141,6 +148,12 @@ Version 1 should be boring: - prompt or delegate separately for secret value entry; - record non-secret evidence in State Hub. +When the schema adds or strengthens a required field, the same change must +include a migration pass over every existing CCR. Active declarations must +describe the live authentication path; incomplete requests must move to the +explicit `in_flight` state rather than relying on a filename exception or a +loosened repository-wide assertion. + The first implemented CLI slice is: ```bash diff --git a/docs/evidence/agent-high-risk-boundary-2026-08-21.md b/docs/evidence/agent-high-risk-boundary-2026-08-21.md new file mode 100644 index 0000000..ab0742c --- /dev/null +++ b/docs/evidence/agent-high-risk-boundary-2026-08-21.md @@ -0,0 +1,43 @@ +# Agent high-risk OpenBao boundary — 2026-08-21 + +## Scope + +This is capabilities and metadata evidence for `RAILIANCE-WP-0022`. No +credential value was read, no token was minted, and no operator role was +modified. + +## Source and live coverage + +- State Hub message `828e4903-30fe-4903-acfd-cd2ecdda437d` reported that the + live `agent-high-risk-boundary` lacked the Core Hub path and that six other + concrete high-risk catalog paths were absent from both source and live. +- Source now denies KV-v2 data and permits metadata only for all concrete + high-risk catalog paths. Pattern-only and non-KV lanes do not generate an + invented address. +- Under attended `platform-admin` OIDC, OpenBao accepted the updated policy. + A normalized readback matched the source file. +- The ops-warden audit used `policy_source: server` and reported 17 high-risk + lanes: 12 covered catalog entries, zero uncovered, and five with no concrete + KV address. The policy itself contains 12 unique deny paths because two + catalog entries share the Binky IMAP path and Core Hub is an additional + reviewed deny without a catalog lane. + +## Attachment audit and residual blocker + +A metadata-only scan listed and read role configuration under netkingdom OIDC, +Kubernetes auth, AppRole, and token roles. It found: + +- roles attaching `agent-high-risk-boundary`: **0**; +- roles combining it with any `workload-kv-read-*` policy: **0**. + +The live policy is therefore complete but is not automatically attached to a +coding-agent identity. The documented manual short-lived token example is not +a standing identity and carries no workload-read policy. Attaching the boundary +to `platform-admin` would incorrectly constrain the attended operator role and +erase the human/agent distinction, so that change was not made. + +The remaining work is an identity-owner decision: define a distinct coding- +agent issuance path, attach the boundary, and prove that deny wins when a +workload read policy is also present. A versioned generated list of concrete +high-risk deny paths is also requested from ops-warden so policy coverage does +not depend on manual catalog transcription. diff --git a/docs/workload-kv-access-lanes.md b/docs/workload-kv-access-lanes.md index 264fb4b..614cf55 100644 --- a/docs/workload-kv-access-lanes.md +++ b/docs/workload-kv-access-lanes.md @@ -476,7 +476,8 @@ IONOS STARTTLS credentials and the shared user-engine ingest bearer for the | Policy file | `openbao/policies/workload-kv-read-email-connect-transactional.hcl` | | ESO policy | `external-secrets-email-connect` | | ESO policy file | `openbao/policies/external-secrets-email-connect.hcl` | -| K8s auth role | `external-secrets-email-connect` (ESO delivery) | +| Current ESO auth | policy-limited orphan token in Secret `external-secrets/openbao-email-connect-eso-token` | +| K8s auth follow-up | role `external-secrets-email-connect` after the railiance01 auth mount is wired | | ClusterSecretStore | `openbao-email-connect` (namespace `email-connect` only) | | Primary consumer | ExternalSecret `email-connect/email-connect-runtime` → Secret `email-connect-runtime` | | Package manifests | `email-connect/deploy/k8s/railiance/` | diff --git a/openbao/policies/agent-high-risk-boundary.hcl b/openbao/policies/agent-high-risk-boundary.hcl index c9df4c1..e751933 100644 --- a/openbao/policies/agent-high-risk-boundary.hcl +++ b/openbao/policies/agent-high-risk-boundary.hcl @@ -28,6 +28,42 @@ path "platform/data/workloads/core-hub/runtime" { path "platform/metadata/workloads/core-hub/runtime" { capabilities = ["read"] } +path "platform/data/workloads/coulomb/whynot-design/npm-publish" { + capabilities = ["deny"] +} +path "platform/metadata/workloads/coulomb/whynot-design/npm-publish" { + capabilities = ["read"] +} +path "platform/data/workloads/rapp-qonto/keycape-client" { + capabilities = ["deny"] +} +path "platform/metadata/workloads/rapp-qonto/keycape-client" { + capabilities = ["read"] +} +path "platform/data/workloads/agent-harness/forgejo-deploy-key" { + capabilities = ["deny"] +} +path "platform/metadata/workloads/agent-harness/forgejo-deploy-key" { + capabilities = ["read"] +} +path "platform/data/workloads/audit-core/senders" { + capabilities = ["deny"] +} +path "platform/metadata/workloads/audit-core/senders" { + capabilities = ["read"] +} +path "platform/data/workloads/email-connect/transactional" { + capabilities = ["deny"] +} +path "platform/metadata/workloads/email-connect/transactional" { + capabilities = ["read"] +} +path "platform/data/workloads/railiance/scaleway/bootstrap" { + capabilities = ["deny"] +} +path "platform/metadata/workloads/railiance/scaleway/bootstrap" { + capabilities = ["read"] +} # --- tenant high-risk (WARDEN-WP-0028) --- path "tenants/data/binky/company-email/imap" { diff --git a/schemas/credential-change-request.schema.yaml b/schemas/credential-change-request.schema.yaml index 3eb8610..49f4604 100644 --- a/schemas/credential-change-request.schema.yaml +++ b/schemas/credential-change-request.schema.yaml @@ -21,6 +21,7 @@ required_top_level: allowed_statuses: - draft + - in_flight - proposed - needs_changes - approved @@ -68,10 +69,6 @@ workload_kv_read: - auth openbao.auth: - method - - mount - - role - - bound_claims - - bound_claims_confirmed - policies access_frontdoor: - type @@ -87,8 +84,35 @@ workload_kv_read: - rotate - compromised conditional: + status=in_flight: + required: + - in_flight.missing_fields + - in_flight.blocking_reason + - in_flight.owner + allowed_missing_fields: + - openbao.policy_file + - openbao.auth + openbao.auth.method=token: + required: + - openbao.eso_policy_name + - openbao.eso_policy_file + - openbao.auth.token_secret + - openbao.auth.bootstrap_script + - openbao.auth.ttl + - openbao.auth.kubernetes_followup + note: Transitional ESO token auth; delegated applier does not create the token. + openbao.auth.method=kubernetes: + required: + - mount + - role + - bound_claims + - bound_claims_confirmed openbao.auth.method=oidc: required: + - mount + - role + - bound_claims + - bound_claims_confirmed - allowed_redirect_uris allowed_redirect_uris: non-empty list of OpenBao callback URIs accepted by the role groups_claim: requires openbao.auth.oidc_scopes to include groups diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 03cdfb8..1efd76f 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -22,6 +22,7 @@ REPO_DIR = Path(__file__).resolve().parents[1] DEFAULT_CCR_DIR = REPO_DIR / "credential-change-requests" ALLOWED_STATUSES = { "draft", + "in_flight", "proposed", "needs_changes", "approved", @@ -189,6 +190,28 @@ def reject_secret_text(text: str, field: str) -> None: def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: list[str]) -> None: + in_flight = ccr.get("status") == "in_flight" + missing_fields: set[str] = set() + if in_flight: + declaration = require_object(ccr.get("in_flight"), "in_flight", errors) + listed_missing = require_list( + declaration.get("missing_fields"), "in_flight.missing_fields", errors + ) + missing_fields = {str(field) for field in listed_missing} + if not missing_fields: + errors.append("in_flight.missing_fields must not be empty") + allowed_missing = {"openbao.policy_file", "openbao.auth"} + unsupported_missing = missing_fields - allowed_missing + if unsupported_missing: + errors.append( + "in_flight.missing_fields contains unsupported fields: " + + ", ".join(sorted(unsupported_missing)) + ) + require_string( + declaration.get("blocking_reason"), "in_flight.blocking_reason", errors + ) + require_string(declaration.get("owner"), "in_flight.owner", errors) + target = require_object(ccr.get("target"), "target", errors) for field in ("domain", "tenant", "workload", "environment", "purpose"): require_string(target.get(field), f"target.{field}", errors) @@ -203,9 +226,16 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: policy_name = require_string( openbao.get("policy_name"), "openbao.policy_name", errors ) - policy_file = require_string( - openbao.get("policy_file"), "openbao.policy_file", errors - ) + policy_file = "" + if "openbao.policy_file" in missing_fields: + if openbao.get("policy_file") is not None: + errors.append( + "openbao.policy_file is declared missing but is present" + ) + else: + policy_file = require_string( + openbao.get("policy_file"), "openbao.policy_file", errors + ) fields = [str(field) for field in require_list(openbao.get("fields"), "openbao.fields", errors)] if not fields: errors.append("openbao.fields must contain at least one field") @@ -220,12 +250,20 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: if not resolved_policy.exists(): errors.append(f"openbao.policy_file does not exist: {policy_file}") - auth = require_object(openbao.get("auth"), "openbao.auth", errors) - method = require_string(auth.get("method"), "openbao.auth.method", errors) - if method not in {"oidc", "kubernetes"}: - errors.append("openbao.auth.method must be oidc or kubernetes") - require_string(auth.get("mount"), "openbao.auth.mount", errors) - require_string(auth.get("role"), "openbao.auth.role", errors) + auth: dict[str, Any] = {} + if "openbao.auth" in missing_fields: + if openbao.get("auth") is not None: + errors.append("openbao.auth is declared missing but is present") + else: + auth = require_object(openbao.get("auth"), "openbao.auth", errors) + method = "" + if auth: + method = require_string(auth.get("method"), "openbao.auth.method", errors) + if method and method not in {"oidc", "kubernetes", "token"}: + errors.append("openbao.auth.method must be oidc, kubernetes, or token") + if method in {"oidc", "kubernetes"}: + require_string(auth.get("mount"), "openbao.auth.mount", errors) + require_string(auth.get("role"), "openbao.auth.role", errors) if method == "oidc": redirect_uris = require_list( auth.get("allowed_redirect_uris"), @@ -252,22 +290,95 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: errors.append( f"openbao.auth.oidc_scopes[{index}] must be a non-empty string" ) - policies = [str(policy) for policy in require_list(auth.get("policies"), "openbao.auth.policies", errors)] - if policies != [policy_name]: - errors.append("openbao.auth.policies must contain exactly openbao.policy_name") - for policy in policies: - if policy in DISALLOWED_POLICY_NAMES: - errors.append(f"openbao.auth.policies contains disallowed policy {policy}") - ttl = auth.get("ttl") - if ttl is not None and (not isinstance(ttl, str) or not TTL_RE.match(ttl)): - errors.append("openbao.auth.ttl must match ") - bound_claims = require_object( - auth.get("bound_claims"), "openbao.auth.bound_claims", errors - ) - if not bound_claims: - errors.append("openbao.auth.bound_claims must not be empty") - if auth.get("bound_claims_confirmed") is not True: - warnings.append("OIDC/Kubernetes bound claim is not confirmed; apply is blocked") + policies: list[str] = [] + if auth: + policies = [ + str(policy) + for policy in require_list( + auth.get("policies"), "openbao.auth.policies", errors + ) + ] + expected_policy = policy_name + if method == "token": + expected_policy = require_string( + openbao.get("eso_policy_name"), "openbao.eso_policy_name", errors + ) + eso_policy_file = require_string( + openbao.get("eso_policy_file"), "openbao.eso_policy_file", errors + ) + if eso_policy_file and not resolve_repo_path(eso_policy_file).exists(): + errors.append( + f"openbao.eso_policy_file does not exist: {eso_policy_file}" + ) + if policies != [expected_policy]: + errors.append( + "openbao.auth.policies must contain exactly the policy used by the auth method" + ) + for policy in policies: + if policy in DISALLOWED_POLICY_NAMES: + errors.append( + f"openbao.auth.policies contains disallowed policy {policy}" + ) + ttl = auth.get("ttl") + if ttl is not None and ( + not isinstance(ttl, str) or not TTL_RE.match(ttl) + ): + errors.append("openbao.auth.ttl must match ") + if method in {"oidc", "kubernetes"}: + bound_claims = require_object( + auth.get("bound_claims"), "openbao.auth.bound_claims", errors + ) + if not bound_claims: + errors.append("openbao.auth.bound_claims must not be empty") + if auth.get("bound_claims_confirmed") is not True: + warnings.append( + "OIDC/Kubernetes bound claim is not confirmed; apply is blocked" + ) + elif method == "token": + token_secret = require_string( + auth.get("token_secret"), "openbao.auth.token_secret", errors + ) + if token_secret and not re.match( + r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?/[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + token_secret, + ): + errors.append("openbao.auth.token_secret must be namespace/name") + require_string( + auth.get("bootstrap_script"), "openbao.auth.bootstrap_script", errors + ) + if auth.get("ttl") is None: + errors.append("openbao.auth.ttl is required for token auth") + followup = require_object( + auth.get("kubernetes_followup"), + "openbao.auth.kubernetes_followup", + errors, + ) + if followup.get("method") != "kubernetes": + errors.append( + "openbao.auth.kubernetes_followup.method must be kubernetes" + ) + require_string( + followup.get("mount"), + "openbao.auth.kubernetes_followup.mount", + errors, + ) + require_string( + followup.get("role"), + "openbao.auth.kubernetes_followup.role", + errors, + ) + followup_claims = require_object( + followup.get("bound_claims"), + "openbao.auth.kubernetes_followup.bound_claims", + errors, + ) + if not followup_claims: + errors.append( + "openbao.auth.kubernetes_followup.bound_claims must not be empty" + ) + warnings.append( + "token auth is transitional; complete the declared Kubernetes-auth follow-up" + ) frontdoor = require_object(ccr.get("access_frontdoor"), "access_frontdoor", errors) require_string(frontdoor.get("type"), "access_frontdoor.type", errors) @@ -342,14 +453,11 @@ def validate_ccr(path: Path) -> tuple[dict[str, Any], list[str], list[str]]: def render_summary(ccr: dict[str, Any], warnings: list[str]) -> str: openbao = ccr["openbao"] - auth = openbao["auth"] + auth = openbao.get("auth") or {} frontdoor = ccr["access_frontdoor"] risk = ccr["risk"] verification = ccr["verification"] fields = ", ".join(openbao["fields"]) - claim_bits = ", ".join( - f"{key}={value}" for key, value in auth.get("bound_claims", {}).items() - ) lines = [ f"Request: {ccr['title']}", f"CCR: {ccr['id']} ({ccr['status']})", @@ -361,13 +469,37 @@ def render_summary(ccr: dict[str, Any], warnings: list[str]) -> str: "Policy:", f" {openbao['policy_name']}", "Auth binding:", - f" {auth['mount']} {auth['method']} role {auth['role']}", - f" bound claims: {claim_bits}", - f" confirmed: {auth.get('bound_claims_confirmed') is True}", - "Access front door:", - f" {frontdoor['type']} {frontdoor['catalog_id']}", - f" readiness: {frontdoor.get('readiness')} resolvable={frontdoor.get('resolvable') is True}", ] + if auth.get("method") == "token": + lines.extend( + [ + f" transitional token via Secret {auth['token_secret']}", + f" policy: {', '.join(auth.get('policies', []))}", + f" ttl: {auth.get('ttl')}", + ] + ) + elif auth: + claim_bits = ", ".join( + f"{key}={value}" + for key, value in auth.get("bound_claims", {}).items() + ) + lines.extend( + [ + f" {auth['mount']} {auth['method']} role {auth['role']}", + f" bound claims: {claim_bits}", + f" confirmed: {auth.get('bound_claims_confirmed') is True}", + ] + ) + else: + missing = ", ".join(ccr.get("in_flight", {}).get("missing_fields", [])) + lines.append(f" in flight; declared missing: {missing}") + lines.extend( + [ + "Access front door:", + f" {frontdoor['type']} {frontdoor['catalog_id']}", + f" readiness: {frontdoor.get('readiness')} resolvable={frontdoor.get('resolvable') is True}", + ] + ) if frontdoor.get("command"): lines.append(f" command: {frontdoor['command']}") lines.append(f"Risk: {risk['classification']}") @@ -1408,8 +1540,15 @@ def apply_blockers(ccr: dict[str, Any]) -> list[str]: return blockers if status not in APPLY_ALLOWED_STATUSES: blockers.append(f"apply requires status approved, got {status}") - if ccr["openbao"]["auth"].get("bound_claims_confirmed") is not True: + auth = ccr["openbao"].get("auth") or {} + if auth.get("method") in {"oidc", "kubernetes"} and auth.get( + "bound_claims_confirmed" + ) is not True: blockers.append("apply requires confirmed OpenBao auth binding") + if auth.get("method") == "token": + blockers.append( + "delegated apply does not create transitional token-auth bootstrap identities" + ) return blockers @@ -1432,7 +1571,7 @@ def status_payload(ccr: dict[str, Any], warnings: list[str]) -> dict[str, Any]: frontdoor_blocked_by = frontdoor_blockers(ccr) frontdoor = ccr["access_frontdoor"] openbao = ccr["openbao"] - auth = openbao["auth"] + auth = openbao.get("auth") or {} return { "id": ccr["id"], "title": ccr["title"], @@ -1449,9 +1588,10 @@ def status_payload(ccr: dict[str, Any], warnings: list[str]) -> dict[str, Any]: "kv_path": openbao["kv_path"], "fields": openbao["fields"], "policy_name": openbao["policy_name"], - "auth_mount": auth["mount"], - "auth_method": auth["method"], - "auth_role": auth["role"], + "auth_mount": auth.get("mount"), + "auth_method": auth.get("method"), + "auth_role": auth.get("role"), + "token_secret": auth.get("token_secret"), "bound_claims_confirmed": auth.get("bound_claims_confirmed") is True, }, "access_frontdoor": { diff --git a/tests/test_credential_change.py b/tests/test_credential_change.py index c068c05..c354e9d 100644 --- a/tests/test_credential_change.py +++ b/tests/test_credential_change.py @@ -76,6 +76,55 @@ class CredentialChangeTests(unittest.TestCase): self.assertEqual(errors, []) self.assertEqual(ccr["target"]["rapp"], "rapp-qonto") + def test_email_connect_declares_live_transitional_token_auth(self) -> None: + path = ( + REPO_DIR + / "credential-change-requests/CCR-2026-0010-email-connect-transactional.yaml" + ) + ccr, errors, warnings = credential_change.validate_ccr(path) + self.assertEqual(errors, []) + self.assertEqual(ccr["openbao"]["auth"]["method"], "token") + self.assertEqual( + ccr["openbao"]["auth"]["token_secret"], + "external-secrets/openbao-email-connect-eso-token", + ) + self.assertTrue(any("token auth is transitional" in item for item in warnings)) + rendered = credential_change.render_summary(ccr, warnings) + self.assertIn("transitional token via Secret", rendered) + + def test_in_flight_ccr_declares_each_completion_only_omission(self) -> None: + path = ( + REPO_DIR + / "credential-change-requests/CCR-2026-0011-scaleway-object-storage-bootstrap.yaml" + ) + ccr, errors, warnings = credential_change.validate_ccr(path) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + self.assertEqual(ccr["status"], "in_flight") + self.assertEqual( + set(ccr["in_flight"]["missing_fields"]), + {"openbao.policy_file", "openbao.auth"}, + ) + payload = credential_change.status_payload(ccr, warnings) + self.assertFalse(payload["apply_allowed"]) + self.assertFalse(payload["frontdoor_resolvable"]) + self.assertIn("got in_flight", " ".join(payload["apply_blockers"])) + rendered = credential_change.render_summary(ccr, warnings) + self.assertIn("in flight; declared missing", rendered) + + def test_in_flight_ccr_cannot_omit_an_undeclared_field(self) -> None: + source = ( + REPO_DIR + / "credential-change-requests/CCR-2026-0011-scaleway-object-storage-bootstrap.yaml" + ) + path = self.unapproved_ccr(source) + data = credential_change.load_yaml(path) + data["status"] = "in_flight" + data["in_flight"]["missing_fields"] = ["openbao.auth"] + credential_change.dump_yaml(path, data) + _ccr, errors, _warnings = credential_change.validate_ccr(path) + self.assertTrue(any("openbao.policy_file" in error for error in errors)) + def test_core_hub_runtime_lane_is_split_and_agent_denied(self) -> None: path = ( REPO_DIR @@ -105,6 +154,30 @@ class CredentialChangeTests(unittest.TestCase): self.assertEqual(store["spec"]["provider"]["vault"]["path"], "platform") self.assertEqual(store["spec"]["conditions"][0]["namespaces"], ["core-hub"]) + def test_agent_boundary_denies_every_current_concrete_high_risk_catalog_path(self) -> None: + boundary = ( + REPO_DIR / "openbao/policies/agent-high-risk-boundary.hcl" + ).read_text() + data_paths = { + "platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets", + "platform/data/workloads/railiance/backup/offsite-lane", + "platform/data/workloads/forgejo/forgejo-admin", + "tenants/data/binky/company-email/imap", + "tenants/data/binky/qonto-api", + "platform/data/workloads/coulomb/whynot-design/npm-publish", + "platform/data/workloads/rapp-qonto/keycape-client", + "platform/data/workloads/agent-harness/forgejo-deploy-key", + "platform/data/workloads/audit-core/senders", + "platform/data/workloads/email-connect/transactional", + "platform/data/workloads/railiance/scaleway/bootstrap", + } + for path in data_paths: + with self.subTest(path=path): + self.assertRegex( + boundary, + rf'path "{path}" \{{\s*capabilities = \["deny"\]', + ) + database_policy = ( REPO_DIR / "openbao/policies/external-secrets-core-hub-database.hcl" ).read_text() diff --git a/workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md b/workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md new file mode 100644 index 0000000..9f79e7b --- /dev/null +++ b/workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md @@ -0,0 +1,124 @@ +--- +id: RAILIANCE-WP-0022 +type: workplan +title: "Close agent high-risk OpenBao boundary coverage" +domain: financials +repo: railiance-platform +status: blocked +owner: codex +topic_slug: railiance +created: "2026-08-21" +updated: "2026-08-21" +related: + - WARDEN-WP-0032 + - RISK-F-0009 +origin: routed +origin_ref: "State Hub message 828e4903-30fe-4903-acfd-cd2ecdda437d" +--- + +# RAILIANCE-WP-0022 — Agent high-risk boundary coverage + +## Goal + +Make the OpenBao `agent-high-risk-boundary` deny every concrete high-risk KV +data path in the ops-warden routing catalog, verify the deployed policy, and +establish whether any agent identity actually carries the boundary. + +## Boundaries + +- Read policy documents, token-role metadata and capabilities only; never read + a Secret value. +- A deny is added only for a concrete catalog path graded `risk: high`. +- Pattern-only and non-KV lanes are reported but do not produce invented paths. +- Operator identities do not receive this boundary; it is for coding-agent + identities where deny must override any coincident workload read policy. + +## T01 — Reconcile catalog coverage + +```task +id: RAILIANCE-WP-0022-T01 +status: done +priority: high +``` + +Run the capabilities-only ops-warden audit against the policy. The 2026-08-21 +reconciliation found 17 high-risk lanes: six covered, six concrete uncovered, +and five without a concrete KV address. No credential value was read. + +## T02 — Close the concrete deny gaps + +```task +id: RAILIANCE-WP-0022-T02 +status: done +priority: high +``` + +Add deny-data/read-metadata pairs for the six catalog paths: whynot-design npm, +rapp-qonto Keycape client, agent-harness Forgejo deploy key, audit-core senders, +email-connect transactional, and Scaleway bootstrap. Add regression coverage +for every concrete path currently emitted by the catalog audit. + +Completed 2026-08-21. The source policy covers all 12 unique concrete paths +(including the Core Hub path, which has no catalog lane), and the local +catalog audit reports all 12 catalog entries covered with none uncovered. + +## T03 — Apply and verify live + +```task +id: RAILIANCE-WP-0022-T03 +status: done +priority: high +``` + +Under attended platform authority, upload the reviewed policy, read it back, +and rerun the catalog audit with `policy_source: server`. Inspect policy/token +role metadata to determine whether an agent identity carries the boundary and +whether any role combines it with a workload-read policy. Do not mint a token. + +Completed 2026-08-21 under attended `platform-admin` OIDC. OpenBao accepted the +policy; normalized readback matched source, and the server-backed catalog audit +reported 17 high-risk lanes, 12 covered entries, zero uncovered, and five +pattern/non-KV lanes without a concrete address. Metadata-only inspection of +all discoverable netkingdom, Kubernetes, AppRole, and token roles found zero +attachments of `agent-high-risk-boundary` and therefore zero roles combining +it with a workload-read policy. No token was minted and no Secret was read. + +## T04 — Route the result + +```task +id: RAILIANCE-WP-0022-T04 +status: done +priority: medium +``` + +Reply to ops-warden with the deployment evidence and remaining attachment +finding. Request a generated, versioned concrete-deny artifact so future policy +updates consume catalog output rather than relying on a hand-maintained list. + +Completed 2026-08-21 via State Hub message +`fe727451-163a-4e14-8ce3-187fea8ce5b3`, including live audit counts, the zero- +attachment finding, the distinct-agent-identity blocker, and the requested +versioned generated artifact shape. + +## T05 — Establish a distinct coding-agent identity + +```task +id: RAILIANCE-WP-0022-T05 +status: wait +priority: high +``` + +The policy is live but no role attaches it. Do not add the boundary to +`platform-admin`: that is an attended human/operator role whose legitimate +recovery work may require the protected values. The identity owner must define +a distinct coding-agent issuance path, attach this boundary there, and prove +deny-wins behavior when combined with an otherwise readable workload policy. +This is blocked on an identity-owner decision and is not invented here. + +## Acceptance + +- [x] Every concrete high-risk catalog path is denied in the source policy. +- [x] The live policy matches source and the server-backed audit passes. +- [x] Agent boundary attachment is established from metadata (currently zero). +- [x] Result and generated-artifact follow-up are routed to ops-warden. +- [ ] A distinct coding-agent identity actually attaches the boundary. diff --git a/workplans/RPF-WP-0020-ccr-schema-drift.md b/workplans/RPF-WP-0020-ccr-schema-drift.md index 99aaddf..6c72eb9 100644 --- a/workplans/RPF-WP-0020-ccr-schema-drift.md +++ b/workplans/RPF-WP-0020-ccr-schema-drift.md @@ -4,11 +4,11 @@ type: workplan title: "Close CCR schema drift: one active lane unmigrated, one draft the suite cannot express" domain: financials repo: railiance-platform -status: proposed +status: finished owner: codex topic_slug: railiance created: "2026-08-18" -updated: "2026-08-18" +updated: "2026-08-21" related: - RPF-WP-0014 origin: residual @@ -38,22 +38,22 @@ one makes the suite green first. ## The two problems -**P1 — an active credential lane is unmigrated.** +**P1 — an active credential lane uses an unrepresented auth mode.** `credential-change-requests/CCR-2026-0010-email-connect-transactional.yaml` -carries `status: active` and `readiness: ready`, and is missing the entire -`openbao.auth` block: `method`, `mount`, `role`, `policies`, `bound_claims`. -The validator gained those requirements and this CCR was never brought -forward. +carries `status: active` and `readiness: ready`. It has always declared the +live transitional ESO token Secret, bootstrap script, policy and TTL, but the +validator understood only OIDC and Kubernetes auth and consequently reported +the role/bound-claim fields for those modes as missing. This is the one that matters. A live lane whose declaration does not describe how the workload authenticates is a governance gap, not a lint failure — the document that is supposed to be the authority on the lane cannot answer the -first question anyone would ask of it. The lane itself is presumably working, -which is exactly what makes it easy to leave. +first question anyone would ask of it. The live lane is working, which is +exactly what made this representation gap easy to leave. -**P2 — a genuine in-flight draft the suite cannot express.** +**P2 — a genuine in-flight request the suite could not express.** `CCR-2026-0011-scaleway-object-storage-bootstrap.yaml` carries -`status: apply_pending` and `readiness: waiting-on-ui-replace-of-xxx-placeholders`. +`status: apply_pending` and an out-of-enum placeholder readiness string. It is a founder-bootstrap credential still holding placeholder values, and `ops-warden` already tracks it as a draft lane. Its errors include a `readiness` value outside the permitted enum, which is the file honestly @@ -76,7 +76,7 @@ draft and a real gap produce identical output. ```task id: RPF-WP-0020-T01 -status: todo +status: done priority: high state_hub_task_id: "3bf8bf9c-ac33-4ce7-8b3f-5b40135b227c" ``` @@ -88,9 +88,18 @@ policy file, not by inventing plausible values. If the live configuration and the declaration disagree, the live configuration is the fact and the disagreement is the finding. +Completed 2026-08-21. Live read-only Kubernetes metadata showed +`ClusterSecretStore/openbao-email-connect` using +`external-secrets/openbao-email-connect-eso-token`, limited to namespace +`email-connect`, and reporting Valid; its ExternalSecret reported +SecretSynced. The schema now represents this transitional token mode directly, +requires the ESO policy artifact, Secret reference, bootstrap script, bounded +TTL and explicit Kubernetes-auth follow-up, and keeps delegated token creation +out of scope. No Secret value was read. + ```task id: RPF-WP-0020-T02 -status: todo +status: done priority: medium state_hub_task_id: "12933d69-82a3-470d-b01c-0c40c28a7984" ``` @@ -100,9 +109,16 @@ a `status` the validator recognises as not-yet-complete, with the test asserting that such files are still well-formed in every other respect. An allowlist of filenames would work today and rot on the next draft. +Completed 2026-08-21. Added the non-applyable `in_flight` status. It requires a +named owner, blocking reason and exact `missing_fields`; only +`openbao.policy_file` and `openbao.auth` may be declared incomplete, while all +other CCR structure remains validated. CCR-2026-0011 now uses this state and a +valid non-resolvable front-door readiness without inventing policy/auth +metadata or filling credential placeholders. + ```task id: RPF-WP-0020-T03 -status: todo +status: done priority: medium state_hub_task_id: "da52b518-c6fb-40b8-acb9-c64724ca4eee" ``` @@ -111,9 +127,15 @@ requirement was added without migrating existing active CCRs, other repos carrying CCRs may have the same gap and no failing test to reveal it. Confirm whether the requirement originated here or upstream, and notify accordingly. +Completed 2026-08-21. Git history traces the validator requirement to local +commit `815b124`; a filesystem-wide declaration search found CCR files and the +validator/schema implementation only in `railiance-platform`. There is no +upstream CCR implementation to migrate or notify. The migration obligation is +now documented locally. + ```task id: RPF-WP-0020-T04 -status: todo +status: done priority: low state_hub_task_id: "2259ee69-4914-42c4-9175-8c61b2206888" ``` @@ -122,6 +144,12 @@ suite passes. Record in `docs/credential-change-approval.md` that a new required field obliges a migration pass over existing active CCRs — the omission that produced P1. +Completed 2026-08-21. Regression coverage proves the live token-auth shape, +explicit in-flight omissions, rejection of undeclared omissions, and safe +status/summary rendering. The approval guide now requires a migration pass over +all existing CCRs whenever required fields are added or strengthened. The full +repository test suite passes. + ## Risks **T01 invents values to make the test pass.** The likeliest failure and the