ZDDC/zddc/internal/handler/directory.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

141 lines
4.9 KiB
Go

package handler
import (
"encoding/json"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"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"
)
// safeJoin joins fsRoot and relPath, then verifies the result is under fsRoot.
// Returns ("", false) if relPath would escape fsRoot.
func safeJoin(fsRoot, relPath string) (string, bool) {
abs := filepath.Join(fsRoot, filepath.FromSlash(relPath))
if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot {
return "", false
}
return abs, true
}
// ServeDirectory handles a request for a directory path.
// If Accept: application/json → return Caddy-compatible JSON listing.
// Otherwise → return minimal HTML.
func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
if !strings.HasSuffix(urlPath, "/") {
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
return
}
email := EmailFromContext(r)
decider := DeciderFromContext(r)
ctx := r.Context()
// Compute relative dir path (no leading or trailing slash)
dirPath := strings.TrimPrefix(urlPath, "/")
dirPath = strings.TrimSuffix(dirPath, "/")
// ACL check on this directory itself.
// Bypassed at the root path: the landing page is a public project
// picker. Per-project filtering inside fs.ListDirectory still hides
// directories the caller can't reach.
absDir, ok := safeJoin(cfg.Root, dirPath)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
if err != nil {
slog.Warn("ACL policy error", "path", absDir, "err", err)
}
isRoot := dirPath == ""
if !isRoot {
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
accept := r.Header.Get("Accept")
// For HTML requests, serve index.html if present (landing page convention)
if !strings.Contains(accept, "application/json") {
indexPath := filepath.Join(absDir, "index.html")
if info, err := os.Stat(indexPath); err == nil && !info.IsDir() {
ServeFile(w, r, indexPath)
return
}
}
// Build base URL for listing entries
baseURL := urlPath // relative URLs suffice for JSON listings
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)
} else {
slog.Error("listing directory", "path", dirPath, "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
// Vary: Accept is critical — the same URL serves either the JSON
// listing or the embedded browse.html depending on the Accept
// header. Without Vary, browsers/CDNs cache one response and
// serve it for the other Accept value, breaking browse.html's
// auto-detect (which fetches the same URL with Accept: JSON).
w.Header().Set("Vary", "Accept")
if strings.Contains(accept, "application/json") {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(entries); err != nil {
slog.Error("encoding directory listing", "err", err)
}
return
}
// Browser HTML fallback: serve the embedded `browse` tool. It's a
// single-file SPA whose autoDetectServerMode loads the JSON listing
// for the current directory and renders it as a sortable, filterable
// tree. Same bytes that get served at /<dir>/browse.html — but at
// the bare directory URL too, so any zddc-served folder presents a
// usable file browser to anyone who navigates to it.
body := apps.EmbeddedBytes("browse")
if len(body) == 0 {
// Bootstrap state: a fresh build hasn't populated browse.html
// into the embed yet. Fall through to JSON for clients that
// will still parse it.
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(entries); err != nil {
slog.Error("encoding directory listing (no-embed fallback)", "err", err)
}
return
}
// ETag + max-age=0 + must-revalidate: every request re-validates and
// gets a 304 unless the binary has been redeployed (the ETag is a
// content hash, computed once at startup and memoized in apps.embed).
// Saves re-transmitting ~230 KB of browse.html on every page load
// while still picking up redeploys immediately.
etag := `"` + apps.EmbeddedETag("browse") + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "embedded:browse")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
}