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:
2026-10-20 17:49:30 -05:00
parent 714c073043
commit 6db7cc45d5
+280 -154
View File
@@ -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).