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>
274 lines
11 KiB
Go
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)
|
|
}
|