ZDDC/zddc/internal/config/config.go
ZDDC e911806eda feat(server): pluggable OPA-compatible policy decider
Add an internal access-decision boundary that all handlers go through
instead of calling zddc.AllowedWithChain directly. Two implementations
ship:

  * InternalDecider — wraps the existing zddc.AllowedWithChain. The
    default. No new dependencies, identical semantics to the legacy
    code path. ZDDC_OPA_URL=internal (or unset).

  * HTTPDecider — POSTs the canonical OPA wire format
    (POST /v1/data/zddc/access/allow with {"input": {...}}, response
    {"result": true|false}) over HTTP, HTTPS, or a Unix-domain socket.
    For federal customers running their own audited Rego policies
    alongside zddc-server. ZDDC_OPA_URL=http(s)://… or unix:///….

External-mode failure semantics: unreachable / non-2xx / malformed
response → fail closed (deny) by default with a WARN log. Operators
who prefer availability over correctness flip with ZDDC_OPA_FAIL_OPEN=1.

The decider is constructed once at startup, plumbed through ACLMiddleware
into the request context. Handlers retrieve it via DeciderFromContext;
non-request callers (fs.ListDirectory, EnumerateProjects, enumerateAccess)
take it as an explicit parameter.

zddc.ZddcFile and zddc.ACLRules gain JSON tags so external Rego authors
get idiomatic input shape (acl.allow, admins, …) instead of Go field
names (ACL.Allow, Admins, …).

Test coverage:
  * InternalDecider parity tests against zddc.AllowedWithChain (every
    documented cascade scenario: empty chain, leaf-allow-wins, leaf-
    deny-beats-parent, leaf-allows-what-parent-denies, deepest-match-
    wins, etc.)
  * HTTPDecider happy-path test (canonical wire format)
  * Fail-closed / fail-open / malformed-response tests

Production binary size unchanged (no new deps; HTTP transport is
stdlib net/http). 11 ACL call sites migrated. End-to-end verified
against the worked-example layout in zddc/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:45:07 -05:00

318 lines
14 KiB
Go

package config
import (
"errors"
"flag"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
)
// Config holds all runtime configuration. Each field can be set via a
// command-line flag (--<name>) or environment variable (ZDDC_<NAME>);
// the flag takes precedence when both are present.
type Config struct {
Root string // --root / ZDDC_ROOT — served file tree (default: CWD)
Addr string // --addr / ZDDC_ADDR — bind address (default :8443)
TLSCert string // --tls-cert / ZDDC_TLS_CERT — PEM cert path; "none" = plain HTTP; empty = self-signed
TLSKey string // --tls-key / ZDDC_TLS_KEY — PEM key path
TLSMode string // computed: none/selfsigned/provided
LogLevel string // --log-level / ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
IndexPath string // --index-path / ZDDC_INDEX_PATH — virtual archive prefix (default .archive)
EmailHeader string // --email-header / ZDDC_EMAIL_HEADER — auth header name (default X-Auth-Request-Email)
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default empty (CORS disabled); explicit value enables
AccessLog string // --access-log / ZDDC_ACCESS_LOG — file path for tee'd JSON access log; empty = stderr only
Insecure bool // --insecure / ZDDC_INSECURE=1 — opt out of safety checks (currently: allow start without a root .zddc, leaving the tree publicly accessible)
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket)
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
}
// ErrHelpRequested is returned by Load when --help is passed; the caller
// should print Usage() to stderr and exit 0.
var ErrHelpRequested = errors.New("help requested")
// ErrVersionRequested is returned by Load when --version is passed; the
// caller should print version info and exit 0.
var ErrVersionRequested = errors.New("version requested")
// Load reads configuration from CLI flags + environment variables.
//
// Precedence (highest → lowest): command-line flag, environment variable,
// hard-coded default. Special-cases:
// - --root / ZDDC_ROOT default to the current working directory if both
// are unset, so an operator can `cd /srv/zddc && zddc-server` with
// zero config.
// - --version and --help return distinguished sentinel errors; the caller
// handles printing and exit. Pass nil for args to skip flag parsing
// entirely (used by tests that set state via env vars only).
//
// Standard usage from main.go:
//
// cfg, err := config.Load(os.Args[1:])
// if errors.Is(err, config.ErrHelpRequested) { os.Exit(0) }
// if errors.Is(err, config.ErrVersionRequested) { /* print versions */ ; os.Exit(0) }
// if err != nil { ... }
func Load(args []string) (Config, error) {
fs := flag.NewFlagSet("zddc-server", flag.ContinueOnError)
// Discard flag's own error output; we wrap and return our own.
fs.SetOutput(io.Discard)
rootFlag := fs.String("root", os.Getenv("ZDDC_ROOT"),
"Path to the served file tree. Default: ZDDC_ROOT or the current directory.")
addrFlag := fs.String("addr", getEnv("ZDDC_ADDR", ":8443"),
"Listen address (host:port). Default: ZDDC_ADDR or :8443.")
tlsCertFlag := fs.String("tls-cert", os.Getenv("ZDDC_TLS_CERT"),
"Path to a PEM TLS certificate. \"none\" disables TLS (plain HTTP). Empty means self-signed.")
tlsKeyFlag := fs.String("tls-key", os.Getenv("ZDDC_TLS_KEY"),
"Path to the matching PEM TLS private key.")
logLevelFlag := fs.String("log-level", getEnv("ZDDC_LOG_LEVEL", "info"),
"Log level: debug, info, warn, error.")
indexPathFlag := fs.String("index-path", getEnv("ZDDC_INDEX_PATH", ".archive"),
"URL segment that triggers the virtual archive index (default \".archive\").")
emailHeaderFlag := fs.String("email-header", getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
"HTTP header carrying the authenticated user's email.")
corsOriginFlag := fs.String("cors-origin", "",
"Comma-separated CORS allowlist. Empty (default) = CORS disabled. Set to your tool-host origin (e.g. https://tools.acme.com) only if browser-loaded pages from that origin call back into this server.")
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
insecureFlag := fs.Bool("insecure", os.Getenv("ZDDC_INSECURE") == "1",
"Allow startup with no root .zddc file (the tree is then publicly accessible). Default: refuse to start.")
opaURLFlag := fs.String("opa-url", getEnv("ZDDC_OPA_URL", "internal"),
"Policy decider endpoint: \"internal\" (built-in Go evaluator, default), \"http(s)://host:port\", or \"unix:///path/to/socket\".")
opaFailOpenFlag := fs.Bool("opa-fail-open", os.Getenv("ZDDC_OPA_FAIL_OPEN") == "1",
"External OPA only: on unreachable / non-2xx / malformed response, allow the request instead of denying. Default: fail closed.")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
"Set explicitly to empty (--access-log=) to disable.")
helpFlag := fs.Bool("help", false, "Print this help and exit.")
versionFlag := fs.Bool("version", false, "Print version info and exit.")
if args != nil {
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return Config{}, ErrHelpRequested
}
return Config{}, err
}
}
if *helpFlag {
return Config{}, ErrHelpRequested
}
if *versionFlag {
return Config{}, ErrVersionRequested
}
// CORS + AccessLog both have "unset → default; explicit-empty →
// disabled" semantics. The flag default is "" in both cases so we
// can't tell unset from explicit-empty via the flag alone —
// fs.Visit catches explicit flag use, and os.LookupEnv catches
// explicit env-var use.
corsFlagSet := false
accessLogFlagSet := false
if args != nil {
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "cors-origin":
corsFlagSet = true
case "access-log":
accessLogFlagSet = true
}
})
}
_, accessLogEnvSet := os.LookupEnv("ZDDC_ACCESS_LOG")
accessLogExplicit := accessLogFlagSet || accessLogEnvSet
cfg := Config{
Root: *rootFlag,
Addr: *addrFlag,
TLSCert: *tlsCertFlag,
TLSKey: *tlsKeyFlag,
LogLevel: *logLevelFlag,
IndexPath: *indexPathFlag,
EmailHeader: *emailHeaderFlag,
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
AccessLog: *accessLogFlag,
Insecure: *insecureFlag,
OPAURL: *opaURLFlag,
OPAFailOpen: *opaFailOpenFlag,
}
// Default Root to the current working directory.
if cfg.Root == "" {
cwd, err := os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("--root not set and could not determine current directory: %w", err)
}
cfg.Root = cwd
}
info, err := os.Stat(cfg.Root)
if err != nil {
return Config{}, fmt.Errorf("--root %q is not accessible: %w", cfg.Root, err)
}
if !info.IsDir() {
return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root)
}
// Refuse to start when the served tree has no root .zddc file. With no
// .zddc anywhere in the chain, AllowedWithChain falls through to its
// "HasAnyFile=false → allow" default, so every directory is publicly
// accessible to anonymous callers. The vast majority of operators do not
// want that — and the few who do (a deliberately public archive) can pass
// --insecure to acknowledge it. See zddc/README.md § Access control.
if !cfg.Insecure {
if _, err := os.Stat(filepath.Join(cfg.Root, ".zddc")); os.IsNotExist(err) {
return Config{}, fmt.Errorf(
"no %s/.zddc file found; the served tree would be publicly accessible to anonymous callers. "+
"Create a starter .zddc (at minimum: `admins: [you@yourcompany.com]`) "+
"or pass --insecure (or ZDDC_INSECURE=1) to acknowledge a deliberately-public deployment",
cfg.Root)
} else if err != nil {
return Config{}, fmt.Errorf("could not stat %s/.zddc: %w", cfg.Root, err)
}
}
// Audit-log default: if neither flag nor env was explicitly set,
// default to <Root>/.zddc.d/logs/access-<hostname>.log so the
// server captures an audit trail by default. Setting the flag/env
// to empty (--access-log=) is the explicit opt-out. Hostname is
// in the filename because operators typically run multiple zddc-
// server replicas against the same dataset (the .zddc.d directory
// is shared FS), and per-host filenames keep the JSON streams
// separable for downstream auditors.
if !accessLogExplicit {
host, herr := os.Hostname()
if herr != nil || host == "" {
host = "unknown"
}
cfg.AccessLog = filepath.Join(cfg.Root, ".zddc.d", "logs",
"access-"+host+".log")
}
// Determine TLS mode.
switch {
case cfg.TLSCert == "none":
cfg.TLSMode = "none"
case cfg.TLSCert == "" && cfg.TLSKey == "":
cfg.TLSMode = "selfsigned"
default:
cfg.TLSMode = "provided"
}
if cfg.TLSMode == "provided" && (cfg.TLSCert == "") != (cfg.TLSKey == "") {
return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty")
}
// Plain HTTP mode trusts the email header from any client. Only safe
// behind an authenticating reverse proxy. Refuse to start when binding
// plain HTTP to a non-loopback interface unless the operator has
// explicitly acknowledged the deployment shape.
if cfg.TLSMode == "none" && !isLoopbackAddr(cfg.Addr) && !*insecureDirectFlag {
return Config{}, fmt.Errorf(
"--tls-cert=none binds plain HTTP to %q which trusts %s headers from any client; "+
"either use TLS (omit --tls-cert or supply a cert), bind to loopback (127.0.0.1: or [::1]:), "+
"or pass --insecure-direct to confirm an authenticating reverse proxy is in front",
cfg.Addr, cfg.EmailHeader)
}
return cfg, nil
}
// Usage prints the flag list to w (stderr is the conventional caller).
// Returned format mirrors `flag.PrintDefaults` plus a one-line summary.
func Usage(w io.Writer) {
fmt.Fprintln(w, "Usage: zddc-server [flags]")
fmt.Fprintln(w, "")
fmt.Fprintln(w, "Each flag has an equivalent ZDDC_* environment variable; the flag wins on conflict.")
fmt.Fprintln(w, "ZDDC_ROOT defaults to the current working directory.")
fmt.Fprintln(w, "")
fmt.Fprintln(w, "Flags:")
fs := flag.NewFlagSet("zddc-server", flag.ContinueOnError)
fs.SetOutput(w)
// Re-register flags to populate Usage output (we discard the values).
fs.String("root", "", "Path to the served file tree. Default: ZDDC_ROOT or the current directory.")
fs.String("addr", ":8443", "Listen address (host:port). Default: ZDDC_ADDR or :8443.")
fs.String("tls-cert", "", "Path to a PEM TLS certificate. \"none\" disables TLS. Empty = self-signed.")
fs.String("tls-key", "", "Path to the matching PEM TLS private key.")
fs.String("log-level", "info", "Log level: debug, info, warn, error.")
fs.String("index-path", ".archive", "URL segment for the virtual archive index.")
fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.")
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty (default) = CORS disabled.")
fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.")
fs.Bool("insecure", false, "Allow startup with no root .zddc file (publicly accessible). Default: refuse.")
fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".")
fs.Bool("opa-fail-open", false, "External OPA: allow on transport error (default: deny / fail closed).")
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.")
fs.Bool("help", false, "Print this help and exit.")
fs.Bool("version", false, "Print version info and exit.")
fs.PrintDefaults()
}
// resolveCORS implements the precedence rules for the CORS allowlist:
// - flag explicitly set → use flag value (empty = disabled)
// - else env var explicitly set → use env value (empty = disabled)
// - else → default to nil (CORS disabled)
//
// Default-empty is intentional: the embedded-tools deployment path (the install
// default) serves tools and data from the same origin, so CORS is unneeded.
// Operators who deliberately load tools from a different origin (e.g. the
// CDN-bootstrap pattern from https://zddc.varasys.io, or self-hosted at
// https://tools.acme.com) opt in by setting the value explicitly. This avoids
// implicit cross-origin trust on third-party domains.
func resolveCORS(flagSet bool, flagValue string) []string {
if flagSet {
return parseCSV(flagValue)
}
if v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN"); ok {
return parseCSV(v)
}
return nil
}
// parseCSV splits a comma-separated list and trims whitespace. Empty
// returns nil (which the middleware treats as "CORS disabled").
func parseCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
// isLoopbackAddr reports whether addr binds only to a loopback interface.
// addr is in net.Listen form: "host:port", ":port", or "[ipv6]:port".
// ":port" means all interfaces, so it is NOT loopback.
func isLoopbackAddr(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if host == "" {
return false
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
return ip.IsLoopback()
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}