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>
This commit is contained in:
ZDDC 2026-05-04 17:45:07 -05:00
parent 6b973906c3
commit e911806eda
13 changed files with 619 additions and 36 deletions

View file

@ -18,6 +18,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
@ -110,7 +111,19 @@ func main() {
// CORSMiddleware — Origin / preflight handling.
// dispatch — the actual request handler.
auditLogger := setupAccessAuditLog(cfg.AccessLog)
mux.Handle("/", handler.ACLMiddleware(cfg, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Construct the policy decider once at startup. ZDDC_OPA_URL=internal
// (default) routes decisions through the in-process Go evaluator;
// http(s):// or unix:// values send each decision to an external
// OPA-compatible server (federal customers, custom Rego policies).
decider, err := policy.New(policy.Config{URL: cfg.OPAURL, FailOpen: cfg.OPAFailOpen})
if err != nil {
slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err)
os.Exit(1)
}
slog.Info("policy decider ready", "mode", policyModeLabel(cfg.OPAURL), "url", cfg.OPAURL)
mux.Handle("/", handler.ACLMiddleware(cfg, decider, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, appsServer, w, r)
})))))
@ -187,6 +200,23 @@ func main() {
// (running user only). For multi-user audit access, the operator should
// use group-readable parent directory permissions and either chmod the
// log out-of-band or run a forwarder that has its own read access.
// policyModeLabel collapses cfg.OPAURL to a one-word mode label for the
// startup log so operators can grep for the active decider quickly.
func policyModeLabel(opaURL string) string {
switch {
case opaURL == "" || strings.EqualFold(opaURL, "internal"):
return "internal"
case strings.HasPrefix(opaURL, "unix://"):
return "external-unix"
case strings.HasPrefix(opaURL, "https://"):
return "external-https"
case strings.HasPrefix(opaURL, "http://"):
return "external-http"
default:
return "unknown"
}
}
func setupAccessAuditLog(path string) *slog.Logger {
if path == "" {
return nil
@ -424,7 +454,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
if apps.AppAvailableAt(cfg.Root, requestDir, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -449,7 +479,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
isRoot := urlPath == "/"
if !isRoot {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -464,7 +494,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// Regular file: ACL on parent directory
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -26,6 +26,8 @@ type Config struct {
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
@ -78,6 +80,10 @@ func Load(args []string) (Config, error) {
"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. "+
@ -131,6 +137,8 @@ func Load(args []string) (Config, error) {
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
AccessLog: *accessLogFlag,
Insecure: *insecureFlag,
OPAURL: *opaURLFlag,
OPAFailOpen: *opaFailOpenFlag,
}
// Default Root to the current working directory.
@ -236,6 +244,8 @@ func Usage(w io.Writer) {
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.")

View file

@ -1,12 +1,14 @@
package fs
import (
"context"
"net/url"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
@ -30,7 +32,14 @@ func safeJoin(fsRoot, relPath string) (string, bool) {
// - dirPath="" means the root of the served tree
//
// baseURL should end with "/" and is the URL prefix for this directory.
func ListDirectory(fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileInfo, error) {
//
// The decider is queried per subdirectory; nil falls back to the internal
// Go evaluator (policy.InternalDecider) for tests that don't wire up
// an explicit decider.
func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileInfo, error) {
if decider == nil {
decider = &policy.InternalDecider{}
}
absDir, ok := safeJoin(fsRoot, dirPath)
if !ok {
return nil, os.ErrNotExist
@ -62,7 +71,12 @@ func ListDirectory(fsRoot, dirPath, userEmail, baseURL string) ([]listing.FileIn
// ACL check for subdirectory
subAbs := filepath.Join(absDir, name)
chain, err := zddc.EffectivePolicy(fsRoot, subAbs)
if err != nil || !zddc.AllowedWithChain(chain, userEmail) {
if err != nil {
continue
}
subURLPath := baseURL + name + "/"
allowed, _ := policy.AllowFromChain(ctx, decider, chain, userEmail, subURLPath)
if !allowed {
continue // omit denied directories silently
}
fi := listing.FileInfo{

View file

@ -10,6 +10,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
@ -32,6 +33,8 @@ import (
// filename: the part after .archive/ (empty for directory listing)
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
email := EmailFromContext(r)
decider := DeciderFromContext(r)
ctx := r.Context()
project := projectFromContextPath(contextPath)
if project == "" {
@ -48,7 +51,7 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
if err != nil {
slog.Warn("ACL policy error", "path", absDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, contextPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -72,7 +75,7 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
if err != nil {
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+target); !allowed {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
@ -95,6 +98,8 @@ func projectFromContextPath(contextPath string) string {
}
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, project, email string) {
decider := DeciderFromContext(r)
ctx := r.Context()
allEntries := idx.AllEntries(project)
archiveBase := contextPath
if !strings.HasSuffix(archiveBase, "/") {
@ -117,7 +122,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
aclCache[fileDir] = false
return false
}
v := zddc.AllowedWithChain(chain, email)
v, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+targetPath)
aclCache[fileDir] = v
return v
}

View file

@ -11,6 +11,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
@ -35,6 +36,8 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
}
email := EmailFromContext(r)
decider := DeciderFromContext(r)
ctx := r.Context()
// Compute relative dir path (no leading or trailing slash)
dirPath := strings.TrimPrefix(urlPath, "/")
@ -54,9 +57,11 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
slog.Warn("ACL policy error", "path", absDir, "err", err)
}
isRoot := dirPath == ""
if !isRoot && !zddc.AllowedWithChain(chain, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
if !isRoot {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
accept := r.Header.Get("Accept")
@ -73,7 +78,7 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// Build base URL for listing entries
baseURL := urlPath // relative URLs suffice for JSON listings
entries, err := appfs.ListDirectory(cfg.Root, dirPath, email, baseURL)
entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Not Found", http.StatusNotFound)

View file

@ -34,6 +34,7 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3"
)
@ -187,7 +188,7 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -263,7 +264,7 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", gateDir, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -346,7 +347,7 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
if err != nil {
slog.Warn("form: policy error", "path", req.DataPath, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -6,6 +6,7 @@ import (
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"log/slog"
)
@ -14,10 +15,19 @@ type contextKey string
// EmailKey is the context key for the authenticated user's email.
const EmailKey contextKey = "email"
// DeciderKey is the context key for the request's policy decider.
// Set by ACLMiddleware so handlers deep in the stack can issue policy
// queries without taking the decider as an explicit parameter. Although
// the decider is an app-wide singleton (not per-request state), routing
// it through context keeps the call-site signatures stable across the
// "swap internal evaluator for external OPA" plumbing change.
const DeciderKey contextKey = "policy-decider"
// ACLMiddleware extracts the user email from the configured header and stores
// it in the request context. It does NOT enforce ACL itself — each handler
// performs its own ACL check via zddc.EffectivePolicy / zddc.AllowedWithChain.
func ACLMiddleware(cfg config.Config, next http.Handler) http.Handler {
// it (along with the policy decider) in the request context. It does NOT
// enforce ACL itself — each handler performs its own ACL check via
// policy.AllowFromChain.
func ACLMiddleware(cfg config.Config, decider policy.Decider, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
email := r.Header.Get(cfg.EmailHeader)
// DEBUG-level header dump for diagnosing proxy / SSO header
@ -33,6 +43,9 @@ func ACLMiddleware(cfg config.Config, next http.Handler) http.Handler {
"observed", email,
"headers", r.Header)
ctx := context.WithValue(r.Context(), EmailKey, email)
if decider != nil {
ctx = context.WithValue(ctx, DeciderKey, decider)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@ -45,6 +58,17 @@ func EmailFromContext(r *http.Request) string {
return ""
}
// DeciderFromContext extracts the policy decider from the request
// context. Returns the internal decider as a fallback if none was
// installed — this matches the "no OPA configured" semantics and
// keeps test setups that don't install ACLMiddleware functional.
func DeciderFromContext(r *http.Request) policy.Decider {
if v, ok := r.Context().Value(DeciderKey).(policy.Decider); ok {
return v
}
return &policy.InternalDecider{}
}
// responseWriter wraps http.ResponseWriter to capture status code and bytes written.
type responseWriter struct {
http.ResponseWriter

View file

@ -32,7 +32,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
// email from the context ACL populated.
chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop))
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -60,7 +60,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
w.WriteHeader(http.StatusOK)
})
chain := ACLMiddleware(cfg, AccessLogMiddleware(nil, noop))
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
// Note: no X-Auth-Request-Email header set.
@ -90,7 +90,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
})
// Inverted order — the ORIGINAL buggy chain.
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, noop))
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, nil, noop))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
@ -119,7 +119,7 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
})
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
chain := ACLMiddleware(cfg, AccessLogMiddleware(auditLogger, noop))
chain := ACLMiddleware(cfg, nil, AccessLogMiddleware(auditLogger, noop))
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
req.Header.Set("X-Auth-Request-Email", "bob@example.com")

View file

@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"path/filepath"
@ -9,6 +10,7 @@ import (
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
@ -40,7 +42,7 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht
case "/", "":
serveProfilePage(cfg, w, r)
case "/access":
writeJSON(w, enumerateAccess(cfg, email))
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, email))
case "/projects":
serveProfileProjectsCreate(cfg, w, r)
case "/whoami":
@ -85,13 +87,13 @@ type AccessView struct {
// JSON endpoint at /.profile/access; the HTML page no longer calls this on
// the request hot path — it ships a shell first and the client fetches the
// view after first paint.
func enumerateAccess(cfg config.Config, email string) AccessView {
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, email string) AccessView {
view := AccessView{
Email: email,
EmailHeader: cfg.EmailHeader,
IsSuperAdmin: zddc.IsAdmin(cfg.Root, email),
}
view.Projects, _ = EnumerateProjects(cfg, email)
view.Projects, _ = EnumerateProjects(ctx, decider, cfg, email)
view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
for _, t := range view.AdminSubtrees {

View file

@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
@ -9,6 +10,7 @@ import (
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
@ -27,7 +29,7 @@ type ProjectInfo struct {
// It returns all top-level directories under cfg.Root that the requesting
// user has access to, as a JSON array of ProjectInfo.
func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) {
projects, err := EnumerateProjects(cfg, EmailFromContext(r))
projects, err := EnumerateProjects(r.Context(), DeciderFromContext(r), cfg, EmailFromContext(r))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
@ -42,8 +44,11 @@ func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request)
// EnumerateProjects returns the visible top-level projects for the given
// caller, reusing the same access logic as ServeProjectList. Exported so
// the profile page can render the same list server-side without an HTTP
// round-trip.
func EnumerateProjects(cfg config.Config, email string) ([]ProjectInfo, error) {
// round-trip. A nil decider falls back to the internal Go evaluator.
func EnumerateProjects(ctx context.Context, decider policy.Decider, cfg config.Config, email string) ([]ProjectInfo, error) {
if decider == nil {
decider = &policy.InternalDecider{}
}
entries, err := os.ReadDir(cfg.Root)
if err != nil {
slog.Error("reading root directory", "err", err)
@ -69,7 +74,7 @@ func EnumerateProjects(cfg config.Config, email string) ([]ProjectInfo, error) {
if err != nil {
slog.Warn("ACL policy error", "path", absPath, "err", err)
}
if !zddc.AllowedWithChain(chain, email) {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+name+"/"); !allowed {
continue
}
// Title comes from <project>/.zddc — optional, ignored on parse error.

View file

@ -0,0 +1,225 @@
// Package policy is the access-decision boundary for zddc-server.
//
// All ACL checks in handlers go through Decider.Allow rather than
// calling zddc.AllowedWithChain directly. This lets a deployment
// route policy decisions to an external OPA-compatible server
// (for federal customers running their own audited Rego policies)
// without changing handler code.
//
// Two implementations:
//
// - InternalDecider — wraps zddc.AllowedWithChain. The default;
// no new dependencies, identical semantics to the legacy code
// path. This is what the docs in zddc/README.md describe.
//
// - HTTPDecider — POSTs to OPA's canonical /v1/data/<package>/allow
// endpoint over HTTP or a Unix-domain socket. Federal customers
// deploy real OPA alongside zddc-server, write their own Rego,
// and point ZDDC_OPA_URL at it.
//
// Configuration knob:
//
// ZDDC_OPA_URL= # internal (default)
// ZDDC_OPA_URL=internal # internal (explicit)
// ZDDC_OPA_URL=http://127.0.0.1:8181 # external HTTP
// ZDDC_OPA_URL=https://opa.example:8181 # external HTTPS
// ZDDC_OPA_URL=unix:///run/opa/opa.sock # external Unix socket
//
// Failure mode (external only): unreachable / non-2xx / malformed
// response → fail closed (deny), with a WARN log. Operators who
// prefer availability over correctness can set ZDDC_OPA_FAIL_OPEN=1
// to flip to fail-open with a WARN log instead.
package policy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// AllowInput is the canonical input shape for Decider.Allow. It
// matches OPA's input convention: a JSON object passed as the
// "input" field of a /v1/data/<package>/allow query.
//
// External Rego policies can:
// - read input.user.email (string)
// - read input.path (string)
// - walk input.policy_chain.levels[].acl.{allow,deny} for
// custom cascade semantics, or read the pre-resolved
// input.policy_chain.has_any_file when implementing the
// same default-deny rule we use internally.
type AllowInput struct {
User struct {
Email string `json:"email"`
} `json:"user"`
Path string `json:"path"`
PolicyChain *SerializableChain `json:"policy_chain,omitempty"`
}
// SerializableChain is a JSON-friendly view of zddc.PolicyChain.
// We don't tag zddc.PolicyChain itself because it's tightly coupled
// to the parser; the duplication is one struct.
type SerializableChain struct {
Levels []zddc.ZddcFile `json:"levels"`
HasAnyFile bool `json:"has_any_file"`
}
func chainToSerializable(c zddc.PolicyChain) *SerializableChain {
return &SerializableChain{Levels: c.Levels, HasAnyFile: c.HasAnyFile}
}
// Decider is the access-decision interface every handler uses.
type Decider interface {
Allow(ctx context.Context, input AllowInput) (bool, error)
}
// Config selects and parameterizes the decider.
type Config struct {
URL string // raw value: "", "internal", "http(s)://...", "unix:///path"
FailOpen bool // external mode only: on transport error, allow instead of deny
}
// New constructs a Decider per cfg.URL semantics.
// - "" or "internal" → InternalDecider
// - "http(s)://..." → HTTPDecider
// - "unix:///..." → HTTPDecider over a Unix socket
//
// Returns an error if URL is unrecognized.
func New(cfg Config) (Decider, error) {
if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") {
return &InternalDecider{}, nil
}
if strings.HasPrefix(cfg.URL, "http://") || strings.HasPrefix(cfg.URL, "https://") {
return newHTTPDecider(cfg.URL, cfg.FailOpen, nil)
}
if strings.HasPrefix(cfg.URL, "unix://") {
path := strings.TrimPrefix(cfg.URL, "unix://")
dialer := &net.Dialer{Timeout: 2 * time.Second}
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, "unix", path)
},
}
// HTTP host part is unused but the URL still has to parse.
return newHTTPDecider("http://opa-unix-socket", cfg.FailOpen, transport)
}
return nil, fmt.Errorf("unrecognized ZDDC_OPA_URL %q (want \"internal\", http(s)://..., or unix:///...)", cfg.URL)
}
// InternalDecider routes Allow through zddc.AllowedWithChain. No
// network, no Rego, no new dependencies — same Go evaluator the
// existing test suite covers.
type InternalDecider struct{}
func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, error) {
chain := zddc.PolicyChain{}
if input.PolicyChain != nil {
chain.Levels = input.PolicyChain.Levels
chain.HasAnyFile = input.PolicyChain.HasAnyFile
}
return zddc.AllowedWithChain(chain, input.User.Email), nil
}
// HTTPDecider POSTs to /v1/data/zddc/access/allow on the configured
// endpoint. Spec:
// - request body {"input": <AllowInput>}
// - response body {"result": true|false}
// - 5-second per-request timeout
// - non-2xx, transport error, missing/malformed result → policy
// decision is "deny" unless FailOpen=true
//
// The path "/v1/data/zddc/access/allow" is the OPA convention; the
// "zddc.access" Rego package on an external server would expose
// `allow` for this endpoint.
type HTTPDecider struct {
endpoint string
client *http.Client
failOpen bool
}
func newHTTPDecider(endpoint string, failOpen bool, transport http.RoundTripper) (*HTTPDecider, error) {
if _, err := url.Parse(endpoint); err != nil {
return nil, fmt.Errorf("invalid OPA URL %q: %w", endpoint, err)
}
c := &http.Client{Timeout: 5 * time.Second}
if transport != nil {
c.Transport = transport
}
return &HTTPDecider{
endpoint: strings.TrimRight(endpoint, "/") + "/v1/data/zddc/access/allow",
client: c,
failOpen: failOpen,
}, nil
}
type opaResponse struct {
Result *bool `json:"result"`
}
func (d *HTTPDecider) Allow(ctx context.Context, input AllowInput) (bool, error) {
body, err := json.Marshal(struct {
Input AllowInput `json:"input"`
}{Input: input})
if err != nil {
return d.failResult(fmt.Errorf("marshal input: %w", err))
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.endpoint, bytes.NewReader(body))
if err != nil {
return d.failResult(fmt.Errorf("build request: %w", err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := d.client.Do(req)
if err != nil {
return d.failResult(fmt.Errorf("opa request: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Read up to 512 bytes of the error body for the log without
// blowing up on a verbose OPA error page.
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return d.failResult(fmt.Errorf("opa returned %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))))
}
var parsed opaResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return d.failResult(fmt.Errorf("decode opa response: %w", err))
}
if parsed.Result == nil {
return d.failResult(errors.New("opa response missing 'result' field"))
}
return *parsed.Result, nil
}
// failResult logs the failure and returns the configured fail-mode
// decision. Logged at WARN so a healthy run is silent but a sick OPA
// is loud.
func (d *HTTPDecider) failResult(err error) (bool, error) {
if d.failOpen {
slog.Warn("policy decision failed; failing open (allow)", "endpoint", d.endpoint, "err", err)
return true, nil
}
slog.Warn("policy decision failed; failing closed (deny)", "endpoint", d.endpoint, "err", err)
return false, nil
}
// AllowFromChain is a convenience for callers that already have a
// PolicyChain in hand. Equivalent to constructing AllowInput manually
// from (chain, email, path) and calling d.Allow.
func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) {
in := AllowInput{Path: path, PolicyChain: chainToSerializable(chain)}
in.User.Email = email
return d.Allow(ctx, in)
}

View file

@ -0,0 +1,258 @@
package policy
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// TestNew_ModeSelection: New() picks the right implementation per URL.
func TestNew_ModeSelection(t *testing.T) {
cases := []struct {
url string
wantType string
wantErr bool
}{
{"", "*policy.InternalDecider", false},
{"internal", "*policy.InternalDecider", false},
{"INTERNAL", "*policy.InternalDecider", false},
{"http://127.0.0.1:8181", "*policy.HTTPDecider", false},
{"https://opa.example:8181", "*policy.HTTPDecider", false},
{"unix:///run/opa.sock", "*policy.HTTPDecider", false},
{"ftp://nope", "", true},
{"garbage", "", true},
}
for _, tc := range cases {
t.Run(tc.url, func(t *testing.T) {
d, err := New(Config{URL: tc.url})
if tc.wantErr {
if err == nil {
t.Fatal("New() = nil error, want error")
}
return
}
if err != nil {
t.Fatalf("New() unexpected error: %v", err)
}
got := describe(d)
if got != tc.wantType {
t.Errorf("New(%q) → %s, want %s", tc.url, got, tc.wantType)
}
})
}
}
func describe(v interface{}) string {
switch v.(type) {
case *InternalDecider:
return "*policy.InternalDecider"
case *HTTPDecider:
return "*policy.HTTPDecider"
default:
return "unknown"
}
}
// TestInternalDecider_ParityWithAllowedWithChain: the internal
// decider returns the same answer as zddc.AllowedWithChain for
// every documented cascade scenario.
func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
allow := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}} }
deny := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}} }
allowDeny := func(a, d []string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
}
empty := zddc.ZddcFile{}
cases := []struct {
name string
chain zddc.PolicyChain
email string
want bool
}{
{
"empty chain, no files → allow",
zddc.PolicyChain{HasAnyFile: false},
"alice@example.com",
true,
},
{
"files exist but no rule matches → deny",
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
"alice@example.com",
false,
},
{
"leaf allow wins",
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"leaf deny beats parent allow (bottom-up first match)",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
deny("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
false,
},
{
"leaf has no rule for user, falls back to parent allow",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
allow("bob@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"leaf allows user that parent denies",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"multi-level: deepest match wins",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
}
d := &InternalDecider{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := AllowFromChain(context.Background(), d, tc.chain, tc.email, "/test")
if err != nil {
t.Fatalf("AllowFromChain: %v", err)
}
want := zddc.AllowedWithChain(tc.chain, tc.email)
if got != want {
t.Errorf("decider = %v, AllowedWithChain = %v (parity broken)", got, want)
}
if got != tc.want {
t.Errorf("decider = %v, want %v", got, tc.want)
}
})
}
}
// TestHTTPDecider_HappyPath: the HTTP decider posts the canonical
// OPA shape and acts on a 200 with {"result": true|false}.
func TestHTTPDecider_HappyPath(t *testing.T) {
var captured struct {
path string
contentType string
body []byte
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured.path = r.URL.Path
captured.contentType = r.Header.Get("Content-Type")
captured.body, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
in.PolicyChain = &SerializableChain{HasAnyFile: true}
got, err := d.Allow(context.Background(), in)
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != true {
t.Errorf("Allow = false, want true")
}
if captured.path != "/v1/data/zddc/access/allow" {
t.Errorf("path = %q, want /v1/data/zddc/access/allow", captured.path)
}
if !strings.HasPrefix(captured.contentType, "application/json") {
t.Errorf("content-type = %q, want application/json", captured.contentType)
}
// Body should be {"input": {...}}
var wrap struct{ Input AllowInput }
if err := json.Unmarshal(captured.body, &wrap); err != nil {
t.Fatalf("body did not unmarshal as {input}: %v (body=%s)", err, captured.body)
}
if wrap.Input.User.Email != "alice@example.com" {
t.Errorf("body.input.user.email = %q, want alice@example.com", wrap.Input.User.Email)
}
}
// TestHTTPDecider_FailClosed: any transport/encoding/HTTP error
// returns deny (false) by default, with no Go error returned to
// the caller (handlers don't have to special-case it).
func TestHTTPDecider_FailClosed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != false {
t.Errorf("on 500, Allow = true, want false (fail-closed default)")
}
}
// TestHTTPDecider_FailOpen: with FailOpen=true, a transport error
// returns allow.
func TestHTTPDecider_FailOpen(t *testing.T) {
d, err := New(Config{URL: "http://127.0.0.1:1", FailOpen: true})
if err != nil {
t.Fatalf("New: %v", err)
}
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != true {
t.Errorf("on unreachable host with FailOpen, Allow = false, want true")
}
}
// TestHTTPDecider_MalformedResponse: a 200 with a missing/garbage
// result field also fails closed.
func TestHTTPDecider_MalformedResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"unexpected": "shape"}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != false {
t.Errorf("on missing result, Allow = true, want false (fail-closed)")
}
}

View file

@ -7,9 +7,13 @@ import (
)
// ACLRules holds email allow/deny lists.
//
// JSON tags are present so this type round-trips cleanly when included
// in the external-OPA input body (see internal/policy). The canonical
// in-repo serialization is YAML; JSON is only used for OPA queries.
type ACLRules struct {
Allow []string `yaml:"allow"`
Deny []string `yaml:"deny"`
Allow []string `yaml:"allow" json:"allow,omitempty"`
Deny []string `yaml:"deny" json:"deny,omitempty"`
}
// ZddcFile represents the parsed contents of a .zddc configuration file.
@ -36,10 +40,10 @@ type ACLRules struct {
// Fetched URL sources are cached in <ZDDC_ROOT>/_app/; the cache is fetch-once
// and never re-validates — operators delete the file to force a refetch.
type ZddcFile struct {
ACL ACLRules `yaml:"acl"`
Admins []string `yaml:"admins"`
Title string `yaml:"title"`
Apps map[string]string `yaml:"apps,omitempty"`
ACL ACLRules `yaml:"acl" json:"acl"`
Admins []string `yaml:"admins" json:"admins,omitempty"`
Title string `yaml:"title" json:"title,omitempty"`
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
}
// ParseFile reads and parses a .zddc YAML file.