diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f88cb5a..aae044f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -498,7 +498,7 @@ none of them is load-bearing alone. |---|---|---| | Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer ` validated against `/.zddc.d/tokens/` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required | | Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` | -| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in `defaults.zddc.yaml` bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins under `--cascade-mode=delegated`, or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data | +| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in `defaults.zddc.yaml` bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego (the bundled `access_federal.rego` is the parent-deny-is-absolute / NIST AC-6 variant) while keeping the same `.zddc` files as input data | | Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into `defaults.zddc.yaml`): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive//{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; `defaults.zddc.yaml` | | Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above | | URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim | @@ -654,7 +654,7 @@ whether to deploy the system should know which column they're in. | Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) | | TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS | | Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) | -| Subtree authority | Operator-toggled cascade mode: `delegated` (default — leaf grants override ancestor denies) or `strict` (`--cascade-mode=strict` — ancestor explicit-denies are absolute, NIST AC-6) | (closed; `strict` is the federal posture) | +| Subtree authority | In-process decider: leaf grants override ancestor denies (delegation primitive). Federal posture: deploy OPA with `access_federal.rego` for ancestor-deny-absolute / NIST AC-6 | (closed; federal posture is the OPA path) | | Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive | | Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery | | Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification | @@ -675,12 +675,9 @@ Five permission verbs gate every read and write: | `d` | delete a file | | `a` | modify the ACL of this subtree (write `.zddc`) | -`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny. Legacy `acl.allow` / `acl.deny` lists fold into `permissions` at parse time (`allow` → `rwcd`, `deny` → `""`), so existing deployments behave identically. +`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny. -Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. Operators select the precedence model for ancestor denies via `--cascade-mode`: - -- `delegated` (default) — historical commercial behavior; a leaf allow overrides an ancestor explicit-deny. -- `strict` — NIST AC-6 posture; an ancestor explicit-deny is absolute and cannot be overridden by any leaf grant. +Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with the bundled `access_federal.rego`. The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask. diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index 47b9ac1..cd47203 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -76,7 +76,12 @@ function canSave(node) { if (isZipMemberNode(node)) return false; - if (node.virtual) return false; + // Virtual .zddc placeholders are designed to be saved — a PUT + // materializes the file from the synthetic body and the next + // listing serves a real entry. Every other virtual node (per- + // user home, canonical-folder virtuals) is just a tree + // affordance, not a writable file. + if (node.virtual && node.name !== '.zddc') return false; // Server-computed authority gate. Mirrors the markdown editor's // check — listing's `writable` bit is the same decision the // file API would reach on PUT. diff --git a/zddc/README.md b/zddc/README.md index 272b4da..aeaa375 100644 --- a/zddc/README.md +++ b/zddc/README.md @@ -425,29 +425,17 @@ for a level whose `acl.permissions` map matches the user. The walk respects an **inherit fence** (see "The `inherit:` directive" below). A level whose `acl.inherit: false` flag is set acts as a fence: ancestors above it are invisible to descendants at-and-below the fence, both for grants and for -role lookups. In strict cascade mode the fence is ignored (NIST AC-6 invariant). +role lookups. Implementation: `GrantedVerbsAtLevel` (`zddc/internal/zddc/acl.go`) computes the per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`); the fence is computed by `PolicyChain.VisibleStart`. -#### Cascade mode - -The leaf-overrides-ancestor behavior above is the default — it's the historical -commercial-tenant model where a subtree owner can grant access without -root-admin involvement. Federal deployments needing absolute parent denies -(NIST AC-6) start the server with `--cascade-mode=strict` (or -`ZDDC_CASCADE_MODE=strict`): - -- **`delegated`** (default) — leaf grant overrides ancestor explicit-deny. -- **`strict`** — two-pass evaluation. First pass walks **root → leaf** for any - matching explicit-deny; if found, denied (subject to root-admin bypass). - Second pass is the leaf→root grant walk above. An ancestor explicit-deny - cannot be overridden by any leaf grant. - -The mode is logged at startup and surfaced on `/.profile/config`. Subtree -`.zddc` files cannot change the mode — it's a deployment-wide policy. +The leaf-overrides-ancestor behaviour above is the in-process decider's only +rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy +OPA with the bundled `access_federal.rego` (or their own Rego); see +"External OPA" below. #### The `inherit:` directive @@ -484,14 +472,13 @@ Behaviour: fence; `inherit: false` does not change WORM behaviour. See "Canonical-folder behaviour via `.zddc` keys" below. -**Strict cascade mode IGNORES `inherit: false`.** NIST AC-6 requires -ancestor explicit-denies to be absolute, and the inherit directive -would let a leaf widen access an ancestor refused. Under -`--cascade-mode=strict` the directive has no effect (and the bundled -federal Rego at `--print-rego=federal` mirrors that rule). Operators -who need fence-style "reset" semantics in a federal-track deployment -should not use the directive — instead, restructure the tree so the -permissive ancestor rule never appears. +**Federal posture and `inherit: false`.** The bundled federal Rego at +`--print-rego=federal` makes ancestor explicit-denies absolute and +therefore ignores `inherit: false` (allowing a leaf to widen access an +ancestor refused would defeat NIST AC-6). Operators who need fence- +style "reset" semantics in a federal-track deployment should not use +the directive — instead, restructure the tree so the permissive +ancestor rule never appears. The cascade tracer (`/.profile/effective-policy`) surfaces every level's `inherit` flag and the `chain.visible_start` index so a @@ -939,13 +926,12 @@ have to redo the gap analysis from scratch. (the upstream proxy still asserts the email; role membership is evaluated server-side against the cascade). - ~~**Least-privilege bounding** (NIST AC-6)~~ — *closed.* Operators - set `--cascade-mode=strict` (or `ZDDC_CASCADE_MODE=strict`) to - switch the in-process Go evaluator into the federal posture: any - ancestor explicit-deny is absolute and cannot be overridden by a - leaf grant. The mode is logged at startup and surfaced on - `/.profile/config`. The legacy commercial behavior is preserved as - the default `delegated` mode. External OPA (`ZDDC_OPA_URL`) remains - available for org-specific Rego on top of this. + deploy OPA (`ZDDC_OPA_URL`) pointed at the bundled federal Rego + (`zddc-server --print-rego=federal`) or their own variant. Under + that policy any ancestor explicit-deny is absolute and cannot be + overridden by a leaf grant. The in-process Go evaluator implements + only the commercial "leaf grants override ancestor denies" rule; + federal posture is exclusively the OPA path. - **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to authoritative sources (PIV cert subject, IdP-managed identity). Required: documented integration with at least one IdP supporting federal identity diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index e97738c..99e7f8c 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -191,10 +191,9 @@ func main() { // http(s):// or unix:// values send each decision to an external // OPA-compatible server (federal customers, custom Rego policies). deciderCfg := policy.Config{ - URL: cfg.OPAURL, - FailOpen: cfg.OPAFailOpen, - CacheTTL: cfg.OPACacheTTL, - CascadeMode: cfg.CascadeMode, + URL: cfg.OPAURL, + FailOpen: cfg.OPAFailOpen, + CacheTTL: cfg.OPACacheTTL, } // Translate "0" (operator opt-out) to "disable cache" (negative TTL is // the policy package's sentinel for "skip the wrapper"). @@ -217,7 +216,6 @@ func main() { "mode", policyModeLabel(cfg.OPAURL), "url", cfg.OPAURL, "cache_ttl", cfg.OPACacheTTL, - "cascade_mode", cfg.CascadeMode, "no_auth", cfg.NoAuth) // Token store: bearer-token issuance and validation. @@ -765,26 +763,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // Split path into segments segments := strings.Split(strings.Trim(urlPath, "/"), "/") - // Legacy `/.zddc.html` form-editor URL → 302 redirect to - // the canonical edit surface (browse opening `.zddc` in its - // YAML/CodeMirror pane). The form editor was retired in favour - // of one canonical edit surface; bookmarks still resolve. - if urlPath == "/.zddc.html" || strings.HasSuffix(urlPath, "/.zddc.html") { - dir := strings.TrimSuffix(urlPath, ".zddc.html") - if dir == "" { - dir = "/" - } - http.Redirect(w, r, dir+"?file=.zddc", http.StatusFound) - return - } - // Raw .zddc YAML view: /.zddc is reachable at every depth // and returns the on-disk file's bytes (Content-Type: application/yaml) // or — when no file exists — a synthetic placeholder body with a - // cascade summary so the user can see what's effective here. - // GET/HEAD only; writes go through the file API (PUT). Carved - // out of the dot-prefix guard so the leaf segment isn't 404'd. - if handler.IsZddcFileRequest(urlPath) { + // cascade summary so the user can see what's effective here. The + // leaf is carved out of the dot-prefix guard below so GET/HEAD + // land here and PUT/DELETE/POST fall through to ServeFileAPI. + if handler.IsZddcFileRequest(urlPath) && (r.Method == http.MethodGet || r.Method == http.MethodHead) { handler.ServeZddcFile(cfg, w, r) return } @@ -811,7 +796,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // the ?hidden flag does NOT relax). hiddenOK := r.URL.Query().Has("hidden") && (r.Method == http.MethodGet || r.Method == http.MethodHead) - for _, seg := range segments { + for i, seg := range segments { if seg == "" { continue } @@ -825,6 +810,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps if seg == cfg.IndexPath { continue } + // `.zddc` is the only writable dot-prefixed file: GET/HEAD was + // handled by ServeZddcFile above; PUT/DELETE/POST fall through + // to ServeFileAPI. Only the LEAF segment carves through — + // `.zddc.d` and other intermediate dot dirs stay reserved. + if seg == handler.ZddcFileBasename && i == len(segments)-1 { + continue + } if hiddenOK { continue } @@ -1161,11 +1153,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } } - // (Subtree download `GET /dir/?zip=1` retired in favour of - // `GET /dir.zip` — see RecognizeVirtualSubtreeZip handling at - // the top of the stat-fails branch above. Real directories - // stat-succeed here, so the virtual zip URL stat-fails at - // /dir.zip and matches there.) + // (Subtree downloads use the virtual `GET /dir.zip` URL — + // see RecognizeVirtualSubtreeZip handling at the top of the + // stat-fails branch above. Real directories stat-succeed + // here, so the virtual zip URL stat-fails at /dir.zip and + // matches there.) // Slash/no-slash routing convention: trailing slash → the // directory view (handler.ServeDirectory → DirTool, which diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 9a1c80d..802ddaf 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -2,7 +2,6 @@ package main import ( "archive/zip" - "bytes" "context" "crypto/ed25519" "crypto/rand" @@ -133,7 +132,7 @@ func TestDispatchAppsResolution(t *testing.T) { // fake upstream. Allow all email patterns (anonymous) so the test // doesn't have to set up email headers. zf := zddc.ZddcFile{ - ACL: zddc.ACLRules{Allow: []string{"*"}}, + ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}}, Apps: map[string]string{ "archive": upstream.URL + "/archive_stable.html", "transmittal": upstream.URL + "/transmittal_stable.html", @@ -224,7 +223,7 @@ var _ = apps.DefaultUpstream func TestDispatchRoutesWritesToFileAPI(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, ".zddc"), - "acl:\n allow:\n - \"*@example.com\"\n deny: []\n") + "acl:\n permissions:\n \"*@example.com\": rwcd\n") mustMkdir(t, filepath.Join(root, "Project-A", "Working")) idx, err := archive.BuildIndex(root) @@ -296,7 +295,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) { func TestDispatchArchiveRedirect(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, ".zddc"), - "acl:\n allow:\n - \"*\"\n") + "acl:\n permissions:\n \"*\": rwcd\n") mustMkdir(t, filepath.Join(root, "ProjectA", "Working")) idx, err := archive.BuildIndex(root) @@ -596,7 +595,7 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) { func TestDispatchArchiveMethodGate(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, ".zddc"), - "acl:\n allow:\n - \"*\"\n") + "acl:\n permissions:\n \"*\": rwcd\n") mustMkdir(t, filepath.Join(root, "ProjectA")) idx, err := archive.BuildIndex(root) @@ -638,7 +637,7 @@ func TestDispatchArchiveMethodGate(t *testing.T) { func TestDispatchCaseInsensitiveURL(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, ".zddc"), - "acl:\n allow:\n - \"*\"\n") + "acl:\n permissions:\n \"*\": rwcd\n") mustMkdir(t, filepath.Join(root, "project-a", "working")) mustWrite(t, filepath.Join(root, "project-a", "working", "note.md"), "lowercase note") @@ -843,79 +842,6 @@ func mustWrite(t *testing.T, path, body string) { } } -// TestDispatchSubtreeZip exercises the `?zip=1` subtree-download hook: -// it routes to handler.ServeSubtreeZip on both the slash and no-slash -// forms of a directory URL, and the dispatch's directory ACL gate -// still applies (a viewer with no read access to the directory gets -// 403 before the zip handler runs). -func TestDispatchSubtreeZip(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, ".zddc"), - "acl:\n permissions:\n \"*\": r\n") - mustMkdir(t, filepath.Join(root, "Proj", "staging", "2025-01-15_AAA-EM-TRN-0001 (IFC) - T")) - mustWrite(t, filepath.Join(root, "Proj", "staging", "2025-01-15_AAA-EM-TRN-0001 (IFC) - T", "doc.txt"), "hello") - // A subtree only alice@x may read. - mustMkdir(t, filepath.Join(root, "Proj", "locked")) - mustWrite(t, filepath.Join(root, "Proj", "locked", ".zddc"), - "acl:\n inherit: false\n permissions:\n \"alice@x\": rwcda\n") - mustWrite(t, filepath.Join(root, "Proj", "locked", "secret.txt"), "s") - - idx, err := archive.BuildIndex(root) - if err != nil { - t.Fatalf("BuildIndex: %v", err) - } - cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"} - ring := handler.NewLogRing(10) - appsSrv, err := setupApps(cfg) - if err != nil { - t.Fatalf("setupApps: %v", err) - } - do := func(path, email string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, path, nil) - req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, email)) - rec := httptest.NewRecorder() - dispatch(cfg, idx, ring, appsSrv, nil, rec, req) - return rec - } - - for _, path := range []string{"/Proj/staging/?zip=1", "/Proj/staging?zip=1"} { - rec := do(path, "bob@x") - if rec.Code != http.StatusOK { - t.Fatalf("%s status=%d, want 200", path, rec.Code) - } - if ct := rec.Header().Get("Content-Type"); ct != "application/zip" { - t.Errorf("%s Content-Type=%q", path, ct) - } - if rec.Header().Get("X-ZDDC-Source") != "subtree-zip" { - t.Errorf("%s missing X-ZDDC-Source", path) - } - body := rec.Body.Bytes() - zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) - if err != nil { - t.Fatalf("%s body not a zip: %v", path, err) - } - var foundDoc bool - for _, f := range zr.File { - if strings.HasSuffix(f.Name, "/doc.txt") || f.Name == "staging/2025-01-15_AAA-EM-TRN-0001 (IFC) - T/doc.txt" { - foundDoc = true - } - } - if !foundDoc { - t.Errorf("%s zip missing doc.txt; entries=%d", path, len(zr.File)) - } - } - - // The dispatch's directory ACL gate runs before ServeSubtreeZip: - // bob@x can't read /Proj/locked at all → 403, no zip. - if rec := do("/Proj/locked/?zip=1", "bob@x"); rec.Code != http.StatusForbidden { - t.Errorf("bob@x /Proj/locked/?zip=1 status=%d, want 403", rec.Code) - } - // alice@x can → 200 zip. - if rec := do("/Proj/locked/?zip=1", "alice@x"); rec.Code != http.StatusOK { - t.Errorf("alice@x /Proj/locked/?zip=1 status=%d, want 200", rec.Code) - } -} - // TestGzhttpWrapper_CompressesLargeResponses asserts the gzhttp wrapper // behavior we wire in main(): responses above MinSize get gzip-encoded // when the client advertises Accept-Encoding: gzip; small responses @@ -994,86 +920,3 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) { }) } -// TestDispatchZddcEditorAtPath verifies the per-directory /.zddc.html -// virtual URL is recognised by the dispatcher and routed to the editor -// handler (carved out from the dot-prefix guard). Permission gate is -// hasAnyAdminScope; non-admins get 404. -func TestDispatchZddcEditorAtPath(t *testing.T) { - root := t.TempDir() - mustWrite(t, filepath.Join(root, ".zddc"), - "admins:\n - root@example.com\n") - mustMkdir(t, filepath.Join(root, "Project", "working")) - mustWrite(t, filepath.Join(root, "Project", ".zddc"), - "title: Demo Project\n") - - idx, err := archive.BuildIndex(root) - if err != nil { - t.Fatalf("BuildIndex: %v", err) - } - cfg := config.Config{ - Root: root, - IndexPath: ".archive", - EmailHeader: "X-Auth-Request-Email", - } - ring := handler.NewLogRing(10) - - cases := []struct { - name string - path string - email string - wantStatus int - wantSubstr string - }{ - { - "root admin opens project editor", - "/Project/.zddc.html", "root@example.com", - http.StatusOK, "Demo Project", - }, - { - "root admin opens working/ editor (no .zddc on disk yet)", - "/Project/working/.zddc.html", "root@example.com", - http.StatusOK, ".zddc editor", - }, - { - "root admin opens deployment-root editor", - "/.zddc.html", "root@example.com", - http.StatusOK, ".zddc editor", - }, - { - "non-admin gets 404", - "/Project/.zddc.html", "stranger@example.com", - http.StatusNotFound, "", - }, - { - "anonymous gets 404", - "/Project/.zddc.html", "", - http.StatusNotFound, "", - }, - { - "missing directory gets 404", - "/Project/no-such-dir/.zddc.html", "root@example.com", - http.StatusNotFound, "", - }, - { - "deeper than leaf rejected", - "/Project/.zddc.html/extra", "root@example.com", - http.StatusNotFound, "", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, tc.path, nil) - req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, tc.email)) - rec := httptest.NewRecorder() - dispatch(cfg, idx, ring, nil, nil, rec, req) - if rec.Code != tc.wantStatus { - t.Fatalf("path=%q status=%d, want %d; body=%s", - tc.path, rec.Code, tc.wantStatus, rec.Body.String()) - } - if tc.wantSubstr != "" && !strings.Contains(rec.Body.String(), tc.wantSubstr) { - t.Errorf("path=%q body missing %q", tc.path, tc.wantSubstr) - } - }) - } -} diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index 64a1063..bbef6a2 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -45,7 +45,6 @@ type Config struct { OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable. AppsPubKey string // --apps-pubkey / ZDDC_APPS_PUBKEY — path to the Ed25519 public key (PEM) used to verify Ed25519 signatures on URL-fetched apps: artifacts. Empty = URL apps disabled (only embedded + local-path apps work). Operators using zddc.varasys.io's canonical channels download pubkey.pem from there. MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413. - CascadeMode string // --cascade-mode / ZDDC_CASCADE_MODE — "delegated" (default; leaf grants override ancestor denies) or "strict" (ancestor explicit-denies are absolute, NIST AC-6). ArchiveRescanInterval time.Duration // --archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL — periodic full re-walk of the archive index. Covers SMB/CIFS where inotify misses cross-client writes. Default 60s; 0 to disable. // MD→{docx,html,pdf} conversion endpoint (see internal/convert). @@ -139,8 +138,6 @@ func Load(args []string) (Config, error) { "Path to the Ed25519 public key (PEM) used to verify signatures on URL-fetched apps: artifacts. Empty (default) = URL-fetched apps refused; only embedded + local-path apps work. Download zddc.varasys.io/pubkey.pem if you use the canonical channels.") maxWriteBytesFlag := fs.Int64("max-write-bytes", parseInt64OrDefault(os.Getenv("ZDDC_MAX_WRITE_BYTES"), 256*1024*1024), "Maximum PUT body size in bytes for the file API. Default 256 MiB. Larger requests are rejected with 413.") - cascadeModeFlag := fs.String("cascade-mode", getEnv("ZDDC_CASCADE_MODE", "delegated"), - "ACL cascade evaluation mode: \"delegated\" (default — subtree allow can override ancestor deny) or \"strict\" (ancestor explicit-deny is absolute; NIST AC-6).") archiveRescanIntervalFlag := fs.Duration("archive-rescan-interval", parseDurationOrDefault(os.Getenv("ZDDC_ARCHIVE_RESCAN_INTERVAL"), 60*time.Second), "Periodic full re-walk of the archive index. Required on SMB/CIFS-backed roots where inotify misses cross-client writes. Default 60s; set 0 to disable.") convertPandocImageFlag := fs.String("convert-pandoc-image", getEnv("ZDDC_CONVERT_PANDOC_IMAGE", "docker.io/pandoc/latex:latest"), @@ -231,7 +228,6 @@ func Load(args []string) (Config, error) { OPACacheTTL: *opaCacheTTLFlag, AppsPubKey: *appsPubKeyFlag, MaxWriteBytes: *maxWriteBytesFlag, - CascadeMode: *cascadeModeFlag, ArchiveRescanInterval: *archiveRescanIntervalFlag, ConvertPandocImage: *convertPandocImageFlag, ConvertChromiumImage: *convertChromiumImageFlag, @@ -317,15 +313,6 @@ func Load(args []string) (Config, error) { return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty") } - switch cfg.CascadeMode { - case "", "delegated": - cfg.CascadeMode = "delegated" - case "strict": - // ok - default: - return Config{}, fmt.Errorf("--cascade-mode must be \"delegated\" or \"strict\", got %q", cfg.CascadeMode) - } - // Plain HTTP mode trusts the email header from any client. Only safe // behind an authenticating reverse proxy. Refuse to start when binding // plain HTTP to a non-loopback interface unless the operator has diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index bd51814..4c4cfdb 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -101,7 +101,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, parentChain, _ := zddc.EffectivePolicy(fsRoot, absDir) principal := zddc.Principal{Email: userEmail, Elevated: elevated} parentActiveAdmin := elevated && userEmail != "" && - zddc.IsAdminForChain(parentChain, userEmail, false) + zddc.IsAdminForChain(parentChain, userEmail) for _, entry := range entries { name := entry.Name() @@ -177,11 +177,17 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // reach. Uses the parent-dir chain (computed once above); // active-admin status short-circuits the per-file decider // query when the principal already holds admin authority. + // .zddc requires ActionAdmin (not ActionWrite) so the verb + // matches the file API's gate at fileapi.go:362-364. + action := policy.ActionWrite + if name == ".zddc" { + action = policy.ActionAdmin + } fileURL := baseURL + name if parentActiveAdmin { fi.Writable = true } else { - allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, policy.ActionWrite) + allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, action) if allowed { fi.Writable = true } @@ -241,7 +247,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // asked for hidden entries (?hidden=1), matching the dot-prefix // hide rule used for every other dotfile. if includeHidden { - if v, ok := virtualZddcEntry(absDir, baseURL); ok { + if v, ok := virtualZddcEntry(ctx, decider, parentChain, principal, parentActiveAdmin, absDir, baseURL); ok { result = append(result, v) } } @@ -254,18 +260,30 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // path (down through embedded defaults), so editing this virtual // entry is always meaningful — a save promotes it to a real on-disk // .zddc that overrides ancestor levels for this directory. -func virtualZddcEntry(absDir, baseURL string) (listing.FileInfo, bool) { +// +// Writable mirrors the real-file path: ActionAdmin against the parent +// chain, short-circuited when the principal already holds admin +// authority. An elevated admin sees writable=true and the editor lets +// them save; a non-admin sees writable=false and the editor mounts +// read-only. +func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain zddc.PolicyChain, principal zddc.Principal, parentActiveAdmin bool, absDir, baseURL string) (listing.FileInfo, bool) { zddcPath := filepath.Join(absDir, ".zddc") if _, err := os.Stat(zddcPath); err == nil { return listing.FileInfo{}, false } else if !os.IsNotExist(err) { return listing.FileInfo{}, false } + writable := parentActiveAdmin + if !writable { + allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc", policy.ActionAdmin) + writable = allowed + } return listing.FileInfo{ - Name: ".zddc", - URL: baseURL + ".zddc", - IsDir: false, - Virtual: true, + Name: ".zddc", + URL: baseURL + ".zddc", + IsDir: false, + Virtual: true, + Writable: writable, }, true } diff --git a/zddc/internal/handler/accepthandler.go b/zddc/internal/handler/accepthandler.go index d4c8154..c449ee4 100644 --- a/zddc/internal/handler/accepthandler.go +++ b/zddc/internal/handler/accepthandler.go @@ -244,7 +244,7 @@ func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Re // Optional Plan Review chain. Invokes executePlanReview directly // against the freshly-created received// path. The ACL - // gates re-run there — the invoker still needs CanEditZddc on the + // gates re-run there — the invoker still needs ActionAdmin on the // workflow roots and `c` on received//, 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 diff --git a/zddc/internal/handler/archivehandler_test.go b/zddc/internal/handler/archivehandler_test.go index b846f14..c383ef4 100644 --- a/zddc/internal/handler/archivehandler_test.go +++ b/zddc/internal/handler/archivehandler_test.go @@ -151,7 +151,7 @@ func contains(xs []string, x string) bool { func TestServeArchive_EmptyProject404(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: - allow: ["*"] + permissions: {"*": rwcd} `) cfg := archiveCfg(root) @@ -170,7 +170,7 @@ func TestServeArchive_EmptyProject404(t *testing.T) { func TestServeArchive_UnknownProject404(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: - allow: ["*"] + permissions: {"*": rwcd} `) cfg := archiveCfg(root) @@ -191,7 +191,7 @@ func TestServeArchive_UnknownProject404(t *testing.T) { func TestServeArchive_ListingScopedToProject(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: - allow: ["*"] + permissions: {"*": rwcd} `) cfg := archiveCfg(root) const email = "alice@example.com" @@ -255,7 +255,7 @@ func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) { // allow list anywhere → every per-target check returns deny → the // filtered listing is empty → 403. writeZddc(t, root, ".", `acl: - allow: ["alice@example.com"] + permissions: {"alice@example.com": rwcd} `) cfg := archiveCfg(root) @@ -277,13 +277,13 @@ func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) { func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: - allow: ["*"] + permissions: {"*": rwcd} `) // Deny alice on the transmittal folder where 100_~A+C1 lives, so her // listing of /ProjectA/.archive/ drops that entry — but other ProjectA // entries stay visible. writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl: - deny: ["alice@example.com"] + permissions: {"alice@example.com": ""} `) cfg := archiveCfg(root) @@ -313,10 +313,10 @@ func TestServeArchive_ResolvePerTargetACLOnly(t *testing.T) { // transmittal folder kicks mallory out at the per-target chain // ("first explicit match wins, bottom-up"). writeZddc(t, root, ".", `acl: - allow: ["alice@example.com", "mallory@example.com"] + permissions: {"alice@example.com": rwcd, "mallory@example.com": rwcd} `) writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl: - deny: ["mallory@example.com"] + permissions: {"mallory@example.com": ""} `) cfg := archiveCfg(root) @@ -345,10 +345,10 @@ func TestServeArchive_ResolveBypassesProjectRootDenyWhenPerTargetAllows(t *testi // — so the per-target chain at the file's directory hits the local // allow first. writeZddc(t, root, ".", `acl: - allow: ["alice@example.com"] + permissions: {"alice@example.com": rwcd} `) writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl: - allow: ["bob@example.com"] + permissions: {"bob@example.com": rwcd} `) cfg := archiveCfg(root) @@ -382,7 +382,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) { } writeZddc(t, root, ".", `acl: - allow: ["*"] + permissions: {"*": rwcd} `) cfg := archiveCfg(root) const email = "alice@example.com" @@ -442,7 +442,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) { func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: - allow: ["*@example.com"] + permissions: {"*@example.com": rwcd} `) cfg := archiveCfg(root) @@ -464,7 +464,7 @@ func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) { func TestServeArchive_ListingContentNegotiation(t *testing.T) { root, idx := archiveTestRoot(t) writeZddc(t, root, ".", `acl: - allow: ["*"] + permissions: {"*": rwcd} `) cfg := archiveCfg(root) const email = "alice@example.com" diff --git a/zddc/internal/handler/auth_invariants_test.go b/zddc/internal/handler/auth_invariants_test.go index 13488c9..5c6497b 100644 --- a/zddc/internal/handler/auth_invariants_test.go +++ b/zddc/internal/handler/auth_invariants_test.go @@ -109,15 +109,27 @@ func TestInvariant_UnelevatedAdminCannotBypassWorm(t *testing.T) { } func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) { - // The .zddc edit authority lives in zddc.CanEditZddc, gated on - // Principal.gate() — un-elevated must return false even for a root - // super-admin. Exercised at the helper boundary; the HTTP path - // guards .zddc at resolveTargetPath separately. + // .zddc edits route through the decider as ActionAdmin. The bypass + // for elevated admins fires only when Principal.Elevated is true. + // Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated + // super-admin must return Forbidden. cfg, _ := invariantsFixture(t) - p := zddc.Principal{Email: "admin@example.com", Elevated: false} - dir := filepath.Join(cfg.Root, "Project-1/working") - if zddc.CanEditZddc(cfg.Root, dir, p) { - t.Fatalf("un-elevated admin can edit .zddc — gate() bypassed") + target := "/Project-1/working/.zddc" + rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "") + if rec.Code != http.StatusForbidden { + t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestInvariant_ElevatedAdminCanEditZddc(t *testing.T) { + // Positive control: a super-admin who has elevated CAN write any + // .zddc. The decider's IsActiveAdmin short-circuit fires in + // AllowActionFromChainP and the file API write proceeds. + cfg, _ := invariantsFixture(t) + target := "/Project-1/working/.zddc" + rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("title: elevated edit\n"), "") + if rec.Code != http.StatusOK && rec.Code != http.StatusCreated { + t.Fatalf("elevated admin .zddc write blocked: status=%d body=%s", rec.Code, rec.Body.String()) } } @@ -171,7 +183,7 @@ func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) { if err != nil { t.Fatalf("EffectivePolicy: %v", err) } - if !zddc.IsAdminForChain(chain, p.Email, false) { + if !zddc.IsAdminForChain(chain, p.Email) { t.Fatalf("subtree admin lost authority to edit own .zddc — strict-ancestor wasn't supposed to apply") } } @@ -184,7 +196,7 @@ func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) { if err != nil { t.Fatalf("EffectivePolicy: %v", err) } - if !zddc.IsAdminForChain(chain, p.Email, false) { + if !zddc.IsAdminForChain(chain, p.Email) { t.Fatalf("subtree admin blocked from editing deeper .zddc") } } diff --git a/zddc/internal/handler/authcheck.go b/zddc/internal/handler/authcheck.go index d22230d..b5e611d 100644 --- a/zddc/internal/handler/authcheck.go +++ b/zddc/internal/handler/authcheck.go @@ -32,11 +32,11 @@ const AuthPathPrefix = "/.auth" // noticeable overhead. // // Scope: gates ON ROOT-ADMIN STATUS ONLY. This is intentionally -// stricter than the regular acl.allow / acl.deny chain — admin-only +// stricter than the regular acl.permissions chain — admin-only // endpoints (the dev-shell IDE, future maintenance routes) shouldn't // fall through to subtree-level allowances. For per-route ACL, callers // continue using the existing handlers (archive, profile, etc.) which -// consult AllowedWithChain. +// consult the policy decider. func ServeAuthAdmin(cfg config.Config, w http.ResponseWriter, r *http.Request) { email := EmailFromContext(r) // Elevation-independent gate. Upstream proxies (Caddy forward_auth diff --git a/zddc/internal/handler/converthandler.go b/zddc/internal/handler/converthandler.go index 69b6baa..b809b7c 100644 --- a/zddc/internal/handler/converthandler.go +++ b/zddc/internal/handler/converthandler.go @@ -55,10 +55,9 @@ const convertTimeout = 90 * time.Second // (the dispatcher) only invokes this when a stat on the requested // path itself fails — a real on-disk file always wins. // -// The path-suffix grammar replaces the legacy `.md?convert=docx` -// query form. A virtual file URL means `` works -// without any query-string handling, and a script's `curl -O …/foo.pdf` -// writes the expected filename. +// A virtual file URL means `` works without any +// query-string handling, and a script's `curl -O …/foo.pdf` writes the +// expected filename. func RecognizeVirtualConvert(fsRoot, urlPath string) (mdAbs, format string, ok bool) { lower := strings.ToLower(urlPath) for _, ext := range []string{".docx", ".html", ".pdf"} { diff --git a/zddc/internal/handler/directory_test.go b/zddc/internal/handler/directory_test.go index 8ff2d81..4d55c9a 100644 --- a/zddc/internal/handler/directory_test.go +++ b/zddc/internal/handler/directory_test.go @@ -29,7 +29,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) { // nothing else. A user without that email would have been 403'd before // the bypass. if err := os.WriteFile(filepath.Join(root, ".zddc"), - []byte("admins:\n - admin@example.com\nacl:\n allow:\n - admin@example.com\n"), + []byte("admins:\n - admin@example.com\nacl:\n permissions:\n admin@example.com: rwcd\n"), 0o644); err != nil { t.Fatalf("write root .zddc: %v", err) } @@ -41,11 +41,11 @@ func TestServeDirectoryRootIsPublic(t *testing.T) { } } if err := os.WriteFile(filepath.Join(root, "PublicProj", ".zddc"), - []byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil { + []byte("acl:\n permissions:\n \"*\": rwcd\n"), 0o644); err != nil { t.Fatalf("write PublicProj .zddc: %v", err) } if err := os.WriteFile(filepath.Join(root, "PrivateProj", ".zddc"), - []byte("acl:\n allow: [admin@example.com]\n"), 0o644); err != nil { + []byte("acl:\n permissions:\n admin@example.com: rwcd\n"), 0o644); err != nil { t.Fatalf("write PrivateProj .zddc: %v", err) } diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index 0b9137d..247d213 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -96,10 +96,16 @@ func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL str // Reject hidden / reserved segments. Mirrors dispatch's guard, // applied here too because external callers reach ServeFileAPI // only via dispatch — but defense in depth costs nothing. - for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") { + // Carve-out: `.zddc` as a leaf segment is writable (admin-gated) + // via the file API. Other dot/underscore segments stay reserved. + segs := strings.Split(strings.Trim(cleanURL, "/"), "/") + for i, seg := range segs { if seg == "" { continue } + if seg == ZddcFileBasename && i == len(segs)-1 { + continue + } if seg == "_app" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") { return "", "", false, http.StatusNotFound, "reserved path segment" } diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index 99762ab..f756c19 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -32,7 +32,7 @@ func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg // Root .zddc grants writer access to *@example.com. if err := os.WriteFile(filepath.Join(root, ".zddc"), - []byte("acl:\n allow:\n - \"*@example.com\"\n deny: []\n"), 0o644); err != nil { + []byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil { t.Fatalf("write root .zddc: %v", err) } @@ -138,7 +138,7 @@ func TestFileAPI_PutDenyForbidden(t *testing.T) { // Tighten ACL to a different domain — alice@example.com no longer // matches and writes must be 403. if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), - []byte("acl:\n allow:\n - \"*@allowed.com\"\n deny: []\n"), 0o644); err != nil { + []byte("acl:\n permissions:\n \"*@allowed.com\": rwcd\n"), 0o644); err != nil { t.Fatalf("rewrite .zddc: %v", err) } zddc.InvalidateCache(cfg.Root) @@ -152,7 +152,10 @@ func TestFileAPI_PutDenyForbidden(t *testing.T) { func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) { _, do, _ := fileAPITestSetup(t, nil, nil) - for _, p := range []string{"/.zddc", "/foo/.hidden", "/_app/spoof.html", "/_template/x"} { + // .zddc as a leaf is carved out — gated on admin authority via the + // decider, not blocked at the segment guard. Every other dot/ + // underscore segment stays reserved. + for _, p := range []string{"/foo/.hidden", "/_app/spoof.html", "/_template/x", "/.zddc.d/x"} { rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil) if rec.Code != http.StatusNotFound { t.Fatalf("want 404 for %s, got %d", p, rec.Code) @@ -437,7 +440,6 @@ acl: Root: root, EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 1024 * 1024, - CascadeMode: "delegated", } decider := &policy.InternalDecider{} @@ -628,46 +630,6 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) { } } -func TestFileAPI_StrictMode_AncestorDenyAbsolute(t *testing.T) { - cfg, _, root := rolePermissionsTestSetup(t) - cfg.CascadeMode = "strict" - - // Add a strict-mode lockout at root: deny vendor_acme everywhere. - rootZ, _ := os.ReadFile(filepath.Join(root, ".zddc")) - updated := strings.Replace(string(rootZ), "_doc_controller: rwcda\n", - "_doc_controller: rwcda\n vendor_acme: \"\"\n", 1) - if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(updated), 0o644); err != nil { - t.Fatalf("rewrite root: %v", err) - } - zddc.InvalidateCache(root) - - // Build a strict-mode decider so the file API uses the new mode. - decider := &policy.InternalDecider{Mode: zddc.ModeStrict} - - doStrict := func(method, target, email string, body []byte) *httptest.ResponseRecorder { - var req *http.Request - if body != nil { - req = httptest.NewRequest(method, target, bytes.NewReader(body)) - } else { - req = httptest.NewRequest(method, target, nil) - } - ctx := context.WithValue(req.Context(), EmailKey, email) - ctx = context.WithValue(ctx, ElevatedKey, true) - ctx = context.WithValue(ctx, DeciderKey, decider) - req = req.WithContext(ctx) - rec := httptest.NewRecorder() - ServeFileAPI(cfg, rec, req) - return rec - } - - // Vendor's leaf rwcd grant in archive/Acme/.zddc is overridden by - // the root deny under strict mode. - rec := doStrict(http.MethodPut, "/Project-X/archive/Acme/incoming/blocked.pdf", "rep@acme.com", []byte("nope")) - if rec.Code != http.StatusForbidden { - t.Fatalf("strict mode: vendor should be denied by root explicit-deny, got %d: %s", rec.Code, rec.Body.String()) - } -} - // --- staging↔working mirror ------------------------------------------------- // stagingMirrorURL builds a URL-safe target path for a transmittal folder diff --git a/zddc/internal/handler/formhandler_test.go b/zddc/internal/handler/formhandler_test.go index eb0bae0..c382689 100644 --- a/zddc/internal/handler/formhandler_test.go +++ b/zddc/internal/handler/formhandler_test.go @@ -229,7 +229,7 @@ func mustWrite(t *testing.T, path, body string) { func TestRenderEmptyForm(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: - allow: ["*@example.com"] + permissions: {"*@example.com": rwcd} `, }) rec := do(http.MethodGet, "/Working/safety/form.html", "casey@example.com", "") @@ -253,7 +253,7 @@ func TestRenderEmptyForm(t *testing.T) { func TestRenderEmptyForm_ACLDeny(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: - allow: ["root@example.com"] + permissions: {"root@example.com": rwcd} `, }) rec := do(http.MethodGet, "/Working/safety/form.html", "stranger@example.com", "") @@ -265,7 +265,7 @@ func TestRenderEmptyForm_ACLDeny(t *testing.T) { func TestCreateSubmission_Valid(t *testing.T) { cfg, do := formTestSetup(t, map[string]string{ "": `acl: - allow: ["*@example.com"] + permissions: {"*@example.com": rwcd} `, }) @@ -305,7 +305,7 @@ func TestCreateSubmission_Valid(t *testing.T) { func TestCreateSubmission_Invalid_Returns422(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: - allow: ["*@example.com"] + permissions: {"*@example.com": rwcd} `, }) @@ -343,7 +343,7 @@ func TestCreateSubmission_Invalid_Returns422(t *testing.T) { func TestCreateSubmission_ACLDeny(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: - allow: ["root@example.com"] + permissions: {"root@example.com": rwcd} `, }) body := `{"date":"2026-05-01","location":"Site A"}` @@ -356,7 +356,7 @@ func TestCreateSubmission_ACLDeny(t *testing.T) { func TestCreateSubmission_NoAuth_Returns401(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: - allow: ["*"] + permissions: {"*": rwcd} `, }) body := `{"date":"2026-05-01","location":"Site A"}` @@ -369,7 +369,7 @@ func TestCreateSubmission_NoAuth_Returns401(t *testing.T) { func TestCreateSubmission_FilenameCollision(t *testing.T) { cfg, do := formTestSetup(t, map[string]string{ "": `acl: - allow: ["*@example.com"] + permissions: {"*@example.com": rwcd} `, }) body := `{"date":"2026-05-01","location":"Site A"}` @@ -403,7 +403,7 @@ func TestCreateSubmission_FilenameCollision(t *testing.T) { func TestRenderEdit_LoadsSubmission(t *testing.T) { cfg, do := formTestSetup(t, map[string]string{ "": `acl: - allow: ["*@example.com"] + permissions: {"*@example.com": rwcd} `, }) @@ -431,7 +431,7 @@ func TestRenderEdit_LoadsSubmission(t *testing.T) { func TestUpdateSubmission_OverwritesFile(t *testing.T) { cfg, do := formTestSetup(t, map[string]string{ "": `acl: - allow: ["*@example.com"] + permissions: {"*@example.com": rwcd} `, }) @@ -465,7 +465,7 @@ func TestUpdateSubmission_OverwritesFile(t *testing.T) { func TestUpdateSubmission_NotFound(t *testing.T) { _, do := formTestSetup(t, map[string]string{ "": `acl: - allow: ["*@example.com"] + permissions: {"*@example.com": rwcd} `, }) body := `{"date":"2026-05-01","location":"Site A"}` diff --git a/zddc/internal/handler/middleware.go b/zddc/internal/handler/middleware.go index 4485980..b0bce30 100644 --- a/zddc/internal/handler/middleware.go +++ b/zddc/internal/handler/middleware.go @@ -194,7 +194,7 @@ func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, em if err != nil { return -1 } - return zddc.AdminLevelInChain(chain, email, false) + return zddc.AdminLevelInChain(chain, email) } abs := filepath.Join(cfg.Root, filepath.FromSlash(rel)) if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root { @@ -218,16 +218,16 @@ func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, em if err != nil { return -1 } - return zddc.AdminLevelInChain(chain, email, false) + return zddc.AdminLevelInChain(chain, email) } // PrincipalFromContext bundles the request's authenticated email plus // its elevation flag into a zddc.Principal — the value type the admin -// functions (IsAdmin, IsSubtreeAdmin, CanEditZddc) consume. One call -// per admin-check site replaces the previous ad-hoc email argument -// AND the previous "did I remember to gate this?" review burden: the -// type system enforces the gate by requiring a Principal value, which -// can only come from ACLMiddleware-tagged contexts. +// functions (IsAdmin, IsSubtreeAdmin) consume. One call per admin-check +// site replaces the previous ad-hoc email argument AND the previous +// "did I remember to gate this?" review burden: the type system +// enforces the gate by requiring a Principal value, which can only +// come from ACLMiddleware-tagged contexts. func PrincipalFromContext(r *http.Request) zddc.Principal { return zddc.Principal{ Email: EmailFromContext(r), @@ -342,8 +342,8 @@ func AccessLogMiddleware(cfg config.Config, auditLogger *slog.Logger, next http. // conferred authority on this request, or -1 if no admin // authority applies. Lets forensics tell "root admin acted" // (level 0) apart from "subtree admin acted" (level N) apart - // from "not admin" (-1). The active_admin bool is derived - // for back-compat with existing log consumers. + // from "not admin" (-1). The active_admin bool is its + // presence/absence projected to a boolean. adminLevel := activeAdminForRequest(cfg, r, elevated, email) args := []any{ diff --git a/zddc/internal/handler/planreview.go b/zddc/internal/handler/planreview.go index 628b5a1..0a9d34b 100644 --- a/zddc/internal/handler/planreview.go +++ b/zddc/internal/handler/planreview.go @@ -31,8 +31,9 @@ import ( // 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. +// - ActionAdmin on reviewing_root/.zddc + staging_root/.zddc. The +// invoker must already administer those subtrees per the cascade +// defaults. // // Operation: // @@ -144,7 +145,7 @@ func executePlanReview(cfg config.Config, r *http.Request, project, party, track // Pre-flight authorisation. No ACL exception — we use existing // cascade grants: - // (a) CanEditZddc on reviewing_root and staging_root proves the + // (a) ActionAdmin 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 @@ -158,10 +159,9 @@ func executePlanReview(cfg config.Config, r *http.Request, project, party, track 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. + // AllowActionFromChainP routes ActionAdmin .zddc edits and the + // single admin-bypass branch for elevated admins. No manual + // IsAdmin / IsSubtreeAdmin branching here. decider := DeciderFromContext(r) for _, root := range []string{reviewingRoot, stagingRoot} { chain, perr := zddc.EffectivePolicy(cfg.Root, root) diff --git a/zddc/internal/handler/profile_assets.go b/zddc/internal/handler/profile_assets.go index 387910c..a0965be 100644 --- a/zddc/internal/handler/profile_assets.go +++ b/zddc/internal/handler/profile_assets.go @@ -9,42 +9,30 @@ import ( "codeberg.org/VARASYS/ZDDC/zddc/internal/config" ) -// Custom CSS pipeline. Lets an operator drop `.profile.css` (or the -// legacy `.admin.css`) at the deployment root and have it picked up -// automatically as styling for the profile page. Previously lived -// alongside the retired form editor; kept because the profile page -// still relies on it. +// Custom CSS pipeline. Lets an operator drop `.profile.css` at the +// deployment root and have it picked up automatically as styling for +// the profile page. -const ( - profileCustomCSSName = ".profile.css" - adminCustomCSSName = ".admin.css" // legacy fallback -) +const profileCustomCSSName = ".profile.css" -// hasCustomProfileCSS reports whether /.profile.css (or the -// legacy .admin.css) exists. The profile template uses this to decide -// whether to inject the tag. +// hasCustomProfileCSS reports whether /.profile.css exists. +// The profile template uses this to decide whether to inject the +// tag. func hasCustomProfileCSS(fsRoot string) bool { - if _, err := os.Stat(filepath.Join(fsRoot, profileCustomCSSName)); err == nil { - return true - } - if _, err := os.Stat(filepath.Join(fsRoot, adminCustomCSSName)); err == nil { - return true - } - return false + _, err := os.Stat(filepath.Join(fsRoot, profileCustomCSSName)) + return err == nil } // profileAssetsPathPrefix is the URL prefix for admin static assets. -// Lived at /.profile/zddc/assets/ during the form-editor era; renamed -// once the form editor retired. The only consumer is the profile page, -// which emits a to /custom.css when an operator has placed one -// at root. +// The only consumer is the profile page, which emits a to +// /custom.css when an operator has placed one at root. const profileAssetsPathPrefix = ProfilePathPrefix + "/assets" // serveProfileAssets handles GET /.profile/assets/. V1 only -// ships `custom.css` (passthrough of /.profile.css when -// present, falling back to /.admin.css); other paths return -// 404 so we don't accidentally expose arbitrary files. The caller -// (profilehandler.go) has already gated on admin scope. +// ships `custom.css` (passthrough of /.profile.css when present); +// other paths return 404 so we don't accidentally expose arbitrary +// files. The caller (profilehandler.go) has already gated on admin +// scope. func serveProfileAssets(cfg config.Config, w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") @@ -56,11 +44,8 @@ func serveProfileAssets(cfg config.Config, w http.ResponseWriter, r *http.Reques case "custom.css": path := filepath.Join(cfg.Root, profileCustomCSSName) if fi, err := os.Stat(path); err != nil || fi.IsDir() { - path = filepath.Join(cfg.Root, adminCustomCSSName) - if fi, err := os.Stat(path); err != nil || fi.IsDir() { - http.NotFound(w, r) - return - } + http.NotFound(w, r) + return } w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index 6df7dab..edd45b0 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -34,14 +34,8 @@ func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.R sub = "/" } - // (The /.profile/zddc/* namespace previously hosted a parallel - // REST API + form-rendered editor for .zddc files. Retired — - // the YAML/CodeMirror editor in browse + the generic file-API - // (PUT/DELETE //.zddc) cover the same surface. Old links - // to `/.zddc.html` are 302'd to `/?file=.zddc` in the - // top-level dispatcher. The /assets/ sub-path is still served - // — the profile page emits a to its custom.css when an - // operator has placed one at root.) + // /assets/ serves the profile page's custom.css when an operator + // has placed one at root. if strings.HasPrefix(sub, "/assets/") { serveProfileAssets(cfg, w, r) return @@ -122,41 +116,40 @@ func serveProfileReindex(cfg config.Config, idx *archive.Index, email string, w }) } -// treeEntry is one row in the AccessView's AdminSubtrees / -// EditableParentChoices lists. The profile page renders them inline; -// the create-project form derives its parent-selector from the -// EditableParentChoices subset. +// treeEntry is one row in the AccessView's AdminSubtrees list — every +// directory containing a .zddc that the caller administers. The profile +// page renders them inline; the create-project form's parent-selector +// seeds from the same list. type treeEntry struct { - Path string `json:"path"` - CanEdit bool `json:"can_edit"` - Title string `json:"title,omitempty"` + Path string `json:"path"` + Title string `json:"title,omitempty"` } // 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. +// in via JS. AdminSubtrees doubles as the create-project parent-selector +// source — every entry is editable, since subtree admins own their own +// .zddc. // // IsSuperAdmin and HasAnyAdminScope reflect EFFECTIVE authority — gated // by elevation. CanElevate is the independent "do you have any admin // grant ANYWHERE in the tree, regardless of elevation?" signal that the // header elevation toggle reads to decide whether to show itself. 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"` - CanElevate bool `json:"can_elevate"` + Email string `json:"email"` + EmailHeader string `json:"email_header"` + IsSuperAdmin bool `json:"is_super_admin"` + HasAnyAdminScope bool `json:"has_any_admin_scope"` + CanElevate bool `json:"can_elevate"` // CanCreateProject is true when the caller is authorized to mkdir a // new top-level project — either via the root .zddc granting `c` to // their email/role, or via super-admin authority (elevated). Drives // the visibility of the profile page's "+ New project" form so the // UI doesn't dangle an affordance the server would 404. - CanCreateProject bool `json:"can_create_project"` - Projects []ProjectInfo `json:"projects"` - AdminSubtrees []treeEntry `json:"admin_subtrees"` - EditableParentChoices []treeEntry `json:"editable_parent_choices"` + CanCreateProject bool `json:"can_create_project"` + Projects []ProjectInfo `json:"projects"` + AdminSubtrees []treeEntry `json:"admin_subtrees"` } // enumerateAccess builds an AccessView for the given caller. Used by the @@ -186,19 +179,14 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate) view.CanCreateProject = allowed } - 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). Returns empty for an un-elevated -// principal — the elevation flag short-circuits each admin check below. +// caller can see as an admin (super-admin or subtree-admin). Every entry +// is editable — subtree admins own their own .zddc. Returns empty for an +// un-elevated principal — the elevation flag short-circuits each admin +// check below. func enumerateAdminSubtrees(cfg config.Config, p zddc.Principal) []treeEntry { dirs, _ := zddc.ScanZddcFiles(cfg.Root) out := make([]treeEntry, 0, len(dirs)) @@ -211,9 +199,8 @@ func enumerateAdminSubtrees(cfg config.Config, p zddc.Principal) []treeEntry { title = zf.Title } out = append(out, treeEntry{ - Path: urlPathOf(cfg.Root, d), - CanEdit: zddc.CanEditZddc(cfg.Root, d, p), - Title: title, + Path: urlPathOf(cfg.Root, d), + Title: title, }) } return out @@ -277,7 +264,6 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques 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, @@ -289,7 +275,6 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques IndexPath: cfg.IndexPath, EmailHeader: cfg.EmailHeader, CORSOrigins: cfg.CORSOrigins, - CascadeMode: cfg.CascadeMode, }) } @@ -365,9 +350,9 @@ func levelRank(s string) int { // "chain": { // "has_any_file": true, // "levels": [ -// {"path": "/", "exists": true, "acl": {"allow": [...]}, "admins": [...]}, +// {"path": "/", "exists": true, "acl": {"permissions": {...}}, "admins": [...]}, // {"path": "/Project-X/", "exists": false}, -// {"path": "/Project-X/sub/", "exists": true, "acl": {"allow": [...]}} +// {"path": "/Project-X/sub/", "exists": true, "acl": {"permissions": {...}}} // ] // } // } @@ -433,17 +418,15 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht // 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"` - CascadeMode string `json:"cascade_mode"` + 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"` + HasAnyFile bool `json:"has_any_file"` // VisibleStart is the lowest chain index whose grants are // visible to evaluation at the leaf, accounting for any - // inherit:false fence in delegated mode. In strict mode it - // is always 0 (fences are ignored under AC-6). + // inherit:false fence. VisibleStart int `json:"visible_start"` Levels []levelView `json:"levels"` } `json:"chain"` @@ -452,11 +435,9 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht Email: probeEmail, Decision: allow, DeciderKind: deciderKind(decider), - CascadeMode: cfg.CascadeMode, } out.Chain.HasAnyFile = chain.HasAnyFile - mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode) - out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels)-1, mode) + out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels) - 1) // Reconstruct level paths from cfg.Root. This mirrors how // zddc.EffectivePolicy builds the chain (see cascade.go). @@ -487,33 +468,30 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht entry := levelView{ Index: i, ZddcPath: lp, - Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0 || len(lvl.ACL.Permissions) > 0 || lvl.ACL.Inherit != nil, + Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Permissions) > 0 || lvl.ACL.Inherit != nil, Inherit: lvl.ACL.Inherit, } 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. + // Per-level email match: which permissions entry at this level + // would hit the email? Empty verbs = explicit deny; any non- + // empty verbs = grant. Mirrors GrantedVerbsAtLevel. anyMatch := false decisionAtLevel := "no_match" - for _, p := range lvl.ACL.Deny { - if zddc.MatchesPattern(p, probeEmail) { - anyMatch = true + for pattern, verbs := range lvl.ACL.Permissions { + if !zddc.MatchesPattern(pattern, probeEmail) { + continue + } + anyMatch = true + if verbs == "" { decisionAtLevel = "deny" break } - } - if !anyMatch { - for _, p := range lvl.ACL.Allow { - if zddc.MatchesPattern(p, probeEmail) { - anyMatch = true - decisionAtLevel = "allow" - break - } - } + decisionAtLevel = "allow" + // Don't break — keep scanning so an explicit deny still + // wins over a same-level grant. } entry.AnyMatch = anyMatch entry.Decision = decisionAtLevel diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 0392086..2a0c762 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -407,11 +407,11 @@ func TestServeProfileAccessJSON(t *testing.T) { } // Subtree-admin discovery used to live in the HTML render; now it flows -// through /.profile/access. Verify the JSON endpoint exposes everything -// the IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees -// for the read-only list, EditableParentChoices for the parent-selector -// options, and HasAnyAdminScope so the IIFE knows whether to clone the -//