package handler import ( "encoding/json" "errors" "fmt" "log/slog" "net/http" "os" "path/filepath" "regexp" "sort" "strings" "gopkg.in/yaml.v3" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // Plan Review — the doc-controller's "establish the canonical record" // step. Right-click on archive//received// in the // browse app; the client POSTs X-ZDDC-Op: plan-review with the body // below. // // Authorisation model — no ACL exception, only existing grants: // // - Create authority on received//. The doc_controller // gets this from `worm: [document_controller]` on received/ in the // cascade defaults; the same `c` (write-once-create) verb that // lets them file canonical submittals lets them establish this // .zddc once. // - CanEditZddc on reviewing_root + staging_root. Existing rule // from the cascade defaults. // // Operation: // // 1. Workflow folders converge first (idempotent — match by // .zddc.received_path; mkdir if missing; rewrite workflow .zddc // with received_path + ACL). // 2. Write received//.zddc — but only if it doesn't exist. // The .zddc schema is server-constrained to {planned_review_date, // planned_response_date, created_by} — no ACL, admins, or other // fields, so this write cannot escalate the invoker's authority. // If the file already exists, the canonical record is sealed; the // dates in the request are ignored and the workflow folders are // converged on top. // // So Plan Review's first run establishes the canonical commitment; // subsequent runs can only re-converge the workflow ACLs (e.g. swap // review lead). The planned dates are write-once — to change them, an // admin must edit received//.zddc directly via their admin // authority (which under the cascade defaults is nobody beneath the // root admin; deliberate). const opPlanReview = "plan-review" // planReviewRequest is the YAML body the browse client POSTs. type planReviewRequest struct { ReviewLead string `yaml:"review_lead"` Approver string `yaml:"approver"` PlanReviewCompleteDate string `yaml:"plan_review_complete_date"` PlanResponseDate string `yaml:"plan_response_date"` } // planReviewResponse is the JSON returned to the client. type planReviewResponse struct { Tracking string `json:"tracking"` Title string `json:"title"` Reviewing planReviewFolderOK `json:"reviewing"` Staging planReviewFolderOK `json:"staging"` Received planReviewFolderOK `json:"received"` } type planReviewFolderOK struct { Path string `json:"path"` Created bool `json:"created"` ZddcWritten bool `json:"zddc_written"` } // receivedURLPattern matches //archive//received// // — Plan Review is only valid at that depth. Trailing slash required. var receivedURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/received/([^/]+)/?$`) func servePlanReview(cfg config.Config, w http.ResponseWriter, r *http.Request) { // 1. URL must be a received-tracking folder. cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/" m := receivedURLPattern.FindStringSubmatch(cleanURL) if m == nil { http.Error(w, "Bad Request — plan-review must POST to //archive//received//", http.StatusBadRequest) return } project, party, tracking := m[1], m[2], m[3] // 2. Body parse. body, ok := readBodyCapped(cfg, w, r) if !ok { return } var req planReviewRequest if err := yaml.Unmarshal(body, &req); err != nil { http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest) return } if req.ReviewLead == "" || req.Approver == "" || req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" { http.Error(w, "Bad Request — body must include review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest) return } resp, status, msg := executePlanReview(cfg, r, project, party, tracking, req) if status != http.StatusOK { auditFile(r, "plan-review", cleanURL, status, 0, nil) http.Error(w, msg, status) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("X-ZDDC-Source", "fileapi:plan-review") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(resp) auditFile(r, "plan-review", cleanURL, http.StatusOK, 0, nil) } // executePlanReview runs the Plan Review three-stage flow against an // already-resolved received// path. URL and body parsing // happen in the caller. Returns the response struct on success; // non-200 (status, message) on auth or execution failure. The caller // is responsible for writing the HTTP response. // // Exposed so accept-transmittal can chain Plan Review in the same // request without round-tripping through HTTP. func executePlanReview(cfg config.Config, r *http.Request, project, party, tracking string, req planReviewRequest) (*planReviewResponse, int, string) { receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking)) receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel)) cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/" prCfg := zddc.OnPlanReviewAt(cfg.Root, receivedAbs) if prCfg == nil || prCfg.ReviewingRoot == "" || prCfg.StagingRoot == "" { return nil, http.StatusConflict, "Conflict — on_plan_review is not configured in the cascade for this subtree" } reviewingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.ReviewingRoot, "/"))) stagingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.StagingRoot, "/"))) // Pre-flight authorisation. No ACL exception — we use existing // cascade grants: // (a) CanEditZddc on reviewing_root and staging_root proves the // invoker is subtree-admin of the workflow roots and can // write the workflow .zddc files. // (b) The invoker has `c` (write-once-create) authority on // received//. For the doc_controller this comes // from `worm: [document_controller]` on received/ in the // cascade defaults — the same authority that lets them file // canonical submittals lets them establish this .zddc once. p := PrincipalFromContext(r) email := EmailFromContext(r) if email == "" { return nil, http.StatusForbidden, "Forbidden — no authenticated principal" } // All three pre-flight checks go through the consolidated decider. // AllowActionFromChainP applies the strict-ancestor rule for // .zddc-targeted actions (ActionAdmin) and the single admin-bypass // branch for elevated admins. No manual IsAdmin / IsSubtreeAdmin / // CanEditZddc branching here. decider := DeciderFromContext(r) for _, root := range []string{reviewingRoot, stagingRoot} { chain, perr := zddc.EffectivePolicy(cfg.Root, root) if perr != nil { return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error() } rel, _ := filepath.Rel(cfg.Root, root) rootURL := "/" + filepath.ToSlash(rel) + "/.zddc" allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, rootURL, policy.ActionAdmin) if !allowed { return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks subtree-admin authority for %s", email, strings.TrimPrefix(root, cfg.Root+string(filepath.Separator))) } } // Verify `c` (create) authority on received//. Elevated // admins short-circuit inside the decider; non-admin doc_controllers // come through the WORM-list grant. One code path either way. { chain, perr := zddc.EffectivePolicy(cfg.Root, receivedAbs) if perr != nil { return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error() } allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, cleanURL, policy.ActionCreate) if !allowed { return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks create authority on %s (filing this submittal requires the doc_controller WORM grant)", email, strings.TrimPrefix(receivedAbs, cfg.Root+string(filepath.Separator))) } } // Derive a title from received//'s contents — first // ZDDC-parseable filename's title field wins. Fallback to the // tracking number itself so the folder name always has a tail. title := deriveTitleFromReceived(receivedAbs) if title == "" { title = tracking } // Materialise roots + received// ancestors (the received // folder itself was created when the doc controller moved the // submittal in; defensive ensure here for tests). for _, root := range []string{reviewingRoot, stagingRoot, receivedAbs} { if err := os.MkdirAll(root, 0o755); err != nil { return nil, http.StatusInternalServerError, "Internal Server Error — ensure dirs: " + err.Error() } } // received//.zddc is WRITE-ONCE — the canonical commitment. // First-run creates it under the invoker's WORM-`c` authority // (verified above); subsequent runs leave it alone and the request's // date fields are ignored. The schema is server-constrained: only // planned_review_date + planned_response_date + created_by are written. // No ACL, admins, or other content — so this write cannot escalate // the invoker's authority. receivedResult, err := establishReceivedPlanDates(receivedAbs, req.PlanReviewCompleteDate, req.PlanResponseDate, email, cfg.Root) if err != nil { return nil, http.StatusInternalServerError, "Internal Server Error — received .zddc: " + err.Error() } // Converge the workflow folders. reviewingResult, err := convergeWorkflowFolder(workflowConverge{ fsRoot: cfg.Root, root: reviewingRoot, forecast: req.PlanReviewCompleteDate, tracking: tracking, title: title, receivedRel: receivedRel, acl: map[string]string{req.ReviewLead: "rwcda"}, creatorEmail: email, }) if err != nil { return nil, http.StatusInternalServerError, "Internal Server Error — reviewing convergence: " + err.Error() } stagingResult, err := convergeWorkflowFolder(workflowConverge{ fsRoot: cfg.Root, root: stagingRoot, forecast: req.PlanResponseDate, tracking: tracking, title: title, receivedRel: receivedRel, acl: map[string]string{req.Approver: "rwcda"}, creatorEmail: email, }) if err != nil { return nil, http.StatusInternalServerError, "Internal Server Error — staging convergence: " + err.Error() } return &planReviewResponse{ Tracking: tracking, Title: title, Reviewing: planReviewFolderOK{ Path: "/" + filepath.ToSlash(reviewingResult.relPath) + "/", Created: reviewingResult.created, ZddcWritten: reviewingResult.zddcWritten, }, Staging: planReviewFolderOK{ Path: "/" + filepath.ToSlash(stagingResult.relPath) + "/", Created: stagingResult.created, ZddcWritten: stagingResult.zddcWritten, }, Received: planReviewFolderOK{ Path: "/" + filepath.ToSlash(receivedResult.relPath) + "/", Created: receivedResult.created, ZddcWritten: receivedResult.zddcWritten, }, }, http.StatusOK, "" } // establishReceivedPlanDates writes received//.zddc with the // committed planned dates iff the file doesn't yet exist. If it does, // the canonical record is already sealed and the call is a no-op // (zddcWritten=false in the result); the request's date fields are // silently ignored on subsequent runs. The schema is server-constrained // to just the two date fields + created_by — no ACL or admin grants. func establishReceivedPlanDates(receivedAbs, planReview, planResponse, creatorEmail, fsRoot string) (workflowResult, error) { var res workflowResult res.absPath = receivedAbs if rel, err := filepath.Rel(fsRoot, receivedAbs); err == nil { res.relPath = filepath.ToSlash(rel) } else { res.relPath = receivedAbs } zddcPath := filepath.Join(receivedAbs, ".zddc") if _, err := os.Stat(zddcPath); err == nil { // Sealed — leave alone. zddcWritten stays false. return res, nil } else if !errors.Is(err, os.ErrNotExist) { return res, err } zf := zddc.ZddcFile{ PlannedReviewDate: planReview, PlannedResponseDate: planResponse, CreatedBy: creatorEmail, } if err := zddc.WriteFile(receivedAbs, zf); err != nil { return res, err } res.zddcWritten = true res.created = true // first-time establishment return res, nil } // deriveTitleFromReceived scans received// for ZDDC-parseable // filenames and returns the first one's title field. Empty if no // parseable file is found. func deriveTitleFromReceived(receivedAbs string) string { entries, err := os.ReadDir(receivedAbs) if err != nil { return "" } // Sort for deterministic title selection (first alphabetical wins). names := make([]string, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } names = append(names, e.Name()) } sort.Strings(names) for _, name := range names { parsed := zddc.ParseFilename(name) if parsed.Valid && parsed.Title != "" { return parsed.Title } } return "" } // workflowConverge captures the parameters for converging a single // reviewing/ or staging/ workflow folder. type workflowConverge struct { fsRoot string // master root (cfg.Root) — used to compute response paths root string // absolute path of reviewing_root or staging_root forecast string // initial forecast date for the folder name (YYYY-MM-DD) tracking string // tracking number title string // derived title receivedRel string // relative path to canonical submittal, e.g. archive/Acme/received/Acme-0042 acl map[string]string // per-folder ACL grants (principal → verb-set) creatorEmail string // creator/audit email } // workflowResult is the post-convergence summary for one folder. type workflowResult struct { relPath string // server-relative path (no leading slash, no trailing slash) absPath string created bool // true iff this convergence run mkdir'd the folder zddcWritten bool // true iff a .zddc was written (always true on success) } // convergeWorkflowFolder converges one of the workflow folders (reviewing // or staging) toward the desired state. Idempotent on re-run. func convergeWorkflowFolder(c workflowConverge) (workflowResult, error) { var res workflowResult // Search the root for an existing folder whose .zddc.received_path // matches. If found, use it — the user controls the folder name via // direct rename, so we don't fight their date. existing, err := findWorkflowFolderByReceivedPath(c.root, c.receivedRel) if err != nil { return res, err } target := existing if target == "" { // No match — mkdir at /_ (TBD) - /. // Append _2, _3 to disambiguate exact-name collisions with a // folder belonging to a DIFFERENT submittal. baseName := sanitiseFolderName(fmt.Sprintf("%s_%s (TBD) - %s", c.forecast, c.tracking, c.title)) candidate := filepath.Join(c.root, baseName) for n := 2; n <= 100; n++ { if _, statErr := os.Stat(candidate); errors.Is(statErr, os.ErrNotExist) { break } else if statErr != nil { return res, statErr } candidate = filepath.Join(c.root, fmt.Sprintf("%s_%d", baseName, n)) if n == 100 { return res, fmt.Errorf("convergence: exhausted suffix attempts for %s", baseName) } } if err := os.MkdirAll(candidate, 0o755); err != nil { return res, fmt.Errorf("mkdir workflow folder: %w", err) } target = candidate res.created = true } // Write .zddc with desired content. Overwrites if present. Workflow // .zddc carries received_path + acl ONLY — no planned dates (those // live in the canonical received/.zddc, which the sub-admins // cannot modify). zf := zddc.ZddcFile{ ReceivedPath: c.receivedRel, CreatedBy: c.creatorEmail, } if len(c.acl) > 0 { zf.ACL = zddc.ACLRules{Permissions: c.acl} } if err := zddc.WriteFile(target, zf); err != nil { return res, fmt.Errorf("write workflow .zddc: %w", err) } res.zddcWritten = true res.absPath = target if rel, err := filepath.Rel(c.fsRoot, target); err == nil { res.relPath = filepath.ToSlash(rel) } else { res.relPath = target } return res, nil } // findWorkflowFolderByReceivedPath scans root for direct children // whose .zddc has received_path matching the given relative path. // Returns the matching absolute path, or "" if none. func findWorkflowFolderByReceivedPath(root, receivedRel string) (string, error) { entries, err := os.ReadDir(root) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", nil } return "", err } want := filepath.ToSlash(filepath.Clean(receivedRel)) for _, e := range entries { if !e.IsDir() { continue } zddcPath := filepath.Join(root, e.Name(), ".zddc") zf, perr := zddc.ParseFile(zddcPath) if perr != nil { slog.Warn("plan-review: parse workflow .zddc", "path", zddcPath, "err", perr) continue } if zf.ReceivedPath == "" { continue } got := filepath.ToSlash(filepath.Clean(zf.ReceivedPath)) if got == want { return filepath.Join(root, e.Name()), nil } } return "", nil } // sanitiseFolderName replaces filesystem-troublesome characters in a // title with safe substitutes. Conservative — keeps the ZDDC folder // grammar (the parens and the " - " separator) intact while taming // arbitrary user input in the title segment. func sanitiseFolderName(name string) string { repl := strings.NewReplacer( "/", "-", "\\", "-", ":", "-", "\x00", "", ) return strings.TrimSpace(repl.Replace(name)) }