feat(admin): AS-1 — Personas tab for L2 admin shell - #229
Conversation
Code review — AS-1 Personas TabRoutine review pass on a privilege-mutation surface. Findings grouped HIGH / MEDIUM / LOW with confidence-based filtering. 3 HIGHs block merge. The three HIGHs all match concerns the dispatch brief explicitly asked be verified — they appear to have been missed in the spawn loop. HIGH — Block mergeH-1:
|
| Check | Present? |
|---|---|
| Disable-then-re-enable round trip | Not evidenced |
| PATCH with invalid persona enum | Pydantic Literal rejects at 422; not explicitly tested |
| Concurrent persona-assignment race | UPSERT in SQLite WAL is last-write-wins; not tested, document as known |
| Disable last admin | Missing — see H-3 |
Summary
| # | Finding | Sev | Conf |
|---|---|---|---|
| H-1 | Soft-disable doesn't block login | HIGH | 92 |
| H-2 | No append-only audit trail | HIGH | 88 |
| H-3 | No last-admin guard | HIGH | 85 |
| M-1 | Test assertion targets wrong revision | MED | 97 |
| M-2 | PATCH silently re-enables disabled users | MED | 90 |
| M-3 | Cross-PR migration chain order unguarded | MED | 82 |
| M-4 | CORS missing PATCH | MED | 88 |
| M-5 | No per-admin invite rate limit | MED | 83 |
| L-1 | get_event_loop() deprecation (pre-existing) |
LOW | 80 |
| L-2 | Phase 5 placeholder token is not a JWT (cross-PR) | LOW | 80 |
H-1 is the most urgent — a "disable" that doesn't disable is the security cliff. H-2 and H-3 are privilege-grant correctness issues the dispatch explicitly asked to verify. M-1 will fail CI.
Per #229 review: the disable endpoint set disabled_at in persona_assignments but no login path consulted it, so a disabled user could still mint a session. Add the check to all three session mint sites: * POST /auth/login (auth.py) — password path * POST /auth/passkey/login/finish (passkey_routes.py) — passkey path * POST /invites/claim (invite_routes.py) — magic-link claim path All three now call store.get_persona_assignment(username) and refuse with HTTP 403 when disabled_at is set. Review: #229 (comment) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per #229 review: H-2: append-only persona_assignment_audit table. New migration 0023 chains from 0022. CREATED / CHANGED / DISABLED rows written in the same transaction as the upsert/disable so the audit and state never desync. HEAD_REVISION bumped to 0023_persona_assignment_audit; test_head_revision_is_current also updated (was stale at 0021 before this change). H-3: last-admin guard. POST .../disable refuses with HTTP 409 + code=LAST_ADMIN when the target is persona='admin' and active admin count <= 1. New store method count_active_admins() does the count. M-2: PATCH no longer silently re-enables a disabled assignment. Returns 409 + code=USER_DISABLED pointing the admin at the yet-to-land POST .../enable endpoint. M-5: per-admin invite rate limit. POST /admin/personas rejects with HTTP 429 + code=RATE_LIMIT when the calling admin has issued >= 20 assignments in the trailing hour. New store method count_invites_by_admin(). Tests added: * test_persona_changes_create_audit_rows * test_disabling_last_admin_returns_409_LAST_ADMIN * test_patch_disabled_user_returns_409_USER_DISABLED * test_admin_cannot_send_more_than_20_invites_per_hour Review: #229 (comment) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…test
M-4: add PATCH to CORSMiddleware allow_methods so the L2 admin UI
can call PATCH /admin/personas/{username} from signup.8th-layer.ai.
Also add test_disabled_user_cannot_obtain_session_cookie pinning
the H-1 behaviour (login refused, no Set-Cookie when persona is
disabled).
Review: #229 (comment)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Review findings addressed — 3 commits pushed. Cites the original review. Fixes shippedHIGH
MEDIUM
Test deltaPre-fix: 13 tests in
Commits
Generated with Claude Code. |
The fix-agent for the HIGH findings pulled persona_routes.py + its app.py wiring into this branch — that surface belongs to AS-1 (#229), not FO-2-backend. Removing here so the two PRs stay cleanly separated and #229 can land its own (audit-tabled, last-admin-guarded) version without a merge conflict. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per #229 review: the disable endpoint set disabled_at in persona_assignments but no login path consulted it, so a disabled user could still mint a session. Add the check to all three session mint sites: * POST /auth/login (auth.py) — password path * POST /auth/passkey/login/finish (passkey_routes.py) — passkey path * POST /invites/claim (invite_routes.py) — magic-link claim path All three now call store.get_persona_assignment(username) and refuse with HTTP 403 when disabled_at is set. Review: #229 (comment) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per #229 review: H-2: append-only persona_assignment_audit table. New migration 0023 chains from 0022. CREATED / CHANGED / DISABLED rows written in the same transaction as the upsert/disable so the audit and state never desync. HEAD_REVISION bumped to 0023_persona_assignment_audit; test_head_revision_is_current also updated (was stale at 0021 before this change). H-3: last-admin guard. POST .../disable refuses with HTTP 409 + code=LAST_ADMIN when the target is persona='admin' and active admin count <= 1. New store method count_active_admins() does the count. M-2: PATCH no longer silently re-enables a disabled assignment. Returns 409 + code=USER_DISABLED pointing the admin at the yet-to-land POST .../enable endpoint. M-5: per-admin invite rate limit. POST /admin/personas rejects with HTTP 429 + code=RATE_LIMIT when the calling admin has issued >= 20 assignments in the trailing hour. New store method count_invites_by_admin(). Tests added: * test_persona_changes_create_audit_rows * test_disabling_last_admin_returns_409_LAST_ADMIN * test_patch_disabled_user_returns_409_USER_DISABLED * test_admin_cannot_send_more_than_20_invites_per_hour Review: #229 (comment) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…test
M-4: add PATCH to CORSMiddleware allow_methods so the L2 admin UI
can call PATCH /admin/personas/{username} from signup.8th-layer.ai.
Also add test_disabled_user_cannot_obtain_session_cookie pinning
the H-1 behaviour (login refused, no Set-Cookie when persona is
disabled).
Review: #229 (comment)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fb5f84f to
eb2fa28
Compare
) * feat(provisioning): FO-2-backend — Enterprise Provisioning Service (closes #224) 6-phase async provisioning state machine per Decision 31 (OneZero1ai/8th-layer-core#61): Phase 1 KEY_MINT — Ed25519 key pair; private key in KMS (SSM SecureString fallback if KMS param absent) Phase 2 DIRECTORY_REGISTER — register enterprise in cq-directory Phase 3 DNS_PROVISION — Cloudflare CNAME + ACM cert (fire-and-continue) Phase 4 L2_STANDUP — AssumeRole → CFN create-stack → poll COMPLETE Phase 5 ADMIN_INVITE_SENT — magic-link invite via SES (reuses email_sender.py) Phase 6 COMPLETED — persist result JSON New module: server/backend/src/cq_server/provisioning/ - __init__.py — package + router export - ids.py — prov_<ULID> job ID generator - models.py — Pydantic request/response shapes (Decision 31 contract) - db.py — SQLAlchemy Core helpers for provisioning_jobs table - routes.py — FastAPI router (POST /enterprises, GET /enterprises/jobs/{id}) - worker.py — asyncio background task state machine New migration: 0021_provisioning_jobs.py - provisioning_jobs(job_id PK [ULID], enterprise_id, status, phase, started_at, completed_at, error TEXT, result_json TEXT, ip_hash TEXT) - Chains from 0020_l2_brand; AS-1 takes 0022 CORS: CORSMiddleware added to app with https://signup.8th-layer.ai allowed Rate limit: 10 req/hr per IP (DB-backed, no external dependency) HEAD_REVISION bumped to 0021_provisioning_jobs; 3 test assertions updated New deps: none (uses existing boto3, cryptography, fastapi) 45 new tests in test_provisioning.py (all pass). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(provisioning): address 8l-reviewer HIGH findings (#228) Fixes all 8 HIGH security/correctness issues from the security review: HIGH #1 — ExternalId required in AssumeRole (confused-deputy prevention). New `assume_role_external_id` field on CreateEnterpriseRequest (min 8 chars). Forwarded to STS AssumeRole in both _validate_assume_role and phase 4. Stored in provisioning_jobs for crash-recovery re-runs. HIGH #2 — Phase 2 was a no-op; now POSTs to the 8th-layer directory announce endpoint via urllib, raises RuntimeError on failure, and returns the real directory record URL used in the completion payload. HIGH #3 — SLUG_TAKEN 409 body is now generic: "This slug is not available." No slug echoed in error body. HIGH #4 — UNIQUE constraint added to enterprise_id in the migration. IntegrityError from concurrent inserts translates to 409 SLUG_TAKEN, closing the TOCTOU window between the idempotency check and insert. HIGH #5 — X-Forwarded-For only trusted when request.client.host is in PROVISIONING_TRUSTED_PROXY_IPS (env, comma-separated). Falls back to transport IP when no trusted proxies are configured. HIGH #6 — Crash recovery on startup via recover_stuck_jobs() called in the lifespan. Detects non-terminal jobs older than PROVISIONING_RECOVERY_THRESHOLD_SEC and re-queues them as asyncio tasks using stored job_params_json. Pre-HIGH#6 rows are marked FAILED. HIGH #7 — Idempotency check before creating new job: if a non-FAILED job already exists for the slug, return it (200) without calling AssumeRole or creating new AWS resources. UNIQUE constraint is the backstop for concurrent races (→ 409). HIGH #8 — Phase 3 errors now propagate. Missing CF_API_TOKEN and Cloudflare HTTP errors raise RuntimeError so the state machine transitions to FAILED. ACM cert failure remains log-and-continue (async, not fatal to the flow). Also: added persona_routes.py from AS-1 branch (was missing on this branch, breaking all route tests). Schema updated with assume_role_external_id and job_params_json columns. 19 new targeted tests added for the HIGH findings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * revert(provisioning): drop persona_routes leak (belongs on #229) The fix-agent for the HIGH findings pulled persona_routes.py + its app.py wiring into this branch — that surface belongs to AS-1 (#229), not FO-2-backend. Removing here so the two PRs stay cleanly separated and #229 can land its own (audit-tabled, last-admin-guarded) version without a merge conflict. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(provisioning): address 8l-reviewer re-review HIGHs Three HIGHs from the 2026-05-12 post-fix re-review: HIGH #2 (still broken) — Phase 2 was POSTing an unsigned payload to the directory's /announce endpoint, which validates a sign_envelope-wrapped shape (see directory_client._post_announce:174). The directory was silently rejecting registration; jobs proceeded to COMPLETED with a fabricated record URL. Fix: thread the fresh Ed25519 private key from phase 1 through to phase 2, reconstruct the key via Ed25519PrivateKey.from_private_bytes, call sign_envelope, POST the envelope (not the bare payload). Lifetime of the priv_b64 in worker memory is bounded to phases 1+2; phase 3 onward only carries the public key. HIGH #4 (still broken) — 0021 declared enterprise_id UNIQUE on the column AND added a separate unique index, both non-partial. A FAILED job permanently locked the slug; customer couldn't retry without DB surgery. Fix: new migration 0021a_provisioning_partial_unique replaces the full UNIQUE with a partial unique index: CREATE UNIQUE INDEX idx_provisioning_jobs_active_slug ON provisioning_jobs(enterprise_id) WHERE status NOT IN ('FAILED', 'COMPLETED') HEAD_REVISION bumped; three test assertions updated. NEW HIGH (regression introduced by HIGH #6 recovery fix) — recovery re-runs jobs from phase 1, but phase 3's bare POST to Cloudflare returns 400 on duplicate CNAME. The recovery docstring claimed CF was idempotent; it isn't. So a recovered job would leave a stranded customer-side CFN stack with no DNS reconciliation. Fix: _cf_upsert_cname helper does GET first to check for existing records, then PATCH on drift, POST on absence. Now actually idempotent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(provisioning): bump external_id min_length 8→22 for entropy floor 8l-reviewer docs gap on HIGH #1: 8 chars is insufficient entropy to defeat enumeration. Bumping to 22 chars allows UUIDs, ULIDs, base64-encoded 16-byte values without restricting customers to a specific format. Also expands the description to instruct customers to use unguessable content (UUID/ULID/random bytes), not a guessable string like their company name. Existing tests cover both the bumped floor (27-char fixture passes) and rejection (5-char 'short' still fails 422). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(provisioning): replace batch-mode UNIQUE drop with raw SQL table swap (HIGH #4) 8l-reviewer third pass empirically verified that the first attempt at this migration (batch_op.alter_column(..., unique=False)) was a no-op: the post-migration DDL still contained UNIQUE (enterprise_id) at the table level. A FAILED job permanently locked the slug — exactly the bug we set out to fix. Root cause: when Alembic batch mode reflects an existing SQLite table, unnamed inline UNIQUE constraints become reflected UniqueConstraint objects that alter_column's unique= keyword does NOT remove. The keyword is treated as advisory hint, not a constraint drop. Replacement strategy: explicit raw-SQL table swap (the standard SQLite "alter table" workaround). Rename old → create fresh without UNIQUE → copy rows → drop old → recreate the rate-limit index → create the partial unique index. Empirically verified against sqlite3 directly: post-migration table has no inline UNIQUE; FAILED slug can be retried; duplicate in-flight slug still blocked. Also: - delete dead `is_slug_taken` helper (callers use `get_active_job_for_slug`) - drop the two test_is_slug_taken_* tests (covered helper is gone) - add test_failed_slug_can_be_retried_post_0021a covering the exact retry scenario the original migration was supposed to enable - add test_duplicate_active_slug_still_blocked covering the partial-unique rejection path Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(provisioning): scoped session policy on AssumeRole + _cf_upsert_cname tests Two carry-over MEDIUMs from 8l-reviewer: 1. Both AssumeRole call sites (_validate_assume_role + _phase4_l2_standup) now attach an inline session policy via a new helper _assume_role_session_policy(enterprise_slug). The policy allows ONLY CFN ops on the specific stack ARN pattern arn:aws:cloudformation:*:*:stack/8th-layer-l2-<slug>/*, so even if the customer's role grants admin, OUR session can only touch CFN on this enterprise's stack. CFN itself runs creates under the customer role's privileges — we don't widen our session. 2. _cf_upsert_cname now has direct branch coverage: - test_noop_when_existing_cname_matches: GET returns matching content - test_patch_when_existing_cname_drifts: GET returns drift → PATCH - test_post_when_no_existing_cname: GET empty → POST fresh - test_list_error_propagates: GET non-success → RuntimeError Decision 31 amended (OneZero1ai/8th-layer-core@aad6ad2) to document the session policy + ratify envelope-encryption-vs-KMS-asymmetric as intentional (KMS doesn't support Ed25519 KeySpec). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(provisioning): CI green — ty type-fix + ruff-format + test fixture mirrors post-0021a schema Three CI-driven fixes: 1. ty type-check (server/backend): the policy-template deepcopy + mutate pattern tripped a 'cannot assign to subscript on str' since the inferred type for nested dict values was str|dict[str, str|list[str]]. Refactor _assume_role_session_policy to construct the policy dict inline (no deepcopy, no subscript mutation) — strictly typed and clearer anyway. 2. ruff format: 7 files reformatted to match repo style. 3. Caught while reviewing: the test fixture was still using the pre-0021a schema (enterprise_id TEXT NOT NULL UNIQUE), which meant the new test_failed_slug_can_be_retried_post_0021a test didn't actually exercise the post-migration behavior. Updated the fixture to mirror the post-migration shape: no inline UNIQUE on enterprise_id, partial unique index on (enterprise_id) WHERE status NOT IN ('FAILED','COMPLETED'). Now the test does what it claims. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(provisioning): lint clean — ruff B017/SIM105/SIM117/F841/E501 - pytest.raises(Exception) → pytest.raises(ValidationError) in TestCreateEnterpriseRequestValidation (8× B017) - try/except/pass → contextlib.suppress (SIM105) - nested with → combined parenthesized with (2× SIM117) - drop unused resp1 assignment (F841) - break long error message string (E501) All ruff checks pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tests): use engine.connect() not begin() for partial-UNIQUE tests CI uncovered: insert_job/fail_job each call conn.commit() internally, so wrapping them in 'with engine.begin() as conn:' raises 'Can't operate on closed transaction inside context manager' because .begin() expects to manage the commit itself. Match the pattern the surrounding TestDbHelpers tests use: connect(), let the helpers commit, get_job() reads after. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Full-stack Human persona management in the 8th-Layer L2 admin UI.
Backend:
- Alembic migration 0022_persona_assignments: table with username FK,
persona ENUM[admin/viewer/agent/external-collaborator], assigned_at,
assigned_by, disabled_at (soft-disable). Chains from 0021_provisioning_jobs.
- HEAD_REVISION bumped to 0022_persona_assignments.
- Four endpoints under /admin/personas, all gated on require_admin:
GET /admin/personas — paginated list
POST /admin/personas — create Human + assign persona + invite
PATCH /admin/personas/{username} — change persona
POST /admin/personas/{username}/disable — soft-disable
- SqliteStore: list/get/upsert/disable persona_assignments + set_user_email.
- 13 tests covering auth gating, CRUD, 409 duplicate, 404 unknown, idempotency.
Frontend:
- PersonasPage with list table, create modal, edit modal, disable dialog.
- Persona badges colour-coded by role (rose/cyan/violet/emerald).
- /admin/personas route + Personas nav link in Layout sidebar.
- Types and api.ts methods for all four endpoints.
- 5 Vitest tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per #229 review: the disable endpoint set disabled_at in persona_assignments but no login path consulted it, so a disabled user could still mint a session. Add the check to all three session mint sites: * POST /auth/login (auth.py) — password path * POST /auth/passkey/login/finish (passkey_routes.py) — passkey path * POST /invites/claim (invite_routes.py) — magic-link claim path All three now call store.get_persona_assignment(username) and refuse with HTTP 403 when disabled_at is set. Review: #229 (comment) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per #229 review: H-2: append-only persona_assignment_audit table. New migration 0023 chains from 0022. CREATED / CHANGED / DISABLED rows written in the same transaction as the upsert/disable so the audit and state never desync. HEAD_REVISION bumped to 0023_persona_assignment_audit; test_head_revision_is_current also updated (was stale at 0021 before this change). H-3: last-admin guard. POST .../disable refuses with HTTP 409 + code=LAST_ADMIN when the target is persona='admin' and active admin count <= 1. New store method count_active_admins() does the count. M-2: PATCH no longer silently re-enables a disabled assignment. Returns 409 + code=USER_DISABLED pointing the admin at the yet-to-land POST .../enable endpoint. M-5: per-admin invite rate limit. POST /admin/personas rejects with HTTP 429 + code=RATE_LIMIT when the calling admin has issued >= 20 assignments in the trailing hour. New store method count_invites_by_admin(). Tests added: * test_persona_changes_create_audit_rows * test_disabling_last_admin_returns_409_LAST_ADMIN * test_patch_disabled_user_returns_409_USER_DISABLED * test_admin_cannot_send_more_than_20_invites_per_hour Review: #229 (comment) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…test
M-4: add PATCH to CORSMiddleware allow_methods so the L2 admin UI
can call PATCH /admin/personas/{username} from signup.8th-layer.ai.
Also add test_disabled_user_cannot_obtain_session_cookie pinning
the H-1 behaviour (login refused, no Set-Cookie when persona is
disabled).
Review: #229 (comment)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two migration smoke-test files (0011_activity_log, 0015_aigrp_peers_pair_secret_ref) still asserted on the pre-AS-1 chain head. Bumped to 0023_persona_assignment_audit to match the HEAD_REVISION constant in migrations.py, otherwise these tests fail on every fresh chain head. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After rebasing AS-1 onto FO-2-backend's tip, 0022_persona_assignments still pointed at down_revision='0021_provisioning_jobs', creating a branched chain (0021a and 0022 both chaining from 0021). Fix the down_revision so the chain is linear: 0021 → 0021a → 0022 → 0023. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CI uncovered after rebasing onto #228: - D102 add docstring to is_active property - D205 reformat two store method docstrings (summary line + blank + detail) - SIM118 drop .keys() in set comprehension - N802 lowercase test function names (test_..._LAST_ADMIN → test_..._last_admin, same for USER_DISABLED) - ruff format reformatted 3 files All ruff checks pass locally. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
88a34a4 to
8738bb9
Compare
- _upsert_persona_assignment_sync returns dict|None (delegated to _get_persona_assignment_sync which may return None on lookup miss) - strip extra trailing newline on persona_routes.py (end-of-file-fixer) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…t args
PersonasPage.tsx was calling api.createPersona with 3 positional args
and api.patchPersona with a string instead of {persona: ...}. Fix to
match the api.ts signatures.
Also drop unused beforeEach import in PersonasPage.test.tsx.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The rebase-conflict resolution earlier dropped both router includes from app.py, causing every /api/v1/admin/personas and /api/v1/enterprises request to 404. Restoring both imports + includes to match main's post-#228 state plus AS-1's persona_router. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…237, #238, #260, #351) (#352) Adds an "Active 8th-Layer fork-delta surfaces" section to FORK_DELTA.md cataloging the merged-and-deployed deviations from upstream mozilla-ai/cq that must be carried across rebases: - Enterprise Provisioning Service + signed-identity (Decision 31): PR #228; consolidates #236 (AIGRP-namespace), #238 (signed-identity), #351 (registry placeholder). - Personas management (L2 admin control): PR #229; source issue #237. - GET /consults/{thread_id} thread-metadata: PR #151; source issue #260. Tagged bucket-2 (upstream candidate). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
persona_assignmentstable (Alembic migration 0022, chains from 0021_provisioning_jobs) with soft-disable support viadisabled_at/admin/personas: list, create (+ magic-link invite), patch persona, soft-disablePersonasPagefrontend: list table with colour-coded persona badges, create/edit modals, disable confirm dialog, "Personas" nav link in Layout sidebarEndpoints
/admin/personas/admin/personas/admin/personas/{username}/admin/personas/{username}/disabledisabled_at)Design notes
username); UPSERT clearsdisabled_aton re-enablerole="enterprise_admin"to satisfymint_invite'starget_l2_idrequirement; actual access is governed by the persona assignmentemail_senderinjected viaDepends(get_email_sender)sodependency_overridesworks in tests;invite_sent=Falseon mint failure (assignment still persists)Test plan
python -m pytest tests/test_persona_routes.py— 13 tests passnpx vitest run src/pages/PersonasPage.test.tsx— 5 tests passnpx vitest run— 33 tests passalembic upgrade headfrom fresh DB reaches 0022_persona_assignments cleanlyGET /admin/personasreturns 403 for non-admin usercloses #200
🤖 Generated with Claude Code
Blocked by: #228 merge (migration chain dependency: 0021_provisioning_jobs -> 0022_persona_assignments -> 0023_persona_assignment_audit).