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>
252 lines
7.9 KiB
Go
252 lines
7.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// ProfilePathPrefix is the URL prefix at which the user-profile page is
|
|
// served. The dot-prefix keeps the namespace out of project-name space
|
|
// (resolvePath rejects dot-prefixed user paths) and matches the `.zddc`
|
|
// / `.archive` reserved-prefix convention.
|
|
const ProfilePathPrefix = "/.profile"
|
|
|
|
// ServeProfile is the entry point for /.profile/* routes. The top-level
|
|
// page and the access-summary JSON are reachable to anyone (anonymous
|
|
// included); admin-only sub-resources (whoami / config / logs / projects /
|
|
// the .zddc editor) keep their existing per-resource 404 leakage gates.
|
|
func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *http.Request) {
|
|
sub := strings.TrimPrefix(r.URL.Path, ProfilePathPrefix)
|
|
if sub == "" {
|
|
sub = "/"
|
|
}
|
|
|
|
// Delegated to ServeZddc; that handler has its own hasAnyAdminScope gate.
|
|
if sub == "/zddc" || strings.HasPrefix(sub, "/zddc/") {
|
|
ServeZddc(cfg, w, r)
|
|
return
|
|
}
|
|
|
|
email := EmailFromContext(r)
|
|
|
|
switch sub {
|
|
case "/", "":
|
|
serveProfilePage(cfg, w, r)
|
|
case "/access":
|
|
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, email))
|
|
case "/projects":
|
|
serveProfileProjectsCreate(cfg, w, r)
|
|
case "/whoami":
|
|
if !zddc.IsAdmin(cfg.Root, email) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
serveProfileWhoami(cfg, email, w, r)
|
|
case "/config":
|
|
if !zddc.IsAdmin(cfg.Root, email) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
serveProfileConfig(cfg, w, r)
|
|
case "/logs":
|
|
if !zddc.IsAdmin(cfg.Root, email) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
serveProfileLogs(ring, w, r)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// AccessView is the data the profile page lazy-loads from /.profile/access
|
|
// after first paint. The HTML shell renders only Email/EmailHeader/
|
|
// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come
|
|
// in via JS. EditableParentChoices is what the create-project form's
|
|
// parent-selector renders — derived from AdminSubtrees on the client.
|
|
type AccessView struct {
|
|
Email string `json:"email"`
|
|
EmailHeader string `json:"email_header"`
|
|
IsSuperAdmin bool `json:"is_super_admin"`
|
|
HasAnyAdminScope bool `json:"has_any_admin_scope"`
|
|
Projects []ProjectInfo `json:"projects"`
|
|
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
|
EditableParentChoices []treeEntry `json:"editable_parent_choices"`
|
|
}
|
|
|
|
// enumerateAccess builds an AccessView for the given caller. Used by the
|
|
// 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(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(ctx, decider, cfg, email)
|
|
view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
|
|
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
|
|
for _, t := range view.AdminSubtrees {
|
|
if t.CanEdit {
|
|
view.EditableParentChoices = append(view.EditableParentChoices, t)
|
|
}
|
|
}
|
|
return view
|
|
}
|
|
|
|
// enumerateAdminSubtrees lists every directory containing a .zddc that the
|
|
// caller can see as an admin (super-admin or subtree-admin). Each entry
|
|
// carries can_edit so the page can label read-only entries (the file that
|
|
// grants the user's own authority).
|
|
func enumerateAdminSubtrees(cfg config.Config, email string) []treeEntry {
|
|
dirs, _ := zddc.ScanZddcFiles(cfg.Root)
|
|
out := make([]treeEntry, 0, len(dirs))
|
|
for _, d := range dirs {
|
|
if !zddc.IsSubtreeAdmin(cfg.Root, d, email) && !zddc.IsAdmin(cfg.Root, email) {
|
|
continue
|
|
}
|
|
var title string
|
|
if zf, err := zddc.ParseFile(filepath.Join(d, ".zddc")); err == nil {
|
|
title = zf.Title
|
|
}
|
|
out = append(out, treeEntry{
|
|
Path: urlPathOf(cfg.Root, d),
|
|
CanEdit: zddc.CanEditZddc(cfg.Root, d, email),
|
|
Title: title,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// writeJSON writes v as indented JSON. Sets Content-Type and disables caching
|
|
// (profile views are always live).
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
enc := json.NewEncoder(w)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(v)
|
|
}
|
|
|
|
// serveProfileWhoami returns the data needed to debug header-passthrough
|
|
// problems: which header the server is configured to read, what value (if
|
|
// any) arrived under that header, the resolved email, and a dump of every
|
|
// header on the request.
|
|
func serveProfileWhoami(cfg config.Config, email string, w http.ResponseWriter, r *http.Request) {
|
|
keys := make([]string, 0, len(r.Header))
|
|
for k := range r.Header {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
headers := make(map[string][]string, len(keys))
|
|
for _, k := range keys {
|
|
headers[k] = r.Header.Values(k)
|
|
}
|
|
|
|
type response struct {
|
|
ConfiguredEmailHeader string `json:"configured_email_header"`
|
|
ObservedEmail string `json:"observed_email"`
|
|
ResolvedEmail string `json:"resolved_email"`
|
|
RemoteAddr string `json:"remote_addr"`
|
|
Method string `json:"method"`
|
|
URL string `json:"url"`
|
|
Headers map[string][]string `json:"headers"`
|
|
}
|
|
writeJSON(w, response{
|
|
ConfiguredEmailHeader: cfg.EmailHeader,
|
|
ObservedEmail: r.Header.Get(cfg.EmailHeader),
|
|
ResolvedEmail: email,
|
|
RemoteAddr: r.RemoteAddr,
|
|
Method: r.Method,
|
|
URL: r.URL.String(),
|
|
Headers: headers,
|
|
})
|
|
}
|
|
|
|
// serveProfileConfig dumps the parsed Config. TLS cert/key paths are echoed,
|
|
// not their file contents; nothing else here is secret.
|
|
func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|
type response struct {
|
|
Root string `json:"root"`
|
|
Addr string `json:"addr"`
|
|
TLSCert string `json:"tls_cert"`
|
|
TLSKey string `json:"tls_key"`
|
|
TLSMode string `json:"tls_mode"`
|
|
LogLevel string `json:"log_level"`
|
|
IndexPath string `json:"index_path"`
|
|
EmailHeader string `json:"email_header"`
|
|
CORSOrigins []string `json:"cors_origins"`
|
|
}
|
|
writeJSON(w, response{
|
|
Root: cfg.Root,
|
|
Addr: cfg.Addr,
|
|
TLSCert: cfg.TLSCert,
|
|
TLSKey: cfg.TLSKey,
|
|
TLSMode: cfg.TLSMode,
|
|
LogLevel: cfg.LogLevel,
|
|
IndexPath: cfg.IndexPath,
|
|
EmailHeader: cfg.EmailHeader,
|
|
CORSOrigins: cfg.CORSOrigins,
|
|
})
|
|
}
|
|
|
|
// serveProfileLogs returns the ring buffer's current contents. Optional query
|
|
// params: level=debug|info|warn|error and since=<RFC3339>.
|
|
func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) {
|
|
if ring == nil {
|
|
writeJSON(w, []LogEntry{})
|
|
return
|
|
}
|
|
|
|
entries := ring.Snapshot()
|
|
|
|
if levelStr := r.URL.Query().Get("level"); levelStr != "" {
|
|
min := levelRank(levelStr)
|
|
out := entries[:0]
|
|
for _, e := range entries {
|
|
if levelRank(strings.ToLower(e.Level)) >= min {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
entries = out
|
|
}
|
|
|
|
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
|
|
if since, err := time.Parse(time.RFC3339, sinceStr); err == nil {
|
|
out := entries[:0]
|
|
for _, e := range entries {
|
|
if !e.Time.Before(since) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
entries = out
|
|
}
|
|
}
|
|
|
|
writeJSON(w, entries)
|
|
}
|
|
|
|
func levelRank(s string) int {
|
|
switch strings.ToLower(s) {
|
|
case "debug":
|
|
return 0
|
|
case "info":
|
|
return 1
|
|
case "warn", "warning":
|
|
return 2
|
|
case "error":
|
|
return 3
|
|
default:
|
|
return 1 // unknown → info
|
|
}
|
|
}
|