internal/api: unify human/service-account authz into one Caller type #24

Merged
niklas merged 1 commits from unify-caller-authz into main 2026-10-02 20:06:38 +00:00
Owner

Redesigns the authorization boundary between a human caller and a service account, instead of patching around it a third time. Closes #23 (by reframing it — see below) and niklas/terdut-operator#3.

Why

ctxUser/ctxTeams (human) and ctxServiceAccount (+ a synthetic ctxTeams entry, service account) were two parallel, un-unified context representations. Every authz predicate had to remember which one(s) it needed to check, and every place that forgot either wrongly 403'd a service account (#23, niklas/terdut-operator#3), crashed on an unchecked zero-value user id, or silently no-op'd. SERVICE-ACCOUNTS.md and TEAM-LOOKUP.md had already each independently caught one instance of this; auditing the whole package found several more.

What changed

New internal/api/caller.go collapses both representations into one Caller, stored under one ctxCaller key by serveAs/serveAsServiceAccount. Every existing predicate (userFromContext, callerTeamIDs, callerRole, callerIsAdmin, isInstanceServiceAccount, AdminOnly, requireSelfOrAdmin, requireTeamOwner, OperatorModeBlock) now reads through it, with identical behavior for every untouched call site (alerts.go, incidents.go, schedule.go, stats.go, etc.) — confirmed by the full existing suite passing unchanged.

Four real fixes land alongside the restructuring:

  1. callerMayManageServiceAccount gains the one load-bearing branch this PR exists for: an instance-scoped service account may now manage (mint/revoke a key on) any team-scoped account, not only a human admin, that team's own human owner, or the account itself. handleCreateServiceAccount already let an instance-scoped caller create one for any team; adopting/rotating one it didn't just create in the same call — terdut-operator's own documented crash-window recovery — had no equivalent permission and 403'd forever. Closes niklas/terdut-operator#3.
  2. A real bug found live while verifying this change: handleCreateInvite wrote a service-account caller's zero-value user id straight into invites.created_by (nullable, but never passed as nil) — a foreign-key-violation 500, not success, for any team-scoped service account minting an invite. Fixed the same way handleCreateServiceAccount already handles the analogous case.
  3. handleMe and handleTestNotification 500'd for a service-account caller; handleDismissOnboarding silently no-op'd. All three now return an explicit 403 ("this endpoint is for human accounts only").
  4. Ratifies, rather than narrows further, two capabilities a team-scoped service account already had by construction and this repo's own docs once called "a gap acknowledged rather than closed": owner-equivalent reach over team membership/invites, and minting another service account for its own team. terdut-operator's new TerdutTeam invite-minting feature (follow-up PR) depends on the first one.

AdminOnly/requireSelfOrAdmin are unchanged in effect — still human-only, forever, for every scope of service account (TestAdminOnly_RefusesEveryServiceAccountScope). #23's named routes (POST /api/users, PUT /api/admin/settings) were never the right thing to widen; its real fix is entirely on the terdut-operator side (a team-scoped credential is already owner-equivalent for minting a team invite, and invite redemption bypasses signup_mode entirely — it just never grew a feature to use either fact). Recorded in SERVICE-ACCOUNTS.md's "What this unblocks"; closing #23 once that feature ships.

SERVICE-ACCOUNTS.md amended in place (its own established convention) to describe the as-built Caller model and correct its own aspirational claim about AdminOnly that TEAM-LOOKUP.md had already flagged as not matching shipped code.

Explicitly out of scope

A separate, real bug the same audit found: every incident-mutation route (acknowledge/resolve/assign/snooze/archive/notes) also writes a service account's zero-value user id into acknowledged_by/assigned_to/incident_events.user_id — a 500, same shape. Fixing that needs a schema migration (an actor-attribution column distinct from user_id) and is real feature work (can a service account act on incidents on a team's behalf at all), not an authorization bug. Will file separately.

Testing

make fmt lint test green, including new coverage:

  • TestServiceAccount_InstanceScopeAdoptsAnExistingTeamScopedAccountsKey — the #operator-3 regression test.
  • TestServiceAccount_TeamScopeCanMintAnotherAccountForItsOwnTeam
  • TestServiceAccount_TeamScopeManagesItsOwnInvites
  • TestSignup_InviteCreatedByAServiceAccountLeavesCreatedByNull
  • TestServiceAccount_HumanOnlyEndpointsRefuseExplicitly
  • TestAdminOnly_RefusesEveryServiceAccountScope

Every existing test (admin_test.go, settings_test.go, TestOperatorMode_*, TestListTeamsByName_*, the full incident/alert/schedule/stats suites) passes unchanged, confirming the refactor didn't perturb existing behavior.

Redesigns the authorization boundary between a human caller and a service account, instead of patching around it a third time. Closes #23 (by reframing it — see below) and `niklas/terdut-operator#3`. ## Why `ctxUser`/`ctxTeams` (human) and `ctxServiceAccount` (+ a synthetic `ctxTeams` entry, service account) were two parallel, un-unified context representations. Every authz predicate had to remember which one(s) it needed to check, and every place that forgot either wrongly 403'd a service account (#23, `niklas/terdut-operator#3`), crashed on an unchecked zero-value user id, or silently no-op'd. `SERVICE-ACCOUNTS.md` and `TEAM-LOOKUP.md` had already each independently caught one instance of this; auditing the whole package found several more. ## What changed New `internal/api/caller.go` collapses both representations into one `Caller`, stored under one `ctxCaller` key by `serveAs`/`serveAsServiceAccount`. Every existing predicate (`userFromContext`, `callerTeamIDs`, `callerRole`, `callerIsAdmin`, `isInstanceServiceAccount`, `AdminOnly`, `requireSelfOrAdmin`, `requireTeamOwner`, `OperatorModeBlock`) now reads through it, with **identical behavior for every untouched call site** (`alerts.go`, `incidents.go`, `schedule.go`, `stats.go`, etc.) — confirmed by the full existing suite passing unchanged. Four real fixes land alongside the restructuring: 1. **`callerMayManageServiceAccount` gains the one load-bearing branch this PR exists for**: an instance-scoped service account may now manage (mint/revoke a key on) *any* team-scoped account, not only a human admin, that team's own human owner, or the account itself. `handleCreateServiceAccount` already let an instance-scoped caller *create* one for any team; adopting/rotating one it didn't just create in the same call — `terdut-operator`'s own documented crash-window recovery — had no equivalent permission and 403'd forever. **Closes `niklas/terdut-operator#3`.** 2. **A real bug found live while verifying this change**: `handleCreateInvite` wrote a service-account caller's zero-value user id straight into `invites.created_by` (nullable, but never passed as `nil`) — a foreign-key-violation 500, not success, for any team-scoped service account minting an invite. Fixed the same way `handleCreateServiceAccount` already handles the analogous case. 3. `handleMe` and `handleTestNotification` 500'd for a service-account caller; `handleDismissOnboarding` silently no-op'd. All three now return an explicit 403 ("this endpoint is for human accounts only"). 4. **Ratifies, rather than narrows further**, two capabilities a team-scoped service account already had by construction and this repo's own docs once called "a gap acknowledged rather than closed": owner-equivalent reach over team membership/invites, and minting another service account for its own team. `terdut-operator`'s new `TerdutTeam` invite-minting feature (follow-up PR) depends on the first one. **`AdminOnly`/`requireSelfOrAdmin` are unchanged in effect** — still human-only, forever, for every scope of service account (`TestAdminOnly_RefusesEveryServiceAccountScope`). #23's named routes (`POST /api/users`, `PUT /api/admin/settings`) were never the right thing to widen; its real fix is entirely on the `terdut-operator` side (a team-scoped credential is already owner-equivalent for minting a team invite, and invite redemption bypasses `signup_mode` entirely — it just never grew a feature to use either fact). Recorded in `SERVICE-ACCOUNTS.md`'s "What this unblocks"; closing #23 once that feature ships. `SERVICE-ACCOUNTS.md` amended in place (its own established convention) to describe the as-built `Caller` model and correct its own aspirational claim about `AdminOnly` that `TEAM-LOOKUP.md` had already flagged as not matching shipped code. ## Explicitly out of scope A separate, real bug the same audit found: every incident-mutation route (acknowledge/resolve/assign/snooze/archive/notes) also writes a service account's zero-value user id into `acknowledged_by`/`assigned_to`/`incident_events.user_id` — a 500, same shape. Fixing that needs a schema migration (an actor-attribution column distinct from `user_id`) and is real feature work (can a service account act on incidents on a team's behalf at all), not an authorization bug. Will file separately. ## Testing `make fmt lint test` green, including new coverage: - `TestServiceAccount_InstanceScopeAdoptsAnExistingTeamScopedAccountsKey` — the #operator-3 regression test. - `TestServiceAccount_TeamScopeCanMintAnotherAccountForItsOwnTeam` - `TestServiceAccount_TeamScopeManagesItsOwnInvites` - `TestSignup_InviteCreatedByAServiceAccountLeavesCreatedByNull` - `TestServiceAccount_HumanOnlyEndpointsRefuseExplicitly` - `TestAdminOnly_RefusesEveryServiceAccountScope` Every existing test (`admin_test.go`, `settings_test.go`, `TestOperatorMode_*`, `TestListTeamsByName_*`, the full incident/alert/schedule/stats suites) passes unchanged, confirming the refactor didn't perturb existing behavior.
niklas added 1 commit 2026-10-02 19:53:16 +00:00
internal/api: unify human/service-account authz into one Caller type
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 15s
CI / test (pull_request) Successful in 5m21s
774fdfcaa8
ctxUser/ctxTeams (human) and ctxServiceAccount (+ a synthetic ctxTeams
entry, service account) used to be two parallel, un-unified context
representations -- every authz predicate had to remember which one(s) it
needed, and every place that forgot either wrongly 403'd a service account
(terdut-server#23, terdut-operator#3), crashed on an unchecked zero-value
user id, or silently no-op'd. New internal/api/caller.go collapses both
into one Caller, stored under one ctxCaller key by serveAs/serveAsServiceAccount;
every existing predicate (userFromContext, callerTeamIDs, callerRole,
callerIsAdmin, isInstanceServiceAccount, AdminOnly, requireSelfOrAdmin,
requireTeamOwner, OperatorModeBlock) now reads through it, with identical
behavior for every untouched call site (alerts.go, incidents.go,
schedule.go, stats.go, etc.) -- confirmed by the full existing suite
passing unchanged.

Four real fixes land alongside the refactor, not just the restructuring:

1. callerMayManageServiceAccount gains the one load-bearing branch this
   exists for: an instance-scoped service account may now manage (mint or
   revoke a key on) any team-scoped account, not only a human admin, that
   team's human owner, or the account itself. handleCreateServiceAccount
   already let an instance-scoped caller *create* a team-scoped account for
   any team; adopting or rotating one it didn't just create in the same
   call -- terdut-operator's own documented crash-window recovery -- had no
   equivalent permission and 403'd forever. Closes terdut-operator#3.

2. handleCreateInvite wrote a service-account caller's zero-value user id
   straight into invites.created_by (nullable, but never passed as nil),
   which foreign-key-violates against users(id) -- a 500, not success, for
   any team-scoped service account minting an invite. Fixed the same way
   handleCreateServiceAccount already handles the analogous case. Found
   live while verifying this change, not filed separately since it's fixed
   in the same place it was found.

3. handleMe and handleTestNotification 500'd for a service-account caller
   (fetchUser/the ntfy_topic lookup against a zero-value user id that
   matches no row); handleDismissOnboarding silently no-op'd (UPDATE ...
   WHERE id = 0). All three now call Caller.AsHuman() and return an
   explicit 403 ("this endpoint is for human accounts only").

4. Ratifies, rather than further narrows, two capabilities a team-scoped
   service account already had by construction and this document's own
   text once called "a gap acknowledged rather than closed": owner-equivalent
   reach over membership/invites, and minting another service account for
   its own team. terdut-operator's new TerdutTeam invite-minting feature is
   about to depend on the first one, so this makes it documented, tested,
   intentional behavior instead of an accident nobody was supposed to rely
   on.

AdminOnly/requireSelfOrAdmin are unchanged in effect: still human-only,
forever, for every scope of service account -- confirmed by
TestAdminOnly_RefusesEveryServiceAccountScope. terdut-server#23's named
routes (POST /api/users, PUT /api/admin/settings) were never the right
thing to widen; its real fix is the terdut-operator invite feature,
recorded in SERVICE-ACCOUNTS.md's "What this unblocks" and closing that
issue once it ships.

SERVICE-ACCOUNTS.md amended in place (not a new file, its own established
convention) to describe the as-built Caller model, correct its own
aspirational claim about AdminOnly that TEAM-LOOKUP.md had already flagged
as not matching shipped code, and record all of the above.
niklas merged commit f45dc2f925 into main 2026-10-02 20:06:38 +00:00
niklas deleted branch unify-caller-authz 2026-10-02 20:06:38 +00:00
Sign in to join this conversation.