Compare commits
6 commits
821ed3ee19
...
41dff23127
| Author | SHA1 | Date | |
|---|---|---|---|
| 41dff23127 | |||
| f7958d7b22 | |||
| dc7bf8ab04 | |||
| ee50213e0b | |||
| 8ffbcb90d1 | |||
| 2ccd72fa35 |
15 changed files with 830 additions and 100 deletions
|
|
@ -251,9 +251,15 @@ 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`).
|
||||
itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`); the
|
||||
fence is computed by `PolicyChain.VisibleStart`.
|
||||
|
||||
#### Cascade mode
|
||||
|
||||
|
|
@ -272,6 +278,56 @@ 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`):
|
||||
|
|
@ -401,14 +457,21 @@ 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 two-level gate-and-reallow (parent denies, deeper level
|
||||
re-allows) — *or* avoid putting `*@company.com` at any ancestor.
|
||||
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.
|
||||
|
||||
```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
|
||||
|
|
|
|||
|
|
@ -447,6 +447,16 @@ 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
|
||||
|
|
@ -529,23 +539,6 @@ 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.
|
||||
|
|
@ -680,6 +673,37 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
|
|
@ -373,11 +374,26 @@ func TestDispatchArchiveRedirect(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDispatchMdlRedirect(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.
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"*@example.com\": rwcda\n")
|
||||
mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme"))
|
||||
"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))
|
||||
}
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
|
|
@ -389,58 +405,46 @@ func TestDispatchMdlRedirect(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
|
||||
wantLoc string
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantNoRedirect bool
|
||||
}{
|
||||
{
|
||||
"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,
|
||||
"",
|
||||
},
|
||||
{"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},
|
||||
}
|
||||
|
||||
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, nil, rec, req)
|
||||
dispatch(cfg, idx, ring, appsSrv, 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.wantLoc != "" {
|
||||
if got := rec.Header().Get("Location"); got != tc.wantLoc {
|
||||
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
|
||||
}
|
||||
if tc.wantNoRedirect && rec.Code >= 300 && rec.Code < 400 {
|
||||
t.Errorf("path=%q unexpected redirect to %q",
|
||||
tc.path, rec.Header().Get("Location"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -581,3 +585,87 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,3 +68,56 @@ 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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,3 +67,49 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -371,6 +371,10 @@ 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
|
||||
|
|
@ -384,17 +388,26 @@ 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"`
|
||||
Levels []levelView `json:"levels"`
|
||||
// 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"`
|
||||
} `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).
|
||||
|
|
@ -425,7 +438,8 @@ 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,
|
||||
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,
|
||||
}
|
||||
if entry.Exists {
|
||||
entry.Acl = &lvl.ACL
|
||||
|
|
|
|||
|
|
@ -564,6 +564,75 @@ 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.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
|
|
@ -46,13 +47,85 @@ 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)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,19 @@ package zddc
|
|||
import "strings"
|
||||
|
||||
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for
|
||||
// callers that only need the legacy boolean read decision. New code
|
||||
// should call GrantedVerbsAtLevel directly.
|
||||
// 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.
|
||||
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
|
||||
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
|
||||
v, m := GrantedVerbsAtLevel(chain, 0, email)
|
||||
v, m := GrantedVerbsAtLevel(chain, 0, email, ModeDelegated)
|
||||
if !m {
|
||||
return false, false
|
||||
}
|
||||
|
|
@ -21,12 +29,13 @@ 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 → root for the closest definition.
|
||||
// walks levelIdx → fence-or-root for the closest definition. mode
|
||||
// controls whether inherit:false fences are honored — see VisibleStart.
|
||||
//
|
||||
// Legacy acl.allow / acl.deny entries are folded in here (rather than at
|
||||
// parse time) so this function works correctly on test-constructed
|
||||
// ZddcFile literals as well as parser output.
|
||||
func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet, bool) {
|
||||
func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string, mode CascadeMode) (VerbSet, bool) {
|
||||
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
||||
return 0, false
|
||||
}
|
||||
|
|
@ -40,7 +49,7 @@ func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string) (VerbSet
|
|||
deniedExplicit := false
|
||||
var grant VerbSet
|
||||
for principal, verbStr := range perms {
|
||||
if !MatchesPrincipal(principal, email, chain, levelIdx) {
|
||||
if !MatchesPrincipal(principal, email, chain, levelIdx, mode) {
|
||||
continue
|
||||
}
|
||||
matched = true
|
||||
|
|
@ -141,16 +150,22 @@ 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)
|
||||
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
|
||||
if matched && grant == 0 {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := toIdx - 1; i >= fromIdx; i-- {
|
||||
grant, matched := GrantedVerbsAtLevel(chain, i, email)
|
||||
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,36 @@ 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
|
||||
|
|
|
|||
|
|
@ -27,6 +27,29 @@ 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.
|
||||
|
|
@ -34,6 +57,17 @@ 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
|
||||
|
|
|
|||
|
|
@ -74,3 +74,63 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
zddc/internal/zddc/inherit_test.go
Normal file
152
zddc/internal/zddc/inherit_test.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -100,17 +100,22 @@ func IsPrincipalRole(principal string) bool {
|
|||
}
|
||||
|
||||
// RoleMembers returns the member-pattern list for roleName as visible
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// Levels are stored root (index 0) → leaf (last index), matching the
|
||||
// EffectivePolicy convention.
|
||||
func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string {
|
||||
func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) []string {
|
||||
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
||||
return nil
|
||||
}
|
||||
for i := levelIdx; i >= 0; i-- {
|
||||
floor := chain.VisibleStart(levelIdx, mode)
|
||||
for i := levelIdx; i >= floor; i-- {
|
||||
role, ok := chain.Levels[i].Roles[roleName]
|
||||
if !ok {
|
||||
continue
|
||||
|
|
@ -121,23 +126,24 @@ func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string {
|
|||
}
|
||||
|
||||
// MatchesPrincipal reports whether email satisfies the given Permissions
|
||||
// key at chain.Levels[levelIdx].
|
||||
// key at chain.Levels[levelIdx]. mode controls whether inherit:false
|
||||
// fences truncate the visible chain when resolving role definitions.
|
||||
//
|
||||
// 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. 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 {
|
||||
// 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 {
|
||||
if !IsPrincipalRole(principal) {
|
||||
return MatchesPattern(principal, email)
|
||||
}
|
||||
members, defined := lookupRoleMembers(chain, levelIdx, principal)
|
||||
members, defined := lookupRoleMembers(chain, levelIdx, principal, mode)
|
||||
if !defined {
|
||||
// Legacy pattern compatibility — bare wildcards / unqualified
|
||||
// strings continue to match via the email-pattern matcher.
|
||||
|
|
@ -154,12 +160,14 @@ 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.
|
||||
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]string, bool) {
|
||||
// (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) {
|
||||
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
||||
return nil, false
|
||||
}
|
||||
for i := levelIdx; i >= 0; i-- {
|
||||
floor := chain.VisibleStart(levelIdx, mode)
|
||||
for i := levelIdx; i >= floor; i-- {
|
||||
role, ok := chain.Levels[i].Roles[roleName]
|
||||
if !ok {
|
||||
continue
|
||||
|
|
@ -171,8 +179,9 @@ func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]stri
|
|||
|
||||
// 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.
|
||||
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string {
|
||||
// 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 {
|
||||
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -182,7 +191,7 @@ func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string
|
|||
}
|
||||
var out []string
|
||||
for principal := range level.ACL.Permissions {
|
||||
if MatchesPrincipal(principal, email, chain, levelIdx) {
|
||||
if MatchesPrincipal(principal, email, chain, levelIdx, mode) {
|
||||
out = append(out, principal)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,12 +78,12 @@ func TestRoleMembersClosestLeafWins(t *testing.T) {
|
|||
},
|
||||
HasAnyFile: true,
|
||||
}
|
||||
got := RoleMembers(chain, 1, "editors")
|
||||
got := RoleMembers(chain, 1, "editors", ModeDelegated)
|
||||
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")
|
||||
got = RoleMembers(chain, 0, "editors", ModeDelegated)
|
||||
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) {
|
||||
if !MatchesPrincipal("*", "alice@example.com", chain, 0, ModeDelegated) {
|
||||
t.Errorf("bare * should match any email via legacy fallback")
|
||||
}
|
||||
if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0) {
|
||||
if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0, ModeDelegated) {
|
||||
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) {
|
||||
if !MatchesPrincipal("vendor_acme", "rep@acme.com", chain, 0, ModeDelegated) {
|
||||
t.Errorf("rep@acme.com should match role vendor_acme")
|
||||
}
|
||||
if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0) {
|
||||
if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0, ModeDelegated) {
|
||||
t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue