- Rotate the session token at the OIDC callback and restore the full lifetime; cap pre-auth sessions at 15 minutes and write no session for bare anonymous requests - Treat db-dsn as a secret: accept db-dsn-file, log only host, port, database and user, and never echo a malformed DSN in an error - Guard the logout callback with a state cookie so a forged visit cannot end a live session - Collapse FedWiki site actions on a foreign tenant's domain to the not-found answer, as for a domain that does not exist
206 lines
8.6 KiB
Go
206 lines
8.6 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
// Package config validates the configuration required to run member-console
|
|
// before any service is initialized, so a misconfiguration fails fast with an
|
|
// actionable message instead of a late, cryptic downstream error.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// ValidateStart checks the configuration required by `member-console start` and
|
|
// returns a single aggregated error naming every problem at once (nil when the
|
|
// configuration is valid). It reads the global Viper configuration, so it must be
|
|
// called after flags, environment variables, the config file, and any
|
|
// *-secret-file references have been resolved — and before any service
|
|
// (database, Temporal, HTTP server) is initialized.
|
|
//
|
|
// integrationSpecs is the aggregated ConfigKey declarations from every
|
|
// installed integration (see ConfigProvider) — the composition root
|
|
// collects them via a registry loop (internal/integrations.All, type-
|
|
// asserted against ConfigProvider) and passes them in here, so this
|
|
// function validates each integration's required-together groups
|
|
// generically instead of a hardcoded per-integration conditional.
|
|
func ValidateStart(integrationSpecs []ConfigKey) error {
|
|
var errs []error
|
|
|
|
// --- Unconditionally required ---
|
|
|
|
dsn := strings.TrimSpace(viper.GetString("db-dsn"))
|
|
if dsn == "" {
|
|
errs = append(errs, required("db-dsn", "e.g. postgres://user:pass@host:5432/dbname?sslmode=disable"))
|
|
} else if u, err := url.Parse(dsn); err != nil || (u.Scheme != "postgres" && u.Scheme != "postgresql") {
|
|
// Names the key and the expected shape only — never the configured
|
|
// value, which may carry a password (design D1, spec
|
|
// startup-configuration: "Connection strings and secrets never reach
|
|
// the logs").
|
|
errs = append(errs, errors.New("db-dsn is not a valid PostgreSQL URL (want postgres://user:pass@host:5432/dbname)"))
|
|
}
|
|
|
|
if strings.TrimSpace(viper.GetString("valkey-addr")) == "" {
|
|
errs = append(errs, required("valkey-addr", "the Valkey/Redis session store, e.g. localhost:6379"))
|
|
}
|
|
|
|
if err := requireURL("oidc-idp-issuer-url", "the OIDC issuer, e.g. https://idp.example.com/realms/main"); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
|
|
if strings.TrimSpace(viper.GetString("oidc-sp-client-id")) == "" {
|
|
errs = append(errs, required("oidc-sp-client-id", "the OIDC client ID registered for this console"))
|
|
}
|
|
|
|
if err := requireURL("base-url", "the public URL of this console, e.g. https://console.example.com"); err != nil {
|
|
errs = append(errs, err)
|
|
} else if scheme := baseURLScheme(); scheme != "http" && scheme != "https" {
|
|
// The scheme decides the session cookie's Secure flag and the CSP
|
|
// upgrade directive (config.ServesHTTPS), so it must be one a
|
|
// browser reaches the console over.
|
|
errs = append(errs, fmt.Errorf("base-url must be http or https, got %q", viper.GetString("base-url")))
|
|
}
|
|
|
|
// deployment-name defaults to config.DefaultDeploymentName ("Member
|
|
// Console") via viper.SetDefault, so this only fires when an operator
|
|
// explicitly overrides it to blank or whitespace — the one input shape
|
|
// the default cannot protect against (design decision 5,
|
|
// ux-honest-surfaces).
|
|
// The session store holds credentials: a session carries the person,
|
|
// organization and workspace ids that authorization is decided from, so an
|
|
// unauthenticated store lets anyone who reaches the port read and forge
|
|
// them. Required rather than optional because there are no production
|
|
// deployments to break (README: pre-production; 10d Slice 3 still lists
|
|
// "deploy to prod"), so the secure posture can be the only posture.
|
|
// TLS stays optional: it needs certificates, and a same-host connection is
|
|
// a weaker case than an unauthenticated one.
|
|
if strings.TrimSpace(viper.GetString("valkey-password")) == "" {
|
|
errs = append(errs, required("valkey-password", "the session store's password (or valkey-password-file); the store must not be reachable unauthenticated"))
|
|
}
|
|
|
|
if strings.TrimSpace(viper.GetString("deployment-name")) == "" {
|
|
errs = append(errs, errors.New("deployment-name cannot be blank or whitespace-only; leave it unset to use the default (\"Member Console\") or set a non-blank name"))
|
|
}
|
|
|
|
// --- Conditional: Temporal ---
|
|
|
|
if strings.TrimSpace(viper.GetString("temporal-host")) != "" {
|
|
if strings.TrimSpace(viper.GetString("temporal-namespace")) == "" {
|
|
errs = append(errs, required("temporal-namespace", `the Temporal namespace, e.g. "default"`))
|
|
}
|
|
// OAuth is optional, but partial OAuth config never works: require the
|
|
// token URL, client ID, and client secret together (or none).
|
|
tokenURL := strings.TrimSpace(viper.GetString("temporal-oauth-token-url"))
|
|
clientID := strings.TrimSpace(viper.GetString("temporal-oauth-client-id"))
|
|
clientSecret := strings.TrimSpace(viper.GetString("temporal-oauth-client-secret"))
|
|
if (tokenURL != "" || clientID != "" || clientSecret != "") &&
|
|
(tokenURL == "" || clientID == "" || clientSecret == "") {
|
|
errs = append(errs, errors.New("temporal OAuth is partially configured: set temporal-oauth-token-url, temporal-oauth-client-id, and temporal-oauth-client-secret together (or none to disable Temporal auth)"))
|
|
}
|
|
}
|
|
|
|
// --- Conditional: installed integrations ---
|
|
|
|
errs = append(errs, validateRequiredGroups(integrationSpecs)...)
|
|
errs = append(errs, validateTypes(integrationSpecs)...)
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// validateTypes checks every declared key's resolved value (from flags,
|
|
// environment, or the config file; ValidateStart runs before ApplyOverlay,
|
|
// so operator overrides are not yet layered in) against Parse, the same
|
|
// parser the settings surface's save path and the boot overlay apply
|
|
// (design D2). An empty/unset value passes; presence is a separate
|
|
// concern, governed by RequiredGroup where it applies. This subsumes the
|
|
// enum-membership check the former validateEnums ran on its own: an enum
|
|
// key's Parse case is exactly that check, so "a value that boots is a value
|
|
// that parses" covers every declared type with one loop.
|
|
func validateTypes(specs []ConfigKey) []error {
|
|
var errs []error
|
|
for _, k := range specs {
|
|
value := strings.TrimSpace(viper.GetString(k.Name))
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, err := Parse(k, value); err != nil {
|
|
errs = append(errs, fmt.Errorf("%s: %w", k.Name, err))
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// validateRequiredGroups checks every non-empty RequiredGroup shared by two
|
|
// or more declared keys: either all keys in the group are set, or none are.
|
|
// A group with some but not all keys set is rejected with a single
|
|
// aggregated error per group, naming every key in it — the same shape and
|
|
// UX the hardcoded "Conditional: Stripe" block used to produce by hand.
|
|
func validateRequiredGroups(specs []ConfigKey) []error {
|
|
type group struct {
|
|
names []string
|
|
set int
|
|
}
|
|
groups := make(map[string]*group)
|
|
// Preserve first-seen group order so aggregated errors are deterministic.
|
|
var order []string
|
|
for _, k := range specs {
|
|
if k.RequiredGroup == "" {
|
|
continue
|
|
}
|
|
g, ok := groups[k.RequiredGroup]
|
|
if !ok {
|
|
g = &group{}
|
|
groups[k.RequiredGroup] = g
|
|
order = append(order, k.RequiredGroup)
|
|
}
|
|
g.names = append(g.names, k.Name)
|
|
if strings.TrimSpace(viper.GetString(k.Name)) != "" {
|
|
g.set++
|
|
}
|
|
}
|
|
|
|
var errs []error
|
|
for _, name := range order {
|
|
g := groups[name]
|
|
if g.set > 0 && g.set < len(g.names) {
|
|
errs = append(errs, fmt.Errorf(
|
|
"%s is partially configured: set %s together (or leave them all empty to disable it)",
|
|
name, strings.Join(g.names, " and ")))
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// requireURL validates that key is a non-empty, parseable URL with a scheme and
|
|
// host, returning an actionable error otherwise.
|
|
func requireURL(key, hint string) error {
|
|
raw := strings.TrimSpace(viper.GetString(key))
|
|
if raw == "" {
|
|
return required(key, hint)
|
|
}
|
|
if u, err := url.Parse(raw); err != nil || u.Scheme == "" || u.Host == "" {
|
|
return fmt.Errorf("%s is not a valid URL (want scheme://host): %q", key, raw)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// baseURLScheme is base-url's scheme, lower-cased; requireURL has already
|
|
// established that it parses and carries one.
|
|
func baseURLScheme() string {
|
|
u, err := url.Parse(strings.TrimSpace(viper.GetString("base-url")))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.ToLower(u.Scheme)
|
|
}
|
|
|
|
// required formats a missing-key error that names both ways to set the value.
|
|
func required(key, hint string) error {
|
|
env := "MC_" + strings.ToUpper(strings.ReplaceAll(key, "-", "_"))
|
|
return fmt.Errorf("%s is required (set %s or the %s key in mc-config.yaml) — %s", key, env, key, hint)
|
|
}
|