Files
terdut-server/internal/config/config.go
T
Niklas Ye 5b4683febf Let each team name its own OIDC group, not a global mapping
Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.

Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.

This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.

TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
2026-09-27 11:43:57 +02:00

252 lines
9.1 KiB
Go

package config
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
)
type Config struct {
Addr string
// DSN is the Postgres connection string, e.g.
// postgres://terdut:secret@host:5432/terdut?sslmode=require. Required:
// unlike the SQLite path it replaced there is no sensible default, and a
// server that silently came up against the wrong database would be worse
// than one that refuses to start.
DSN string
ArchiveAfter time.Duration
// StaleAfter is how long a firing alert may go without a refreshing webhook
// before the sweeper treats it as resolved. It must exceed Alertmanager's
// repeat_interval (default 4h), which is what refreshes the alert.
StaleAfter time.Duration
// DeadmanMatchers selects the alerts that are heartbeats rather than
// problems: receiving one opens no incident, and the absence of one does.
//
// ";" separates matchers, "," the label conditions within one, "=" is exact
// equality — `alertname=Watchdog,cluster=prod; alertname=Heartbeat`. Every
// matcher must name an alertname. See api.ParseDeadmanConfig.
DeadmanMatchers string
// DeadmanTimeout is how long a heartbeat may go unheard before its switch is
// declared dead. It must be *shorter* than the Alertmanager repeat_interval
// of the route carrying the heartbeat — the opposite of StaleAfter, and the
// reason a dead man's switch usually wants a route of its own. Zero disables
// dead man's switch handling entirely.
DeadmanTimeout time.Duration
// DeadmanSeverity is the severity a dead man's switch incident opens at.
// These incidents have no member alerts to derive one from.
DeadmanSeverity string
// NtfyURL is the ntfy server push notifications are published to. Empty
// disables notifications entirely.
NtfyURL string
// NtfyToken is an optional bearer token for an access-controlled ntfy.
NtfyToken string
// NtfyFallbackTopic receives incidents that open with nobody on call.
NtfyFallbackTopic string
// PublicURL is the base URL a phone uses to reach this server, used for the
// link and the Acknowledge button inside a notification. Without it
// notifications carry neither.
PublicURL string
// NotifyRepeat is how long an incident may sit unacknowledged before it is
// notified again. Zero disables reminders.
NotifyRepeat time.Duration
// DisablePasswordLogin refuses signing in, or signing up, with a password.
// It is how an install moves to SSO only, and turning it back off is the way
// in when the identity provider is down. Stated negatively so that the zero
// Config, which is what a test or a new caller builds, keeps passwords working.
DisablePasswordLogin bool
// OIDC configures single sign-on. The zero value, with no Issuer, is off.
OIDC OIDC
}
// OIDC is the single sign-on configuration. Groups from the provider decide
// who may sign in, which teams they belong to, and whether they administer the
// install, in the manner of Grafana's org and role mapping.
type OIDC struct {
// Issuer is the provider's issuer URL. Discovery is fetched from
// <Issuer>/.well-known/openid-configuration. For Authentik this is the
// application's issuer, e.g. https://auth.example.com/application/o/terdut/.
// Empty turns single sign-on off.
Issuer string
ClientID string
ClientSecret string
// Name is what the sign-in button calls the provider.
Name string
// Scopes to request. The groups claim normally needs "profile" on Authentik.
Scopes []string
// UsernameClaim, EmailClaim and GroupsClaim name the ID token claims read.
UsernameClaim string
EmailClaim string
GroupsClaim string
// TrustEmail links a sign-in to an existing local user by email even when the
// provider does not vouch that the address is verified. Authentik reports
// email_verified false unless told otherwise, and an install that runs its
// own provider has already decided that its addresses can be trusted.
TrustEmail bool
// AllowedGroups gates sign-in: somebody in none of them is refused, however
// well the provider authenticated them. Empty admits everybody the provider
// authenticates, and access control is left to the provider.
AllowedGroups []string
// AdminGroup grants the system administrator flag while the user is in it.
AdminGroup string
// SessionMaxAge is the hard ceiling on a session made by an SSO login. The
// login is the only moment groups are re-read, so this is how long a change
// in the provider may take to reach terdut.
SessionMaxAge time.Duration
}
// Enabled reports whether single sign-on is configured.
func (o OIDC) Enabled() bool { return o.Issuer != "" }
func Load() Config {
addr := os.Getenv("TERDUT_ADDR")
if addr == "" {
addr = ":8080"
}
deadmanMatchers := os.Getenv("TERDUT_DEADMAN_MATCHERS")
if deadmanMatchers == "" {
deadmanMatchers = "alertname=Watchdog"
}
deadmanSeverity := os.Getenv("TERDUT_DEADMAN_SEVERITY")
if deadmanSeverity == "" {
deadmanSeverity = "critical"
}
return Config{
Addr: addr,
DSN: os.Getenv("TERDUT_DB_DSN"),
ArchiveAfter: duration("TERDUT_ARCHIVE_AFTER", 7*24*time.Hour),
StaleAfter: duration("TERDUT_STALE_AFTER", 6*time.Hour),
DeadmanMatchers: deadmanMatchers,
DeadmanTimeout: duration("TERDUT_DEADMAN_TIMEOUT", 15*time.Minute),
DeadmanSeverity: deadmanSeverity,
NtfyURL: os.Getenv("TERDUT_NTFY_URL"),
NtfyToken: os.Getenv("TERDUT_NTFY_TOKEN"),
NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"),
PublicURL: os.Getenv("TERDUT_PUBLIC_URL"),
NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute),
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
OIDC: loadOIDC(),
}
}
func loadOIDC() OIDC {
o := OIDC{
Issuer: strings.TrimSpace(os.Getenv("TERDUT_OIDC_ISSUER")),
ClientID: os.Getenv("TERDUT_OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("TERDUT_OIDC_CLIENT_SECRET"),
Name: str("TERDUT_OIDC_NAME", "SSO"),
Scopes: list("TERDUT_OIDC_SCOPES", "openid profile email"),
UsernameClaim: str("TERDUT_OIDC_USERNAME_CLAIM", "preferred_username"),
EmailClaim: str("TERDUT_OIDC_EMAIL_CLAIM", "email"),
GroupsClaim: str("TERDUT_OIDC_GROUPS_CLAIM", "groups"),
TrustEmail: boolean("TERDUT_OIDC_TRUST_EMAIL", false),
AllowedGroups: list("TERDUT_OIDC_ALLOWED_GROUPS", ""),
AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"),
SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour),
}
return o
}
// Validate reports a configuration the server should refuse to start with.
// Single sign-on is the only part that can be inconsistent: a half-configured
// provider would come up and then fail every login, which is harder to notice
// than not starting.
func (c Config) Validate() error {
o := c.OIDC
if !o.Enabled() {
if c.DisablePasswordLogin {
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
}
if o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not")
}
return nil
}
if u, err := url.Parse(o.Issuer); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("TERDUT_OIDC_ISSUER %q is not a URL", o.Issuer)
}
if o.ClientID == "" || o.ClientSecret == "" {
return errors.New("TERDUT_OIDC_CLIENT_ID and TERDUT_OIDC_CLIENT_SECRET are required with TERDUT_OIDC_ISSUER")
}
if c.PublicURL == "" {
return errors.New("TERDUT_PUBLIC_URL is required with TERDUT_OIDC_ISSUER: it is the base of the redirect URI")
}
if o.SessionMaxAge <= 0 {
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
}
// Team grants are no longer visible here: they live on each team's own
// oidc_member_group/oidc_owner_group columns, set by that team's owner, not
// in config Validate can see at startup. The one thing left to guard against
// is an install nobody can administer at all.
if c.DisablePasswordLogin && o.AdminGroup == "" {
return errors.New("TERDUT_PASSWORD_LOGIN=false with no TERDUT_OIDC_ADMIN_GROUP leaves nobody able to administer the install")
}
return nil
}
func str(env, def string) string {
if s := strings.TrimSpace(os.Getenv(env)); s != "" {
return s
}
return def
}
// list reads a comma- or space-separated env var.
func list(env, def string) []string {
s := os.Getenv(env)
if strings.TrimSpace(s) == "" {
s = def
}
return strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' })
}
// boolean reads a true/false env var. An unrecognised value takes the default,
// so the two flags read this way (password login on, trusting email off) both
// fail towards the cautious setting.
func boolean(env string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(env))) {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
}
return def
}
// duration reads a time.ParseDuration-formatted env var. An unset or
// unparseable value falls back to def rather than failing startup: a typo in one
// tuning knob should not take the server down.
func duration(env string, def time.Duration) time.Duration {
if s := os.Getenv(env); s != "" {
if d, err := time.ParseDuration(s); err == nil {
return d
}
}
return def
}