ZDDC/zddc/internal/handler/profilehandler.go
ZDDC 3115e388fc feat(server): authenticated CRUD + verb-based RBAC with WORM archive folders
Replaces the binary acl.allow/deny model with five permission verbs
(r/w/c/d/a) and first-class roles, and adds an authenticated file API
(PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over
HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps.

File API (zddc/internal/handler/fileapi.go)
  - PUT <new>      → action c
  - PUT <existing> → action w
  - PUT <.zddc>    → action a (CanEditZddc strict-ancestor rule)
  - DELETE         → action d
  - POST mkdir     → action c (auto-writes creator-owned .zddc when the
                     parent is Incoming/Working/Staging)
  - POST move      → action w on src + c on dst, atomic via os.Rename
  - Optional If-Match for optimistic concurrency, --max-write-bytes cap,
    audit log emits a structured file_write event per operation.

Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go)
  - acl.permissions: { principal → verb-set } map; principals are email
    patterns or role names. Empty verb set is an explicit deny.
  - roles: { name → members } definitions, available at the level they
    declare and all descendants. Closer-to-leaf shadows ancestor.
  - Legacy acl.allow/deny still work; they fold into permissions at
    parse time (allow → "rwcd", deny → "").
  - Cascade walks leaf→root; first level with any matching entry wins;
    the union of matching verb sets at that level decides.
  - --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an
    ancestor explicit-deny is absolute (NIST AC-6). Default delegated
    preserves the existing commercial behavior.

Special folders (zddc/internal/zddc/special.go)
  - Incoming / Working / Staging: mkdir auto-writes a .zddc into the new
    subdir granting created_by + that email rwcda directly. Same form
    operators write by hand; creator can edit it later to add others.
  - Issued / Received: server-enforced WORM split. Cascade grants
    inherited from above the WORM folder are masked to r only; grants
    placed at-or-below the WORM folder retain r,c. Operators grant
    write-once (cr) to the doc controller via an explicit .zddc at the
    Issued/Received folder. Admins exempt — only escape hatch.

Browser polyfill (shared/zddc-source.js)
  - HttpDirectoryHandle + HttpFileHandle implement the FS Access API
    surface (values, getFileHandle, createWritable, removeEntry,
    queryPermission/requestPermission) over zddc-server's listing JSON
    and file API. Existing tools written against showDirectoryPicker
    work unchanged.
  - detectServerRoot() returns { handle, status }: tools auto-load on
    HTTP, surface a clear "no permission to list" message on 403, and
    fall back to the welcome screen on 0.
  - classifier renames take the atomic POST move path on HTTP-backed
    handles; mdedit and transmittal route reads/writes through the
    polyfill so prior FS-API code paths cover both modes.

Tests
  - zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover
    delegated vs strict, role membership / shadowing / legacy fallback,
    WORM split semantics, verb-set parser round-trip.
  - zddc/internal/handler/fileapi_test.go now also covers role-based
    vendor scenarios, WORM blocking vendor & doc controller writes,
    explicit Issued .zddc unlocking the cr drop-box, admin bypass,
    auto-ownership on mkdir, and strict-mode lockouts.

Docs
  - ARCHITECTURE.md + zddc/README.md document the verb model, role
    syntax, special-folder behaviors, cascade-mode flag, and full file
    API surface. Federal-readiness gap analysis strikes AC-3(7) and
    AC-6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:58:04 -05:00

438 lines
14 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"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)
case "/effective-policy":
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
serveProfileEffectivePolicy(cfg, 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"`
CascadeMode string `json:"cascade_mode"`
}
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,
CascadeMode: cfg.CascadeMode,
})
}
// 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
}
}
// serveProfileEffectivePolicy is the cascade-tracer endpoint:
// /.profile/effective-policy?path=<URL-path>&email=<email>
// returns the resolved policy chain plus the allow/deny decision the
// active decider produces, in JSON. Eliminates the need for operators
// to manual-trace .zddc files when debugging "why can't alice see
// /Project-X?" reports.
//
// Both query params are required. The endpoint is admin-only (404 to
// non-admins via the dispatch gate).
//
// Response shape (each chain level is a directory along the walk
// from ZDDC_ROOT down to the requested path):
//
// {
// "path": "/Project-X/sub/",
// "email": "alice@mycompany.com",
// "decision": true,
// "decider_kind": "*policy.InternalDecider",
// "chain": {
// "has_any_file": true,
// "levels": [
// {"path": "/", "exists": true, "acl": {"allow": [...]}, "admins": [...]},
// {"path": "/Project-X/", "exists": false},
// {"path": "/Project-X/sub/", "exists": true, "acl": {"allow": [...]}}
// ]
// }
// }
//
// Note: this evaluates the same input the production hot path would
// build for a request from <email> to <path>; if zddc-server is
// configured for external OPA, the decision goes through that OPA
// (so this endpoint is also a useful smoke test for the OPA wiring).
func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
probePath := q.Get("path")
probeEmail := q.Get("email")
if probePath == "" || probeEmail == "" {
http.Error(w, "both ?path= and ?email= are required", http.StatusBadRequest)
return
}
if !strings.HasPrefix(probePath, "/") {
http.Error(w, "path must start with /", http.StatusBadRequest)
return
}
// Resolve the URL path to a filesystem directory the same way the
// dispatch hot path does.
rel := strings.TrimPrefix(probePath, "/")
rel = strings.TrimSuffix(rel, "/")
absDir, ok := safeJoin(cfg.Root, rel)
if !ok {
http.Error(w, "path escapes ZDDC_ROOT", http.StatusBadRequest)
return
}
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
if err != nil {
http.Error(w, "policy chain error: "+err.Error(), http.StatusInternalServerError)
return
}
// Evaluate the decision through whatever decider is wired into the
// request — internal in commercial deployments, an external OPA in
// federal ones. The returned bool is "allow".
ctx := r.Context()
decider := DeciderFromContext(r)
allow, _ := policy.AllowFromChain(ctx, decider, chain, probeEmail, probePath)
type levelView struct {
Index int `json:"index"`
ZddcPath string `json:"zddc_path"`
Exists bool `json:"exists"`
Acl *zddc.ACLRules `json:"acl,omitempty"`
Admins []string `json:"admins,omitempty"`
AnyMatch bool `json:"matches_email"`
Decision string `json:"decision_at_level"`
}
// Build the per-level breakdown by walking the chain levels in
// the same order the cascade does (root → leaf in the data, but
// the live evaluator walks bottom-up). For each level we report
// whether the file actually existed (HasAnyFile is global; we
// don't have per-level existence, but ZddcFile.Admins/ACL being
// non-empty is a reasonable proxy).
out := struct {
Path string `json:"path"`
Email string `json:"email"`
Decision bool `json:"decision"`
DeciderKind string `json:"decider_kind"`
Chain struct {
HasAnyFile bool `json:"has_any_file"`
Levels []levelView `json:"levels"`
} `json:"chain"`
}{
Path: probePath,
Email: probeEmail,
Decision: allow,
DeciderKind: deciderKind(decider),
}
out.Chain.HasAnyFile = chain.HasAnyFile
// Reconstruct level paths from cfg.Root. This mirrors how
// zddc.EffectivePolicy builds the chain (see cascade.go).
levelPaths := []string{cfg.Root}
if rel != "" {
current := cfg.Root
for _, seg := range strings.Split(rel, "/") {
if seg == "" {
continue
}
current = current + "/" + seg
levelPaths = append(levelPaths, current)
}
}
for i, lvl := range chain.Levels {
var lp string
if i < len(levelPaths) {
// Map filesystem path back to a URL-style path under
// cfg.Root for legibility in the response.
fsPath := levelPaths[i]
urlPath := strings.TrimPrefix(fsPath, cfg.Root)
if urlPath == "" {
urlPath = "/"
}
lp = urlPath + "/.zddc"
}
entry := levelView{
Index: i,
ZddcPath: lp,
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0,
}
if entry.Exists {
entry.Acl = &lvl.ACL
entry.Admins = lvl.Admins
}
// Per-level email match: would this level's deny or allow
// patterns hit the email if checked? Reuses the same
// MatchesPattern code the live evaluator does.
anyMatch := false
decisionAtLevel := "no_match"
for _, p := range lvl.ACL.Deny {
if zddc.MatchesPattern(p, probeEmail) {
anyMatch = true
decisionAtLevel = "deny"
break
}
}
if !anyMatch {
for _, p := range lvl.ACL.Allow {
if zddc.MatchesPattern(p, probeEmail) {
anyMatch = true
decisionAtLevel = "allow"
break
}
}
}
entry.AnyMatch = anyMatch
entry.Decision = decisionAtLevel
out.Chain.Levels = append(out.Chain.Levels, entry)
}
writeJSON(w, out)
}
// deciderKind returns a short string label for the active decider.
// Mirrors the helper used in policy package tests; duplicated here
// to avoid a cross-package import that would only exist for one
// debug-endpoint string.
func deciderKind(d policy.Decider) string {
if d == nil {
return "nil"
}
t := fmt.Sprintf("%T", d)
return t
}