Files
terdut-server/TEAM-LOOKUP.md
T
Niklas Ye 871274a3a0 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.
2026-10-01 10:43:09 +02:00

4.3 KiB

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=<value> 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=<the same 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.