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:
parent
ae105fde1c
commit
f196205622
54 changed files with 589 additions and 1515 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"} {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"}`
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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{}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue