plans: #436b design r2 on dewey-0754: Gitea user id and a nonce adopt, grant.* as reserved action.allowed bodies with a system/grant trigger, seat-token rotation settled by GET /api/v1/token, the admin file's real reach (PATCH /admin/users proven), admin checks before consume with grants run, items 6 to 12; F8 to F12 from scratch 1.27.1; no code change
This commit is contained in:
+280
-154
@@ -1,4 +1,4 @@
|
|||||||
# Gitea grant path, design draft r1 (Vikunja #436b, Gitea #1697 follow-up)
|
# Gitea grant path, design draft r2 (Vikunja #436b, Gitea #1697 follow-up)
|
||||||
|
|
||||||
Author: Megavolt. Reviewer: Dewey. GitHub half: Bushroot. Base: Gizmoduck's #436a r4 (1c5705d6d,
|
Author: Megavolt. Reviewer: Dewey. GitHub half: Bushroot. Base: Gizmoduck's #436a r4 (1c5705d6d,
|
||||||
`agents/gizmoduck/work/machine-grant/DESIGN.md` on origin/gizmoduck/machine-grant-design) and row 199's
|
`agents/gizmoduck/work/machine-grant/DESIGN.md` on origin/gizmoduck/machine-grant-design) and row 199's
|
||||||
@@ -7,6 +7,35 @@ DESIGN.md section 7a (e10e03f6b). Mos rulings used here: mos-1130 and mos-1136.
|
|||||||
This draft builds nothing. It says how an Approve in Jason's inbox makes an agent's Gitea user, its token and
|
This draft builds nothing. It says how an Approve in Jason's inbox makes an agent's Gitea user, its token and
|
||||||
its forks with no manual step, and it answers mos-1130 item 3 (a to e) on the executor's admin credential.
|
its forks with no manual step, and it answers mos-1130 item 3 (a to e) on the executor's admin credential.
|
||||||
|
|
||||||
|
## r2 changes (from r1 714c07304, Dewey's dewey-0754, verdict sha256 20d63dfb…)
|
||||||
|
|
||||||
|
- D1 (blocks). `gitea_users` records the Gitea user `id`, and every later grant, rotation and fire compares it
|
||||||
|
(step 2). A nonce goes in `grant.step user` before the create and in the user's `full_name`, so a restart
|
||||||
|
adopts a user it made and refuses anything else (section 5, X-G6). Both are proven on scratch (F8, F9).
|
||||||
|
- D2 (blocks). The audit records are `action.allowed` bodies with reserved `grant.*` actions, plus a trigger
|
||||||
|
in the store migration that only `system/grant` writes them (section 7). A call and its result are two
|
||||||
|
records paired by `callId`, because `events` is append-only (3d). To settle once with Gizmoduck for #436a.
|
||||||
|
- D3 (blocks). Seat-token rotation gets its own rows. After `issue` has renamed the file, the executor settles
|
||||||
|
by asking Gitea which token the file holds (`GET /api/v1/token`, F7), and goes forward or back on that
|
||||||
|
answer, never by guessing (section 5).
|
||||||
|
- D4 (blocks). 3c and section 8 now state the real gap. Reading the admin file gives instance admin, which is
|
||||||
|
more than a forged Approve gives. `write:admin` resets any user's password, email and admin flag, proven on
|
||||||
|
scratch (F10). The separate uid is deferred on cost, and the draft says so.
|
||||||
|
- D5. The admin checks run before `#consumeGrant`, so a predictable refusal leaves Jason's Approve unspent. A
|
||||||
|
human `mosaic grants run <decision>` starts it again (step 0). The raise refuses when the admin slot is past
|
||||||
|
its rotate-by date.
|
||||||
|
- D6. One list of start checks (3a, 3e). The "no organization" claim is now part of the bootstrap promise,
|
||||||
|
and the check is that `GET /user/repos` is empty, which catches org and collaborator repositories (F11, and
|
||||||
|
F12 for a site-admin token).
|
||||||
|
- D7. Admin rotation: a timestamped name (F9: names are unique per user), `--rotate-by` required, and `setAt`
|
||||||
|
changes, so open approvals are voided and `status` says so.
|
||||||
|
- D8. The restart's remote undo runs the same admin checks and writes the same call records.
|
||||||
|
- D9 (Q-B1). On fire: `prohibit_login` (F10) and delete every token the user has, not only the recorded one.
|
||||||
|
- D10 (Q-B2). Step 5 checks the seat token's real reach with `GET /user/repos` (F11). Section 8 says reach can
|
||||||
|
grow later.
|
||||||
|
- D11. One Gitea per business, stated, and checked at `set` and at raise.
|
||||||
|
- D12. Section 8 asks for the jason.woltje comparison. I can't check it myself.
|
||||||
|
|
||||||
## 0. What this adds to #436a
|
## 0. What this adds to #436a
|
||||||
|
|
||||||
#436a section 11 left `method: "gitea"` for a later draft, because the executor needs an admin credential and
|
#436a section 11 left `method: "gitea"` for a later draft, because the executor needs an admin credential and
|
||||||
@@ -16,36 +45,54 @@ executor reads it.
|
|||||||
|
|
||||||
Everything in #436a's sections 4 and 5 holds unless this draft says otherwise: the `credential.mint` raise
|
Everything in #436a's sections 4 and 5 holds unless this draft says otherwise: the `credential.mint` raise
|
||||||
with a `grant` body, chief only (R3), the rendered block (R4), Jason's Approve via cli (R5, X1),
|
with a `grant` body, chief only (R3), the rendered block (R4), Jason's Approve via cli (R5, X1),
|
||||||
`#consumeGrant` with `operation: 'grant.gitea'`, the `#inflight` claim and abort (X5), `grant.step` events and
|
`#consumeGrant` with `operation: 'grant.gitea'`, the `#inflight` claim and abort (X5), `grant.step` records and
|
||||||
restart reconcile (X3), and revoke local first (section 6).
|
restart reconcile (X3), and revoke local first (section 6).
|
||||||
|
|
||||||
What row 199 already gives: the agent's `identity = {name, email, gitea: {url, user}}`, its
|
What row 199 already gives: the agent's `identity = {name, email, gitea: {url, user}}`, its
|
||||||
`publish: [project]` grant and each project's `remote` (199 D8, D9), the store and `issue(instance, service,
|
`publish: [project]` grant and each project's `remote` (199 D8, D9), the store and `issue(instance, service,
|
||||||
token, date, commit)` (`packages/bus/src/credentials.mjs:156`), and section 7a's rule that `issue` accepts a
|
token, date, commit)` (`packages/bus/src/credentials.mjs:156`), and section 7a's rule that `issue` accepts a
|
||||||
resolved `credential.mint` decision as the human path.
|
resolved `credential.mint` decision as the human path. `issue` writes the file (tmp, fsync, rename), then calls
|
||||||
|
`commit`, then fills memory (`credentials.mjs:156-193`). Section 5 depends on that order.
|
||||||
|
|
||||||
## 1. Facts, proven on a scratch Gitea
|
## 1. Facts, proven on a scratch Gitea
|
||||||
|
|
||||||
Run on gitea/gitea:1.27.1 (sha256:fe4e4b1c8b93) on 127.0.0.1, throwaway data, container removed after each
|
Run on gitea/gitea:1.27.1 (sha256:fe4e4b1c8b93) on 127.0.0.1, throwaway data, container removed after each
|
||||||
run. Scripts and logs: `/mnt/storage/scratch/megavolt-r199/g436b/` (`scratch_gitea.py`, `run4.log`,
|
run. Scripts and logs: `/mnt/storage/scratch/megavolt-r199/g436b/`. No log holds a token or password value.
|
||||||
`scope_matrix.py`, `matrix.log`). No token value appears in either log. Reported in megavolt-0034, and Mos
|
F1 to F6 were reported in megavolt-0034, and Mos accepted them in mos-1136.
|
||||||
accepted them in mos-1136.
|
|
||||||
|
|
||||||
- F1. `POST /api/v1/users/{u}/tokens` with `Authorization: Basic <admin>:<admin token>` creates a token for
|
- F1 (`run4.log`). `POST /api/v1/users/{u}/tokens` with `Authorization: Basic <admin>:<admin token>` creates a
|
||||||
another user: 201. The same token in a `token` or `Bearer` header gets 401 on that route (`auth required`),
|
token for another user: 201. The same token in a `token` or `Bearer` header gets 401 on that route, while the
|
||||||
while the `token` header works on `GET /api/v1/user` (200). So the executor sends Basic on the token routes
|
`token` header works on `GET /api/v1/user` (200). So the executor sends Basic on the token routes and
|
||||||
and `token` everywhere else.
|
`token` everywhere else.
|
||||||
- F2. The child-scope cap: a child scope broader than the parent's gets 403 ("cannot create an access token
|
- F2 (`run4.log`). The child-scope cap: a child scope broader than the parent's gets 403 ("cannot create an
|
||||||
with a broader scope than the authenticating token"), for `write:organization` and for `all`. A narrower one
|
access token with a broader scope than the authenticating token"), for `write:organization` and for `all`.
|
||||||
gets 201.
|
A narrower one gets 201.
|
||||||
- F3. TOTP 2FA on the admin account blocks password Basic (401) and doesn't block token Basic (201).
|
- F3 (`run4.log`). TOTP 2FA on the admin account blocks password Basic (401) and doesn't block token Basic
|
||||||
|
(201).
|
||||||
- F4. The route's check is `reqSelfOrAdmin`: whether the account is a site admin, not the token's scope. Any
|
- F4. The route's check is `reqSelfOrAdmin`: whether the account is a site admin, not the token's scope. Any
|
||||||
`write:user` token on a site-admin account can mint a token for any user.
|
`write:user` token on a site-admin account can mint a token for any user.
|
||||||
- F5. `write:X` implies `read:X` under Gitea's `HasScope`.
|
- F5. `write:X` implies `read:X` under Gitea's `HasScope`.
|
||||||
- F6. The cap is Gitea #38406, backported in #38426 (de4b8277e9 on release/v1.27, 2026-07-12). It first ships
|
- F6. The cap is Gitea #38406, backported in #38426 (de4b8277e9 on release/v1.27, 2026-07-12). It first ships
|
||||||
in v1.27.0. v1.27.0-rc0 lacks it, and so does every 1.24.x, 1.25.x and 1.26.x through 1.26.4.
|
in v1.27.0. v1.27.0-rc0 lacks it, and so does every 1.24.x, 1.25.x and 1.26.x through 1.26.4.
|
||||||
- F7. `GET /api/v1/token` and `DELETE /api/v1/token` exist in 1.27.1 (introspection and self-delete, api.go
|
- F7 (`introspect.log`). `GET /api/v1/token` with the `token` header gives 200 with `id`, `name`, `scopes`,
|
||||||
:1050-1053). `GET` needs the `token` header and reports normalized scopes (3b).
|
`user`, `created_at` and `last_used_at`, and no token value. With Basic it gives 404. Scopes come back
|
||||||
|
normalized (3b).
|
||||||
|
- F8 (`r2probe.log` 1a-1d). `POST /admin/users` with a `full_name` gives 201 with the user's `id` and that
|
||||||
|
`full_name`. `GET /users/SEAT-ONE` finds `seat-one` with the same `id`: names match case-insensitively. A
|
||||||
|
create of `Seat-One` next to `seat-one` gets 422.
|
||||||
|
- F9 (`r2probe.log` 2a-2c). A second token with a name already used by that user gets 400. `GET
|
||||||
|
/users/{u}/tokens` with Basic lists the user's tokens.
|
||||||
|
- F10 (`r2probe.log` 4a-4f, 5a). With `write:admin`, `PATCH /admin/users/{u}` on another site admin sets a new
|
||||||
|
password (200; the new password then signs in, the old one gets 401) and a new email. On a plain user it sets
|
||||||
|
`admin: true`, then `admin: false` with `prohibit_login: true`. Without `write:admin` the same PATCH gets 403.
|
||||||
|
- F11 (`reach.log`). With the seat token (`write:repository`, `read:user`), `GET /user/repos` lists the user's
|
||||||
|
own fork, then also a repository it was made collaborator on, then also an org repository after it joined an
|
||||||
|
org team. `GET /user/orgs` and `GET /users/{u}/orgs` need `read:organization`, which neither token holds.
|
||||||
|
- F12 (`c4.log`). With a site-admin account's three-scope token, `GET /user/repos` is empty on an instance
|
||||||
|
where other users and an org it isn't in hold public and private repositories: being site admin doesn't add
|
||||||
|
them. It lists a repository once the account is a collaborator, and an org repository once it joins that
|
||||||
|
org's team. Joining an org with no repositories leaves it empty, and `GET /user/orgs` gets 403 (no
|
||||||
|
`read:organization`).
|
||||||
|
|
||||||
## 2. The binding
|
## 2. The binding
|
||||||
|
|
||||||
@@ -65,14 +112,15 @@ Checks at raise, inside the raise transaction. Each refusal stores nothing:
|
|||||||
`identity` and a `publish` grant (199 D8). Otherwise `gitea-not-declared`.
|
`identity` and a `publish` grant (199 D8). Otherwise `gitea-not-declared`.
|
||||||
- Each project in `target.projects` is in the agent's `publish` list (`remote-not-granted`), and its
|
- Each project in `target.projects` is in the agent's `publish` list (`remote-not-granted`), and its
|
||||||
`remote`'s origin equals `identity.gitea.url`'s (`remote-host-mismatch`), the same checks as 199 D9 step 2.
|
`remote`'s origin equals `identity.gitea.url`'s (`remote-host-mismatch`), the same checks as 199 D9 step 2.
|
||||||
- The business has an admin slot for `gitea` whose recorded `url` has the same origin (`grant-admin-missing`,
|
- The business has an admin slot for `gitea` whose recorded `url` has the same origin
|
||||||
section 3).
|
(`grant-admin-missing`), and the slot isn't past its rotate-by date (`grant-admin-rotation-due`).
|
||||||
- `rotateBy` is inside 199's rotation window.
|
- `rotateBy` is inside 199's rotation window.
|
||||||
|
|
||||||
The rendered block, appended by the broker as #436a R4 does: agent, Gitea user, email, Gitea URL, each fork
|
The rendered block, appended by the broker as #436a R4 does: agent, Gitea user, email, Gitea URL, each fork
|
||||||
URL it will make (`<url>/<user>/<repo>`), the two fixed scopes, the rotate-by date, and one of "new Gitea
|
URL it will make (`<url>/<user>/<repo>`), the two fixed scopes, the rotate-by date, one of "new Gitea user",
|
||||||
user", "existing stack-made user, new token" or "token rotation" (section 4 step 2). The 4096-character cap
|
"existing stack-made user, new token" or "token rotation" (step 2), and the line "The token can write every
|
||||||
applies as in R4.
|
repository this user can write. Step 5 checks that this is only its forks." The 4096-character cap applies as
|
||||||
|
in R4.
|
||||||
|
|
||||||
The raise event body gets `grant = {method, agent, target, rotateBy, definitionSha256, identitySha256,
|
The raise event body gets `grant = {method, agent, target, rotateBy, definitionSha256, identitySha256,
|
||||||
adminSha256}`:
|
adminSha256}`:
|
||||||
@@ -88,18 +136,24 @@ upstream or admin credential between raise and Approve voids the Approve (REQ-GR
|
|||||||
|
|
||||||
### 3a. A dedicated account
|
### 3a. A dedicated account
|
||||||
|
|
||||||
One Gitea account per Gitea instance a stack grants on, named `mosaic-grant` by default. It is a site admin,
|
One Gitea account per Gitea instance, named `mosaic-grant` by default. It is a site admin and has TOTP 2FA on
|
||||||
it has TOTP 2FA on (F3: the token path still works), it owns no repositories and belongs to no organization.
|
(F3: the token path still works). Its password and TOTP secret stay with Jason and never reach the stack. It
|
||||||
Its password and TOTP secret stay with Jason and never reach the stack. It is never jason.woltje or any
|
is never jason.woltje or any other human's account. It owns no repositories, belongs to no organization and is
|
||||||
other human's account.
|
no repository's collaborator.
|
||||||
|
|
||||||
What code can check, at `set` (3e) and at every executor start:
|
The admin checks, one list, run at `set` (3e), before every executor start (step 0) and before every remote
|
||||||
|
undo (section 5):
|
||||||
|
|
||||||
- `GET /api/v1/user` with the token returns `login == account` as recorded at `set`, and `is_admin` true.
|
- C1. `GET /api/v1/version` is at least 1.27.0 (F6), compared as numbers, or `gitea-too-old`.
|
||||||
- `GET /api/v1/users/{account}/repos` is empty.
|
- C2. `GET /api/v1/user` gives `login == account` as recorded at `set`, and `is_admin` true, or
|
||||||
|
`grant-admin-account`.
|
||||||
|
- C3. `GET /api/v1/token` gives exactly the three scopes (3b), or `grant-admin-scope`.
|
||||||
|
- C4. `GET /api/v1/user/repos` is empty, or `grant-admin-not-dedicated`. By F11 and F12 this list covers
|
||||||
|
owned, collaborator and org-team repositories, and site admin alone doesn't fill it.
|
||||||
|
|
||||||
What code can't check is that nobody else uses the account. That is the bootstrap step's promise, and it goes
|
What code can't check: that nobody else uses the account, and org membership with no repositories (F11: the
|
||||||
in the approval notes (section 8).
|
orgs routes need `read:organization`, which the admin token doesn't hold and shouldn't). Both are the bootstrap
|
||||||
|
step's promise and go in the approval notes (section 8).
|
||||||
|
|
||||||
### 3b. The scopes, each shown needed
|
### 3b. The scopes, each shown needed
|
||||||
|
|
||||||
@@ -114,30 +168,24 @@ The admin token holds exactly `write:admin`, `write:user` and `write:repository`
|
|||||||
| without `write:repository` | 201 | **403** (cap, F2) | 202 | 204 |
|
| without `write:repository` | 201 | **403** (cap, F2) | 202 | 204 |
|
||||||
| without `read:user` | 201 | 201 | 202 | 204 |
|
| without `read:user` | 201 | 201 | 202 | 204 |
|
||||||
|
|
||||||
- `write:admin` is needed for A1.
|
- `write:admin` is needed for A1, and for `prohibit_login` on fire (F10).
|
||||||
- `write:user` is needed for A2 and A5.
|
- `write:user` is needed for A2 and A5.
|
||||||
- `write:repository` is needed only because of the cap. The seat's token holds `write:repository`, and a
|
- `write:repository` is needed only because of the cap. The seat's token holds `write:repository`, and a
|
||||||
parent must hold at least what it mints. The executor itself never writes a repository with it.
|
parent must hold at least what it mints. The executor itself never writes a repository with it.
|
||||||
- `read:user` is covered by `write:user` (F5), so it is dropped.
|
- `read:user` is covered by `write:user` (F5), so it is dropped.
|
||||||
|
|
||||||
The executor checks the token's scopes at `set` and at each start with `GET /api/v1/token`, and refuses
|
C3 reads the scopes with the `token` header (F7). Gitea reports normalized scopes, so a token made with the
|
||||||
`grant-admin-scope` unless they are exactly those three. A broader token (`all`, or another category) refuses
|
three plus `read:user` reads as the three. That is harmless, because `read:user` adds nothing to `write:user`.
|
||||||
too, so the slot can't silently hold more than the design says.
|
A token made with `all`, or with another category, refuses.
|
||||||
|
|
||||||
Checked on a scratch 1.27.1 (`token_introspect.py`, `introspect.log`, 2026-10-20T22:40Z):
|
|
||||||
|
|
||||||
- With the `token` header, `GET /api/v1/token` gives 200 with `id`, `name`, `scopes`, `user`, `created_at` and
|
|
||||||
`last_used_at`, and no token value. With Basic it gives 404 (`invalid access token`). So this check sends
|
|
||||||
the `token` header, unlike the mint route (F1).
|
|
||||||
- Gitea reports normalized scopes. A token made with the three scopes plus `read:user` reports the same three,
|
|
||||||
so the check can't tell them apart. That is harmless, because `read:user` adds nothing to `write:user` (F5).
|
|
||||||
A token made with `all` reports `["all"]` and refuses.
|
|
||||||
|
|
||||||
### 3c. Where it lives, who can read it
|
### 3c. Where it lives, who can read it
|
||||||
|
|
||||||
- The token: `<configDir>/secrets/grants/<business>/gitea/admin`, 0600, in 0700 dirs, owned by the stack's
|
- The token: `<configDir>/secrets/grants/<business>/gitea/admin`, 0600, in 0700 dirs, owned by the stack's
|
||||||
OS user. Written and checked by row 199's store code (D1: tmp, fsync, rename, the mode and owner checks).
|
OS user. Written and checked by row 199's store code (D1: tmp, fsync, rename, the mode and owner checks).
|
||||||
- The metadata: `admin.json` next to it, 0600, holding `url`, `account`, `rotateBy` and `setAt`. No secret.
|
- The metadata: `admin.json` next to it, 0600, holding `url`, `account`, `rotateBy` and `setAt`. No secret.
|
||||||
|
- A business grants on one Gitea. `set` refuses a second `url` while a slot exists (`grant-admin-exists`;
|
||||||
|
`remove` first), and the raise checks the agent's Gitea against the slot's `url` (section 2). Two businesses
|
||||||
|
on one Gitea each hold their own token for the same `mosaic-grant` account.
|
||||||
|
|
||||||
Who can read the file: the stack's OS user, and root.
|
Who can read the file: the stack's OS user, and root.
|
||||||
|
|
||||||
@@ -153,47 +201,58 @@ Whether an agent process runs as that uid:
|
|||||||
- On the .53 and .54 installs (#432) the stack runs as its own OS user. Workers run sandboxed as that user.
|
- On the .53 and .54 installs (#432) the stack runs as its own OS user. Workers run sandboxed as that user.
|
||||||
The T3 seats don't run there.
|
The T3 seats don't run there.
|
||||||
|
|
||||||
Why not a separate uid for the executor now: the Approve itself rests on `verifyHuman`, a cooperative
|
What a separate uid would buy. A same-uid process has two paths. It can forge a human proof (`verifyHuman` is
|
||||||
same-UID check (`packages/bus/src/human.mjs:50-52`, #436a section 9). A process that can read the admin file
|
a cooperative same-UID check, `packages/bus/src/human.mjs:50-52`), and an approved grant then gives it a
|
||||||
as the stack's uid can also forge a human proof as that uid, and get the same token through an approved
|
two-scope token for a stack-made, non-admin user, because step 2 refuses foreign and admin users. Or it can
|
||||||
grant. A separate uid for the file alone would add an IPC hop and not raise the bar. It becomes worth doing
|
read the admin file, which gives instance admin (section 8). A separate uid for the file and the executor would
|
||||||
when the human proof stops being same-uid (#435 behind Q175 sign-in). I'd make that a follow-up row with the
|
close the second path and leave the first. That is a real step up, from "a seat token for a stack user" to "no
|
||||||
executor as its own process and uid.
|
admin".
|
||||||
|
|
||||||
|
I defer it on cost, not because it wouldn't help. It means the executor runs as its own process under its own
|
||||||
|
uid, with an IPC path from the bus that carries the decision id and nothing the bus could forge. That is a row
|
||||||
|
of its own, and it is cleaner after the human proof stops being same-uid (#435 behind Q175 sign-in). Jason
|
||||||
|
approves the gap as section 8 states it.
|
||||||
|
|
||||||
### 3d. Every use logged, and rotation
|
### 3d. Every use logged, and rotation
|
||||||
|
|
||||||
Every Gitea call made with the admin token writes one event, `grant.service.call`:
|
Every Gitea call made with the admin token, and every call the executor makes with a seat token, writes two
|
||||||
|
records (section 7 has where they live):
|
||||||
|
|
||||||
```
|
```
|
||||||
{ decision | revoke | sweep | admin, method: "gitea", op, status }
|
grant.call { callId, reason, method: "gitea", op } written before the call
|
||||||
|
grant.result { callId, status } written after it
|
||||||
```
|
```
|
||||||
|
|
||||||
- `op` is one of `version`, `self`, `token-scopes`, `user-get`, `user-create`, `token-create`,
|
- `op` is one of `version`, `self`, `token-scopes`, `repos-check`, `user-get`, `user-create`, `user-patch`,
|
||||||
`token-delete`, `repos-check`, `admin-rotate`.
|
`token-list`, `token-create`, `token-delete`, `admin-rotate`, `fork-get`, `fork`, `seat-self`,
|
||||||
- The first key is the reason for the call: the decision id for a grant, the `grant.revoked` event id for a
|
`seat-repos`, `token-whoami`.
|
||||||
revoke, the `sweep` event id, or `admin` with the human actor for `set` and `rotate`.
|
- `reason` is the decision id for a grant or its undo, the `grant.revoked` record id for a revoke, the
|
||||||
- No body, no header, no token, no URL query. `Credentials.assertClean` (`packages/bus/src/credentials.mjs:233`, the runtime's `secretCheck`) runs on
|
`grant.sweep` record id for a sweep, or `admin:<verb>` with the human actor for `set`, `rotate` and `remove`.
|
||||||
it.
|
- No body, no header, no token, no URL query. `Credentials.assertClean` (`packages/bus/src/credentials.mjs:233`,
|
||||||
- The call runs only after its event row is written as `pending`. The status is filled in the same
|
the runtime's `secretCheck`) runs on both.
|
||||||
transaction as the step's next `grant.step`. A crash between the two leaves a pending call, which reconcile
|
- `events` is append-only (`events_no_update`, `packages/bus/src/migrations/3b.sql:131`), so the result is its
|
||||||
reports.
|
own record, written in the transaction of the step's next `grant.step` where there is one. A `grant.call`
|
||||||
|
with no `grant.result` means the process died during the call. Reconcile lists each one in its message.
|
||||||
|
|
||||||
Calls made with the agent's new token (forks, verify) are logged the same way, with `op` `fork` and
|
Rotation of the admin token: a human-only verb, `mosaic grants admin rotate gitea --rotate-by <date>`. It never
|
||||||
`seat-self`. They carry the decision id too.
|
runs from an agent, the chief or the tester, and it doesn't need a decision because only `verifyHuman` can
|
||||||
|
start it:
|
||||||
|
|
||||||
Rotation of the admin token: a human-only verb, `mosaic grants admin rotate gitea`. It never runs from an
|
1. C1 to C4 with the current token.
|
||||||
agent, the chief or the tester, and it doesn't need a decision because only `verifyHuman` can start it:
|
2. Mint a successor for the admin account itself: `POST /users/<account>/tokens` with Basic and the same three
|
||||||
|
scopes, named `mosaic-grant-<UTC yyyymmddThhmmssZ>`, so a second rotation on one day doesn't collide (F9).
|
||||||
|
F2's cap holds it to the current token's scopes.
|
||||||
|
3. C2 and C3 with the successor.
|
||||||
|
4. Write it through 199's tmp, fsync and rename. Update `admin.json` with the new `rotateBy` and a new `setAt`.
|
||||||
|
5. Delete the old token at Gitea by name, with the new token. If that fails, `status` shows
|
||||||
|
`adminTokenLeft: <name>` until `rotate --finish` succeeds.
|
||||||
|
|
||||||
1. Mint a successor for the admin account itself: `POST /users/<account>/tokens` with Basic and the same three
|
A new `setAt` changes `adminSha256`, so every grant decision raised before the rotation and not yet run
|
||||||
scopes, name `mosaic-grant-<setAt date>`. F2's cap holds it to the current token's scopes.
|
refuses `decision-mismatch` (fail closed). `rotate` prints how many open grant decisions that voids, and
|
||||||
2. Check the successor: `GET /api/v1/user` (login, `is_admin`) and `GET /api/v1/token` (the three scopes).
|
`status` lists them.
|
||||||
3. Write it through 199's tmp, fsync and rename, and update `admin.json`.
|
|
||||||
4. Delete the old token at Gitea by name, with the new token. If that fails, `status` shows `adminTokenLeft:
|
|
||||||
<name>` until a retry succeeds.
|
|
||||||
|
|
||||||
`status` shows the rotate-by date. Past it, the executor refuses `grant-admin-rotation-due` and grants wait
|
`status` shows the rotate-by date. Past it, the raise and the executor refuse `grant-admin-rotation-due` until
|
||||||
until Jason rotates. That fails closed, so a forgotten rotation stops grants rather than letting an old token
|
Jason rotates. A forgotten rotation stops grants rather than letting an old token run on.
|
||||||
run on.
|
|
||||||
|
|
||||||
If the admin token leaks, Jason deletes it in Gitea (Settings, Applications, as `mosaic-grant`) or disables
|
If the admin token leaks, Jason deletes it in Gitea (Settings, Applications, as `mosaic-grant`) or disables
|
||||||
the account, then runs `mosaic grants admin remove gitea`, which unlinks the slot. Live agent tokens keep
|
the account, then runs `mosaic grants admin remove gitea`, which unlinks the slot. Live agent tokens keep
|
||||||
@@ -204,83 +263,103 @@ working. Each one is revoked on its own (section 6).
|
|||||||
Once per Gitea instance per stack, at #436b deploy. This is the only manual credential step left, and
|
Once per Gitea instance per stack, at #436b deploy. This is the only manual credential step left, and
|
||||||
Jason's approval names it:
|
Jason's approval names it:
|
||||||
|
|
||||||
1. In Gitea, Jason creates `mosaic-grant` as a site admin, turns on TOTP, and as that user creates a token
|
1. In Gitea, Jason creates `mosaic-grant` as a site admin with no repositories and no organizations, turns on
|
||||||
with `write:admin`, `write:user` and `write:repository`.
|
TOTP, and as that user creates a token with `write:admin`, `write:user` and `write:repository`.
|
||||||
2. On the stack host, as the stack's OS user:
|
2. On the stack host, as the stack's OS user:
|
||||||
`mosaic grants admin set gitea --url <url> --account mosaic-grant --rotate-by <date>`, with the token on
|
`mosaic grants admin set gitea --url <url> --account mosaic-grant --rotate-by <date>`, with the token on
|
||||||
stdin (never argv).
|
stdin (never argv).
|
||||||
|
|
||||||
`set` is human-only (`verifyHuman`). Before it writes anything it checks, live:
|
`set` is human-only (`verifyHuman`). It runs C1 to C4 (3a) before it writes anything, and any failure writes
|
||||||
|
nothing.
|
||||||
- `GET /api/v1/version` is at least 1.27.0 (mos-1136 item 3, F6), or it refuses `gitea-too-old`.
|
|
||||||
- `GET /api/v1/user` gives `login == --account` and `is_admin` true.
|
|
||||||
- `GET /api/v1/token` gives exactly the three scopes (3b).
|
|
||||||
- The account owns no repositories (3a).
|
|
||||||
|
|
||||||
Any failure writes nothing. The executor repeats the version, self and scope checks at every start, so an
|
|
||||||
in-place downgrade, a demoted account or a swapped token stops grants.
|
|
||||||
|
|
||||||
## 4. The executor's steps
|
## 4. The executor's steps
|
||||||
|
|
||||||
Before step 1 the executor claims `(agent, "gitea")` in `#inflight` (#436a X5). The tag is #436a X2 step 2's,
|
0. **Start and pre-checks.** X1 starts the executor on Jason's yes. It claims `(agent, "gitea")` in
|
||||||
over business, agent and `"gitea"`.
|
`#inflight` (#436a X5), then runs C1 to C4 and the admin rotate-by check. A refusal here writes
|
||||||
|
`grant.failed {code, spent: false}` and leaves the decision unspent. After the fix (a rotation, an upgrade),
|
||||||
|
Jason runs `mosaic grants run <decision>` (human-only, `verifyHuman`), which starts step 0 again. No other
|
||||||
|
path starts it. The tag is #436a X2 step 2's, over business, agent and `"gitea"`.
|
||||||
1. **Recheck and consume**, in one transaction: `#consumeGrant` with `operation: 'grant.gitea'`, the
|
1. **Recheck and consume**, in one transaction: `#consumeGrant` with `operation: 'grant.gitea'`, the
|
||||||
definition still declares `gitea`, the three hashes match (section 2), no revoke of `(agent, gitea)` newer
|
definition still declares `gitea`, the three hashes match (section 2), no revoke of `(agent, gitea)` newer
|
||||||
than the raise. Then, outside the transaction and before anything is written at Gitea: the version, self
|
than the raise. Any failure is `grant.failed` with its code, and nothing remote has changed.
|
||||||
and scope checks (3e), and the rotate-by check (3d). Any failure is `grant.failed` with its code, and
|
2. **User.** `GET /users/{user}` (F8: case-insensitive).
|
||||||
nothing remote has changed.
|
- 404: write `grant.step {step: 'user', nonce}`, where `nonce` is 16 random hex digits. Then
|
||||||
2. **User.** `GET /users/{user}`.
|
`POST /admin/users` with the agent's `gitea.user` and `email`, `full_name` = `<identity.name> [mosaic
|
||||||
- 404: write `grant.step {step: 'user'}`, then `POST /admin/users` with the agent's `gitea.user` and
|
<nonce>]`, a random password that is never stored, and `must_change_password: false`. Then, in one
|
||||||
`email`, a random password that is never stored, and `must_change_password: false`. Then record the user
|
transaction, record `gitea_users {user, id, nonce}` with the `id` from the response (F8) and
|
||||||
in `gitea_users` (section 7).
|
`grant.result`.
|
||||||
- 200 and the user is in `gitea_users` for this business: go on. That is a second grant or a rotation.
|
- 200, and `gitea_users` has this business and agent with the same `id`: go on. That is a second grant or a
|
||||||
- 200 and it isn't: refuse `gitea-user-foreign`. The executor never mints a token for a user this stack
|
rotation. A user deleted at Gitea and remade by a human under the same name has a new `id`, and refuses.
|
||||||
didn't create. Without this check an `identity` naming a human's account, jason.woltje included, would get
|
- 200 with any other `id`, or no `gitea_users` record: refuse `gitea-user-foreign`. The executor never mints
|
||||||
a token minted for that account on Approve.
|
a token for a user this stack didn't create. Without this check an `identity` naming a human's account,
|
||||||
- The user is a site admin: refuse `gitea-user-admin`.
|
jason.woltje included, would get a token minted for that account on Approve.
|
||||||
|
- The user is a site admin, or has `prohibit_login`: refuse `gitea-user-admin` or `gitea-user-disabled`.
|
||||||
3. **Token.** Write `grant.step {step: 'mint', tokenName}`, with the name `mosaic-<tag>-<decision>`. Then
|
3. **Token.** Write `grant.step {step: 'mint', tokenName}`, with the name `mosaic-<tag>-<decision>`. Then
|
||||||
`POST /users/{user}/tokens` with Basic and scopes `write:repository` and `read:user`. The value is held in
|
`POST /users/{user}/tokens` with Basic and scopes `write:repository` and `read:user`. The value is held in
|
||||||
memory only. Write `grant.step {step: 'minted', tokenId}`.
|
memory only. Write `grant.step {step: 'minted', tokenId}`.
|
||||||
4. **Forks.** With the new token, for each project: `GET /repos/{user}/{repo}`. If it exists, its `parent`
|
4. **Forks.** With the new token, for each project: `GET /repos/{user}/{repo}`. If it exists, its `parent`
|
||||||
must be the project's upstream, or `fork-foreign`. If it's 404, `POST /repos/{owner}/{repo}/forks`.
|
must be the project's upstream, or `fork-foreign`. If it's 404, `POST /repos/{owner}/{repo}/forks`.
|
||||||
Push-to-create stays off on the Gitea side (199 N11).
|
Push-to-create stays off on the Gitea side (199 N11).
|
||||||
5. **Verify.** `GET /api/v1/user` with the new token gives `login == user`.
|
5. **Verify.** With the new token: `GET /api/v1/user` gives `login == user` and the recorded `id`, and
|
||||||
6. **Go live.** Write `grant.step {step: 'commit', previousTokenId}`. Check the abort signal, and with no
|
`GET /api/v1/user/repos` lists exactly this user's forks of granted upstreams (F11). Any other repository
|
||||||
`await` between, call 199 `issue(agent, "gitea", token, rotateBy, commit)`. `commit` writes the
|
refuses `gitea-reach-wider` and names it, so a user someone added as a collaborator or to an org team
|
||||||
`gitea_grants` record (tokenId, tokenName, projects, rotateBy, decision). The reference is registered first
|
doesn't get a token that reaches past the rendered block.
|
||||||
(#436a X4).
|
6. **Go live.** Write `grant.step {step: 'commit', previousTokenId}` (`null` for a first grant). Check the
|
||||||
|
abort signal, and with no `await` between, call 199 `issue(agent, "gitea", token, rotateBy, commit)`.
|
||||||
|
`commit` writes the `gitea_grants` record (tokenId, tokenName, projects, rotateBy, decision). The reference
|
||||||
|
is registered first (#436a X4).
|
||||||
7. **Old token.** For a rotation, `DELETE /users/{user}/tokens/{previousTokenId}` after the new one is live.
|
7. **Old token.** For a rotation, `DELETE /users/{user}/tokens/{previousTokenId}` after the new one is live.
|
||||||
A failure adds `serviceLeft: <id>`, which `sweep` clears.
|
A failure adds `serviceLeft: <id>`, which `sweep` clears.
|
||||||
8. **Event and message.** `grant.applied {decision, agent, user, tokenId, forks}` and a message to the human
|
8. **Applied.** `grant.applied {decision, agent, user, userId, tokenId, forks}` and a message to the human and
|
||||||
and the chief. The token appears in no event, message or log.
|
the chief. The token appears in no record, message or log.
|
||||||
|
|
||||||
The claim is released in a `finally`.
|
The claim is released in a `finally`.
|
||||||
|
|
||||||
## 5. Failure and restart
|
## 5. Failure and restart
|
||||||
|
|
||||||
Ordered so a failure leaves nothing an agent can use. "Delete the token" is `DELETE /users/{user}/tokens/{id
|
Ordered so a failure leaves nothing an agent can use. "Delete the token" is `DELETE /users/{user}/tokens/{id
|
||||||
or name}` with the admin token. Best effort, and a failure adds `serviceLeft`.
|
or name}` with the admin token, after C1 to C4, with `grant.call` and `grant.result` whose reason is the
|
||||||
|
decision (D8). Best effort; a failure adds `serviceLeft`.
|
||||||
|
|
||||||
|
**Settle.** Once `issue` has renamed the file, the file may hold the new token or the previous one, and the
|
||||||
|
record may name either. The executor and the restart never guess. They read the store file and ask Gitea which
|
||||||
|
token it is: `GET /api/v1/token` with that value in the `token` header (F7, op `token-whoami`). The answer is
|
||||||
|
an `id`, never a value.
|
||||||
|
|
||||||
|
- `id` is the new token: forward. The `gitea_grants` record gets the new token (if `commit` hadn't run),
|
||||||
|
memory loads the file (`Credentials.reload(instance, service)`, a new 199 method with load's checks), and
|
||||||
|
the previous token is deleted.
|
||||||
|
- `id` is the previous token: back. The new token is deleted. The record keeps or gets the previous token.
|
||||||
|
- 401, or no file: both are gone or unusable. Delete both by id, unregister, delete the record, and end
|
||||||
|
`grant.failed {code: 'grant-lost'}`. A new decision is needed.
|
||||||
|
|
||||||
| Fails at | State left | Cleanup |
|
| Fails at | State left | Cleanup |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 0 | nothing, the decision unspent | none. `grants run` retries |
|
||||||
| 1 | nothing | none |
|
| 1 | nothing | none |
|
||||||
| 2 | maybe a new user with no token | none. The user is recorded and harmless without a token. A later grant reuses it |
|
| 2, before the create | nothing | none |
|
||||||
| 3 before `minted` | maybe a token nobody holds | delete the token by its recorded name |
|
| 2, after the create, before the record | a user with the nonce | none here. Restart adopts it by the nonce (below) |
|
||||||
|
| 3, before `minted` | maybe a token nobody holds | delete the token by its recorded name |
|
||||||
| 4 or 5 | a token, maybe some forks | delete the token. Forks stay; they hold only upstream content |
|
| 4 or 5 | a token, maybe some forks | delete the token. Forks stay; they hold only upstream content |
|
||||||
| 6 before `commit` | a token, maybe a store file | unlink the file, unregister, delete the token |
|
| 6 before the rename, first grant | a token | unregister, delete the token |
|
||||||
| 6 after `commit` | a live grant | the record back to the previous one (deleted for a first grant), 199 `revoke`, delete the token |
|
| 6 after the rename, first grant | a store file with the new token, maybe the record | unlink the file, unregister, delete the record if written, delete the token |
|
||||||
|
| 6 before the rename, rotation | the previous grant intact | delete the new token |
|
||||||
|
| 6 after the rename, rotation | the file holds the new token, memory the previous | settle (forward: the new token passed step 5) |
|
||||||
|
| 7 | both tokens live, the new one in use | `serviceLeft: <previousTokenId>` |
|
||||||
|
|
||||||
**Restart**, before `Credentials` loads, from the last `grant.step`, as #436a X3:
|
**Restart**, before `Credentials` loads, from the last `grant.step`, as #436a X3. The local part runs first;
|
||||||
|
the remote part runs after the bus is up, with C1 to C4.
|
||||||
|
|
||||||
| Last step | Local undo | Remote undo, after the bus is up |
|
| Last step | Local | Remote |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `user` | none | none |
|
| `user` | none | `GET /users/{user}`. 200 with `full_name` ending `[mosaic <nonce>]`, not admin: adopt, record `gitea_users` with its `id`. 404: none. Anything else: `gitea-user-orphan` with the name, for Jason to look at; nothing is deleted |
|
||||||
| `mint` or `minted` | none | delete the token by name |
|
| `mint` or `minted` | none | delete the token by name |
|
||||||
| `commit` | unlink the file if present, record back to the previous one | delete the new token |
|
| `commit`, first grant | unlink the file if present, delete the record if present | delete the new token |
|
||||||
|
| `commit`, rotation | none | settle |
|
||||||
|
|
||||||
Then `grant.failed {code: 'interrupted'}` and a message to Jason, who approves a new decision. A pending
|
Then `grant.failed {code: 'interrupted'}` (or `grant.applied` if a rotation settled forward) and a message to
|
||||||
`grant.service.call` with no result is listed in that message.
|
Jason. A `grant.call` with no `grant.result` is listed in that message. After `interrupted`, the chief raises
|
||||||
|
again and Jason approves again.
|
||||||
|
|
||||||
## 6. Revoke
|
## 6. Revoke
|
||||||
|
|
||||||
@@ -289,35 +368,68 @@ dev only):
|
|||||||
|
|
||||||
1. 199 `revoke`: memory, abort a use or the executor in flight, unlink the file.
|
1. 199 `revoke`: memory, abort a use or the executor in flight, unlink the file.
|
||||||
2. One store transaction deletes the `gitea_grants` record and records `grant.revoked`.
|
2. One store transaction deletes the `gitea_grants` record and records `grant.revoked`.
|
||||||
3. `DELETE /users/{user}/tokens/{tokenId}` with the admin token. If Gitea can't be reached,
|
3. After C1 to C4, `DELETE /users/{user}/tokens/{tokenId}` with the admin token. If Gitea can't be reached,
|
||||||
`grant.revoked {serviceLeft: <tokenId>}`, and `mosaic grants sweep gitea` clears it later.
|
`grant.revoked {serviceLeft: <tokenId>}`, and `mosaic grants sweep gitea` clears it later.
|
||||||
|
|
||||||
That closes 199 D12's gap: the token is gone at Gitea without Jason's A5 step.
|
That closes 199 D12's gap: the token is gone at Gitea without Jason's A5 step.
|
||||||
|
|
||||||
The Gitea user and its forks stay after a revoke, so a new grant can reuse them. On `credentials delete` (a
|
The Gitea user and its forks stay after a revoke, so a new grant can reuse them.
|
||||||
fire), the executor also sets `prohibit_login` on the user through `PATCH /admin/users/{user}`. That route is
|
|
||||||
not yet proven on scratch (Q-B1).
|
On `credentials delete` (a fire), after the steps above and with the recorded `id` checked first:
|
||||||
|
|
||||||
|
- `GET /users/{user}/tokens` with Basic (F9), and delete every token it lists, not only the recorded one. A
|
||||||
|
token the record doesn't know about doesn't outlive a fire.
|
||||||
|
- `PATCH /admin/users/{user}` with `prohibit_login: true` (F10).
|
||||||
|
- Each step that fails is listed in `serviceLeft`, and `sweep` retries it.
|
||||||
|
|
||||||
## 7. Store records
|
## 7. Store records
|
||||||
|
|
||||||
Two tables, in #436a S1's migration if row b of #436a and this row land together, otherwise a `3j-3k.sql`:
|
Two tables, in #436a S1's migration if #436a row b and this row land together, otherwise a `3j-3k.sql`:
|
||||||
|
|
||||||
- `gitea_users`: business, agent, user, the decision that created it.
|
- `gitea_users`: business, agent, user, id, nonce, the decision that created it.
|
||||||
- `gitea_grants`: business, agent, user, tokenId, tokenName, projects, rotate-by date, decision.
|
- `gitea_grants`: business, agent, user, tokenId, tokenName, projects, rotate-by date, decision.
|
||||||
|
|
||||||
The executor, revoke and `sweep` are the only writers, each in the transaction of its event. The admin slot
|
The executor, revoke and `sweep` are the only writers, each in the transaction of its record. The admin slot
|
||||||
is a file, not a table, so a store backup holds no admin token.
|
is a file, not a table, so a store backup holds no admin token.
|
||||||
|
|
||||||
|
The audit records (`grant.step`, `grant.call`, `grant.result`, `grant.applied`, `grant.failed`,
|
||||||
|
`grant.revoked`, `grant.sweep`) are `action.allowed` events with that name in `body.action`, the pattern
|
||||||
|
`#consumeGrant` already uses for `grant.<method>`. They are not new event kinds, so `events` and its CHECK
|
||||||
|
(`3b.sql:9-30`) aren't rebuilt. Two guards stop a forged record:
|
||||||
|
|
||||||
|
- `recordEvent`, the trusted adapters' path, refuses `reserved-event` for any `action.allowed` whose
|
||||||
|
`body.action` starts with `grant.`, as it does today for `git.push.working` and the merge action
|
||||||
|
(`packages/bus/src/broker.mjs` `recordEvent`).
|
||||||
|
- The migration adds a trigger: an `action.allowed` event with `body.action` GLOB `'grant.*'` aborts unless
|
||||||
|
its actor is `system/grant`. So a path that misses the first guard still can't write one.
|
||||||
|
|
||||||
|
#436a S1 has the same gap for `grant.step` and `host.enroll.step`. I'll settle this with Gizmoduck so both use
|
||||||
|
one rule.
|
||||||
|
|
||||||
## 8. Approval notes for Jason
|
## 8. Approval notes for Jason
|
||||||
|
|
||||||
Stated once, here, for the #436b approval:
|
Stated once, here, for the #436b approval:
|
||||||
|
|
||||||
- **Site admin plus `write:user`.** The admin token can mint a token for any Gitea user, with any scope up to
|
- **Site admin plus `write:user`, and `write:admin`.** The admin token can mint a token for any Gitea user,
|
||||||
its own three, including users the stack didn't create (F4). The executor refuses those (step 2), but the
|
with any scope up to its own three, including users the stack didn't create (F4). With `write:admin` it can
|
||||||
token itself can. Whoever holds it can create users and repositories and read or write any repository.
|
also set any user's password, email and admin flag, and disable any user (F10, proven on scratch against a
|
||||||
|
second site admin). Whoever holds the token can take over any account on that Gitea, jason.woltje's
|
||||||
|
included, make any user a site admin, and read or write any repository. The executor uses none of that
|
||||||
|
beyond its steps, but the token itself can.
|
||||||
- **Same uid on dragon-lin.** The token is a file readable by jwoltje, and every agent process on dragon-lin,
|
- **Same uid on dragon-lin.** The token is a file readable by jwoltje, and every agent process on dragon-lin,
|
||||||
T3 dev seats included, runs as jwoltje (3c). The seat sandbox keeps sandboxed workers away from it. It does
|
T3 dev seats included, runs as jwoltje (3c). The seat sandbox keeps sandboxed workers away from it. It does
|
||||||
not keep an unsandboxed same-uid process away. A separate uid waits on a stronger human proof (3c).
|
not keep an unsandboxed same-uid process away. Such a process gets instance admin by reading the file, which
|
||||||
|
is more than it could get by forging an Approve. A separate uid would close that path. It is deferred on cost
|
||||||
|
(3c), not because it wouldn't help.
|
||||||
|
- **For comparison, today (D12).** Every dev seat on dragon-lin already pushes as jason.woltje through
|
||||||
|
`scripts/git-credential-mosaic.mjs` (mos-1106). If jason.woltje is a site admin on git.mosaicstack.dev, the
|
||||||
|
admin file adds little exposure there beyond what the seats already have. I can't check that account's admin
|
||||||
|
flag myself. Sage or Mos should confirm it before this note goes to Jason.
|
||||||
|
- **A seat token's reach can grow.** Step 5 checks, at the grant, that the token reaches only its forks. If
|
||||||
|
someone later adds the user to an org or as a collaborator, the token reaches that too, until the next grant
|
||||||
|
or rotation checks again.
|
||||||
|
- **The admin account is dedicated by promise.** Code checks that it is a site admin with these three scopes
|
||||||
|
and no reachable repositories (C1 to C4). It can't check that nobody else uses it or that it is in no org.
|
||||||
- **The bootstrap step** (3e) is the one manual credential step: Jason creates `mosaic-grant`, its TOTP and
|
- **The bootstrap step** (3e) is the one manual credential step: Jason creates `mosaic-grant`, its TOTP and
|
||||||
its token, and enters the token once on stdin.
|
its token, and enters the token once on stdin.
|
||||||
|
|
||||||
@@ -329,40 +441,54 @@ or any other live service. No token value is printed.
|
|||||||
|
|
||||||
| Test | Claim |
|
| Test | Claim |
|
||||||
|---|---|
|
|---|---|
|
||||||
| X-G1 | a gitea raise for an agent without `gitea`, `identity` or `publish`, or a project outside `publish`, or a remote on another host, refuses and stores nothing |
|
| X-G1 | a gitea raise for an agent without `gitea`, `identity` or `publish`, or a project outside `publish`, a remote on another host, no admin slot, or a slot past rotate-by refuses and stores nothing |
|
||||||
| X-G2 | `set` refuses a version below 1.27.0 (fake reports 1.26.4 and 1.27.0-rc0), a non-admin, a login mismatch, an account with repositories, and a token with a fourth scope or `all`. It writes nothing on any refusal |
|
| X-G2 | `set` refuses a version below 1.27.0 (fake reports 1.26.4, 1.27.0-rc0, and 1.9.0 against 1.27.0 as a string trap), a non-admin, a login mismatch, an account that reaches a repository, a token with another scope or `all`, and a second `url`. It writes nothing on any refusal |
|
||||||
| X-G3 | on scratch 1.27.1, end to end: user made, token minted with the two scopes, forks made, `issue` writes the 0600 file, `grant.applied`, then revoke deletes the token at Gitea (404 on its list) |
|
| X-G3 | on scratch 1.27.1, end to end: user made with the nonce and its `id` recorded, token minted with the two scopes, forks made, step 5 passes, `issue` writes the 0600 file, `grant.applied`, then revoke deletes the token at Gitea (gone from its list) |
|
||||||
| X-G4 | an existing user not in `gitea_users`, or a site-admin user, refuses before any token call |
|
| X-G4 | an existing user with no record, a recorded name whose `id` differs (deleted and remade, and a case variant of the name), a site-admin user and a disabled user each refuse before any token call |
|
||||||
| X-G5 | each failure row of section 5 leaves the stated state. A failed delete leaves `serviceLeft` |
|
| X-G5 | each failure row of section 5 leaves the stated state, rotation rows included. A failed delete leaves `serviceLeft` |
|
||||||
| X-G6 | a restart after `mint`, after `minted` and after `commit` each reconciles to no token at Gitea, no file and no record |
|
| X-G6 | a restart after `user` adopts a user whose `full_name` carries the nonce, does nothing on 404, and reports `gitea-user-orphan` for a different `full_name`. A restart after `mint`, `minted` and `commit` of a first grant leaves no token at Gitea, no file and no record. A restart after `commit` of a rotation settles forward when the file holds the new token and back when it holds the previous one |
|
||||||
| X-G7 | an identity, publish, upstream or admin slot change between raise and Approve refuses `decision-mismatch` before any Gitea call |
|
| X-G7 | an identity, publish, upstream or admin slot change between raise and Approve refuses `decision-mismatch` before any Gitea call. An admin rotate voids open grant decisions and `status` lists them |
|
||||||
| X-G8 | every admin-token call writes a `grant.service.call` with its decision or revoke id, and no event body holds a token |
|
| X-G8 | every admin-token and seat-token call writes `grant.call` before it and `grant.result` after it, paired by `callId`. A crash between them is listed by reconcile. No record body holds a token |
|
||||||
| X-G9 | admin rotate: the successor is checked, written, and the old token deleted. Past rotate-by, the executor refuses |
|
| X-G9 | admin rotate: C1 to C4, the successor named by time, checked, written, the old token deleted. Two rotations in one second's name refuse cleanly, not half done. Past rotate-by, the raise and the executor refuse |
|
||||||
| X-G10 | rotation of a seat token: the new token is live before the old one is deleted |
|
| X-G10 | seat-token rotation: the new token is live before the old one is deleted |
|
||||||
|
| X-G11 | a step 0 refusal (rotation due, too old) leaves the decision unspent, and `grants run` by the human runs it. `grants run` from an agent, the chief or the tester refuses |
|
||||||
|
| X-G12 | step 5 refuses `gitea-reach-wider` when the user was made collaborator on another repository, and when it joined an org team, and deletes the token |
|
||||||
|
| X-G13 | `recordEvent` refuses an `action.allowed` with `body.action` `grant.call` (and each `grant.*`). A direct store insert of one with another actor aborts on the trigger |
|
||||||
|
| X-G14 | fire: every token the user has is deleted, the user gets `prohibit_login`, and a failure of either lands in `serviceLeft` |
|
||||||
|
|
||||||
Mutants, a first list: drop the version floor; compare the version as a string; accept a fourth admin scope;
|
Mutants, a first list: drop the version floor; compare the version as a string; accept another admin scope;
|
||||||
skip `gitea-user-foreign`; skip the admin-user refusal; mint before writing `grant.step mint`; delete the old
|
skip `gitea-user-foreign`; compare the user by name instead of `id`; skip the admin-user refusal; adopt a user
|
||||||
token before `issue`; drop the `identitySha256` compare; drop `adminSha256`; skip the `grant.service.call`
|
at restart without the nonce; create the user before writing `grant.step user`; mint before writing
|
||||||
write; send the admin token in a `token` header on the mint route (F1 says it 401s, so a test must catch the
|
`grant.step mint`; delete the old token before `issue`; settle a rotation by the record instead of
|
||||||
wrong header anywhere it matters); revoke the remote token before the local steps; skip the fork `parent`
|
`token-whoami`; run C1 to C4 after `#consumeGrant` instead of before; drop the `identitySha256` compare; drop
|
||||||
check.
|
`adminSha256`; skip the `grant.call` write; write `grant.result` before the call; drop the `grant.` reservation
|
||||||
|
in `recordEvent`; drop the trigger; send the admin token in a `token` header on the mint route; revoke the
|
||||||
|
remote token before the local steps; skip the fork `parent` check; skip step 5's reach check; delete only the
|
||||||
|
recorded token on fire; let `grants run` accept an agent.
|
||||||
|
|
||||||
## 10. Shared with Bushroot's GitHub half (proposed in megavolt-0036, answer pending)
|
## 10. Shared with Bushroot's GitHub half (proposed in megavolt-0036, answer pending)
|
||||||
|
|
||||||
- S1. The `grant` body: `{method, agent, rotateBy, target}`, scopes fixed by code per method.
|
- S1. The `grant` body: `{method, agent, rotateBy, target}`, scopes fixed by code per method.
|
||||||
- S2. The admin slot: `<configDir>/secrets/grants/<business>/<method>/admin`, set once by
|
- S2. The admin slot: `<configDir>/secrets/grants/<business>/<method>/admin`, set once by
|
||||||
`mosaic grants admin set <method>`, never an agent service.
|
`mosaic grants admin set <method>`, never an agent service. One service instance per method per business.
|
||||||
- S3. `grant.service.call {reason id, method, op, status}` for every service call.
|
- S3. `grant.call` and `grant.result` (r2: two records, paired by `callId`) for every service call, as reserved
|
||||||
- S4. The executor shape: claim, consume, mint, verify, `issue`, applied, with `grant.step` before and after
|
`action.allowed` bodies (section 7).
|
||||||
the mint. GitHub may not fit "mint once and store" (App installation tokens expire within an hour). If so,
|
- S4. The executor shape: pre-checks before consume (step 0), claim, consume, mint, verify, `issue`, applied,
|
||||||
S4 becomes per method and the rest stays shared.
|
with `grant.step` before and after the mint, and settle by asking the service which credential the file
|
||||||
|
holds. GitHub may not fit "mint once and store" (App installation tokens expire within an hour). If so, S4
|
||||||
|
becomes per method and the rest stays shared.
|
||||||
- S5. Revoke local first, then the record, then the service, with `serviceLeft` and `grants sweep`.
|
- S5. Revoke local first, then the record, then the service, with `serviceLeft` and `grants sweep`.
|
||||||
- S6. A capability floor per method, checked at `set` and every start.
|
- S6. A capability floor per method, checked at `set`, before every start and before every remote undo.
|
||||||
|
|
||||||
|
Dewey notes items 2, 3 and 5 of dewey-0754 apply to S3, S4 and S6. The r2 text above carries them.
|
||||||
|
|
||||||
## 11. Questions
|
## 11. Questions
|
||||||
|
|
||||||
- Q-B1. On fire, set `prohibit_login` on the agent's Gitea user, or leave the user as is? I'd set it, after a
|
- Q-B1. Answered (dewey-0754 item 9): on fire, `prohibit_login` and delete every token (section 6). The PATCH
|
||||||
scratch proof of the PATCH route with `write:admin`.
|
route is proven (F10).
|
||||||
- Q-B2. Does a grant ever create a Gitea organization or add the user to one? I'd say no. Forks live under the
|
- Q-B2. Answered (dewey-0754 item 10): no organizations. The token's real reach is checked at step 5 and
|
||||||
user, as 199 D9 builds them.
|
stated in section 8.
|
||||||
- Q-B3. Bushroot's answer on S1 to S6.
|
- Q-B3. Bushroot's answer on S1 to S6.
|
||||||
|
- Q-B4. For Sage or Mos: is jason.woltje a site admin on git.mosaicstack.dev? Section 8's comparison line needs
|
||||||
|
the answer.
|
||||||
|
- Q-B5. For Gizmoduck: one rule for `grant.*` records in #436a and #436b (section 7).
|
||||||
|
|||||||
Reference in New Issue
Block a user