ops-mason/plans/tenant-engine-audit-core-sender-projection.md
custodian-sync c69073601d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-10-03:
  - update .custodian-brief.md for ops-mason

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

12 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 approved Bernd Worsch (founder), stated in tenant-engine session 2026-10-03 2026-10-01 2026-10-03

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

APPROVED 2026-10-03 (see approval note below). 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.

Approval record

Approved by Bernd Worsch (founder), stated in the tenant-engine session on 2026-10-03 ("I approve, lets go"). Approval covers declaring the objects in section 3 rows 1-6 and 8 in the owning repos. It does NOT cover any apply, and it does NOT cover the row-7 seed, which still requires the founder's attended wrapped platform-admin step.