9029d48584
- 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
105 lines
3.1 KiB
Go
105 lines
3.1 KiB
Go
// Package oidc signs users in through an OpenID Connect provider and turns the
|
|
// groups it reports into the access terdut grants.
|
|
//
|
|
// The package knows nothing about the database or HTTP handlers: Grants is a
|
|
// pure function of configuration and groups, and Provider is the protocol. The
|
|
// api package joins them to users, teams and sessions.
|
|
package oidc
|
|
|
|
import (
|
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
|
)
|
|
|
|
// Role names match models.RoleOwner and RoleMember. They are restated here so
|
|
// the package stays free of the models import; config.Validate has already
|
|
// refused anything else.
|
|
const (
|
|
roleOwner = "owner"
|
|
roleMember = "member"
|
|
)
|
|
|
|
// Grants is the account-wide access a set of groups confers. Team access is a
|
|
// separate question — see TeamGroup and ComputeTeamGrants — because it is
|
|
// configured per team in the database, not in this package's cfg.
|
|
type Grants struct {
|
|
// Admitted is false when AllowedGroups is set and the user is in none of
|
|
// them. Nothing else in the struct means anything then.
|
|
Admitted bool
|
|
|
|
// Admin is whether the user is in the admin group.
|
|
Admin bool
|
|
}
|
|
|
|
// ComputeGrants evaluates the account-wide configuration against groups.
|
|
func ComputeGrants(cfg config.OIDC, groups []string) Grants {
|
|
in := make(map[string]bool, len(groups))
|
|
for _, g := range groups {
|
|
in[g] = true
|
|
}
|
|
|
|
var g Grants
|
|
|
|
g.Admitted = len(cfg.AllowedGroups) == 0
|
|
for _, allowed := range cfg.AllowedGroups {
|
|
if in[allowed] {
|
|
g.Admitted = true
|
|
break
|
|
}
|
|
}
|
|
if !g.Admitted {
|
|
return g
|
|
}
|
|
|
|
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
|
|
return g
|
|
}
|
|
|
|
// TeamGroup is one team's own OIDC binding: which group, if any, grants
|
|
// member access to it and which grants owner access, as read from
|
|
// teams.oidc_member_group / teams.oidc_owner_group.
|
|
type TeamGroup struct {
|
|
TeamID int64
|
|
MemberGroup string // "" means no group grants member access here.
|
|
OwnerGroup string // "" means no group grants owner access here.
|
|
}
|
|
|
|
// ComputeTeamGrants evaluates every team's own group binding against groups,
|
|
// and returns the role each team grants, keyed by team ID. A team absent from
|
|
// the result is not granted at all. Where a team's member and owner groups
|
|
// both match, the owner group wins — the same "highest role wins" rule that
|
|
// applied across the old global mapping list applies here across one team's
|
|
// two fields, so belonging to both groups makes somebody an owner rather than
|
|
// whichever field happened to be checked last.
|
|
func ComputeTeamGrants(teamGroups []TeamGroup, groups []string) map[int64]string {
|
|
in := make(map[string]bool, len(groups))
|
|
for _, g := range groups {
|
|
in[g] = true
|
|
}
|
|
|
|
out := map[int64]string{}
|
|
for _, tg := range teamGroups {
|
|
role := ""
|
|
if tg.MemberGroup != "" && in[tg.MemberGroup] {
|
|
role = roleMember
|
|
}
|
|
if tg.OwnerGroup != "" && in[tg.OwnerGroup] && rank(roleOwner) > rank(role) {
|
|
role = roleOwner
|
|
}
|
|
if role != "" {
|
|
out[tg.TeamID] = role
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rank orders roles; an unknown or absent role ranks lowest.
|
|
func rank(role string) int {
|
|
switch role {
|
|
case roleOwner:
|
|
return 2
|
|
case roleMember:
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|