Skip to content

feat(admin): AS-1 — Personas tab for L2 admin shell - #229

Merged
dwinter3 merged 14 commits into
mainfrom
as-1-personas-tab
May 12, 2026
Merged

feat(admin): AS-1 — Personas tab for L2 admin shell#229
dwinter3 merged 14 commits into
mainfrom
as-1-personas-tab

Conversation

@dwinter3

@dwinter3 dwinter3 commented May 12, 2026

Copy link
Copy Markdown

Summary

  • Adds persona_assignments table (Alembic migration 0022, chains from 0021_provisioning_jobs) with soft-disable support via disabled_at
  • Four admin-gated REST endpoints under /admin/personas: list, create (+ magic-link invite), patch persona, soft-disable
  • Full PersonasPage frontend: list table with colour-coded persona badges, create/edit modals, disable confirm dialog, "Personas" nav link in Layout sidebar
  • 13 backend tests + 5 frontend tests; 33/33 frontend suite green

Endpoints

Method Path Action
GET /admin/personas Paginated list of Humans + assignments
POST /admin/personas Create Human + assign persona + send invite
PATCH /admin/personas/{username} Change persona
POST /admin/personas/{username}/disable Soft-disable (sets disabled_at)

Design notes

  • One active persona per Human per L2 (UNIQUE on username); UPSERT clears disabled_at on re-enable
  • Invite delivery uses role="enterprise_admin" to satisfy mint_invite's target_l2_id requirement; actual access is governed by the persona assignment
  • email_sender injected via Depends(get_email_sender) so dependency_overrides works in tests; invite_sent=False on mint failure (assignment still persists)
  • Migration 0022 chains from 0021_provisioning_jobs (FO-2-backend, closed FO-2-backend: Enterprise Provisioning Service #224)

Test plan

  • python -m pytest tests/test_persona_routes.py — 13 tests pass
  • npx vitest run src/pages/PersonasPage.test.tsx — 5 tests pass
  • Full frontend suite: npx vitest run — 33 tests pass
  • Smoke: alembic upgrade head from fresh DB reaches 0022_persona_assignments cleanly
  • Smoke: GET /admin/personas returns 403 for non-admin user
  • Smoke: create → list → patch → disable flow via UI

closes #200

🤖 Generated with Claude Code

Blocked by: #228 merge (migration chain dependency: 0021_provisioning_jobs -> 0022_persona_assignments -> 0023_persona_assignment_audit).

@dwinter3

Copy link
Copy Markdown
Author

Code review — AS-1 Personas Tab

Routine 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 merge

H-1: disabled_at is not consulted at login — soft-disable doesn't actually block authentication

server/backend/src/cq_server/persona_routes.py:566-588 (disable_persona); fix lives in FO-1c session-creation path.

POST /admin/personas/{username}/disable writes disabled_at to persona_assignments. The users row is intentionally left alone (audit-trail preservation). But the passkey + magic-link session-creation handlers authenticate against users, not against persona_assignments, and nothing in this PR adds a check.

A disabled Human can therefore still complete a passkey assertion or redeem a magic-link, receive a valid aud=admin session cookie, and continue acting as admin. The disable endpoint hides them from the Personas list but does not prevent authentication. This is the exact footgun the dispatch brief asked you to verify.

Required fix: in the FO-1c session-creation handler, after store.get_user(...), call store.get_persona_assignment(username) and reject the login if disabled_at IS NOT NULL. Or hoist disabled_at onto users so it's a single query. Test required: "disabled user cannot obtain session cookie."

H-2: No append-only audit trail for persona grants — mutable row only

persona_routes.py:488-494 (create) + 551-557 (patch)

upsert_persona_assignment overwrites a single row, recording the most recent assigned_by / assigned_at. After three persona changes, only the latest survives. There is no append-only log proving the sequence of grants. The scenario the brief calls out: an admin re-grants admin to themselves (or to a compromised account), and the only evidence is the latest mutable row — overwritten on the next PATCH.

Server logs / access logs aren't a substitute — they're ephemeral, not queryable, and contain admin email in the clear (separate concern).

Required fix: new persona_assignment_audit table — id, username, old_persona, new_persona, changed_by, changed_at, action (CREATED / CHANGED / DISABLED / ENABLED). Write inside the same engine.begin() transaction as the mutation. Also enables the disable-then-re-enable round-trip test that's currently missing.

H-3: No last-admin guard on disable

persona_routes.py:566-588

Disable endpoint checks existence + not-already-disabled. Does not check whether the target is persona='admin' and whether disabling them leaves zero active admins. Operator who disables themselves (or the last remaining admin) locks everyone out of the L2 admin shell.

Required fix: before writing disabled_at:

SELECT COUNT(*) FROM persona_assignments
WHERE persona = 'admin' AND disabled_at IS NULL

If count is 1 and target is admin, return 409 with code=LAST_ADMIN. Add test.


MEDIUM — Address pre-merge

M-1: test_head_revision_is_current asserts wrong revision — will fail CI on this diff

server/backend/tests/test_default_enterprise_backfill.py

Diff shows the assertion bumped only to "0021_provisioning_jobs". After this PR, actual HEAD_REVISION is "0022_persona_assignments". Mechanical slip during the rebase on top of #228 — the assertion needs one more bump.

Fix: assert HEAD_REVISION == "0022_persona_assignments"

M-2: PATCH silently re-enables disabled users via upsert

persona_routes.py:536-563 (patch) + store/_sqlite.py:1917-1937 (upsert)

PATCH fetches the existing assignment, 404s if missing, but doesn't check disabled_at. The upsert SQL clears disabled_at = NULL on conflict. So a PATCH on a disabled user re-enables them AND changes their persona in one call, with no audit, no signal in the response, no confirmation step. Easy footgun.

Fix: In patch_persona, if existing["disabled_at"] is set, reject with 409 ("user is disabled — re-enable first via POST /admin/personas/{username}/enable") OR require a force=true flag. Better: separate explicit re-enable endpoint.

M-3: Migration chain depends on sibling PR #228; merge order is unguarded

alembic/versions/0022_persona_assignments.py:171down_revision = "0021_provisioning_jobs"

#228 is still in security re-review (8 HIGHs from 8l-reviewer being addressed). If #229 lands first, Alembic chain breaks. No GitHub dependency annotation in this PR.

Fix: add "blocked by #228" to PR description + merge checklist gate. Or, accept dual-head temporarily by chaining from 0020_l2_brand and reconciling at merge time.

M-4: PATCH method missing from CORS allow_methods

app.py:1263-1269 — lists only GET, POST, OPTIONS. PATCH /admin/personas/{username} will preflight-reject from any cross-origin caller. Future bite even if same-origin today.

Fix: add "PATCH" to allow_methods.

M-5: No per-admin invite-send rate limit

persona_routes.py:496-524 (create_persona)

Authenticated admin can POST /admin/personas 1000 times with unique usernames and send 1000 outbound SES invites in a loop. No per-sender throttle. Provisioning routes have IP-hash rate limits; this one doesn't.

Fix: before mint_invite, count WHERE assigned_by = admin AND assigned_at >= now() - 1h, cap at 20/hour for v1.


LOW — FYI

L-1: asyncio.get_event_loop() deprecation

Five new store methods use asyncio.get_event_loop().run_in_executor(...). Deprecated in 3.10+, will raise in 3.12. Pre-existing pattern in the codebase — not a regression introduced by this PR.

L-2: Phase 5 placeholder token is not a real JWT

Cross-PR observation — provisioning/worker.py:1678-1680 of #228 uses hashlib.sha256(...) hex as a jwt claim parameter. Email template will render it as a magic-link, claim endpoint won't parse. Documented as a stub but worth fixing before #228 demos.


Coverage gaps (dispatch-requested checks)

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.

dwinter3 added a commit that referenced this pull request May 12, 2026
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>
dwinter3 added a commit that referenced this pull request May 12, 2026
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>
dwinter3 added a commit that referenced this pull request May 12, 2026
…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>
@dwinter3

Copy link
Copy Markdown
Author

Review findings addressed — 3 commits pushed.

Cites the original review.

Fixes shipped

HIGH

  • H-1 (disabled login bypass): store.get_persona_assignment(username) now consulted at all three session-mint sites — POST /auth/login, POST /auth/passkey/login/finish, POST /invites/claim. Returns HTTP 403 when disabled_at IS NOT NULL.
  • H-2 (no audit trail): new Alembic migration 0023_persona_assignment_audit chains from 0022_persona_assignments. CREATED / CHANGED / DISABLED rows written atomically with the upsert/disable in the same engine.begin() block. HEAD_REVISION bumped to 0023_persona_assignment_audit.
  • H-3 (last-admin guard): new store.count_active_admins(); disable_persona refuses with 409 + code=LAST_ADMIN when target is persona=admin and active admin count <= 1.

MEDIUM

  • M-1: test_head_revision_is_current assertion bumped (was already stale at 0021_provisioning_jobs, lifted to 0023_persona_assignment_audit).
  • M-2 (PATCH silently re-enables): patch_persona returns 409 + code=USER_DISABLED when disabled_at is set.
  • M-3 (cross-PR chain dep): PR description updated with "Blocked by feat(provisioning): FO-2-backend — Enterprise Provisioning Service #228 merge".
  • M-4 (CORS missing PATCH): allow_methods now ["GET","POST","PATCH","OPTIONS"].
  • M-5 (per-admin invite rate): new store.count_invites_by_admin(); create_persona rejects with 429 + code=RATE_LIMIT when this admin has issued >= 20 assignments in the trailing hour.

Test delta

Pre-fix: 13 tests in test_persona_routes.py.
Post-fix: 18 passed in 15.80s. Five new tests added:

  • test_disabled_user_cannot_obtain_session_cookie
  • 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

test_head_revision_is_current also passes.

Commits

  1. bc9a355 — H-1 session-creation disabled check (all three login paths)
  2. 4e145ef — H-2/H-3/M-2/M-5 + M-1 (audit migration + last-admin guard + rate limit)
  3. 632d1fa — M-4 + H-1 test (CORS PATCH + login-disable assertion)

Generated with Claude Code.

dwinter3 added a commit that referenced this pull request May 12, 2026
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>
dwinter3 added a commit that referenced this pull request May 12, 2026
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>
dwinter3 added a commit that referenced this pull request May 12, 2026
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>
dwinter3 added a commit that referenced this pull request May 12, 2026
…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>
@dwinter3
dwinter3 force-pushed the as-1-personas-tab branch from fb5f84f to eb2fa28 Compare May 12, 2026 17:02
dwinter3 added a commit that referenced this pull request May 12, 2026
)

* 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>
@dwinter3 dwinter3 closed this May 12, 2026
@dwinter3 dwinter3 reopened this May 12, 2026
dwinter3 and others added 9 commits May 12, 2026 13:46
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>
@dwinter3
dwinter3 force-pushed the as-1-personas-tab branch from 88a34a4 to 8738bb9 Compare May 12, 2026 17:46
dwinter3 and others added 5 commits May 12, 2026 13:48
- _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>
@dwinter3
dwinter3 merged commit ae5d567 into main May 12, 2026
5 checks passed
dwinter3 added a commit that referenced this pull request May 20, 2026
…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>
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.

FO-2-backend: Enterprise Provisioning Service AS-1: Personas tab — agent persona lifecycle UI

1 participant