a27ff49171
terdut can now sign people in through any OIDC provider (written against Authentik), and let groups at the provider decide who may sign in, which teams they belong to and whether they administer the install. Password login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off, and is refused at startup unless SSO is configured. With no TERDUT_OIDC_* setting nothing changes, so every existing install behaves as before. Identity is (issuer, subject), never email or username: those are mutable at the provider and a recycled address must not inherit an account. An existing user is linked by email only when the provider marks it verified, or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs. Group grants are marked source='oidc' on team_members and users, and the sync changes only those rows. Hand-made memberships and administrators are left alone, and the sync bypasses the last-owner and last-admin guards because the provider is the source of truth for what it grants. Editing managed access by hand is refused with 409, since the next sign-in would undo it. The web UI badges it as SSO and disables the controls. Groups are read only at sign-in, so an SSO session carries a hard ceiling (sessions.max_expires_at, 12h by default) that sliding never extends. There is no refresh token, which means API keys of somebody removed at the provider stay valid until an administrator disables the user. That is accepted and documented, not fixed. A client with no browser, the TUI over SSH, signs in with a device code run by terdut itself (POST /api/oidc/device and /device/token), so the terminal never talks to the provider and ends up with the ordinary terdut_session cookie. Only a browser session can approve a code; an API key cannot. /device?code= sends a signed-out visitor through sign-in and back, which is what oidc_logins.next is for. oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile builds on 1.25. Migrations 011 and 012 add tables and defaulted columns only.
280 lines
9.9 KiB
Go
280 lines
9.9 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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
|
|
|
|
// GroupMappings grants team roles. A user in Group gets Role in Team.
|
|
GroupMappings []GroupMapping
|
|
|
|
// 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
|
|
|
|
// parseErr is a malformed TERDUT_OIDC_GROUP_MAPPINGS, reported by Validate:
|
|
// Load cannot fail, and a mapping that was silently dropped would grant
|
|
// less access than the operator wrote down.
|
|
parseErr error
|
|
}
|
|
|
|
// GroupMapping grants Role in Team to members of Group.
|
|
type GroupMapping struct {
|
|
Group string `json:"group"`
|
|
Team string `json:"team"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
if raw := strings.TrimSpace(os.Getenv("TERDUT_OIDC_GROUP_MAPPINGS")); raw != "" {
|
|
if err := json.Unmarshal([]byte(raw), &o.GroupMappings); err != nil {
|
|
o.parseErr = fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS: %w", err)
|
|
}
|
|
}
|
|
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.parseErr != nil {
|
|
return o.parseErr
|
|
}
|
|
if !o.Enabled() {
|
|
if c.DisablePasswordLogin {
|
|
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
|
|
}
|
|
if len(o.GroupMappings) > 0 || 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")
|
|
}
|
|
for i, m := range o.GroupMappings {
|
|
if m.Group == "" || m.Team == "" {
|
|
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: group and team are required", i)
|
|
}
|
|
if m.Role != "owner" && m.Role != "member" {
|
|
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: role must be owner or member, got %q", i, m.Role)
|
|
}
|
|
}
|
|
if c.DisablePasswordLogin && len(o.GroupMappings) == 0 && o.AdminGroup == "" {
|
|
return errors.New("TERDUT_PASSWORD_LOGIN=false with no OIDC group grants leaves nobody able to do anything")
|
|
}
|
|
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
|
|
}
|