5b4683febf
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.
252 lines
9.1 KiB
Go
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
|
|
}
|