--- id: tenant-engine-audit-core-sender-projection demand_source: tenant-engine/TEN-IN-0005 consumer_repo: tenant-engine credential_type: openbao-kubernetes-kv status: approved approved_by: Bernd Worsch (founder), stated in tenant-engine session approved_at: "2026-10-03" created: "2026-10-01" updated: "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): ```hcl 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---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.