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

92 lines
3.1 KiB
Go

package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ProjectInfo is a single entry in the project list response.
//
// Title is read from the project's own .zddc file (its `title:` field) when
// present; absent or empty means the landing page shows just the directory
// name. omitempty keeps the JSON small for projects without titles.
type ProjectInfo struct {
Name string `json:"name"`
URL string `json:"url"`
Title string `json:"title,omitempty"`
}
// ServeProjectList handles GET / with Accept: application/json.
// 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(r.Context(), DeciderFromContext(r), cfg, EmailFromContext(r))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(projects); err != nil {
slog.Error("encoding project list", "err", err)
}
}
// 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. 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)
return nil, err
}
var projects []ProjectInfo
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
// Skip hidden directories. Both '.' and '_' are reserved prefixes:
// '.' for system/internal state (matches the listing-pipeline filter
// and the dispatch dot-prefix guard); '_' for operator-managed
// scaffolding like install.zip's _template/ directory that should
// be reachable by direct URL but not appear in the project picker.
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
continue
}
absPath := filepath.Join(cfg.Root, name)
chain, err := zddc.EffectivePolicy(cfg.Root, absPath)
if err != nil {
slog.Warn("ACL policy error", "path", absPath, "err", err)
}
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+name+"/"); !allowed {
continue
}
// Title comes from <project>/.zddc — optional, ignored on parse error.
var title string
if zf, err := zddc.ParseFile(filepath.Join(absPath, ".zddc")); err == nil {
title = zf.Title
}
projects = append(projects, ProjectInfo{
Name: name,
URL: "/" + name + "/",
Title: title,
})
}
return projects, nil
}