diff --git a/credential-change-requests/CCR-2026-0007-binky-company-email-imap.yaml b/credential-change-requests/CCR-2026-0007-binky-company-email-imap.yaml new file mode 100644 index 0000000..d6fabbb --- /dev/null +++ b/credential-change-requests/CCR-2026-0007-binky-company-email-imap.yaml @@ -0,0 +1,118 @@ +id: CCR-2026-0007 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: Binky company email IMAP credentials (tenant lane) +status: applied +created: '2026-07-17' +updated: '2026-07-17' +requester: + agent: grok + reason: Establish OpenBao custody for Binky-Hedgehog GmbH company mailbox IMAP credentials + used by email-connect read-only scans (binky-control dogfood; WARDEN-WP-0028 first + tenant lane on mount tenants/). +review: + required: true + required_approvers: + - platform-operator + comments: + - at: '2026-07-17T00:00:00+00:00' + reviewer: bernd.worsch + decision: approved + comment: "Approved in chat \u2014 proceed with tenants/ mount and binky company-email\ + \ IMAP lane; founder provisions values Red-lane once." +target: + domain: financials + tenant: binky + workload: company-email + environment: production + purpose: Read-only IMAP scan of company mailbox for control-plane event intake +openbao: + mount: tenants + kv_path: tenants/binky/company-email/imap + fields: + - IMAP_USERNAME + - IMAP_PASSWORD + policy_name: workload-kv-read-binky-company-email-imap + policy_file: openbao/policies/workload-kv-read-binky-company-email-imap.hcl + auth: + method: oidc + mount: netkingdom + role: binky-company-email-imap-workload-kv-read + allowed_redirect_uris: + - https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback + - http://localhost:8250/oidc/callback + - http://127.0.0.1:8250/oidc/callback + oidc_scopes: + - openid + - profile + - email + - groups + user_claim: sub + groups_claim: groups + bound_claims: + groups: + - net-kingdom-admins + bound_claims_confirmed: true + policies: + - workload-kv-read-binky-company-email-imap + ttl: 15m +access_frontdoor: + type: ops-warden + catalog_id: binky-company-email-imap + selector: binky company email imap mailbox + command: warden access binky-company-email-imap --out FILE + resolvable: false + readiness: applied-pending-provision +delivery: + surface: operator-workstation + target: email-connect scan-mailbox via env IMAP_USERNAME/IMAP_PASSWORD (names only + in config); binky-control mailmeta for metadata-only evidence +risk: + classification: high + notes: + - IMAP credentials grant mailbox read access for the company domain. + - "High-risk lane (WP-0026 T04) \u2014 agents must not stream raw values; use --out/--exec/--wrap." + - Values must not appear in Git, State Hub, chat, or workplans. + - ops-warden proxies as the caller and must not retain values. + - Distinct from platform/workloads/* fleet lanes; lives on mount tenants/. +verification: + positive: + - Approved net-kingdom-admins identity can read the data path (capabilities include + read) without printing values. + negative: + - default-policy token denied on tenants/data/binky/company-email/imap. + activation_conditions: + - tenants/ KV v2 mount enabled on bao.coulomb.social. + - Policy and OIDC role applied. + - Secret values provisioned by founder (Red lane) through approved custody. + - Positive and negative capabilities-safe verification recorded. + - ops-warden catalog promoted after verify + provision. + evidence: + - at: '2026-07-17T00:00:00+00:00' + actor: grok + kind: mount_enable + result: passed + details: + - 'Enabled KV v2 mount tenants/ description: Client/tenant commercial secrets + (WARDEN-WP-0028).' + - No secret values written. + - at: '2026-07-17T12:00:00+00:00' + actor: grok + kind: delegated_metadata_apply + result: passed + details: + - 'Policy metadata write: sys/policies/acl/workload-kv-read-binky-company-email-imap' + - 'Auth role metadata write: auth/netkingdom/role/binky-company-email-imap-workload-kv-read' + - "Capabilities-safe: lane-policy token \u2192 read on tenants/data/.../imap;\ + \ default-policy \u2192 deny" + - No secret values written (founder Red provision remains). +lifecycle: + deactivate: Disable ops-warden catalog entry and detach OIDC role policy; rotate + mailbox password at provider. + rotate: Mint a new mailbox app password; bao kv put tenants/binky/company-email/imap + IMAP_PASSWORD=@file; verify capabilities-safe. + compromised: Deactivate front door, rotate mailbox password, clear EXPOSED taint + after rotation, record blast-radius notes. +state_hub: + workplan_id: WARDEN-WP-0028 diff --git a/docs/workload-kv-access-lanes.md b/docs/workload-kv-access-lanes.md index e63dfec..da63522 100644 --- a/docs/workload-kv-access-lanes.md +++ b/docs/workload-kv-access-lanes.md @@ -360,3 +360,39 @@ PAT mint: attended session as Forgejo user `tegwick` (site admin). Minimum scope `read:package`, `write:package`, `read:repository`, `write:repository`, plus admin scopes required by `forgejo-operator-bootstrap`. Downstream repos update `load_token()` to read OpenBao when env is unset after lane verification. + +## Tenant commercial secrets (mount `tenants/`) — WARDEN-WP-0028 + +Client/tenant secrets are **not** under `platform/workloads/`. They use a +dedicated KV v2 mount: + +```text +tenants/// +``` + +CCR applier allowlist accepts `mount: tenants` and paths under `tenants/` +(in addition to `platform/workloads/` for fleet lanes). + +### Binky company email IMAP (`CCR-2026-0007`) + +| Item | Value | +| --- | --- | +| ops-warden catalog id | `binky-company-email-imap` | +| Tenant | `binky` | +| Mount | `tenants` | +| OpenBao CLI path | `tenants/binky/company-email/imap` | +| Fields | `IMAP_USERNAME`, `IMAP_PASSWORD` | +| Read policy | `workload-kv-read-binky-company-email-imap` | +| Policy file | `openbao/policies/workload-kv-read-binky-company-email-imap.hcl` | +| OIDC role | `binky-company-email-imap-workload-kv-read` | +| Front-door readiness | draft / applied-pending-provision (values: founder Red) | +| Risk | high | + +```bash +bao login -method=oidc -path=netkingdom role=binky-company-email-imap-workload-kv-read +# after provision: +bao kv get -field=IMAP_PASSWORD tenants/binky/company-email/imap # interactive human only +warden access binky-company-email-imap --all --out /tmp/p # preferred +``` + +Onboarding playbook: `ops-warden/wiki/playbooks/tenant-secret-onboarding.md`. diff --git a/openbao/policies/agent-high-risk-boundary.hcl b/openbao/policies/agent-high-risk-boundary.hcl index 6299491..ae59d32 100644 --- a/openbao/policies/agent-high-risk-boundary.hcl +++ b/openbao/policies/agent-high-risk-boundary.hcl @@ -1,36 +1,21 @@ -# Agent coding read-boundary for high-risk secret lanes (WARDEN-WP-0026 T04). +# Agent coding read-boundary for high-risk secret lanes (WARDEN-WP-0026 T04 / WP-0028). # -# Coding agents (Claude/Codex/Grok sessions, automated workers) MUST NOT hold -# raw data-read on high-risk recovery/upload/admin secrets. This policy is the -# allow-list they *may* carry for those paths: metadata + capabilities only. -# Operator OIDC roles keep the full workload-kv-read-* policies separately. -# -# High-risk set (must stay in sync with ops-warden catalog risk: high): -# - railiance backup offsite (WebDAV upload + age recovery escrow) -# - forgejo admin PAT -# - openrouter LLM provider key (provider spend + prompt-adjacent) -# -# Apply live: `bao policy write agent-high-risk-boundary openbao/policies/agent-high-risk-boundary.hcl` -# Attach to agent AppRoles / tokens; never attach workload-kv-read-* for these paths -# to the same identity. +# Coding agents MUST NOT hold raw data-read on high-risk recovery/upload/admin +# or tenant mailbox secrets. Metadata + capabilities only. -# --- railiance-backup-offsite-lane --- +# --- platform high-risk --- path "platform/data/workloads/railiance/backup/offsite-lane" { capabilities = ["deny"] } path "platform/metadata/workloads/railiance/backup/offsite-lane" { capabilities = ["read"] } - -# --- forgejo-admin --- path "platform/data/workloads/forgejo/forgejo-admin" { capabilities = ["deny"] } path "platform/metadata/workloads/forgejo/forgejo-admin" { capabilities = ["read"] } - -# --- openrouter-llm-connect --- path "platform/data/workloads/activity-core/llm-connect/llm-connect-provider-secrets" { capabilities = ["deny"] } @@ -38,7 +23,14 @@ path "platform/metadata/workloads/activity-core/llm-connect/llm-connect-provider capabilities = ["read"] } -# Capabilities introspection (no secret values) +# --- tenant high-risk (WARDEN-WP-0028) --- +path "tenants/data/binky/company-email/imap" { + capabilities = ["deny"] +} +path "tenants/metadata/binky/company-email/imap" { + capabilities = ["read"] +} + path "sys/capabilities-self" { capabilities = ["update"] } diff --git a/openbao/policies/workload-kv-read-binky-company-email-imap.hcl b/openbao/policies/workload-kv-read-binky-company-email-imap.hcl new file mode 100644 index 0000000..7ec066e --- /dev/null +++ b/openbao/policies/workload-kv-read-binky-company-email-imap.hcl @@ -0,0 +1,11 @@ +# Least-privilege read policy for Binky company email IMAP credentials. +# Tenant mount (WARDEN-WP-0028) — client commercial secrets, not platform workloads. +# Exact path only; no list on parent, no sibling tenant paths. + +path "tenants/data/binky/company-email/imap" { + capabilities = ["read"] +} + +path "tenants/metadata/binky/company-email/imap" { + capabilities = ["read"] +} diff --git a/scripts/credential-change.py b/scripts/credential-change.py index f44229e..aaf8402 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -591,10 +591,22 @@ def applier_policy_violations(ccr: dict[str, Any]) -> list[str]: policy_file = str(openbao.get("policy_file") or "") fields = openbao.get("fields") or [] - if mount != "platform": - violations.append(f"openbao.mount must be platform, got {mount}") - if not kv_path.startswith("platform/workloads/"): - violations.append("openbao.kv_path must stay under platform/workloads/") + # WARDEN-WP-0028: platform fleet lanes stay under platform/workloads/; + # client/tenant commercial secrets live on mount tenants/ (exact-path policies). + allowed_mount_prefixes = { + "platform": "platform/workloads/", + "tenants": "tenants/", + } + if mount not in allowed_mount_prefixes: + violations.append( + f"openbao.mount must be one of {sorted(allowed_mount_prefixes)}, got {mount}" + ) + else: + prefix = allowed_mount_prefixes[mount] + if not kv_path.startswith(prefix): + violations.append( + f"openbao.kv_path for mount {mount!r} must stay under {prefix}" + ) if any(fragment in kv_path for fragment in ("*", "..", "//")): violations.append("openbao.kv_path must not contain wildcard, parent, or empty segments") if "/data/" in kv_path or "/metadata/" in kv_path: @@ -1600,7 +1612,7 @@ def decision_templates(ccr: dict[str, Any] | None = None) -> dict[str, str]: else: context = { "id": "", - "kv_path": "", + "kv_path": " or /...>", "policy_name": "", "auth_role_path": "auth//role/", "decision_link": "", diff --git a/tests/test_credential_change.py b/tests/test_credential_change.py index f59cc12..4f8e2b9 100644 --- a/tests/test_credential_change.py +++ b/tests/test_credential_change.py @@ -358,8 +358,30 @@ class CredentialChangeTests(unittest.TestCase): ccr["openbao"]["mount"] = "secret" ccr["openbao"]["kv_path"] = "secret/platform-admin" blockers = credential_change.applier_readiness_blockers(ccr) - self.assertIn("openbao.mount must be platform, got secret", blockers) - self.assertIn("openbao.kv_path must stay under platform/workloads/", blockers) + self.assertTrue( + any("openbao.mount must be one of" in b and "secret" in b for b in blockers), + blockers, + ) + + def test_applier_accepts_tenants_mount_path(self) -> None: + """WARDEN-WP-0028 — tenant commercial secrets on mount tenants/.""" + ccr, errors, _warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + ccr["status"] = "approved" + ccr["openbao"]["mount"] = "tenants" + ccr["openbao"]["kv_path"] = "tenants/binky/company-email/imap" + ccr["openbao"]["policy_name"] = "workload-kv-read-binky-company-email-imap" + ccr["openbao"]["policy_file"] = ( + "openbao/policies/workload-kv-read-binky-company-email-imap.hcl" + ) + ccr["openbao"]["auth"]["role"] = "binky-company-email-imap-workload-kv-read" + ccr["openbao"]["auth"]["policies"] = [ + "workload-kv-read-binky-company-email-imap" + ] + # Skip policy_file body match in this unit check (file exists in repo for real CCR). + ccr["openbao"]["policy_file"] = "" + violations = credential_change.applier_policy_violations(ccr) + self.assertEqual(violations, [], violations) def test_nonprod_applier_policy_remains_metadata_only(self) -> None: policy = (