This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Environment string
|
||||
|
||||
const (
|
||||
Development Environment = "development"
|
||||
Test Environment = "test"
|
||||
Production Environment = "production"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment Environment
|
||||
Timezone string
|
||||
DefaultLocale string
|
||||
LogLevel string
|
||||
PublicURL string
|
||||
DatabaseURL string
|
||||
PrometheusURL string
|
||||
PrometheusTimeout time.Duration
|
||||
UnraidURL string
|
||||
UnraidAPIToken string
|
||||
AuthMode string
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCRedirectURL string
|
||||
OIDCGroupsClaim string
|
||||
OIDCRoleMapping map[string]string
|
||||
SessionIdleTTL time.Duration
|
||||
SessionAbsoluteTTL time.Duration
|
||||
BreakGlassEnabled bool
|
||||
BackupDirectory string
|
||||
BackupRetention int
|
||||
// ContainerSourceID is the data_sources UUID the worker attributes container
|
||||
// discovery to. Discovery stays disabled until a source is registered, so no
|
||||
// inventory is ever written against an unknown origin.
|
||||
ContainerSourceID string
|
||||
// ProbeAllowedNetworks are the private/loopback CIDRs service probes may
|
||||
// reach. The probe network policy blocks private space unless it is
|
||||
// explicitly allowlisted here; link-local, multicast and cloud metadata
|
||||
// addresses stay blocked regardless.
|
||||
ProbeAllowedNetworks []string
|
||||
NotificationWebhookURL string
|
||||
NotificationWebhookToken string
|
||||
NotificationWebhookTimeout time.Duration
|
||||
}
|
||||
|
||||
type ValidationError struct {
|
||||
Fields []string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return "invalid configuration: " + strings.Join(e.Fields, "; ")
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
return LoadFrom(os.LookupEnv)
|
||||
}
|
||||
|
||||
func LoadFrom(lookup func(string) (string, bool)) (Config, error) {
|
||||
config, err := parseFrom(lookup)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return config, Validate(config)
|
||||
}
|
||||
|
||||
// LoadWorker loads only the settings consumed by the background worker. API
|
||||
// authentication credentials are intentionally not part of that container's
|
||||
// privilege boundary.
|
||||
func LoadWorker() (Config, error) {
|
||||
return LoadWorkerFrom(os.LookupEnv)
|
||||
}
|
||||
|
||||
func LoadWorkerFrom(lookup func(string) (string, bool)) (Config, error) {
|
||||
config, err := parseFrom(lookup)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return config, ValidateWorker(config)
|
||||
}
|
||||
|
||||
func parseFrom(lookup func(string) (string, bool)) (Config, error) {
|
||||
get := func(key, fallback string) string {
|
||||
if value, ok := lookup(key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
config := Config{
|
||||
Environment: Environment(get("PULSE_ENV", string(Development))),
|
||||
Timezone: get("PULSE_TIMEZONE", "Europe/Brussels"),
|
||||
DefaultLocale: get("PULSE_DEFAULT_LOCALE", "nl-BE"),
|
||||
LogLevel: get("PULSE_LOG_LEVEL", "info"),
|
||||
PublicURL: get("PULSE_PUBLIC_URL", ""),
|
||||
DatabaseURL: get("PULSE_DATABASE_URL", ""),
|
||||
PrometheusURL: get("PULSE_PROMETHEUS_URL", ""),
|
||||
UnraidURL: get("PULSE_UNRAID_URL", ""),
|
||||
UnraidAPIToken: get("PULSE_UNRAID_API_TOKEN", ""),
|
||||
AuthMode: get("PULSE_AUTH_MODE", "oidc"),
|
||||
OIDCIssuer: get("PULSE_OIDC_ISSUER", ""),
|
||||
OIDCClientID: get("PULSE_OIDC_CLIENT_ID", ""),
|
||||
OIDCClientSecret: get("PULSE_OIDC_CLIENT_SECRET", ""),
|
||||
OIDCRedirectURL: get("PULSE_OIDC_REDIRECT_URL", ""),
|
||||
OIDCGroupsClaim: get("PULSE_OIDC_GROUPS_CLAIM", "groups"),
|
||||
SessionIdleTTL: 8 * time.Hour,
|
||||
SessionAbsoluteTTL: 7 * 24 * time.Hour,
|
||||
BackupDirectory: get("PULSE_BACKUP_DIR", ""),
|
||||
BackupRetention: 5,
|
||||
|
||||
ContainerSourceID: strings.TrimSpace(get("PULSE_CONTAINER_SOURCE_ID", "")),
|
||||
NotificationWebhookURL: strings.TrimSpace(get("PULSE_NOTIFICATION_WEBHOOK_URL", "")),
|
||||
NotificationWebhookToken: get("PULSE_NOTIFICATION_WEBHOOK_TOKEN", ""),
|
||||
}
|
||||
networks, networksErr := parseAllowedNetworks(get("PULSE_PROBE_ALLOWED_NETWORKS", ""))
|
||||
if networksErr != nil {
|
||||
return Config{}, &ValidationError{Fields: []string{networksErr.Error()}}
|
||||
}
|
||||
config.ProbeAllowedNetworks = networks
|
||||
mapping, mappingErr := parseRoleMapping(get("PULSE_OIDC_ROLE_MAPPING", ""))
|
||||
if mappingErr != nil {
|
||||
return Config{}, &ValidationError{Fields: []string{mappingErr.Error()}}
|
||||
}
|
||||
config.OIDCRoleMapping = mapping
|
||||
config.PrometheusTimeout = 10 * time.Second
|
||||
config.NotificationWebhookTimeout = 10 * time.Second
|
||||
if raw := get("PULSE_SESSION_IDLE_TTL", ""); raw != "" {
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return Config{}, &ValidationError{Fields: []string{"PULSE_SESSION_IDLE_TTL must be a duration"}}
|
||||
}
|
||||
config.SessionIdleTTL = parsed
|
||||
}
|
||||
if raw := get("PULSE_SESSION_ABSOLUTE_TTL", ""); raw != "" {
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return Config{}, &ValidationError{Fields: []string{"PULSE_SESSION_ABSOLUTE_TTL must be a duration"}}
|
||||
}
|
||||
config.SessionAbsoluteTTL = parsed
|
||||
}
|
||||
if raw := get("PULSE_PROMETHEUS_TIMEOUT", ""); raw != "" {
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return Config{}, &ValidationError{Fields: []string{"PULSE_PROMETHEUS_TIMEOUT must be a duration"}}
|
||||
}
|
||||
config.PrometheusTimeout = parsed
|
||||
}
|
||||
if raw := get("PULSE_NOTIFICATION_WEBHOOK_TIMEOUT", ""); raw != "" {
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return Config{}, &ValidationError{Fields: []string{"PULSE_NOTIFICATION_WEBHOOK_TIMEOUT must be a duration"}}
|
||||
}
|
||||
config.NotificationWebhookTimeout = parsed
|
||||
}
|
||||
if raw := get("PULSE_BREAK_GLASS_ENABLED", "false"); raw != "" {
|
||||
parsed, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return Config{}, &ValidationError{Fields: []string{"PULSE_BREAK_GLASS_ENABLED must be true or false"}}
|
||||
}
|
||||
config.BreakGlassEnabled = parsed
|
||||
}
|
||||
if raw := get("PULSE_BACKUP_RETENTION", ""); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return Config{}, &ValidationError{Fields: []string{"PULSE_BACKUP_RETENTION must be an integer"}}
|
||||
}
|
||||
config.BackupRetention = parsed
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ValidateWorker validates the worker's actual source and sink boundary. OIDC,
|
||||
// session, backup and Unraid settings belong to the API or agent and must not
|
||||
// be copied into the worker merely to satisfy unrelated validation.
|
||||
func ValidateWorker(config Config) error {
|
||||
var fields []string
|
||||
if config.Environment != Development && config.Environment != Test && config.Environment != Production {
|
||||
fields = append(fields, "PULSE_ENV must be development, test, or production")
|
||||
}
|
||||
if strings.TrimSpace(config.DatabaseURL) == "" {
|
||||
fields = append(fields, "PULSE_DATABASE_URL is required: every worker job is database-coordinated")
|
||||
} else {
|
||||
validateDatabaseURL(&fields, config.DatabaseURL)
|
||||
}
|
||||
if config.PrometheusTimeout <= 0 || config.PrometheusTimeout > time.Minute {
|
||||
fields = append(fields, "PULSE_PROMETHEUS_TIMEOUT must be between 1ns and 1m")
|
||||
}
|
||||
if config.PrometheusURL != "" {
|
||||
validateURL(&fields, "PULSE_PROMETHEUS_URL", config.PrometheusURL, config.Environment, false)
|
||||
}
|
||||
if config.ContainerSourceID != "" && !uuidPattern.MatchString(config.ContainerSourceID) {
|
||||
fields = append(fields, "PULSE_CONTAINER_SOURCE_ID must be a UUID")
|
||||
}
|
||||
if config.NotificationWebhookTimeout < time.Second || config.NotificationWebhookTimeout > 30*time.Second {
|
||||
fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TIMEOUT must be between 1s and 30s")
|
||||
}
|
||||
if config.NotificationWebhookURL != "" {
|
||||
validateURL(&fields, "PULSE_NOTIFICATION_WEBHOOK_URL", config.NotificationWebhookURL, config.Environment, true)
|
||||
parsed, err := url.Parse(config.NotificationWebhookURL)
|
||||
if err == nil && (parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "") {
|
||||
fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_URL may not contain credentials, query parameters, or a fragment")
|
||||
}
|
||||
if strings.TrimSpace(config.NotificationWebhookToken) == "" {
|
||||
fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TOKEN is required when the webhook is configured")
|
||||
}
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return &ValidationError{Fields: fields}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Validate(config Config) error {
|
||||
var fields []string
|
||||
if config.Environment != Development && config.Environment != Test && config.Environment != Production {
|
||||
fields = append(fields, "PULSE_ENV must be development, test, or production")
|
||||
}
|
||||
if config.Timezone == "" {
|
||||
fields = append(fields, "PULSE_TIMEZONE is required")
|
||||
} else if _, err := time.LoadLocation(config.Timezone); err != nil {
|
||||
fields = append(fields, "PULSE_TIMEZONE must be a valid IANA timezone")
|
||||
}
|
||||
if config.DefaultLocale == "" {
|
||||
fields = append(fields, "PULSE_DEFAULT_LOCALE is required")
|
||||
}
|
||||
if !contains([]string{"debug", "info", "warn", "error"}, config.LogLevel) {
|
||||
fields = append(fields, "PULSE_LOG_LEVEL must be debug, info, warn, or error")
|
||||
}
|
||||
if config.PrometheusTimeout <= 0 || config.PrometheusTimeout > time.Minute {
|
||||
fields = append(fields, "PULSE_PROMETHEUS_TIMEOUT must be between 1ns and 1m")
|
||||
}
|
||||
if config.NotificationWebhookTimeout < time.Second || config.NotificationWebhookTimeout > 30*time.Second {
|
||||
fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TIMEOUT must be between 1s and 30s")
|
||||
}
|
||||
minimumIdle := 5 * time.Second
|
||||
minimumAbsolute := config.SessionIdleTTL
|
||||
if config.Environment == Production {
|
||||
minimumIdle = 10 * time.Minute
|
||||
minimumAbsolute = 24 * time.Hour
|
||||
}
|
||||
if config.SessionIdleTTL < minimumIdle || config.SessionIdleTTL > 24*time.Hour {
|
||||
fields = append(fields, fmt.Sprintf("PULSE_SESSION_IDLE_TTL must be between %s and 24h", minimumIdle))
|
||||
}
|
||||
if config.SessionAbsoluteTTL < minimumAbsolute || config.SessionAbsoluteTTL > 30*24*time.Hour || config.SessionAbsoluteTTL < config.SessionIdleTTL {
|
||||
fields = append(fields, fmt.Sprintf("PULSE_SESSION_ABSOLUTE_TTL must be between %s and 720h and not shorter than PULSE_SESSION_IDLE_TTL", minimumAbsolute))
|
||||
}
|
||||
if config.NotificationWebhookURL != "" {
|
||||
validateURL(&fields, "PULSE_NOTIFICATION_WEBHOOK_URL", config.NotificationWebhookURL, config.Environment, true)
|
||||
parsed, err := url.Parse(config.NotificationWebhookURL)
|
||||
if err == nil && (parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "") {
|
||||
fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_URL may not contain credentials, query parameters, or a fragment")
|
||||
}
|
||||
if strings.TrimSpace(config.NotificationWebhookToken) == "" {
|
||||
fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TOKEN is required when the webhook is configured")
|
||||
}
|
||||
}
|
||||
if config.BackupRetention < 1 || config.BackupRetention > 100 {
|
||||
fields = append(fields, "PULSE_BACKUP_RETENTION must be between 1 and 100")
|
||||
}
|
||||
if config.PublicURL != "" {
|
||||
validateURL(&fields, "PULSE_PUBLIC_URL", config.PublicURL, config.Environment, true)
|
||||
}
|
||||
if config.DatabaseURL != "" {
|
||||
validateDatabaseURL(&fields, config.DatabaseURL)
|
||||
}
|
||||
if config.PrometheusURL != "" {
|
||||
validateURL(&fields, "PULSE_PROMETHEUS_URL", config.PrometheusURL, config.Environment, false)
|
||||
}
|
||||
if config.UnraidURL != "" {
|
||||
validateURL(&fields, "PULSE_UNRAID_URL", config.UnraidURL, config.Environment, false)
|
||||
}
|
||||
if config.ContainerSourceID != "" && !uuidPattern.MatchString(config.ContainerSourceID) {
|
||||
fields = append(fields, "PULSE_CONTAINER_SOURCE_ID must be a UUID")
|
||||
}
|
||||
if config.AuthMode != "oidc" && config.AuthMode != "mock" {
|
||||
fields = append(fields, "PULSE_AUTH_MODE must be oidc or mock")
|
||||
}
|
||||
if config.OIDCIssuer != "" {
|
||||
validateURL(&fields, "PULSE_OIDC_ISSUER", config.OIDCIssuer, config.Environment, true)
|
||||
}
|
||||
if config.OIDCRedirectURL != "" {
|
||||
validateURL(&fields, "PULSE_OIDC_REDIRECT_URL", config.OIDCRedirectURL, config.Environment, true)
|
||||
}
|
||||
if config.Environment == Production {
|
||||
for key, value := range map[string]string{
|
||||
"PULSE_PUBLIC_URL": config.PublicURL,
|
||||
"PULSE_DATABASE_URL": config.DatabaseURL,
|
||||
"PULSE_OIDC_ISSUER": config.OIDCIssuer,
|
||||
"PULSE_OIDC_CLIENT_ID": config.OIDCClientID,
|
||||
"PULSE_OIDC_CLIENT_SECRET": config.OIDCClientSecret,
|
||||
"PULSE_OIDC_REDIRECT_URL": config.OIDCRedirectURL,
|
||||
} {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
fields = append(fields, key+" is required in production")
|
||||
}
|
||||
}
|
||||
if config.AuthMode != "oidc" {
|
||||
fields = append(fields, "PULSE_AUTH_MODE=mock is forbidden in production")
|
||||
}
|
||||
if len(config.OIDCRoleMapping) == 0 {
|
||||
fields = append(fields, "PULSE_OIDC_ROLE_MAPPING is required in production; without it no identity can be granted a role")
|
||||
}
|
||||
if config.BreakGlassEnabled {
|
||||
fields = append(fields, "PULSE_BREAK_GLASS_ENABLED must remain false in production until secure initialization exists")
|
||||
}
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return &ValidationError{Fields: fields}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) String() string {
|
||||
return fmt.Sprintf("Config{environment=%s, public_url=%s, database_url=%s, oidc_issuer=%s, oidc_client_id=%s, oidc_client_secret=%s, unraid_api_token=%s, notification_webhook_url=%s, notification_webhook_token=%s}", c.Environment, redacted(c.PublicURL), redacted(c.DatabaseURL), redacted(c.OIDCIssuer), redacted(c.OIDCClientID), redacted(c.OIDCClientSecret), redacted(c.UnraidAPIToken), redacted(c.NotificationWebhookURL), redacted(c.NotificationWebhookToken))
|
||||
}
|
||||
|
||||
func (c Config) Redacted() Config {
|
||||
c.DatabaseURL = redacted(c.DatabaseURL)
|
||||
c.OIDCClientSecret = redacted(c.OIDCClientSecret)
|
||||
c.UnraidAPIToken = redacted(c.UnraidAPIToken)
|
||||
c.NotificationWebhookToken = redacted(c.NotificationWebhookToken)
|
||||
return c
|
||||
}
|
||||
|
||||
func redacted(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "<unset>"
|
||||
}
|
||||
return "<set>"
|
||||
}
|
||||
|
||||
func validateDatabaseURL(fields *[]string, raw string) {
|
||||
if err := ValidateDatabaseURL(raw); err != nil {
|
||||
*fields = append(*fields, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateDatabaseURL reports whether raw is a usable PostgreSQL URL. It is exported so
|
||||
// services that take only the database URL from the environment — pulse-agent, which
|
||||
// must not require the API's public URL or OIDC settings — apply the same rule as the
|
||||
// full configuration loader instead of inventing a second one.
|
||||
func ValidateDatabaseURL(raw string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || (parsed.Scheme != "postgres" && parsed.Scheme != "postgresql") || parsed.Host == "" {
|
||||
return errors.New("PULSE_DATABASE_URL must be a PostgreSQL URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateURL is the reusable strict URL boundary for narrowly scoped runtime
|
||||
// components. The full application validation keeps field-specific messages; callers
|
||||
// such as pulse-agent need the same HTTPS/absolute-url policy without copying it.
|
||||
func ValidateURL(raw string, requireHTTPS bool) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return errors.New("must be an absolute URL")
|
||||
}
|
||||
if requireHTTPS && parsed.Scheme != "https" {
|
||||
return errors.New("must use https")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateURL(fields *[]string, key, raw string, environment Environment, requireHTTPS bool) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
*fields = append(*fields, key+" must be an absolute URL")
|
||||
return
|
||||
}
|
||||
if requireHTTPS && environment == Production && parsed.Scheme != "https" {
|
||||
*fields = append(*fields, key+" must use https in production")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// uuidPattern bounds identifiers that must reference a database row.
|
||||
var uuidPattern = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$")
|
||||
|
||||
// maxAllowedNetworks mirrors the bound enforced by the probe network policy.
|
||||
const maxAllowedNetworks = 32
|
||||
|
||||
// parseAllowedNetworks reads a comma-separated CIDR list. An invalid or
|
||||
// unbounded list fails startup rather than silently widening or narrowing what
|
||||
// probes may reach.
|
||||
func parseAllowedNetworks(raw string) ([]string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
entries := strings.Split(trimmed, ",")
|
||||
networks := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := netip.ParsePrefix(entry); err != nil {
|
||||
return nil, errors.New("PULSE_PROBE_ALLOWED_NETWORKS entries must be CIDR prefixes")
|
||||
}
|
||||
networks = append(networks, entry)
|
||||
}
|
||||
if len(networks) == 0 || len(networks) > maxAllowedNetworks {
|
||||
return nil, fmt.Errorf("PULSE_PROBE_ALLOWED_NETWORKS must contain between 1 and %d CIDR prefixes", maxAllowedNetworks)
|
||||
}
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
// knownRoles bounds the Pulse role names accepted in PULSE_OIDC_ROLE_MAPPING. It
|
||||
// mirrors the roles defined in internal/auth without importing that package, so
|
||||
// configuration stays free of runtime dependencies.
|
||||
var knownRoles = []string{"viewer", "operator", "editor", "administrator"}
|
||||
|
||||
// parseRoleMapping reads a comma-separated "claim=role" list mapping identity
|
||||
// provider group claim values onto Pulse roles, for example
|
||||
// "pulse-admin=administrator,pulse-staff=viewer". An empty value yields a nil map,
|
||||
// which means no group grants access.
|
||||
func parseRoleMapping(raw string) (map[string]string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
mapping := make(map[string]string)
|
||||
for _, entry := range strings.Split(trimmed, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
claim, role, found := strings.Cut(entry, "=")
|
||||
claim = strings.TrimSpace(claim)
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
if !found || claim == "" || role == "" {
|
||||
return nil, errors.New("PULSE_OIDC_ROLE_MAPPING entries must use claim=role")
|
||||
}
|
||||
if !contains(knownRoles, role) {
|
||||
return nil, errors.New("PULSE_OIDC_ROLE_MAPPING role must be one of " + strings.Join(knownRoles, ", "))
|
||||
}
|
||||
if _, duplicate := mapping[claim]; duplicate {
|
||||
return nil, errors.New("PULSE_OIDC_ROLE_MAPPING contains a duplicate claim")
|
||||
}
|
||||
mapping[claim] = role
|
||||
}
|
||||
if len(mapping) == 0 {
|
||||
return nil, errors.New("PULSE_OIDC_ROLE_MAPPING must contain at least one claim=role entry")
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProductionListsAllMissingMandatoryValues(t *testing.T) {
|
||||
values := map[string]string{"PULSE_ENV": "production"}
|
||||
_, err := LoadFrom(mapLookup(values))
|
||||
if err == nil {
|
||||
t.Fatal("expected production validation error")
|
||||
}
|
||||
message := err.Error()
|
||||
for _, key := range []string{"PULSE_PUBLIC_URL", "PULSE_DATABASE_URL", "PULSE_OIDC_ISSUER", "PULSE_OIDC_CLIENT_ID", "PULSE_OIDC_CLIENT_SECRET", "PULSE_OIDC_REDIRECT_URL"} {
|
||||
if !strings.Contains(message, key) {
|
||||
t.Errorf("error %q does not mention %s", message, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookConfigurationIsSecureAndRedacted(t *testing.T) {
|
||||
secret := "webhook-runtime-secret"
|
||||
configuration, err := LoadFrom(mapLookup(map[string]string{
|
||||
"PULSE_ENV": "development",
|
||||
"PULSE_NOTIFICATION_WEBHOOK_URL": "http://127.0.0.1:8080/pulse",
|
||||
"PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret,
|
||||
"PULSE_NOTIFICATION_WEBHOOK_TIMEOUT": "3s",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if configuration.NotificationWebhookTimeout != 3*time.Second {
|
||||
t.Fatalf("timeout = %s", configuration.NotificationWebhookTimeout)
|
||||
}
|
||||
if strings.Contains(configuration.String(), secret) || strings.Contains(configuration.Redacted().NotificationWebhookToken, secret) {
|
||||
t.Fatal("webhook credential leaked through config rendering")
|
||||
}
|
||||
for name, values := range map[string]map[string]string{
|
||||
"missing token": {"PULSE_NOTIFICATION_WEBHOOK_URL": "https://receiver.example/hook"},
|
||||
"query token": {"PULSE_NOTIFICATION_WEBHOOK_URL": "https://receiver.example/hook?token=value", "PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret},
|
||||
"production http": {"PULSE_ENV": "production", "PULSE_NOTIFICATION_WEBHOOK_URL": "http://receiver.example/hook", "PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret},
|
||||
"unbounded timeout": {"PULSE_NOTIFICATION_WEBHOOK_URL": "https://receiver.example/hook", "PULSE_NOTIFICATION_WEBHOOK_TOKEN": secret, "PULSE_NOTIFICATION_WEBHOOK_TIMEOUT": "31s"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := LoadFrom(mapLookup(values)); err == nil {
|
||||
t.Fatal("expected webhook configuration rejection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionRejectsMockAuth(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"PULSE_ENV": "production", "PULSE_PUBLIC_URL": "https://pulse.example",
|
||||
"PULSE_DATABASE_URL": "postgres://pulse@db/pulse", "PULSE_OIDC_ISSUER": "https://auth.example",
|
||||
"PULSE_OIDC_CLIENT_ID": "pulse", "PULSE_OIDC_CLIENT_SECRET": "secret-value",
|
||||
"PULSE_OIDC_REDIRECT_URL": "https://pulse.example/auth/callback", "PULSE_AUTH_MODE": "mock",
|
||||
}
|
||||
_, err := LoadFrom(mapLookup(values))
|
||||
if err == nil || !strings.Contains(err.Error(), "mock") {
|
||||
t.Fatalf("expected mock-auth rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrorsAndStringNeverExposeSecrets(t *testing.T) {
|
||||
secret := "super-secret-token"
|
||||
values := map[string]string{
|
||||
"PULSE_ENV": "production", "PULSE_DATABASE_URL": "not-a-url", "PULSE_OIDC_CLIENT_SECRET": secret,
|
||||
"PULSE_UNRAID_API_TOKEN": secret, "PULSE_AUTH_MODE": "mock",
|
||||
}
|
||||
config, err := LoadFrom(mapLookup(values))
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatal("validation error leaked a secret")
|
||||
}
|
||||
if strings.Contains(config.String(), secret) {
|
||||
t.Fatal("config String leaked a secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentDefaultsAllowExplicitMockMode(t *testing.T) {
|
||||
values := map[string]string{"PULSE_ENV": "development", "PULSE_AUTH_MODE": "mock"}
|
||||
config, err := LoadFrom(mapLookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("development defaults rejected: %v", err)
|
||||
}
|
||||
if config.Environment != Development || config.AuthMode != "mock" {
|
||||
t.Fatalf("unexpected config: %s", config)
|
||||
}
|
||||
if config.SessionIdleTTL != 8*time.Hour || config.SessionAbsoluteTTL != 7*24*time.Hour {
|
||||
t.Fatalf("unexpected session defaults: idle=%s absolute=%s", config.SessionIdleTTL, config.SessionAbsoluteTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLifetimeConfigurationIsBounded(t *testing.T) {
|
||||
configured, err := LoadFrom(mapLookup(map[string]string{
|
||||
"PULSE_ENV": "development", "PULSE_AUTH_MODE": "mock",
|
||||
"PULSE_SESSION_IDLE_TTL": "20s", "PULSE_SESSION_ABSOLUTE_TTL": "2m",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if configured.SessionIdleTTL != 20*time.Second || configured.SessionAbsoluteTTL != 2*time.Minute {
|
||||
t.Fatalf("unexpected session lifetimes: %#v", configured)
|
||||
}
|
||||
|
||||
for name, values := range map[string]map[string]string{
|
||||
"invalid duration": {"PULSE_SESSION_IDLE_TTL": "later"},
|
||||
"idle too short": {"PULSE_SESSION_IDLE_TTL": "4s"},
|
||||
"absolute shorter than idle": {"PULSE_SESSION_IDLE_TTL": "20s", "PULSE_SESSION_ABSOLUTE_TTL": "10s"},
|
||||
"absolute unbounded": {"PULSE_SESSION_ABSOLUTE_TTL": "721h"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := LoadFrom(mapLookup(values)); err == nil {
|
||||
t.Fatal("expected bounded session configuration rejection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionRequiresWallboardCapableAbsoluteSessionLifetime(t *testing.T) {
|
||||
config := Config{
|
||||
Environment: Production, Timezone: "Europe/Brussels", DefaultLocale: "nl-BE", LogLevel: "info",
|
||||
PublicURL: "https://pulse.example", DatabaseURL: "postgres://pulse@db/pulse", AuthMode: "oidc",
|
||||
OIDCIssuer: "https://auth.example", OIDCClientID: "pulse", OIDCClientSecret: "secret",
|
||||
OIDCRedirectURL: "https://pulse.example/auth/callback", OIDCRoleMapping: map[string]string{"viewer": "viewer"},
|
||||
PrometheusTimeout: 10 * time.Second, NotificationWebhookTimeout: 10 * time.Second, BackupRetention: 5,
|
||||
SessionIdleTTL: 8 * time.Hour, SessionAbsoluteTTL: 23 * time.Hour,
|
||||
}
|
||||
if err := Validate(config); err == nil || !strings.Contains(err.Error(), "PULSE_SESSION_ABSOLUTE_TTL") {
|
||||
t.Fatalf("production accepted a session unable to cover the wallboard budget: %v", err)
|
||||
}
|
||||
config.SessionAbsoluteTTL = 24 * time.Hour
|
||||
config.SessionIdleTTL = 5 * time.Minute
|
||||
if err := Validate(config); err == nil || !strings.Contains(err.Error(), "PULSE_SESSION_IDLE_TTL") {
|
||||
t.Fatalf("production accepted an idle TTL that can race the five-minute wallboard refresh: %v", err)
|
||||
}
|
||||
config.SessionIdleTTL = 10 * time.Minute
|
||||
if err := Validate(config); err != nil {
|
||||
t.Fatalf("bounded 24-hour production session rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupConfigurationIsBoundedAndOptional(t *testing.T) {
|
||||
values := map[string]string{"PULSE_ENV": "development", "PULSE_AUTH_MODE": "mock", "PULSE_BACKUP_DIR": "C:/pulse-backups", "PULSE_BACKUP_RETENTION": "12"}
|
||||
config, err := LoadFrom(mapLookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("backup config rejected: %v", err)
|
||||
}
|
||||
if config.BackupDirectory != values["PULSE_BACKUP_DIR"] || config.BackupRetention != 12 {
|
||||
t.Fatalf("unexpected backup config: %#v", config)
|
||||
}
|
||||
values["PULSE_BACKUP_RETENTION"] = "101"
|
||||
if _, err := LoadFrom(mapLookup(values)); err == nil || !strings.Contains(err.Error(), "PULSE_BACKUP_RETENTION") {
|
||||
t.Fatalf("expected bounded retention error, got %v", err)
|
||||
}
|
||||
}
|
||||
func mapLookup(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) { value, ok := values[key]; return value, ok }
|
||||
}
|
||||
|
||||
func TestRoleMappingParsesClaimsOntoRoles(t *testing.T) {
|
||||
config, err := LoadFrom(mapLookup(map[string]string{
|
||||
"PULSE_OIDC_ROLE_MAPPING": "pulse-admin=administrator, pulse-staff =viewer,pulse-ops=Operator",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expected := map[string]string{"pulse-admin": "administrator", "pulse-staff": "viewer", "pulse-ops": "operator"}
|
||||
if len(config.OIDCRoleMapping) != len(expected) {
|
||||
t.Fatalf("expected %d mapped claims, got %d", len(expected), len(config.OIDCRoleMapping))
|
||||
}
|
||||
for claim, role := range expected {
|
||||
if config.OIDCRoleMapping[claim] != role {
|
||||
t.Fatalf("claim %q mapped to %q, want %q", claim, config.OIDCRoleMapping[claim], role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleMappingDefaultsToNoAccess(t *testing.T) {
|
||||
config, err := LoadFrom(mapLookup(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if config.OIDCRoleMapping != nil {
|
||||
t.Fatal("an unset role mapping must grant nobody a role")
|
||||
}
|
||||
if config.OIDCGroupsClaim != "groups" {
|
||||
t.Fatalf("groups claim defaulted to %q, want groups", config.OIDCGroupsClaim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleMappingRejectsMalformedInput(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"missing separator": "pulse-admin",
|
||||
"empty claim": "=administrator",
|
||||
"empty role": "pulse-admin=",
|
||||
"unknown role": "pulse-admin=superuser",
|
||||
"duplicate claim": "pulse-admin=viewer,pulse-admin=editor",
|
||||
"only separators": ",,",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := LoadFrom(mapLookup(map[string]string{"PULSE_OIDC_ROLE_MAPPING": raw})); err == nil {
|
||||
t.Fatalf("expected %q to be rejected", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionRequiresARoleMapping(t *testing.T) {
|
||||
base := map[string]string{
|
||||
"PULSE_ENV": "production",
|
||||
"PULSE_PUBLIC_URL": "https://pulse.example.test",
|
||||
"PULSE_DATABASE_URL": "postgres://pulse@db:5432/pulse",
|
||||
"PULSE_OIDC_ISSUER": "https://id.example.test",
|
||||
"PULSE_OIDC_CLIENT_ID": "pulse",
|
||||
"PULSE_OIDC_CLIENT_SECRET": "secret",
|
||||
"PULSE_OIDC_REDIRECT_URL": "https://pulse.example.test/auth/callback",
|
||||
}
|
||||
if _, err := LoadFrom(mapLookup(base)); err == nil {
|
||||
t.Fatal("production without a role mapping must fail: no identity could obtain a role")
|
||||
}
|
||||
base["PULSE_OIDC_ROLE_MAPPING"] = "pulse-admin=administrator"
|
||||
if _, err := LoadFrom(mapLookup(base)); err != nil {
|
||||
t.Fatalf("production with a role mapping must succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWorkerProductionDoesNotRequireAPICredentials(t *testing.T) {
|
||||
configuration, err := LoadWorkerFrom(mapLookup(map[string]string{
|
||||
"PULSE_ENV": "production",
|
||||
"PULSE_DATABASE_URL": "postgres://pulse:secret@pulse-postgres:5432/pulse?sslmode=disable",
|
||||
"PULSE_PROMETHEUS_URL": "http://192.0.2.10:9090",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWorkerFrom returned API-only validation error: %v", err)
|
||||
}
|
||||
if configuration.Environment != Production || configuration.DatabaseURL == "" {
|
||||
t.Fatalf("unexpected worker config: %#v", configuration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWorkerStillRequiresDatabaseAndValidatesSources(t *testing.T) {
|
||||
_, err := LoadWorkerFrom(mapLookup(map[string]string{
|
||||
"PULSE_ENV": "production",
|
||||
"PULSE_PROMETHEUS_URL": "://invalid",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("LoadWorkerFrom accepted missing database and malformed Prometheus URL")
|
||||
}
|
||||
message := err.Error()
|
||||
if !strings.Contains(message, "PULSE_DATABASE_URL") || !strings.Contains(message, "PULSE_PROMETHEUS_URL") {
|
||||
t.Fatalf("worker validation error = %q", message)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user