Add a system administrator role, and gate account management behind it #10

Merged
niklas merged 3 commits from admin-role into main 2026-09-20 13:10:47 +00:00
Owner

Closes #3. Second step of #1, and a prerequisite for #4 and #5.

Until now every authenticated caller could create and delete users, set anybody's password and mint anybody's API keys — auth.go said so in a comment. Defensible with one operator and a hand-made account; not once people sign themselves up (#7), and not in a multi-tenant install (#4) where the user list stops being "everybody who works here".

The line

admin create user, delete user, grant/revoke the flag
self or admin password, ntfy topic, API keys
anyone signed in list users, and all incident work

Two deliberate choices that differ from the issue's first sketch:

  • Minting your own API key stays self-service. A key carries exactly the rights of the user it belongs to, so issuing one is no more than signing in again. Requiring an admin would mean a responder cannot set up the TUI without somebody else in the room.
  • /api/users stays readable by everybody. The queue's assignment control and the on-call schedule both have to name people, and hiding the roster from the people on it buys nothing.

The self-or-admin endpoints go through requireSelfOrAdmin in the handler rather than sitting behind AdminOnly, because which rule applies depends on the {id} in the path, not on the route.

The migration makes every existing user an administrator

They already hold these powers, so nobody's access changes on upgrade — it names what is already true and leaves demotion as a deliberate act afterwards. Promoting only user 1 would silently strip the others, and could leave an install whose only administrator is an account nobody has a password for.

Keeping an install administrable

Two guards: the last administrator can be neither deleted nor demoted, and nobody can delete or demote themselves — the likelier accident, where the only admin clears their own flag while tidying and locks the door behind them.

Verified

make fmt lint test helm-lint green with -race against Postgres 17. Six new tests in admin_test.go, covering:

  • a non-admin gets 403 on all six previously-open endpoints, including the ones that used to let any user take over any account (set the admin's password, mint a key for the admin, retarget the admin's notifications);
  • that same user keeps their own account — topic, password, keys — and can still list users;
  • incident work is untouched for them;
  • granting the flag turns the powers on and revoking turns them off again;
  • the last-administrator and self-demotion guards both return 409;
  • /api/me reports the flag, which is what #5's admin page will render from.

Not in here

No UI changes — there are no account-management screens yet. models.User now carries is_admin (not omitempty, so a client can tell false from an old server), which is the hook #5 needs.

Closes #3. Second step of #1, and a prerequisite for #4 and #5. Until now every authenticated caller could create and delete users, set anybody's password and mint anybody's API keys — `auth.go` said so in a comment. Defensible with one operator and a hand-made account; not once people sign themselves up (#7), and not in a multi-tenant install (#4) where the user list stops being "everybody who works here". ### The line | | | |---|---| | **admin** | create user, delete user, grant/revoke the flag | | **self or admin** | password, ntfy topic, API keys | | **anyone signed in** | list users, and all incident work | Two deliberate choices that differ from the issue's first sketch: - **Minting your own API key stays self-service.** A key carries exactly the rights of the user it belongs to, so issuing one is no more than signing in again. Requiring an admin would mean a responder cannot set up the TUI without somebody else in the room. - **`/api/users` stays readable by everybody.** The queue's assignment control and the on-call schedule both have to name people, and hiding the roster from the people on it buys nothing. The self-or-admin endpoints go through `requireSelfOrAdmin` in the handler rather than sitting behind `AdminOnly`, because which rule applies depends on the `{id}` in the path, not on the route. ### The migration makes every existing user an administrator They already hold these powers, so **nobody's access changes on upgrade** — it names what is already true and leaves demotion as a deliberate act afterwards. Promoting only user 1 would silently strip the others, and could leave an install whose only administrator is an account nobody has a password for. ### Keeping an install administrable Two guards: the last administrator can be neither deleted nor demoted, and nobody can delete or demote themselves — the likelier accident, where the only admin clears their own flag while tidying and locks the door behind them. ### Verified `make fmt lint test helm-lint` green with `-race` against Postgres 17. Six new tests in `admin_test.go`, covering: - a non-admin gets `403` on all six previously-open endpoints, including the ones that used to let any user take over any account (set the admin's password, mint a key for the admin, retarget the admin's notifications); - that same user keeps their own account — topic, password, keys — and can still list users; - incident work is untouched for them; - granting the flag turns the powers on and revoking turns them off again; - the last-administrator and self-demotion guards both return `409`; - `/api/me` reports the flag, which is what #5's admin page will render from. ### Not in here No UI changes — there are no account-management screens yet. `models.User` now carries `is_admin` (not `omitempty`, so a client can tell `false` from an old server), which is the hook #5 needs.
niklas added 1 commit 2026-09-20 11:20:23 +00:00
Add a system administrator role, and gate account management behind it
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 1m37s
1377d9005b
Until now every authenticated caller could create and delete users, set
anybody's password and mint anybody's API keys -- auth.go said so in a
comment. Defensible with one operator and a hand-made account; not once
people sign themselves up (#7), and not in a multi-tenant install (#4),
where the user list is no longer everybody who works here.

users.is_admin is the flag. AdminOnly gates creating and deleting users
and granting the flag itself. The endpoints that are self-service for
your own account and administration for somebody else's -- password,
ntfy topic, API keys -- go through requireSelfOrAdmin instead, because
which rule applies depends on the {id} in the path rather than on the
route.

Minting your own API key stays self-service. A key carries exactly the
rights of the user it belongs to, so issuing one is no more than signing
in again; requiring an admin for it would mean a responder cannot set up
the TUI without somebody else in the room.

/api/users stays readable by everybody. The queue's assignment control
and the on-call schedule both have to name people, and hiding the roster
from the people on it buys nothing.

THE MIGRATION MAKES EVERY EXISTING USER AN ADMINISTRATOR. They already
hold these powers, so nobody's access changes on upgrade: it names what
is already true and leaves demotion as a deliberate act. Promoting only
user 1 would silently strip the others, and could leave an install whose
only administrator is an account nobody has a password for.

Two guards keep an install administrable: the last administrator can be
neither deleted nor demoted, and nobody can delete or demote themselves
-- the likelier accident, where the only admin clears their own flag
while tidying up and locks the door behind them.

No UI changes: there are no account-management screens yet. models.User
carries is_admin (not omitempty, so a client can tell false from an old
server), which is what #5's admin page will render from.
niklas added 2 commits 2026-09-20 11:39:35 +00:00
Scope everything to a team, and route alerts by integration key
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 1m49s
a4fbd60441
The core of #4, and what #1 is for: terdut stops being one shared space.
A team owns its incidents, alerts, schedule and integrations; a user sees
exactly the teams they are in. Everything that existed moves into one
Default team and every existing user becomes an owner of it, so the
upgrade is a no-op for the people using it.

Ingestion is the load-bearing half. An alert arrives on a team's
integration key, and the key is both the credential and the routing: it
says that the sender may post, and which team the alerts belong to. That
also closes the unauthenticated webhook -- the old path stays for one
release, deprecated and routed to the oldest team, so an upgrade does not
stop delivering while somebody edits the Alertmanager config.

Scoping is enforced in as few places as possible, because the failure
mode is silent. serveAs loads the caller's memberships once; list queries
carry `team_id = ANY(...)`; and every incident route goes through
incidentIDParam, which now parses the id AND checks the team in the same
call, so a new handler cannot remember the first half and forget the
second. Anything in another team is 404, never 403: whether an incident
exists is that team's business.

Two bugs this found, both of which would have been silent:

  * upsertAlerts decided "is this a new occurrence" by looking up the
    fingerprint alone. Across teams that made team B's first alert look
    like a re-send of team A's, so it opened no incident at all. The
    lookups are keyed on (team_id, fingerprint) now, as the index is.

  * Every uniqueness rule was written for one tenant. Two teams watching
    two clusters legitimately see the same fingerprint, the same
    groupKey, and want somebody on call on the same day; all three
    constraints move to include team_id.

Roles inside a team are separate from the system administrator flag: an
owner configures the team, a member works its incidents, and an admin is
NOT implicitly in every team -- administration is about accounts, not
about reading other people's incidents. An admin can still repair a team
whose owner has left, which is why requireTeamOwner lets them through.

A shift can only be given to somebody in the team. Paging a person who
cannot open the incident is worse than paging nobody.

The UI is updated only as far as keeping it working: it loads the
viewer's teams with the session and uses the first one, since nobody has
a second yet. "On call now" shows every team the viewer is in, named only
when there is more than one, so the common case reads exactly as before.
The team switcher, badges and per-team settings pages are the next step.

Breaking for API clients: the schedule endpoints moved under the team,
and /api/schedule/current returns an array rather than an object or a
404. terdut-tui will need a version for that.

Per-team dead-man configuration is deliberately not here. A heartbeat's
incident already opens in the team whose key received it, which is the
part that matters for isolation; moving the matchers out of env into
per-team rows is a change to how deadman.go is configured rather than to
who sees what.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
Merge pull request 'Scope everything to a team, and route alerts by integration key' (#11) from teams into admin-role
CI / test (pull_request) Successful in 4s
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 11s
2de5c8412d
Reviewed-on: #11
niklas merged commit 43f69272f0 into main 2026-09-20 13:10:47 +00:00
Sign in to join this conversation.