package handler import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "path/filepath" "sort" "strings" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/archive" "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 / reindex) keep their existing per-resource 404 leakage // gates. func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.ResponseWriter, r *http.Request) { sub := strings.TrimPrefix(r.URL.Path, ProfilePathPrefix) if sub == "" { sub = "/" } // /assets/ serves the profile page's custom.css when an operator // has placed one at root. if strings.HasPrefix(sub, "/assets/") { serveProfileAssets(cfg, w, r) return } email := EmailFromContext(r) // adminOnly wraps an admin-gated sub-handler. Routes that need root- // admin authority (sudo-style, elevation-gated) deny with 404 (not // 403) so a non-admin probing the namespace can't enumerate which // admin-only resources exist. Single helper instead of five copy- // pasted gates. adminOnly := func(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)) { http.NotFound(w, r) return } fn(w, r) } } switch sub { case "/", "": serveProfilePage(cfg, w, r) case "/access": writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r), r.URL.Query().Get("path"))) case "/projects": serveProfileProjectsCreate(cfg, w, r) case "/whoami": adminOnly(func(w http.ResponseWriter, r *http.Request) { serveProfileWhoami(cfg, email, w, r) })(w, r) case "/config": adminOnly(func(w http.ResponseWriter, r *http.Request) { serveProfileConfig(cfg, w, r) })(w, r) case "/logs": adminOnly(func(w http.ResponseWriter, r *http.Request) { serveProfileLogs(ring, w, r) })(w, r) case "/effective-policy": adminOnly(func(w http.ResponseWriter, r *http.Request) { serveProfileEffectivePolicy(cfg, w, r) })(w, r) case "/reindex": adminOnly(func(w http.ResponseWriter, r *http.Request) { serveProfileReindex(cfg, idx, email, w, r) })(w, r) default: http.NotFound(w, r) } } // serveProfileReindex is an admin-only POST endpoint that triggers an // immediate full re-walk of the archive index. Useful when a write has // landed on the share via a path the local watcher can't see (other SMB // clients, the just-restarted dev pod hitting prod data, etc.) and the // operator wants the index updated without waiting for the next periodic // rescan. func serveProfileReindex(cfg config.Config, idx *archive.Index, email string, w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } dur, projects, tracking, err := idx.Rebuild(cfg.Root) if err != nil { slog.Warn("admin reindex failed", "email", email, "err", err, "duration", dur) http.Error(w, fmt.Sprintf("reindex failed: %v", err), http.StatusInternalServerError) return } slog.Info("admin reindex ok", "email", email, "duration", dur, "projects", projects, "tracking", tracking) writeJSON(w, map[string]any{ "duration_ms": dur.Milliseconds(), "project_count": projects, "tracking_count": tracking, }) } // treeEntry is one row in the AccessView's AdminSubtrees list — every // directory containing a .zddc that the caller administers. The profile // page renders them inline; the create-project form's parent-selector // seeds from the same list. type treeEntry struct { Path string `json:"path"` Title string `json:"title,omitempty"` } // 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. AdminSubtrees doubles as the create-project parent-selector // source — every entry is editable, since subtree admins own their own // .zddc. // // IsSuperAdmin and HasAnyAdminScope reflect EFFECTIVE authority — gated // by elevation. CanElevate is the independent "do you have any admin // grant ANYWHERE in the tree, regardless of elevation?" signal that the // header elevation toggle reads to decide whether to show itself. 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"` CanElevate bool `json:"can_elevate"` // CanCreateProject is true when the caller is authorized to mkdir a // new top-level project — either via the root .zddc granting `c` to // their email/role, or via super-admin authority (elevated). Drives // the visibility of the profile page's "+ New project" form so the // UI doesn't dangle an affordance the server would 404. CanCreateProject bool `json:"can_create_project"` Projects []ProjectInfo `json:"projects"` AdminSubtrees []treeEntry `json:"admin_subtrees"` // Path-scoped fields. Populated only when the caller passes // ?path= on the request. Empty when the global view // (no ?path=) was requested, so the existing global-shape clients // keep their wire format unchanged. // // PathVerbs is the canonical "rwcda" subset granted to the caller // at the requested path under their CURRENT elevation state. A // top-of-tool affordance (transmittal's Publish, tables' +Add row, // browse's +New folder toolbar) reads this once on load and gates // itself accordingly. // // PathIsAdmin reports whether the caller has subtree-admin // authority at the requested path, again under current elevation. // Distinct from "verbs include 'a'": admin authority is the WORM- // bypass capability, not just .zddc edit access. // // PathCanElevateGrant is the verb set the caller would hold AT // THIS PATH if they elevated — empty when elevation would change // nothing (already elevated, or no admin grant on the chain). // Drives toast offers like "Elevate to delete this file" without // the client second-guessing the cascade. PathVerbs string `json:"path_verbs,omitempty"` PathIsAdmin bool `json:"path_is_admin,omitempty"` PathCanElevateGrant string `json:"path_can_elevate_grant,omitempty"` } // 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. The principal carries elevation: an un-elevated // admin reports IsSuperAdmin=false here, so the UI naturally renders the // non-elevated view (no admin scaffolds shown) until the user opts in. // // pathQuery is the optional ?path= query value — when non-empty // the path-scoped fields (PathVerbs, PathIsAdmin, PathCanElevateGrant) are // populated so a single fetch answers both "what can I do globally" and // "what can I do at this URL". An invalid or escape-attempting path is // silently ignored (the global fields still return). func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal, pathQuery string) AccessView { view := AccessView{ Email: p.Email, EmailHeader: cfg.EmailHeader, IsSuperAdmin: zddc.IsAdmin(cfg.Root, p), } view.Projects, _ = EnumerateProjects(ctx, decider, cfg, p) view.AdminSubtrees = enumerateAdminSubtrees(cfg, p) view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0 // CanElevate is the elevation-INDEPENDENT discovery flag: "does // this user have admin authority that they could opt into?" // Drives the header elevation toggle's visibility — an un- // elevated admin still needs to see the toggle they'd flip. view.CanElevate = zddc.HasAnyAdminGrant(cfg.Root, p.Email) // CanCreateProject mirrors the gate in serveProfileProjectsCreate — // same decider call, same authority, no daylight between the UI // affordance and the endpoint. if rootChain, perr := zddc.EffectivePolicy(cfg.Root, cfg.Root); perr == nil { allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate) view.CanCreateProject = allowed } if pathQuery != "" { populatePathScopedAccess(ctx, decider, cfg, p, pathQuery, &view) } return view } // populatePathScopedAccess fills the PathVerbs / PathIsAdmin / // PathCanElevateGrant fields by walking the cascade at pathQuery and // running the decider for each verb under (1) the caller's actual // elevation and (2) a hypothetical elevated principal. Path resolution // mirrors serveProfileEffectivePolicy: must start with "/", must not // escape ZDDC_ROOT. Validation failures leave the fields empty rather // than 400ing — the global view is still useful, and the client can // detect absence. func populatePathScopedAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal, pathQuery string, view *AccessView) { if !strings.HasPrefix(pathQuery, "/") { return } rel := strings.TrimPrefix(pathQuery, "/") rel = strings.TrimSuffix(rel, "/") absDir, ok := safeJoin(cfg.Root, rel) if !ok { return } chain, err := zddc.EffectivePolicy(cfg.Root, absDir) if err != nil { return } verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery) view.PathVerbs = verbs.String() view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email) // would_elevate_grant: only meaningful when (a) the caller isn't // already elevated and (b) elevation would actually change the // verb set. Avoid noise — an empty value tells the client there // is nothing to offer. if !p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email) { elevatedP := zddc.Principal{Email: p.Email, Elevated: true} ifElevated := policy.EffectiveVerbsFromChainP(ctx, decider, chain, elevatedP, pathQuery) if ifElevated != verbs { view.PathCanElevateGrant = ifElevated.String() } } } // enumerateAdminSubtrees lists every directory containing a .zddc that the // caller can see as an admin (super-admin or subtree-admin). Every entry // is editable — subtree admins own their own .zddc. Returns empty for an // un-elevated principal — the elevation flag short-circuits each admin // check below. func enumerateAdminSubtrees(cfg config.Config, p zddc.Principal) []treeEntry { dirs, _ := zddc.ScanZddcFiles(cfg.Root) out := make([]treeEntry, 0, len(dirs)) for _, d := range dirs { if !zddc.IsSubtreeAdmin(cfg.Root, d, p) && !zddc.IsAdmin(cfg.Root, p) { 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), 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=. 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=&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": {"permissions": {...}}, "admins": [...]}, // {"path": "/Project-X/", "exists": false}, // {"path": "/Project-X/sub/", "exists": true, "acl": {"permissions": {...}}} // ] // } // } // // Note: this evaluates the same input the production hot path would // build for a request from to ; 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"` // Inherit is the level's explicit inherit setting if present // (nil for absent — defaults to "inherit normally"). When // false, this level fences ancestors above it from descendants. Inherit *bool `json:"inherit,omitempty"` } // 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"` // VisibleStart is the lowest chain index whose grants are // visible to evaluation at the leaf, accounting for any // inherit:false fence. VisibleStart int `json:"visible_start"` Levels []levelView `json:"levels"` } `json:"chain"` }{ Path: probePath, Email: probeEmail, Decision: allow, DeciderKind: deciderKind(decider), } out.Chain.HasAnyFile = chain.HasAnyFile out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels) - 1) // 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.Permissions) > 0 || lvl.ACL.Inherit != nil, Inherit: lvl.ACL.Inherit, } if entry.Exists { entry.Acl = &lvl.ACL entry.Admins = lvl.Admins } // Per-level email match: which permissions entry at this level // would hit the email? Empty verbs = explicit deny; any non- // empty verbs = grant. Mirrors GrantedVerbsAtLevel. anyMatch := false decisionAtLevel := "no_match" for pattern, verbs := range lvl.ACL.Permissions { if !zddc.MatchesPattern(pattern, probeEmail) { continue } anyMatch = true if verbs == "" { decisionAtLevel = "deny" break } decisionAtLevel = "allow" // Don't break — keep scanning so an explicit deny still // wins over a same-level grant. } 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 }