9da913080f
Every incident-mutation handler read userFromContext(ctx) and wrote the result's .ID into acknowledged_by/incident_events.user_id without checking the ok bool. For a team-scoped service-account caller this returned a zero-value user id, which violated the users(id) FK and 500'd on acknowledge, unacknowledge, resolve, snooze, unsnooze and create-note. handleDeleteNote didn't crash but silently matched zero rows instead (WHERE user_id = 0), so a service account could never delete its own note. Add acknowledged_by_service_account_id (incidents) and service_account_id (incident_events) as nullable FKs to service_accounts(id), parallel to and mutually exclusive with the existing human columns (migration 015, with a CHECK enforcing the exclusion). Route every one of the six handlers plus delete-note through a new callerActorIDs() helper that branches on Caller.AsHuman()/ServiceAccountID() instead of assuming a human, and thread a serviceAccountID parameter through logEvent and the new acknowledgeIncidentAs (acknowledgeIncident itself is untouched: its only other caller, the push-notification Acknowledge button, is always human). Render the new actor distinctly from both a human and "the server acted" in the web UI's incident timeline and facts card. handleIncidentAssign, handleIncidentArchive and handleIncidentUnarchive are deliberately not touched here — they track no actor at all today, for anyone, which is a separate pre-existing gap (follow-up issue to come). Fixes #25
135 lines
5.1 KiB
Go
135 lines
5.1 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"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 MayActAsInstanceAdmin deliberately does.
|
|
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
|
|
}
|
|
|
|
// Identity is a stable, log/audit-facing string distinguishing a human
|
|
// caller from a service account — "user:42" or "service-account:7". Not
|
|
// wired into any database column — incidents.go's acknowledged_by/
|
|
// incident_events.user_id use AsHuman()/ServiceAccountID() directly against
|
|
// the parallel *_service_account_id columns (migration 015) instead, since a
|
|
// column needs the id, not this rendered string. assigned_to stays
|
|
// human-only and out of scope (terdut-server#25's follow-up).
|
|
func (c Caller) Identity() string {
|
|
switch {
|
|
case c.user != nil:
|
|
return fmt.Sprintf("user:%d", c.user.ID)
|
|
case c.sa != nil:
|
|
return fmt.Sprintf("service-account:%d", c.sa.id)
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func callerFromContext(ctx context.Context) (Caller, bool) {
|
|
c, ok := ctx.Value(ctxCaller).(Caller)
|
|
return c, ok
|
|
}
|