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>
147 lines
5.1 KiB
Go
147 lines
5.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
// ServeArchive handles requests under a .archive virtual path segment.
|
|
//
|
|
// .archive is exposed at every folder depth so HTML produced for offline use
|
|
// can reference sibling tracking numbers via "../.archive/<tracking>.html".
|
|
// In a browser the relative link is resolved before the request reaches the
|
|
// server, so the contextPath the request arrives under is significant: its
|
|
// FIRST segment is the project, and the .archive listing/resolver is scoped
|
|
// to that project's bucket. This avoids cross-project collisions when the
|
|
// same tracking number is issued under multiple projects.
|
|
//
|
|
// contextPath: the URL path leading up to (but not including) .archive
|
|
// - first segment selects the project bucket
|
|
// - used to gate the listing endpoint via cascading .zddc ACL
|
|
// - used as the URL prefix for the entries returned in the listing
|
|
// - empty (root /.archive/) returns 404 — refs must be project-rooted
|
|
//
|
|
// 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 == "" {
|
|
http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. /<project>/.archive/)", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// ACL gate on the context directory: callers who can't reach the
|
|
// directory hosting this .archive shouldn't be able to query it either.
|
|
dirPath := strings.TrimPrefix(contextPath, "/")
|
|
dirPath = strings.TrimSuffix(dirPath, "/")
|
|
absDir := filepath.Join(cfg.Root, filepath.FromSlash(dirPath))
|
|
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
|
|
if err != nil {
|
|
slog.Warn("ACL policy error", "path", absDir, "err", err)
|
|
}
|
|
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, contextPath); !allowed {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if filename == "" {
|
|
serveArchiveListing(cfg, idx, w, r, contextPath, project, email)
|
|
return
|
|
}
|
|
|
|
target, ok := archive.Resolve(idx, project, filename)
|
|
if !ok {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Per-target ACL: the resolved file may live in a subtree the caller
|
|
// can't reach even though they could reach the contextPath. 404 (not
|
|
// 403) so the tracking number's mere existence isn't disclosed.
|
|
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(target)))
|
|
chain, err = zddc.EffectivePolicy(cfg.Root, fileDir)
|
|
if err != nil {
|
|
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
|
|
}
|
|
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+target); !allowed {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/"+target, http.StatusFound)
|
|
}
|
|
|
|
// projectFromContextPath returns the first non-empty segment of the
|
|
// contextPath, which is the project bucket key for archive lookups. Returns
|
|
// "" for "/" or "" (root .archive — has no project).
|
|
func projectFromContextPath(contextPath string) string {
|
|
cleaned := strings.Trim(contextPath, "/")
|
|
if cleaned == "" {
|
|
return ""
|
|
}
|
|
if i := strings.IndexByte(cleaned, '/'); i >= 0 {
|
|
return cleaned[:i]
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
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, "/") {
|
|
archiveBase += "/"
|
|
}
|
|
archiveBase += cfg.IndexPath + "/"
|
|
|
|
// ACL chains are folder-keyed and the listing typically hits the same
|
|
// few directories repeatedly (one per transmittal folder), so cache the
|
|
// allow/deny decision per directory rather than re-walking .zddc files
|
|
// for every entry.
|
|
aclCache := make(map[string]bool)
|
|
allowed := func(targetPath string) bool {
|
|
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(targetPath)))
|
|
if v, ok := aclCache[fileDir]; ok {
|
|
return v
|
|
}
|
|
chain, err := zddc.EffectivePolicy(cfg.Root, fileDir)
|
|
if err != nil {
|
|
aclCache[fileDir] = false
|
|
return false
|
|
}
|
|
v, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+targetPath)
|
|
aclCache[fileDir] = v
|
|
return v
|
|
}
|
|
|
|
var result []listing.FileInfo
|
|
for _, e := range allEntries {
|
|
if !allowed(e.TargetPath) {
|
|
continue
|
|
}
|
|
result = append(result, listing.FileInfo{
|
|
Name: e.URLName,
|
|
URL: archiveBase + e.URLName,
|
|
IsDir: false,
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
slog.Error("encoding archive listing", "err", err)
|
|
}
|
|
}
|