model-access B: the refreshable-grant machinery (ADR 0050 carve-out) #14

Merged
jschoubben merged 1 commits from feat/model-access-refreshable into main 2026-09-06 22:25:15 +00:00
Owner

Phase B — the generic, vendor-neutral refreshable-grant machinery: the ADR-0050 carve-out, built so the control plane never sees a refresh token in the clear.

  • Manager model: a licence.manager column (node name), set via licence manager <name> <node>; refused for static-key licences (the "manager node only" axis).
  • Encrypted-at-rest (internal/secrets/atrest.go): envelope encryption — the refresh token is NaCl-secretbox'd under a random 32-byte data key; only the data key is wrapped (anonymous box) to the manager node's existing sealing key. Stored in a separate refresh_grant table. A DB dump alone can't read it (needs the manager's private half, which the control plane never held). No new key material.
  • Refresh flow (Licences.Refresh): a transaction-scoped advisory lock (the single-refresher lease), open the at-rest grant → call the injected VendorRefresher (the Phase-C seam; the module opens the envelope on the manager node and does the OAuth call — the control plane never decrypts) → seal the returned access token per holder → replace the envelope only if the vendor rotated the refresh token. Reuses doc-13's reseal-and-publish shape; delivery is the existing push path.
  • Access-token-only delivery: KeyFor returns the sealed access token; the refresh token is never in a holder's delivery. Static-key keeps the full guarantee (no token to hold, carve-out never fires).

17 new tests (crypto/adapter/refresh-flow) + the 4 required security checks; go build/go vet/make check (real Postgres, migrations 0001+0003) all green.

https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF

Phase B — the generic, vendor-neutral refreshable-grant machinery: the ADR-0050 carve-out, built so the control plane never sees a refresh token in the clear. - **Manager model**: a `licence.manager` column (node name), set via `licence manager <name> <node>`; refused for static-key licences (the "manager node only" axis). - **Encrypted-at-rest** (`internal/secrets/atrest.go`): envelope encryption — the refresh token is NaCl-secretbox'd under a random 32-byte data key; only the data key is wrapped (anonymous box) to the **manager node's existing sealing key**. Stored in a separate `refresh_grant` table. A DB dump alone can't read it (needs the manager's private half, which the control plane never held). No new key material. - **Refresh flow** (`Licences.Refresh`): a transaction-scoped advisory lock (the single-refresher lease), open the at-rest grant → call the injected `VendorRefresher` (the **Phase-C seam**; the module opens the envelope on the manager node and does the OAuth call — the control plane never decrypts) → seal the returned access token **per holder** → replace the envelope only if the vendor rotated the refresh token. Reuses doc-13's reseal-and-publish shape; delivery is the existing push path. - **Access-token-only delivery**: `KeyFor` returns the sealed access token; the refresh token is never in a holder's delivery. Static-key keeps the full guarantee (no token to hold, carve-out never fires). 17 new tests (crypto/adapter/refresh-flow) + the 4 required security checks; `go build`/`go vet`/`make check` (real Postgres, migrations 0001+0003) all green. https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
jschoubben added 1 commit 2026-09-06 22:25:09 +00:00
The ADR 0050 carve-out, built generic and vendor-neutral. A refreshable-grant
licence records one manager node; that node holds the refresh token encrypted at
rest, access tokens are still sealed per holder, and the refresh token is never in
a holder's delivery. Bounded on the three stated axes: refreshable-grant vendors
only, the refresh token only, the manager node only. Anthropic's actual OAuth
refresh stays a Phase-C plug-in behind a clean seam.

- New at-rest crypto (secrets.SealAtRest/OpenAtRest): envelope encryption distinct
  from the per-holder anonymous-box seal. The refresh token is under a symmetric
  data key (secretbox); the data key is wrapped to the manager node's public
  sealing key. The database alone holds ciphertext and a wrapped key with no
  private half to open either — only the manager node reads it back.

- Refreshable-grant adapter dispatch: anthropic is now refreshable-grant,
  anthropic-api-key the static-key second case. The adapter implements the
  Refresher seam by delegating to an injected VendorRefresher (the Phase-C plug,
  none shipped). static-key is untouched. The type assertion to Refresher is what
  gates the carve-out to refreshable-grant vendors.

- Refresh lease/rotate/publish flow (Licences.Refresh): a transaction-scoped
  advisory lock is the single-refresher lease; the new access token comes from the
  vendor refresh, is sealed per holder (secrets.Seal, as Accept does) and delivered
  on the next push — doc 13's reseal-and-publish half, all-or-nothing. The refresh
  token stays put, re-encrypted at rest only if the vendor rotated it.

- Manager and refresh_grant schema: consolidated into migrations/0001 and carried
  by a new incremental 0003 (the dual-write rule).

- 17 new tests, including the four security checks: KeyFor never carries the
  refresh token, a static key has no manager and cannot be refreshed, the at-rest
  token needs the manager's key, and a refresh delivers a new sealed access token.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
jschoubben merged commit 59faa150ac into main 2026-09-06 22:25:15 +00:00
jschoubben deleted branch feat/model-access-refreshable 2026-09-06 22:25:15 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: novox/mesh-controller#14