Files
terdut-server/internal/api/caller.go
T
Niklas Ye 9029d48584
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Failing after 19s
CI / test (pull_request) Successful in 5m34s
Let an operator authenticate with a seeded key, and reset the schema
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
  "terdut-operator" at every start, so terdut-operator needs no bootstrap
  handshake. An instance-scoped account now acts as owner of every team's
  configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
  is idempotent on it, so automation finds its own team again after a crash
  instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
  escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
  TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
  their own. Existing development databases must be recreated.

Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
  non-admins.
- The access log records the route pattern, so integration keys and ack
  tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
  right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
  concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.

Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-09 14:56:13 +02:00

116 lines
4.4 KiB
Go

package api
import (
"context"
"git.ryuvia.com/niklas/terdut-server/internal/models"
)
// Caller is the one principal type every authorization predicate in this
// package reads from. Before this, a human (ctxUser + ctxTeams) and a
// service account (ctxServiceAccount + a synthetic ctxTeams entry) were two
// parallel, un-unified context representations — every predicate had to
// remember which one(s) it needed to check, and the ones that forgot either
// 403'd a service account that should have been let through (terdut-server#23,
// terdut-operator#3), crashed on an unchecked zero-value user ID (handleMe,
// handleTestNotification), or silently no-op'd (handleDismissOnboarding).
// serveAs and serveAsServiceAccount now both build exactly one Caller and
// store it under one context key; everything else in this file is a read
// of one of its methods.
type Caller struct {
// user is set for a human caller (session cookie or a user's own API
// key), nil for a service account of either scope.
user *models.User
// sa is set for a service-account caller, nil for a human.
sa *serviceAccountPrincipal
// memberships is the caller's real team_members rows for a human, or —
// for a team-scoped service account — the single synthetic owner
// membership serveAsServiceAccount injects (see its own comment for
// why). Always nil for an instance-scoped service account: it acts on
// teams by id, not by belonging to one.
memberships []membership
}
// AsHuman returns the real user behind this caller, or false for a service
// account of either scope. Every handler that needs a real user_id to act
// on behalf of — not just "is this caller sufficiently privileged" — calls
// this and handles the false case explicitly, replacing the unchecked
// userFromContext(ctx) zero-value reads that used to silently misbehave for
// a service-account caller.
func (c Caller) AsHuman() (models.User, bool) {
if c.user == nil {
return models.User{}, false
}
return *c.user, true
}
// IsAdmin is true only for a human system administrator — never for a
// service account, of either scope, under any circumstance. AdminOnly and
// requireSelfOrAdmin key on this and nothing else: user management and
// /api/admin/settings stay human-only forever, by design (SERVICE-ACCOUNTS.md).
func (c Caller) IsAdmin() bool {
return c.user != nil && c.user.IsAdmin
}
// IsInstanceServiceAccount reports whether this caller is specifically an
// instance-scoped service account — never true for a human, including a
// human admin. handleCreateTeam needs exactly this: a human creates a team
// by being a human (and becomes its owner as a side effect), an
// instance-scoped service account creates one with no human owner at all;
// the two paths are not interchangeable, so this predicate must not also
// admit a human admin the way an administrator check would.
func (c Caller) IsInstanceServiceAccount() bool {
return c.sa != nil && c.sa.scope == models.ServiceAccountScopeInstance
}
// Role reports the caller's role in teamID, and whether they belong to it
// at all.
func (c Caller) Role(teamID int64) (string, bool) {
for _, m := range c.memberships {
if m.teamID == teamID {
return m.role, true
}
}
return "", false
}
// TeamIDs lists every team this caller belongs to: a human's real
// memberships, or a team-scoped service account's own single team. Always
// empty for an instance-scoped service account.
func (c Caller) TeamIDs() []int64 {
ids := make([]int64, 0, len(c.memberships))
for _, m := range c.memberships {
ids = append(ids, m.teamID)
}
return ids
}
// ServiceAccountID reports this caller's own service-account id, for the
// "may manage/rotate its own credential" self-check in
// callerMayManageServiceAccount, and for OperatorModeBlock's "any service
// account passes" rule.
func (c Caller) ServiceAccountID() (int64, bool) {
if c.sa == nil {
return 0, false
}
return c.sa.id, true
}
// ServiceAccountName reports this caller's own service-account name, for a
// handler's synchronous response — the same credential it authenticated
// with, already resolved onto the Caller by serveAsServiceAccount, so no
// extra query is needed.
func (c Caller) ServiceAccountName() (string, bool) {
if c.sa == nil {
return "", false
}
return c.sa.name, true
}
func callerFromContext(ctx context.Context) (Caller, bool) {
c, ok := ctx.Value(ctxCaller).(Caller)
return c, ok
}