ops-mason/plans/state-hub-forge-derivation-read.md
tegwick 4c0ada21ca feat(mason): deliver and verify the forge read credential
Minted on a dedicated identity rather than a site admin. A PAT on
tegwick would have read every repository on the instance — wider than
the approved organisation-wide breadth and wider than the plan's stated
blast radius. Built as user state-hub-svc in team coulomb/state-hub-read
(read, all repositories, repo.code unit only), following the existing
issue-core-svc precedent. The ceiling is enforced by org membership, not
by a scope string, and clones are attributable to a named identity.

Delivered by warden desk --act paste_once_provision; ops-mason neither
minted nor pasted the value.

Verified: rapp-openbao, rapp-core-hub, rapp-issue-core and
rapp-policy-nexus all read — four of the nine that failed
STATE-WP-0083-T04. Push 403, admin API 403, /api/v1/user 403 (the scope
carries no read:user). Clone test used GIT_ASKPASS, so no credential
entered argv or a clone URL.

Records a deviation rather than glossing it: SCOPE.md puts secret values
out of scope, and that held for minting and delivery but not for
verification — the value was read back to fingerprint it and to run the
clone test, under the build-stage disclosure posture agreed 2026-08-27.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3377672@bnt-lap001
Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
2026-08-27 23:46:42 +02:00

323 lines
16 KiB
Markdown

---
id: state-hub-forge-derivation-read
demand_source: ops-mason/workplans/MASON-WP-0003
consumer_repo: state-hub
credential_type: openbao-approle-kv
status: built
approved_by: "Bernd Worsch"
approved_at: "2026-08-26"
breadth: organisation-wide-read
created: "2026-08-26"
updated: "2026-08-26"
---
# Construction plan: State Hub forge-derivation read lane
## 1. Demand
`CUST-ADR-012` decision 1 makes the forge the projection source: central
clones a repository's default branch and derives its work records from what
Forgejo holds. The `state-hub` pod does this anonymously today, which works
for public repositories and fails outright for private ones.
The first fleet-wide reset (`state-hub/STATE-WP-0083-T04`, 2026-08-26)
covered 121 repositories. Nine failed identically:
```text
fatal: could not read Username for 'https://forgejo.coulomb.social'
```
`rapp-core-hub`, `rapp-issue-core`, `rapp-openbao`, `rapp-policy-nexus` and
five others are invisible to derivation entirely.
The demand is therefore: a **read-only** credential the `state-hub` workload
can authenticate for non-interactively (no operator present — derivation runs
unattended in-cluster), sufficient for `git clone --depth 1` against private
repositories and nothing more.
## 2. Existing-structure survey
**Checked:** `ops-warden/registry/routing/catalog.yaml` (on disk).
**Not checked:** live `bao policy list` / `bao auth list` — no valid OpenBao
token in this session. Whoever executes phase 4 must re-verify against live
state before applying.
`warden route find "state hub read private repository"` returns **no lane**.
**Nearest existing entry: `forgejo-admin-api-token`**
(`ops-warden/registry/routing/catalog.yaml:577`) — a site-admin PAT owned by
`railiance-platform`, stored at `platform/workloads/forgejo/forgejo-admin`
(field `API_TOKEN`), gated by policy `workload-kv-read-forgejo-admin`, marked
`risk: high`, attended-minted, currently under interim delegation to
`secrets-engine` (`SECRETS-WP-0006`).
**Reuse considered and declined.** Binding a `state-hub` AppRole to
`workload-kv-read-forgejo-admin` is mechanically possible and is the wrong
build: derivation performs a shallow clone and nothing else, and a projection
that only reads must not hold an identity that can administer the forge. It
would also make an unattended in-cluster workload a standing consumer of a
lane that is explicitly attended-minted and mid-delegation. No compaction
opportunity exists in the other direction either — the admin lane keeps its
own consumers unchanged.
No second existing AppRole or policy is scoped to a Forgejo *read* credential.
This is genuinely new structure.
## 3. Proposed changes
| # | Action | Object | Reuse-vs-new rationale |
|---|---|---|---|
| 1 | create | KV v2 secret path `platform/workloads/state-hub/forge-derivation` (field `FORGE_READ_TOKEN`) | New, holds one credential and nothing else — mirrors the `platform/workloads/<workload>/<purpose>` shape already used by `platform/workloads/forgejo/forgejo-admin` |
| 2 | create | Policy `workload-kv-read-state-hub-forge-derivation` — read-only, scoped to exactly path #1 | New — same `workload-kv-read-<path-slug>` convention as `workload-kv-read-forgejo-admin`, scoped to one path |
| 3 | create | AppRole `state-hub-forge-derivation`, bound to policy #2 | New — unattended in-cluster login; TTLs per §4 |
| 4 | deliver | The Forgejo token value into path #1 via ops-warden's `paste_once_provision` desk | ops-mason never touches the value (`SCOPE.md`); the forge owner mints, the desk delivers |
| 5 | propose | ops-warden catalog entry `state-hub-forge-derivation-read` — pointer-only, `warden_executes: false`, `status: draft` | New — no existing entry covers this need; `forgejo-admin-api-token` stays as-is, untouched |
No tear-down. Nothing existing becomes redundant.
**Not in this plan** (other repos' work, tracked as `state-hub/STATE-WP-0084`):
mounting the credential into the `state-hub` deployment, teaching
`derive_from_forge()` to use it, and re-running the fleet reset.
## 4. Review notes (phase 2)
- **Naming check:** path, policy, and AppRole names all follow the
conventions visible in the existing catalog. No divergence proposed.
- **TTL/scoping check:** short-lived tokens (`token_ttl=15m`,
`token_max_ttl=30m`) matching the workload AppRole shape already in use.
Derivation fetches once per pass, so a bounded token costs nothing.
`secret_id_ttl` per current build-phase posture
(`ops-warden/wiki/WorkloadSecurityPosture.md`) — no rotation schedule
beyond what posture requires. **Explicit assumption, flagged in §5.**
- **Redundancy check:** no existing policy or AppRole grants Forgejo read.
- **Compaction opportunity:** none identified.
- **Ease of use for the consumer:** the pod already consumes `state-hub-env`;
the credential arrives the same way. Rotation must not require a chart
change or a redeploy — that constraint belongs to `STATE-WP-0084`, but it
is a consequence of this plan's delivery shape and is stated here so the
two do not drift.
- **Credential handling constraint (carried to the consumer):** the token
must never reach the clone URL as embedded userinfo, where it lands in logs
and process listings. Use a credential helper or an HTTP header.
### The breadth question, argued both ways
**Organisation-wide read.** New private repositories derive without further
work, and no one has to remember this lane exists.
**Per-repository read.** Tighter, but needs maintenance each time a
repository is added.
The original argument for organisation-wide read was that the narrow option
fails *invisibly* — a forgotten grant looks exactly like a broken repository.
That argument does not survive `STATE-WP-0084-T01`, which makes "cannot read"
report as unreadable rather than as an error or, worse, as an empty
derivation. Once a missing grant surfaces as a named condition, the narrow
option's failure mode is visible and recoverable.
What remains is a straight blast-radius trade: an organisation-wide read
token, if it leaks, reads **every private repository in the organisation**
source, history, and whatever those repositories contain. A per-repository
grant leaks nine repositories' worth.
**Recommendation: organisation-wide read, contingent on `STATE-WP-0084-T01`
landing first.** The maintenance burden of the narrow option is real and
recurring, and the visibility objection to it is genuine until T01 exists.
But this is the founder's call, not a mechanical one — §5 puts it as an
explicit decision, not a default.
## 5. Executive summary (phase 3)
**One-line ask:** create a read-only Forgejo credential lane so the State Hub
can derive projections for private repositories, which it cannot read today.
**Who/what gets access:** a new OpenBao AppRole `state-hub-forge-derivation`,
used by the `state-hub` workload in-cluster. Nothing else — no existing role,
human, or workload gains anything new.
**To what:** read-only access to exactly one KV path,
`platform/workloads/state-hub/forge-derivation`, which does not exist today
and will hold one Forgejo token. The AppRole cannot read the existing
`forgejo-admin` path or any other path.
The Forgejo token itself grants **repository read only** — no push, no
administration, no other Forgejo resource.
**For how long:** each login is a 15-minute token, renewable to a 30-minute
cap. The AppRole credential itself does not expire on a schedule at current
build-phase posture — the same choice already made for existing workload
AppRoles. Override this if you want rotation now.
**Blast radius if the credential leaks:** whoever holds the
`role_id`/`secret_id` pair can mint a short-lived token that reads one
Forgejo read token — nothing else in OpenBao. What that Forgejo token then
reads is the open decision:
- **organisation-wide read (recommended):** every private repository in the
organisation is readable. No write, no admin, no persistence.
- **per-repository read:** the nine listed repositories are readable, and
every future private repository needs a grant added by hand.
**Cost to reverse:** revoke the Forgejo token, delete one AppRole, one
policy, one KV path — four commands, no other lane or consumer affected. The
State Hub returns to deriving public repositories only, which is what it does
today.
**Sequencing note:** the recommendation above assumes `STATE-WP-0084-T01`
(report unreadable repositories as unreadable, never as retirement work)
lands first. That task is independently worth doing — today a clone that
returned empty instead of failing would propose every record in that
repository for retirement, the near-miss that `vergabe-teilnahme` produced.
**Decision needed:**
1. Approve the lane as proposed, reject, or send back to phase 1.
2. Breadth: organisation-wide read, or per-repository read.
3. Rotation: accept build-phase posture (no scheduled `secret_id` expiry), or
require rotation now.
## 6. Decision (phase 3 outcome)
**Approved 2026-08-26 by Bernd Worsch. Breadth: organisation-wide repository
read.**
The reasoning, recorded because a bare value would not survive the next person
asking why:
- **Approved as proposed.** No change to the path, policy, or AppRole shape in
§3, and no change to the rejection of `forgejo-admin-api-token` reuse in §2.
- **Organisation-wide read over per-repository.** The recurring maintenance
cost of the narrow option is real and lands on whoever adds a private
repository months from now, who has no reason to know this lane exists. The
accepted price is the wider leak radius: a leaked token reads every private
repository in the organisation — read only, no write, no administration, no
persistence.
- **The contingency in §4 is satisfied.** The recommendation was conditional on
`STATE-WP-0084-T01` landing first, so a missing grant would surface as a
named condition rather than as silence. It landed on 2026-08-26 (state-hub
`85181cd`): an unreadable repository now reports as unreadable, and a source
that produced no records cannot retire anything even when retirement is
acknowledged.
- **Rotation: not separately decided; build-phase posture stands** as proposed
— no scheduled `secret_id` expiry, matching the existing workload AppRoles.
This is the §5 default rather than an explicit ruling, and it is the thing to
revisit first when posture changes.
**What approval authorises.** Phase 4 for `MASON-WP-0003-T02` and `T03` only:
the OpenBao policy, the AppRole, the KV path structure, and the pointer-only
ops-warden catalog entry. It does not authorise ops-mason to mint or handle the
token value — the forge owner mints it with organisation-wide repository read
and no other scope, and `paste_once_provision` delivers it.
## 7. Build result (phase 4)
Built 2026-08-27 against a live `platform-root` session. Recorded in
`audit/build-log.jsonl`.
| # | Object | State |
|---|---|---|
| 1 | KV path `platform/workloads/state-hub/forge-derivation` | created 2026-08-27, `FORGE_READ_TOKEN` v1, delivered by the paste-once desk |
| 2 | Policy `workload-kv-read-state-hub-forge-derivation` | created, read on the data and metadata paths only |
| 3 | Kubernetes auth role `state-hub-forge-derivation` | created, bound to ServiceAccount `state-hub/state-hub`, `token_ttl=15m`, `token_max_ttl=30m` |
| 4 | Token value | minted by the founder on `state-hub-svc`, delivered via `warden desk`; see §8 on value handling |
| 5 | ops-warden catalog entry | drafted on branch `mason/state-hub-forge-derivation-read`, `status: draft` |
### Deviation from the approved plan: Kubernetes auth, not AppRole
§2's survey was written from disk with no OpenBao session, and did not know that
`kubernetes/` auth is enabled on this same cluster (`https://10.43.0.1:443`)
with four `external-secrets-*` roles already using it. The plan's own phase-4
instruction — re-verify against live state before applying — is what surfaced it.
Ruled by the founder at phase 4 on 2026-08-27: **Kubernetes auth**. The pod
authenticates with its own projected ServiceAccount token, so no `role_id`
/`secret_id` pair exists to deliver into the cluster, store, or rotate. That
directly satisfies `STATE-WP-0084-T02`'s constraints — nothing baked into the
image, rotation without a redeploy — and removes a static credential of exactly
the kind whose custody gap cost a full session on the LLDAP side the previous
day.
### Verification
Positive and negative checked with `bao token capabilities` through a 2-minute
test token, revoked immediately after:
```text
platform/data/workloads/state-hub/forge-derivation read
platform/data/workloads/forgejo/forgejo-admin deny
platform/data/workloads/activity-core/llm-connect/...provider-secrets deny
platform/metadata/workloads/state-hub deny
```
The negative on `forgejo-admin` is the one that matters: not being able to do
what the admin lane can is the entire argument for building this lane rather
than reusing it.
### Reuse re-checked against live state
`bao policy list` shows one Forgejo-adjacent workload policy that §2 could not
see: `workload-kv-read-agent-harness-forgejo`. Read, and confirmed not
reusable — it grants a single agent-harness **deploy key** for one repository,
not organisation-wide read for a different consumer. §2's conclusion stands.
### Prerequisites before the lane carries traffic
1. ServiceAccount `state-hub` in namespace `state-hub` — the deployment
currently runs as `default`, and the role deliberately does not bind to
`default`, which would grant the lane to every pod in the namespace.
`STATE-WP-0084-T02`.
2. The Forgejo token, minted at organisation-wide repository read and delivered
to the KV path via `paste_once_provision`.
Until both are done the lane is correctly scoped and inert.
## 8. Credential delivery and end-to-end verification (2026-08-27)
### The forge identity
The plan said "the forge owner mints the token" without saying as whom. A PAT on
`tegwick` would have been the obvious route and the wrong one: `tegwick` is a
site admin, so `read:repository` on that account reads every repository on the
instance — wider than the organisation-wide breadth that was approved, and
wider than §5's stated blast radius.
Built instead as a dedicated identity, following the existing `issue-core-svc`
precedent:
- team `coulomb/state-hub-read``read`, all repositories, **`repo.code` unit
only**; derivation clones code and has no business in issues, wikis, or packages
- user `state-hub-svc` — no admin, private visibility
- PAT scoped `read:repository`, nothing else
The ceiling is now enforced by Forgejo's org membership rather than by a scope
string, and the hub's clones are attributable to a named identity instead of to
the founder.
### Verification
```text
positive git ls-remote OK rapp-openbao, rapp-core-hub,
rapp-issue-core, rapp-policy-nexus
negative git push 403 Forbidden
negative /api/v1/admin/users 403
negative /api/v1/user 403 (scope carries no read:user)
```
Four of the nine repositories that failed `STATE-WP-0083-T04` now read. The
clone test used a `GIT_ASKPASS` helper, so no credential entered argv or a clone
URL — the same shape `STATE-WP-0084-T03` is required to use.
### Value handling — a stated deviation
`SCOPE.md` puts secret values out of scope for ops-mason entirely. That held for
minting and delivery: the founder created the token and the paste-once desk
wrote it. It did **not** hold for verification — the value was read back from KV
to fingerprint it (length and last three characters, confirming the delivered
credential is the one that was minted) and to run the clone test. Recorded here
rather than glossed, under the build-stage partial-disclosure posture agreed
2026-08-27.
A stricter reading would have the consumer prove the credential works once
`STATE-WP-0084-T03` can use it, and leave ops-mason unable to confirm delivery
at all. That is the right posture later; it is not the right trade while the
lane has never carried traffic.