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 |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| 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:
| Route | Body or params | Codes | Audit | Notes |
|---|---|---|---|---|
GET /users?limit=&offset= (896 to 932) | query | 200 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) | query | 200 {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_user | is_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 boolean | 200 {id, email, is_active}; 404 | no | flips the switch; sessions untouched |
PATCH /users/{user_id}/role?role= (964 to 1016) | query, user or admin | 200 {id, email, role}; 400 "Invalid role"; 400 the api_only refusal (989 to 1000); 404 | no | writes 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: new | in_project | full}` | 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_admin | sets 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_admin | the 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_tokens | credits users.voucher_tokens (legacy, pre-D2) |
GET /audit-log?skip=&limit=&admin_id= (1594 to 1622) | query | 200 {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_id | no (read) | newest first |
GET /active-users?hours=24 (431 to 521) | query | 200 {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) | query | 200 the user, their queries, AI interactions and sessions in the window; 400; 404 | no (read) | reads query_history, ai_interactions, sessions |
POST /users/{user_id}/impersonate (196 to 241) | none | 200 {user_id, impersonation_token, expires_at}; 403 "Cannot impersonate another admin"; 404 | yes, impersonate_user_start | no _end event; no session row |
POST /agency-access (3101 to 3157) | {workspace_ids?: [...]}, empty means every active workspace | 200 {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) | path | 200 {revoked, workspace_id, user_id}; 404 "No agency membership found for that user and workspace." | yes, revoke_agency_access | only 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) | query | 200 every workspace with owner email and member count | no (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 byAdminUseron every request (S5) and again byAdminService.is_admininside the three service methods;users.role = 'admin'grants nothing (grep -rn 'role == "admin"' api/ services/finds no reader;WorkerUserreadsdesigneranddelivery,api/dependencies.py381). - Admin is orthogonal to surfaces: appointing writes no grant, and an admin without a
labsgrant cannot sign in (S5_require_labs_grant);test_api_only_session_is_denied_on_every_labs_gated_route(tests/test_surface_scoped_access.py354) 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_onlyaccount keeps its label:PATCH /users/{id}/rolerefuses it with the sentence naming thelabsgrant 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_SURFACESwritten withgranted_bythe 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_actionsrow 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 returnNone. - The impersonation token is bounded to fifteen minutes and to the
labssurface; nothing about it survives itsexp.
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) withtests/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 withis_admin=Truesees 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) andtest_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) andtests/test_api_endpoints.py::test_403_forbidden(1003): an anonymous or non-adminGET /api/admin/usersis 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 routePOST /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 plusimp),audislabs, andexp - iatis900; the appoint path'sdetailsis{"email": <target email>}; the super-admin guard fires only foradmin_appointed_by == id. - What a new app adds and Labs lacks: a hermetic test per write route asserting the
admin_actionsrow (type, admin, target, details) inside the same test as the state change; a test thatPATCH /statuswithis_active=falseends the target's sessions; a test that an impersonation token is refused after fifteen minutes and that a route readingimprecords the admin; a test thatmake_admin.py's outcome andappoint_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) andPATCH /users/{id}/role(964 to 1016) write noadmin_actionsrow; the inventory's "every action throughlog_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: addlog_actionwith{old, new}details to both, before the commit. - 2026-09-12 (measured at fd8558f):
PATCH /users/{id}/status?is_active=falseflips the flag and nothing else; the target'ssessionsrows stay active and every access token they hold works untilexp(60 minutes) and every refresh token until the dependency'sis_activecheck refuses it (S5:get_current_useranswers 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 callsrevoke_sessionfor each. - 2026-09-12 (measured at fd8558f): the
impclaim 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_byandadmin_actions.admin_idrecords the target; the only trace is theimpersonate_user_startrow minted fifteen minutes before, with no end. D8:get_current_userreadsimpintorequest.state.impersonator,ActingEmailand 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, notcreate_session, so there is nosessionsrow:POST /api/auth/logoutcannot revoke it,refreshcannot extend it (correct), andGET /api/admin/active-usersdoes not show it (it readssessions). Nothing checks that the target holds alabsgrant 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}/impersonateexists in the repository (grep -rl impersonate frontend/srcis empty;AdminUsers.tsxcalls 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_actioncommits inside itself and, on any exception, rolls the session back and returnsNone(47 to 98). Because the three service methods call it after the state change and before their owndb.commit(), a failed audit write silently reverts the appointment, revocation or credit and the method still returnssuccess: True(the outerdb.commit()commits nothing).create-userhas 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-statusis 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 ordinarydb.addinside the caller's transaction and lets the commit raise. - 2026-09-12 (measured at fd8558f):
PATCH /users/{id}/role?role=adminsetsusers.roletoadminand nothing reads that value for authorisation (is_adminis the bit,WorkerUserreadsdesigneranddelivery). An operator who uses it expecting to appoint an admin has changed a label; the console's toggle callsappoint-admin, which is the right route. - 2026-09-12 (measured at fd8558f):
revoke_admin's self-revocation guard protects only the row whoseadmin_appointed_byequals its own id (200).make_admin.pyleavesadmin_appointed_byNULL(it setsis_adminandadmin_appointed_atonly, 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.pycarries a hard-coded personal address at line 49 and no argument parsing; running it unedited targets that address. A rebuild takes--emailand 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_accessandrevoke_agency_accessuseget_client_ip(request)(3147, 3195), which honoursTRUSTED_PROXY_COUNT. One helper, everywhere. - 2026-09-12 (measured at fd8558f):
grant_agency_accessreactivates an inactiveworkspace_usersrow of any role (3131 to 3134), not only anagencyone; 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_accessis narrower and only touchesagencyrows. Symmetry is the fix. - 2026-09-12 (measured at fd8558f):
POST /add-tokenscreditsusers.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-listreturns nosurfacesand no admin route readsuser_surface_grants. A rebuild addssurfacesto the list row (S5'sget_user_surfaces_standalone) and a grant or revoke route with its own audit type. - 2026-09-12 (measured at fd8558f):
GET /usersandGET /users-listare two listings of the same table with different fields and pagination vocabularies (offsetversusskip); the console uses the second,AdminBrandEssence.tsxuses it withlimit: 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-sourceat fd8558f, backend identical tofeat/service-deskb1089b6):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),UpdateClientStatusRequestandupdate_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 atapp_fastapi.py874.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(ClientStatus45,role68,is_active69,client_status91 to 93,token_quotaandvoucher_tokens96 to 99,metal_tier102,is_adminand the appointment columns 116 to 118,appointed_by_admin133).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/users53,/admin/audit63).- 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 theimpclaim).
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.
python -m pytest tests/test_desk_agency.py tests/test_admin_api_key_scopes.py -q --no-cov -o addopts=""prints27 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.grep -c "admin_user: AdminUser\|admin: AdminUser" api/routers/admin_router.pyprints a number equal togrep -c "^@router\." api/routers/admin_router.py(every route carries the dependency; at fd8558f both print48), andgrep -c "Depends(get_current_user)" api/routers/admin_router.pyprints0.grep -n "log_action\|db.add(AdminAction" api/routers/admin_router.py services/admin_service.py | wc -lcounts the audit sites;sed -n 934,1016p api/routers/admin_router.py | grep -c "log_action"prints0(the two unaudited writes, Gotchas).grep -rn '"imp"' api/ services/ core/ middleware/ | wc -lprints1(the mint at line 234; a reader raises it to at least2).grep -n "admin_appointed_by" make_admin.py | wc -lprints0andgrep -n "target_user.admin_appointed_by == target_user.id" services/admin_service.pyprints line 200 (the guard and the script that sidesteps it).- With the harness up and an admin session (
dev-loginor a row appointed throughPOST /appoint-admin):POST /api/admin/appoint-adminwith the seeded owner's id returns 200"Admin appointed successfully";GET /api/admin/audit-log?limit=1showsaction_type: "appoint_admin"with the owner's email indetails;SELECT is_admin, admin_appointed_by FROM users WHERE id = <owner>printstand the appointing admin's id; a second identicalPOSTreturns 400"User is already an admin". - Same session:
POST /api/admin/users/<a non-admin user id>/impersonatereturns 200 withexpires_atfifteen 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']andaudlabs;GET /api/auth/mewith that token returns the target's profile;GET /api/admin/audit-log?limit=1showsimpersonate_user_start;SELECT count(*) FROM sessions WHERE access_token = '<the token>'prints0. - Same session:
POST /api/admin/users/<an admin's id>/impersonatereturns 403"Cannot impersonate another admin";PATCH /api/admin/users/<an api_only user id>/role?role=userreturns 400 whose detail names thelabssurface. - Same session:
PATCH /api/admin/users/<user>/status?is_active=falsereturns 200 with"is_active":false,GET /api/admin/audit-log?limit=1does not show astatusaction (Gotchas), and a request with that user's still-valid token returns 403"User account is disabled". - Same session:
POST /api/admin/agency-accesswith{}returns 200 withgranted + reactivated + already_member == total_accessible;SELECT role FROM workspace_users WHERE user_id = <admin> AND workspace_id = <any listed>printsagencyfor a workspace the admin did not already belong to;DELETE /api/admin/agency-access/<that workspace>returns 200"revoked": true, and the sameDELETEon a workspace the admin owns returns 404. npm run checkin 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.