From 871274a3a0b8233ac9f8fe0967e33282cbef6840 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Thu, 1 Oct 2026 10:43:09 +0200 Subject: [PATCH] Request: team lookup for service accounts (TEAM-LOOKUP.md) Raised by terdut-operator's TerdutTeam controller (ROADMAP.md Stage 2): an instance-scoped service account has no way to recover a team's id after a 409 on POST /api/teams, unlike the equivalent, already-solved case for service accounts themselves (GET /api/service-accounts?name=). Also corrects a claim in SERVICE-ACCOUNTS.md's own text that doesn't match AdminOnly's actual code -- confirmed against source, not assumed. --- TEAM-LOOKUP.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 TEAM-LOOKUP.md diff --git a/TEAM-LOOKUP.md b/TEAM-LOOKUP.md new file mode 100644 index 0000000..67419cd --- /dev/null +++ b/TEAM-LOOKUP.md @@ -0,0 +1,73 @@ +# Team lookup for service accounts: closing terdut-operator's create-path crash window + +This is a design note for a feature, not an implementation plan — same posture as +`SERVICE-ACCOUNTS.md`, and raised for the same reason: `terdut-operator`'s `TerdutTeam` +controller (ROADMAP.md Stage 2, a separate repo, no shared code) hit a gap this server has +no answer for yet. + +## The problem + +`POST /api/teams` (`handleCreateTeam`, confirmed against `internal/api/teams.go`) lets an +instance-scoped service account create a team — it has its own explicit +`isInstanceServiceAccount(...)` branch alongside the human-user path, not gated by +`AdminOnly`. If that call succeeds server-side but the caller (`TerdutTeam`'s controller) +crashes before persisting the resulting team ID locally, a retry's `POST` 409s on the name's +unique constraint (confirmed: the `isUniqueViolation` branch in the same handler). + +Recovering from that 409 means looking the team up by name, and nothing today permits that +for a service account: + +- `GET /api/teams` (`handleListTeams`) answers "what teams does the *caller* belong to", via + a `team_members` join keyed on `userFromContext`'s `caller.ID` — confirmed against source. + A service account is never a member of anything, so this always returns empty for one, + regardless of what exists. +- `GET /api/admin/teams` (`handleAdminListTeams`) is gated by `AdminOnly`, and `AdminOnly`'s + actual code (`internal/api/middleware.go`) checks only `userFromContext(...).IsAdmin` — no + branch for a service account at all, confirmed against source. This contradicts + `SERVICE-ACCOUNTS.md`'s own text, which claims "an instance-scoped [service account + satisfies] `AdminOnly` for team-creation/listing purposes" — that claim doesn't match this + endpoint's actual, shipped code. (Team *creation* is fine: `handleCreateTeam` isn't behind + `AdminOnly` at all, it has its own check. Only the listing half of that sentence is wrong.) + +This is exactly the shape of gap `SERVICE-ACCOUNTS.md`'s own `GET /api/service-accounts?name=` +closed for service accounts themselves (confirmed: that endpoint's own comment — +"the name lookup is open to any authenticated caller... what lets a service account find its +own account on the 403 that follows a second POST"). Teams never got the equivalent, because +nothing needed it until an operator started creating them unattended. + +## Goals + +- A service-account-accessible way to look up one team by exact name, mirroring + `GET /api/service-accounts?name=` as closely as possible — same shape, same reasoning, + same low sensitivity of what it discloses. +- No change to today's behavior for an empty/no-name request. + +## Proposed shape + +Extend `GET /api/teams` itself, the same way `handleListServiceAccounts` already branches on +a `?name=` query param, rather than adding a new route: + +- `name` unset (today's behavior, unchanged): the caller's own teams, via `team_members`. +- `name=` set: look up that one team by exact name — a one-or-zero-length array, not + an error on no match, mirroring `GET /api/service-accounts?name=`'s own response shape and + status codes exactly. Deliberately **not** gated by `isInstanceServiceAccount` or + `AdminOnly`: a human caller who's already a member sees this same information in their own + team list regardless, and a non-member learning only that a name is taken — not who's in + the team, not any of its data — is the same low-sensitivity disclosure + `GET /api/service-accounts?name=` already accepts for service-account names. + +## What this unblocks + +Directly resolves the crash-window gap in `terdut-operator`'s `TerdutTeam` controller: on a +409 from `POST /api/teams`, `GET /api/teams?name=` — authenticated with the +same instance-scoped credential that just got the 409 — finds the id, and the controller +proceeds as if its own create had returned it directly. The same adopt-on-409 pattern already +proven for service accounts (that repo's `DESIGN.md` §6 point 1, §5's general rule), not a +new one. + +## Suggested sequencing + +Land this before `TerdutTeam`'s create path is implemented — the same reasoning +`SERVICE-ACCOUNTS.md` gave for its own sequencing: writing that code against today's gap as a +"known-temporary workaround" is wasted effort when the fix is this small and this +well-precedented.