Compare commits
No commits in common. "41dff23127722e7ecdf8b009b02405a7abfda3fd" and "821ed3ee192f442e93d7d0a2e5e59565e3098d6b" have entirely different histories.
41dff23127
...
821ed3ee19
15 changed files with 100 additions and 830 deletions
|
|
@ -251,15 +251,9 @@ for a level whose `acl.permissions` map matches the user.
|
||||||
- No `.zddc` anywhere (`HasAnyFile=false`) → **allow** (the empty-tree default).
|
- No `.zddc` anywhere (`HasAnyFile=false`) → **allow** (the empty-tree default).
|
||||||
- At least one `.zddc` existed (`HasAnyFile=true`) → **403 Forbidden** (default-deny).
|
- 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
|
Implementation: `GrantedVerbsAtLevel` (`zddc/internal/zddc/acl.go`) computes the
|
||||||
per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain
|
per-level grant; `EffectiveVerbs` / `AllowedAction` walk the chain; the chain
|
||||||
itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`); the
|
itself is built by `EffectivePolicy` (`zddc/internal/zddc/cascade.go`).
|
||||||
fence is computed by `PolicyChain.VisibleStart`.
|
|
||||||
|
|
||||||
#### Cascade mode
|
#### 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
|
The mode is logged at startup and surfaced on `/.profile/config`. Subtree
|
||||||
`.zddc` files cannot change the mode — it's a deployment-wide policy.
|
`.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
|
#### Special folders
|
||||||
|
|
||||||
Five folder names trigger built-in behaviors regardless of cascade mode (canonical list in `zddc/internal/zddc/special.go`):
|
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
|
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
|
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
|
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
|
from a leaf, use the two-level gate-and-reallow (parent denies, deeper level
|
||||||
`inherit:` directive" above), or the two-level gate-and-reallow pattern, or
|
re-allows) — *or* avoid putting `*@company.com` at any ancestor.
|
||||||
avoid putting `*@company.com` at any ancestor in the first place.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# /Closed/.zddc — DOES NOT WORK as intended
|
# /Closed/.zddc — DOES NOT WORK as intended
|
||||||
acl:
|
acl:
|
||||||
allow: [alice@company.com]
|
allow: [alice@company.com]
|
||||||
deny: ["*@company.com"] # blocks alice too — deny is checked first at same level
|
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
|
2. **A leaf-level `allow: [subset]` does NOT restrict when a parent has
|
||||||
|
|
|
||||||
|
|
@ -447,16 +447,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
// Split path into segments
|
// Split path into segments
|
||||||
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
|
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||||
|
|
||||||
// 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
|
// Reserve dot-prefixed path segments. The listing pipeline already hides
|
||||||
// hidden entries (internal/listing/listing.go:17, projectshandler.go:40),
|
// hidden entries (internal/listing/listing.go:17, projectshandler.go:40),
|
||||||
// but direct URL access would still serve them. 404 here so hidden trees
|
// 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
|
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
|
// Tables-system intercept: *.table.html is a virtual URL that the
|
||||||
// table handler renders inline, reading rows from a directory of
|
// table handler renders inline, reading rows from a directory of
|
||||||
// *.yaml files declared in the directory's .zddc tables: map.
|
// *.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
|
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, "/") {
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -374,26 +373,11 @@ func TestDispatchArchiveRedirect(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDispatchSlashRouting(t *testing.T) {
|
func TestDispatchMdlRedirect(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()
|
root := t.TempDir()
|
||||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||||
"acl:\n permissions:\n \"*\": rwcda\n")
|
"acl:\n permissions:\n \"*@example.com\": rwcda\n")
|
||||||
for _, sub := range []string{
|
mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme"))
|
||||||
"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)
|
idx, err := archive.BuildIndex(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -405,46 +389,58 @@ func TestDispatchSlashRouting(t *testing.T) {
|
||||||
EmailHeader: "X-Auth-Request-Email",
|
EmailHeader: "X-Auth-Request-Email",
|
||||||
}
|
}
|
||||||
ring := handler.NewLogRing(10)
|
ring := handler.NewLogRing(10)
|
||||||
appsSrv, err := setupApps(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("setupApps: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
path string
|
path string
|
||||||
wantStatus int
|
wantStatus int
|
||||||
wantNoRedirect bool
|
wantLoc string
|
||||||
}{
|
}{
|
||||||
{"working no-slash → mdedit", "/Project/working", http.StatusOK, true},
|
{
|
||||||
{"working slash → browse", "/Project/working/", http.StatusOK, true},
|
"mdl trailing slash → table",
|
||||||
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true},
|
"/ProjectA/archive/Acme/mdl/",
|
||||||
{"staging slash → browse", "/Project/staging/", http.StatusOK, true},
|
http.StatusFound,
|
||||||
{"archive no-slash → archive", "/Project/archive", http.StatusOK, true},
|
"/ProjectA/archive/Acme/mdl.table.html",
|
||||||
{"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},
|
"case-fold MDL trailing slash",
|
||||||
{"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true},
|
"/ProjectA/archive/Acme/MDL/",
|
||||||
{"archive/<party>/mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true},
|
http.StatusFound,
|
||||||
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true},
|
"/ProjectA/archive/Acme/mdl.table.html",
|
||||||
{"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},
|
"case-fold ARCHIVE",
|
||||||
{"project root no-slash → 301 to slash", "/Project", http.StatusMovedPermanently, false},
|
"/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 {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, appsSrv, rec, req)
|
dispatch(cfg, idx, ring, nil, rec, req)
|
||||||
if rec.Code != tc.wantStatus {
|
if rec.Code != tc.wantStatus {
|
||||||
t.Fatalf("path=%q status=%d, want %d; body=%s",
|
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
||||||
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"))
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -68,56 +68,3 @@ func inAncestorWithName(root, requestDir string, names ...string) bool {
|
||||||
}
|
}
|
||||||
return false
|
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,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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -371,10 +371,6 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
|
||||||
Admins []string `json:"admins,omitempty"`
|
Admins []string `json:"admins,omitempty"`
|
||||||
AnyMatch bool `json:"matches_email"`
|
AnyMatch bool `json:"matches_email"`
|
||||||
Decision string `json:"decision_at_level"`
|
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
|
// Build the per-level breakdown by walking the chain levels in
|
||||||
|
|
@ -388,14 +384,8 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Decision bool `json:"decision"`
|
Decision bool `json:"decision"`
|
||||||
DeciderKind string `json:"decider_kind"`
|
DeciderKind string `json:"decider_kind"`
|
||||||
CascadeMode string `json:"cascade_mode"`
|
|
||||||
Chain struct {
|
Chain struct {
|
||||||
HasAnyFile bool `json:"has_any_file"`
|
HasAnyFile bool `json:"has_any_file"`
|
||||||
// VisibleStart is the lowest chain index whose grants are
|
|
||||||
// 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"`
|
} `json:"chain"`
|
||||||
}{
|
}{
|
||||||
|
|
@ -403,11 +393,8 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
|
||||||
Email: probeEmail,
|
Email: probeEmail,
|
||||||
Decision: allow,
|
Decision: allow,
|
||||||
DeciderKind: deciderKind(decider),
|
DeciderKind: deciderKind(decider),
|
||||||
CascadeMode: cfg.CascadeMode,
|
|
||||||
}
|
}
|
||||||
out.Chain.HasAnyFile = chain.HasAnyFile
|
out.Chain.HasAnyFile = chain.HasAnyFile
|
||||||
mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode)
|
|
||||||
out.Chain.VisibleStart = chain.VisibleStart(len(chain.Levels)-1, mode)
|
|
||||||
|
|
||||||
// Reconstruct level paths from cfg.Root. This mirrors how
|
// Reconstruct level paths from cfg.Root. This mirrors how
|
||||||
// zddc.EffectivePolicy builds the chain (see cascade.go).
|
// zddc.EffectivePolicy builds the chain (see cascade.go).
|
||||||
|
|
@ -438,8 +425,7 @@ func serveProfileEffectivePolicy(cfg config.Config, w http.ResponseWriter, r *ht
|
||||||
entry := levelView{
|
entry := levelView{
|
||||||
Index: i,
|
Index: i,
|
||||||
ZddcPath: lp,
|
ZddcPath: lp,
|
||||||
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0 || len(lvl.ACL.Permissions) > 0 || lvl.ACL.Inherit != nil,
|
Exists: len(lvl.Admins) > 0 || len(lvl.ACL.Allow) > 0 || len(lvl.ACL.Deny) > 0,
|
||||||
Inherit: lvl.ACL.Inherit,
|
|
||||||
}
|
}
|
||||||
if entry.Exists {
|
if entry.Exists {
|
||||||
entry.Acl = &lvl.ACL
|
entry.Acl = &lvl.ACL
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
|
||||||
// .zddc exists but has no admins list — page is still reachable,
|
// .zddc exists but has no admins list — page is still reachable,
|
||||||
// but the admin/super-admin sections are absent.
|
// but the admin/super-admin sections are absent.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"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)
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
email := EmailFromContext(r)
|
||||||
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
|
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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"))
|
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "Cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,11 @@ package zddc
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for
|
// AllowedAtLevel is a thin shim over GrantedVerbsAtLevel preserved for
|
||||||
// callers that only need the legacy boolean read decision on a single
|
// callers that only need the legacy boolean read decision. New code
|
||||||
// ZddcFile (no cascade chain).
|
// should call GrantedVerbsAtLevel directly.
|
||||||
//
|
|
||||||
// Hardcodes ModeDelegated — safe because the synthetic chain has only
|
|
||||||
// one level and no ancestors to fence — but callers that operate on a
|
|
||||||
// real PolicyChain must call GrantedVerbsAtLevel directly with the
|
|
||||||
// active mode.
|
|
||||||
//
|
|
||||||
// Deprecated: prefer GrantedVerbsAtLevel for any code path that may
|
|
||||||
// later need fence-aware or strict-mode evaluation.
|
|
||||||
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
|
func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool) {
|
||||||
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
|
chain := PolicyChain{Levels: []ZddcFile{level}, HasAnyFile: true}
|
||||||
v, m := GrantedVerbsAtLevel(chain, 0, email, ModeDelegated)
|
v, m := GrantedVerbsAtLevel(chain, 0, email)
|
||||||
if !m {
|
if !m {
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
@ -29,13 +21,12 @@ func AllowedAtLevel(level ZddcFile, email string) (decision bool, matched bool)
|
||||||
// - matched=true, set!={} → union of verb sets from every matching entry
|
// - matched=true, set!={} → union of verb sets from every matching entry
|
||||||
//
|
//
|
||||||
// Role lookups for principal keys without "@" use RoleMembers, which
|
// Role lookups for principal keys without "@" use RoleMembers, which
|
||||||
// walks levelIdx → fence-or-root for the closest definition. mode
|
// walks levelIdx → root for the closest definition.
|
||||||
// controls whether inherit:false fences are honored — see VisibleStart.
|
|
||||||
//
|
//
|
||||||
// Legacy acl.allow / acl.deny entries are folded in here (rather than at
|
// Legacy acl.allow / acl.deny entries are folded in here (rather than at
|
||||||
// parse time) so this function works correctly on test-constructed
|
// parse time) so this function works correctly on test-constructed
|
||||||
// ZddcFile literals as well as parser output.
|
// 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) {
|
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +40,7 @@ func GrantedVerbsAtLevel(chain PolicyChain, levelIdx int, email string, mode Cas
|
||||||
deniedExplicit := false
|
deniedExplicit := false
|
||||||
var grant VerbSet
|
var grant VerbSet
|
||||||
for principal, verbStr := range perms {
|
for principal, verbStr := range perms {
|
||||||
if !MatchesPrincipal(principal, email, chain, levelIdx, mode) {
|
if !MatchesPrincipal(principal, email, chain, levelIdx) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
matched = true
|
matched = true
|
||||||
|
|
@ -150,22 +141,16 @@ func EffectiveVerbsRange(chain PolicyChain, fromIdx, toIdx int, email string, mo
|
||||||
// caller has another range to combine with).
|
// caller has another range to combine with).
|
||||||
return 0
|
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 {
|
if mode == ModeStrict {
|
||||||
for i := fromIdx; i < toIdx; i++ {
|
for i := fromIdx; i < toIdx; i++ {
|
||||||
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
|
grant, matched := GrantedVerbsAtLevel(chain, i, email)
|
||||||
if matched && grant == 0 {
|
if matched && grant == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i := toIdx - 1; i >= fromIdx; i-- {
|
for i := toIdx - 1; i >= fromIdx; i-- {
|
||||||
grant, matched := GrantedVerbsAtLevel(chain, i, email, mode)
|
grant, matched := GrantedVerbsAtLevel(chain, i, email)
|
||||||
if !matched {
|
if !matched {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,36 +13,6 @@ type PolicyChain struct {
|
||||||
HasAnyFile bool // true if at least one .zddc file exists in the chain
|
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.
|
// policyCache caches effective policies keyed by dirPath.
|
||||||
// Values are PolicyChain.
|
// Values are PolicyChain.
|
||||||
var policyCache sync.Map
|
var policyCache sync.Map
|
||||||
|
|
|
||||||
|
|
@ -27,29 +27,6 @@ import (
|
||||||
// (and so existing operator-authored .zddc files render unchanged in
|
// (and so existing operator-authored .zddc files render unchanged in
|
||||||
// the admin UI); the cascade evaluator reads only Permissions.
|
// 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
|
// JSON tags are present so this type round-trips cleanly when included
|
||||||
// in the external-OPA input body (see internal/policy). The canonical
|
// in the external-OPA input body (see internal/policy). The canonical
|
||||||
// in-repo serialization is YAML; JSON is only used for OPA queries.
|
// in-repo serialization is YAML; JSON is only used for OPA queries.
|
||||||
|
|
@ -57,17 +34,6 @@ type ACLRules struct {
|
||||||
Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
|
Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
|
||||||
Deny []string `yaml:"deny,omitempty" json:"deny,omitempty"`
|
Deny []string `yaml:"deny,omitempty" json:"deny,omitempty"`
|
||||||
Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
|
Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
|
||||||
// Inherit *bool: nil = unset (inherit normally), &true = same,
|
|
||||||
// &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
|
// Role is the named principal-grouping primitive. Members are email
|
||||||
|
|
|
||||||
|
|
@ -74,63 +74,3 @@ acl:
|
||||||
t.Errorf("Tables = %+v want nil for absent tables: key", zf.Tables)
|
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -100,22 +100,17 @@ func IsPrincipalRole(principal string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoleMembers returns the member-pattern list for roleName as visible
|
// RoleMembers returns the member-pattern list for roleName as visible
|
||||||
// at chain.Levels[levelIdx]. Lookup walks levelIdx → fence-or-root and
|
// at chain.Levels[levelIdx]. Lookup walks levelIdx → root and returns
|
||||||
// returns the first definition found (closer-to-leaf wins). The lower
|
// the first definition found (closer-to-leaf wins). Returns nil if no
|
||||||
// bound is determined by chain.VisibleStart(levelIdx, mode): in
|
// level in the visible chain defines the role.
|
||||||
// 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
|
// Levels are stored root (index 0) → leaf (last index), matching the
|
||||||
// EffectivePolicy convention.
|
// EffectivePolicy convention.
|
||||||
func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) []string {
|
func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string {
|
||||||
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
floor := chain.VisibleStart(levelIdx, mode)
|
for i := levelIdx; i >= 0; i-- {
|
||||||
for i := levelIdx; i >= floor; i-- {
|
|
||||||
role, ok := chain.Levels[i].Roles[roleName]
|
role, ok := chain.Levels[i].Roles[roleName]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
|
|
@ -126,24 +121,23 @@ func RoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeM
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchesPrincipal reports whether email satisfies the given Permissions
|
// MatchesPrincipal reports whether email satisfies the given Permissions
|
||||||
// key at chain.Levels[levelIdx]. mode controls whether inherit:false
|
// key at chain.Levels[levelIdx].
|
||||||
// fences truncate the visible chain when resolving role definitions.
|
|
||||||
//
|
//
|
||||||
// Resolution order:
|
// Resolution order:
|
||||||
//
|
//
|
||||||
// 1. Principals containing "@" are always email patterns; dispatch to
|
// 1. Principals containing "@" are always email patterns; dispatch to
|
||||||
// MatchesPattern.
|
// MatchesPattern.
|
||||||
// 2. Principals without "@" are role-or-pattern. Look up the name in
|
// 2. Principals without "@" are role-or-pattern. Look up the name in
|
||||||
// the cascade's roles, honoring fences. If a role definition is
|
// the cascade's roles. If a role definition is found, match the
|
||||||
// found in the visible chain, match the user against the role's
|
// user against the role's members. If no role definition exists
|
||||||
// members. If no role definition exists in the visible chain, fall
|
// anywhere in the cascade, fall back to MatchesPattern. The
|
||||||
// back to MatchesPattern. The fallback preserves legacy patterns
|
// fallback preserves legacy patterns like "*" or "*example.com"
|
||||||
// like "*" or "*example.com" that pre-date the roles feature.
|
// that pre-date the roles feature.
|
||||||
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int, mode CascadeMode) bool {
|
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int) bool {
|
||||||
if !IsPrincipalRole(principal) {
|
if !IsPrincipalRole(principal) {
|
||||||
return MatchesPattern(principal, email)
|
return MatchesPattern(principal, email)
|
||||||
}
|
}
|
||||||
members, defined := lookupRoleMembers(chain, levelIdx, principal, mode)
|
members, defined := lookupRoleMembers(chain, levelIdx, principal)
|
||||||
if !defined {
|
if !defined {
|
||||||
// Legacy pattern compatibility — bare wildcards / unqualified
|
// Legacy pattern compatibility — bare wildcards / unqualified
|
||||||
// strings continue to match via the email-pattern matcher.
|
// 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
|
// lookupRoleMembers returns the member list and whether the role was
|
||||||
// defined anywhere in the visible chain. Distinguishes "role exists
|
// defined anywhere in the visible chain. Distinguishes "role exists
|
||||||
// but is empty" (defined=true, empty members) from "role not defined"
|
// but is empty" (defined=true, empty members) from "role not defined"
|
||||||
// (defined=false), which the principal-fallback logic depends on. The
|
// (defined=false), which the principal-fallback logic depends on.
|
||||||
// visible-chain bound is determined by chain.VisibleStart(levelIdx, mode).
|
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]string, bool) {
|
||||||
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string, mode CascadeMode) ([]string, bool) {
|
|
||||||
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
floor := chain.VisibleStart(levelIdx, mode)
|
for i := levelIdx; i >= 0; i-- {
|
||||||
for i := levelIdx; i >= floor; i-- {
|
|
||||||
role, ok := chain.Levels[i].Roles[roleName]
|
role, ok := chain.Levels[i].Roles[roleName]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
|
|
@ -179,9 +171,8 @@ func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string, mode Ca
|
||||||
|
|
||||||
// MatchingPrincipals returns the keys of level.ACL.Permissions whose
|
// MatchingPrincipals returns the keys of level.ACL.Permissions whose
|
||||||
// principal matches email at chain.Levels[levelIdx]. Output is sorted
|
// principal matches email at chain.Levels[levelIdx]. Output is sorted
|
||||||
// for stable iteration in tests and audit logs. mode is forwarded to
|
// for stable iteration in tests and audit logs.
|
||||||
// MatchesPrincipal for fence-aware role resolution.
|
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string {
|
||||||
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string, mode CascadeMode) []string {
|
|
||||||
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +182,7 @@ func MatchingPrincipals(chain PolicyChain, levelIdx int, email string, mode Casc
|
||||||
}
|
}
|
||||||
var out []string
|
var out []string
|
||||||
for principal := range level.ACL.Permissions {
|
for principal := range level.ACL.Permissions {
|
||||||
if MatchesPrincipal(principal, email, chain, levelIdx, mode) {
|
if MatchesPrincipal(principal, email, chain, levelIdx) {
|
||||||
out = append(out, principal)
|
out = append(out, principal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,12 +78,12 @@ func TestRoleMembersClosestLeafWins(t *testing.T) {
|
||||||
},
|
},
|
||||||
HasAnyFile: true,
|
HasAnyFile: true,
|
||||||
}
|
}
|
||||||
got := RoleMembers(chain, 1, "editors", ModeDelegated)
|
got := RoleMembers(chain, 1, "editors")
|
||||||
if len(got) != 1 || got[0] != "bob@example.com" {
|
if len(got) != 1 || got[0] != "bob@example.com" {
|
||||||
t.Errorf("leaf shadow failed: %v", got)
|
t.Errorf("leaf shadow failed: %v", got)
|
||||||
}
|
}
|
||||||
// At root level, only the root definition is visible.
|
// 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" {
|
if len(got) != 1 || got[0] != "alice@example.com" {
|
||||||
t.Errorf("root visibility failed: %v", got)
|
t.Errorf("root visibility failed: %v", got)
|
||||||
}
|
}
|
||||||
|
|
@ -96,10 +96,10 @@ func TestMatchesPrincipalLegacyPatternFallback(t *testing.T) {
|
||||||
Levels: []ZddcFile{{}},
|
Levels: []ZddcFile{{}},
|
||||||
HasAnyFile: true,
|
HasAnyFile: true,
|
||||||
}
|
}
|
||||||
if !MatchesPrincipal("*", "alice@example.com", chain, 0, ModeDelegated) {
|
if !MatchesPrincipal("*", "alice@example.com", chain, 0) {
|
||||||
t.Errorf("bare * should match any email via legacy fallback")
|
t.Errorf("bare * should match any email via legacy fallback")
|
||||||
}
|
}
|
||||||
if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0, ModeDelegated) {
|
if !MatchesPrincipal("*example.com", "alice@example.com", chain, 0) {
|
||||||
t.Errorf("*example.com should match alice@example.com via legacy fallback")
|
t.Errorf("*example.com should match alice@example.com via legacy fallback")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,10 +115,10 @@ func TestMatchesPrincipalRoleNamePrefersRole(t *testing.T) {
|
||||||
}},
|
}},
|
||||||
HasAnyFile: true,
|
HasAnyFile: true,
|
||||||
}
|
}
|
||||||
if !MatchesPrincipal("vendor_acme", "rep@acme.com", chain, 0, ModeDelegated) {
|
if !MatchesPrincipal("vendor_acme", "rep@acme.com", chain, 0) {
|
||||||
t.Errorf("rep@acme.com should match role vendor_acme")
|
t.Errorf("rep@acme.com should match role vendor_acme")
|
||||||
}
|
}
|
||||||
if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0, ModeDelegated) {
|
if MatchesPrincipal("vendor_acme", "rep@other.com", chain, 0) {
|
||||||
t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed")
|
t.Errorf("rep@other.com should NOT match role vendor_acme — fallback to pattern would wrongly succeed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue