Scoped API keys and the join permission (D342, D343) #340

Open
opened 2026-11-04 09:53:26 +00:00 by kayg · 2 comments
Owner

Design note for scoped API keys with a join permission, replacing the "setup keys" idea (D342, D343; also D333, D336, D337, D339, D235, D318, infraternal up from #263, keys from #303).

Job J182 (design only, no code). Branch design-scoped-keys-j182 from origin/dev 5d33f7cdd5ab0ae7527befde92835f4b342556b5. Output: docs/design/scoped-keys.md.

Plan (about 2 h): read the decisions and the key code on apikey-actions-303 and the up join on dev; write the note (data model, up --setup-key, server checks and rate limits incl. the #263 pools, audit, Settings › Access › Keys in words, CLI and MCP, migrations, existing keys, threat model, slices with estimates); commit and push; post every open question here with options and a recommendation.

Design note for scoped API keys with a join permission, replacing the "setup keys" idea (D342, D343; also D333, D336, D337, D339, D235, D318, `infraternal up` from #263, keys from #303). Job J182 (design only, no code). Branch `design-scoped-keys-j182` from origin/dev `5d33f7cdd5ab0ae7527befde92835f4b342556b5`. Output: `docs/design/scoped-keys.md`. Plan (about 2 h): read the decisions and the key code on `apikey-actions-303` and the `up` join on dev; write the note (data model, `up --setup-key`, server checks and rate limits incl. the #263 pools, audit, Settings › Access › Keys in words, CLI and MCP, migrations, existing keys, threat model, slices with estimates); commit and push; post every open question here with options and a recommendation.
Author
Owner

J182 design note pushed: docs/design/scoped-keys.md on design-scoped-keys-j182, head 7c6e4c46922614c0f1e7f64ac8c8bad519f48886 (base origin/dev 5d33f7cdd). Docs only: no code changed, so the gate does not apply.

What the decisions already settle, so it is not asked below: one api_keys table with scopes plus a join-settings row (D342); creation spends a passkey tap bound to a digest of the exact settings (D336, D342); up --setup-key=env:|file:|- only (D343); a key join reuses today's receipt and root helper unchanged; a key-joined machine has machine identity only and no person link, because a link needs the person's passkey (D235); key joins create no approval rows or ceremonies, so they cannot fill the #263 pools, and the other way round; unknown keys are refused with a read, before any writer transaction; revoking stops joins and keeps machines, and removing them is a separate action (D342).

Scope and model

  • Q1 Scope name. (a) hosts:join next to hosts:read/write; (b) machines:join; (c) join. R: (a). The UI says "Join machines" anyway.
  • Q2 May a join key also hold other scopes? Join keys end up in CI secret stores and cloud-init files, where leaks happen. (a) any mix; (b) join plus read scopes only, no write; (c) join alone. R: (b): still one key type, and it can be loosened later.
  • Q3 "User to assign". remote_hosts.owner_id is NOT NULL; D237 makes a machine shared by tagging it. (a) creator only in community, any person in EE; (b) also "Shared (nobody)", which needs ≥1 tag; (c) creator only. R: (a)+(b), with a shared flag so owner_id still records who added it.
  • Q4 Use limit. (a) one-time or unlimited only; (b) also "at most N machines". R: (b): it bounds a leaked reusable key.
  • Q5 Install modes. up joins as a root service today. D339 holds back root installs requested through MCP, but with up the person runs sudo themselves. (a) any mode; (b) a per-key choice of system/user/both; (c) user only unless the key says otherwise. R: (b), both on by default.
  • Q6 A tag deleted before a join. (a) refuse the join and name the tag; (b) join without it and audit; (c) re-create the tag. R: (a): CI fails loudly instead of producing machines without the access they need.
  • Q7 Ephemeral default offline time. (a) 10 min; (b) 1 h; (c) 24 h; (d) the creator must choose. R: 1 h, with choices of 10 min, 1 h, 24 h and 7 d. After a server restart every machine gets a full window again, so a downed server doesn't remove the fleet.

Creation and expiry

  • Q8 Does a key's secret ever reach an AI agent? (a) returned in the MCP result, which puts it in the AI provider's transcript; (b) shown only on the approval page after the tap; (c) the local CLI writes it to a 0600 file and returns only the path. R: (b) for MCP over HTTP, (c) for the local CLI.
  • Q9 "No expiry" and the 30-day inactivity end. A fleet-image key can sit unused longer than 30 days and would die without warning. (a) Never = no hard end and no inactivity end; (b) the inactivity end still applies; (c) a separate inactivity setting. R: (a), plus an Inbox reminder after 90 days unused. The inactivity policy stays for keys that have an end, but no longer caps the lifetime you can choose.
  • Q10 Secret format. (a) keep 32 random bytes in base64url; (b) a prefix + checksum (e.g. ifk_…) so secret scanners and our CLI recognise it, and a mistyped key is caught offline. R: (b) for new keys.

up --setup-key

  • Q11 A group- or world-readable key file (secret mounts are often 0644). (a) refuse; (b) warn; (c) accept silently. R: warn if readable, refuse if world-writable.
  • Q12 When a one-time key is spent. If the network drops between the receipt being issued and enrolment, the key is gone. (a) spend it at issue, and a retry with the same host and machine key gets the same receipt; (b) also give the use back if the 10-min receipt expires unredeemed; (c) spend it only when enrolment completes. R: (a)+(b). Only the exact machine key can redeem the receipt. (c) lets two machines race through one key.
  • Q13 Takeover of a machine that belongs to another server (D337 allows it only when the old install is broken). (a) a key join always refuses; (b) it follows D337 automatically; (c) it follows D337 only with --take-over. R: (c): the common case is CI images with stale installs.

Server limits and audit

  • Q14 Rate limits. Per key: (a) burst 10, then 1 every 6 s; (b) a per-key setting; (c) none. Per network: the approval caps (5 per address or /64, 32 per /24 or /48, per minute), or more. R: (a), and per network 4× the approval caps (20 / 128 per minute): an autoscaler behind one NAT starts tens of runners at once.
  • Q15 Recording where a join came from. The approval code never writes client addresses to the DB, but "a key used from many networks" can only be seen if the network is stored. D235's connection log already keeps "from where". (a) nothing; (b) the /24 or /48 only; (c) the full address. R: (b), kept 30 days like the connection log.
  • Q16 Telling you about joins. (a) list and audit only; (b) one Inbox item per key, batched every 10 min; (c) only for the first join from a new network. R: (b), with (c) highlighted: quiet for a fleet, loud for a leak.

Settings › Access › Keys

  • Q17 Filter chips vs TASTE "group automatically" and "one input, not modes". (a) filter chips: All / Join / Sign-in / No expiry / Ended; (b) fixed groups (Join keys, Sign-in keys, Ended collapsed) plus one filter field that turns words like "join", "never" or a tag into chips; (c) both. R: (b).
  • Q18 Tap rule for "remove machines joined with this key". D339 puts single-host removal under the 15-min admin window. (a) the window is enough, with the dry-run list in the confirmation; (b) a fresh tap bound to the host list above N machines; (c) always a fresh tap. R: (a).
  • Q19 Editing a key. (a) keys never change, so you create a new one and revoke the old; (b) narrowing without a tap, widening with a bound tap (D333). R: (a) for now.

CLI and MCP

  • Q20 Key-management scopes. (a) new keys:read and keys:write, where write covers revoke only, since creation always needs a tap; (b) keys:read only, revoke only in the browser; (c) fold them into existing scopes. R: (a): an AI agent that spots a leak can revoke it at once.

Threat model

  • Q21 Key-joined machines and the allow-all default network policy (D228). Login needs a person (D235), but under allow-all a machine joined with a leaked key reaches every mesh machine's open ports, whatever its tags. (a) same default as any machine; (b) only what its tags' grants give; (c) a per-key "Can reach: everything / only what its tags allow", defaulting to tags. R: (c).
  • Q22 Limiting a key to certain networks. (a) no limit; (b) an optional "Only from" list of address ranges per key; (c) a lock to the first network that used it. R: (a) now, (b) as a later slice if wanted.

Slices (after #303 merges; about 12 job-days plus reviews and design rounds)

1 schema and scopes, 1 d · 2 creation with a bound tap, 1.5 d · 3 /auth/machine-join with checks, limits and audit, 2 d · 4 up --setup-key, 1 d · 5 ephemeral removal, 1 d · 6 revoke and bulk removal, 1 d · 7 Keys UI (design grill first), 3 d + rounds · 8 CLI keys and MCP, 1 d · 9 Inbox items, 0.5 d. Slices 4–6 can run in parallel after 3. The UI grill can start once Q17 and Q18 are answered.

Finding while checking migration numbers: trust-285-a uses 0041 and 0042, and mesh-ssh-s1-j175 also uses 0041, so those two branches collide on merge. #303 uses 0043–0048.

**J182 design note pushed:** [`docs/design/scoped-keys.md`](https://git.kayg.org/kayg/infraternal/src/branch/design-scoped-keys-j182/docs/design/scoped-keys.md) on `design-scoped-keys-j182`, head `7c6e4c46922614c0f1e7f64ac8c8bad519f48886` (base origin/dev `5d33f7cdd`). Docs only: no code changed, so the gate does not apply. What the decisions already settle, so it is not asked below: one `api_keys` table with scopes plus a join-settings row (D342); creation spends a passkey tap bound to a digest of the exact settings (D336, D342); `up --setup-key=env:|file:|-` only (D343); a key join reuses today's receipt and root helper unchanged; a key-joined machine has machine identity only and no person link, because a link needs the person's passkey (D235); key joins create no approval rows or ceremonies, so they cannot fill the #263 pools, and the other way round; unknown keys are refused with a read, before any writer transaction; revoking stops joins and keeps machines, and removing them is a separate action (D342). ## Open questions for kayg (each has a recommendation; **R** = recommended) **Scope and model** - **Q1 Scope name.** (a) `hosts:join` next to `hosts:read/write`; (b) `machines:join`; (c) `join`. **R: (a).** The UI says "Join machines" anyway. - **Q2 May a join key also hold other scopes?** Join keys end up in CI secret stores and cloud-init files, where leaks happen. (a) any mix; (b) join plus read scopes only, no write; (c) join alone. **R: (b)**: still one key type, and it can be loosened later. - **Q3 "User to assign".** `remote_hosts.owner_id` is NOT NULL; D237 makes a machine *shared* by tagging it. (a) creator only in community, any person in EE; (b) also "Shared (nobody)", which needs ≥1 tag; (c) creator only. **R: (a)+(b)**, with a `shared` flag so `owner_id` still records who added it. - **Q4 Use limit.** (a) one-time or unlimited only; (b) also "at most N machines". **R: (b)**: it bounds a leaked reusable key. - **Q5 Install modes.** `up` joins as a root service today. D339 holds back root installs requested through MCP, but with `up` the person runs sudo themselves. (a) any mode; (b) a per-key choice of system/user/both; (c) user only unless the key says otherwise. **R: (b), both on by default.** - **Q6 A tag deleted before a join.** (a) refuse the join and name the tag; (b) join without it and audit; (c) re-create the tag. **R: (a)**: CI fails loudly instead of producing machines without the access they need. - **Q7 Ephemeral default offline time.** (a) 10 min; (b) 1 h; (c) 24 h; (d) the creator must choose. **R: 1 h, with choices of 10 min, 1 h, 24 h and 7 d.** After a server restart every machine gets a full window again, so a downed server doesn't remove the fleet. **Creation and expiry** - **Q8 Does a key's secret ever reach an AI agent?** (a) returned in the MCP result, which puts it in the AI provider's transcript; (b) shown only on the approval page after the tap; (c) the local CLI writes it to a 0600 file and returns only the path. **R: (b) for MCP over HTTP, (c) for the local CLI.** - **Q9 "No expiry" and the 30-day inactivity end.** A fleet-image key can sit unused longer than 30 days and would die without warning. (a) Never = no hard end and no inactivity end; (b) the inactivity end still applies; (c) a separate inactivity setting. **R: (a), plus an Inbox reminder after 90 days unused.** The inactivity policy stays for keys that have an end, but no longer caps the lifetime you can choose. - **Q10 Secret format.** (a) keep 32 random bytes in base64url; (b) a prefix + checksum (e.g. `ifk_…`) so secret scanners and our CLI recognise it, and a mistyped key is caught offline. **R: (b) for new keys.** **`up --setup-key`** - **Q11 A group- or world-readable key file** (secret mounts are often 0644). (a) refuse; (b) warn; (c) accept silently. **R: warn if readable, refuse if world-writable.** - **Q12 When a one-time key is spent.** If the network drops between the receipt being issued and enrolment, the key is gone. (a) spend it at issue, and a retry with the same host and machine key gets the same receipt; (b) also give the use back if the 10-min receipt expires unredeemed; (c) spend it only when enrolment completes. **R: (a)+(b).** Only the exact machine key can redeem the receipt. (c) lets two machines race through one key. - **Q13 Takeover of a machine that belongs to another server** (D337 allows it only when the old install is broken). (a) a key join always refuses; (b) it follows D337 automatically; (c) it follows D337 only with `--take-over`. **R: (c)**: the common case is CI images with stale installs. **Server limits and audit** - **Q14 Rate limits.** Per key: (a) burst 10, then 1 every 6 s; (b) a per-key setting; (c) none. Per network: the approval caps (5 per address or /64, 32 per /24 or /48, per minute), or more. **R: (a), and per network 4× the approval caps (20 / 128 per minute)**: an autoscaler behind one NAT starts tens of runners at once. - **Q15 Recording where a join came from.** The approval code never writes client addresses to the DB, but "a key used from many networks" can only be seen if the network is stored. D235's connection log already keeps "from where". (a) nothing; (b) the /24 or /48 only; (c) the full address. **R: (b), kept 30 days like the connection log.** - **Q16 Telling you about joins.** (a) list and audit only; (b) one Inbox item per key, batched every 10 min; (c) only for the first join from a new network. **R: (b), with (c) highlighted**: quiet for a fleet, loud for a leak. **Settings › Access › Keys** - **Q17 Filter chips vs TASTE "group automatically" and "one input, not modes".** (a) filter chips: All / Join / Sign-in / No expiry / Ended; (b) fixed groups (Join keys, Sign-in keys, Ended collapsed) plus one filter field that turns words like "join", "never" or a tag into chips; (c) both. **R: (b).** - **Q18 Tap rule for "remove machines joined with this key".** D339 puts single-host removal under the 15-min admin window. (a) the window is enough, with the dry-run list in the confirmation; (b) a fresh tap bound to the host list above N machines; (c) always a fresh tap. **R: (a).** - **Q19 Editing a key.** (a) keys never change, so you create a new one and revoke the old; (b) narrowing without a tap, widening with a bound tap (D333). **R: (a) for now.** **CLI and MCP** - **Q20 Key-management scopes.** (a) new `keys:read` and `keys:write`, where write covers revoke only, since creation always needs a tap; (b) `keys:read` only, revoke only in the browser; (c) fold them into existing scopes. **R: (a)**: an AI agent that spots a leak can revoke it at once. **Threat model** - **Q21 Key-joined machines and the allow-all default network policy (D228).** Login needs a person (D235), but under allow-all a machine joined with a leaked key reaches every mesh machine's open ports, whatever its tags. (a) same default as any machine; (b) only what its tags' grants give; (c) a per-key "Can reach: everything / only what its tags allow", defaulting to tags. **R: (c).** - **Q22 Limiting a key to certain networks.** (a) no limit; (b) an optional "Only from" list of address ranges per key; (c) a lock to the first network that used it. **R: (a) now, (b) as a later slice if wanted.** ## Slices (after #303 merges; about 12 job-days plus reviews and design rounds) 1 schema and scopes, 1 d · 2 creation with a bound tap, 1.5 d · 3 `/auth/machine-join` with checks, limits and audit, 2 d · 4 `up --setup-key`, 1 d · 5 ephemeral removal, 1 d · 6 revoke and bulk removal, 1 d · 7 Keys UI (design grill first), 3 d + rounds · 8 CLI `keys` and MCP, 1 d · 9 Inbox items, 0.5 d. Slices 4–6 can run in parallel after 3. The UI grill can start once Q17 and Q18 are answered. Finding while checking migration numbers: `trust-285-a` uses 0041 and 0042, and `mesh-ssh-s1-j175` also uses 0041, so those two branches collide on merge. #303 uses 0043–0048.
Author
Owner

Owner answers, 2026-11-08 (inline grill with the orchestrator). All 22 questions are answered and recorded as D370 and D371 (merge dba2f6424 on dev).

  • As recommended: Q1 hosts:join, Q2 join + read only, Q3, Q4, Q5, Q6, Q7 (1 h), Q8, Q10, Q11, Q12, Q14, Q16, Q17, Q18, Q19, Q21 and Q22.
  • Changed from the recommendation:
    • Q9: a separate inactivity setting for keys that never expire.
    • Q13: a key join takes over another server's broken install automatically, following D337.
  • Refined:
    • Q15: networks are stored at /24 or /48 for 30 days, and the new-network Inbox item names the network.
    • Q20: becomes D371. Every area gets read and write scopes, plus admin for all of them. Over MCP, any action that needs a tap returns "approval needed" with a link the AI agent sends to the owner.

Slices can be planned against D370 and D371 once #303 is merged.

**Owner answers, 2026-11-08 (inline grill with the orchestrator).** All 22 questions are answered and recorded as **D370** and **D371** (merge `dba2f6424` on dev). - **As recommended:** Q1 `hosts:join`, Q2 join + read only, Q3, Q4, Q5, Q6, Q7 (1 h), Q8, Q10, Q11, Q12, Q14, Q16, Q17, Q18, Q19, Q21 and Q22. - **Changed from the recommendation:** - Q9: a separate inactivity setting for keys that never expire. - Q13: a key join takes over another server's broken install automatically, following D337. - **Refined:** - Q15: networks are stored at /24 or /48 for 30 days, and the new-network Inbox item names the network. - Q20: becomes D371. Every area gets read and write scopes, plus `admin` for all of them. Over MCP, any action that needs a tap returns "approval needed" with a link the AI agent sends to the owner. Slices can be planned against D370 and D371 once #303 is merged.
Sign in to join this conversation.
No labels
launch-qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/infraternal#340
No description provided.