Compare commits

..

No commits in common. "41dff23127722e7ecdf8b009b02405a7abfda3fd" and "821ed3ee192f442e93d7d0a2e5e59565e3098d6b" have entirely different histories.

15 changed files with 100 additions and 830 deletions

View file

@ -251,15 +251,9 @@ for a level whose `acl.permissions` map matches the user.
- No `.zddc` anywhere (`HasAnyFile=false`) → **allow** (the empty-tree default).
- At least one `.zddc` existed (`HasAnyFile=true`) → **403 Forbidden** (default-deny).
The walk respects an **inherit fence** (see "The `inherit:` directive" below).
A level whose `acl.inherit: false` flag is set acts as a fence: ancestors above
it are invisible to descendants at-and-below the fence, both for grants and for
role lookups. In strict cascade mode the fence is ignored (NIST AC-6 invariant).
Implementation: `GrantedVerbsAtLevel` (`zddc/internal/zddc/acl.go`) computes the
per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain
itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`); the
fence is computed by `PolicyChain.VisibleStart`.
itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`).
#### Cascade mode
@ -278,56 +272,6 @@ root-admin involvement. Federal deployments needing absolute parent denies
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
A `.zddc` may declare `acl.inherit: false` to fence off all ancestor grants
and roles from the descendant subtree. Useful for the "complete reset, then
add back specific principals" pattern — vendor folders, regulated subtrees,
anywhere a permissive ancestor rule is too broad and the operator wants
explicit local control.
```yaml
# <ZDDC_ROOT>/Vendor/.zddc — vendor folder, fully reset
acl:
inherit: false # ancestors above this level become invisible
permissions:
"*@vendor.com": rwcd # the vendor and the doc controller are
_doc_controller: rwcda # the only principals with access here
```
Behaviour:
- **Grants:** the cascade walker (leaf → root) stops at the deepest
`inherit: false` level when looking for a matching grant. Ancestor
`permissions` entries don't contribute. The default-deny rule still
applies if no descendant grant matches.
- **Roles:** role definitions in ancestors above the fence are also
invisible. To use a role inside a fenced subtree, redefine it locally
(a redefinition at-or-below the fence is fine).
- **Admins:** the root `admins:` list is unaffected. Root admins still
bypass all ACL evaluation, fence or no fence — that's the deliberate
escape hatch for misfiled documents.
- **WORM:** the `archive/<party>/issued|received/` mask is path-based,
not cascade-based. `inherit:` does not change WORM behaviour.
**Strict cascade mode IGNORES `inherit: false`.** NIST AC-6 requires
ancestor explicit-denies to be absolute, and the inherit directive
would let a leaf widen access an ancestor refused. Under
`--cascade-mode=strict` the directive has no effect (and the bundled
federal Rego at `--print-rego=federal` mirrors that rule). Operators
who need fence-style "reset" semantics in a federal-track deployment
should not use the directive — instead, restructure the tree so the
permissive ancestor rule never appears.
The cascade tracer (`/.profile/effective-policy`) surfaces every
level's `inherit` flag and the `chain.visible_start` index so a
reviewer can see exactly where the fence sits and whether it's in
effect under the active mode.
Implementation: parser (`zddc/internal/zddc/file.go`),
`PolicyChain.VisibleStart` (`zddc/internal/zddc/cascade.go`), and the
fence-aware role walk (`zddc/internal/zddc/roles.go`).
#### Special folders
Five folder names trigger built-in behaviors regardless of cascade mode (canonical list in `zddc/internal/zddc/special.go`):
@ -457,21 +401,14 @@ naive intuition suggests.
1. **Same-level `allow + deny "*@company.com"` does NOT lock the level down to the
allow's targets.** Deny is checked before allow within a single `.zddc`, so the
allowed user's email matches the deny first and is blocked. To exclude insiders
from a leaf, use the `inherit: false` directive (preferred — see "The
`inherit:` directive" above), or the two-level gate-and-reallow pattern, or
avoid putting `*@company.com` at any ancestor in the first place.
from a leaf, use the two-level gate-and-reallow (parent denies, deeper level
re-allows) — *or* avoid putting `*@company.com` at any ancestor.
```yaml
# /Closed/.zddc — DOES NOT WORK as intended
acl:
allow: [alice@company.com]
deny: ["*@company.com"] # blocks alice too — deny is checked first at same level
# /Closed/.zddc — works correctly
acl:
inherit: false # ancestor "*@company.com" rule is invisible here
permissions:
alice@company.com: rwcd
```
2. **A leaf-level `allow: [subset]` does NOT restrict when a parent has

View file

@ -447,16 +447,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// Split path into segments
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
// Per-directory .zddc editor: <dir>/.zddc.html is a virtual URL
// served by the existing form-based editor (same handler that
// powers /.profile/zddc/edit?path=<dir>). Routed BEFORE the
// dot-prefix guard so the leaf segment isn't 404'd. The handler
// itself gates on hasAnyAdminScope; non-admins see 404.
if handler.IsZddcEditorRequest(urlPath) {
handler.ServeZddcEditorAtPath(cfg, w, r)
return
}
// Reserve dot-prefixed path segments. The listing pipeline already hides
// hidden entries (internal/listing/listing.go:17, projectshandler.go:40),
// but direct URL access would still serve them. 404 here so hidden trees
@ -539,6 +529,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
// MDL convenience redirect: GET archive/<party>/mdl/ → mdl.table.html.
// The table app is the canonical view for the per-party Master
// Deliverables List. Direct navigation to the data folder lands on
// the grid editor; clients that want the raw row listing can still
// hit archive/<party>/mdl/ via the file API or with an explicit
// trailing-segment beyond mdl/.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && len(segments) == 4 {
if strings.EqualFold(segments[1], "archive") && strings.EqualFold(segments[3], "mdl") {
target := "/" + segments[0] + "/" + segments[1] + "/" + segments[2] + "/mdl.table.html"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusFound)
return
}
}
// Tables-system intercept: *.table.html is a virtual URL that the
// table handler renders inline, reading rows from a directory of
// *.yaml files declared in the directory's .zddc tables: map.
@ -673,37 +680,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
}
// URL convention: trailing slash → browse (handled by
// ServeDirectory, which serves browse.html for HTML requests
// and JSON for application/json). No trailing slash → the
// canonical default tool for this directory's context, if any
// (mdedit under working/, transmittal under staging/, archive
// under archive/, tables under archive/<party>/mdl/). When no
// default applies, fall back to the historical redirect-to-
// trailing-slash behaviour.
if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot {
switch apps.DefaultAppAt(cfg.Root, absPath) {
case "tables":
// Tables aren't an apps-subsystem app — the table
// handler responds to <dir>/<name>.table.html. Serve
// the equivalent table view inline at the bare-mdl
// URL by routing through the handler with the
// canonical .table.html name appended.
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+".table.html"); tr != nil {
handler.ServeTable(cfg, tr, w, r)
return
}
case "archive", "transmittal", "mdedit":
if appsSrv != nil {
app := apps.DefaultAppAt(cfg.Root, absPath)
if apps.AppAvailableAt(cfg.Root, absPath, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
appsSrv.Serve(w, r, app, chain, absPath)
return
}
}
}
}
if !strings.HasSuffix(urlPath, "/") {
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
return

View file

@ -1,7 +1,6 @@
package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
@ -374,26 +373,11 @@ func TestDispatchArchiveRedirect(t *testing.T) {
}
}
func TestDispatchSlashRouting(t *testing.T) {
// Convention: <dir>/ → browse (directory view); <dir> → the canonical
// default tool for the directory (mdedit under working/, transmittal
// under staging/, archive under archive/, tables under
// archive/<party>/mdl/). Without a default app, no-slash falls
// through to the legacy 301-to-trailing-slash redirect.
func TestDispatchMdlRedirect(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, ".zddc"),
"acl:\n permissions:\n \"*\": rwcda\n")
for _, sub := range []string{
"Project/working",
"Project/staging",
"Project/archive",
"Project/archive/Acme",
"Project/archive/Acme/incoming",
"Project/archive/Acme/mdl",
"Project/scratch",
} {
mustMkdir(t, filepath.Join(root, sub))
}
"acl:\n permissions:\n \"*@example.com\": rwcda\n")
mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme"))
idx, err := archive.BuildIndex(root)
if err != nil {
@ -405,46 +389,58 @@ func TestDispatchSlashRouting(t *testing.T) {
EmailHeader: "X-Auth-Request-Email",
}
ring := handler.NewLogRing(10)
appsSrv, err := setupApps(cfg)
if err != nil {
t.Fatalf("setupApps: %v", err)
}
cases := []struct {
name string
path string
wantStatus int
wantNoRedirect bool
name string
path string
wantStatus int
wantLoc string
}{
{"working no-slash → mdedit", "/Project/working", http.StatusOK, true},
{"working slash → browse", "/Project/working/", http.StatusOK, true},
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true},
{"staging slash → browse", "/Project/staging/", http.StatusOK, true},
{"archive no-slash → archive", "/Project/archive", http.StatusOK, true},
{"archive slash → browse", "/Project/archive/", http.StatusOK, true},
{"archive/<party> no-slash → archive", "/Project/archive/Acme", http.StatusOK, true},
{"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true},
{"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true},
{"archive/<party>/mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true},
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true},
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true},
{"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false},
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true},
{"project root no-slash → 301 to slash", "/Project", http.StatusMovedPermanently, false},
{
"mdl trailing slash → table",
"/ProjectA/archive/Acme/mdl/",
http.StatusFound,
"/ProjectA/archive/Acme/mdl.table.html",
},
{
"case-fold MDL trailing slash",
"/ProjectA/archive/Acme/MDL/",
http.StatusFound,
"/ProjectA/archive/Acme/mdl.table.html",
},
{
"case-fold ARCHIVE",
"/ProjectA/Archive/Acme/mdl/",
http.StatusFound,
"/ProjectA/Archive/Acme/mdl.table.html",
},
{
"deeper than party-level mdl is NOT redirected",
"/ProjectA/archive/Acme/incoming/mdl/",
// Falls through to static-file pipeline; no folder exists there → 404.
http.StatusNotFound,
"",
},
{
"working/mdl is NOT redirected (not under archive)",
"/ProjectA/working/mdl/",
http.StatusNotFound,
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, rec, req)
dispatch(cfg, idx, ring, 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())
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantNoRedirect && rec.Code >= 300 && rec.Code < 400 {
t.Errorf("path=%q unexpected redirect to %q",
tc.path, rec.Header().Get("Location"))
if tc.wantLoc != "" {
if got := rec.Header().Get("Location"); got != tc.wantLoc {
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
}
}
})
}
@ -585,87 +581,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, rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("path=%q status=%d, want %d; body=%s",
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantSubstr != "" && !strings.Contains(rec.Body.String(), tc.wantSubstr) {
t.Errorf("path=%q body missing %q", tc.path, tc.wantSubstr)
}
})
}
}

View file

@ -68,56 +68,3 @@ func inAncestorWithName(root, requestDir string, names ...string) bool {
}
return false
}
// DefaultAppAt returns the canonical default tool name for requestDir,
// or "" if no specific tool fits. Used by the dispatcher to decide
// which app to serve at a directory URL with no trailing slash —
// trailing-slash URLs serve the browse app for any directory.
//
// Rules (case-insensitive on canonical folder names):
//
// - <project>/archive/<party>/mdl/... → "tables"
// - <project>/archive/ → "archive"
// - <project>/archive/<party>/... → "archive"
// - <project>/staging/... → "transmittal"
// - <project>/working/... → "mdedit"
// - any other directory → "" (no default)
//
// The mdl rule wins over the broader archive rule because the table
// editor is a more specific surface for browsing planned deliverables
// than the archive index.
//
// requestDir and root are absolute filesystem paths; requestDir must
// be under root (otherwise "" is returned).
func DefaultAppAt(root, requestDir string) string {
root = filepath.Clean(root)
requestDir = filepath.Clean(requestDir)
if requestDir == root {
return ""
}
rel, err := filepath.Rel(root, requestDir)
if err != nil || strings.HasPrefix(rel, "..") {
return ""
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) < 2 {
// Project root itself — no default tool.
return ""
}
// The project segment is parts[0]; canonical folder is parts[1].
canonical := strings.ToLower(parts[1])
switch canonical {
case "archive":
// Inside archive/. Check for the mdl sub-case at depth 4
// (parts: project, archive, party, mdl).
if len(parts) >= 4 && strings.EqualFold(parts[3], "mdl") {
return "tables"
}
return "archive"
case "staging":
return "transmittal"
case "working":
return "mdedit"
}
return ""
}

View file

@ -67,49 +67,3 @@ func TestAppAvailableAt(t *testing.T) {
})
}
}
func TestDefaultAppAt(t *testing.T) {
root := "/srv/zddc"
cases := []struct {
dir string
want string
}{
// At the deployment root itself, no default tool — landing handles
// the project picker via a separate path.
{root, ""},
// Bare project root: no default. Trailing-slash URL serves browse;
// no-slash falls through to the redirect.
{root + "/Project-A", ""},
// Canonical project-root folders.
{root + "/Project-A/working", "mdedit"},
{root + "/Project-A/working/alice@example.com", "mdedit"},
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"},
{root + "/Project-A/staging", "transmittal"},
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
// archive: at the archive root, party folders, and per-party
// subfolders (incoming/received/issued).
{root + "/Project-A/archive", "archive"},
{root + "/Project-A/archive/Acme", "archive"},
{root + "/Project-A/archive/Acme/incoming", "archive"},
{root + "/Project-A/archive/Acme/issued", "archive"},
{root + "/Project-A/archive/Acme/received", "archive"},
// mdl wins over the broader archive rule.
{root + "/Project-A/archive/Acme/mdl", "tables"},
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
// reviewing/ is virtual — no default tool wired here yet.
{root + "/Project-A/reviewing", ""},
// Random non-canonical folder names → no default.
{root + "/Project-A/scratch", ""},
// Case-fold on canonical names.
{root + "/Project-A/Working", "mdedit"},
{root + "/Project-A/STAGING", "transmittal"},
{root + "/Project-A/Archive/Acme/MDL", "tables"},
}
for _, tc := range cases {
t.Run(tc.dir, func(t *testing.T) {
if got := DefaultAppAt(root, tc.dir); got != tc.want {
t.Errorf("DefaultAppAt(%q) = %q, want %q", tc.dir, got, tc.want)
}
})
}
}

View file

@ -371,10 +371,6 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
Admins []string `json:"admins,omitempty"`
AnyMatch bool `json:"matches_email"`
Decision string `json:"decision_at_level"`
// Inherit is the level's explicit inherit setting if present
// (nil for absent — defaults to "inherit normally"). When
// false, this level fences ancestors above it from descendants.
Inherit *bool `json:"inherit,omitempty"`
}
// Build the per-level breakdown by walking the chain levels in
@ -388,26 +384,17 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
Email string `json:"email"`
Decision bool `json:"decision"`
DeciderKind string `json:"decider_kind"`
CascadeMode string `json:"cascade_mode"`
Chain struct {
HasAnyFile bool `json:"has_any_file"`
// VisibleStart is the lowest chain index whose grants are
// visible to evaluation at the leaf, accounting for any
// inherit:false fence in delegated mode. In strict mode it
// is always 0 (fences are ignored under AC-6).
VisibleStart int `json:"visible_start"`
Levels []levelView `json:"levels"`
Levels []levelView `json:"levels"`
} `json:"chain"`
}{
Path: probePath,
Email: probeEmail,
Decision: allow,
DeciderKind: deciderKind(decider),
CascadeMode: cfg.CascadeMode,
}
out.Chain.HasAnyFile = chain.HasAnyFile
mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode)
out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels)-1, mode)
// Reconstruct level paths from cfg.Root. This mirrors how
// zddc.EffectivePolicy builds the chain (see cascade.go).
@ -438,8 +425,7 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
entry := levelView{
Index: i,
ZddcPath: lp,
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0 || len(lvl.ACL.Permissions) > 0 || lvl.ACL.Inherit != nil,
Inherit: lvl.ACL.Inherit,
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0,
}
if entry.Exists {
entry.Acl = &lvl.ACL

View file

@ -564,75 +564,6 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
}
}
// TestServeProfileEffectivePolicy_InheritFence: a child .zddc with
// inherit:false fences ancestor grants. The tracer surfaces both the
// per-level inherit flag and the chain-level visible_start so an
// operator can see why ancestor grants don't apply at the leaf.
func TestServeProfileEffectivePolicy_InheritFence(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"super@admin.com"})
// Vendor-folder pattern: root grants everyone-at-mycompany rwcd;
// the vendor folder fences and only allows the vendor. Preserve
// the admins: list so the test admin can hit the tracer.
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
[]byte("admins:\n - super@admin.com\nacl:\n permissions:\n \"*@mycompany.com\": rwcd\n"),
0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
if err := os.MkdirAll(filepath.Join(cfg.Root, "Vendor"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(cfg.Root, "Vendor", ".zddc"),
[]byte("acl:\n inherit: false\n permissions:\n \"*@vendor.com\": rwcd\n"),
0o644); err != nil {
t.Fatalf("write vendor .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
type respShape struct {
Decision bool `json:"decision"`
CascadeMode string `json:"cascade_mode"`
Chain struct {
VisibleStart int `json:"visible_start"`
Levels []struct {
Index int `json:"index"`
Inherit *bool `json:"inherit,omitempty"`
} `json:"levels"`
} `json:"chain"`
}
// Trace a my-company user — fenced out at the leaf, despite root grant.
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec,
requestWithEmail(http.MethodGet,
"/.profile/effective-policy?path=/Vendor/&email=alice@mycompany.com",
"super@admin.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var got respShape
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Decision {
t.Errorf("alice should be fenced out; decision = true")
}
if got.Chain.VisibleStart != 1 {
t.Errorf("VisibleStart = %d, want 1 (fence at /Vendor/)", got.Chain.VisibleStart)
}
if len(got.Chain.Levels) != 2 {
t.Fatalf("expected 2 levels; got %d", len(got.Chain.Levels))
}
leaf := got.Chain.Levels[1]
if leaf.Inherit == nil || *leaf.Inherit != false {
t.Errorf("leaf.Inherit should be explicit false; got %v", leaf.Inherit)
}
root := got.Chain.Levels[0]
if root.Inherit != nil {
t.Errorf("root.Inherit should be unset; got %v", root.Inherit)
}
}
func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// .zddc exists but has no admins list — page is still reachable,
// but the admin/super-admin sections are absent.

View file

@ -5,7 +5,6 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
@ -47,85 +46,13 @@ func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request)
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
email := EmailFromContext(r)
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
if err != nil {
http.NotFound(w, r)
return
}
renderZddcEditor(cfg, w, r, abs)
}
// ServeZddcEditorAtPath is the per-directory entry to the editor. The
// dispatcher routes <dir>/.zddc.html requests here; the directory is
// derived from the URL path (parent of the .zddc.html leaf) rather
// than from a query parameter.
//
// Permission gate: the user must have an admin authority somewhere
// in the tree (same gate as the /.profile/zddc namespace). A non-
// admin sees 404 — no leak that an editor would otherwise be
// available. Within the editor, CanEditZddc decides whether the form
// is interactive or read-only at THIS specific .zddc; non-editors
// can still inspect the cascade if they have any admin scope.
func ServeZddcEditorAtPath(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
email := EmailFromContext(r)
if !hasAnyAdminScope(cfg.Root, email) {
http.NotFound(w, r)
return
}
// URL is <dir>/.zddc.html (or "/.zddc.html" for the deployment
// root). Strip the leaf to get the directory.
urlPath := strings.TrimSuffix(r.URL.Path, "/")
leafPath := "/" + ZddcEditorBasename
if !strings.HasSuffix(urlPath, leafPath) {
http.NotFound(w, r)
return
}
dirURL := strings.TrimSuffix(urlPath, leafPath)
if dirURL == "" {
dirURL = "/"
}
abs, err := resolvePath(cfg.Root, dirURL)
if err != nil {
http.NotFound(w, r)
return
}
// The directory must exist on disk; the per-path editor URL is a
// view onto an existing tree position, not a way to materialise
// arbitrary new directories. (The /.profile editor accepts a
// missing dir for the legacy path-as-query workflow.)
if info, err := os.Stat(abs); err != nil || !info.IsDir() {
http.NotFound(w, r)
return
}
renderZddcEditor(cfg, w, r, abs)
}
// ZddcEditorBasename is the URL leaf that the dispatcher recognises as
// a per-directory editor request. The dot-prefix guard carves this one
// segment out so the editor reaches the handler.
const ZddcEditorBasename = ".zddc.html"
// IsZddcEditorRequest reports whether urlPath ends with the editor's
// virtual basename. Used by the dispatcher to route the request to
// ServeZddcEditorAtPath ahead of the dot-prefix guard.
func IsZddcEditorRequest(urlPath string) bool {
clean := strings.TrimSuffix(urlPath, "/")
return strings.HasSuffix(clean, "/"+ZddcEditorBasename) ||
clean == "/"+ZddcEditorBasename
}
// renderZddcEditor renders the editor template against the .zddc at
// abs (which may not exist on disk yet). Shared between the
// /.profile/zddc/edit?path= entry and the per-directory <dir>/.zddc.html
// entry.
func renderZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request, abs string) {
email := EmailFromContext(r)
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
if err != nil {
http.Error(w, "Cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)

View file

@ -3,19 +3,11 @@ package zddc
import "strings"
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for
// callers that only need the legacy boolean read decision on a single
// ZddcFile (no cascade chain).
//
// Hardcodes ModeDelegated — safe because the synthetic chain has only
// one level and no ancestors to fence — but callers that operate on a
// real PolicyChain must call GrantedVerbsAtLevel directly with the
// active mode.
//
// Deprecated: prefer GrantedVerbsAtLevel for any code path that may
// later need fence-aware or strict-mode evaluation.
// callers that only need the legacy boolean read decision. New code
// should call GrantedVerbsAtLevel directly.
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
v, m := GrantedVerbsAtLevel(chain, 0, email, ModeDelegated)
v, m := GrantedVerbsAtLevel(chain, 0, email)
if !m {
return false, false
}
@ -29,13 +21,12 @@ func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool)
// - matched=true, set!={} → union of verb sets from every matching entry
//
// Role lookups for principal keys without "@" use RoleMembers, which
// walks levelIdx → fence-or-root for the closest definition. mode
// controls whether inherit:false fences are honored — see VisibleStart.
// walks levelIdx → root for the closest definition.
//
// 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) {
func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return 0, false
}
@ -49,7 +40,7 @@ func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string, mode Cas
deniedExplicit := false
var grant VerbSet
for principal, verbStr := range perms {
if !MatchesPrincipal(principal, email, chain, levelIdx, mode) {
if !MatchesPrincipal(principal, email, chain, levelIdx) {
continue
}
matched = true
@ -150,22 +141,16 @@ func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mo
// caller has another range to combine with).
return 0
}
// Honor inherit:false fences — clamp fromIdx upward to the deepest
// fence visible from the leaf end of the range. In strict mode the
// fence helper returns 0 unconditionally, so this is a no-op.
if fence := chain.VisibleStart(toIdx-1, mode); fence > fromIdx {
fromIdx = fence
}
if mode == ModeStrict {
for i := fromIdx; i < toIdx; i++ {
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
grant, matched := GrantedVerbsAtLevel(chain, i, email)
if matched && grant == 0 {
return 0
}
}
}
for i := toIdx - 1; i >= fromIdx; i-- {
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
grant, matched := GrantedVerbsAtLevel(chain, i, email)
if !matched {
continue
}

View file

@ -13,36 +13,6 @@ type PolicyChain struct {
HasAnyFile bool // true if at least one .zddc file exists in the chain
}
// VisibleStart returns the lowest level index visible to evaluation at
// any level in [0, toIdx], honoring inherit:false fences. A level with
// `acl.inherit: false` is a fence: ancestors above it are invisible to
// descendants at-and-below the fence. The deepest fence in the prefix
// wins (nested fences are supported; the closer-to-leaf wins).
//
// In strict cascade mode, fences are ignored — returns 0 — because
// federal/AC-6 deployments require ancestor explicit-denies to be
// absolute, and the inherit directive would let a leaf widen access an
// ancestor refused.
//
// toIdx is clamped to len(chain.Levels)-1.
func (chain PolicyChain) VisibleStart(toIdx int, mode CascadeMode) int {
if mode == ModeStrict {
return 0
}
if toIdx >= len(chain.Levels) {
toIdx = len(chain.Levels) - 1
}
if toIdx < 0 {
return 0
}
for i := toIdx; i >= 0; i-- {
if !chain.Levels[i].ACL.InheritsAncestors() {
return i
}
}
return 0
}
// policyCache caches effective policies keyed by dirPath.
// Values are PolicyChain.
var policyCache sync.Map

View file

@ -27,29 +27,6 @@ import (
// (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
// its ancestors. The default (when the field is absent — represented
// here as a nil pointer) is "inherit normally." Setting `inherit: false`
// makes this level a fence: grants and roles defined in any ancestor
// .zddc are invisible at-and-below this point in the cascade. Useful
// for "complete reset, then add back specific principals" patterns
// (e.g. a vendor folder where only the vendor and the doc controller
// should have access regardless of broader project-level grants).
//
// In strict cascade mode (federal / NIST AC-6), inherit:false is
// REFUSED — a leaf-level directive cannot widen access an ancestor
// refused. The internal decider silently treats it as inherit:true;
// the cascade tracer (/.profile/effective-policy) reports both
// `cascade_mode` and `chain.visible_start` so an operator can see
// that a configured fence is being ignored under the active mode.
// Operators running the federal Rego preset get the same behaviour
// from policy enforcement.
//
// Inherit is per-level and not itself cascading: an ancestor's
// `inherit: false` does not transitively block descendants from
// adding their own grants — it only fences off ANCESTORS of the
// fenced level from the descendant subtree.
//
// JSON tags are present so this type round-trips cleanly when included
// in the external-OPA input body (see internal/policy). The canonical
// in-repo serialization is YAML; JSON is only used for OPA queries.
@ -57,17 +34,6 @@ type ACLRules struct {
Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
Deny []string `yaml:"deny,omitempty" json:"deny,omitempty"`
Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
// Inherit *bool: nil = unset (inherit normally), &true = same,
// &false = fence ancestors. Using a pointer so the default is
// distinguishable from an explicit `inherit: true`.
Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`
}
// InheritsAncestors reports whether this level imports grants and
// roles from ancestors. True when Inherit is unset or explicitly true;
// false only when explicitly set to false.
func (r ACLRules) InheritsAncestors() bool {
return r.Inherit == nil || *r.Inherit
}
// Role is the named principal-grouping primitive. Members are email

View file

@ -74,63 +74,3 @@ acl:
t.Errorf("Tables = %+v want nil for absent tables: key", zf.Tables)
}
}
// Inherit defaults to "inherit normally" when the field is absent;
// explicit true behaves the same; explicit false marks the level as
// a fence.
func TestParseFile_InheritDirective(t *testing.T) {
cases := []struct {
name string
body string
wantPtrNil bool
wantInherit bool
}{
{
name: "absent → nil pointer, inherits",
body: `acl:
permissions:
"*@example.com": r
`,
wantPtrNil: true,
wantInherit: true,
},
{
name: "explicit true → non-nil, inherits",
body: `acl:
inherit: true
permissions:
"*@example.com": r
`,
wantPtrNil: false,
wantInherit: true,
},
{
name: "explicit false → non-nil, fences",
body: `acl:
inherit: false
permissions:
"*@vendor.com": rwcd
`,
wantPtrNil: false,
wantInherit: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), ".zddc")
if err := os.WriteFile(path, []byte(tc.body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
zf, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if (zf.ACL.Inherit == nil) != tc.wantPtrNil {
t.Errorf("Inherit pointer nil=%v want %v", zf.ACL.Inherit == nil, tc.wantPtrNil)
}
if got := zf.ACL.InheritsAncestors(); got != tc.wantInherit {
t.Errorf("InheritsAncestors() = %v want %v", got, tc.wantInherit)
}
})
}
}

View file

@ -1,152 +0,0 @@
package zddc
import "testing"
// helper: build a chain from levels (root-to-leaf), HasAnyFile=true.
func buildChain(levels ...ZddcFile) PolicyChain {
return PolicyChain{Levels: levels, HasAnyFile: true}
}
// helper: ACL with a permissions map and an explicit inherit setting.
func aclFenced(perms map[string]string, inherit bool) ACLRules {
return ACLRules{Permissions: perms, Inherit: &inherit}
}
func aclOpen(perms map[string]string) ACLRules {
return ACLRules{Permissions: perms}
}
func TestVisibleStart_NoFence(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "r"})},
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcd"})},
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcda"})},
)
if got := chain.VisibleStart(2, ModeDelegated); got != 0 {
t.Errorf("no fence: VisibleStart = %d, want 0", got)
}
}
func TestVisibleStart_FenceClampsToFence(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, ModeDelegated); got != 1 {
t.Errorf("fence at 1: VisibleStart(2) = %d, want 1", got)
}
if got := chain.VisibleStart(1, ModeDelegated); got != 1 {
t.Errorf("fence at 1: VisibleStart(1) = %d, want 1", got)
}
// Fence above toIdx is irrelevant.
if got := chain.VisibleStart(0, ModeDelegated); got != 0 {
t.Errorf("fence at 1: VisibleStart(0) = %d, want 0 (fence not yet in scope)", got)
}
}
func TestVisibleStart_NestedFencesDeepestWins(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "r"})},
ZddcFile{ACL: aclFenced(map[string]string{"*@a.com": "r"}, false)},
ZddcFile{ACL: aclFenced(map[string]string{"*@b.com": "rwcd"}, false)},
ZddcFile{ACL: aclOpen(map[string]string{})},
)
if got := chain.VisibleStart(3, ModeDelegated); got != 2 {
t.Errorf("nested fence: deepest wins, got %d want 2", got)
}
}
func TestVisibleStart_StrictModeIgnoresFence(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "r"})},
ZddcFile{ACL: aclFenced(map[string]string{"*@vendor.com": "rwcd"}, false)},
ZddcFile{ACL: aclOpen(map[string]string{})},
)
if got := chain.VisibleStart(2, ModeStrict); got != 0 {
t.Errorf("strict mode must ignore fence: got %d, want 0", got)
}
}
// End-to-end: a fence at the vendor folder hides root-level grants for
// users who don't match the vendor-folder grants.
func TestEffectiveVerbs_FenceHidesAncestorGrants(t *testing.T) {
chain := buildChain(
// Root: everyone-at-example reads everywhere.
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcd"})},
// Vendor folder: deny everyone-at-example, allow vendor explicitly,
// AND fence — so the root grant doesn't sneak through.
ZddcFile{ACL: aclFenced(map[string]string{
"*@vendor.com": "rwcd",
"_doc_controller": "rwcda",
}, false)},
)
// alice@example.com used to inherit root rwcd; with the fence she has
// no grant in the vendor folder → 0 verbs.
if got := EffectiveVerbs(chain, "alice@example.com", ModeDelegated); got != 0 {
t.Errorf("alice should be denied by fence; got %s", got)
}
// rep@vendor.com matches the local rule.
if got := EffectiveVerbs(chain, "rep@vendor.com", ModeDelegated); got != VerbsRWCD {
t.Errorf("vendor should have rwcd; got %s", got)
}
}
// In strict mode the fence is ignored: alice keeps her root grant
// because ancestor grants ARE absolute under AC-6 / strict cascade.
func TestEffectiveVerbs_StrictModeKeepsAncestorGrants(t *testing.T) {
chain := buildChain(
ZddcFile{ACL: aclOpen(map[string]string{"*@example.com": "rwcd"})},
ZddcFile{ACL: aclFenced(map[string]string{
"*@vendor.com": "rwcd",
}, false)},
)
// In strict mode, alice's root rwcd is visible — fence ignored.
// She doesn't match anything in the vendor folder, so leaf walk
// continues to root, finds rwcd, and returns it.
if got := EffectiveVerbs(chain, "alice@example.com", ModeStrict); got != VerbsRWCD {
t.Errorf("strict mode: alice should retain root rwcd; got %s", got)
}
}
// Roles defined above the fence are invisible to descendants — operators
// who fence must redefine roles locally if they want to use them.
func TestRoleMembers_FenceHidesAncestorRoles(t *testing.T) {
rootLevel := ZddcFile{
Roles: map[string]Role{"_doc_controller": {Members: []string{"dc@example.com"}}},
ACL: aclOpen(map[string]string{"*@example.com": "r"}),
}
fencedLevel := ZddcFile{
ACL: aclFenced(map[string]string{"*@vendor.com": "rwcd"}, false),
}
chain := buildChain(rootLevel, fencedLevel)
// Below the fence, the role from root is invisible.
if got := RoleMembers(chain, 1, "_doc_controller", ModeDelegated); got != nil {
t.Errorf("role above fence should be invisible; got %v", got)
}
// In strict mode, the fence is ignored and the role is visible.
if got := RoleMembers(chain, 1, "_doc_controller", ModeStrict); len(got) != 1 || got[0] != "dc@example.com" {
t.Errorf("strict mode: role should be visible; got %v", got)
}
}
// A role redefined locally below the fence shadows correctly because
// the redefinition is at-or-below the fence (visible).
func TestRoleMembers_LocalRedefinitionWorks(t *testing.T) {
chain := buildChain(
ZddcFile{
Roles: map[string]Role{"_doc_controller": {Members: []string{"dc@example.com"}}},
},
ZddcFile{
ACL: aclFenced(map[string]string{"_doc_controller": "rwcda"}, false),
Roles: map[string]Role{"_doc_controller": {Members: []string{"vendor-dc@example.com"}}},
},
)
got := RoleMembers(chain, 1, "_doc_controller", ModeDelegated)
if len(got) != 1 || got[0] != "vendor-dc@example.com" {
t.Errorf("local redefinition should win; got %v", got)
}
}

View file

@ -100,22 +100,17 @@ func IsPrincipalRole(principal string) bool {
}
// RoleMembers returns the member-pattern list for roleName as visible
// at chain.Levels[levelIdx]. Lookup walks levelIdx → fence-or-root and
// returns the first definition found (closer-to-leaf wins). The lower
// bound is determined by chain.VisibleStart(levelIdx, mode): in
// delegated mode, an inherit:false fence at-or-below levelIdx hides
// any role definitions in levels above it; in strict mode the full
// chain is visible. Returns nil if no level in the visible chain
// defines the role.
// at chain.Levels[levelIdx]. Lookup walks levelIdx → root and returns
// the first definition found (closer-to-leaf wins). Returns nil if no
// level in the visible chain defines the role.
//
// Levels are stored root (index 0) → leaf (last index), matching the
// EffectivePolicy convention.
func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) []string {
func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil
}
floor := chain.VisibleStart(levelIdx, mode)
for i := levelIdx; i >= floor; i-- {
for i := levelIdx; i >= 0; i-- {
role, ok := chain.Levels[i].Roles[roleName]
if !ok {
continue
@ -126,24 +121,23 @@ func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeM
}
// MatchesPrincipal reports whether email satisfies the given Permissions
// key at chain.Levels[levelIdx]. mode controls whether inherit:false
// fences truncate the visible chain when resolving role definitions.
// key at chain.Levels[levelIdx].
//
// Resolution order:
//
// 1. Principals containing "@" are always email patterns; dispatch to
// MatchesPattern.
// 2. Principals without "@" are role-or-pattern. Look up the name in
// the cascade's roles, honoring fences. If a role definition is
// found in the visible chain, match the user against the role's
// members. If no role definition exists in the visible chain, fall
// back to MatchesPattern. The fallback preserves legacy patterns
// like "*" or "*example.com" that pre-date the roles feature.
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int, mode CascadeMode) bool {
// the cascade's roles. If a role definition is found, match the
// user against the role's members. If no role definition exists
// anywhere in the cascade, fall back to MatchesPattern. The
// fallback preserves legacy patterns like "*" or "*example.com"
// that pre-date the roles feature.
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int) bool {
if !IsPrincipalRole(principal) {
return MatchesPattern(principal, email)
}
members, defined := lookupRoleMembers(chain, levelIdx, principal, mode)
members, defined := lookupRoleMembers(chain, levelIdx, principal)
if !defined {
// Legacy pattern compatibility — bare wildcards / unqualified
// strings continue to match via the email-pattern matcher.
@ -160,14 +154,12 @@ func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int,
// lookupRoleMembers returns the member list and whether the role was
// defined anywhere in the visible chain. Distinguishes "role exists
// but is empty" (defined=true, empty members) from "role not defined"
// (defined=false), which the principal-fallback logic depends on. The
// visible-chain bound is determined by chain.VisibleStart(levelIdx, mode).
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) ([]string, bool) {
// (defined=false), which the principal-fallback logic depends on.
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]string, bool) {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil, false
}
floor := chain.VisibleStart(levelIdx, mode)
for i := levelIdx; i >= floor; i-- {
for i := levelIdx; i >= 0; i-- {
role, ok := chain.Levels[i].Roles[roleName]
if !ok {
continue
@ -179,9 +171,8 @@ func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string, mode Ca
// MatchingPrincipals returns the keys of level.ACL.Permissions whose
// principal matches email at chain.Levels[levelIdx]. Output is sorted
// for stable iteration in tests and audit logs. mode is forwarded to
// MatchesPrincipal for fence-aware role resolution.
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string, mode CascadeMode) []string {
// for stable iteration in tests and audit logs.
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string {
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
return nil
}
@ -191,7 +182,7 @@ func MatchingPrincipals(chain PolicyChain, levelIdx int, email string, mode Casc
}
var out []string
for principal := range level.ACL.Permissions {
if MatchesPrincipal(principal, email, chain, levelIdx, mode) {
if MatchesPrincipal(principal, email, chain, levelIdx) {
out = append(out, principal)
}
}

View file

@ -78,12 +78,12 @@ func TestRoleMembersClosestLeafWins(t *testing.T) {
},
HasAnyFile: true,
}
got := RoleMembers(chain, 1, "editors", ModeDelegated)
got := RoleMembers(chain, 1, "editors")
if len(got) != 1 || got[0] != "bob@example.com" {
t.Errorf("leaf shadow failed: %v", got)
}
// At root level, only the root definition is visible.
got = RoleMembers(chain, 0, "editors", ModeDelegated)
got = RoleMembers(chain, 0, "editors")
if len(got) != 1 || got[0] != "alice@example.com" {
t.Errorf("root visibility failed: %v", got)
}
@ -96,10 +96,10 @@ func TestMatchesPrincipalLegacyPatternFallback(t *testing.T) {
Levels: []ZddcFile{{}},
HasAnyFile: true,
}
if !MatchesPrincipal("*", "alice@example.com", chain, 0, ModeDelegated) {
if !MatchesPrincipal("*", "alice@example.com", chain, 0) {
t.Errorf("bare * should match any email via legacy fallback")
}
if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0, ModeDelegated) {
if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0) {
t.Errorf("*example.com should match alice@example.com via legacy fallback")
}
}
@ -115,10 +115,10 @@ func TestMatchesPrincipalRoleNamePrefersRole(t *testing.T) {
}},
HasAnyFile: true,
}
if !MatchesPrincipal("vendor_acme", "rep@acme.com", chain, 0, ModeDelegated) {
if !MatchesPrincipal("vendor_acme", "rep@acme.com", chain, 0) {
t.Errorf("rep@acme.com should match role vendor_acme")
}
if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0, ModeDelegated) {
if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0) {
t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed")
}
}