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 = "/" } // 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) case "/reindex": if !zddc.IsAdmin(cfg.Root, email) { http.NotFound(w, r) return } serveProfileReindex(cfg, idx, email, 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, }) } // 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=. 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": {"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 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"` } // 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 }