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>
This commit is contained in:
ZDDC 2026-05-18 16:28:07 -05:00
parent ae105fde1c
commit f196205622
54 changed files with 589 additions and 1515 deletions

View file

@ -498,7 +498,7 @@ none of them is load-bearing alone.
|---|---|---|
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (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` (`<email>: 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/<party>/{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.

View file

@ -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.

View file

@ -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

View file

@ -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 `<dir>/.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: <dir>/.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

View file

@ -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 <dir>/.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)
}
})
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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/<tracking>/ 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/<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

View file

@ -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"

View file

@ -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")
}
}

View file

@ -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

View file

@ -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 `<file>.md?convert=docx`
// query form. A virtual file URL means `<a href="…/foo.docx">` works
// without any query-string handling, and a script's `curl -O …/foo.pdf`
// writes the expected filename.
// A virtual file URL means `<a href="…/foo.docx">` 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"} {

View file

@ -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)
}

View file

@ -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"
}

View file

@ -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

View file

@ -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"}`

View file

@ -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{

View file

@ -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)

View file

@ -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 <fsRoot>/.profile.css (or the
// legacy .admin.css) exists. The profile template uses this to decide
// whether to inject the <link> tag.
// hasCustomProfileCSS reports whether <fsRoot>/.profile.css exists.
// The profile template uses this to decide whether to inject the
// <link> 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 <link> to /custom.css when an operator has placed one
// at root.
// The only consumer is the profile page, which emits a <link> to
// /custom.css when an operator has placed one at root.
const profileAssetsPathPrefix = ProfilePathPrefix + "/assets"
// serveProfileAssets handles GET /.profile/assets/<file>. V1 only
// ships `custom.css` (passthrough of <root>/.profile.css when
// present, falling back to <root>/.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 <root>/.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")

View file

@ -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 /<path>/.zddc) cover the same surface. Old links
// to `<dir>/.zddc.html` are 302'd to `<dir>/?file=.zddc` in the
// top-level dispatcher. The /assets/ sub-path is still served
// — the profile page emits a <link> 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

View file

@ -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
// <template>. Pure non-admins get an empty access view and no scaffold.
// through /.profile/access. Verify the JSON endpoint exposes what the
// IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees
// for both the read-only list AND the parent-selector options, and
// HasAnyAdminScope so the IIFE knows whether to clone the <template>.
// Pure non-admins get an empty access view and no scaffold.
func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
@ -451,16 +451,12 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
if len(carol.AdminSubtrees) != 0 {
t.Errorf("carol AdminSubtrees = %v, want empty", carol.AdminSubtrees)
}
if len(carol.EditableParentChoices) != 0 {
t.Errorf("carol EditableParentChoices = %v, want empty", carol.EditableParentChoices)
}
// Subtree-admin: AdminSubtrees lists projects/ so the create-project
// parent dropdown can offer it; HasAnyAdminScope triggers template
// hydration. The projects/.zddc is NOT editable by bob — he cannot
// edit the file that grants him his own authority — so
// EditableParentChoices is empty and the Editable-files list will
// render its "None" placeholder.
// hydration. Subtree admins own their .zddc (strict-ancestor retired),
// so bob's projects/ entry is plainly listed and the Editable-files
// list will render it inline.
bob := fetchAccess("bob@example.com")
if bob.IsSuperAdmin {
t.Errorf("bob IsSuperAdmin = true, want false")
@ -475,17 +471,11 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
for _, s := range bob.AdminSubtrees {
if strings.HasSuffix(s.Path, "/projects") {
gotProjects = true
if s.CanEdit {
t.Errorf("bob's projects/ entry CanEdit = true; he should not be able to edit the .zddc granting his own authority")
}
}
}
if !gotProjects {
t.Errorf("bob AdminSubtrees missing projects/: %+v", bob.AdminSubtrees)
}
if len(bob.EditableParentChoices) != 0 {
t.Errorf("bob EditableParentChoices = %+v, want empty (his only subtree is one he can't edit)", bob.EditableParentChoices)
}
// Super-admin: AdminSubtrees enumerates every .zddc directory.
alice := fetchAccess("alice@example.com")
@ -508,7 +498,7 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(cfg.Root, "Closed-Project", ".zddc"),
[]byte("acl:\n allow:\n - alice@mycompany.com\n"), 0o644); err != nil {
[]byte("acl:\n permissions:\n alice@mycompany.com: rwcd\n"), 0o644); err != nil {
t.Fatalf("write child .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
@ -603,9 +593,8 @@ func TestServeProfileEffectivePolicy_InheritFence(t *testing.T) {
zddc.InvalidateCache(cfg.Root)
type respShape struct {
Decision bool `json:"decision"`
CascadeMode string `json:"cascade_mode"`
Chain struct {
Decision bool `json:"decision"`
Chain struct {
VisibleStart int `json:"visible_start"`
Levels []struct {
Index int `json:"index"`
@ -650,7 +639,7 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// .zddc exists but has no admins list — page is still reachable,
// but the admin/super-admin sections are absent.
cfg, ring := profileTestRoot(t, nil)
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n permissions:\n \"*\": rwcd\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
@ -785,7 +774,7 @@ func TestServeProfileProjectsCreateValidatesZddc(t *testing.T) {
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
body := `{"parent":"/", "name":"badproject", "acl":{"allow":["bad@@glob"], "deny":[]}}`
body := `{"parent":"/", "name":"badproject", "acl":{"permissions":{"bad@@glob":"rwcd"}}}`
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), EmailKey, "root@example.com")

View file

@ -212,12 +212,10 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
<label>Title (optional)
<input type="text" name="title" id="cp-title" maxlength="200">
</label>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Allow (optional)</h3>
<div class="list" data-field="acl.allow"></div>
<button type="button" class="add" data-target="acl.allow">+ Add allow rule</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Deny (optional)</h3>
<div class="list" data-field="acl.deny"></div>
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Permissions (optional)</h3>
<p class="help" style="margin: 0 0 .3rem;">Pattern (email or role) verbs (drawn from <code>r w c d a</code>). Empty verbs = explicit deny.</p>
<div class="list" data-field="acl.permissions"></div>
<button type="button" class="add" data-target="acl.permissions">+ Add permission</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Additional admins (optional)</h3>
<div class="list" data-field="admins"></div>
<button type="button" class="add" data-target="admins">+ Add admin</button>
@ -391,31 +389,24 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
subtrees.forEach(function(s) {
html += '<li><code>' + escText(s.path) + '</code>';
if (s.title) html += ' ' + escText(s.title);
if (s.can_edit) {
html += ' <span class="muted">(editable)</span>';
} else {
html += ' <span class="muted">(read-only you cannot edit the file granting your own authority)</span>';
}
html += '</li>';
});
html += '</ul>';
host.innerHTML = html;
}
function renderEditableList(parents, hasAnyAdminScope) {
function renderEditableList(parents) {
var host = document.getElementById("editable-list");
if (!host) return;
if (!parents || parents.length === 0) {
host.innerHTML = '<p class="muted">No <code>.zddc</code> files within your edit authority. Subtree admins cannot edit the file that grants their own authority only an admin from a higher level can.</p>';
host.innerHTML = '<p class="muted">No <code>.zddc</code> files within your edit authority.</p>';
return;
}
var html = '<ul class="bare">';
parents.forEach(function(p) {
var path = escText(p.path);
// Link to browse opening the .zddc in the YAML/CodeMirror
// editor (with .zddc-schema lint). Replaces the retired form-
// based editor at <prefix>/zddc/edit?path=; same data, one
// canonical edit surface.
// editor (with .zddc-schema lint).
var dirURL = path === '/' ? '/' : path + '/';
html += '<li><a href="' + dirURL + '?file=.zddc">'
+ '<code>' + path + '/.zddc</code></a>';
@ -455,6 +446,18 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
div.appendChild(input); div.appendChild(del);
return div;
}
function permRowFor() {
var div = document.createElement("div"); div.className = "row";
var pat = document.createElement("input");
pat.type = "text"; pat.dataset.role = "pattern"; pat.placeholder = "pattern (email or role)";
var verbs = document.createElement("input");
verbs.type = "text"; verbs.dataset.role = "verbs"; verbs.placeholder = "verbs (rwcda) — empty = deny";
verbs.style.maxWidth = "10em";
var del = document.createElement("button");
del.type = "button"; del.textContent = ""; del.className = "del";
div.appendChild(pat); div.appendChild(verbs); div.appendChild(del);
return div;
}
function collectList(field) {
var out = [];
document.querySelectorAll('#cp-form .list[data-field="' + field + '"] input').forEach(function(i) {
@ -462,11 +465,21 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
});
return out;
}
function collectPermissions() {
var out = {};
document.querySelectorAll('#cp-form .list[data-field="acl.permissions"] .row').forEach(function(row) {
var pat = row.querySelector('input[data-role="pattern"]').value.trim();
if (!pat) return;
out[pat] = row.querySelector('input[data-role="verbs"]').value.trim();
});
return out;
}
function wireCreateProjectForm() {
document.querySelectorAll("#cp-form button.add").forEach(function(btn) {
btn.addEventListener("click", function() {
var field = btn.dataset.target;
document.querySelector('#cp-form .list[data-field="' + field + '"]').appendChild(rowFor(field));
var host = document.querySelector('#cp-form .list[data-field="' + field + '"]');
host.appendChild(field === "acl.permissions" ? permRowFor() : rowFor(field));
});
});
document.getElementById("cp-form").addEventListener("click", function(e) {
@ -478,16 +491,15 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
ev.preventDefault();
document.getElementById("cp-name-err").textContent = "";
document.getElementById("cp-ok").hidden = true;
var allow = collectList("acl.allow");
var deny = collectList("acl.deny");
var permissions = collectPermissions();
var admins = collectList("admins");
var title = document.getElementById("cp-title").value.trim();
var body = {
parent: document.getElementById("cp-parent").value,
name: document.getElementById("cp-name").value.trim()
};
if (title) body.title = title;
if (allow.length || deny.length) body.acl = { allow: allow, deny: deny };
if (title) body.title = title;
if (Object.keys(permissions).length) body.acl = { permissions: permissions };
if (admins.length) body.admins = admins;
fetch(prefix + "/projects", {
method: "POST",
@ -523,7 +535,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
if (tmpl) {
var slot = document.getElementById("subtree-admin-slot");
slot.appendChild(tmpl.content.cloneNode(true));
renderEditableList(view.editable_parent_choices, view.has_any_admin_scope);
renderEditableList(view.admin_subtrees);
}
}
// Create-project mounts independently on the can_create_project
@ -535,7 +547,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
if (cpTmpl) {
var cpSlot = document.getElementById("create-project-slot");
cpSlot.appendChild(cpTmpl.content.cloneNode(true));
populateParentChoices(view.editable_parent_choices || []);
populateParentChoices(view.admin_subtrees || []);
wireCreateProjectForm();
}
}

View file

@ -122,7 +122,7 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
}
zf.Admins = admins
wantsZddc := len(zf.Admins) > 0 || zf.Title != "" ||
(req.ACL != nil && (len(req.ACL.Allow) > 0 || len(req.ACL.Deny) > 0 || len(req.ACL.Permissions) > 0))
(req.ACL != nil && len(req.ACL.Permissions) > 0)
if wantsZddc {
if errs := zddc.ValidateFile(zf); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8")

View file

@ -41,13 +41,12 @@ func zipMethodFor(name string) uint16 {
// URL strips to a real directory under fsRoot, or to a cascade-
// declared path that the listing pipeline would render as empty.
//
// The path-suffix grammar replaces the legacy `<dir>/?zip=1` query
// form. A virtual file living next to its source means clients can
// emit a plain `<a href>` without query-string handling; mirror
// tools pick it up via normal recursion; `curl -O` writes a sensible
// filename without a `--remote-header-name` hint. Real `.zip` files
// in the tree always win — stat is checked before this helper, so a
// genuine archive at `<path>.zip` serves its bytes normally.
// A virtual file living next to its source means clients can emit a
// plain `<a href>` without query-string handling; mirror tools pick
// it up via normal recursion; `curl -O` writes a sensible filename
// without a `--remote-header-name` hint. Real `.zip` files in the
// tree always win — stat is checked before this helper, so a genuine
// archive at `<path>.zip` serves its bytes normally.
func RecognizeVirtualSubtreeZip(fsRoot, urlPath string) (absDir string, ok bool) {
if !strings.HasSuffix(urlPath, ".zip") {
return "", false

View file

@ -265,14 +265,14 @@ func isNotExistError(err error) bool {
// allow, the embedded HTML is written verbatim. The client takes over
// from there — see tables/js/main.js.
func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
p := PrincipalFromContext(r)
decider := DeciderFromContext(r)
chain, err := zddc.EffectivePolicy(cfg.Root, req.Dir)
if err != nil {
slog.Warn("table: policy error", "path", req.Dir, "err", err)
}
if allowed, _ := policy.AllowActionFromChain(r.Context(), decider, chain, email, r.URL.Path, policy.ActionRead); !allowed {
if allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, r.URL.Path, policy.ActionRead); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

View file

@ -29,8 +29,8 @@ func IsZddcFileRequest(urlPath string) bool {
// ServeZddcFile serves a directory's .zddc as a plain YAML view.
//
// Method: GET / HEAD only; everything else → 405 with the existing
// /.profile/zddc editor pointed to in the body.
// Method: GET / HEAD only — the dispatcher routes writes
// (PUT/DELETE/POST) directly to ServeFileAPI.
// ACL: the parent directory's read permission gates access. A
// user who can read the directory can read its .zddc.
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim
@ -43,16 +43,6 @@ func IsZddcFileRequest(urlPath string) bool {
// The virtual response sets X-ZDDC-Source: virtual so the
// client can distinguish.
func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD, PUT, DELETE")
http.Error(w,
"Method Not Allowed — this URL serves the .zddc bytes for "+
"GET/HEAD. Writes go through the file API at the same "+
"URL (PUT to overwrite, DELETE to remove); for an editor, "+
"open <dir>/?file=.zddc to land on the YAML/CodeMirror view.\n",
http.StatusMethodNotAllowed)
return
}
decider := DeciderFromContext(r)
// URL is <dir>/.zddc. Strip the leaf to get the directory.
@ -175,12 +165,6 @@ func summariseLevel(lvl zddc.ZddcFile) string {
if lvl.Title != "" {
fmt.Fprintf(&b, "# title: %q\n", lvl.Title)
}
if len(lvl.ACL.Allow) > 0 {
fmt.Fprintf(&b, "# acl.allow: %v\n", lvl.ACL.Allow)
}
if len(lvl.ACL.Deny) > 0 {
fmt.Fprintf(&b, "# acl.deny: %v\n", lvl.ACL.Deny)
}
if len(lvl.ACL.Permissions) > 0 {
fmt.Fprintf(&b, "# acl.permissions: %v\n", lvl.ACL.Permissions)
}

View file

@ -105,20 +105,3 @@ func TestServeZddcFile_VirtualDefault(t *testing.T) {
}
}
func TestServeZddcFile_NonGetRejected(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcda\n")
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodPut, "/.zddc",
strings.NewReader("title: hacked\n"))
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeZddcFile(cfg, rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
}

View file

@ -43,8 +43,20 @@ func TestFederalRego_DivergencesFromStandard(t *testing.T) {
t.Fatalf("compile federal rego: %v", err)
}
allow := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}} }
deny := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}} }
allow := func(p ...string) zddc.ZddcFile {
m := make(map[string]string, len(p))
for _, x := range p {
m[x] = "rwcd"
}
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
}
deny := func(p ...string) zddc.ZddcFile {
m := make(map[string]string, len(p))
for _, x := range p {
m[x] = ""
}
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
}
empty := zddc.ZddcFile{}
cases := []struct {

View file

@ -37,13 +37,28 @@ func TestRegoParity_AllInternalCases(t *testing.T) {
}
allow := func(p ...string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}}
m := make(map[string]string, len(p))
for _, x := range p {
m[x] = "rwcd"
}
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
}
deny := func(p ...string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}}
m := make(map[string]string, len(p))
for _, x := range p {
m[x] = ""
}
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
}
allowDeny := func(a, d []string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
m := make(map[string]string, len(a)+len(d))
for _, x := range a {
m[x] = "rwcd"
}
for _, x := range d {
m[x] = ""
}
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
}
empty := zddc.ZddcFile{}

View file

@ -8,9 +8,9 @@
//
// Two implementations:
//
// - InternalDecider — wraps zddc.AllowedWithChain. The default;
// no new dependencies, identical semantics to the legacy code
// path. This is what the docs in zddc/README.md describe.
// - InternalDecider — wraps zddc.GrantedVerbsAtLevel walked over a
// PolicyChain. The default; no external dependencies. This is what
// the docs in zddc/README.md describe.
//
// - HTTPDecider — POSTs to OPA's canonical /v1/data/<package>/allow
// endpoint over HTTP or a Unix-domain socket. Federal customers
@ -93,11 +93,11 @@ type AllowInput struct {
PolicyChain *SerializableChain `json:"policy_chain,omitempty"`
}
// Action constants used in AllowInput.Action. Empty string is also
// accepted for back-compat with callers that don't specify a verb.
// Action constants used in AllowInput.Action. Empty string is treated
// as ActionRead for callers that don't specify a verb.
const (
ActionRead = "read" // listing + reading file bytes
ActionWrite = "write" // overwriting an existing file (legacy alias for the historical write-vs-read split)
ActionWrite = "write" // overwriting an existing file
ActionCreate = "create" // creating a new file or directory
ActionDelete = "delete" // deleting a file
ActionAdmin = "admin" // modifying ACL / .zddc / role definitions
@ -143,13 +143,6 @@ type Config struct {
URL string // raw value: "", "internal", "http(s)://...", "unix:///path"
FailOpen bool // external mode only: on transport error, allow instead of deny
CacheTTL time.Duration // external mode only: per-decision cache TTL. Zero = default 1s. Negative = no cache.
// CascadeMode controls how the InternalDecider walks the ACL chain:
// "delegated" (default — leaf grants override ancestor denies) or
// "strict" (ancestor explicit-deny is absolute; NIST AC-6).
// External deciders ignore this — Rego policies access the chain
// directly and implement either semantic themselves.
CascadeMode string
}
// New constructs a Decider per cfg.URL semantics.
@ -164,9 +157,8 @@ type Config struct {
//
// Returns an error if URL is unrecognized.
func New(cfg Config) (Decider, error) {
mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode)
if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") {
return &InternalDecider{Mode: mode}, nil
return &InternalDecider{}, nil
}
var inner Decider
var err error
@ -213,16 +205,15 @@ func (AllowAllDecider) Allow(_ context.Context, _ AllowInput) (bool, error) {
return true, nil
}
// InternalDecider routes Allow through zddc.AllowedAction with the
// configured cascade mode and applies the Issued/Received WORM mask
// post-decision. No network, no Rego, no new dependencies.
// InternalDecider routes Allow through zddc.AllowedAction and applies
// the Issued/Received WORM mask post-decision. No network, no Rego, no
// new dependencies.
//
// The decider does NOT consult the admins:/IsAdmin escape hatch —
// callers in the handler package wire IsAdmin / IsSubtreeAdmin around
// the decision. Admins bypass the WORM mask there as well.
type InternalDecider struct {
Mode zddc.CascadeMode
}
// The decider's admin bypass fires when AllowInput.User.IsActiveAdmin is
// true (the handler middleware sets that flag for elevated principals
// named in an admins: list anywhere on the chain). All other decisions
// flow through the normal cascade + WORM mask.
type InternalDecider struct{}
func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, error) {
chain := zddc.PolicyChain{}
@ -254,16 +245,13 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro
// so write/delete/admin are always stripped, create survives only
// via the worm: map (the deployment names its document
// controller there), and read survives via either the normal ACL
// or the worm: map. Admins are excluded from this code path by
// callers — the handler does the IsAdmin / IsSubtreeAdmin bypass
// before invoking Allow — so a mis-filed document still has a
// human escape hatch.
if wormGrant, inWorm := zddc.WormZoneGrant(chain, email, d.Mode); inWorm {
normalRead := zddc.EffectiveVerbs(chain, email, d.Mode) & zddc.VerbR
// or the worm: map.
if wormGrant, inWorm := zddc.WormZoneGrant(chain, email); inWorm {
normalRead := zddc.EffectiveVerbs(chain, email) & zddc.VerbR
return (normalRead | (wormGrant & zddc.VerbsRC)).Has(verb), nil
}
return zddc.AllowedAction(chain, email, verb, d.Mode), nil
return zddc.AllowedAction(chain, email, verb), nil
}
// HTTPDecider POSTs to /v1/data/zddc/access/allow on the configured
@ -361,13 +349,6 @@ func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, emai
return AllowActionFromChain(ctx, d, chain, email, path, ActionRead)
}
// AllowWriteFromChain is the legacy write-action helper. Newer callers
// should pick the specific verb (ActionCreate / ActionWrite /
// ActionDelete / ActionAdmin) via AllowActionFromChain instead.
func AllowWriteFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) {
return AllowActionFromChain(ctx, d, chain, email, path, ActionWrite)
}
// AllowActionFromChain is the canonical access-decision helper.
// External Rego policies can branch on input.action to differentiate
// among the five verbs (read / write / create / delete / admin). The
@ -395,21 +376,19 @@ func AllowFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChain, p z
// when (and only when) the caller actually holds elevated admin
// authority on this chain.
//
// Strict-ancestor rule: NOT applied by default. A subtree admin whose
// admins: entry lives in <dir>/.zddc CAN edit that file — they own
// the directory and everything it grants. Footgun: they can also
// remove themselves from the admins list (recoverable: a super-admin
// always retains authority via the cascade from the root .zddc and
// can restore the grant). The prior strict-ancestor mode protected
// Strict-ancestor rule: not applied. A subtree admin whose admins:
// entry lives in <dir>/.zddc CAN edit that file — they own the
// directory and everything it grants. Footgun: they can also remove
// themselves from the admins list (recoverable: a super-admin always
// retains authority via the cascade from the root .zddc and can
// restore the grant). The prior strict-ancestor mode protected
// against peer-addition / delegator-removal but was always partial
// (deeper .zddc files were freely editable) and made the common
// case — "project creator wants to add a collaborator" — friction-y
// enough to be unusable. IsAdminForChain still accepts excludeLeaf
// for any caller that wants strict mode; the default path doesn't
// fire it.
// (deeper .zddc files were freely editable) and made the common case
// — "project creator wants to add a collaborator" — friction-y enough
// to be unusable.
func AllowActionFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChain, p zddc.Principal, path, action string) (bool, error) {
isAdmin := p.Elevated && p.Email != "" &&
zddc.IsAdminForChain(chain, p.Email, false)
zddc.IsAdminForChain(chain, p.Email)
in := AllowInput{Path: path, Action: action, PolicyChain: chainToSerializable(chain)}
in.User.Email = p.Email
in.User.IsActiveAdmin = isAdmin

View file

@ -66,14 +66,13 @@ func describe(v interface{}) string {
}
}
// TestInternalDecider_ParityWithAllowedWithChain: the internal
// decider returns the same answer as zddc.AllowedWithChain for
// every documented cascade scenario.
func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
allow := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}} }
deny := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}} }
allowDeny := func(a, d []string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
// TestInternalDecider_CascadeScenarios exercises the internal decider
// against the documented cascade rules: default-allow on empty trees,
// default-deny when .zddc files exist but nothing matches, leaf-wins
// for first match bottom-up, and re-allow at the deepest level.
func TestInternalDecider_CascadeScenarios(t *testing.T) {
perm := func(p map[string]string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: p}}
}
empty := zddc.ZddcFile{}
@ -91,21 +90,21 @@ func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
},
{
"files exist but no rule matches → deny",
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
zddc.PolicyChain{Levels: []zddc.ZddcFile{perm(map[string]string{"*@trusted.com": "r"})}, HasAnyFile: true},
"alice@example.com",
false,
},
{
"leaf allow wins",
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, perm(map[string]string{"*@example.com": "r"})}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"leaf deny beats parent allow (bottom-up first match)",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
deny("alice@example.com"),
perm(map[string]string{"*@example.com": "r"}),
perm(map[string]string{"alice@example.com": ""}),
}, HasAnyFile: true},
"alice@example.com",
false,
@ -113,8 +112,8 @@ func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
{
"leaf has no rule for user, falls back to parent allow",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
allow("bob@example.com"),
perm(map[string]string{"*@example.com": "r"}),
perm(map[string]string{"bob@example.com": "r"}),
}, HasAnyFile: true},
"alice@example.com",
true,
@ -122,8 +121,8 @@ func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
{
"leaf allows user that parent denies",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("alice@example.com"),
allow("alice@example.com"),
perm(map[string]string{"alice@example.com": ""}),
perm(map[string]string{"alice@example.com": "r"}),
}, HasAnyFile: true},
"alice@example.com",
true,
@ -131,9 +130,9 @@ func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
{
"multi-level: deepest match wins",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}),
allow("alice@example.com"),
perm(map[string]string{"*@example.com": "r"}),
perm(map[string]string{"*@example.com": "r", "alice@example.com": ""}),
perm(map[string]string{"alice@example.com": "r"}),
}, HasAnyFile: true},
"alice@example.com",
true,
@ -146,10 +145,6 @@ func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
if err != nil {
t.Fatalf("AllowFromChain: %v", err)
}
want := zddc.AllowedWithChain(tc.chain, tc.email)
if got != want {
t.Errorf("decider = %v, AllowedWithChain = %v (parity broken)", got, want)
}
if got != tc.want {
t.Errorf("decider = %v, want %v", got, tc.want)
}

View file

@ -15,15 +15,18 @@
# "policy_chain": {
# "levels": [
# {"acl": {}, "admins": ["admin@example.com"]},
# {"acl": {"allow": ["*@example.com"]}}
# {"acl": {"permissions": {"*@example.com": "rwcd"}}}
# ],
# "has_any_file": true
# }
# }
#
# acl.permissions maps each principal pattern to a verb string drawn from
# {r,w,c,d,a}. An empty verb string is an explicit deny.
#
# Levels are ordered ROOT → LEAF (deepest level last). Cascade walks
# bottom-up (deepest first); first explicit match wins; within a single
# level, a deny pattern is checked before an allow pattern.
# level, an explicit-deny entry is checked before a grant entry.
#
# Default-allow when has_any_file is false (no .zddc anywhere → public);
# default-deny when has_any_file is true and nothing matched (the safety
@ -49,37 +52,34 @@ allow if {
level_grants(input.policy_chain.levels[deepest])
}
# Set of level indices where the email matches at least one allow or deny
# pattern. The deepest-index member is the level whose decision counts.
# Set of level indices where the email matches at least one permission
# entry. The deepest-index member is the level whose decision counts.
matched_levels := {i |
some i
level_matches(input.policy_chain.levels[i])
}
# A level "matches" if its email is in either its deny list or its allow
# list. Whether the level grants or denies is a separate question
# (level_grants below) — deny is checked before allow within a level.
# A level "matches" if some permission entry's pattern matches the email
# (regardless of whether the verb string grants or denies). Whether the
# level grants or denies is a separate question (level_grants below).
level_matches(level) if {
some pattern in level.acl.deny
some pattern, _ in level.acl.permissions
email_matches(pattern, input.user.email)
}
level_matches(level) if {
some pattern in level.acl.allow
email_matches(pattern, input.user.email)
}
# A level grants iff (a) no deny pattern matches at this level AND (b) some
# allow pattern matches. Mirrors AllowedAtLevel in acl.go: deny is checked
# first; if no deny hit, an allow match returns true.
# A level grants iff (a) no explicit-deny entry at this level matches AND
# (b) some grant entry (non-empty verbs) matches. Mirrors
# GrantedVerbsAtLevel in acl.go: explicit deny wins within a level.
level_grants(level) if {
not level_denies(level)
some pattern in level.acl.allow
some pattern, verbs in level.acl.permissions
verbs != ""
email_matches(pattern, input.user.email)
}
level_denies(level) if {
some pattern in level.acl.deny
some pattern, verbs in level.acl.permissions
verbs == ""
email_matches(pattern, input.user.email)
}

View file

@ -27,6 +27,8 @@
# whatever they write.
#
# Input shape: identical to access.rego — see that file's docstring.
# acl.permissions maps principal patterns to verb strings; an empty
# verb string is an explicit deny.
package zddc.access_federal
@ -54,17 +56,19 @@ allow if {
any_allow_match
}
# Any deny pattern at ANY level matches the email.
# Any explicit-deny permission entry at ANY level matches the email.
any_deny_match if {
some level in input.policy_chain.levels
some pattern in level.acl.deny
some pattern, verbs in level.acl.permissions
verbs == ""
email_matches(pattern, input.user.email)
}
# Any allow pattern at ANY level matches the email.
# Any grant permission entry (non-empty verbs) at ANY level matches.
any_allow_match if {
some level in input.policy_chain.levels
some pattern in level.acl.allow
some pattern, verbs in level.acl.permissions
verbs != ""
email_matches(pattern, input.user.email)
}

View file

@ -2,20 +2,12 @@ package zddc
import "strings"
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for
// callers that only need the legacy boolean read decision on a single
// ZddcFile (no cascade chain).
//
// Hardcodes ModeDelegated — safe because the synthetic chain has only
// one level and no ancestors to fence — but callers that operate on a
// real PolicyChain must call GrantedVerbsAtLevel directly with the
// active mode.
//
// Deprecated: prefer GrantedVerbsAtLevel for any code path that may
// later need fence-aware or strict-mode evaluation.
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel for callers
// that have a single ZddcFile (no cascade chain) and only need the
// boolean read decision.
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
v, m := GrantedVerbsAtLevel(chain, 0, email, ModeDelegated)
v, m := GrantedVerbsAtLevel(chain, 0, email)
if !m {
return false, false
}
@ -29,27 +21,21 @@ func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool)
// - matched=true, set!={} → union of verb sets from every matching entry
//
// Role lookups for principal keys without "@" use RoleMembers, which
// walks levelIdx → fence-or-root for the closest definition. mode
// controls whether inherit:false fences are honored — see VisibleStart.
//
// Legacy acl.allow / acl.deny entries are folded in here (rather than at
// parse time) so this function works correctly on test-constructed
// ZddcFile literals as well as parser output.
func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string, mode CascadeMode) (VerbSet, bool) {
// walks levelIdx → fence-or-root for the closest definition.
func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return 0, false
}
level := chain.Levels[levelIdx]
perms := effectivePermissions(level.ACL)
if len(perms) == 0 {
if len(level.ACL.Permissions) == 0 {
return 0, false
}
matched := false
deniedExplicit := false
var grant VerbSet
for principal, verbStr := range perms {
if !MatchesPrincipal(principal, email, chain, levelIdx, mode) {
for principal, verbStr := range level.ACL.Permissions {
if !MatchesPrincipal(principal, email, chain, levelIdx) {
continue
}
matched = true
@ -72,44 +58,17 @@ func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string, mode Cas
return grant, true
}
// effectivePermissions returns the union of acl.permissions and the
// legacy acl.allow / acl.deny fields, with permissions winning on
// collision. Returns nil if all three are empty. Does not mutate rules.
func effectivePermissions(rules ACLRules) map[string]string {
if len(rules.Permissions) == 0 && len(rules.Allow) == 0 && len(rules.Deny) == 0 {
return nil
}
out := make(map[string]string, len(rules.Permissions)+len(rules.Allow)+len(rules.Deny))
for _, pat := range rules.Allow {
out[pat] = "rwcd"
}
for _, pat := range rules.Deny {
out[pat] = ""
}
for k, v := range rules.Permissions {
out[k] = v
}
return out
}
// AllowedWithChain evaluates a PolicyChain leaf→root (deepest level first)
// for the read action. Preserved for legacy callers and existing read paths
// that haven't migrated to AllowedAction yet.
func AllowedWithChain(chain PolicyChain, email string) bool {
return AllowedAction(chain, email, VerbR, ModeDelegated)
}
// AllowedAction evaluates a PolicyChain for a specific verb and cascade mode.
// Thin wrapper around EffectiveVerbs that surfaces the boolean answer.
func AllowedAction(chain PolicyChain, email string, verb VerbSet, mode CascadeMode) bool {
return EffectiveVerbs(chain, email, mode).Has(verb)
// AllowedAction evaluates a PolicyChain for a specific verb.
// Thin wrapper over EffectiveVerbs.
func AllowedAction(chain PolicyChain, email string, verb VerbSet) bool {
return EffectiveVerbs(chain, email).Has(verb)
}
// EffectiveVerbs computes the verb set granted to email by the cascade.
// Walks the full chain and applies the default-allow rule (no .zddc
// anywhere → public access).
func EffectiveVerbs(chain PolicyChain, email string, mode CascadeMode) VerbSet {
v := EffectiveVerbsRange(chain, 0, len(chain.Levels), email, mode)
func EffectiveVerbs(chain PolicyChain, email string) VerbSet {
v := EffectiveVerbsRange(chain, 0, len(chain.Levels), email)
if v == 0 && !chain.HasAnyFile {
// Public-tree default: empty chain with no .zddc files anywhere
// → grant everything. EffectiveVerbsRange returns 0 in this
@ -130,14 +89,9 @@ func EffectiveVerbs(chain PolicyChain, email string, mode CascadeMode) VerbSet {
// WORM-folder are evaluated as separate ranges, then their grants are
// masked and unioned.
//
// Cascade mode controls whether ancestor explicit-denies are absolute
// (Strict) or can be overridden by a leaf grant (Delegated). The
// strict-mode pass is restricted to the same range — splitting the
// chain implies splitting the strict-mode walk too.
//
// This function does NOT consult the admins:/IsAdmin escape hatch and
// does NOT apply the Issued/Received WORM mask.
func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mode CascadeMode) VerbSet {
func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string) VerbSet {
if fromIdx < 0 {
fromIdx = 0
}
@ -151,21 +105,12 @@ func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mo
return 0
}
// Honor inherit:false fences — clamp fromIdx upward to the deepest
// fence visible from the leaf end of the range. In strict mode the
// fence helper returns 0 unconditionally, so this is a no-op.
if fence := chain.VisibleStart(toIdx-1, mode); fence > fromIdx {
// fence visible from the leaf end of the range.
if fence := chain.VisibleStart(toIdx - 1); fence > fromIdx {
fromIdx = fence
}
if mode == ModeStrict {
for i := fromIdx; i < toIdx; i++ {
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
if matched && grant == 0 {
return 0
}
}
}
for i := toIdx - 1; i >= fromIdx; i-- {
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
grant, matched := GrantedVerbsAtLevel(chain, i, email)
if !matched {
continue
}

View file

@ -113,7 +113,7 @@ func TestAllowedAtLevel(t *testing.T) {
{
name: "allow matched",
level: ZddcFile{ACL: ACLRules{
Allow: []string{"*@example.com"},
Permissions: map[string]string{"*@example.com": "rwcd"},
}},
email: "alice@example.com",
wantAllowed: true,
@ -122,7 +122,7 @@ func TestAllowedAtLevel(t *testing.T) {
{
name: "deny matched",
level: ZddcFile{ACL: ACLRules{
Deny: []string{"alice@example.com"},
Permissions: map[string]string{"alice@example.com": ""},
}},
email: "alice@example.com",
wantAllowed: false,
@ -131,8 +131,10 @@ func TestAllowedAtLevel(t *testing.T) {
{
name: "deny wins over allow at the same level",
level: ZddcFile{ACL: ACLRules{
Allow: []string{"*@example.com"},
Deny: []string{"alice@example.com"},
Permissions: map[string]string{
"*@example.com": "rwcd",
"alice@example.com": "",
},
}},
email: "alice@example.com",
wantAllowed: false,
@ -141,8 +143,10 @@ func TestAllowedAtLevel(t *testing.T) {
{
name: "neither rule matches",
level: ZddcFile{ACL: ACLRules{
Allow: []string{"*@example.com"},
Deny: []string{"*@evil.com"},
Permissions: map[string]string{
"*@example.com": "rwcd",
"*@evil.com": "",
},
}},
email: "carol@other.org",
wantAllowed: false,
@ -160,91 +164,3 @@ func TestAllowedAtLevel(t *testing.T) {
}
}
func TestAllowedWithChain(t *testing.T) {
allow := func(p ...string) ZddcFile { return ZddcFile{ACL: ACLRules{Allow: p}} }
deny := func(p ...string) ZddcFile { return ZddcFile{ACL: ACLRules{Deny: p}} }
allowDeny := func(a, d []string) ZddcFile { return ZddcFile{ACL: ACLRules{Allow: a, Deny: d}} }
empty := ZddcFile{}
cases := []struct {
name string
chain PolicyChain
email string
want bool
}{
{
name: "empty chain, no files: default allow",
chain: PolicyChain{HasAnyFile: false},
email: "alice@example.com",
want: true,
},
{
name: "files exist but no rule matches: default deny",
chain: PolicyChain{Levels: []ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
email: "alice@example.com",
want: false,
},
{
name: "leaf allow wins",
chain: PolicyChain{Levels: []ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
email: "alice@example.com",
want: true,
},
{
name: "leaf deny beats parent allow (bottom-up first match)",
chain: PolicyChain{Levels: []ZddcFile{
allow("*@example.com"),
deny("alice@example.com"),
}, HasAnyFile: true},
email: "alice@example.com",
want: false,
},
{
name: "leaf has no rule for user, falls back to parent allow",
chain: PolicyChain{Levels: []ZddcFile{
allow("*@example.com"),
allow("bob@example.com"), // doesn't match alice
}, HasAnyFile: true},
email: "alice@example.com",
want: true,
},
{
name: "leaf allows user that parent denies",
chain: PolicyChain{Levels: []ZddcFile{
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
email: "alice@example.com",
want: true, // leaf wins
},
{
name: "multi-level: deepest match wins",
chain: PolicyChain{Levels: []ZddcFile{
allow("*@example.com"),
allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}),
allow("alice@example.com"), // deepest re-allows alice
}, HasAnyFile: true},
email: "alice@example.com",
want: true,
},
{
name: "no match anywhere with files present: deny",
chain: PolicyChain{Levels: []ZddcFile{empty, empty, empty}, HasAnyFile: true},
email: "alice@example.com",
want: false,
},
{
name: "no match anywhere without files: allow",
chain: PolicyChain{Levels: []ZddcFile{empty, empty, empty}, HasAnyFile: false},
email: "alice@example.com",
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := AllowedWithChain(tc.chain, tc.email); got != tc.want {
t.Errorf("AllowedWithChain = %v, want %v", got, tc.want)
}
})
}
}

View file

@ -36,37 +36,21 @@ func (p Principal) gate() bool {
// caller is responsible for gating on Principal.Elevated before
// treating the result as live authority.
//
// excludeLeaf=true drops the deepest level from the walk (strict-
// ancestor opt-in; ModeStrict deployments use this for .zddc edits).
// The root .zddc has no strict ancestor, so the rule degenerates at
// len==1: the loop falls back to checking that single level,
// preserving the bootstrap super-admin path.
//
// Role lookups inside this walk are bounded to the levels visible
// AT the matching level (via MatchesPrincipal's third/fourth args).
// A role defined at the deepest level never confers self-edit
// rights indirectly because the role definition isn't visible above
// the level it's defined at.
// AT the matching level (via MatchesPrincipal's third/fourth args
// and the fence-aware VisibleStart helper).
//
// Exposed separately from IsAdminForChain so audit-logging callers
// can record WHICH level conferred admin authority — useful for
// forensics across nested delegation (root admin vs subtree admin
// at depth N).
func AdminLevelInChain(chain PolicyChain, email string, excludeLeaf bool) int {
func AdminLevelInChain(chain PolicyChain, email string) int {
if email == "" {
return -1
}
n := len(chain.Levels)
if n == 0 {
return -1
}
end := n
if excludeLeaf && n > 1 {
end = n - 1
}
for i := 0; i < end; i++ {
for _, principal := range chain.Levels[i].Admins {
if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
for i, level := range chain.Levels {
for _, principal := range level.Admins {
if MatchesPrincipal(principal, email, chain, i) {
return i
}
}
@ -75,10 +59,9 @@ func AdminLevelInChain(chain PolicyChain, email string, excludeLeaf bool) int {
}
// IsAdminForChain is the boolean shortcut over AdminLevelInChain.
// Returns true iff some level grants admin authority. See
// AdminLevelInChain for parameter semantics.
func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool {
return AdminLevelInChain(chain, email, excludeLeaf) >= 0
// Returns true iff some level grants admin authority.
func IsAdminForChain(chain PolicyChain, email string) bool {
return AdminLevelInChain(chain, email) >= 0
}
// HasAnyAdminGrant reports whether email is named as an admin somewhere
@ -86,9 +69,9 @@ func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool {
// subtree-admin grant via paths:.<dir>.admins. ELEVATION-INDEPENDENT:
// answers "could this user opt into admin powers if they wanted to?",
// which the header elevation toggle reads to decide whether to render
// itself. The elevation-AWARE checks (IsAdmin, IsSubtreeAdmin,
// CanEditZddc) take a Principal and short-circuit on !Elevated;
// this function just asks the cascade.
// itself. The elevation-AWARE checks (IsAdmin, IsSubtreeAdmin) take a
// Principal and short-circuit on !Elevated; this function just asks
// the cascade.
//
// Returns false for an empty email so anonymous callers can't probe.
func HasAnyAdminGrant(fsRoot, email string) bool {
@ -113,7 +96,7 @@ func HasAnyAdminGrant(fsRoot, email string) bool {
}
for i, level := range chain.Levels {
for _, principal := range level.Admins {
if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
if MatchesPrincipal(principal, email, chain, i) {
return true
}
}
@ -131,7 +114,7 @@ func HasAnyAdminGrant(fsRoot, email string) bool {
// Subtree-scoped admin authority (the "fiefdom" model) is checked via
// IsSubtreeAdmin / CanEditZddc instead.
//
// Patterns use the same glob syntax as acl.allow / acl.deny (see
// Patterns use the same glob syntax as acl.permissions keys (see
// MatchesPattern). Returns false if the root file does not exist, has an
// empty Admins list, no entry matches, or the principal hasn't elevated.
func IsAdmin(fsRoot string, p Principal) bool {
@ -153,16 +136,14 @@ func IsAdmin(fsRoot string, p Principal) bool {
// IsSubtreeAdmin reports whether email administers the subtree rooted at
// dirPath. Authority cascades: a match against any Admins entry on the chain
// from fsRoot down to dirPath (inclusive) confers admin rights for dirPath.
// Subtree admins own their own .zddc — both reading admin tools and
// writing the file itself are gated by this same check (the file API's
// ActionAdmin path on .zddc edits).
//
// Admins entries may be email-glob patterns OR role references (a bare
// role name, or @role:<name>) — resolved the same way acl.permissions
// keys are, so `admins: [document_controller]` works once a deployment
// populates that role.
//
// This is the read-side check — "can email *see* admin tools for this
// subtree?". For write authority over a specific .zddc file, use
// CanEditZddc, which adds the strict-ancestor rule that prevents
// self-elevation.
func IsSubtreeAdmin(fsRoot, dirPath string, p Principal) bool {
if !p.gate() {
return false
@ -173,7 +154,7 @@ func IsSubtreeAdmin(fsRoot, dirPath string, p Principal) bool {
}
for i, level := range chain.Levels {
for _, principal := range level.Admins {
if MatchesPrincipal(principal, p.Email, chain, i, ModeDelegated) {
if MatchesPrincipal(principal, p.Email, chain, i) {
return true
}
}
@ -181,52 +162,3 @@ func IsSubtreeAdmin(fsRoot, dirPath string, p Principal) bool {
return false
}
// CanEditZddc reports whether email may write or delete <dirPath>/.zddc.
//
// The strict-ancestor rule: authority must come from a .zddc file STRICTLY
// ABOVE dirPath. An admin granted in <dirPath>/.zddc cannot edit that same
// file (which is what grants their own authority) — they can only edit
// .zddc files in deeper subtrees. This prevents a subtree admin from
// adding peers at their own level, removing their delegator, or otherwise
// elevating themselves.
//
// The root file <fsRoot>/.zddc is the bootstrap exception: it has no
// strict ancestor, so it is governed by its own Admins list (the same
// allowlist IsAdmin checks). The very first super-admin is created by
// hand-editing this file at server install time.
func CanEditZddc(fsRoot, dirPath string, p Principal) bool {
if !p.gate() {
return false
}
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil || len(chain.Levels) == 0 {
return false
}
// Bootstrap: the root file is governed by its own Admins.
if dirPath == fsRoot {
for _, pattern := range chain.Levels[0].Admins {
if MatchesPattern(pattern, p.Email) {
return true
}
}
return false
}
// Strict-ancestor: scan all levels EXCEPT the deepest, which IS dirPath.
// EffectivePolicy returns levels ordered root (index 0) → leaf (last).
// Admins entries may be email globs or role references (resolved
// against the chain up to level i — so a role defined at the
// deepest level, which is dirPath, never confers self-edit rights).
for i := 0; i < len(chain.Levels)-1; i++ {
for _, principal := range chain.Levels[i].Admins {
if MatchesPrincipal(principal, p.Email, chain, i, ModeDelegated) {
return true
}
}
}
return false
}

View file

@ -21,7 +21,7 @@ func TestIsAdmin(t *testing.T) {
},
{
name: "zddc file with no admins key → not admin",
zddcBody: "acl:\n allow: [\"*\"]\n",
zddcBody: "acl:\n permissions:\n \"*\": rwcd\n",
email: "alice@example.com",
want: false,
},
@ -63,7 +63,7 @@ func TestIsAdmin(t *testing.T) {
},
{
name: "acl deny does not affect admins",
zddcBody: "acl:\n deny: [\"*@example.com\"]\nadmins:\n - alice@example.com\n",
zddcBody: "acl:\n permissions:\n \"*@example.com\": \"\"\nadmins:\n - alice@example.com\n",
email: "alice@example.com",
want: true,
},
@ -86,8 +86,8 @@ func TestIsAdmin(t *testing.T) {
// TestIsAdminSubdirIgnored documents that admins entries in subdirectory
// .zddc files are NOT honored by IsAdmin — only the root .zddc grants the
// server-wide super-admin role. Subtree admin authority for "fiefdom"
// editing is a separate concept covered by IsSubtreeAdmin / CanEditZddc.
// server-wide super-admin role. Subtree admin authority is a separate
// concept covered by IsSubtreeAdmin.
func TestIsAdminSubdirIgnored(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "project")
@ -96,7 +96,7 @@ func TestIsAdminSubdirIgnored(t *testing.T) {
}
// Root has no admins; subdir tries to grant admin.
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("acl:\n permissions:\n \"*\": rwcd\n"), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
if err := os.WriteFile(filepath.Join(sub, ".zddc"), []byte("admins:\n - mallory@example.com\n"), 0o644); err != nil {
@ -254,185 +254,17 @@ func TestIsSubtreeAdmin(t *testing.T) {
}
}
func TestCanEditZddc(t *testing.T) {
// TestIsAdminForChain pins the unified helper that backs IsAdmin and
// IsSubtreeAdmin. Each table entry covers one property: cascade walk,
// role resolution scope, the bootstrap case for the root file, empty-
// email refusal.
func TestIsAdminForChain(t *testing.T) {
cases := []struct {
name string
files map[string]string
dir string
email string
want bool
}{
{
name: "root super-admin can edit root .zddc (bootstrap)",
files: map[string]string{
"": "admins:\n - root@example.com\n",
},
dir: "",
email: "root@example.com",
want: true,
},
{
name: "non-admin cannot edit root",
files: map[string]string{
"": "admins:\n - root@example.com\n",
},
dir: "",
email: "alice@example.com",
want: false,
},
{
name: "no zddc files at all → nobody edits root",
files: map[string]string{},
dir: "",
email: "anyone@example.com",
want: false,
},
{
name: "root super-admin can edit any subtree file",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects/x": "",
},
dir: "projects/x",
email: "root@example.com",
want: true,
},
{
name: "subtree admin can edit deeper file (strict ancestor satisfied)",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/x": "",
},
dir: "projects/x",
email: "alice@example.com",
want: true,
},
{
name: "subtree admin CANNOT edit their own grant file (no strict ancestor for them)",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
},
dir: "projects",
email: "alice@example.com",
want: false,
},
{
name: "subtree admin CANNOT edit root",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
},
dir: "",
email: "alice@example.com",
want: false,
},
{
name: "subtree admin CANNOT edit sibling's grant file",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"foo": "admins:\n - alice@example.com\n",
"bar": "admins:\n - bob@example.com\n",
},
dir: "bar",
email: "alice@example.com",
want: false,
},
{
name: "two-level delegation — mid-level admin edits leaf below their grant",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/sub": "admins:\n - bob@example.com\n",
"projects/sub/x": "",
},
dir: "projects/sub/x",
email: "alice@example.com",
want: true,
},
{
name: "two-level delegation — bob (mid-level admin) cannot edit own grant",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/sub": "admins:\n - bob@example.com\n",
},
dir: "projects/sub",
email: "bob@example.com",
want: false,
},
{
name: "two-level delegation — bob can still edit deeper",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/sub": "admins:\n - bob@example.com\n",
"projects/sub/x": "",
},
dir: "projects/sub/x",
email: "bob@example.com",
want: true,
},
{
name: "mallory in a subdir admins list — original escalation case stays blocked",
files: map[string]string{
"": "acl:\n allow: [\"*\"]\n",
"project": "admins:\n - mallory@example.com\n",
},
dir: "project",
email: "mallory@example.com",
want: false,
},
{
name: "glob root admin can edit anything",
files: map[string]string{
"": "admins:\n - \"*@varasys.io\"\n",
"projects/x": "",
},
dir: "projects/x",
email: "alice@varasys.io",
want: true,
},
{
name: "empty email never edits",
files: map[string]string{"": "admins:\n - \"*\"\n"},
dir: "",
email: "",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
writeZddcTree(t, root, tc.files)
dir := filepath.Join(root, tc.dir)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir target: %v", err)
}
InvalidateCache(dir)
if got := CanEditZddc(root, dir, Principal{Email: tc.email, Elevated: true}); got != tc.want {
t.Errorf("CanEditZddc(dir=%q, email=%q) = %v, want %v",
tc.dir, tc.email, got, tc.want)
}
})
}
}
// TestIsAdminForChain pins the unified helper that replaces IsAdmin +
// IsSubtreeAdmin + CanEditZddc once callers migrate. Each table entry
// covers one property: cascade walk, role resolution scope, the
// strict-ancestor rule for .zddc edits (excludeLeaf), the bootstrap
// case for the root file, empty-email refusal.
func TestIsAdminForChain(t *testing.T) {
cases := []struct {
name string
files map[string]string
dir string
email string
excludeLeaf bool
want bool
}{
{
name: "root super-admin matches at any depth",
@ -451,6 +283,16 @@ func TestIsAdminForChain(t *testing.T) {
email: "alice@example.com",
want: true,
},
{
name: "subtree admin matches at their grant level (no strict-ancestor)",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"project": "admins:\n - alice@example.com\n",
},
dir: "project",
email: "alice@example.com",
want: true,
},
{
name: "subtree admin does NOT match outside their subtree",
files: map[string]string{
@ -461,38 +303,6 @@ func TestIsAdminForChain(t *testing.T) {
email: "alice@example.com",
want: false,
},
{
name: "excludeLeaf hides the leaf .zddc's own admins (self-elevation prevention)",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"project": "admins:\n - alice@example.com\n",
},
dir: "project",
email: "alice@example.com",
excludeLeaf: true,
want: false,
},
{
name: "excludeLeaf lets ancestor admins through (peer protection)",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"project": "admins:\n - alice@example.com\n",
},
dir: "project",
email: "root@example.com",
excludeLeaf: true,
want: true,
},
{
name: "excludeLeaf at root falls back to the root admins (bootstrap)",
files: map[string]string{
"": "admins:\n - alice@example.com\n",
},
dir: "",
email: "alice@example.com",
excludeLeaf: true,
want: true,
},
{
name: "empty email never matches",
files: map[string]string{"": "admins:\n - \"*\"\n"},
@ -510,20 +320,6 @@ func TestIsAdminForChain(t *testing.T) {
email: "bob@example.com",
want: true,
},
{
name: "role defined at leaf is NOT visible above it under excludeLeaf",
files: map[string]string{
"": "admins:\n - leafrole\n",
"project": "roles:\n leafrole:\n members: [alice@example.com]\n",
},
dir: "project",
email: "alice@example.com",
excludeLeaf: true,
// admins entry at root references leafrole, but the role is
// defined at the leaf — under strict-ancestor rule (excludeLeaf),
// the leaf's role definition isn't visible. So the match fails.
want: false,
},
}
for _, tc := range cases {
@ -539,9 +335,9 @@ func TestIsAdminForChain(t *testing.T) {
if err != nil {
t.Fatalf("EffectivePolicy: %v", err)
}
if got := IsAdminForChain(chain, tc.email, tc.excludeLeaf); got != tc.want {
t.Errorf("IsAdminForChain(dir=%q, email=%q, excludeLeaf=%v) = %v, want %v",
tc.dir, tc.email, tc.excludeLeaf, got, tc.want)
if got := IsAdminForChain(chain, tc.email); got != tc.want {
t.Errorf("IsAdminForChain(dir=%q, email=%q) = %v, want %v",
tc.dir, tc.email, got, tc.want)
}
})
}
@ -552,12 +348,11 @@ func TestIsAdminForChain(t *testing.T) {
// index, no match returns -1.
func TestAdminLevelInChain(t *testing.T) {
cases := []struct {
name string
files map[string]string
dir string
email string
excludeLeaf bool
want int
name string
files map[string]string
dir string
email string
want int
}{
{
name: "root super-admin matches at level 0",
@ -601,28 +396,6 @@ func TestAdminLevelInChain(t *testing.T) {
email: "",
want: -1,
},
{
name: "excludeLeaf hides deepest match — falls back to ancestor",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"project": "admins:\n - alice@example.com\n",
},
dir: "project",
email: "root@example.com",
excludeLeaf: true,
want: 0,
},
{
name: "excludeLeaf with only leaf match returns -1",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"project": "admins:\n - alice@example.com\n",
},
dir: "project",
email: "alice@example.com",
excludeLeaf: true,
want: -1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@ -637,7 +410,7 @@ func TestAdminLevelInChain(t *testing.T) {
if err != nil {
t.Fatalf("EffectivePolicy: %v", err)
}
if got := AdminLevelInChain(chain, tc.email, tc.excludeLeaf); got != tc.want {
if got := AdminLevelInChain(chain, tc.email); got != tc.want {
t.Errorf("AdminLevelInChain(dir=%q, email=%q) = %d, want %d",
tc.dir, tc.email, got, tc.want)
}

View file

@ -31,16 +31,8 @@ type PolicyChain struct {
// descendants at-and-below the fence. The deepest fence in the prefix
// wins (nested fences are supported; the closer-to-leaf wins).
//
// In strict cascade mode, fences are ignored — returns 0 — because
// federal/AC-6 deployments require ancestor explicit-denies to be
// absolute, and the inherit directive would let a leaf widen access an
// ancestor refused.
//
// toIdx is clamped to len(chain.Levels)-1.
func (chain PolicyChain) VisibleStart(toIdx int, mode CascadeMode) int {
if mode == ModeStrict {
return 0
}
func (chain PolicyChain) VisibleStart(toIdx int) int {
if toIdx >= len(chain.Levels) {
toIdx = len(chain.Levels) - 1
}

View file

@ -1,48 +0,0 @@
package zddc
// CascadeMode selects the access-decision algorithm used by AllowedAction.
//
// ModeDelegated (default) preserves the historical commercial-tenant
// behavior: the cascade walks leaf→root and the first level with a
// matching entry decides. Subtree allows can override ancestor denies —
// this is the load-bearing delegation primitive that lets a subtree
// owner grant access without root-admin involvement.
//
// ModeStrict implements the federal posture (NIST AC-6 "least
// privilege"): a deny anywhere in the ancestor chain is absolute and
// cannot be overridden by a leaf grant. Implemented as a two-pass
// evaluation — first walk root→leaf for any matching explicit deny,
// then walk leaf→root for the grant.
//
// The mode is operator-controlled at startup via --cascade-mode (config
// flag) or ZDDC_CASCADE_MODE (env var). Subtree .zddc files cannot
// override the mode — it is a deployment-wide policy.
type CascadeMode int
const (
ModeDelegated CascadeMode = iota
ModeStrict
)
// String returns the operator-facing name (matches the flag value).
func (m CascadeMode) String() string {
switch m {
case ModeStrict:
return "strict"
default:
return "delegated"
}
}
// ParseCascadeMode resolves a flag/env string to a CascadeMode. Empty
// or unrecognized input defaults to ModeDelegated; the caller can warn
// on unrecognized values, but the safe default is the existing behavior.
func ParseCascadeMode(s string) (CascadeMode, bool) {
switch s {
case "", "delegated":
return ModeDelegated, true
case "strict":
return ModeStrict, true
}
return ModeDelegated, false
}

View file

@ -1,108 +0,0 @@
package zddc
import "testing"
// helpers
func chain(levels ...ZddcFile) PolicyChain {
return PolicyChain{Levels: levels, HasAnyFile: len(levels) > 0}
}
func perms(p map[string]string) ZddcFile {
return ZddcFile{ACL: ACLRules{Permissions: p}}
}
// TestDelegated_LeafGrantOverridesAncestorDeny verifies the historical
// commercial behavior preserved as ModeDelegated.
func TestDelegated_LeafGrantOverridesAncestorDeny(t *testing.T) {
c := chain(
perms(map[string]string{"vendor_acme": ""}), // root: deny
ZddcFile{ // mid: define the role
ACL: ACLRules{},
Roles: map[string]Role{"vendor_acme": {Members: []string{"*@acme.com"}}},
},
perms(map[string]string{"vendor_acme": "rwcd"}), // leaf: allow
)
// Need the role definition to flow up to root for the deny entry to
// match acme members. Add the role at root too.
c.Levels[0].Roles = map[string]Role{"vendor_acme": {Members: []string{"*@acme.com"}}}
if !AllowedAction(c, "rep@acme.com", VerbR, ModeDelegated) {
t.Errorf("delegated mode: leaf rwcd should override root deny for read")
}
if !AllowedAction(c, "rep@acme.com", VerbW, ModeDelegated) {
t.Errorf("delegated mode: leaf rwcd should override root deny for write")
}
}
func TestStrict_AncestorDenyAbsolute(t *testing.T) {
c := chain(
ZddcFile{
ACL: ACLRules{Permissions: map[string]string{"vendor_acme": ""}},
Roles: map[string]Role{"vendor_acme": {Members: []string{"*@acme.com"}}},
},
ZddcFile{
ACL: ACLRules{Permissions: map[string]string{"vendor_acme": "rwcd"}},
},
)
if AllowedAction(c, "rep@acme.com", VerbR, ModeStrict) {
t.Errorf("strict mode: root deny should not be overridable by leaf grant")
}
if AllowedAction(c, "rep@acme.com", VerbW, ModeStrict) {
t.Errorf("strict mode: root deny should not be overridable by leaf grant (write)")
}
}
func TestStrict_NoAncestorDenyMeansLeafDecides(t *testing.T) {
c := chain(
ZddcFile{
ACL: ACLRules{Permissions: map[string]string{"_company": "r"}},
Roles: map[string]Role{"_company": {Members: []string{"*@mycompany.com"}}},
},
perms(map[string]string{"alice@mycompany.com": "rwcd"}),
)
if !AllowedAction(c, "alice@mycompany.com", VerbW, ModeStrict) {
t.Errorf("strict: leaf grant should decide when no ancestor explicit-deny matches")
}
}
func TestStrict_AncestorDenyOnRoleSpecificEntryDoesNotBlockOthers(t *testing.T) {
// Root denies vendor_acme but grants _company. acme is locked out
// under strict; mycompany staff still see leaf grants.
c := chain(
ZddcFile{
ACL: ACLRules{Permissions: map[string]string{
"vendor_acme": "",
"_company": "r",
}},
Roles: map[string]Role{
"vendor_acme": {Members: []string{"*@acme.com"}},
"_company": {Members: []string{"*@mycompany.com"}},
},
},
perms(map[string]string{"_company": "rwcd"}),
)
if AllowedAction(c, "rep@acme.com", VerbR, ModeStrict) {
t.Errorf("strict: acme should be denied (root deny is absolute)")
}
if !AllowedAction(c, "alice@mycompany.com", VerbW, ModeStrict) {
t.Errorf("strict: mycompany's leaf grant should still apply (no matching ancestor deny)")
}
}
func TestParseCascadeMode(t *testing.T) {
cases := map[string]CascadeMode{
"": ModeDelegated,
"delegated": ModeDelegated,
"strict": ModeStrict,
}
for in, want := range cases {
got, ok := ParseCascadeMode(in)
if !ok || got != want {
t.Errorf("ParseCascadeMode(%q) = %v %v, want %v true", in, got, ok, want)
}
}
if _, ok := ParseCascadeMode("loose"); ok {
t.Errorf("ParseCascadeMode(\"loose\") should be ok=false")
}
}

View file

@ -51,8 +51,8 @@ func TestEffectivePolicy(t *testing.T) {
if err := os.MkdirAll(leaf, 0o755); err != nil {
t.Fatal(err)
}
writeZddc(t, root, "acl:\n allow:\n - root@example.com\n")
writeZddc(t, leaf, "acl:\n allow:\n - leaf@example.com\n")
writeZddc(t, root, "acl:\n permissions:\n root@example.com: rwcd\n")
writeZddc(t, leaf, "acl:\n permissions:\n leaf@example.com: rwcd\n")
chain, err := EffectivePolicy(root, leaf)
if err != nil {
@ -64,14 +64,20 @@ func TestEffectivePolicy(t *testing.T) {
if len(chain.Levels) != 3 {
t.Fatalf("len(Levels) = %d, want 3", len(chain.Levels))
}
if got := chain.Levels[0].ACL.Allow; len(got) != 1 || got[0] != "root@example.com" {
t.Errorf("root level Allow = %v, want [root@example.com]", got)
if got := chain.Levels[0].ACL.Permissions["root@example.com"]; got != "rwcd" {
t.Errorf("root level permissions[root] = %q, want %q", got, "rwcd")
}
if got := chain.Levels[1].ACL.Allow; len(got) != 0 {
t.Errorf("middle level Allow = %v, want empty", got)
// Middle level has no fixture-specific .zddc; the merge accumulator
// at this depth must not have grown a root@/leaf@ entry from
// somewhere unexpected.
if _, ok := chain.Levels[1].ACL.Permissions["root@example.com"]; ok {
t.Errorf("middle level unexpectedly carries root@example.com")
}
if got := chain.Levels[2].ACL.Allow; len(got) != 1 || got[0] != "leaf@example.com" {
t.Errorf("leaf level Allow = %v, want [leaf@example.com]", got)
if _, ok := chain.Levels[1].ACL.Permissions["leaf@example.com"]; ok {
t.Errorf("middle level unexpectedly carries leaf@example.com")
}
if got := chain.Levels[2].ACL.Permissions["leaf@example.com"]; got != "rwcd" {
t.Errorf("leaf level permissions[leaf] = %q, want %q", got, "rwcd")
}
})
@ -101,7 +107,7 @@ func TestEffectivePolicy(t *testing.T) {
}
// Garbage YAML
writeZddc(t, root, "::: not yaml :::")
writeZddc(t, leaf, "acl:\n allow:\n - leaf@example.com\n")
writeZddc(t, leaf, "acl:\n permissions:\n leaf@example.com: rwcd\n")
chain, err := EffectivePolicy(root, leaf)
if err != nil {
@ -111,18 +117,18 @@ func TestEffectivePolicy(t *testing.T) {
t.Error("HasAnyFile = false, want true (malformed file still counts as present)")
}
// Root level parsed empty (parse error path), leaf level has the rule
if got := chain.Levels[0].ACL.Allow; len(got) != 0 {
t.Errorf("root level Allow = %v, want empty (parse error)", got)
if got := chain.Levels[0].ACL.Permissions; len(got) != 0 {
t.Errorf("root level permissions = %v, want empty (parse error)", got)
}
if got := chain.Levels[1].ACL.Allow; len(got) != 1 || got[0] != "leaf@example.com" {
t.Errorf("leaf level Allow = %v, want [leaf@example.com]", got)
if got := chain.Levels[1].ACL.Permissions["leaf@example.com"]; got != "rwcd" {
t.Errorf("leaf level permissions[leaf] = %q, want %q", got, "rwcd")
}
})
t.Run("dirPath equal to fsRoot has single level", func(t *testing.T) {
resetCache()
root := t.TempDir()
writeZddc(t, root, "acl:\n allow:\n - root@example.com\n")
writeZddc(t, root, "acl:\n permissions:\n root@example.com: rwcd\n")
chain, err := EffectivePolicy(root, root)
if err != nil {
@ -144,23 +150,23 @@ func TestEffectivePolicyEndToEnd(t *testing.T) {
t.Fatal(err)
}
}
writeZddc(t, priv, "acl:\n allow:\n - alice@example.com\n deny:\n - bob@example.com\n")
writeZddc(t, priv, "acl:\n permissions:\n \"alice@example.com\": r\n \"bob@example.com\": \"\"\n")
// public/: no .zddc on the chain → default allow for everyone
chainPub, _ := EffectivePolicy(root, pub)
if !AllowedWithChain(chainPub, "anyone@anywhere.com") {
if !AllowedAction(chainPub, "anyone@anywhere.com", VerbR) {
t.Error("public/ should be open when no .zddc files exist on the chain")
}
// private/: has rules → only alice gets in
chainPriv, _ := EffectivePolicy(root, priv)
if !AllowedWithChain(chainPriv, "alice@example.com") {
if !AllowedAction(chainPriv, "alice@example.com", VerbR) {
t.Error("alice should be allowed in private/")
}
if AllowedWithChain(chainPriv, "bob@example.com") {
if AllowedAction(chainPriv, "bob@example.com", VerbR) {
t.Error("bob should be denied in private/")
}
if AllowedWithChain(chainPriv, "carol@example.com") {
if AllowedAction(chainPriv, "carol@example.com", VerbR) {
t.Error("carol (unlisted) should be denied in private/ when files exist")
}
}

View file

@ -8,24 +8,13 @@ import (
// ACLRules holds the access-control rules at one cascade level.
//
// Three input forms, all merged at parse time into a single map keyed
// by principal (Permissions):
// One input form, keyed by principal (Permissions):
//
// - acl.permissions: { principal → verb-set } — the canonical form.
// Principal is an email pattern (contains "@") or a role name
// (no "@"); roles are looked up via ZddcFile.Roles in this file
// or any ancestor. Verb-set is a string drawn from {r,w,c,d,a};
// empty string is an explicit deny.
//
// - acl.allow: [pattern, ...] — legacy. Each pattern becomes
// Permissions[pattern] = "rwcd" at parse time.
//
// - acl.deny: [pattern, ...] — legacy. Each pattern becomes
// Permissions[pattern] = "" at parse time (explicit deny).
//
// Allow and Deny are retained on the struct for round-trip fidelity
// (and so existing operator-authored .zddc files render unchanged in
// the admin UI); the cascade evaluator reads only Permissions.
// - acl.permissions: { principal → verb-set }. Principal is an email
// pattern (contains "@") or a role name (no "@"); roles are looked
// up via ZddcFile.Roles in this file or any ancestor. Verb-set is
// a string drawn from {r,w,c,d,a}; empty string is an explicit
// deny.
//
// Inherit controls whether this level imports grants and roles from
// its ancestors. The default (when the field is absent — represented
@ -36,14 +25,12 @@ import (
// (e.g. a vendor folder where only the vendor and the doc controller
// should have access regardless of broader project-level grants).
//
// In strict cascade mode (federal / NIST AC-6), inherit:false is
// REFUSED — a leaf-level directive cannot widen access an ancestor
// refused. The internal decider silently treats it as inherit:true;
// the cascade tracer (/.profile/effective-policy) reports both
// `cascade_mode` and `chain.visible_start` so an operator can see
// that a configured fence is being ignored under the active mode.
// Operators running the federal Rego preset get the same behaviour
// from policy enforcement.
// Federal deployments running the bundled `access_federal.rego` get
// parent-deny-is-absolute / NIST AC-6 semantics; the directive's
// fence-style "reset" should be avoided there because it would let a
// leaf widen access an ancestor refused. The cascade tracer at
// /.profile/effective-policy reports `chain.visible_start` so an
// operator can verify which level a fence is actually cutting off.
//
// Inherit is per-level and not itself cascading: an ancestor's
// `inherit: false` does not transitively block descendants from
@ -54,8 +41,6 @@ import (
// in the external-OPA input body (see internal/policy). The canonical
// in-repo serialization is YAML; JSON is only used for OPA queries.
type ACLRules struct {
Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
Deny []string `yaml:"deny,omitempty" json:"deny,omitempty"`
Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
// Inherit *bool: nil = unset (inherit normally), &true = same,
// &false = fence ancestors. Using a pointer so the default is
@ -71,9 +56,8 @@ func (r ACLRules) InheritsAncestors() bool {
}
// Role is the named principal-grouping primitive. Members are email
// patterns (same syntax as the legacy allow/deny entries — see
// MatchesPattern). A role defined at level L is in scope at L and all
// descendants.
// patterns (see MatchesPattern). A role defined at level L is in scope
// at L and all descendants.
//
// Role membership UNIONS across the cascade: if the same role name is
// defined at multiple levels, the effective member set is the union
@ -418,30 +402,5 @@ func parseBytes(data []byte) (ZddcFile, error) {
if err := yaml.Unmarshal(data, &zf); err != nil {
return ZddcFile{}, err
}
mergeLegacyACL(&zf.ACL)
return zf, nil
}
// mergeLegacyACL folds legacy acl.allow / acl.deny lists into the
// canonical ACL.Permissions map so cascade evaluators only need to
// consult one place. Existing entries in Permissions take precedence
// (operators who specified both forms get the new form's value);
// allow entries become "rwcd" grants, deny entries become "" denies.
func mergeLegacyACL(rules *ACLRules) {
if len(rules.Allow) == 0 && len(rules.Deny) == 0 {
return
}
if rules.Permissions == nil {
rules.Permissions = make(map[string]string, len(rules.Allow)+len(rules.Deny))
}
for _, pat := range rules.Allow {
if _, present := rules.Permissions[pat]; !present {
rules.Permissions[pat] = "rwcd"
}
}
for _, pat := range rules.Deny {
if _, present := rules.Permissions[pat]; !present {
rules.Permissions[pat] = ""
}
}
}

View file

@ -22,7 +22,7 @@ func TestVisibleStart_NoFence(t *testing.T) {
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcd"})},
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcda"})},
)
if got := chain.VisibleStart(2, ModeDelegated); got != 0 {
if got := chain.VisibleStart(2); got != 0 {
t.Errorf("no fence: VisibleStart = %d, want 0", got)
}
}
@ -33,14 +33,14 @@ func TestVisibleStart_FenceClampsToFence(t *testing.T) {
ZddcFile{ACL: aclFenced(map[string]string{"*@vendor.com": "rwcd"}, false)},
ZddcFile{ACL: aclOpen(map[string]string{})},
)
if got := chain.VisibleStart(2, ModeDelegated); got != 1 {
if got := chain.VisibleStart(2); got != 1 {
t.Errorf("fence at 1: VisibleStart(2) = %d, want 1", got)
}
if got := chain.VisibleStart(1, ModeDelegated); got != 1 {
if got := chain.VisibleStart(1); got != 1 {
t.Errorf("fence at 1: VisibleStart(1) = %d, want 1", got)
}
// Fence above toIdx is irrelevant.
if got := chain.VisibleStart(0, ModeDelegated); got != 0 {
if got := chain.VisibleStart(0); got != 0 {
t.Errorf("fence at 1: VisibleStart(0) = %d, want 0 (fence not yet in scope)", got)
}
}
@ -52,22 +52,11 @@ func TestVisibleStart_NestedFencesDeepestWins(t *testing.T) {
ZddcFile{ACL: aclFenced(map[string]string{"*@b.com": "rwcd"}, false)},
ZddcFile{ACL: aclOpen(map[string]string{})},
)
if got := chain.VisibleStart(3, ModeDelegated); got != 2 {
if got := chain.VisibleStart(3); got != 2 {
t.Errorf("nested fence: deepest wins, got %d want 2", got)
}
}
func TestVisibleStart_StrictModeIgnoresFence(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "r"})},
ZddcFile{ACL: aclFenced(map[string]string{"*@vendor.com": "rwcd"}, false)},
ZddcFile{ACL: aclOpen(map[string]string{})},
)
if got := chain.VisibleStart(2, ModeStrict); got != 0 {
t.Errorf("strict mode must ignore fence: got %d, want 0", got)
}
}
// End-to-end: a fence at the vendor folder hides root-level grants for
// users who don't match the vendor-folder grants.
func TestEffectiveVerbs_FenceHidesAncestorGrants(t *testing.T) {
@ -84,33 +73,15 @@ func TestEffectiveVerbs_FenceHidesAncestorGrants(t *testing.T) {
// alice@example.com used to inherit root rwcd; with the fence she has
// no grant in the vendor folder → 0 verbs.
if got := EffectiveVerbs(chain, "alice@example.com", ModeDelegated); got != 0 {
if got := EffectiveVerbs(chain, "alice@example.com"); got != 0 {
t.Errorf("alice should be denied by fence; got %s", got)
}
// rep@vendor.com matches the local rule.
if got := EffectiveVerbs(chain, "rep@vendor.com", ModeDelegated); got != VerbsRWCD {
if got := EffectiveVerbs(chain, "rep@vendor.com"); got != VerbsRWCD {
t.Errorf("vendor should have rwcd; got %s", got)
}
}
// In strict mode the fence is ignored: alice keeps her root grant
// because ancestor grants ARE absolute under AC-6 / strict cascade.
func TestEffectiveVerbs_StrictModeKeepsAncestorGrants(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcd"})},
ZddcFile{ACL: aclFenced(map[string]string{
"*@vendor.com": "rwcd",
}, false)},
)
// In strict mode, alice's root rwcd is visible — fence ignored.
// She doesn't match anything in the vendor folder, so leaf walk
// continues to root, finds rwcd, and returns it.
if got := EffectiveVerbs(chain, "alice@example.com", ModeStrict); got != VerbsRWCD {
t.Errorf("strict mode: alice should retain root rwcd; got %s", got)
}
}
// Roles defined above the fence are invisible to descendants — operators
// who fence must redefine roles locally if they want to use them.
func TestRoleMembers_FenceHidesAncestorRoles(t *testing.T) {
@ -124,13 +95,9 @@ func TestRoleMembers_FenceHidesAncestorRoles(t *testing.T) {
chain := buildChain(rootLevel, fencedLevel)
// Below the fence, the role from root is invisible.
if got := RoleMembers(chain, 1, "_doc_controller", ModeDelegated); got != nil {
if got := RoleMembers(chain, 1, "_doc_controller"); got != nil {
t.Errorf("role above fence should be invisible; got %v", got)
}
// In strict mode, the fence is ignored and the role is visible.
if got := RoleMembers(chain, 1, "_doc_controller", ModeStrict); len(got) != 1 || got[0] != "dc@example.com" {
t.Errorf("strict mode: role should be visible; got %v", got)
}
}
// A role redefined locally below the fence shadows correctly because
@ -145,7 +112,7 @@ func TestRoleMembers_LocalRedefinitionWorks(t *testing.T) {
Roles: map[string]Role{"_doc_controller": {Members: []string{"vendor-dc@example.com"}}},
},
)
got := RoleMembers(chain, 1, "_doc_controller", ModeDelegated)
got := RoleMembers(chain, 1, "_doc_controller")
if len(got) != 1 || got[0] != "vendor-dc@example.com" {
t.Errorf("local redefinition should win; got %v", got)
}

View file

@ -318,7 +318,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
if len(zf.Admins) > 0 {
return false
}
if len(zf.ACL.Permissions) > 0 || len(zf.ACL.Allow) > 0 || len(zf.ACL.Deny) > 0 {
if len(zf.ACL.Permissions) > 0 {
return false
}
if zf.ACL.Inherit != nil {

View file

@ -20,8 +20,8 @@ const (
VerbAll = VerbR | VerbW | VerbC | VerbD | VerbA
// VerbsRWCD is the verb set the legacy acl.allow translation grants —
// every right except admin (which always required the admins: list).
// VerbsRWCD is the every-non-admin verb set — granted to a principal
// that holds read+write+create+delete but not admin authority.
VerbsRWCD = VerbR | VerbW | VerbC | VerbD
// VerbsRC is the WORM-mask survivor: read + create only. Drop boxes
@ -104,21 +104,19 @@ func IsPrincipalRole(principal string) bool {
// definition in the visible chain, with a role.Reset=true level
// stopping the walk (its members plus anything deeper; ancestors
// above the reset excluded). The visible-chain lower bound is
// chain.VisibleStart(levelIdx, mode): in delegated mode, an
// inherit:false fence at-or-below levelIdx hides definitions above
// it; in strict mode the full chain is visible. Returns nil if no
// level in the visible chain defines the role.
// chain.VisibleStart(levelIdx) — an inherit:false fence at-or-below
// levelIdx hides definitions above it. Returns nil if no level in the
// visible chain defines the role.
//
// Levels are stored root (index 0) → leaf (last index), matching the
// EffectivePolicy convention.
func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) []string {
members, _ := lookupRoleMembers(chain, levelIdx, roleName, mode)
func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string {
members, _ := lookupRoleMembers(chain, levelIdx, roleName)
return members
}
// MatchesPrincipal reports whether email satisfies the given Permissions
// key at chain.Levels[levelIdx]. mode controls whether inherit:false
// fences truncate the visible chain when resolving role definitions.
// key at chain.Levels[levelIdx].
//
// Resolution order:
//
@ -128,16 +126,15 @@ func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeM
// the cascade's roles, honoring fences. If a role definition is
// found in the visible chain, match the user against the role's
// members. If no role definition exists in the visible chain, fall
// back to MatchesPattern. The fallback preserves legacy patterns
// like "*" or "*example.com" that pre-date the roles feature.
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int, mode CascadeMode) bool {
// back to MatchesPattern so bare wildcards like "*" still match.
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int) bool {
if !IsPrincipalRole(principal) {
return MatchesPattern(principal, email)
}
members, defined := lookupRoleMembers(chain, levelIdx, principal, mode)
members, defined := lookupRoleMembers(chain, levelIdx, principal)
if !defined {
// Legacy pattern compatibility — bare wildcards / unqualified
// strings continue to match via the email-pattern matcher.
// Bare wildcards / unqualified strings still match via the
// email-pattern matcher when no role of that name exists.
return MatchesPattern(principal, email)
}
for _, m := range members {
@ -152,17 +149,17 @@ func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int,
// defined anywhere in the visible chain. Distinguishes "role exists
// but is empty" (defined=true, empty members) from "role not defined"
// (defined=false), which the principal-fallback logic depends on. The
// visible-chain bound is determined by chain.VisibleStart(levelIdx, mode).
// visible-chain bound is determined by chain.VisibleStart(levelIdx).
//
// Members UNION across every level that defines the role. Walking
// deep→shallow, a level with role.Reset=true stops the walk: its
// members (plus anything deeper that already accumulated) are the
// final set; ancestor definitions above the reset are excluded.
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) ([]string, bool) {
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]string, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil, false
}
floor := chain.VisibleStart(levelIdx, mode)
floor := chain.VisibleStart(levelIdx)
var members []string
seen := make(map[string]struct{})
defined := false
@ -200,9 +197,8 @@ func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string, mode Ca
// MatchingPrincipals returns the keys of level.ACL.Permissions whose
// principal matches email at chain.Levels[levelIdx]. Output is sorted
// for stable iteration in tests and audit logs. mode is forwarded to
// MatchesPrincipal for fence-aware role resolution.
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string, mode CascadeMode) []string {
// for stable iteration in tests and audit logs.
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil
}
@ -212,7 +208,7 @@ func MatchingPrincipals(chain PolicyChain, levelIdx int, email string, mode Casc
}
var out []string
for principal := range level.ACL.Permissions {
if MatchesPrincipal(principal, email, chain, levelIdx, mode) {
if MatchesPrincipal(principal, email, chain, levelIdx) {
out = append(out, principal)
}
}

View file

@ -78,7 +78,7 @@ func TestRoleMembersUnionAcrossCascade(t *testing.T) {
},
HasAnyFile: true,
}
got := RoleMembers(chain, 1, "editors", ModeDelegated)
got := RoleMembers(chain, 1, "editors")
if len(got) != 2 {
t.Fatalf("union: got %v, want both alice + bob", got)
}
@ -94,7 +94,7 @@ func TestRoleMembersUnionAcrossCascade(t *testing.T) {
t.Errorf("union: got %v, want alice + bob", got)
}
// At root level, only the root definition is in the visible chain.
got = RoleMembers(chain, 0, "editors", ModeDelegated)
got = RoleMembers(chain, 0, "editors")
if len(got) != 1 || got[0] != "alice@example.com" {
t.Errorf("root visibility: got %v, want [alice]", got)
}
@ -118,7 +118,7 @@ func TestRoleMembersResetBreaksUnion(t *testing.T) {
},
HasAnyFile: true,
}
got := RoleMembers(chain, 2, "editors", ModeDelegated)
got := RoleMembers(chain, 2, "editors")
// Expect carol (reset level) + dave (leaf), NOT alice (excluded by reset).
if len(got) != 2 {
t.Fatalf("reset: got %v, want carol + dave only", got)
@ -137,10 +137,10 @@ func TestMatchesPrincipalLegacyPatternFallback(t *testing.T) {
Levels: []ZddcFile{{}},
HasAnyFile: true,
}
if !MatchesPrincipal("*", "alice@example.com", chain, 0, ModeDelegated) {
if !MatchesPrincipal("*", "alice@example.com", chain, 0) {
t.Errorf("bare * should match any email via legacy fallback")
}
if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0, ModeDelegated) {
if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0) {
t.Errorf("*example.com should match alice@example.com via legacy fallback")
}
}
@ -156,10 +156,10 @@ func TestMatchesPrincipalRoleNamePrefersRole(t *testing.T) {
}},
HasAnyFile: true,
}
if !MatchesPrincipal("vendor_acme", "rep@acme.com", chain, 0, ModeDelegated) {
if !MatchesPrincipal("vendor_acme", "rep@acme.com", chain, 0) {
t.Errorf("rep@acme.com should match role vendor_acme")
}
if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0, ModeDelegated) {
if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0) {
t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed")
}
}

View file

@ -5,12 +5,9 @@ import (
"strings"
)
// Phase 3 retired the hardcoded canonical-folder predicates and their
// supporting lists (ProjectRootFolders, PartyFolders, AutoOwnCanonicalNames,
// VirtualOnlyCanonicalNames, IsArchivePartyFolder, IsArchivePartyMdlDir,
// IsProjectRootFolder). The .zddc cascade is the authority now: see
// defaults.zddc.yaml for the canonical convention and lookups.go for
// the helpers consumers call (DefaultToolAt, AutoOwnAt, VirtualAt,
// The .zddc cascade is the authority for canonical-folder behaviour;
// see defaults.zddc.yaml for the conventions and lookups.go for the
// helpers consumers call (DefaultToolAt, AutoOwnAt, VirtualAt,
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
@ -87,11 +84,3 @@ func ResolveCanonical(parentDir, logical string) (string, error) {
return "", nil
}
// Retired in the cascade-config migration — driven by .zddc keys now:
// - IsAutoOwnPath → the `auto_own:` flag, resolved by AutoOwnAt
// - IsWormPath / WormFolderLevelIndex / WormMask
// → the `worm:` list, resolved by WormZoneGrant
// defaults.zddc.yaml carries the canonical conventions (auto_own on
// working/staging/archive-party/incoming, worm: on received/issued),
// so behaviour is unchanged; the difference is an operator can
// reshape or rename any of it without a code change.

View file

@ -34,10 +34,10 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
}
// Mirror InternalDecider.Allow's WORM-aware composition.
var got VerbSet
if g, inWorm := WormZoneGrant(chain, dc, ModeDelegated); inWorm {
got = (EffectiveVerbs(chain, dc, ModeDelegated) & VerbR) | (g & VerbsRC)
if g, inWorm := WormZoneGrant(chain, dc); inWorm {
got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC)
} else {
got = EffectiveVerbs(chain, dc, ModeDelegated)
got = EffectiveVerbs(chain, dc)
}
if got.String() != want {
t.Errorf("doc controller verbs at %s = %q, want %q", dir[len(root):], got.String(), want)
@ -105,24 +105,24 @@ created_by: alice@example.com
// Alice (team member) inside her own home → rwcda.
chain, _ := EffectivePolicy(root, homeDir)
if got := EffectiveVerbs(chain, alice, ModeDelegated); got.String() != "rwcda" {
if got := EffectiveVerbs(chain, alice); got.String() != "rwcda" {
t.Errorf("alice in own home = %q, want rwcda", got.String())
}
// Bob (team member) inside Alice's fenced home → nothing (fence
// blocks the project-level project_team:r; bob isn't named in the
// fenced .zddc).
if got := EffectiveVerbs(chain, bob, ModeDelegated); got != 0 {
if got := EffectiveVerbs(chain, bob); got != 0 {
t.Errorf("bob in alice's fenced home = %q, want empty (fence blocks inherited grants)", got.String())
}
// Alice elsewhere in the project (not her home, not WORM) → r.
chain2, _ := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme"))
if got := EffectiveVerbs(chain2, alice, ModeDelegated); got.String() != "r" {
if got := EffectiveVerbs(chain2, alice); got.String() != "r" {
t.Errorf("alice in archive/Acme = %q, want r", got.String())
}
// Alice CANNOT write to incoming/ — that's the counterparty's drop
// zone, QC'd by the document controller. project_team gets read only.
chain3, _ := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme", "incoming"))
if got := EffectiveVerbs(chain3, alice, ModeDelegated); got.String() != "r" {
if got := EffectiveVerbs(chain3, alice); got.String() != "r" {
t.Errorf("alice in incoming/ = %q, want r (no create/write for project_team)", got.String())
}
}

View file

@ -227,8 +227,14 @@ func ValidateFile(zf ZddcFile) []FieldError {
}
}
}
check("acl.allow", zf.ACL.Allow)
check("acl.deny", zf.ACL.Deny)
for principal := range zf.ACL.Permissions {
if err := ValidatePattern(principal); err != nil {
errs = append(errs, FieldError{
Field: fmt.Sprintf("acl.permissions[%q]", principal),
Message: err.Error(),
})
}
}
check("admins", zf.Admins)
if len(zf.Title) > 200 {
errs = append(errs, FieldError{

View file

@ -38,8 +38,12 @@ func TestValidatePattern(t *testing.T) {
func TestValidateFile(t *testing.T) {
zf := ZddcFile{
Title: "ok",
ACL: ACLRules{Allow: []string{"good@example.com", "@bad"}, Deny: []string{"two@@ats"}},
Title: "ok",
ACL: ACLRules{Permissions: map[string]string{
"good@example.com": "rwcd",
"@bad": "rwcd",
"two@@ats": "",
}},
Admins: []string{"@nobody"},
}
errs := ValidateFile(zf)
@ -48,9 +52,9 @@ func TestValidateFile(t *testing.T) {
t.Fatalf("got %d errors, want 3: %+v", len(errs), errs)
}
wantFields := map[string]bool{
"acl.allow[1]": false,
"acl.deny[0]": false,
"admins[0]": false,
"acl.permissions[\"@bad\"]": false,
"acl.permissions[\"two@@ats\"]": false,
"admins[0]": false,
}
for _, e := range errs {
if _, ok := wantFields[e.Field]; !ok {

View file

@ -106,8 +106,6 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
out.Admins = mergeStringSlice(out.Admins, top.Admins)
out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow)
out.ACL.Deny = mergeStringSlice(out.ACL.Deny, top.ACL.Deny)
if top.ACL.Inherit != nil {
out.ACL.Inherit = top.ACL.Inherit
}

View file

@ -29,7 +29,7 @@ package zddc
// i.e. inside a WORM zone, w/d/a are always stripped; c survives only
// via the worm: grant; r survives via the normal ACL or the worm:
// grant. Admins are excluded upstream (handler's IsAdmin bypass).
func WormZoneGrant(chain PolicyChain, email string, mode CascadeMode) (grant VerbSet, inWorm bool) {
func WormZoneGrant(chain PolicyChain, email string) (grant VerbSet, inWorm bool) {
for i := 0; i < len(chain.Levels); i++ {
wl := chain.Levels[i].Worm
if wl == nil {
@ -37,7 +37,7 @@ func WormZoneGrant(chain PolicyChain, email string, mode CascadeMode) (grant Ver
}
inWorm = true
for _, principal := range wl {
if MatchesPrincipal(principal, email, chain, i, mode) {
if MatchesPrincipal(principal, email, chain, i) {
grant |= VerbsRC // listed controllers get read + write-once-create
}
}

View file

@ -31,7 +31,7 @@ func TestWormZoneGrant_EmbeddedConvention(t *testing.T) {
if err != nil {
t.Fatalf("EffectivePolicy(%q): %v", tc.path, err)
}
grant, inWorm := WormZoneGrant(chain, "anyone@example.com", ModeDelegated)
grant, inWorm := WormZoneGrant(chain, "anyone@example.com")
if inWorm != tc.wantInWorm {
t.Errorf("WormZoneGrant(%q): inWorm = %v, want %v", tc.path[len(root):], inWorm, tc.wantInWorm)
}
@ -58,14 +58,14 @@ func TestWormZoneGrant_OperatorGrantsController(t *testing.T) {
if err != nil {
t.Fatal(err)
}
g, inWorm := WormZoneGrant(chain, "doc-control@example.com", ModeDelegated)
g, inWorm := WormZoneGrant(chain, "doc-control@example.com")
if !inWorm {
t.Fatalf("inWorm = false, want true")
}
if g != VerbsRC {
t.Errorf("controller grant = %v, want rc", g)
}
g2, _ := WormZoneGrant(chain, "rando@example.com", ModeDelegated)
g2, _ := WormZoneGrant(chain, "rando@example.com")
if g2 != 0 {
t.Errorf("non-controller grant = %v, want 0", g2)
}
@ -83,7 +83,7 @@ func TestWormZoneGrant_GrantIsAlwaysRC(t *testing.T) {
}
writeZddc(t, rec, "worm:\n - x@example.com\n")
chain, _ := EffectivePolicy(root, rec)
g, _ := WormZoneGrant(chain, "x@example.com", ModeDelegated)
g, _ := WormZoneGrant(chain, "x@example.com")
if g != VerbsRC {
t.Errorf("grant = %v (%s), want rc", g, g.String())
}
@ -105,11 +105,11 @@ func TestWormZoneGrant_GrantsUnionAcrossCascade(t *testing.T) {
writeZddc(t, rec, "worm:\n - bob@example.com\n")
chain, _ := EffectivePolicy(root, rec)
ga, inA := WormZoneGrant(chain, "alice@example.com", ModeDelegated)
ga, inA := WormZoneGrant(chain, "alice@example.com")
if !inA || ga != VerbsRC {
t.Errorf("alice grant = %v inWorm=%v, want rc/true", ga, inA)
}
gb, _ := WormZoneGrant(chain, "bob@example.com", ModeDelegated)
gb, _ := WormZoneGrant(chain, "bob@example.com")
if gb != VerbsRC {
t.Errorf("bob grant = %v, want rc", gb)
}

View file

@ -12,8 +12,10 @@ func TestWriteFileRoundTrip(t *testing.T) {
in := ZddcFile{
Title: "Greenfield Substation",
ACL: ACLRules{
Allow: []string{"*@varasys.io"},
Deny: []string{"intern@varasys.io"},
Permissions: map[string]string{
"*@varasys.io": "rwcd",
"intern@varasys.io": "",
},
},
Admins: []string{"alice@varasys.io"},
}
@ -29,8 +31,11 @@ func TestWriteFileRoundTrip(t *testing.T) {
if out.Title != in.Title {
t.Errorf("Title = %q, want %q", out.Title, in.Title)
}
if len(out.ACL.Allow) != 1 || out.ACL.Allow[0] != in.ACL.Allow[0] {
t.Errorf("ACL.Allow = %v, want %v", out.ACL.Allow, in.ACL.Allow)
if out.ACL.Permissions["*@varasys.io"] != "rwcd" {
t.Errorf("ACL.Permissions[*@varasys.io] = %q, want %q", out.ACL.Permissions["*@varasys.io"], "rwcd")
}
if v, ok := out.ACL.Permissions["intern@varasys.io"]; !ok || v != "" {
t.Errorf("ACL.Permissions[intern@varasys.io] = (%q, ok=%v), want (\"\", true)", v, ok)
}
if len(out.Admins) != 1 || out.Admins[0] != "alice@varasys.io" {
t.Errorf("Admins = %v, want [alice@varasys.io]", out.Admins)
@ -67,7 +72,7 @@ func TestWriteFileInvalidatesCache(t *testing.T) {
}
if err := WriteFile(sub, ZddcFile{
ACL: ACLRules{Allow: []string{"alice@example.com"}},
ACL: ACLRules{Permissions: map[string]string{"alice@example.com": "rwcd"}},
}); err != nil {
t.Fatalf("WriteFile: %v", err)
}
@ -81,8 +86,8 @@ func TestWriteFileInvalidatesCache(t *testing.T) {
t.Fatal("HasAnyFile = false; cache not invalidated")
}
leaf := chain.Levels[len(chain.Levels)-1]
if len(leaf.ACL.Allow) != 1 || leaf.ACL.Allow[0] != "alice@example.com" {
t.Errorf("leaf allow = %v, want [alice@example.com]", leaf.ACL.Allow)
if got := leaf.ACL.Permissions["alice@example.com"]; got != "rwcd" {
t.Errorf("leaf permissions[alice] = %q, want %q", got, "rwcd")
}
}