diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index f3df1fc..a18a5ba 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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 } diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index 44d997c..f8b78d0 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -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.d/logs/access-.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.d/logs/access-.log; --access-log= disables.") fs.Bool("help", false, "Print this help and exit.") fs.Bool("version", false, "Print version info and exit.") diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index a0b5f4f..4cae494 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -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{ diff --git a/zddc/internal/handler/archivehandler.go b/zddc/internal/handler/archivehandler.go index 05c1b0a..95a3973 100644 --- a/zddc/internal/handler/archivehandler.go +++ b/zddc/internal/handler/archivehandler.go @@ -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 } diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index 8d720aa..5069898 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -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) diff --git a/zddc/internal/handler/formhandler.go b/zddc/internal/handler/formhandler.go index 9deb915..2d1724a 100644 --- a/zddc/internal/handler/formhandler.go +++ b/zddc/internal/handler/formhandler.go @@ -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 } diff --git a/zddc/internal/handler/middleware.go b/zddc/internal/handler/middleware.go index 4fccdb3..f0c1779 100644 --- a/zddc/internal/handler/middleware.go +++ b/zddc/internal/handler/middleware.go @@ -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 diff --git a/zddc/internal/handler/middleware_test.go b/zddc/internal/handler/middleware_test.go index ab1a7de..4b856f0 100644 --- a/zddc/internal/handler/middleware_test.go +++ b/zddc/internal/handler/middleware_test.go @@ -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") diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index 2f64cdd..0fc2796 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -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 { diff --git a/zddc/internal/handler/projectshandler.go b/zddc/internal/handler/projectshandler.go index 8c9152a..d3fe9f6 100644 --- a/zddc/internal/handler/projectshandler.go +++ b/zddc/internal/handler/projectshandler.go @@ -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 /.zddc — optional, ignored on parse error. diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go new file mode 100644 index 0000000..4faef4b --- /dev/null +++ b/zddc/internal/policy/policy.go @@ -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//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//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": } +// - 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) +} diff --git a/zddc/internal/policy/policy_test.go b/zddc/internal/policy/policy_test.go new file mode 100644 index 0000000..93b7a6a --- /dev/null +++ b/zddc/internal/policy/policy_test.go @@ -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)") + } +} diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 1f824da..863839b 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -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 /_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.