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 | | 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/` | | 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` | | 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 | | 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 | | 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) | | 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 | | 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`) | | 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 | | 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 | | 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 | | 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 | | `d` | delete a file |
| `a` | modify the ACL of this subtree (write `.zddc`) | | `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`: 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`.
- `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.
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. 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) { function canSave(node) {
if (isZipMemberNode(node)) return false; 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 // Server-computed authority gate. Mirrors the markdown editor's
// check — listing's `writable` bit is the same decision the // check — listing's `writable` bit is the same decision the
// file API would reach on PUT. // 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). 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 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 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 Implementation: `GrantedVerbsAtLevel` (`zddc/internal/zddc/acl.go`) computes the
per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain
itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`); the itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`); the
fence is computed by `PolicyChain.VisibleStart`. fence is computed by `PolicyChain.VisibleStart`.
#### Cascade mode The leaf-overrides-ancestor behaviour above is the in-process decider's only
rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy
The leaf-overrides-ancestor behavior above is the default — it's the historical OPA with the bundled `access_federal.rego` (or their own Rego); see
commercial-tenant model where a subtree owner can grant access without "External OPA" below.
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 `inherit:` directive #### The `inherit:` directive
@ -484,14 +472,13 @@ Behaviour:
fence; `inherit: false` does not change WORM behaviour. See fence; `inherit: false` does not change WORM behaviour. See
"Canonical-folder behaviour via `.zddc` keys" below. "Canonical-folder behaviour via `.zddc` keys" below.
**Strict cascade mode IGNORES `inherit: false`.** NIST AC-6 requires **Federal posture and `inherit: false`.** The bundled federal Rego at
ancestor explicit-denies to be absolute, and the inherit directive `--print-rego=federal` makes ancestor explicit-denies absolute and
would let a leaf widen access an ancestor refused. Under therefore ignores `inherit: false` (allowing a leaf to widen access an
`--cascade-mode=strict` the directive has no effect (and the bundled ancestor refused would defeat NIST AC-6). Operators who need fence-
federal Rego at `--print-rego=federal` mirrors that rule). Operators style "reset" semantics in a federal-track deployment should not use
who need fence-style "reset" semantics in a federal-track deployment the directive — instead, restructure the tree so the permissive
should not use the directive — instead, restructure the tree so the ancestor rule never appears.
permissive ancestor rule never appears.
The cascade tracer (`/.profile/effective-policy`) surfaces every The cascade tracer (`/.profile/effective-policy`) surfaces every
level's `inherit` flag and the `chain.visible_start` index so a 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 (the upstream proxy still asserts the email; role membership is
evaluated server-side against the cascade). evaluated server-side against the cascade).
- ~~**Least-privilege bounding** (NIST AC-6)~~*closed.* Operators - ~~**Least-privilege bounding** (NIST AC-6)~~*closed.* Operators
set `--cascade-mode=strict` (or `ZDDC_CASCADE_MODE=strict`) to deploy OPA (`ZDDC_OPA_URL`) pointed at the bundled federal Rego
switch the in-process Go evaluator into the federal posture: any (`zddc-server --print-rego=federal`) or their own variant. Under
ancestor explicit-deny is absolute and cannot be overridden by a that policy any ancestor explicit-deny is absolute and cannot be
leaf grant. The mode is logged at startup and surfaced on overridden by a leaf grant. The in-process Go evaluator implements
`/.profile/config`. The legacy commercial behavior is preserved as only the commercial "leaf grants override ancestor denies" rule;
the default `delegated` mode. External OPA (`ZDDC_OPA_URL`) remains federal posture is exclusively the OPA path.
available for org-specific Rego on top of this.
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to - **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
authoritative sources (PIV cert subject, IdP-managed identity). Required: authoritative sources (PIV cert subject, IdP-managed identity). Required:
documented integration with at least one IdP supporting federal identity 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 // http(s):// or unix:// values send each decision to an external
// OPA-compatible server (federal customers, custom Rego policies). // OPA-compatible server (federal customers, custom Rego policies).
deciderCfg := policy.Config{ deciderCfg := policy.Config{
URL: cfg.OPAURL, URL: cfg.OPAURL,
FailOpen: cfg.OPAFailOpen, FailOpen: cfg.OPAFailOpen,
CacheTTL: cfg.OPACacheTTL, CacheTTL: cfg.OPACacheTTL,
CascadeMode: cfg.CascadeMode,
} }
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is // Translate "0" (operator opt-out) to "disable cache" (negative TTL is
// the policy package's sentinel for "skip the wrapper"). // the policy package's sentinel for "skip the wrapper").
@ -217,7 +216,6 @@ func main() {
"mode", policyModeLabel(cfg.OPAURL), "mode", policyModeLabel(cfg.OPAURL),
"url", cfg.OPAURL, "url", cfg.OPAURL,
"cache_ttl", cfg.OPACacheTTL, "cache_ttl", cfg.OPACacheTTL,
"cascade_mode", cfg.CascadeMode,
"no_auth", cfg.NoAuth) "no_auth", cfg.NoAuth)
// Token store: bearer-token issuance and validation. // 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 // Split path into segments
segments := strings.Split(strings.Trim(urlPath, "/"), "/") 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 // Raw .zddc YAML view: <dir>/.zddc is reachable at every depth
// and returns the on-disk file's bytes (Content-Type: application/yaml) // and returns the on-disk file's bytes (Content-Type: application/yaml)
// or — when no file exists — a synthetic placeholder body with a // or — when no file exists — a synthetic placeholder body with a
// cascade summary so the user can see what's effective here. // cascade summary so the user can see what's effective here. The
// GET/HEAD only; writes go through the file API (PUT). Carved // leaf is carved out of the dot-prefix guard below so GET/HEAD
// out of the dot-prefix guard so the leaf segment isn't 404'd. // land here and PUT/DELETE/POST fall through to ServeFileAPI.
if handler.IsZddcFileRequest(urlPath) { if handler.IsZddcFileRequest(urlPath) && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
handler.ServeZddcFile(cfg, w, r) handler.ServeZddcFile(cfg, w, r)
return return
} }
@ -811,7 +796,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// the ?hidden flag does NOT relax). // the ?hidden flag does NOT relax).
hiddenOK := r.URL.Query().Has("hidden") && hiddenOK := r.URL.Query().Has("hidden") &&
(r.Method == http.MethodGet || r.Method == http.MethodHead) (r.Method == http.MethodGet || r.Method == http.MethodHead)
for _, seg := range segments { for i, seg := range segments {
if seg == "" { if seg == "" {
continue continue
} }
@ -825,6 +810,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
if seg == cfg.IndexPath { if seg == cfg.IndexPath {
continue 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 { if hiddenOK {
continue continue
} }
@ -1161,11 +1153,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return return
} }
} }
// (Subtree download `GET /dir/?zip=1` retired in favour of // (Subtree downloads use the virtual `GET /dir.zip` URL —
// `GET /dir.zip` — see RecognizeVirtualSubtreeZip handling at // see RecognizeVirtualSubtreeZip handling at the top of the
// the top of the stat-fails branch above. Real directories // stat-fails branch above. Real directories stat-succeed
// stat-succeed here, so the virtual zip URL stat-fails at // here, so the virtual zip URL stat-fails at /dir.zip and
// /dir.zip and matches there.) // matches there.)
// Slash/no-slash routing convention: trailing slash → the // Slash/no-slash routing convention: trailing slash → the
// directory view (handler.ServeDirectory → DirTool, which // directory view (handler.ServeDirectory → DirTool, which

View file

@ -2,7 +2,6 @@ package main
import ( import (
"archive/zip" "archive/zip"
"bytes"
"context" "context"
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
@ -133,7 +132,7 @@ func TestDispatchAppsResolution(t *testing.T) {
// fake upstream. Allow all email patterns (anonymous) so the test // fake upstream. Allow all email patterns (anonymous) so the test
// doesn't have to set up email headers. // doesn't have to set up email headers.
zf := zddc.ZddcFile{ zf := zddc.ZddcFile{
ACL: zddc.ACLRules{Allow: []string{"*"}}, ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}},
Apps: map[string]string{ Apps: map[string]string{
"archive": upstream.URL + "/archive_stable.html", "archive": upstream.URL + "/archive_stable.html",
"transmittal": upstream.URL + "/transmittal_stable.html", "transmittal": upstream.URL + "/transmittal_stable.html",
@ -224,7 +223,7 @@ var _ = apps.DefaultUpstream
func TestDispatchRoutesWritesToFileAPI(t *testing.T) { func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"), 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")) mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
idx, err := archive.BuildIndex(root) idx, err := archive.BuildIndex(root)
@ -296,7 +295,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
func TestDispatchArchiveRedirect(t *testing.T) { func TestDispatchArchiveRedirect(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"), mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*\"\n") "acl:\n permissions:\n \"*\": rwcd\n")
mustMkdir(t, filepath.Join(root, "ProjectA", "Working")) mustMkdir(t, filepath.Join(root, "ProjectA", "Working"))
idx, err := archive.BuildIndex(root) idx, err := archive.BuildIndex(root)
@ -596,7 +595,7 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
func TestDispatchArchiveMethodGate(t *testing.T) { func TestDispatchArchiveMethodGate(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"), mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n allow:\n - \"*\"\n") "acl:\n permissions:\n \"*\": rwcd\n")
mustMkdir(t, filepath.Join(root, "ProjectA")) mustMkdir(t, filepath.Join(root, "ProjectA"))
idx, err := archive.BuildIndex(root) idx, err := archive.BuildIndex(root)
@ -638,7 +637,7 @@ func TestDispatchArchiveMethodGate(t *testing.T) {
func TestDispatchCaseInsensitiveURL(t *testing.T) { func TestDispatchCaseInsensitiveURL(t *testing.T) {
root := t.TempDir() root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"), 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")) mustMkdir(t, filepath.Join(root, "project-a", "working"))
mustWrite(t, filepath.Join(root, "project-a", "working", "note.md"), "lowercase note") 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 // TestGzhttpWrapper_CompressesLargeResponses asserts the gzhttp wrapper
// behavior we wire in main(): responses above MinSize get gzip-encoded // behavior we wire in main(): responses above MinSize get gzip-encoded
// when the client advertises Accept-Encoding: gzip; small responses // 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. 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. 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. 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. 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). // 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.") "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), 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.") "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), 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.") "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"), 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, OPACacheTTL: *opaCacheTTLFlag,
AppsPubKey: *appsPubKeyFlag, AppsPubKey: *appsPubKeyFlag,
MaxWriteBytes: *maxWriteBytesFlag, MaxWriteBytes: *maxWriteBytesFlag,
CascadeMode: *cascadeModeFlag,
ArchiveRescanInterval: *archiveRescanIntervalFlag, ArchiveRescanInterval: *archiveRescanIntervalFlag,
ConvertPandocImage: *convertPandocImageFlag, ConvertPandocImage: *convertPandocImageFlag,
ConvertChromiumImage: *convertChromiumImageFlag, 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") 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 // Plain HTTP mode trusts the email header from any client. Only safe
// behind an authenticating reverse proxy. Refuse to start when binding // behind an authenticating reverse proxy. Refuse to start when binding
// plain HTTP to a non-loopback interface unless the operator has // 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) parentChain, _ := zddc.EffectivePolicy(fsRoot, absDir)
principal := zddc.Principal{Email: userEmail, Elevated: elevated} principal := zddc.Principal{Email: userEmail, Elevated: elevated}
parentActiveAdmin := elevated && userEmail != "" && parentActiveAdmin := elevated && userEmail != "" &&
zddc.IsAdminForChain(parentChain, userEmail, false) zddc.IsAdminForChain(parentChain, userEmail)
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() 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); // reach. Uses the parent-dir chain (computed once above);
// active-admin status short-circuits the per-file decider // active-admin status short-circuits the per-file decider
// query when the principal already holds admin authority. // 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 fileURL := baseURL + name
if parentActiveAdmin { if parentActiveAdmin {
fi.Writable = true fi.Writable = true
} else { } else {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, policy.ActionWrite) allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, action)
if allowed { if allowed {
fi.Writable = true 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 // asked for hidden entries (?hidden=1), matching the dot-prefix
// hide rule used for every other dotfile. // hide rule used for every other dotfile.
if includeHidden { 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) 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 // path (down through embedded defaults), so editing this virtual
// entry is always meaningful — a save promotes it to a real on-disk // entry is always meaningful — a save promotes it to a real on-disk
// .zddc that overrides ancestor levels for this directory. // .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") zddcPath := filepath.Join(absDir, ".zddc")
if _, err := os.Stat(zddcPath); err == nil { if _, err := os.Stat(zddcPath); err == nil {
return listing.FileInfo{}, false return listing.FileInfo{}, false
} else if !os.IsNotExist(err) { } else if !os.IsNotExist(err) {
return listing.FileInfo{}, false return listing.FileInfo{}, false
} }
writable := parentActiveAdmin
if !writable {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc", policy.ActionAdmin)
writable = allowed
}
return listing.FileInfo{ return listing.FileInfo{
Name: ".zddc", Name: ".zddc",
URL: baseURL + ".zddc", URL: baseURL + ".zddc",
IsDir: false, IsDir: false,
Virtual: true, Virtual: true,
Writable: writable,
}, true }, 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 // Optional Plan Review chain. Invokes executePlanReview directly
// against the freshly-created received/<tracking>/ path. The ACL // 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 // workflow roots and `c` on received/<tracking>/, both of which
// they had a moment ago for the move itself. A chained failure does // 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 // 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) { func TestServeArchive_EmptyProject404(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["*"] permissions: {"*": rwcd}
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
@ -170,7 +170,7 @@ func TestServeArchive_EmptyProject404(t *testing.T) {
func TestServeArchive_UnknownProject404(t *testing.T) { func TestServeArchive_UnknownProject404(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["*"] permissions: {"*": rwcd}
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
@ -191,7 +191,7 @@ func TestServeArchive_UnknownProject404(t *testing.T) {
func TestServeArchive_ListingScopedToProject(t *testing.T) { func TestServeArchive_ListingScopedToProject(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["*"] permissions: {"*": rwcd}
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
const email = "alice@example.com" 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 // allow list anywhere → every per-target check returns deny → the
// filtered listing is empty → 403. // filtered listing is empty → 403.
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["alice@example.com"] permissions: {"alice@example.com": rwcd}
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
@ -277,13 +277,13 @@ func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) {
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) { func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["*"] permissions: {"*": rwcd}
`) `)
// Deny alice on the transmittal folder where 100_~A+C1 lives, so her // Deny alice on the transmittal folder where 100_~A+C1 lives, so her
// listing of /ProjectA/.archive/ drops that entry — but other ProjectA // listing of /ProjectA/.archive/ drops that entry — but other ProjectA
// entries stay visible. // entries stay visible.
writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl: writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl:
deny: ["alice@example.com"] permissions: {"alice@example.com": ""}
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
@ -313,10 +313,10 @@ func TestServeArchive_ResolvePerTargetACLOnly(t *testing.T) {
// transmittal folder kicks mallory out at the per-target chain // transmittal folder kicks mallory out at the per-target chain
// ("first explicit match wins, bottom-up"). // ("first explicit match wins, bottom-up").
writeZddc(t, root, ".", `acl: 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: writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
deny: ["mallory@example.com"] permissions: {"mallory@example.com": ""}
`) `)
cfg := archiveCfg(root) 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 // — so the per-target chain at the file's directory hits the local
// allow first. // allow first.
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["alice@example.com"] permissions: {"alice@example.com": rwcd}
`) `)
writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl: writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
allow: ["bob@example.com"] permissions: {"bob@example.com": rwcd}
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
@ -382,7 +382,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
} }
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["*"] permissions: {"*": rwcd}
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
const email = "alice@example.com" const email = "alice@example.com"
@ -442,7 +442,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) { func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["*@example.com"] permissions: {"*@example.com": rwcd}
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
@ -464,7 +464,7 @@ func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
func TestServeArchive_ListingContentNegotiation(t *testing.T) { func TestServeArchive_ListingContentNegotiation(t *testing.T) {
root, idx := archiveTestRoot(t) root, idx := archiveTestRoot(t)
writeZddc(t, root, ".", `acl: writeZddc(t, root, ".", `acl:
allow: ["*"] permissions: {"*": rwcd}
`) `)
cfg := archiveCfg(root) cfg := archiveCfg(root)
const email = "alice@example.com" const email = "alice@example.com"

View file

@ -109,15 +109,27 @@ func TestInvariant_UnelevatedAdminCannotBypassWorm(t *testing.T) {
} }
func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) { func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) {
// The .zddc edit authority lives in zddc.CanEditZddc, gated on // .zddc edits route through the decider as ActionAdmin. The bypass
// Principal.gate() — un-elevated must return false even for a root // for elevated admins fires only when Principal.Elevated is true.
// super-admin. Exercised at the helper boundary; the HTTP path // Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated
// guards .zddc at resolveTargetPath separately. // super-admin must return Forbidden.
cfg, _ := invariantsFixture(t) cfg, _ := invariantsFixture(t)
p := zddc.Principal{Email: "admin@example.com", Elevated: false} target := "/Project-1/working/.zddc"
dir := filepath.Join(cfg.Root, "Project-1/working") rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "")
if zddc.CanEditZddc(cfg.Root, dir, p) { if rec.Code != http.StatusForbidden {
t.Fatalf("un-elevated admin can edit .zddc — gate() bypassed") 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 { if err != nil {
t.Fatalf("EffectivePolicy: %v", err) 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") 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 { if err != nil {
t.Fatalf("EffectivePolicy: %v", err) 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") t.Fatalf("subtree admin blocked from editing deeper .zddc")
} }
} }

View file

@ -32,11 +32,11 @@ const AuthPathPrefix = "/.auth"
// noticeable overhead. // noticeable overhead.
// //
// Scope: gates ON ROOT-ADMIN STATUS ONLY. This is intentionally // 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 // endpoints (the dev-shell IDE, future maintenance routes) shouldn't
// fall through to subtree-level allowances. For per-route ACL, callers // fall through to subtree-level allowances. For per-route ACL, callers
// continue using the existing handlers (archive, profile, etc.) which // 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) { func ServeAuthAdmin(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r) email := EmailFromContext(r)
// Elevation-independent gate. Upstream proxies (Caddy forward_auth // 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 // (the dispatcher) only invokes this when a stat on the requested
// path itself fails — a real on-disk file always wins. // path itself fails — a real on-disk file always wins.
// //
// The path-suffix grammar replaces the legacy `<file>.md?convert=docx` // A virtual file URL means `<a href="…/foo.docx">` works without any
// query form. A virtual file URL means `<a href="…/foo.docx">` works // query-string handling, and a script's `curl -O …/foo.pdf` writes the
// without any query-string handling, and a script's `curl -O …/foo.pdf` // expected filename.
// writes the expected filename.
func RecognizeVirtualConvert(fsRoot, urlPath string) (mdAbs, format string, ok bool) { func RecognizeVirtualConvert(fsRoot, urlPath string) (mdAbs, format string, ok bool) {
lower := strings.ToLower(urlPath) lower := strings.ToLower(urlPath)
for _, ext := range []string{".docx", ".html", ".pdf"} { 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 // nothing else. A user without that email would have been 403'd before
// the bypass. // the bypass.
if err := os.WriteFile(filepath.Join(root, ".zddc"), 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 { 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err) 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"), 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) t.Fatalf("write PublicProj .zddc: %v", err)
} }
if err := os.WriteFile(filepath.Join(root, "PrivateProj", ".zddc"), 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) 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, // Reject hidden / reserved segments. Mirrors dispatch's guard,
// applied here too because external callers reach ServeFileAPI // applied here too because external callers reach ServeFileAPI
// only via dispatch — but defense in depth costs nothing. // 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 == "" { if seg == "" {
continue continue
} }
if seg == ZddcFileBasename && i == len(segs)-1 {
continue
}
if seg == "_app" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") { if seg == "_app" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
return "", "", false, http.StatusNotFound, "reserved path segment" 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. // Root .zddc grants writer access to *@example.com.
if err := os.WriteFile(filepath.Join(root, ".zddc"), 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) 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 // Tighten ACL to a different domain — alice@example.com no longer
// matches and writes must be 403. // matches and writes must be 403.
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), 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) t.Fatalf("rewrite .zddc: %v", err)
} }
zddc.InvalidateCache(cfg.Root) zddc.InvalidateCache(cfg.Root)
@ -152,7 +152,10 @@ func TestFileAPI_PutDenyForbidden(t *testing.T) {
func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) { func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, nil) _, 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) rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 for %s, got %d", p, rec.Code) t.Fatalf("want 404 for %s, got %d", p, rec.Code)
@ -437,7 +440,6 @@ acl:
Root: root, Root: root,
EmailHeader: "X-Auth-Request-Email", EmailHeader: "X-Auth-Request-Email",
MaxWriteBytes: 1024 * 1024, MaxWriteBytes: 1024 * 1024,
CascadeMode: "delegated",
} }
decider := &policy.InternalDecider{} 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 ------------------------------------------------- // --- staging↔working mirror -------------------------------------------------
// stagingMirrorURL builds a URL-safe target path for a transmittal folder // 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) { func TestRenderEmptyForm(t *testing.T) {
_, do := formTestSetup(t, map[string]string{ _, do := formTestSetup(t, map[string]string{
"": `acl: "": `acl:
allow: ["*@example.com"] permissions: {"*@example.com": rwcd}
`, `,
}) })
rec := do(http.MethodGet, "/Working/safety/form.html", "casey@example.com", "") 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) { func TestRenderEmptyForm_ACLDeny(t *testing.T) {
_, do := formTestSetup(t, map[string]string{ _, do := formTestSetup(t, map[string]string{
"": `acl: "": `acl:
allow: ["root@example.com"] permissions: {"root@example.com": rwcd}
`, `,
}) })
rec := do(http.MethodGet, "/Working/safety/form.html", "stranger@example.com", "") 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) { func TestCreateSubmission_Valid(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{ cfg, do := formTestSetup(t, map[string]string{
"": `acl: "": `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) { func TestCreateSubmission_Invalid_Returns422(t *testing.T) {
_, do := formTestSetup(t, map[string]string{ _, do := formTestSetup(t, map[string]string{
"": `acl: "": `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) { func TestCreateSubmission_ACLDeny(t *testing.T) {
_, do := formTestSetup(t, map[string]string{ _, do := formTestSetup(t, map[string]string{
"": `acl: "": `acl:
allow: ["root@example.com"] permissions: {"root@example.com": rwcd}
`, `,
}) })
body := `{"date":"2026-05-01","location":"Site A"}` 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) { func TestCreateSubmission_NoAuth_Returns401(t *testing.T) {
_, do := formTestSetup(t, map[string]string{ _, do := formTestSetup(t, map[string]string{
"": `acl: "": `acl:
allow: ["*"] permissions: {"*": rwcd}
`, `,
}) })
body := `{"date":"2026-05-01","location":"Site A"}` 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) { func TestCreateSubmission_FilenameCollision(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{ cfg, do := formTestSetup(t, map[string]string{
"": `acl: "": `acl:
allow: ["*@example.com"] permissions: {"*@example.com": rwcd}
`, `,
}) })
body := `{"date":"2026-05-01","location":"Site A"}` body := `{"date":"2026-05-01","location":"Site A"}`
@ -403,7 +403,7 @@ func TestCreateSubmission_FilenameCollision(t *testing.T) {
func TestRenderEdit_LoadsSubmission(t *testing.T) { func TestRenderEdit_LoadsSubmission(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{ cfg, do := formTestSetup(t, map[string]string{
"": `acl: "": `acl:
allow: ["*@example.com"] permissions: {"*@example.com": rwcd}
`, `,
}) })
@ -431,7 +431,7 @@ func TestRenderEdit_LoadsSubmission(t *testing.T) {
func TestUpdateSubmission_OverwritesFile(t *testing.T) { func TestUpdateSubmission_OverwritesFile(t *testing.T) {
cfg, do := formTestSetup(t, map[string]string{ cfg, do := formTestSetup(t, map[string]string{
"": `acl: "": `acl:
allow: ["*@example.com"] permissions: {"*@example.com": rwcd}
`, `,
}) })
@ -465,7 +465,7 @@ func TestUpdateSubmission_OverwritesFile(t *testing.T) {
func TestUpdateSubmission_NotFound(t *testing.T) { func TestUpdateSubmission_NotFound(t *testing.T) {
_, do := formTestSetup(t, map[string]string{ _, do := formTestSetup(t, map[string]string{
"": `acl: "": `acl:
allow: ["*@example.com"] permissions: {"*@example.com": rwcd}
`, `,
}) })
body := `{"date":"2026-05-01","location":"Site A"}` 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 { if err != nil {
return -1 return -1
} }
return zddc.AdminLevelInChain(chain, email, false) return zddc.AdminLevelInChain(chain, email)
} }
abs := filepath.Join(cfg.Root, filepath.FromSlash(rel)) abs := filepath.Join(cfg.Root, filepath.FromSlash(rel))
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root { 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 { if err != nil {
return -1 return -1
} }
return zddc.AdminLevelInChain(chain, email, false) return zddc.AdminLevelInChain(chain, email)
} }
// PrincipalFromContext bundles the request's authenticated email plus // PrincipalFromContext bundles the request's authenticated email plus
// its elevation flag into a zddc.Principal — the value type the admin // its elevation flag into a zddc.Principal — the value type the admin
// functions (IsAdmin, IsSubtreeAdmin, CanEditZddc) consume. One call // functions (IsAdmin, IsSubtreeAdmin) consume. One call per admin-check
// per admin-check site replaces the previous ad-hoc email argument // site replaces the previous ad-hoc email argument AND the previous
// AND the previous "did I remember to gate this?" review burden: the // "did I remember to gate this?" review burden: the type system
// type system enforces the gate by requiring a Principal value, which // enforces the gate by requiring a Principal value, which can only
// can only come from ACLMiddleware-tagged contexts. // come from ACLMiddleware-tagged contexts.
func PrincipalFromContext(r *http.Request) zddc.Principal { func PrincipalFromContext(r *http.Request) zddc.Principal {
return zddc.Principal{ return zddc.Principal{
Email: EmailFromContext(r), 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 // conferred authority on this request, or -1 if no admin
// authority applies. Lets forensics tell "root admin acted" // authority applies. Lets forensics tell "root admin acted"
// (level 0) apart from "subtree admin acted" (level N) apart // (level 0) apart from "subtree admin acted" (level N) apart
// from "not admin" (-1). The active_admin bool is derived // from "not admin" (-1). The active_admin bool is its
// for back-compat with existing log consumers. // presence/absence projected to a boolean.
adminLevel := activeAdminForRequest(cfg, r, elevated, email) adminLevel := activeAdminForRequest(cfg, r, elevated, email)
args := []any{ args := []any{

View file

@ -31,8 +31,9 @@ import (
// cascade defaults; the same `c` (write-once-create) verb that // cascade defaults; the same `c` (write-once-create) verb that
// lets them file canonical submittals lets them establish this // lets them file canonical submittals lets them establish this
// .zddc once. // .zddc once.
// - CanEditZddc on reviewing_root + staging_root. Existing rule // - ActionAdmin on reviewing_root/.zddc + staging_root/.zddc. The
// from the cascade defaults. // invoker must already administer those subtrees per the cascade
// defaults.
// //
// Operation: // 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 // Pre-flight authorisation. No ACL exception — we use existing
// cascade grants: // 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 // invoker is subtree-admin of the workflow roots and can
// write the workflow .zddc files. // write the workflow .zddc files.
// (b) The invoker has `c` (write-once-create) authority on // (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" return nil, http.StatusForbidden, "Forbidden — no authenticated principal"
} }
// All three pre-flight checks go through the consolidated decider. // All three pre-flight checks go through the consolidated decider.
// AllowActionFromChainP applies the strict-ancestor rule for // AllowActionFromChainP routes ActionAdmin .zddc edits and the
// .zddc-targeted actions (ActionAdmin) and the single admin-bypass // single admin-bypass branch for elevated admins. No manual
// branch for elevated admins. No manual IsAdmin / IsSubtreeAdmin / // IsAdmin / IsSubtreeAdmin branching here.
// CanEditZddc branching here.
decider := DeciderFromContext(r) decider := DeciderFromContext(r)
for _, root := range []string{reviewingRoot, stagingRoot} { for _, root := range []string{reviewingRoot, stagingRoot} {
chain, perr := zddc.EffectivePolicy(cfg.Root, root) chain, perr := zddc.EffectivePolicy(cfg.Root, root)

View file

@ -9,42 +9,30 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
) )
// Custom CSS pipeline. Lets an operator drop `.profile.css` (or the // Custom CSS pipeline. Lets an operator drop `.profile.css` at the
// legacy `.admin.css`) at the deployment root and have it picked up // deployment root and have it picked up automatically as styling for
// automatically as styling for the profile page. Previously lived // the profile page.
// alongside the retired form editor; kept because the profile page
// still relies on it.
const ( const profileCustomCSSName = ".profile.css"
profileCustomCSSName = ".profile.css"
adminCustomCSSName = ".admin.css" // legacy fallback
)
// hasCustomProfileCSS reports whether <fsRoot>/.profile.css (or the // hasCustomProfileCSS reports whether <fsRoot>/.profile.css exists.
// legacy .admin.css) exists. The profile template uses this to decide // The profile template uses this to decide whether to inject the
// whether to inject the <link> tag. // <link> tag.
func hasCustomProfileCSS(fsRoot string) bool { func hasCustomProfileCSS(fsRoot string) bool {
if _, err := os.Stat(filepath.Join(fsRoot, profileCustomCSSName)); err == nil { _, err := os.Stat(filepath.Join(fsRoot, profileCustomCSSName))
return true return err == nil
}
if _, err := os.Stat(filepath.Join(fsRoot, adminCustomCSSName)); err == nil {
return true
}
return false
} }
// profileAssetsPathPrefix is the URL prefix for admin static assets. // profileAssetsPathPrefix is the URL prefix for admin static assets.
// Lived at /.profile/zddc/assets/ during the form-editor era; renamed // The only consumer is the profile page, which emits a <link> to
// once the form editor retired. The only consumer is the profile page, // /custom.css when an operator has placed one at root.
// which emits a <link> to /custom.css when an operator has placed one
// at root.
const profileAssetsPathPrefix = ProfilePathPrefix + "/assets" const profileAssetsPathPrefix = ProfilePathPrefix + "/assets"
// serveProfileAssets handles GET /.profile/assets/<file>. V1 only // serveProfileAssets handles GET /.profile/assets/<file>. V1 only
// ships `custom.css` (passthrough of <root>/.profile.css when // ships `custom.css` (passthrough of <root>/.profile.css when present);
// present, falling back to <root>/.admin.css); other paths return // other paths return 404 so we don't accidentally expose arbitrary
// 404 so we don't accidentally expose arbitrary files. The caller // files. The caller (profilehandler.go) has already gated on admin
// (profilehandler.go) has already gated on admin scope. // scope.
func serveProfileAssets(cfg config.Config, w http.ResponseWriter, r *http.Request) { func serveProfileAssets(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET") w.Header().Set("Allow", "GET")
@ -56,11 +44,8 @@ func serveProfileAssets(cfg config.Config, w http.ResponseWriter, r *http.Reques
case "custom.css": case "custom.css":
path := filepath.Join(cfg.Root, profileCustomCSSName) path := filepath.Join(cfg.Root, profileCustomCSSName)
if fi, err := os.Stat(path); err != nil || fi.IsDir() { if fi, err := os.Stat(path); err != nil || fi.IsDir() {
path = filepath.Join(cfg.Root, adminCustomCSSName) http.NotFound(w, r)
if fi, err := os.Stat(path); err != nil || fi.IsDir() { return
http.NotFound(w, r)
return
}
} }
w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache") 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 = "/" sub = "/"
} }
// (The /.profile/zddc/* namespace previously hosted a parallel // /assets/ serves the profile page's custom.css when an operator
// REST API + form-rendered editor for .zddc files. Retired — // has placed one at root.
// 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.)
if strings.HasPrefix(sub, "/assets/") { if strings.HasPrefix(sub, "/assets/") {
serveProfileAssets(cfg, w, r) serveProfileAssets(cfg, w, r)
return 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 / // treeEntry is one row in the AccessView's AdminSubtrees list — every
// EditableParentChoices lists. The profile page renders them inline; // directory containing a .zddc that the caller administers. The profile
// the create-project form derives its parent-selector from the // page renders them inline; the create-project form's parent-selector
// EditableParentChoices subset. // seeds from the same list.
type treeEntry struct { type treeEntry struct {
Path string `json:"path"` Path string `json:"path"`
CanEdit bool `json:"can_edit"` Title string `json:"title,omitempty"`
Title string `json:"title,omitempty"`
} }
// AccessView is the data the profile page lazy-loads from /.profile/access // AccessView is the data the profile page lazy-loads from /.profile/access
// after first paint. The HTML shell renders only Email/EmailHeader/ // after first paint. The HTML shell renders only Email/EmailHeader/
// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come // IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come
// in via JS. EditableParentChoices is what the create-project form's // in via JS. AdminSubtrees doubles as the create-project parent-selector
// parent-selector renders — derived from AdminSubtrees on the client. // source — every entry is editable, since subtree admins own their own
// .zddc.
// //
// IsSuperAdmin and HasAnyAdminScope reflect EFFECTIVE authority — gated // IsSuperAdmin and HasAnyAdminScope reflect EFFECTIVE authority — gated
// by elevation. CanElevate is the independent "do you have any admin // by elevation. CanElevate is the independent "do you have any admin
// grant ANYWHERE in the tree, regardless of elevation?" signal that the // grant ANYWHERE in the tree, regardless of elevation?" signal that the
// header elevation toggle reads to decide whether to show itself. // header elevation toggle reads to decide whether to show itself.
type AccessView struct { type AccessView struct {
Email string `json:"email"` Email string `json:"email"`
EmailHeader string `json:"email_header"` EmailHeader string `json:"email_header"`
IsSuperAdmin bool `json:"is_super_admin"` IsSuperAdmin bool `json:"is_super_admin"`
HasAnyAdminScope bool `json:"has_any_admin_scope"` HasAnyAdminScope bool `json:"has_any_admin_scope"`
CanElevate bool `json:"can_elevate"` CanElevate bool `json:"can_elevate"`
// CanCreateProject is true when the caller is authorized to mkdir a // CanCreateProject is true when the caller is authorized to mkdir a
// new top-level project — either via the root .zddc granting `c` to // new top-level project — either via the root .zddc granting `c` to
// their email/role, or via super-admin authority (elevated). Drives // their email/role, or via super-admin authority (elevated). Drives
// the visibility of the profile page's "+ New project" form so the // the visibility of the profile page's "+ New project" form so the
// UI doesn't dangle an affordance the server would 404. // UI doesn't dangle an affordance the server would 404.
CanCreateProject bool `json:"can_create_project"` CanCreateProject bool `json:"can_create_project"`
Projects []ProjectInfo `json:"projects"` Projects []ProjectInfo `json:"projects"`
AdminSubtrees []treeEntry `json:"admin_subtrees"` AdminSubtrees []treeEntry `json:"admin_subtrees"`
EditableParentChoices []treeEntry `json:"editable_parent_choices"`
} }
// enumerateAccess builds an AccessView for the given caller. Used by the // 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) allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate)
view.CanCreateProject = allowed view.CanCreateProject = allowed
} }
for _, t := range view.AdminSubtrees {
if t.CanEdit {
view.EditableParentChoices = append(view.EditableParentChoices, t)
}
}
return view return view
} }
// enumerateAdminSubtrees lists every directory containing a .zddc that the // enumerateAdminSubtrees lists every directory containing a .zddc that the
// caller can see as an admin (super-admin or subtree-admin). Each entry // caller can see as an admin (super-admin or subtree-admin). Every entry
// carries can_edit so the page can label read-only entries (the file that // is editable — subtree admins own their own .zddc. Returns empty for an
// grants the user's own authority). Returns empty for an un-elevated // un-elevated principal — the elevation flag short-circuits each admin
// principal — the elevation flag short-circuits each admin check below. // check below.
func enumerateAdminSubtrees(cfg config.Config, p zddc.Principal) []treeEntry { func enumerateAdminSubtrees(cfg config.Config, p zddc.Principal) []treeEntry {
dirs, _ := zddc.ScanZddcFiles(cfg.Root) dirs, _ := zddc.ScanZddcFiles(cfg.Root)
out := make([]treeEntry, 0, len(dirs)) out := make([]treeEntry, 0, len(dirs))
@ -211,9 +199,8 @@ func enumerateAdminSubtrees(cfg config.Config, p zddc.Principal) []treeEntry {
title = zf.Title title = zf.Title
} }
out = append(out, treeEntry{ out = append(out, treeEntry{
Path: urlPathOf(cfg.Root, d), Path: urlPathOf(cfg.Root, d),
CanEdit: zddc.CanEditZddc(cfg.Root, d, p), Title: title,
Title: title,
}) })
} }
return out return out
@ -277,7 +264,6 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
IndexPath string `json:"index_path"` IndexPath string `json:"index_path"`
EmailHeader string `json:"email_header"` EmailHeader string `json:"email_header"`
CORSOrigins []string `json:"cors_origins"` CORSOrigins []string `json:"cors_origins"`
CascadeMode string `json:"cascade_mode"`
} }
writeJSON(w, response{ writeJSON(w, response{
Root: cfg.Root, Root: cfg.Root,
@ -289,7 +275,6 @@ func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Reques
IndexPath: cfg.IndexPath, IndexPath: cfg.IndexPath,
EmailHeader: cfg.EmailHeader, EmailHeader: cfg.EmailHeader,
CORSOrigins: cfg.CORSOrigins, CORSOrigins: cfg.CORSOrigins,
CascadeMode: cfg.CascadeMode,
}) })
} }
@ -365,9 +350,9 @@ func levelRank(s string) int {
// "chain": { // "chain": {
// "has_any_file": true, // "has_any_file": true,
// "levels": [ // "levels": [
// {"path": "/", "exists": true, "acl": {"allow": [...]}, "admins": [...]}, // {"path": "/", "exists": true, "acl": {"permissions": {...}}, "admins": [...]},
// {"path": "/Project-X/", "exists": false}, // {"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 // don't have per-level existence, but ZddcFile.Admins/ACL being
// non-empty is a reasonable proxy). // non-empty is a reasonable proxy).
out := struct { out := struct {
Path string `json:"path"` Path string `json:"path"`
Email string `json:"email"` Email string `json:"email"`
Decision bool `json:"decision"` Decision bool `json:"decision"`
DeciderKind string `json:"decider_kind"` DeciderKind string `json:"decider_kind"`
CascadeMode string `json:"cascade_mode"`
Chain struct { Chain struct {
HasAnyFile bool `json:"has_any_file"` HasAnyFile bool `json:"has_any_file"`
// VisibleStart is the lowest chain index whose grants are // VisibleStart is the lowest chain index whose grants are
// visible to evaluation at the leaf, accounting for any // visible to evaluation at the leaf, accounting for any
// inherit:false fence in delegated mode. In strict mode it // inherit:false fence.
// is always 0 (fences are ignored under AC-6).
VisibleStart int `json:"visible_start"` VisibleStart int `json:"visible_start"`
Levels []levelView `json:"levels"` Levels []levelView `json:"levels"`
} `json:"chain"` } `json:"chain"`
@ -452,11 +435,9 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
Email: probeEmail, Email: probeEmail,
Decision: allow, Decision: allow,
DeciderKind: deciderKind(decider), DeciderKind: deciderKind(decider),
CascadeMode: cfg.CascadeMode,
} }
out.Chain.HasAnyFile = chain.HasAnyFile out.Chain.HasAnyFile = chain.HasAnyFile
mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode) out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels) - 1)
out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels)-1, mode)
// Reconstruct level paths from cfg.Root. This mirrors how // Reconstruct level paths from cfg.Root. This mirrors how
// zddc.EffectivePolicy builds the chain (see cascade.go). // zddc.EffectivePolicy builds the chain (see cascade.go).
@ -487,33 +468,30 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
entry := levelView{ entry := levelView{
Index: i, Index: i,
ZddcPath: lp, 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, Inherit: lvl.ACL.Inherit,
} }
if entry.Exists { if entry.Exists {
entry.Acl = &lvl.ACL entry.Acl = &lvl.ACL
entry.Admins = lvl.Admins entry.Admins = lvl.Admins
} }
// Per-level email match: would this level's deny or allow // Per-level email match: which permissions entry at this level
// patterns hit the email if checked? Reuses the same // would hit the email? Empty verbs = explicit deny; any non-
// MatchesPattern code the live evaluator does. // empty verbs = grant. Mirrors GrantedVerbsAtLevel.
anyMatch := false anyMatch := false
decisionAtLevel := "no_match" decisionAtLevel := "no_match"
for _, p := range lvl.ACL.Deny { for pattern, verbs := range lvl.ACL.Permissions {
if zddc.MatchesPattern(p, probeEmail) { if !zddc.MatchesPattern(pattern, probeEmail) {
anyMatch = true continue
}
anyMatch = true
if verbs == "" {
decisionAtLevel = "deny" decisionAtLevel = "deny"
break break
} }
} decisionAtLevel = "allow"
if !anyMatch { // Don't break — keep scanning so an explicit deny still
for _, p := range lvl.ACL.Allow { // wins over a same-level grant.
if zddc.MatchesPattern(p, probeEmail) {
anyMatch = true
decisionAtLevel = "allow"
break
}
}
} }
entry.AnyMatch = anyMatch entry.AnyMatch = anyMatch
entry.Decision = decisionAtLevel 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 // Subtree-admin discovery used to live in the HTML render; now it flows
// through /.profile/access. Verify the JSON endpoint exposes everything // through /.profile/access. Verify the JSON endpoint exposes what the
// the IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees // IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees
// for the read-only list, EditableParentChoices for the parent-selector // for both the read-only list AND the parent-selector options, and
// options, and HasAnyAdminScope so the IIFE knows whether to clone the // HasAnyAdminScope so the IIFE knows whether to clone the <template>.
// <template>. Pure non-admins get an empty access view and no scaffold. // Pure non-admins get an empty access view and no scaffold.
func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) { func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
root := t.TempDir() root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - alice@example.com\n"), 0o644); err != nil { 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 { if len(carol.AdminSubtrees) != 0 {
t.Errorf("carol AdminSubtrees = %v, want empty", carol.AdminSubtrees) 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 // Subtree-admin: AdminSubtrees lists projects/ so the create-project
// parent dropdown can offer it; HasAnyAdminScope triggers template // parent dropdown can offer it; HasAnyAdminScope triggers template
// hydration. The projects/.zddc is NOT editable by bob — he cannot // hydration. Subtree admins own their .zddc (strict-ancestor retired),
// edit the file that grants him his own authority — so // so bob's projects/ entry is plainly listed and the Editable-files
// EditableParentChoices is empty and the Editable-files list will // list will render it inline.
// render its "None" placeholder.
bob := fetchAccess("bob@example.com") bob := fetchAccess("bob@example.com")
if bob.IsSuperAdmin { if bob.IsSuperAdmin {
t.Errorf("bob IsSuperAdmin = true, want false") t.Errorf("bob IsSuperAdmin = true, want false")
@ -475,17 +471,11 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
for _, s := range bob.AdminSubtrees { for _, s := range bob.AdminSubtrees {
if strings.HasSuffix(s.Path, "/projects") { if strings.HasSuffix(s.Path, "/projects") {
gotProjects = true 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 { if !gotProjects {
t.Errorf("bob AdminSubtrees missing projects/: %+v", bob.AdminSubtrees) 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. // Super-admin: AdminSubtrees enumerates every .zddc directory.
alice := fetchAccess("alice@example.com") alice := fetchAccess("alice@example.com")
@ -508,7 +498,7 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
t.Fatalf("mkdir: %v", err) t.Fatalf("mkdir: %v", err)
} }
if err := os.WriteFile(filepath.Join(cfg.Root, "Closed-Project", ".zddc"), 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) t.Fatalf("write child .zddc: %v", err)
} }
zddc.InvalidateCache(cfg.Root) zddc.InvalidateCache(cfg.Root)
@ -603,9 +593,8 @@ func TestServeProfileEffectivePolicy_InheritFence(t *testing.T) {
zddc.InvalidateCache(cfg.Root) zddc.InvalidateCache(cfg.Root)
type respShape struct { type respShape struct {
Decision bool `json:"decision"` Decision bool `json:"decision"`
CascadeMode string `json:"cascade_mode"` Chain struct {
Chain struct {
VisibleStart int `json:"visible_start"` VisibleStart int `json:"visible_start"`
Levels []struct { Levels []struct {
Index int `json:"index"` Index int `json:"index"`
@ -650,7 +639,7 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// .zddc exists but has no admins list — page is still reachable, // .zddc exists but has no admins list — page is still reachable,
// but the admin/super-admin sections are absent. // but the admin/super-admin sections are absent.
cfg, ring := profileTestRoot(t, nil) 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) t.Fatalf("write .zddc: %v", err)
} }
zddc.InvalidateCache(cfg.Root) zddc.InvalidateCache(cfg.Root)
@ -785,7 +774,7 @@ func TestServeProfileProjectsCreateValidatesZddc(t *testing.T) {
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} 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 := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), EmailKey, "root@example.com") 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) <label>Title (optional)
<input type="text" name="title" id="cp-title" maxlength="200"> <input type="text" name="title" id="cp-title" maxlength="200">
</label> </label>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Allow (optional)</h3> <h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Permissions (optional)</h3>
<div class="list" data-field="acl.allow"></div> <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>
<button type="button" class="add" data-target="acl.allow">+ Add allow rule</button> <div class="list" data-field="acl.permissions"></div>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Deny (optional)</h3> <button type="button" class="add" data-target="acl.permissions">+ Add permission</button>
<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;">Additional admins (optional)</h3> <h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Additional admins (optional)</h3>
<div class="list" data-field="admins"></div> <div class="list" data-field="admins"></div>
<button type="button" class="add" data-target="admins">+ Add admin</button> <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) { subtrees.forEach(function(s) {
html += '<li><code>' + escText(s.path) + '</code>'; html += '<li><code>' + escText(s.path) + '</code>';
if (s.title) html += ' ' + escText(s.title); 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 += '</li>';
}); });
html += '</ul>'; html += '</ul>';
host.innerHTML = html; host.innerHTML = html;
} }
function renderEditableList(parents, hasAnyAdminScope) { function renderEditableList(parents) {
var host = document.getElementById("editable-list"); var host = document.getElementById("editable-list");
if (!host) return; if (!host) return;
if (!parents || parents.length === 0) { 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; return;
} }
var html = '<ul class="bare">'; var html = '<ul class="bare">';
parents.forEach(function(p) { parents.forEach(function(p) {
var path = escText(p.path); var path = escText(p.path);
// Link to browse opening the .zddc in the YAML/CodeMirror // Link to browse opening the .zddc in the YAML/CodeMirror
// editor (with .zddc-schema lint). Replaces the retired form- // editor (with .zddc-schema lint).
// based editor at <prefix>/zddc/edit?path=; same data, one
// canonical edit surface.
var dirURL = path === '/' ? '/' : path + '/'; var dirURL = path === '/' ? '/' : path + '/';
html += '<li><a href="' + dirURL + '?file=.zddc">' html += '<li><a href="' + dirURL + '?file=.zddc">'
+ '<code>' + path + '/.zddc</code></a>'; + '<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); div.appendChild(input); div.appendChild(del);
return div; 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) { function collectList(field) {
var out = []; var out = [];
document.querySelectorAll('#cp-form .list[data-field="' + field + '"] input').forEach(function(i) { 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; 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() { function wireCreateProjectForm() {
document.querySelectorAll("#cp-form button.add").forEach(function(btn) { document.querySelectorAll("#cp-form button.add").forEach(function(btn) {
btn.addEventListener("click", function() { btn.addEventListener("click", function() {
var field = btn.dataset.target; 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) { document.getElementById("cp-form").addEventListener("click", function(e) {
@ -478,16 +491,15 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
ev.preventDefault(); ev.preventDefault();
document.getElementById("cp-name-err").textContent = ""; document.getElementById("cp-name-err").textContent = "";
document.getElementById("cp-ok").hidden = true; document.getElementById("cp-ok").hidden = true;
var allow = collectList("acl.allow"); var permissions = collectPermissions();
var deny = collectList("acl.deny");
var admins = collectList("admins"); var admins = collectList("admins");
var title = document.getElementById("cp-title").value.trim(); var title = document.getElementById("cp-title").value.trim();
var body = { var body = {
parent: document.getElementById("cp-parent").value, parent: document.getElementById("cp-parent").value,
name: document.getElementById("cp-name").value.trim() name: document.getElementById("cp-name").value.trim()
}; };
if (title) body.title = title; if (title) body.title = title;
if (allow.length || deny.length) body.acl = { allow: allow, deny: deny }; if (Object.keys(permissions).length) body.acl = { permissions: permissions };
if (admins.length) body.admins = admins; if (admins.length) body.admins = admins;
fetch(prefix + "/projects", { fetch(prefix + "/projects", {
method: "POST", method: "POST",
@ -523,7 +535,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
if (tmpl) { if (tmpl) {
var slot = document.getElementById("subtree-admin-slot"); var slot = document.getElementById("subtree-admin-slot");
slot.appendChild(tmpl.content.cloneNode(true)); 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 // 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) { if (cpTmpl) {
var cpSlot = document.getElementById("create-project-slot"); var cpSlot = document.getElementById("create-project-slot");
cpSlot.appendChild(cpTmpl.content.cloneNode(true)); cpSlot.appendChild(cpTmpl.content.cloneNode(true));
populateParentChoices(view.editable_parent_choices || []); populateParentChoices(view.admin_subtrees || []);
wireCreateProjectForm(); wireCreateProjectForm();
} }
} }

View file

@ -122,7 +122,7 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
} }
zf.Admins = admins zf.Admins = admins
wantsZddc := len(zf.Admins) > 0 || zf.Title != "" || 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 wantsZddc {
if errs := zddc.ValidateFile(zf); len(errs) > 0 { if errs := zddc.ValidateFile(zf); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8") 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- // URL strips to a real directory under fsRoot, or to a cascade-
// declared path that the listing pipeline would render as empty. // declared path that the listing pipeline would render as empty.
// //
// The path-suffix grammar replaces the legacy `<dir>/?zip=1` query // A virtual file living next to its source means clients can emit a
// form. A virtual file living next to its source means clients can // plain `<a href>` without query-string handling; mirror tools pick
// emit a plain `<a href>` without query-string handling; mirror // it up via normal recursion; `curl -O` writes a sensible filename
// tools pick it up via normal recursion; `curl -O` writes a sensible // without a `--remote-header-name` hint. Real `.zip` files in the
// filename without a `--remote-header-name` hint. Real `.zip` files // tree always win — stat is checked before this helper, so a genuine
// in the tree always win — stat is checked before this helper, so a // archive at `<path>.zip` serves its bytes normally.
// genuine archive at `<path>.zip` serves its bytes normally.
func RecognizeVirtualSubtreeZip(fsRoot, urlPath string) (absDir string, ok bool) { func RecognizeVirtualSubtreeZip(fsRoot, urlPath string) (absDir string, ok bool) {
if !strings.HasSuffix(urlPath, ".zip") { if !strings.HasSuffix(urlPath, ".zip") {
return "", false return "", false

View file

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

View file

@ -29,8 +29,8 @@ func IsZddcFileRequest(urlPath string) bool {
// ServeZddcFile serves a directory's .zddc as a plain YAML view. // ServeZddcFile serves a directory's .zddc as a plain YAML view.
// //
// Method: GET / HEAD only; everything else → 405 with the existing // Method: GET / HEAD only — the dispatcher routes writes
// /.profile/zddc editor pointed to in the body. // (PUT/DELETE/POST) directly to ServeFileAPI.
// ACL: the parent directory's read permission gates access. A // ACL: the parent directory's read permission gates access. A
// user who can read the directory can read its .zddc. // user who can read the directory can read its .zddc.
// On-disk: if <dir>/.zddc exists, its bytes are returned verbatim // 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 // The virtual response sets X-ZDDC-Source: virtual so the
// client can distinguish. // client can distinguish.
func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) { 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) decider := DeciderFromContext(r)
// URL is <dir>/.zddc. Strip the leaf to get the directory. // URL is <dir>/.zddc. Strip the leaf to get the directory.
@ -175,12 +165,6 @@ func summariseLevel(lvl zddc.ZddcFile) string {
if lvl.Title != "" { if lvl.Title != "" {
fmt.Fprintf(&b, "# title: %q\n", 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 { if len(lvl.ACL.Permissions) > 0 {
fmt.Fprintf(&b, "# acl.permissions: %v\n", lvl.ACL.Permissions) 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) t.Fatalf("compile federal rego: %v", err)
} }
allow := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}} } allow := func(p ...string) zddc.ZddcFile {
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] = "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{} empty := zddc.ZddcFile{}
cases := []struct { cases := []struct {

View file

@ -37,13 +37,28 @@ func TestRegoParity_AllInternalCases(t *testing.T) {
} }
allow := func(p ...string) zddc.ZddcFile { 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 { 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 { 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{} empty := zddc.ZddcFile{}

View file

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

View file

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

View file

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

View file

@ -27,6 +27,8 @@
# whatever they write. # whatever they write.
# #
# Input shape: identical to access.rego — see that file's docstring. # 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 package zddc.access_federal
@ -54,17 +56,19 @@ allow if {
any_allow_match 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 { any_deny_match if {
some level in input.policy_chain.levels 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) 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 { any_allow_match if {
some level in input.policy_chain.levels 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) email_matches(pattern, input.user.email)
} }

View file

@ -2,20 +2,12 @@ package zddc
import "strings" import "strings"
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for // AllowedAtLevel is a thin shim over GrantedVerbsAtLevel for callers
// callers that only need the legacy boolean read decision on a single // that have a single ZddcFile (no cascade chain) and only need the
// ZddcFile (no cascade chain). // boolean read decision.
//
// 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.
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) { func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true} chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
v, m := GrantedVerbsAtLevel(chain, 0, email, ModeDelegated) v, m := GrantedVerbsAtLevel(chain, 0, email)
if !m { if !m {
return false, false 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 // - matched=true, set!={} → union of verb sets from every matching entry
// //
// Role lookups for principal keys without "@" use RoleMembers, which // Role lookups for principal keys without "@" use RoleMembers, which
// walks levelIdx → fence-or-root for the closest definition. mode // walks levelIdx → fence-or-root for the closest definition.
// controls whether inherit:false fences are honored — see VisibleStart. func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet, bool) {
//
// 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) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) { if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return 0, false return 0, false
} }
level := chain.Levels[levelIdx] level := chain.Levels[levelIdx]
perms := effectivePermissions(level.ACL) if len(level.ACL.Permissions) == 0 {
if len(perms) == 0 {
return 0, false return 0, false
} }
matched := false matched := false
deniedExplicit := false deniedExplicit := false
var grant VerbSet var grant VerbSet
for principal, verbStr := range perms { for principal, verbStr := range level.ACL.Permissions {
if !MatchesPrincipal(principal, email, chain, levelIdx, mode) { if !MatchesPrincipal(principal, email, chain, levelIdx) {
continue continue
} }
matched = true matched = true
@ -72,44 +58,17 @@ func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string, mode Cas
return grant, true return grant, true
} }
// effectivePermissions returns the union of acl.permissions and the // AllowedAction evaluates a PolicyChain for a specific verb.
// legacy acl.allow / acl.deny fields, with permissions winning on // Thin wrapper over EffectiveVerbs.
// collision. Returns nil if all three are empty. Does not mutate rules. func AllowedAction(chain PolicyChain, email string, verb VerbSet) bool {
func effectivePermissions(rules ACLRules) map[string]string { return EffectiveVerbs(chain, email).Has(verb)
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)
} }
// EffectiveVerbs computes the verb set granted to email by the cascade. // EffectiveVerbs computes the verb set granted to email by the cascade.
// Walks the full chain and applies the default-allow rule (no .zddc // Walks the full chain and applies the default-allow rule (no .zddc
// anywhere → public access). // anywhere → public access).
func EffectiveVerbs(chain PolicyChain, email string, mode CascadeMode) VerbSet { func EffectiveVerbs(chain PolicyChain, email string) VerbSet {
v := EffectiveVerbsRange(chain, 0, len(chain.Levels), email, mode) v := EffectiveVerbsRange(chain, 0, len(chain.Levels), email)
if v == 0 && !chain.HasAnyFile { if v == 0 && !chain.HasAnyFile {
// Public-tree default: empty chain with no .zddc files anywhere // Public-tree default: empty chain with no .zddc files anywhere
// → grant everything. EffectiveVerbsRange returns 0 in this // → 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 // WORM-folder are evaluated as separate ranges, then their grants are
// masked and unioned. // 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 // This function does NOT consult the admins:/IsAdmin escape hatch and
// does NOT apply the Issued/Received WORM mask. // 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 { if fromIdx < 0 {
fromIdx = 0 fromIdx = 0
} }
@ -151,21 +105,12 @@ func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mo
return 0 return 0
} }
// Honor inherit:false fences — clamp fromIdx upward to the deepest // Honor inherit:false fences — clamp fromIdx upward to the deepest
// fence visible from the leaf end of the range. In strict mode the // fence visible from the leaf end of the range.
// fence helper returns 0 unconditionally, so this is a no-op. if fence := chain.VisibleStart(toIdx - 1); fence > fromIdx {
if fence := chain.VisibleStart(toIdx-1, mode); fence > fromIdx {
fromIdx = fence 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-- { for i := toIdx - 1; i >= fromIdx; i-- {
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode) grant, matched := GrantedVerbsAtLevel(chain, i, email)
if !matched { if !matched {
continue continue
} }

View file

@ -113,7 +113,7 @@ func TestAllowedAtLevel(t *testing.T) {
{ {
name: "allow matched", name: "allow matched",
level: ZddcFile{ACL: ACLRules{ level: ZddcFile{ACL: ACLRules{
Allow: []string{"*@example.com"}, Permissions: map[string]string{"*@example.com": "rwcd"},
}}, }},
email: "alice@example.com", email: "alice@example.com",
wantAllowed: true, wantAllowed: true,
@ -122,7 +122,7 @@ func TestAllowedAtLevel(t *testing.T) {
{ {
name: "deny matched", name: "deny matched",
level: ZddcFile{ACL: ACLRules{ level: ZddcFile{ACL: ACLRules{
Deny: []string{"alice@example.com"}, Permissions: map[string]string{"alice@example.com": ""},
}}, }},
email: "alice@example.com", email: "alice@example.com",
wantAllowed: false, wantAllowed: false,
@ -131,8 +131,10 @@ func TestAllowedAtLevel(t *testing.T) {
{ {
name: "deny wins over allow at the same level", name: "deny wins over allow at the same level",
level: ZddcFile{ACL: ACLRules{ level: ZddcFile{ACL: ACLRules{
Allow: []string{"*@example.com"}, Permissions: map[string]string{
Deny: []string{"alice@example.com"}, "*@example.com": "rwcd",
"alice@example.com": "",
},
}}, }},
email: "alice@example.com", email: "alice@example.com",
wantAllowed: false, wantAllowed: false,
@ -141,8 +143,10 @@ func TestAllowedAtLevel(t *testing.T) {
{ {
name: "neither rule matches", name: "neither rule matches",
level: ZddcFile{ACL: ACLRules{ level: ZddcFile{ACL: ACLRules{
Allow: []string{"*@example.com"}, Permissions: map[string]string{
Deny: []string{"*@evil.com"}, "*@example.com": "rwcd",
"*@evil.com": "",
},
}}, }},
email: "carol@other.org", email: "carol@other.org",
wantAllowed: false, 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 // caller is responsible for gating on Principal.Elevated before
// treating the result as live authority. // 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 // Role lookups inside this walk are bounded to the levels visible
// AT the matching level (via MatchesPrincipal's third/fourth args). // AT the matching level (via MatchesPrincipal's third/fourth args
// A role defined at the deepest level never confers self-edit // and the fence-aware VisibleStart helper).
// rights indirectly because the role definition isn't visible above
// the level it's defined at.
// //
// Exposed separately from IsAdminForChain so audit-logging callers // Exposed separately from IsAdminForChain so audit-logging callers
// can record WHICH level conferred admin authority — useful for // can record WHICH level conferred admin authority — useful for
// forensics across nested delegation (root admin vs subtree admin // forensics across nested delegation (root admin vs subtree admin
// at depth N). // at depth N).
func AdminLevelInChain(chain PolicyChain, email string, excludeLeaf bool) int { func AdminLevelInChain(chain PolicyChain, email string) int {
if email == "" { if email == "" {
return -1 return -1
} }
n := len(chain.Levels) for i, level := range chain.Levels {
if n == 0 { for _, principal := range level.Admins {
return -1 if MatchesPrincipal(principal, email, chain, i) {
}
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) {
return i return i
} }
} }
@ -75,10 +59,9 @@ func AdminLevelInChain(chain PolicyChain, email string, excludeLeaf bool) int {
} }
// IsAdminForChain is the boolean shortcut over AdminLevelInChain. // IsAdminForChain is the boolean shortcut over AdminLevelInChain.
// Returns true iff some level grants admin authority. See // Returns true iff some level grants admin authority.
// AdminLevelInChain for parameter semantics. func IsAdminForChain(chain PolicyChain, email string) bool {
func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool { return AdminLevelInChain(chain, email) >= 0
return AdminLevelInChain(chain, email, excludeLeaf) >= 0
} }
// HasAnyAdminGrant reports whether email is named as an admin somewhere // 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: // subtree-admin grant via paths:.<dir>.admins. ELEVATION-INDEPENDENT:
// answers "could this user opt into admin powers if they wanted to?", // answers "could this user opt into admin powers if they wanted to?",
// which the header elevation toggle reads to decide whether to render // which the header elevation toggle reads to decide whether to render
// itself. The elevation-AWARE checks (IsAdmin, IsSubtreeAdmin, // itself. The elevation-AWARE checks (IsAdmin, IsSubtreeAdmin) take a
// CanEditZddc) take a Principal and short-circuit on !Elevated; // Principal and short-circuit on !Elevated; this function just asks
// this function just asks the cascade. // the cascade.
// //
// Returns false for an empty email so anonymous callers can't probe. // Returns false for an empty email so anonymous callers can't probe.
func HasAnyAdminGrant(fsRoot, email string) bool { func HasAnyAdminGrant(fsRoot, email string) bool {
@ -113,7 +96,7 @@ func HasAnyAdminGrant(fsRoot, email string) bool {
} }
for i, level := range chain.Levels { for i, level := range chain.Levels {
for _, principal := range level.Admins { for _, principal := range level.Admins {
if MatchesPrincipal(principal, email, chain, i, ModeDelegated) { if MatchesPrincipal(principal, email, chain, i) {
return true return true
} }
} }
@ -131,7 +114,7 @@ func HasAnyAdminGrant(fsRoot, email string) bool {
// Subtree-scoped admin authority (the "fiefdom" model) is checked via // Subtree-scoped admin authority (the "fiefdom" model) is checked via
// IsSubtreeAdmin / CanEditZddc instead. // 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 // MatchesPattern). Returns false if the root file does not exist, has an
// empty Admins list, no entry matches, or the principal hasn't elevated. // empty Admins list, no entry matches, or the principal hasn't elevated.
func IsAdmin(fsRoot string, p Principal) bool { 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 // IsSubtreeAdmin reports whether email administers the subtree rooted at
// dirPath. Authority cascades: a match against any Admins entry on the chain // dirPath. Authority cascades: a match against any Admins entry on the chain
// from fsRoot down to dirPath (inclusive) confers admin rights for dirPath. // 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 // Admins entries may be email-glob patterns OR role references (a bare
// role name, or @role:<name>) — resolved the same way acl.permissions // role name, or @role:<name>) — resolved the same way acl.permissions
// keys are, so `admins: [document_controller]` works once a deployment // keys are, so `admins: [document_controller]` works once a deployment
// populates that role. // 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 { func IsSubtreeAdmin(fsRoot, dirPath string, p Principal) bool {
if !p.gate() { if !p.gate() {
return false return false
@ -173,7 +154,7 @@ func IsSubtreeAdmin(fsRoot, dirPath string, p Principal) bool {
} }
for i, level := range chain.Levels { for i, level := range chain.Levels {
for _, principal := range level.Admins { for _, principal := range level.Admins {
if MatchesPrincipal(principal, p.Email, chain, i, ModeDelegated) { if MatchesPrincipal(principal, p.Email, chain, i) {
return true return true
} }
} }
@ -181,52 +162,3 @@ func IsSubtreeAdmin(fsRoot, dirPath string, p Principal) bool {
return false 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", 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", email: "alice@example.com",
want: false, want: false,
}, },
@ -63,7 +63,7 @@ func TestIsAdmin(t *testing.T) {
}, },
{ {
name: "acl deny does not affect admins", 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", email: "alice@example.com",
want: true, want: true,
}, },
@ -86,8 +86,8 @@ func TestIsAdmin(t *testing.T) {
// TestIsAdminSubdirIgnored documents that admins entries in subdirectory // TestIsAdminSubdirIgnored documents that admins entries in subdirectory
// .zddc files are NOT honored by IsAdmin — only the root .zddc grants the // .zddc files are NOT honored by IsAdmin — only the root .zddc grants the
// server-wide super-admin role. Subtree admin authority for "fiefdom" // server-wide super-admin role. Subtree admin authority is a separate
// editing is a separate concept covered by IsSubtreeAdmin / CanEditZddc. // concept covered by IsSubtreeAdmin.
func TestIsAdminSubdirIgnored(t *testing.T) { func TestIsAdminSubdirIgnored(t *testing.T) {
root := t.TempDir() root := t.TempDir()
sub := filepath.Join(root, "project") sub := filepath.Join(root, "project")
@ -96,7 +96,7 @@ func TestIsAdminSubdirIgnored(t *testing.T) {
} }
// Root has no admins; subdir tries to grant admin. // 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) 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 { 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 { cases := []struct {
name string name string
files map[string]string files map[string]string
dir string dir string
email string email string
want bool 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", name: "root super-admin matches at any depth",
@ -451,6 +283,16 @@ func TestIsAdminForChain(t *testing.T) {
email: "alice@example.com", email: "alice@example.com",
want: true, 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", name: "subtree admin does NOT match outside their subtree",
files: map[string]string{ files: map[string]string{
@ -461,38 +303,6 @@ func TestIsAdminForChain(t *testing.T) {
email: "alice@example.com", email: "alice@example.com",
want: false, 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", name: "empty email never matches",
files: map[string]string{"": "admins:\n - \"*\"\n"}, files: map[string]string{"": "admins:\n - \"*\"\n"},
@ -510,20 +320,6 @@ func TestIsAdminForChain(t *testing.T) {
email: "bob@example.com", email: "bob@example.com",
want: true, 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 { for _, tc := range cases {
@ -539,9 +335,9 @@ func TestIsAdminForChain(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("EffectivePolicy: %v", err) t.Fatalf("EffectivePolicy: %v", err)
} }
if got := IsAdminForChain(chain, tc.email, tc.excludeLeaf); got != tc.want { if got := IsAdminForChain(chain, tc.email); got != tc.want {
t.Errorf("IsAdminForChain(dir=%q, email=%q, excludeLeaf=%v) = %v, want %v", t.Errorf("IsAdminForChain(dir=%q, email=%q) = %v, want %v",
tc.dir, tc.email, tc.excludeLeaf, got, tc.want) tc.dir, tc.email, got, tc.want)
} }
}) })
} }
@ -552,12 +348,11 @@ func TestIsAdminForChain(t *testing.T) {
// index, no match returns -1. // index, no match returns -1.
func TestAdminLevelInChain(t *testing.T) { func TestAdminLevelInChain(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
files map[string]string files map[string]string
dir string dir string
email string email string
excludeLeaf bool want int
want int
}{ }{
{ {
name: "root super-admin matches at level 0", name: "root super-admin matches at level 0",
@ -601,28 +396,6 @@ func TestAdminLevelInChain(t *testing.T) {
email: "", email: "",
want: -1, 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 { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@ -637,7 +410,7 @@ func TestAdminLevelInChain(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("EffectivePolicy: %v", err) 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", t.Errorf("AdminLevelInChain(dir=%q, email=%q) = %d, want %d",
tc.dir, tc.email, got, tc.want) 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 // descendants at-and-below the fence. The deepest fence in the prefix
// wins (nested fences are supported; the closer-to-leaf wins). // 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. // toIdx is clamped to len(chain.Levels)-1.
func (chain PolicyChain) VisibleStart(toIdx int, mode CascadeMode) int { func (chain PolicyChain) VisibleStart(toIdx int) int {
if mode == ModeStrict {
return 0
}
if toIdx >= len(chain.Levels) { if toIdx >= len(chain.Levels) {
toIdx = len(chain.Levels) - 1 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 { if err := os.MkdirAll(leaf, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
writeZddc(t, root, "acl:\n allow:\n - root@example.com\n") writeZddc(t, root, "acl:\n permissions:\n root@example.com: rwcd\n")
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) chain, err := EffectivePolicy(root, leaf)
if err != nil { if err != nil {
@ -64,14 +64,20 @@ func TestEffectivePolicy(t *testing.T) {
if len(chain.Levels) != 3 { if len(chain.Levels) != 3 {
t.Fatalf("len(Levels) = %d, want 3", len(chain.Levels)) 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" { if got := chain.Levels[0].ACL.Permissions["root@example.com"]; got != "rwcd" {
t.Errorf("root level Allow = %v, want [root@example.com]", got) t.Errorf("root level permissions[root] = %q, want %q", got, "rwcd")
} }
if got := chain.Levels[1].ACL.Allow; len(got) != 0 { // Middle level has no fixture-specific .zddc; the merge accumulator
t.Errorf("middle level Allow = %v, want empty", got) // 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" { if _, ok := chain.Levels[1].ACL.Permissions["leaf@example.com"]; ok {
t.Errorf("leaf level Allow = %v, want [leaf@example.com]", got) 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 // Garbage YAML
writeZddc(t, root, "::: not 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) chain, err := EffectivePolicy(root, leaf)
if err != nil { if err != nil {
@ -111,18 +117,18 @@ func TestEffectivePolicy(t *testing.T) {
t.Error("HasAnyFile = false, want true (malformed file still counts as present)") t.Error("HasAnyFile = false, want true (malformed file still counts as present)")
} }
// Root level parsed empty (parse error path), leaf level has the rule // Root level parsed empty (parse error path), leaf level has the rule
if got := chain.Levels[0].ACL.Allow; len(got) != 0 { if got := chain.Levels[0].ACL.Permissions; len(got) != 0 {
t.Errorf("root level Allow = %v, want empty (parse error)", got) 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" { if got := chain.Levels[1].ACL.Permissions["leaf@example.com"]; got != "rwcd" {
t.Errorf("leaf level Allow = %v, want [leaf@example.com]", got) t.Errorf("leaf level permissions[leaf] = %q, want %q", got, "rwcd")
} }
}) })
t.Run("dirPath equal to fsRoot has single level", func(t *testing.T) { t.Run("dirPath equal to fsRoot has single level", func(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() 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) chain, err := EffectivePolicy(root, root)
if err != nil { if err != nil {
@ -144,23 +150,23 @@ func TestEffectivePolicyEndToEnd(t *testing.T) {
t.Fatal(err) 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 // public/: no .zddc on the chain → default allow for everyone
chainPub, _ := EffectivePolicy(root, pub) 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") t.Error("public/ should be open when no .zddc files exist on the chain")
} }
// private/: has rules → only alice gets in // private/: has rules → only alice gets in
chainPriv, _ := EffectivePolicy(root, priv) 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/") 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/") 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") 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. // ACLRules holds the access-control rules at one cascade level.
// //
// Three input forms, all merged at parse time into a single map keyed // One input form, keyed by principal (Permissions):
// by principal (Permissions):
// //
// - acl.permissions: { principal → verb-set } — the canonical form. // - acl.permissions: { principal → verb-set }. Principal is an email
// Principal is an email pattern (contains "@") or a role name // pattern (contains "@") or a role name (no "@"); roles are looked
// (no "@"); roles are looked up via ZddcFile.Roles in this file // up via ZddcFile.Roles in this file or any ancestor. Verb-set is
// or any ancestor. Verb-set is a string drawn from {r,w,c,d,a}; // a string drawn from {r,w,c,d,a}; empty string is an explicit
// empty string is an explicit deny. // 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.
// //
// Inherit controls whether this level imports grants and roles from // Inherit controls whether this level imports grants and roles from
// its ancestors. The default (when the field is absent — represented // 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 // (e.g. a vendor folder where only the vendor and the doc controller
// should have access regardless of broader project-level grants). // should have access regardless of broader project-level grants).
// //
// In strict cascade mode (federal / NIST AC-6), inherit:false is // Federal deployments running the bundled `access_federal.rego` get
// REFUSED — a leaf-level directive cannot widen access an ancestor // parent-deny-is-absolute / NIST AC-6 semantics; the directive's
// refused. The internal decider silently treats it as inherit:true; // fence-style "reset" should be avoided there because it would let a
// the cascade tracer (/.profile/effective-policy) reports both // leaf widen access an ancestor refused. The cascade tracer at
// `cascade_mode` and `chain.visible_start` so an operator can see // /.profile/effective-policy reports `chain.visible_start` so an
// that a configured fence is being ignored under the active mode. // operator can verify which level a fence is actually cutting off.
// Operators running the federal Rego preset get the same behaviour
// from policy enforcement.
// //
// Inherit is per-level and not itself cascading: an ancestor's // Inherit is per-level and not itself cascading: an ancestor's
// `inherit: false` does not transitively block descendants from // `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 the external-OPA input body (see internal/policy). The canonical
// in-repo serialization is YAML; JSON is only used for OPA queries. // in-repo serialization is YAML; JSON is only used for OPA queries.
type ACLRules struct { 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"` Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
// Inherit *bool: nil = unset (inherit normally), &true = same, // Inherit *bool: nil = unset (inherit normally), &true = same,
// &false = fence ancestors. Using a pointer so the default is // &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 // Role is the named principal-grouping primitive. Members are email
// patterns (same syntax as the legacy allow/deny entries — see // patterns (see MatchesPattern). A role defined at level L is in scope
// MatchesPattern). A role defined at level L is in scope at L and all // at L and all descendants.
// descendants.
// //
// Role membership UNIONS across the cascade: if the same role name is // Role membership UNIONS across the cascade: if the same role name is
// defined at multiple levels, the effective member set is the union // 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 { if err := yaml.Unmarshal(data, &zf); err != nil {
return ZddcFile{}, err return ZddcFile{}, err
} }
mergeLegacyACL(&zf.ACL)
return zf, nil 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": "rwcd"})},
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcda"})}, 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) 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: aclFenced(map[string]string{"*@vendor.com": "rwcd"}, false)},
ZddcFile{ACL: aclOpen(map[string]string{})}, 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) 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) t.Errorf("fence at 1: VisibleStart(1) = %d, want 1", got)
} }
// Fence above toIdx is irrelevant. // 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) 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: aclFenced(map[string]string{"*@b.com": "rwcd"}, false)},
ZddcFile{ACL: aclOpen(map[string]string{})}, 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) 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 // End-to-end: a fence at the vendor folder hides root-level grants for
// users who don't match the vendor-folder grants. // users who don't match the vendor-folder grants.
func TestEffectiveVerbs_FenceHidesAncestorGrants(t *testing.T) { 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 // alice@example.com used to inherit root rwcd; with the fence she has
// no grant in the vendor folder → 0 verbs. // 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) t.Errorf("alice should be denied by fence; got %s", got)
} }
// rep@vendor.com matches the local rule. // 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) 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 // Roles defined above the fence are invisible to descendants — operators
// who fence must redefine roles locally if they want to use them. // who fence must redefine roles locally if they want to use them.
func TestRoleMembers_FenceHidesAncestorRoles(t *testing.T) { func TestRoleMembers_FenceHidesAncestorRoles(t *testing.T) {
@ -124,13 +95,9 @@ func TestRoleMembers_FenceHidesAncestorRoles(t *testing.T) {
chain := buildChain(rootLevel, fencedLevel) chain := buildChain(rootLevel, fencedLevel)
// Below the fence, the role from root is invisible. // 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) 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 // 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"}}}, 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" { if len(got) != 1 || got[0] != "vendor-dc@example.com" {
t.Errorf("local redefinition should win; got %v", got) 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 { if len(zf.Admins) > 0 {
return false 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 return false
} }
if zf.ACL.Inherit != nil { if zf.ACL.Inherit != nil {

View file

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

View file

@ -78,7 +78,7 @@ func TestRoleMembersUnionAcrossCascade(t *testing.T) {
}, },
HasAnyFile: true, HasAnyFile: true,
} }
got := RoleMembers(chain, 1, "editors", ModeDelegated) got := RoleMembers(chain, 1, "editors")
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("union: got %v, want both alice + bob", got) 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) t.Errorf("union: got %v, want alice + bob", got)
} }
// At root level, only the root definition is in the visible chain. // 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" { if len(got) != 1 || got[0] != "alice@example.com" {
t.Errorf("root visibility: got %v, want [alice]", got) t.Errorf("root visibility: got %v, want [alice]", got)
} }
@ -118,7 +118,7 @@ func TestRoleMembersResetBreaksUnion(t *testing.T) {
}, },
HasAnyFile: true, HasAnyFile: true,
} }
got := RoleMembers(chain, 2, "editors", ModeDelegated) got := RoleMembers(chain, 2, "editors")
// Expect carol (reset level) + dave (leaf), NOT alice (excluded by reset). // Expect carol (reset level) + dave (leaf), NOT alice (excluded by reset).
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("reset: got %v, want carol + dave only", got) t.Fatalf("reset: got %v, want carol + dave only", got)
@ -137,10 +137,10 @@ func TestMatchesPrincipalLegacyPatternFallback(t *testing.T) {
Levels: []ZddcFile{{}}, Levels: []ZddcFile{{}},
HasAnyFile: true, 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") 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") t.Errorf("*example.com should match alice@example.com via legacy fallback")
} }
} }
@ -156,10 +156,10 @@ func TestMatchesPrincipalRoleNamePrefersRole(t *testing.T) {
}}, }},
HasAnyFile: true, 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") 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") 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" "strings"
) )
// Phase 3 retired the hardcoded canonical-folder predicates and their // The .zddc cascade is the authority for canonical-folder behaviour;
// supporting lists (ProjectRootFolders, PartyFolders, AutoOwnCanonicalNames, // see defaults.zddc.yaml for the conventions and lookups.go for the
// VirtualOnlyCanonicalNames, IsArchivePartyFolder, IsArchivePartyMdlDir, // helpers consumers call (DefaultToolAt, AutoOwnAt, VirtualAt,
// 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,
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt). // IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting // WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
@ -87,11 +84,3 @@ func ResolveCanonical(parentDir, logical string) (string, error) {
return "", nil 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. // Mirror InternalDecider.Allow's WORM-aware composition.
var got VerbSet var got VerbSet
if g, inWorm := WormZoneGrant(chain, dc, ModeDelegated); inWorm { if g, inWorm := WormZoneGrant(chain, dc); inWorm {
got = (EffectiveVerbs(chain, dc, ModeDelegated) & VerbR) | (g & VerbsRC) got = (EffectiveVerbs(chain, dc) & VerbR) | (g & VerbsRC)
} else { } else {
got = EffectiveVerbs(chain, dc, ModeDelegated) got = EffectiveVerbs(chain, dc)
} }
if got.String() != want { if got.String() != want {
t.Errorf("doc controller verbs at %s = %q, want %q", dir[len(root):], 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. // Alice (team member) inside her own home → rwcda.
chain, _ := EffectivePolicy(root, homeDir) 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()) t.Errorf("alice in own home = %q, want rwcda", got.String())
} }
// Bob (team member) inside Alice's fenced home → nothing (fence // Bob (team member) inside Alice's fenced home → nothing (fence
// blocks the project-level project_team:r; bob isn't named in the // blocks the project-level project_team:r; bob isn't named in the
// fenced .zddc). // 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()) 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. // Alice elsewhere in the project (not her home, not WORM) → r.
chain2, _ := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme")) 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()) t.Errorf("alice in archive/Acme = %q, want r", got.String())
} }
// Alice CANNOT write to incoming/ — that's the counterparty's drop // Alice CANNOT write to incoming/ — that's the counterparty's drop
// zone, QC'd by the document controller. project_team gets read only. // zone, QC'd by the document controller. project_team gets read only.
chain3, _ := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme", "incoming")) 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()) 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) for principal := range zf.ACL.Permissions {
check("acl.deny", zf.ACL.Deny) if err := ValidatePattern(principal); err != nil {
errs = append(errs, FieldError{
Field: fmt.Sprintf("acl.permissions[%q]", principal),
Message: err.Error(),
})
}
}
check("admins", zf.Admins) check("admins", zf.Admins)
if len(zf.Title) > 200 { if len(zf.Title) > 200 {
errs = append(errs, FieldError{ errs = append(errs, FieldError{

View file

@ -38,8 +38,12 @@ func TestValidatePattern(t *testing.T) {
func TestValidateFile(t *testing.T) { func TestValidateFile(t *testing.T) {
zf := ZddcFile{ zf := ZddcFile{
Title: "ok", Title: "ok",
ACL: ACLRules{Allow: []string{"good@example.com", "@bad"}, Deny: []string{"two@@ats"}}, ACL: ACLRules{Permissions: map[string]string{
"good@example.com": "rwcd",
"@bad": "rwcd",
"two@@ats": "",
}},
Admins: []string{"@nobody"}, Admins: []string{"@nobody"},
} }
errs := ValidateFile(zf) errs := ValidateFile(zf)
@ -48,9 +52,9 @@ func TestValidateFile(t *testing.T) {
t.Fatalf("got %d errors, want 3: %+v", len(errs), errs) t.Fatalf("got %d errors, want 3: %+v", len(errs), errs)
} }
wantFields := map[string]bool{ wantFields := map[string]bool{
"acl.allow[1]": false, "acl.permissions[\"@bad\"]": false,
"acl.deny[0]": false, "acl.permissions[\"two@@ats\"]": false,
"admins[0]": false, "admins[0]": false,
} }
for _, e := range errs { for _, e := range errs {
if _, ok := wantFields[e.Field]; !ok { 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.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
out.Admins = mergeStringSlice(out.Admins, top.Admins) 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 { if top.ACL.Inherit != nil {
out.ACL.Inherit = top.ACL.Inherit 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 // 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: // via the worm: grant; r survives via the normal ACL or the worm:
// grant. Admins are excluded upstream (handler's IsAdmin bypass). // 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++ { for i := 0; i < len(chain.Levels); i++ {
wl := chain.Levels[i].Worm wl := chain.Levels[i].Worm
if wl == nil { if wl == nil {
@ -37,7 +37,7 @@ func WormZoneGrant(chain PolicyChain, email string, mode CascadeMode) (grant Ver
} }
inWorm = true inWorm = true
for _, principal := range wl { 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 grant |= VerbsRC // listed controllers get read + write-once-create
} }
} }

View file

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

View file

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