feat(admin): manage system administrators #43

Merged
ryan.mcgee merged 4 commits from feat/admin-administrators into main 2026-10-22 14:51:00 +00:00
Owner

Stacked on #42 (feat/admin-break-glass, itself stacked on #41): this PR's base is that branch so the diff shows only this change. Merge #41, then #42, then retarget this PR to main.

Summary

  • Phase 4, the last of the system administration panel: managing who the system administrators are, with the protections you asked for.

Changes

  • /admin/administrators: the administrators with whether each has a second factor (one without is read-only), a form to add one by email, the invitations with where they stand (invited, needs a second factor, expired) and a way to withdraw them, and a demote link.
  • A second factor first. An existing account that has one (an authenticator app, a security key, or a login linked to an identity provider that is turned on) is promoted at once. An account without one is refused with instructions to set it up. Anyone without an account is invited.
  • Server invitations (server_invitations, reversible migration, token stored only as a SHA-256): a single-use link shown once, valid for 7 days, to /server-invites/:token. The accept page creates the account whatever TRAEFIK_CONTROL_SIGNUP says, or accepts as the logged-in invitee. The invitee becomes a system administrator only once they have a second factor, within 7 days of accepting.
  • Activation runs whenever a second factor is added: confirming an authenticator app, registering a security key, or linking an identity provider (Administrators.activate_pending/1, idempotent and claimed with a conditional update). The invitee's two-factor page says what is waiting for them.
  • Protections: nobody can demote themselves, and the last administrator cannot be demoted. The check and the change run while the administrators are locked (SELECT ... FOR UPDATE on PostgreSQL, chosen at compile time; SQLite has one writer), so two demotions at once cannot leave none. Everything also needs Admin.authorize_change/1 in the context.
  • Audited in the server audit log: administrator.promoted, .invited, .invitation_revoked, .demoted, and, done by the system, .invitation_accepted and .activated.
  • Email validation is now shared between organizations and administrators (Admin.Email).

Verification

  • mix precommit: exit 0. mix credo --strict: no issues. mix dialyzer: passed.
  • mix test: 1762 passed on SQLite and on PostgreSQL; migration rollback and re-apply clean on both.
  • Production image: registered the first account, enrolled TOTP, invited new.admin@example.com from the page, logged out and opened the link as that person (sign-up is closed), created the account (not an administrator: /admin refused them), opened their two-factor page (the notice was there), confirmed an authenticator app (flash "You are a system administrator now", the notice gone, the invitation no longer listed), then demoted the first account from the page. The audit page listed invited, invitation accepted (system), activated (system) and demoted.
  • Not exercised: the PostgreSQL row lock under real concurrency (the two-demotion race cannot be reproduced deterministically); the last-administrator refusal is tested with a session that has not noticed it was demoted.

Notes

  • The last-administrator guard can only trigger through that stale-session case or a race, because the acting administrator is always one of the administrators and cannot be the target.
  • That completes the four phases: panel shell, organizations, break-glass access, administrators.
> **Stacked on #42** (`feat/admin-break-glass`, itself stacked on #41): this PR's base is that branch so the diff shows only this change. Merge #41, then #42, then retarget this PR to `main`. ## Summary - Phase 4, the last of the system administration panel: managing who the system administrators are, with the protections you asked for. ## Changes - **`/admin/administrators`**: the administrators with whether each has a second factor (one without is read-only), a form to add one by email, the invitations with where they stand (invited, needs a second factor, expired) and a way to withdraw them, and a demote link. - **A second factor first.** An existing account that has one (an authenticator app, a security key, or a login linked to an identity provider that is turned on) is promoted at once. An account without one is refused with instructions to set it up. Anyone without an account is invited. - **Server invitations** (`server_invitations`, reversible migration, token stored only as a SHA-256): a single-use link shown once, valid for 7 days, to `/server-invites/:token`. The accept page creates the account whatever `TRAEFIK_CONTROL_SIGNUP` says, or accepts as the logged-in invitee. The invitee becomes a system administrator only once they have a second factor, within 7 days of accepting. - **Activation** runs whenever a second factor is added: confirming an authenticator app, registering a security key, or linking an identity provider (`Administrators.activate_pending/1`, idempotent and claimed with a conditional update). The invitee's two-factor page says what is waiting for them. - **Protections**: nobody can demote themselves, and the last administrator cannot be demoted. The check and the change run while the administrators are locked (`SELECT ... FOR UPDATE` on PostgreSQL, chosen at compile time; SQLite has one writer), so two demotions at once cannot leave none. Everything also needs `Admin.authorize_change/1` in the context. - **Audited** in the server audit log: `administrator.promoted`, `.invited`, `.invitation_revoked`, `.demoted`, and, done by the system, `.invitation_accepted` and `.activated`. - Email validation is now shared between organizations and administrators (`Admin.Email`). ## Verification - `mix precommit`: exit 0. `mix credo --strict`: no issues. `mix dialyzer`: passed. - `mix test`: 1762 passed on SQLite and on PostgreSQL; migration rollback and re-apply clean on both. - Production image: registered the first account, enrolled TOTP, invited `new.admin@example.com` from the page, logged out and opened the link as that person (sign-up is closed), created the account (not an administrator: `/admin` refused them), opened their two-factor page (the notice was there), confirmed an authenticator app (flash "You are a system administrator now", the notice gone, the invitation no longer listed), then demoted the first account from the page. The audit page listed invited, invitation accepted (system), activated (system) and demoted. - Not exercised: the PostgreSQL row lock under real concurrency (the two-demotion race cannot be reproduced deterministically); the last-administrator refusal is tested with a session that has not noticed it was demoted. ## Notes - The last-administrator guard can only trigger through that stale-session case or a race, because the acting administrator is always one of the administrators and cannot be the target. - That completes the four phases: panel shell, organizations, break-glass access, administrators.
feat(admin): manage system administrators
All checks were successful
Development images / images (push) Successful in 1m38s
d4a6326b27
- add /admin/administrators: list the system administrators with
  whether each has a second factor, promote an account, invite someone
  without one, demote, and withdraw invitations
- require a second factor first: an account that has one (app, key or a
  login linked to an enabled provider) is promoted at once, one without
  is refused, and an invitee becomes an administrator the moment they
  add one, within 7 days of accepting
- add server invitations (server_invitations) with an accept page at
  /server-invites/:token that creates the account regardless of the
  sign-up setting
- activate pending invitations when a TOTP app or security key is
  confirmed or an identity provider is linked, and tell the invitee on
  their two-factor page
- never demote oneself or the last administrator; the check and the
  change run while the administrators are locked on PostgreSQL
- record promotions, invitations, acceptance, activation and demotion
  in the server audit log
- share email validation between organizations and administrators
ryan.mcgee changed target branch from feat/admin-break-glass to main 2026-10-22 14:46:15 +00:00
chore: merge main (squash of #42) into feat/admin-administrators
All checks were successful
Development images / images (push) Successful in 14s
CI / tests (SQLite) (pull_request) Successful in 2m36s
CI / format, compile, credo, dialyzer (pull_request) Successful in 1m43s
CI / dependency audit (pull_request) Successful in 49s
CI / tests (PostgreSQL) (pull_request) Successful in 2m40s
b2accab177
ryan.mcgee deleted branch feat/admin-administrators 2026-10-22 14:51:00 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
White_Ghost_Cyber_Systems/traefik-control!43
No description provided.