Skip to content

feat(auth)!: migrate service accounts to successor agents (theme-8 phase 1) - #1386

Draft
Manuel-Jentic wants to merge 7 commits into
mainfrom
feat/theme-8-sa-migration
Draft

Manuel-Jentic wants to merge 7 commits into
mainfrom
feat/theme-8-sa-migration

Conversation

@Manuel-Jentic

Copy link
Copy Markdown
Collaborator

Theme-8 Phase 1: migrate every service account to a successor agent — copy-then-sweep, with the full SA writer surface stamp-guarded for the coexistence window.

Part of epic #1379. Plan: jentic-one-plans themes/theme-8-remove-service-accounts.md (rev 5, converged after 5 review passes) + themes/theme-8-IMPL.md — see plans#48 (approved).

What this ships

  • Admin migration (c0d1e2f3a4b5 on head b9d0e1f2a3b4): migrated_to_actor_id/migrated_at stamp pair on service_accounts, the service_account_migration_acks sentinel table, and a unique partial index on agent_credentials.api_key_hash (both postgresql_where and sqlite_where) as the cross-dialect race backstop.
  • Migration job (copy→revoke→stamp→audit in ONE admin transaction): raw-SQL successor creation (never picks up DEFAULT_AGENT_SCOPES), grant/binding rows copied — twins coexist so old-image pods keep serving through a rolling upgrade; live SA access/refresh tokens revoked transactionally (incl. skip-but-stamp dispositions); control-DB toolkit_keys re-stamp self-heals on re-run.
  • Deferred sweep, age-gated (service_account_sweep_min_stamp_age_hours, default 24): archives stamped SAs (including pre-archived ones' lingering rows) only after the fleet is past the window.
  • Agent-first resolver: sak_/jntc_live_ digests resolve to the successor agent; unmigrated SAs fall back exactly as before (WARNING + service_account_fallback_resolve counter — trending to zero is the sweep-readiness signal). Fails closed on both kill levers: an inactive successor stops the fallback, and a stamped SA is never a valid identity source.
  • Stamp guards (409 service_account_migrated) on all 7 SA mutation verbs + the raw-SQL key-retirement writers; kill-switch semantics preserved pre-migration.
  • JWT shrink (feat(broker)!): _ALLOWED_ACTOR_TYPES{AGENT}.
  • CLI: jentic_one migrate-service-accounts with --diff-only/--report/--sweep-migrated/--verify/--acknowledge; boot wiring on both lifespans; full runbook in docs/development/releasing.md.

Review

Adversarially reviewed against the converged plan invariants. NEEDS-FIXES findings all fixed and folded in: H1 (SA fallback defeated the dual-kill lever — resolver now fails closed when the successor digest exists or the SA is stamped), M1–M5 (restamp healing, pre-archived sweepability, jntc_live_ deprecation signal kept on the agent arm, stamp-guarded raw writers, skip-stamp token revoke), L1–L3 (real two-session race test on both dialects, per-row failure isolation, sweep re-check).

Suites green on both dialects (unit+arch, web, Postgres + SQLite integration). Two test_verify.py Postgres failures are pre-existing shared-fixture rot (proven with changes stashed).

Breaking changes

  • JWTs asserting actor_type=service_account are refused (broker).
  • Post-migration, SA mutation endpoints answer 409 service_account_migrated.

Manuel-Jentic and others added 7 commits September 16, 2026 13:26
…index

Theme-8 Phase 1 schema trio, one additive admin migration (c0d1e2f3a4b5):

- service_accounts.migrated_to_actor_id + migrated_at — the migration
  job's idempotency stamp (successor agnt_ id or the literal 'skipped'
  sentinel) and its timestamp (NF-3 verify criterion, N3 sweep age gate).
- service_account_migration_acks — the operator-acknowledgement sentinel
  the theme-8 Phase-4 drop migrations require (toolkit_flattening_acks
  precedent; admin DB so the drop reads it where it drops).
- uq_agent_credentials_api_key_hash — partial (NULL-exempt) unique index
  with BOTH dialect kwargs; the dialect-independent double-mint backstop
  for concurrent migration runs.

Round-trip (upgrade/downgrade) and native partial-index enforcement are
pinned against real SQLite; the Postgres twin runs in the integration
suite.

Co-authored-by: Cursor <cursoragent@cursor.com>
Theme-8 Phase 1 (H-2): the sak_ and jntc_live_ resolver arms now try
agent_credentials first — a migrated key's digest lives there, so the
unchanged plaintext resolves as its successor agent. Keys not yet
migrated fall back to the unchanged service-account arm and resolve
identically for the whole coexistence window (never a hard repoint:
old-image pods must keep serving migrated keys through a rolling
upgrade).

Each fallback hit logs a service_account_fallback_resolve WARNING and
bumps the new SERVICE_ACCOUNT_FALLBACK_RESOLVE telemetry event (L-C —
pod-local stdout is not alertable); trending to zero is the
sweep-readiness signal. The theme-5 deprecated_toolkit_key_used WARNING
stays on the jntc_live_ fallback path, reworded to point at the
successor agent.

IMPL-DECISION 4 deviation, documented: ApiKeyResolver construction runs
before the lifespan wires ctx.telemetry, so the injected sink is None in
practice — the resolver falls back to the process-global active sink at
record time; the constructor parameter stays for direct test injection.

Co-authored-by: Cursor <cursoragent@cursor.com>
Theme-8 Phase 1 (N-2, NF-1/NF-2): once a service account carries the
migration stamp, the live actor is its successor agent — a status flip,
scope replacement, key rotation, or archive against the stamped row
would silently no-op against the key (new pods resolve agent-first) or
diverge the copied state (the archive's revoke_all would delete the
grant rows the copy keeps; a rotation would break digest parity, the
NF-3 verify criterion).

Uniform service-layer predicate (migrated_to_actor_id IS NOT NULL) on
the 7 mutation verbs: approve, deny, disable, enable (via
_check_transition), archive, replace_scopes, and register_api_key. New
ServiceAccountMigratedError names the successor; mapped to 409
service_account_migrated (IMPL-DECISION 7: the resource still exists
and is readable during the window — 410 belongs to Phase 2's surface
removal). create stays unguarded (F5: new rows are unstamped; the boot
re-run migrates them). register_client_secret is unrouted (NF-4) — no
guard on dead code.

Co-authored-by: Cursor <cursoragent@cursor.com>
Theme-8 Phase 1 (F3, in Phase 1 deliberately): the self-contained-JWT
path builds Identity from claim-supplied scopes with no DB read, so a
service_account claim would sail past the SA→agent grant migration and
token revocation for the whole coexistence window. _ALLOWED_ACTOR_TYPES
shrinks to {AGENT}; refusals take the existing uniform-401 path with the
jwt_refused WARNING (reason jwt_actor_type_not_allowed). No rolling-skew
issue: old pods stay permissive (status quo), only new pods enforce.

BREAKING CHANGE: trusted-issuer JWTs claiming actor_type=service_account
are refused (401). Re-issue such tokens against the successor agent
(actor_type=agent) created by the theme-8 service-account migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
Theme-8 Phase 1 core (W2/W3/W4/W9). Per service account, one admin
transaction (copy → revoke → stamp → audit): a raw-SQL successor agent
(never AgentService.create/approve — F1: DEFAULT_AGENT_SCOPES must not
leak onto a zero-grant successor), COPIES of the stored scope grants
(theme-8-retired service-accounts:* scopes excluded), toolkit and
credential binding twins, the API-key digest copy (SA side stays live
until the sweep — H-B), family revocation of the SA's opaque sessions
(H-1), the idempotency stamp (migrated_to_actor_id + migrated_at;
'skipped' for pending/rejected/archived rows, disabled SAs get a
disabled successor — OQ-1), and per-SA audit rows under the system
actor. The control-DB toolkit_keys.migrated_actor_id re-stamp (M-E)
follows the admin commit.

Concurrency (H-A x F6): pg advisory-lock fast path, in-transaction
stamp re-check, and the uq_agent_credentials_api_key_hash partial
unique index as the dialect-independent backstop — a losing double-mint
fails its whole transaction and is reported, never a partial write.

The deferred sweep (N3) archives stamped rows, deletes the SA-keyed
grant/binding originals, and NULLs the SA-side digest — gated on
services.service_account_sweep_min_stamp_age_hours (default 24; 0
disables the gate, negative disables the automatic arm). verify runs
the acceptance queries; --verify --acknowledge writes the
service_account_migration_acks sentinel only when the verification
passed in the same invocation (Phase-4 gate).

Wiring: the migrate-service-accounts CLI (--diff-only, --report,
--sweep-migrated, --verify, --acknowledge) and a fire-and-forget boot
job on the control surface (stamp makes re-runs cheap no-ops and
catches SAs created during the window — F5), followed by the age-gated
automatic sweep.

IMPL-DECISION 5 deviation, documented: audit rows use
actor_type='system:job' (the toolkit_flattening precedent) — the
guide's bare 'system' literal is forbidden by the no-system-actor arch
test.

Co-authored-by: Cursor <cursoragent@cursor.com>
Theme-8 Phase 1 (W8): after the SA→agent migration, no code path may
create a service-account row — the boot-time retirement job would
otherwise be an SA producer the migration then has to chase. Successor
creation retargets to agents/agent_credentials (raw SQL, still exactly
capabilities:execute — the F1 constraint applies identically), grant
twins write actor_type='agent', and the outcome field is renamed
successor_actor_id (pre-theme-8 stamps may still carry sva_ ids until
the migration job re-stamps them).

set_service_account_status becomes set_actor_status, dual-table during
the window (IMPL-DECISION 8): agents first — after the M-E re-stamp,
toolkit_keys.migrated_actor_id holds agnt_ ids and the old UPDATE
would match zero rows, silently no-opping the operator's
compromise-response lever — with a service_accounts fallback for
unmigrated stragglers. find-by-name reuses a pre-theme-8 SA crash
remnant rather than double-minting its digest onto an agent.

Co-authored-by: Cursor <cursoragent@cursor.com>
Operator runbook for the service-account → agent migration: snapshot
first (post-sweep reversal needs it — M-D), diff-only preview, the
dual-kill note (disable the successor agent to cut a key in the window
— NF-1), the fallback-counter sweep-readiness signal, sweep age gating
(N3), stated window semantics (token death at migration, pre-sweep
client-credentials trade-off), the successor-key rotation caveat (F2),
and the verify/acknowledge sentinel gating the Phase-4 drops. Opens the
service-accounts deprecation window in the Deprecations table.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant