ops-mason/plans/tenant-engine-audit-core-sender-projection.md
tegwick 2149ee2718 MASON-WP-0007: plan tenant-engine audit-core sender token projection (draft, not applied)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 292700@bnt-lap001
Assistant-Session: 42de6b79-9019-4cfd-b49a-6db3bbfe9341
2026-10-01 21:38:01 +02:00

11 KiB

id demand_source consumer_repo credential_type status approved_by approved_at created updated
tenant-engine-audit-core-sender-projection tenant-engine/TEN-IN-0005 tenant-engine openbao-kubernetes-kv draft null null 2026-10-01 2026-10-01

Construction plan: tenant-engine audit-core sender token projection

DRAFT. Nothing here is applied. No OpenBao or cluster object was read or changed to write it; the survey is from repository files only and must be re-checked live (phase 2) with the scoped ops-mason-build grant.

1. Demand

audit-core admitted tenant-engine as an attributive sender and minted its token on 2026-09-15 (AUDIT-WP-0010-T02). The token sits inside the sender registry document at platform/workloads/audit-core/senders (KV v2, registry version 9 at mint). tenant-engine's Deployment (commit 9fabfd7) mounts an optional Secret tenant-engine-audit-core-sender, key token, namespace tenant-engine, at /var/run/secrets/audit-core-sender/token. Nothing creates that Secret, so audit emission stays in the local outbox. The demand: deliver exactly that one token, and nothing else, into that Secret, with no founder-typed value and no expiring login token.

2. Existing-structure survey

  • The registry path cannot be scoped to one sender. It is one JSON document holding every sender (user-engine, operator, approval-engine, informed-decision, tenant-engine). OpenBao policies grant on a path, not a field. Reusing external-secrets-audit-core (reads that path) or pointing an ExternalSecret property: at it would let the tenant-engine identity read every other sender's token, including the read-capable operator token. Rejected. ESO property: / template filtering is a delivery convenience, not an access boundary.
  • openbao-tenant-engine-database is the wrong store to extend. (rapp-tenant-engine/manifests/tenant-engine-database-secrets.yaml) It is a ClusterSecretStore on mount database, authenticated by static token Secret external-secrets/openbao-tenant-engine-eso-token. That token is the expiry-bound parent token (expires 2026-10-25) and its policy is database credential reads. Widening it to KV would bind a new secret to the expiring token and violate the one-identity-per-lane rule applied to email-connect/user-engine. Left untouched. Its own migration off the static token is a separate matter (see section 4).
  • A non-token-bound pattern exists and is current. RPF-WP-0045 (finished 2026-09-23) moved openbao-email-connect and openbao-activity-core to ESO auth.kubernetes: dedicated ServiceAccount in the consumer namespace, audiences: [openbao], one OpenBao role per lane bound to that single SA and namespace, ttl/max_ttl/explicit_max_ttl=15m, token_no_default_policy=true, one policy (exact platform/data/... read + auth/token/lookup-self read + auth/token/revoke-self update). auth/kubernetes/config uses the OpenBao pod's own token, so there is no reviewer JWT or login token to expire. The same pattern serves openbao-forgejo, openbao-reuse, openbao-target-revenue. Lane records live in railiance-platform/openbao/eso-auth-recovery/lanes.json; policies in railiance-platform/openbao/policies/workload-kv-read-*-eso.hcl.
  • Reference shapes in ops-mason. audit-core-openbao-runtime-custody and user-engine-openbao-runtime-custody used AppRole bridges (secret_id bootstrap Secrets in external-secrets), explicitly "transitional until same-cluster Kubernetes auth". That bridge is no longer the target and is not proposed here.
  • Catalog. audit-core-senders (owner ops-mason, pointer only) already exists in ops-warden. This lane adds a consumer under it; it does not need a new entry, only a pointer line once built.
  • Does an existing lane already satisfy this? No. No path, policy, role or store holds or may read the tenant-engine token alone.
  • Unknown, to verify live: whether platform/workloads/tenant-engine/ has any metadata; whether ESO's controller may create serviceaccounts/token in tenant-engine (RPF-WP-0045 confirmed this for two other namespaces only); whether SA tenant-engine/tenant-engine-eso exists (not in rapp-tenant-engine/manifests/ as of this draft).

3. Proposed changes

# Action Object Reuse-vs-new rationale
1 create KV v2 path platform/workloads/tenant-engine/audit-core-sender, one field token Only a dedicated path can be read by one identity without exposing other senders. Value is a copy of the tenant-engine token already in the registry.
2 create policy workload-kv-read-tenant-engine-audit-core-sender-eso: read on platform/data/workloads/tenant-engine/audit-core-sender, plus auth/token/lookup-self read and auth/token/revoke-self update Same shape as workload-kv-read-email-connect-transactional-eso. No metadata, no list, no wildcard.
3 create Kubernetes auth role tenant-engine-audit-core-sender-eso on auth/kubernetes: SA tenant-engine-eso, namespace tenant-engine, audience openbao, ttl/max_ttl/explicit_max_ttl 15m, token_no_default_policy=true, policy from row 2 Not bound to any static token; no AppRole secret_id to rotate.
4 create (consumer repo) ServiceAccount tenant-engine/tenant-engine-eso (no mounted token needs, no RBAC) Dedicated identity so the role cannot be satisfied by the app's own SA.
5 create (consumer repo) ClusterSecretStore openbao-tenant-engine-audit-core-sender: provider vault, server http://openbao.openbao.svc:8200, path: platform, version: v2, auth.kubernetes {mountPath: kubernetes, role: tenant-engine-audit-core-sender-eso, serviceAccountRef {name: tenant-engine-eso, namespace: tenant-engine, audiences: [openbao]}}, conditions: namespaces: [tenant-engine] Separate from openbao-tenant-engine-database; namespace-limited. ClusterSecretStore (not namespaced SecretStore) follows the three recovered stores.
6 create (consumer repo) ExternalSecret tenant-engine/tenant-engine-audit-core-sender: refreshInterval: 1h, target Secret tenant-engine-audit-core-sender, creationPolicy: Owner, deletionPolicy: Retain, one data entry secretKey: token from workloads/tenant-engine/audit-core-sender property: token Produces exactly the Secret/key the Deployment mounts.
7 seed the KV value from the registry, in one wrapped platform-admin process: read registry doc, select sender tenant-engine, write token to row 1's path, return only version numbers Same technique as the user-engine migration. ops-mason cannot do this: ops-mason-build denies every */data/* read.
8 propose ops-warden audit-core-senders playbook: one line naming this lane as the consumer path for tenant-engine Pointer only.

Not proposed: widening external-secrets-audit-core, reusing the database store or its static token, any AppRole bridge, any bao kv put by the founder.

Where each object is declared (ownership):

  • Rows 1-3, 7: OpenBao side. Policy and role are railiance-platform's declarative surface (policy file under openbao/policies/, lane entry in openbao/eso-auth-recovery/lanes.json, or its credential-change request path if that supports Kubernetes-auth roles; to confirm with railiance-platform). ops-mason's KubernetesKVSpec builder can write the role against a pinned existing policy but writes only bound_service_account_*, policies, ttl; it does not set audiences, max_ttl/explicit_max_ttl or token_no_default_policy. Use the railiance-platform lane unless the builder is extended first.
  • Rows 4-6: production-tier manifests, reconciled by ArgoCD from the rApp manifest repository (rapp-tenant-engine/manifests/, next to tenant-engine-database-secrets.yaml). ops-mason's plane apply refuses Secret kinds, cluster-scoped additions beyond its allowlist and production targets, so ops-mason does not apply these.

Draft policy (for the owner to carry; not committed to any repo by ops-mason):

path "platform/data/workloads/tenant-engine/audit-core-sender" {
  capabilities = ["read"]
}
path "auth/token/lookup-self" {
  capabilities = ["read"]
}
path "auth/token/revoke-self" {
  capabilities = ["update"]
}

4. Review notes (phase 2, to be completed live)

  • Least privilege: one path, one field, one SA, one namespace, 15 minute tokens, no default policy. Compromise of the identity discloses only the tenant-engine sender token, which can only append attributed events.
  • Expiry: no dependence on openbao-tenant-engine-eso-token or any login token. This lane keeps working after 2026-10-25. The existing database store does not; it needs its own move to the same Kubernetes-auth pattern (policy and role tenant-engine-database-eso). Recommend raising that to railiance-platform separately; it is outside this demand and is not done here.
  • Duplication cost: the dedicated path is a second copy of the token. The registry remains the authority audit-core reads. Rotation must be overlap-first: add new token to registry (audit-core runbook steps 1-4), then re-run the wrapped seed (row 7), wait for ESO refresh, then drop the old token from the registry. Record this in the audit-core runbook. Option to remove the duplication later: audit-core could read per-sender KV paths instead of one document; that is an audit-core design decision, not part of this plan.
  • Optional mount behaviour: the Secret volume is optional: true, so the pod runs before the Secret exists and kubelet fills the mount once the Secret appears. Whether tenant-engine re-reads the token file per request or only at start is tenant-engine's to confirm; if only at start, a rollout is needed once after first sync.
  • Naming: follows workload-kv-read-<consumer>-<purpose>-eso and role = policy-suffix conventions of RPF-WP-0045.
  • Not in this plan: the token's content, audit-core's registry, the database lane.

5. Executive summary (draft)

One-line ask: approve a single-purpose OpenBao path, read policy and Kubernetes-auth role so External Secrets can copy only tenant-engine's own audit-core sender token into tenant-engine/tenant-engine-audit-core-sender.

Who gets access: only External Secrets, as ServiceAccount tenant-engine/tenant-engine-eso, via role tenant-engine-audit-core-sender-eso. The application pod keeps seeing a mounted file.

To what: read of exactly platform/workloads/tenant-engine/audit-core-sender. Not the sender registry, not other senders, not the database lane.

For how long: 15 minute tokens from a Kubernetes service-account login; no static token or secret_id exists to expire or leak.

Blast radius if the identity is abused: tenant-engine's own sender token can be read, which allows writing audit events as tenant-engine. It cannot read events. Remedy: rotate the sender token (overlap-first), re-seed, delete the role.

Cost to reverse: delete the role, the policy, the ExternalSecret/store/SA and the KV path. No registry change.

Decision: approve, reject, or send back. Approval authorizes ops-mason to coordinate and verify (metadata only) and the attended seed in row 7. It does not authorize printing the value.

6. Build result

Not started.