Prima Get an API key

Module kit / Identity and auth

Admin user management and impersonation

identity-auth/admin-user-management-and-impersonation

Kind backend
Domain identity-auth
Maturity prod
Reusable extract
Stack fastapi, sqlalchemy-sync, postgres, ed25519-jws
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/test-layout
Frontends identity-auth/ui/admin-users-console
Reference implementation
  • alchemy_labs/backend/api/routers/admin_router.py
  • alchemy_labs/backend/services/admin_service.py
  • alchemy_labs/backend/models/admin_action_model.py
  • alchemy_labs/backend/models/user_model.py
  • alchemy_labs/backend/tests/test_desk_agency.py
  • alchemy_labs/backend/make_admin.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Also measured on
  • philosophers_stone main 261be0e 2026-09-12
Version 1

Purpose

A platform admin can list and search every account, create one with its Labs grants and personal workspace, switch it on or off, change its label and client status, appoint or revoke another admin, credit legacy tokens, read what an account has been doing, grant themselves an agency seat in client workspaces, and mint a fifteen-minute token that acts as another user. The invariant that makes it trustworthy is that admin is a boolean on the account row (users.is_admin) checked live by one dependency (AdminUser), and every consequential action is meant to leave an admin_actions row naming the admin, the target, the client address and what changed; where a route does not, this recipe says so, because an admin panel without a complete audit trail is the thing a rebuild must not copy.

When to use

Read it before adding a route under /api/admin, before touching is_admin, before letting anyone act as another account, and when a person asks "who made this account", "who gave them admin", "why does this account have a workspace it never used" or "why is this admin action not in the log". Trigger phrases: "appoint admin", "revoke admin", "super admin", "impersonate", "the imp claim", "client status", "in_project", "add tokens", "agency access", "audit log", "make_admin".

Not this recipe: what AdminUser is and how the grants table gates the SPA (S5, and identity-auth/surface-registry-and-gate, B4); the workspace rows the create path writes (identity-auth/multi-tenant-workspaces, B15); the admin-minted API key for a user (POST /api/admin/users/{id}/api-key, identity-auth/developer-api-keys-and-security-policy, B10); the desk's agency queue that reads the agency memberships this recipe writes (agency-queue-and-metrics, B137); the other 32 of the router's 48 routes (system health, analytics, subscriptions, bug reports, system logs, brand essence, dashboards, tracking, notify), which belong to their domains; the AI access tiers client_status used to gate (pre-D2; check_ai_access has zero callers, S5 Gotchas).

Stack assumptions

Labs at fd8558f: fastapi==0.118.2, synchronous handlers on DatabaseSession, AdminUser from S5 (api/dependencies.py 364 to 375: get_current_active_user then is_admin, else 403 "Admin access required"), Ed25519 minting through auth_service.create_access_token for the impersonation token (S5 _mint, services/auth_service.py 366 to 405), bcrypt for the optional password on create. Foundations assumed: S4 (one mount, app_fastapi.py line 874, prefix /api/admin, LABS_SURFACES, so a Prima-surface session is 403 before any handler), S6 (the agency grant is a membership row read by S6's fences), S7 (the Labs envelope on every refusal).

Environment: none read by these routes. Fail behaviour: AdminService.log_action catches every exception, rolls the session back and returns None (services/admin_service.py 47 to 98), and the three service methods that call it return {"success": False, ...} on their own exceptions after a rollback (100 to 313); the router turns a success: False into 400 with the service's sentence. The consequence of the swallow is under Gotchas.

Data model

The audit table (models/admin_action_model.py lines 14 to 38; in the baseline revision baseline_prod_schema_001, line 436):

admin_actions (
  id                   uuid PRIMARY KEY,
  admin_id             uuid NOT NULL REFERENCES users(id),      -- who acted
  action_type          varchar(100) NOT NULL,                   -- appoint_admin | revoke_admin | add_tokens | create_user
                                                                -- | update_client_status | impersonate_user_start
                                                                -- | grant_agency_access | revoke_agency_access | ...
  target_user_id       uuid NULL REFERENCES users(id),          -- who it was done to
  target_resource_type varchar(50) NULL,                        -- e.g. 'workspace'
  target_resource_id   varchar(255) NULL,
  details              jsonb NULL,                              -- per action, below
  ip_address           varchar(45) NULL,
  user_agent           text NULL,
  created_at           timestamp NOT NULL
);
CREATE INDEX idx_admin_actions_admin_id       ON admin_actions(admin_id);
CREATE INDEX idx_admin_actions_target_user_id ON admin_actions(target_user_id);
CREATE INDEX idx_admin_actions_created_at     ON admin_actions(created_at DESC);
CREATE INDEX idx_admin_actions_action_type    ON admin_actions(action_type);

The account columns this recipe reads and writes (models/user_model.py):

users (
  ...
  role                varchar(50) DEFAULT 'user',       -- label; CHECK ck_users_role: user | admin | designer | delivery | api_only | medical_reviewer
  is_active           boolean DEFAULT true,             -- the off switch (line 69)
  is_verified         boolean DEFAULT false,
  client_status       clientstatus NOT NULL DEFAULT 'new',   -- new | in_project | full (enum, line 91)
  ai_trial_started_at timestamp, ai_trial_ends_at timestamp,  -- set when client_status becomes full (92 to 93)
  token_quota         integer DEFAULT 100000,           -- legacy quota (96)
  voucher_tokens      integer DEFAULT 0,                -- what add-tokens credits (99)
  metal_tier          metaltier NOT NULL DEFAULT 'gratis',    -- legacy billing (102)
  is_admin            boolean DEFAULT false,            -- the one authorisation bit (116)
  admin_appointed_by  uuid NULL REFERENCES users(id),   -- who appointed; self for the super admin (117)
  admin_appointed_at  timestamp NULL                    -- (118)
);

details per action, as written: appoint_admin {email}; revoke_admin {email, previously_appointed_by}; add_tokens {email, tokens_added, previous_balance, new_balance, reason}; update_client_status {old_status, new_status, trial_started, trial_ends_at}; create_user {email, username, client_status}; impersonate_user_start {impersonated_user_email}; grant_agency_access {granted: [...], reactivated: [...], already_member: [...]} with target_resource_id "<n> workspaces"; revoke_agency_access with target_resource_id the workspace id.

The impersonation token is a v2 Labs access token (S5) with sub the target, aud: "labs", exp fifteen minutes out, the target's tid and tids_n, and one extra claim imp: <admin user id> (admin_router.py 222 to 236). What is NOT stored for it: a sessions row (create_access_token, not create_session, so revoke_session and refresh_session cannot find it), and no reader of imp exists anywhere (Gotchas).

What is NOT stored otherwise: an audit row for PATCH /users/{id}/status or PATCH /users/{id}/role (Gotchas); a surface grant written by the appoint path (admin is orthogonal to surfaces: an admin still needs a labs grant to sign in); a per-workspace admin (this recipe's admin is platform-wide; the workspace admin role is B15's).

API surface

api/routers/admin_router.py, prefix /api/admin, LABS_SURFACES, every route admin_user: AdminUser (401 without a credential, 403 "Admin access required" for a non-admin, S5), the Labs envelope. The audit column says whether an admin_actions row is written:

RouteBody or paramsCodesAuditNotes
GET /users?limit=&offset= (896 to 932)query200 list of {id, email, username, first_name, last_name, company, role, is_active, is_verified, subscription_tier, client_status, ai_trial_ends_at, created_at, last_login}no (read)newest first; no search
GET /users-list?skip=&limit=&search=&is_admin= (1439 to 1459)query200 {users: [{id, email, username, is_admin, is_active, subscription_tier, client_status, token_quota, tokens_used_current_period, voucher_tokens, admin_appointed_at, created_at, last_login}]}no (read)search is ILIKE %term% on email and username (admin_service.py 340 to 346); what the SPA reads
POST /create-user (1462 to 1591){email, username?, password?, first_name?, last_name?, company?, client_status?, project_name?, send_setup_email: true}200 {id, email, username, client_status, is_active}; 400 "User with email <email> already exists"yes, create_useris_active and is_verified true, LABS_SIGNUP_SURFACES grants with granted_by the admin (1523 to 1526), a personal workspace (1529 to 1552, B15), optional setup mail as a background task
PATCH /users/{user_id}/status?is_active= (934 to 962)query boolean200 {id, email, is_active}; 404noflips the switch; sessions untouched
PATCH /users/{user_id}/role?role= (964 to 1016)query, user or admin200 {id, email, role}; 400 "Invalid role"; 400 the api_only refusal (989 to 1000); 404nowrites the label only; role='admin' does not set is_admin and nothing reads it (Gotchas)
PATCH /users/{user_id}/client-status (1019 to 1099)`{status: newin_projectfull}`200 {id, email, client_status, ai_trial_started_at, ai_trial_ends_at, trial_started}; 400; 404
POST /appoint-admin (1329 to 1361){user_id}200 {"message": "Admin appointed successfully"}; 400 "Invalid user ID format", "Target user not found", "User is already an admin", "Only admins can appoint other admins"yes, appoint_adminsets is_admin, admin_appointed_by, admin_appointed_at (admin_service.py 100 to 161)
POST /revoke-admin (1364 to 1396){user_id}200; 400 "User is not an admin", "Super admin cannot revoke their own privileges"yes, revoke_adminthe super admin is the row whose admin_appointed_by is itself (200); anyone else may revoke anyone, including themselves
POST /add-tokens (1399 to 1436){user_id, token_amount, reason?}200 {message, new_balance}; 400 "Token amount must be positive"yes, add_tokenscredits users.voucher_tokens (legacy, pre-D2)
GET /audit-log?skip=&limit=&admin_id= (1594 to 1622)query200 {actions: [{id, admin_id, action_type, target_user_id, target_resource_type, target_resource_id, details, ip_address, user_agent, created_at}]}; 400 bad admin_idno (read)newest first
GET /active-users?hours=24 (431 to 521)query200 {active_users: [{user_id, email, name, company, last_activity, last_action, last_action_time, ip_address, user_agent, status}], total_active, period_hours}no (read)from sessions rows active in the window; status is active under 300 s idle, else idle
GET /user-activity/{user_id}?days=7 (524 to 650)query200 the user, their queries, AI interactions and sessions in the window; 400; 404no (read)reads query_history, ai_interactions, sessions
POST /users/{user_id}/impersonate (196 to 241)none200 {user_id, impersonation_token, expires_at}; 403 "Cannot impersonate another admin"; 404yes, impersonate_user_startno _end event; no session row
POST /agency-access (3101 to 3157){workspace_ids?: [...]}, empty means every active workspace200 {user_id, granted, reactivated, already_member, total_accessible}yes, grant_agency_access (an AdminAction added directly, not through log_action)self only: the target is the calling admin (3111); writes role='agency', never downgrades an existing active row, reactivates an inactive one of any role
DELETE /agency-access/{workspace_id} (3160 to 3199)path200 {revoked, workspace_id, user_id}; 404 "No agency membership found for that user and workspace."yes, revoke_agency_accessonly a row with role='agency' is revocable, so an owner or a client teammate can never be evicted here; sets is_active = false
GET /workspaces?page=&page_size= (124 to 195)query200 every workspace with owner email and member countno (read)B15's platform-wide list

The is_active and role patches take their value as a query parameter, not a body, which is what the signatures say (is_active: bool, role: str without a model, 936 to 938 and 966 to 968).

Application logic

Appointing (POST /appoint-admin, AdminService.appoint_admin, 100 to 161), in order: parse the UUID (400 on failure); is_admin(appointer) re-read from the row (a second check after the dependency, 121 to 122); load the target (400 if absent, 400 if already admin); set is_admin = True, admin_appointed_by = appointer, admin_appointed_at = now; log_action(...), which adds the audit row and commits (that commit is the one that persists the flag change too, 85 to 87); db.commit() again (a no-op); {"success": True}. Revoking mirrors it with the super-admin guard: target == revoker and target.admin_appointed_by == target.id refuses (200); the three columns are cleared and the audit row records previously_appointed_by (213 to 224).

Creating (POST /create-user, 1462 to 1591), in order: duplicate email is 400 with the address in the sentence; username from the local part, suffixed with six hex characters on collision (1494 to 1499); the users row with is_active and is_verified true and the optional bcrypt hash (1502 to 1515); grant_surfaces(db, user.id, LABS_SIGNUP_SURFACES, granted_by=admin) (1526: labs plus the three Prima surfaces, core/surfaces.py 185); the personal workspace, membership and settings inside a try/except that logs and continues (1529 to 1552); log_action("create_user"), which commits everything so far (1557 to 1568); db.commit(); the optional setup mail as a background task (1576 to 1582); 200.

Impersonating (POST /users/{id}/impersonate, 196 to 241), in order: load the target (404); refuse an admin target (403, 210 to 211); log_action("impersonate_user_start", details={impersonated_user_email}) with request.client.host as the address (211 to 219); auth_service.create_access_token(data={"sub": target, "imp": admin}, expires_delta=15 min, surface=LABS) (230 to 234), which runs S5's _mint and adds the target's tenant claims; return the token and expires_at. Nothing else: no sessions row, no grant check at mint (the gate refuses the token on use if the target holds no labs grant), no end event.

Agency access (POST /agency-access, 3101 to 3157), in order: the target is the caller (3111, the model docstring at 3085 to 3098 says a user_id field was removed on purpose); the active workspaces, all or the listed ones (3111 to 3114); per workspace, an existing active row is left alone and counted already_member, an existing inactive row is reactivated whatever its role, else a new row role='agency', added_by=admin; one AdminAction with the three lists; one commit (3139 to 3149). Revoke (3160 to 3199) finds the caller's own agency row for the workspace, flips is_active, writes the action, commits.

The other writes: PATCH /users/{id}/status loads, sets is_active, commits, returns (934 to 962; no audit, no session revocation); PATCH /users/{id}/role validates the string against ["user", "admin"], refuses an api_only account, sets the label, commits (964 to 1016; no audit); PATCH /users/{id}/client-status sets the enum and, on a transition to full, the trial window, commits, then logs (1019 to 1099: the log is after the commit, so a log failure leaves the change and loses the row); POST /add-tokens adds to voucher_tokens and logs before the commit (admin_service.py 234 to 313).

make_admin.py (repo root of backend/): finds a user by a hard-coded address, sets is_admin and admin_appointed_at, commits; no admin_appointed_by, no audit row (12 to 44). It is how the first admin was made; admin_appointed_by IS NULL AND is_admin is its signature in the table, and such a row does not match the super-admin guard.

Invariants

  • Admin is users.is_admin, read live by AdminUser on every request (S5) and again by AdminService.is_admin inside the three service methods; users.role = 'admin' grants nothing (grep -rn 'role == "admin"' api/ services/ finds no reader; WorkerUser reads designer and delivery, api/dependencies.py 381).
  • Admin is orthogonal to surfaces: appointing writes no grant, and an admin without a labs grant cannot sign in (S5 _require_labs_grant); test_api_only_session_is_denied_on_every_labs_gated_route (tests/test_surface_scoped_access.py 354) walks /api/admin/* with a Prima-surface session and expects 403 on every route.
  • An admin cannot be impersonated (403 at 210 to 211), and the only person who cannot revoke themselves is the self-appointed super admin.
  • An api_only account keeps its label: PATCH /users/{id}/role refuses it with the sentence naming the labs grant as the way to give Labs access (989 to 1000).
  • Agency access is self-granted and self-revoked, never on behalf of another account, never at the expense of an owner or a genuine teammate: only role='agency' rows are revocable, and an active row of any other role is never rewritten (3128 to 3134, 3177 to 3183).
  • An admin-created account is a Labs account and says so: LABS_SIGNUP_SURFACES written with granted_by the admin, in the same transaction as the row (1519 to 1526).
  • Every write that changes what an account may do is meant to leave an admin_actions row with the admin, the target, the address and the agent; at fd8558f two of them do not (Gotchas), and a rebuild treats the audit write as part of the transaction, not a side effect that may return None.
  • The impersonation token is bounded to fifteen minutes and to the labs surface; nothing about it survives its exp.

Frontend

The module that renders this, by id:

identity-auth/ui/admin-users-console   F4, SPA: /admin/users (AdminUsers.tsx at fd8558f calls GET /api/admin/users-list
                                       with search, POST /add-tokens, POST /appoint-admin and /revoke-admin,
                                       PATCH /users/{id}/client-status, POST /create-user; lines 81, 103, 121, 132, 147);
                                       the surface grants column and the impersonation banner the inventory names are
                                       not backed by a call in the SPA at this commit (Gotchas)

Minimum admin and debug surface: GET /api/admin/audit-log (the table, newest first, filterable by admin); SELECT id, email, is_admin, admin_appointed_by, admin_appointed_at FROM users WHERE is_admin ORDER BY admin_appointed_at (the admin roster; a NULL admin_appointed_by is a make_admin.py row); SELECT action_type, count(*) FROM admin_actions GROUP BY 1 ORDER BY 2 DESC (which actions are being taken; impersonate_user_start with no _end counterpart); SELECT workspace_id FROM workspace_users WHERE user_id = <admin> AND role = 'agency' AND is_active (the agency seats); the log lines Admin action logged: <type> by admin <id> and Failed to log admin action: <error>.

Security & tenancy

Auth dependency: AdminUser on every route (S5), which is is_active then is_admin; the router is mounted LABS_SURFACES, so the surface gate refuses a prima_* session before the dependency runs. Workspace derivation: none; these routes are platform-wide by design, and the one that touches memberships (agency-access) names its workspaces in the body and writes rows for the caller only.

Fail direction: the dependency closed; the audit write open (log_action returns None on failure and the caller proceeds); the appoint and revoke paths closed on their own exceptions (rollback, success: False, 400); the create path's workspace step open (logged, continues); the impersonation mint closed (any _mint failure is a 500 and no token).

Credentials at rest: the optional password on create as bcrypt (AuthService.hash_password, 1513 to 1514); the impersonation token nowhere (returned once, never stored, so it cannot be revoked before exp); admin_actions.details carries the target's email on six action types and the impersonated address on one (PII in the audit table by design; the table is admin-read only).

Rate limits: none on this router; the general limiter (B18) applies. Attacker-controlled inputs, all behind AdminUser: user_id path and body strings (parsed as UUID, 400 on failure; the two PATCH routes pass the raw string to User.id == and let the driver refuse); search (parametrised ILIKE); email on create (echoed in the 400 sentence); workspace_ids (filtered by is_active, unknown ids silently dropped); role and status (allowlists); token_amount (positive integer, no upper bound); reason (free text into details). The client address recorded is request.client.host on every path but the two agency routes, which use get_client_ip(request) (utils/net.py); behind the ingress the former is the proxy (Gotchas).

Tenancy of the impersonation token: it carries the target's tid and tids_n (S5 _tenant_claims), so every tenant fence sees the target; aem is not set, so audit trails that read ActingEmail record the target's own address, not the admin's; imp is present and read by nothing.

Tests

On the test Postgres of S9, all at fd8558f, run 2026-09-12:

  • tests/test_desk_agency.py (7 cases) with tests/test_admin_api_key_scopes.py: 27 passed in 16.32s. The agency cases that read this recipe's rows: test_agency_scope_is_the_agents_list_of_each_workspace (64), test_agency_filters_and_out_of_scope_workspace_is_403 (91), test_platform_admin_sees_every_workspace (146: a user made with is_admin=True sees every workspace's tickets and the agency workspace list is a superset of the three fixtures), test_agency_workspaces_shape (161).
  • tests/test_surface_scoped_access.py::test_api_only_session_is_denied_on_every_labs_gated_route (354) and test_api_only_session_403s_over_real_http (400, parametrised, ("GET", "/api/admin/users") among the rows at 389): a Prima-surface session is 403 on the admin router.
  • tests/test_critical_endpoints.py::test_admin_users_requires_admin (326) and tests/test_api_endpoints.py::test_403_forbidden (1003): an anonymous or non-admin GET /api/admin/users is refused (the assertions accept 401, 403 or 404, so they pin "refused", not the code).
  • tests/test_admin_api_key_scopes.py (20 cases): the neighbour route POST /users/{id}/api-key (B10).
  • Known-answer vectors: an impersonation token's payload keys are ['aud', 'exp', 'iat', 'imp', 'iss', 'jti', 'sub', 'tid', 'tids_n', 'type', 'ver'] for a single-workspace target (S5's list plus imp), aud is labs, and exp - iat is 900; the appoint path's details is {"email": <target email>}; the super-admin guard fires only for admin_appointed_by == id.
  • What a new app adds and Labs lacks: a hermetic test per write route asserting the admin_actions row (type, admin, target, details) inside the same test as the state change; a test that PATCH /status with is_active=false ends the target's sessions; a test that an impersonation token is refused after fifteen minutes and that a route reading imp records the admin; a test that make_admin.py's outcome and appoint_admin's outcome are indistinguishable in the table (they are not today). Playwright: none dedicated; F4's spec covers the console.

Deploy & ops

Config keys: none. Migrations order: admin_actions and the admin columns on users are in the baseline; ck_users_role from surface_grants_001 (B14) admits the six labels the role patch writes into. Scheduler jobs: none. The first admin on a fresh database is made by python make_admin.py after editing the address in the file (a --email argument is the recipe's rule), or by UPDATE users SET is_admin = true, admin_appointed_by = id, admin_appointed_at = now() WHERE email = ..., which is the shape the super-admin guard recognises.

Argus checks: GET /api/admin/audit-log?limit=1 with an admin session returns 200; SELECT count(*) FROM users WHERE is_admin is the expected number (an unexpected increase is an appoint_admin row or a make_admin.py run); on the harness, dev-login (S5) signs in the first admin, so the seeded owner must not be one if the login page is needed (AUTHORING.md). Rollback: nothing deployable beyond the code; an audit row is never deleted by a rollback.

Gotchas

  • 2026-09-12 (measured at fd8558f): PATCH /users/{id}/status (934 to 962) and PATCH /users/{id}/role (964 to 1016) write no admin_actions row; the inventory's "every action through log_action" is true of seven of the nine writes. Disabling an account is the most consequential of the nine and is the least recorded. D8: add log_action with {old, new} details to both, before the commit.
  • 2026-09-12 (measured at fd8558f): PATCH /users/{id}/status?is_active=false flips the flag and nothing else; the target's sessions rows stay active and every access token they hold works until exp (60 minutes) and every refresh token until the dependency's is_active check refuses it (S5: get_current_user answers 403 "User account is disabled" on the next request, so the effect is immediate on the API even though nothing was revoked). The row-level fact is still that the sessions are not ended; a rebuild calls revoke_session for each.
  • 2026-09-12 (measured at fd8558f): the imp claim is written (230 to 234) and read by nothing (grep -rn '"imp"' api/ services/ core/ middleware/ hits the mint only). While an admin acts as a user, every audit trail, ActingEmail, created_by and admin_actions.admin_id records the target; the only trace is the impersonate_user_start row minted fifteen minutes before, with no end. D8: get_current_user reads imp into request.state.impersonator, ActingEmail and the audit writers record it, and the token is refused on the admin router itself.
  • 2026-09-12 (measured at fd8558f): the impersonation token is minted with create_access_token, not create_session, so there is no sessions row: POST /api/auth/logout cannot revoke it, refresh cannot extend it (correct), and GET /api/admin/active-users does not show it (it reads sessions). Nothing checks that the target holds a labs grant at mint; the gate refuses the token on use, which is the right outcome and a surprising 403 for the admin.
  • 2026-09-12 (measured at fd8558f): no caller of POST /users/{id}/impersonate exists in the repository (grep -rl impersonate frontend/src is empty; AdminUsers.tsx calls users-list, add-tokens, appoint, revoke, client-status and create-user). The F4 row's "impersonation UX (15 min banner)" describes a screen that is not there at this commit; the F4 recipe decides whether to build it or drop the route.
  • 2026-09-12 (measured at fd8558f): AdminService.log_action commits inside itself and, on any exception, rolls the session back and returns None (47 to 98). Because the three service methods call it after the state change and before their own db.commit(), a failed audit write silently reverts the appointment, revocation or credit and the method still returns success: True (the outer db.commit() commits nothing). create-user has the same shape at the route level (1557 to 1571): the user row and grants are rolled back and the handler returns the id of an account that does not exist. client-status is the other way round: commit first (1073), log after (1076), so the change persists and the audit is lost. A rebuild writes the audit row as an ordinary db.add inside the caller's transaction and lets the commit raise.
  • 2026-09-12 (measured at fd8558f): PATCH /users/{id}/role?role=admin sets users.role to admin and nothing reads that value for authorisation (is_admin is the bit, WorkerUser reads designer and delivery). An operator who uses it expecting to appoint an admin has changed a label; the console's toggle calls appoint-admin, which is the right route.
  • 2026-09-12 (measured at fd8558f): revoke_admin's self-revocation guard protects only the row whose admin_appointed_by equals its own id (200). make_admin.py leaves admin_appointed_by NULL (it sets is_admin and admin_appointed_at only, lines 29 to 30), so the admin it made can revoke themselves, and two admins can revoke each other down to zero. The recipe's rule is that a revocation leaving no admin is refused.
  • 2026-09-12 (measured at fd8558f): make_admin.py carries a hard-coded personal address at line 49 and no argument parsing; running it unedited targets that address. A rebuild takes --email and writes the audit row.
  • 2026-09-12 (measured at fd8558f): the client address on every audit row except the two agency routes is request.client.host (219, 1087, 1302, 1351, 1386, 1423, 1567), which behind the ingress is the proxy's address; grant_agency_access and revoke_agency_access use get_client_ip(request) (3147, 3195), which honours TRUSTED_PROXY_COUNT. One helper, everywhere.
  • 2026-09-12 (measured at fd8558f): grant_agency_access reactivates an inactive workspace_users row of any role (3131 to 3134), not only an agency one; a teammate whose membership was deactivated by other means is re-admitted as whatever they were when the admin grants themselves access to that workspace. revoke_agency_access is narrower and only touches agency rows. Symmetry is the fix.
  • 2026-09-12 (measured at fd8558f): POST /add-tokens credits users.voucher_tokens, the voucher-era quota; D2 (2026-09-11) makes the prepaid credit in Prima's ledger the one truth for AI spend, so the route and the console's "add tokens" action are legacy. A rebuild does not copy them; the inventory's F4 row lists the voucher-era columns to retire.
  • 2026-09-12 (measured at fd8558f): the console's "grants per surface as a column" (F4 row) has no backing field: users-list returns no surfaces and no admin route reads user_surface_grants. A rebuild adds surfaces to the list row (S5's get_user_surfaces_standalone) and a grant or revoke route with its own audit type.
  • 2026-09-12 (measured at fd8558f): GET /users and GET /users-list are two listings of the same table with different fields and pagination vocabularies (offset versus skip); the console uses the second, AdminBrandEssence.tsx uses it with limit: 1000. One listing.

Reference implementation

Paths are relative to the repo root; commits as in measured_on and also_measured_on.

  • alchemy_labs (d1a-token-source at fd8558f, backend identical to feat/service-desk b1089b6):
    • backend/api/routers/admin_router.py (3199 lines; this recipe's routes): list_workspaces (124 to 195), impersonate_user (196 to 241), AppointAdminRequest, AddTokensRequest, CreateUserRequest (354 to 373), get_active_users (431 to 521), get_user_activity_details (524 to 650), list_users (896 to 932), update_user_status (934 to 962), update_user_role (964 to 1016), UpdateClientStatusRequest and update_client_status (1019 to 1099), appoint_admin (1329 to 1361), revoke_admin (1364 to 1396), add_tokens (1399 to 1436), get_users_list (1439 to 1459), create_user (1462 to 1591), get_audit_log (1594 to 1622), AgencyAccessRequest (3085 to 3098), grant_agency_access (3101 to 3157), revoke_agency_access (3160 to 3199); the mount at app_fastapi.py 874.
    • backend/services/admin_service.py: is_admin (25 to 43), log_action (47 to 98), appoint_admin (100 to 161), revoke_admin (163 to 232), add_tokens_to_user (234 to 313), get_all_users (315 to 378), get_admin_actions (380 to 426).
    • backend/models/admin_action_model.py (14 to 41); backend/models/user_model.py (ClientStatus 45, role 68, is_active 69, client_status 91 to 93, token_quota and voucher_tokens 96 to 99, metal_tier 102, is_admin and the appointment columns 116 to 118, appointed_by_admin 133).
    • backend/schemas/admin_schemas.py: ImpersonationTokenResponse (56 to 62), WorkspaceListResponse.
    • backend/api/dependencies.py: get_current_admin_user (364 to 375), AdminUser (S5); backend/services/auth_service.py: create_access_token (407 to 440); backend/core/surfaces.py: LABS_SIGNUP_SURFACES (185).
    • backend/make_admin.py (12 to 50).
    • frontend/src/pages/AdminUsers.tsx (the calls at 81, 103, 121, 132, 147); frontend/src/layouts/AdminLayout.tsx (/admin/users 53, /admin/audit 63).
    • Tests as listed under Tests.
  • Wiki philosophers_stone (261be0e): platform/architecture.md ("Authentication & Authorization"), platform/service-desk.md (the agency role and the served-workspace list), operations/prima-modules-inventory.md (rows B16, F4; the D8 identity list naming the imp claim).

Deviations of the reference from this recipe, all dated 2026-09-12: two unaudited writes; the audit write that can silently revert the change or be lost; the unread imp claim and the session-less token; no impersonation caller; the label-only role patch; the super-admin guard that make_admin.py bypasses; the hard-coded address; the proxy address on seven audit paths; the over-broad reactivation on agency grant; the voucher-era token credit; no grants column and no grant route; two user listings.

Acceptance checks

Run from alchemy_labs/backend at fd8558f with the :5434 test Postgres up, or from a new app built to this recipe. Expected observations are exact.

  1. python -m pytest tests/test_desk_agency.py tests/test_admin_api_key_scopes.py -q --no-cov -o addopts="" prints 27 passed; python -m pytest tests/test_surface_scoped_access.py -q --no-cov -o addopts="" -k "labs_gated_route or real_http" passes with no failure.
  2. grep -c "admin_user: AdminUser\|admin: AdminUser" api/routers/admin_router.py prints a number equal to grep -c "^@router\." api/routers/admin_router.py (every route carries the dependency; at fd8558f both print 48), and grep -c "Depends(get_current_user)" api/routers/admin_router.py prints 0.
  3. grep -n "log_action\|db.add(AdminAction" api/routers/admin_router.py services/admin_service.py | wc -l counts the audit sites; sed -n 934,1016p api/routers/admin_router.py | grep -c "log_action" prints 0 (the two unaudited writes, Gotchas).
  4. grep -rn '"imp"' api/ services/ core/ middleware/ | wc -l prints 1 (the mint at line 234; a reader raises it to at least 2).
  5. grep -n "admin_appointed_by" make_admin.py | wc -l prints 0 and grep -n "target_user.admin_appointed_by == target_user.id" services/admin_service.py prints line 200 (the guard and the script that sidesteps it).
  6. With the harness up and an admin session (dev-login or a row appointed through POST /appoint-admin): POST /api/admin/appoint-admin with the seeded owner's id returns 200 "Admin appointed successfully"; GET /api/admin/audit-log?limit=1 shows action_type: "appoint_admin" with the owner's email in details; SELECT is_admin, admin_appointed_by FROM users WHERE id = <owner> prints t and the appointing admin's id; a second identical POST returns 400 "User is already an admin".
  7. Same session: POST /api/admin/users/<a non-admin user id>/impersonate returns 200 with expires_at fifteen minutes out; decoding the token's payload (S5 acceptance check 10) prints the keys ['aud', 'exp', 'iat', 'imp', 'iss', 'jti', 'sub', 'tid', 'tids_n', 'type', 'ver'] and aud labs; GET /api/auth/me with that token returns the target's profile; GET /api/admin/audit-log?limit=1 shows impersonate_user_start; SELECT count(*) FROM sessions WHERE access_token = '<the token>' prints 0.
  8. Same session: POST /api/admin/users/<an admin's id>/impersonate returns 403 "Cannot impersonate another admin"; PATCH /api/admin/users/<an api_only user id>/role?role=user returns 400 whose detail names the labs surface.
  9. Same session: PATCH /api/admin/users/<user>/status?is_active=false returns 200 with "is_active":false, GET /api/admin/audit-log?limit=1 does not show a status action (Gotchas), and a request with that user's still-valid token returns 403 "User account is disabled".
  10. Same session: POST /api/admin/agency-access with {} returns 200 with granted + reactivated + already_member == total_accessible; SELECT role FROM workspace_users WHERE user_id = <admin> AND workspace_id = <any listed> prints agency for a workspace the admin did not already belong to; DELETE /api/admin/agency-access/<that workspace> returns 200 "revoked": true, and the same DELETE on a workspace the admin owns returns 404.
  11. npm run check in this repo prints no line for this recipe.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.