ZDDC/zddc/internal/handler/accepthandler.go
ZDDC f196205622 refactor(audit): pre-release cleanup pass
Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.

Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
  to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
  to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
  instead of allow/deny lists.

Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
  own their own .zddc; the policy decider's IsActiveAdmin short-circuit
  is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
  same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
  true after the retirement). Profile page renders AdminSubtrees
  directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
  IsAdminForChain — no production caller passed true.

Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).

ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
  branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
  InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
  GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
  EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
  MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
  /.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
  "deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
  is the parent-deny-is-absolute variant. The in-process Go evaluator
  implements only the commercial cascade.

Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
  .zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.

.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
  .zddc out of the dot-prefix guard so PUT/DELETE/POST reach
  ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
  the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
  API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
  (matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
  parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
  body is designed to materialize on PUT.

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

274 lines
11 KiB
Go

package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"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"
)
// Accept Transmittal — the doc-controller's "file a counterparty
// upload into the immutable received archive" step. Right-click on a
// single transmittal folder under archive/<party>/incoming/ in the
// browse app; the client POSTs X-ZDDC-Op: accept-transmittal with the
// body below.
//
// Authorisation model — same primitives as Plan Review, no exceptions:
//
// - ActionWrite on incoming/<transmittal>/ (move source).
// document_controller has rwcd on incoming/ via the cascade defaults.
// - ActionCreate on received/<tracking>/ (move destination, WORM zone).
// document_controller has `cr` here via worm: [document_controller].
//
// Operation:
//
// 1. Parse URL — must be a direct child of archive/<party>/incoming/.
// 2. Validate the transmittal folder name via ParseTransmittalFolder
// (date, tracking, status, title). Reject if not well-formed.
// 3. Validate every file in the folder via ParseFilename. Each file's
// parsed tracking must match the folder's tracking. Reject on any
// non-conformance — client should cancel and tell sender to fix.
// 4. ACL pre-flight (source write, destination create).
// 5. mkdir received/ (parent of the destination) if missing.
// 6. If received/<tracking>/ does NOT exist → os.Rename the whole
// folder (atomic, fast).
// If received/<tracking>/ DOES exist (re-submission of the same
// tracking) → per-file move. Refuse if any child filename already
// exists at the destination — WORM forbids overwrite.
// 7. Optional Plan Review chain: when the body's setup_plan_review
// flag is true, the same handler dispatches through Plan Review's
// three-stage flow against the new received/<tracking>/ URL. The
// ACL gates re-run there (idempotent against the same principal),
// which is correct: both authorities are required by design.
//
// The accept itself does NOT write received/<tracking>/.zddc — the
// cascade's worm: [document_controller] inheritance is enough. If
// Plan Review is chained, IT writes the .zddc with planned dates.
// Filesystem mtime on the moved folder records when the accept
// happened; the audit log records who.
const opAcceptTransmittal = "accept-transmittal"
// incomingURLPattern matches /<project>/archive/<party>/incoming/<transmittal>/.
var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/incoming/([^/]+)/?$`)
type acceptRequest struct {
ReceivedDate string `yaml:"received_date"`
SetupPlanReview bool `yaml:"setup_plan_review"`
ReviewLead string `yaml:"review_lead"`
Approver string `yaml:"approver"`
PlanReviewCompleteDate string `yaml:"plan_review_complete_date"`
PlanResponseDate string `yaml:"plan_response_date"`
}
type acceptResponse struct {
Tracking string `json:"tracking"`
IncomingPath string `json:"incoming_path"`
ReceivedPath string `json:"received_path"`
MovedFiles int `json:"moved_files"`
Merged bool `json:"merged"`
PlanReview *planReviewResponse `json:"plan_review,omitempty"`
}
func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Request) {
cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
m := incomingURLPattern.FindStringSubmatch(cleanURL)
if m == nil {
http.Error(w, "Bad Request — accept-transmittal must POST to /<project>/archive/<party>/incoming/<transmittal>/", http.StatusBadRequest)
return
}
project, party, transmittalFolder := m[1], m[2], m[3]
date, tracking, _, _, ok := zddc.ParseTransmittalFolder(transmittalFolder)
if !ok {
http.Error(w, "Bad Request — folder name does not conform to ZDDC transmittal grammar (expected YYYY-MM-DD_<tracking> (<status>) - <title>)", http.StatusBadRequest)
return
}
_ = date // available for audit; mtime carries the actual accept time
body, ok2 := readBodyCapped(cfg, w, r)
if !ok2 {
return
}
var req acceptRequest
if len(body) > 0 {
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.SetupPlanReview {
if req.ReviewLead == "" || req.Approver == "" ||
req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" {
http.Error(w, "Bad Request — setup_plan_review requires review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest)
return
}
}
incomingAbs := filepath.Join(cfg.Root, project, "archive", party, "incoming", transmittalFolder)
receivedAbs := filepath.Join(cfg.Root, project, "archive", party, "received", tracking)
receivedURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
// Source must exist as a directory.
srcInfo, err := os.Stat(incomingAbs)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "Not Found", http.StatusNotFound)
} else {
http.Error(w, "Internal Server Error — stat source: "+err.Error(), http.StatusInternalServerError)
}
return
}
if !srcInfo.IsDir() {
http.Error(w, "Bad Request — accept-transmittal target is not a directory", http.StatusBadRequest)
return
}
// Validate every file in the folder before any side-effect.
entries, err := os.ReadDir(incomingAbs)
if err != nil {
http.Error(w, "Internal Server Error — read source: "+err.Error(), http.StatusInternalServerError)
return
}
var fileNames []string
var violations []string
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue // skip dotfiles silently (e.g. .zddc dropped by counterparty)
}
if e.IsDir() {
violations = append(violations, name+": nested directories are not permitted in a transmittal folder")
continue
}
parsed := zddc.ParseFilename(name)
if !parsed.Valid {
violations = append(violations, name+": does not conform to ZDDC filename grammar")
continue
}
if parsed.TrackingNumber != tracking {
violations = append(violations, fmt.Sprintf("%s: tracking %q does not match folder tracking %q", name, parsed.TrackingNumber, tracking))
continue
}
fileNames = append(fileNames, name)
}
if len(violations) > 0 {
http.Error(w, "Conflict — transmittal folder contents do not conform:\n"+strings.Join(violations, "\n"), http.StatusConflict)
return
}
if len(fileNames) == 0 {
http.Error(w, "Conflict — transmittal folder is empty", http.StatusConflict)
return
}
// ACL pre-flight: source needs Write (rename out), destination needs Create.
if !authorizeAction(cfg, w, r, incomingAbs, cleanURL, policy.ActionWrite) {
return
}
if !authorizeAction(cfg, w, r, receivedAbs, receivedURL, policy.ActionCreate) {
return
}
email := EmailFromContext(r)
if email == "" {
http.Error(w, "Forbidden — no authenticated principal", http.StatusForbidden)
return
}
// Ensure received/'s parent exists (received/ itself materialises via
// the rename or the per-file moves below).
receivedParent := filepath.Dir(receivedAbs)
if err := os.MkdirAll(receivedParent, 0o755); err != nil {
auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error — mkdir received/: "+err.Error(), http.StatusInternalServerError)
return
}
merged := false
if _, err := os.Stat(receivedAbs); err == nil {
// Re-submission of an already-accepted tracking → merge per-file.
// Refuse any filename collision; WORM forbids overwriting.
merged = true
for _, name := range fileNames {
dst := filepath.Join(receivedAbs, name)
if _, statErr := os.Stat(dst); statErr == nil {
http.Error(w, "Conflict — "+name+" already exists in received/"+tracking+"/ (WORM forbids overwrite)", http.StatusConflict)
return
} else if !errors.Is(statErr, os.ErrNotExist) {
http.Error(w, "Internal Server Error — stat destination: "+statErr.Error(), http.StatusInternalServerError)
return
}
}
for _, name := range fileNames {
src := filepath.Join(incomingAbs, name)
dst := filepath.Join(receivedAbs, name)
if err := os.Rename(src, dst); err != nil {
auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error — rename "+name+": "+err.Error(), http.StatusInternalServerError)
return
}
}
// Best-effort: remove the now-empty incoming folder. Leaves it in
// place if non-empty (e.g. operator left ad-hoc notes alongside
// the conformant files); audit log captures the success either way.
_ = os.Remove(incomingAbs)
} else if errors.Is(err, os.ErrNotExist) {
// Fresh acceptance → atomic folder rename.
if err := os.Rename(incomingAbs, receivedAbs); err != nil {
auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error — rename folder: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "Internal Server Error — stat received: "+err.Error(), http.StatusInternalServerError)
return
}
resp := acceptResponse{
Tracking: tracking,
IncomingPath: cleanURL,
ReceivedPath: receivedURL,
MovedFiles: len(fileNames),
Merged: merged,
}
// Optional Plan Review chain. Invokes executePlanReview directly
// against the freshly-created received/<tracking>/ path. The ACL
// gates re-run there — the invoker still needs ActionAdmin on the
// workflow roots and `c` on received/<tracking>/, both of which
// they had a moment ago for the move itself. A chained failure does
// NOT roll back the move: the canonical record is sealed, and the
// user can re-trigger Plan Review later from the received/<tracking>/
// folder context menu.
if req.SetupPlanReview {
planReq := planReviewRequest{
ReviewLead: req.ReviewLead,
Approver: req.Approver,
PlanReviewCompleteDate: req.PlanReviewCompleteDate,
PlanResponseDate: req.PlanResponseDate,
}
prResp, status, msg := executePlanReview(cfg, r, project, party, tracking, planReq)
if status != http.StatusOK {
auditFile(r, "accept-transmittal", cleanURL, status, 0, errors.New(msg))
http.Error(w, "Chained plan-review: "+msg, status)
return
}
resp.PlanReview = prResp
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "fileapi:accept-transmittal")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
auditFile(r, "accept-transmittal", cleanURL+" -> "+receivedURL, http.StatusOK, 0, nil)
}