ZDDC/zddc/internal/handler/ssrhandler.go
ZDDC 73e34bed5e feat: per-party RSK + project-level SSR/MDL/RSK rollup tables
Adds the risk register as a sibling of MDL under archive/<party>/, and
three project-level virtual aggregations at <project>/{ssr,mdl,rsk}:

  - SSR aggregates archive/<party>/ssr.yaml; "+ Add row" materializes a
    new party folder (mkdir + auto-own .zddc + ssr.yaml). Renames go
    through X-ZDDC-Op: ssr-rename, which os.Rename's the party
    directory so every row inside follows. Party name doubles as the
    folder name (no opaque IDs) and is path-derived on read.

  - MDL/RSK rollups list every deliverable / every risk across all
    parties with a derived `party` column; "+ Add row" is suppressed
    because party affiliation is ambiguous in the aggregate view.

All four virtual roots are declared `virtual: true` in
defaults.zddc.yaml. Spec/form bytes come from six new embedded
defaults (default-rsk.*, default-ssr.*, default-project-{mdl,rsk}.*)
served via a generalized IsDefaultSpec/IsDefaultSpecAbs that replaces
the MDL-only recognizer. Listing synthesis lives in fs/tree.go;
ACL on each synthetic row evaluates against the canonical
archive/<party>/ chain so non-owners see rows read-only. PUT/DELETE
through virtual URLs rewrite to canonical paths in fileapi.go via
sibling-shape blocks that don't touch the ACL gate. SSR row DELETE
returns 405 (delete the party folder via the archive view).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:47:56 -05:00

287 lines
10 KiB
Go

// Package handler — ssrhandler.go: Supplier/Subcontractor Status Report
// lifecycle endpoints that don't fit the generic form / file API shapes.
//
// Two endpoints live here:
//
// POST /<project>/ssr/form.html → "+ Add row" / SSR create
// POST /<project>/ssr/<old>.yaml with X-ZDDC-Op: ssr-rename
// X-ZDDC-Destination: /<project>/ssr/<new>.yaml
//
// Both target the project-level SSR aggregator, which is a virtual view
// (see zddc/virtualviews.go). Internally they materialize / rename real
// party folders under <project>/archive/.
//
// The generic file API only does file-level moves; renaming a party
// folder means renaming a directory and bringing every row inside with
// it. Rather than weaken serveFileMove's IsDir-source guard (which keeps
// callers from accidentally moving entire subtrees), this file
// implements ssr-rename as a tightly-scoped op that only fires for the
// SSR virtual URL shape.
package handler
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3"
)
// opSSRRename is the X-ZDDC-Op value that fires serveSSRRename. Dispatched
// from serveFilePost in fileapi.go.
const opSSRRename = "ssr-rename"
// serveFormCreateSSR materializes a new party folder under
// <project>/archive/<name>/ and writes archive/<name>/ssr.yaml from the
// submitted form body. The `name` field doubles as the party folder
// name and is stripped from the YAML before write (path-derived).
//
// ACL gate: ActionCreate at <project>/archive/<name>/ — typically
// satisfied by document_controller's rwc grant on archive/ in the
// project-level cascade, OR by a deployment that grants `c` to a wider
// audience.
//
// Auto-own .zddc is seeded for the new party folder via
// WriteAutoOwnZddc, the same machinery the generic mkdir uses.
// Authenticated email is required (401 otherwise) so the auto-own
// grant always names a real principal.
func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
if req.Project == "" {
http.Error(w, "internal: SSR create missing project", http.StatusInternalServerError)
return
}
data, err := decodeRequestData(r)
if err != nil {
http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
return
}
spec, err := loadFormSpec(cfg.Root, req.SpecPath)
if err != nil {
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return
}
if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
writeValidationErrors(w, errs)
return
}
dataMap, ok := data.(map[string]interface{})
if !ok {
http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest)
return
}
nameRaw, _ := dataMap["name"].(string)
name := strings.TrimSpace(nameRaw)
if !zddc.ValidPartyName(name) {
writeValidationErrors(w, []jsonschema.Error{{
Path: "/name",
Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`,
}})
return
}
partyAbs := filepath.Join(cfg.Root, req.Project, "archive", name)
if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
partyURL := "/" + req.Project + "/archive/" + name + "/"
rowURL := "/" + req.Project + "/ssr/" + name + ".yaml"
// ACL gate: create at <project>/archive/<name>/. authorizeAction walks
// up to the closest existing ancestor for the chain — typically
// <project>/archive/, where document_controller carries rwc per the
// project-level cascade.
if !authorizeAction(cfg, w, r, partyAbs, partyURL, policy.ActionCreate) {
return
}
// Refuse to clobber an existing party folder. The SSR view shows
// any folder under archive/*/; if one with this name exists, the
// user should edit that row instead of creating a duplicate.
if info, err := os.Stat(partyAbs); err == nil {
if info.IsDir() {
http.Error(w, "Conflict — a party folder with that name already exists", http.StatusConflict)
return
}
http.Error(w, "Conflict — a file exists at this path", http.StatusConflict)
return
} else if !errors.Is(err, os.ErrNotExist) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Materialize canonical ancestors (<project>/archive/) with auto-own
// seeding before creating the party folder itself. Mirrors what the
// generic file-API mkdir does at fileapi.go:629-634.
yamlAbs := filepath.Join(partyAbs, "ssr.yaml")
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, yamlAbs, email, 0o755); err != nil {
slog.Warn("ssr-create: ensure canonical ancestors", "path", yamlAbs, "err", err)
}
if err := os.MkdirAll(partyAbs, 0o755); err != nil {
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Auto-own .zddc on the new party folder. archive/*/ is declared
// auto_own in defaults.zddc.yaml, so the unfenced creator grant
// fires here exactly as it would for a manual mkdir.
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
var werr error
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email)
} else {
werr = zddc.WriteAutoOwnZddc(partyAbs, email)
}
if werr != nil {
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)
}
}
// Drop the path-derived `name` field — it's the folder name, not
// row data. The dispatcher re-injects it on read.
delete(dataMap, "name")
yamlBytes, err := yaml.Marshal(dataMap)
if err != nil {
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err)
http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
return
}
if err := zddc.WriteAtomic(yamlAbs, yamlBytes); err != nil {
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), err)
http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Location", rowURL)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "ssr-create")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL)))
auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(yamlBytes), nil)
}
// serveSSRRename renames a party folder by rewriting an SSR row URL.
//
// Wire-form: POST /<project>/ssr/<old>.yaml
//
// X-ZDDC-Op: ssr-rename
// X-ZDDC-Destination: /<project>/ssr/<new>.yaml
//
// Both source and destination must resolve as SSR-row virtual URLs in
// the same project. The destination party name must be a valid party
// folder name, must differ from the source, and must not already exist.
//
// ACL: ActionWrite at <project>/archive/<old>/ AND ActionCreate at
// <project>/archive/<new>/. Both gates evaluate against their canonical
// archive paths so non-owners can't rename someone else's party.
//
// On success: os.Rename moves archive/<old>/ → archive/<new>/ (an
// atomic directory rename on the same filesystem); every row inside
// follows. No denormalized `party:` field rewrite is needed — the
// MDL/RSK schemas don't carry one.
func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
if email == "" {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
src := zddc.ResolveVirtualView(cfg.Root, r.URL.Path)
if !src.Resolved || src.Kind != zddc.VirtualViewSSRRow {
http.Error(w, "Bad Request — ssr-rename source must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
return
}
dstHeader := r.Header.Get(headerDestination)
if dstHeader == "" {
http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest)
return
}
if dec, err := url.PathUnescape(dstHeader); err == nil {
dstHeader = dec
}
dst := zddc.ResolveVirtualView(cfg.Root, dstHeader)
if !dst.Resolved || dst.Kind != zddc.VirtualViewSSRRow {
http.Error(w, "Bad Request — ssr-rename destination must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
return
}
if dst.Project != src.Project {
http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest)
return
}
if dst.Party == src.Party {
http.Error(w, "Bad Request — destination is the same as source", http.StatusBadRequest)
return
}
// Source party folder must exist.
srcArchive := src.PartyArchive
if info, err := os.Stat(srcArchive); err != nil || !info.IsDir() {
if err != nil && !errors.Is(err, os.ErrNotExist) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// Destination must not exist.
dstArchive := dst.PartyArchive
if _, err := os.Stat(dstArchive); err == nil {
http.Error(w, "Conflict — destination party folder already exists", http.StatusConflict)
return
} else if !errors.Is(err, os.ErrNotExist) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// ACL: write on src archive, create on dst archive. URLs include
// the trailing slash convention used elsewhere for directory ops.
srcArchiveURL := "/" + src.Project + "/archive/" + src.Party + "/"
dstArchiveURL := "/" + dst.Project + "/archive/" + dst.Party + "/"
if !authorizeAction(cfg, w, r, srcArchive, srcArchiveURL, policy.ActionWrite) {
return
}
if !authorizeAction(cfg, w, r, dstArchive, dstArchiveURL, policy.ActionCreate) {
return
}
// Optional If-Match against the source ssr.yaml etag.
if !checkIfMatch(w, r, src.CanonicalAbs) {
return
}
if err := os.Rename(srcArchive, dstArchive); err != nil {
auditFile(r, "ssr-rename", r.URL.Path, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
newURL := "/" + dst.Project + "/ssr/" + dst.Party + ".yaml"
w.Header().Set("Location", newURL)
w.Header().Set("X-ZDDC-Destination", newURL)
w.Header().Set("X-ZDDC-Source", "ssr-rename")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
resp, _ := json.Marshal(map[string]string{"location": newURL})
_, _ = w.Write(resp)
auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil)
}