forgejo: the bounded credential adapter — one token per lease, deleted exactly #42

Merged
larandar merged 1 commit from forge/29-credential-adapter into fastlane 2026-09-03 23:00:20 +00:00
Owner

Session #20's last leaf, under OpenSpec 20. src/forgejo.rs, sibling of src/kanidm.rs.

Two findings from the live API that shaped this

Checked against the deployed instance's own OpenAPI description — Forgejo 16.0.3 — rather than from memory.

A Forgejo token has no expiry. CreateAccessTokenOption takes name, scopes and repositories, and nothing else. The AccessToken that comes back carries no expiry either. So boundedness is not something this adapter can ask for — it is a property the lease enforces by deleting the exact token when it ends.

That is a real divergence from #8: Kanidm's mint refuses without an explicit expiry, and this one has no such gate available. The type therefore carries no expiry field, so no caller can believe a Forge token expires on its own, and the mint test asserts none is sent — inventing one would be a lie on the wire.

Repository restriction is native. repositories is Forgejo's own least-scope mechanism, so OpenSpec 61's "repository-restricted" is a field rather than a convention to emulate.

Design decisions worth arguing with

The admin path, not the self-service one. /users/{name}/tokens authenticates as the account it mints for — which would mean Vedanta holding the queen's own credential. /admin/users/{name}/tokens lets one operator identity mint on her behalf without ever possessing what she authenticates with.

An empty repositories is refused before sending. Forgejo accepts it and mints a token reaching everything the account can, which is the opposite of a per-lease credential. Same for an empty scopes. The refusal belongs here, where it can say why.

404 on delete is success; 403 is not. A revocation that ran once must be able to finish twice, so an already-absent token is observed_before: false and the operation succeeds — the external result is identical either way, and which happened is kept rather than flattened, exactly as kanidm.rs does for invalidation. But treating a refusal as "already gone" would report a credential dead while it is live, which is the one lie this must never tell.

A mint response without the value is terminal. The value is returned exactly once; if that response did not carry it, nothing can recover it, so re-reading a path that will never return a secret is not a recovery.

Verification

Gate green: fmt stable, clippy --workspace --all-targets -D warnings clean, cargo test --workspace passing — 113 lib tests, up from 103.

Ten new tests against a loopback capture double that records method, path, body and the credential presented — so claims are about what the adapter did, not what it returned:

claim how it is proven
mints on the admin path exact path asserted, plus the operator credential in the header
names its repositories and scopes the sent body is decoded and compared
sends no expiry asserted absent, since Forgejo has no such field
account-wide mint refused request log is empty — nothing was sent
scopeless mint refused same
a denial is not retried exactly one request after a 403
deletes one exact id path asserted; never a name, never a sweep
an absent token still succeeds observed_before: false
a refused delete errors 403 does not become a silent success
listing never returns a value TokenRecord has no value field to return

Not here

No live proof. Per this record's delivery class, the live least-scope proof against a deployed Forgejo is a separate operator-gated record. Nothing here has authenticated to a real instance — the wire shapes come from the published OpenAPI description, and the first apply is what confirms the admin path's permission model in practice.

Closes #29
Refs #20

Session [#20](https://jo.et0.pw/Ting/Vedanta/issues/20)'s last leaf, under [OpenSpec 20](https://jo.et0.pw/Ting/Vedanta/wiki/OpenSpec-20-openbao-forge-identity-lifecycle). `src/forgejo.rs`, sibling of `src/kanidm.rs`. ## Two findings from the live API that shaped this Checked against the deployed instance's own OpenAPI description — **Forgejo 16.0.3** — rather than from memory. **A Forgejo token has no expiry.** `CreateAccessTokenOption` takes `name`, `scopes` and `repositories`, and nothing else. The `AccessToken` that comes back carries no expiry either. So boundedness is *not* something this adapter can ask for — it is a property the **lease** enforces by deleting the exact token when it ends. That is a real divergence from [#8](https://jo.et0.pw/Ting/Vedanta/issues/8): Kanidm's mint *refuses* without an explicit expiry, and this one has no such gate available. The type therefore carries **no expiry field**, so no caller can believe a Forge token expires on its own, and the mint test asserts none is sent — inventing one would be a lie on the wire. **Repository restriction is native.** `repositories` is Forgejo's own least-scope mechanism, so OpenSpec 61's "repository-restricted" is a field rather than a convention to emulate. ## Design decisions worth arguing with **The admin path, not the self-service one.** `/users/{name}/tokens` authenticates *as* the account it mints for — which would mean Vedanta holding the queen's own credential. `/admin/users/{name}/tokens` lets one operator identity mint on her behalf without ever possessing what she authenticates with. **An empty `repositories` is refused before sending.** Forgejo accepts it and mints a token reaching everything the account can, which is the opposite of a per-lease credential. Same for an empty `scopes`. The refusal belongs here, where it can say why. **404 on delete is success; 403 is not.** A revocation that ran once must be able to finish twice, so an already-absent token is `observed_before: false` and the operation succeeds — the external result is identical either way, and which happened is kept rather than flattened, exactly as `kanidm.rs` does for invalidation. But treating a *refusal* as "already gone" would report a credential dead while it is live, which is the one lie this must never tell. **A mint response without the value is terminal.** The value is returned exactly once; if that response did not carry it, nothing can recover it, so re-reading a path that will never return a secret is not a recovery. ## Verification Gate green: fmt stable, `clippy --workspace --all-targets -D warnings` clean, `cargo test --workspace` passing — **113 lib tests, up from 103.** Ten new tests against a loopback capture double that records method, path, body and the credential presented — so claims are about what the adapter *did*, not what it returned: | claim | how it is proven | |---|---| | mints on the admin path | exact path asserted, plus the operator credential in the header | | names its repositories and scopes | the sent body is decoded and compared | | sends no expiry | asserted absent, since Forgejo has no such field | | account-wide mint refused | request log is **empty** — nothing was sent | | scopeless mint refused | same | | a denial is not retried | exactly one request after a 403 | | deletes one exact id | path asserted; never a name, never a sweep | | an absent token still succeeds | `observed_before: false` | | a refused delete errors | 403 does not become a silent success | | listing never returns a value | `TokenRecord` has no value field to return | ## Not here **No live proof.** Per this record's delivery class, the live least-scope proof against a deployed Forgejo is a separate operator-gated record. Nothing here has authenticated to a real instance — the wire shapes come from the published OpenAPI description, and the first apply is what confirms the admin path's permission model in practice. Closes #29 Refs #20
Session #20's last leaf. The sibling of src/kanidm.rs, separate because the
stores are: different APIs, different permission models, different failure
modes, different doubles.

TWO THINGS DIFFER FROM THE KANIDM ADAPTER, and both come from Forgejo rather
than from taste. Verified against the deployed instance's OpenAPI description,
Forgejo 16.0.3.

A FORGEJO TOKEN HAS NO EXPIRY. CreateAccessTokenOption takes a name, scopes and
repositories, and nothing else; the record that comes back carries no expiry
either. Boundedness is therefore not something this adapter can ask for — it is
a property the LEASE enforces by deleting the exact token when it ends. Kanidm's
mint refuses without an explicit expiry; this one cannot have that gate, so the
type carries no expiry field that would let a caller believe the credential
expires on its own. The mint test asserts no expiry is sent, because inventing
one would be a lie on the wire.

RESTRICTION TO REPOSITORIES IS NATIVE. `repositories` is Forgejo's own
least-scope mechanism, so the contract's "repository-restricted" is a field
rather than a convention. An empty list is account-wide access, which is the
opposite of a per-lease credential, so the adapter refuses it before sending —
as it does an empty scope list.

THE ADMIN PATH, NOT THE SELF-SERVICE ONE. `/users/{name}/tokens` authenticates
as the account it mints for, which would mean Vedanta holding the queen's own
credential. `/admin/users/{name}/tokens` lets one operator identity mint on her
behalf without ever possessing what she authenticates with.

A 404 on delete is success with observed_before: false — a revocation that ran
once must be able to finish twice, and the external result is identical either
way. A 403 is NOT that: treating a refusal as "already gone" would report a
credential dead while it is live, which is the one lie this must never tell.

A mint response without the value is terminal. The value is returned exactly
once; if that response did not carry it, nothing can recover it, so re-reading a
path that will never return a secret is not a recovery.
larandar merged commit 4673e207e2 into fastlane 2026-09-03 23:00:20 +00:00
larandar deleted branch forge/29-credential-adapter 2026-09-03 23:00:20 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Ting/Vedanta!42
No description provided.