Files
member-console/internal/config/validate.go
T
cgalo5758 ea4fee18b6 Fix five findings from security audit run 2
- 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
2026-09-09 20:53:31 -05:00

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)
}