feat(audit): chain_admin_level field in access log
The audit log now records WHICH chain level conferred admin
authority on each request — 0 for root super-admin, N for a
subtree admin at depth N, -1 for no admin authority. Forensics can
now distinguish:
elevated=true active_admin=true chain_admin_level=0
→ root super-admin acting
elevated=true active_admin=true chain_admin_level=3
→ subtree admin at /<project>/<sub>/<dir>/.zddc acting
elevated=true active_admin=false chain_admin_level=-1
→ opted into admin but no grant on this path (out of scope)
New helper zddc.AdminLevelInChain returns the level index (or -1);
IsAdminForChain becomes a thin wrapper. Middleware's
activeAdminForRequest is rewired to return the level so the audit
emission gets the attribution without double-walking the cascade.
Pre-existing TestServeProfileProjectsCreate's "no .zddc unless body
supplies fields" expectation flipped — the project-create flow now
always seeds admins: [creator] so the test asserts the new
contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
df19a63853
commit
ae105fde1c
5 changed files with 164 additions and 41 deletions
|
|
@ -169,15 +169,19 @@ func WithElevation(ctx context.Context, elevated bool) context.Context {
|
|||
|
||||
// activeAdminForRequest reports whether the elevated principal would
|
||||
// trigger the decider's admin-bypass branch on the chain at the
|
||||
// request's target path. Best-effort: walks the closest existing
|
||||
// ancestor (mirroring the file API's authorize logic) so a write
|
||||
// targeting a not-yet-existing file still answers correctly. Returns
|
||||
// false on anonymous or un-elevated requests without touching the
|
||||
// filesystem. The cascade is mtime-cached upstream, so the per-
|
||||
// request cost is one map lookup in the common case.
|
||||
func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, email string) bool {
|
||||
// request's target path, AND which chain level conferred that
|
||||
// authority. Returned level is 0-based (root=0) when authority is
|
||||
// active, -1 otherwise.
|
||||
//
|
||||
// Best-effort: walks the closest existing ancestor (mirroring the
|
||||
// file API's authorize logic) so a write targeting a not-yet-
|
||||
// existing file still answers correctly. Returns -1 on anonymous
|
||||
// or un-elevated requests without touching the filesystem. The
|
||||
// cascade is mtime-cached upstream, so the per-request cost is one
|
||||
// map lookup in the common case.
|
||||
func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, email string) int {
|
||||
if !elevated || email == "" || email == "anonymous" {
|
||||
return false
|
||||
return -1
|
||||
}
|
||||
cleanURL := strings.TrimSuffix(r.URL.Path, "/")
|
||||
if cleanURL == "" {
|
||||
|
|
@ -188,13 +192,13 @@ func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, em
|
|||
// Root request: chain is just the root .zddc.
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, cfg.Root)
|
||||
if err != nil {
|
||||
return false
|
||||
return -1
|
||||
}
|
||||
return zddc.IsAdminForChain(chain, email, false)
|
||||
return zddc.AdminLevelInChain(chain, email, false)
|
||||
}
|
||||
abs := filepath.Join(cfg.Root, filepath.FromSlash(rel))
|
||||
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root {
|
||||
return false
|
||||
return -1
|
||||
}
|
||||
probe := abs
|
||||
for {
|
||||
|
|
@ -212,9 +216,9 @@ func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, em
|
|||
}
|
||||
chain, err := zddc.EffectivePolicy(cfg.Root, probe)
|
||||
if err != nil {
|
||||
return false
|
||||
return -1
|
||||
}
|
||||
return zddc.IsAdminForChain(chain, email, false)
|
||||
return zddc.AdminLevelInChain(chain, email, false)
|
||||
}
|
||||
|
||||
// PrincipalFromContext bundles the request's authenticated email plus
|
||||
|
|
@ -334,13 +338,20 @@ func AccessLogMiddleware(cfg config.Config, auditLogger *slog.Logger, next http.
|
|||
email = "anonymous"
|
||||
}
|
||||
elevated := ElevatedFromContext(r)
|
||||
activeAdmin := activeAdminForRequest(cfg, r, elevated, email)
|
||||
// adminLevel: 0-based chain index of the admins: entry that
|
||||
// conferred authority on this request, or -1 if no admin
|
||||
// authority applies. Lets forensics tell "root admin acted"
|
||||
// (level 0) apart from "subtree admin acted" (level N) apart
|
||||
// from "not admin" (-1). The active_admin bool is derived
|
||||
// for back-compat with existing log consumers.
|
||||
adminLevel := activeAdminForRequest(cfg, r, elevated, email)
|
||||
|
||||
args := []any{
|
||||
"ts", start.Format(time.RFC3339),
|
||||
"email", email,
|
||||
"elevated", elevated,
|
||||
"active_admin", activeAdmin,
|
||||
"active_admin", adminLevel >= 0,
|
||||
"chain_admin_level", adminLevel,
|
||||
"method", r.Method,
|
||||
"path", requestedPath,
|
||||
"status", wrapped.status,
|
||||
|
|
|
|||
|
|
@ -699,6 +699,8 @@ func TestServeProfileProjectsCreate(t *testing.T) {
|
|||
}
|
||||
|
||||
// Happy path: super-admin creates /alpha with no .zddc body.
|
||||
// Post-refactor: the .zddc IS auto-written with the creator in
|
||||
// admins: so they own the new project from birth.
|
||||
rec := post("root@example.com", `{"parent":"/", "name":"alpha"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("happy path status=%d body=%s", rec.Code, rec.Body.String())
|
||||
|
|
@ -706,8 +708,12 @@ func TestServeProfileProjectsCreate(t *testing.T) {
|
|||
if _, err := os.Stat(filepath.Join(root, "alpha")); err != nil {
|
||||
t.Errorf("alpha dir not created on disk: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err == nil {
|
||||
t.Errorf(".zddc should NOT be auto-written when no fields supplied")
|
||||
if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err != nil {
|
||||
t.Errorf(".zddc should be auto-written with creator as admin: %v", err)
|
||||
} else if zf, perr := zddc.ParseFile(filepath.Join(root, "alpha", ".zddc")); perr == nil {
|
||||
if len(zf.Admins) != 1 || zf.Admins[0] != "root@example.com" {
|
||||
t.Errorf("alpha .zddc Admins=%v, want [root@example.com]", zf.Admins)
|
||||
}
|
||||
}
|
||||
|
||||
// Body with a title also writes a .zddc.
|
||||
|
|
|
|||
|
|
@ -1559,7 +1559,7 @@ body.is-elevated {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 15:46:59 · b80b11c-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 15:55:46 · df19a63-dirty</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -30,34 +30,35 @@ func (p Principal) gate() bool {
|
|||
return p.Email != "" && p.Elevated
|
||||
}
|
||||
|
||||
// IsAdminForChain reports whether email is granted admin authority by
|
||||
// any level in chain. Elevation-INDEPENDENT — the caller is responsible
|
||||
// for gating on Principal.Elevated before treating the result as live
|
||||
// authority.
|
||||
// AdminLevelInChain reports the chain-level index (0-based, root=0)
|
||||
// at which the first admins: entry matching email occurs, or -1 if
|
||||
// no level grants admin authority. Elevation-INDEPENDENT — the
|
||||
// caller is responsible for gating on Principal.Elevated before
|
||||
// treating the result as live authority.
|
||||
//
|
||||
// excludeLeaf=true drops the deepest level from the walk. This implements
|
||||
// the strict-ancestor rule for .zddc edits: a subtree admin granted at
|
||||
// /<dir>/.zddc cannot edit that same file (otherwise they could add
|
||||
// peers, remove the delegator, or otherwise self-elevate). The root
|
||||
// .zddc has no strict ancestor, so the rule degenerates at len==1: the
|
||||
// loop falls back to checking that single level, preserving the
|
||||
// bootstrap super-admin path.
|
||||
// excludeLeaf=true drops the deepest level from the walk (strict-
|
||||
// ancestor opt-in; ModeStrict deployments use this for .zddc edits).
|
||||
// The root .zddc has no strict ancestor, so the rule degenerates at
|
||||
// len==1: the loop falls back to checking that single level,
|
||||
// preserving the bootstrap super-admin path.
|
||||
//
|
||||
// Role lookups inside this walk are bounded to the levels visible AT
|
||||
// the matching level (via MatchesPrincipal's third/fourth args). A role
|
||||
// defined at the deepest level never confers self-edit rights even
|
||||
// indirectly, because the role definition isn't visible above the
|
||||
// level it's defined at.
|
||||
// Role lookups inside this walk are bounded to the levels visible
|
||||
// AT the matching level (via MatchesPrincipal's third/fourth args).
|
||||
// A role defined at the deepest level never confers self-edit
|
||||
// rights indirectly because the role definition isn't visible above
|
||||
// the level it's defined at.
|
||||
//
|
||||
// Replaces the trio of IsAdmin / IsSubtreeAdmin / CanEditZddc under a
|
||||
// single signature once callers migrate.
|
||||
func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool {
|
||||
// Exposed separately from IsAdminForChain so audit-logging callers
|
||||
// can record WHICH level conferred admin authority — useful for
|
||||
// forensics across nested delegation (root admin vs subtree admin
|
||||
// at depth N).
|
||||
func AdminLevelInChain(chain PolicyChain, email string, excludeLeaf bool) int {
|
||||
if email == "" {
|
||||
return false
|
||||
return -1
|
||||
}
|
||||
n := len(chain.Levels)
|
||||
if n == 0 {
|
||||
return false
|
||||
return -1
|
||||
}
|
||||
end := n
|
||||
if excludeLeaf && n > 1 {
|
||||
|
|
@ -66,11 +67,18 @@ func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool {
|
|||
for i := 0; i < end; i++ {
|
||||
for _, principal := range chain.Levels[i].Admins {
|
||||
if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
|
||||
return true
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return -1
|
||||
}
|
||||
|
||||
// IsAdminForChain is the boolean shortcut over AdminLevelInChain.
|
||||
// Returns true iff some level grants admin authority. See
|
||||
// AdminLevelInChain for parameter semantics.
|
||||
func IsAdminForChain(chain PolicyChain, email string, excludeLeaf bool) bool {
|
||||
return AdminLevelInChain(chain, email, excludeLeaf) >= 0
|
||||
}
|
||||
|
||||
// HasAnyAdminGrant reports whether email is named as an admin somewhere
|
||||
|
|
|
|||
|
|
@ -546,3 +546,101 @@ func TestIsAdminForChain(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminLevelInChain pins the level-index contract used by audit
|
||||
// logging: root match returns 0, subtree match returns the deeper
|
||||
// index, no match returns -1.
|
||||
func TestAdminLevelInChain(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
dir string
|
||||
email string
|
||||
excludeLeaf bool
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "root super-admin matches at level 0",
|
||||
files: map[string]string{"": "admins:\n - alice@example.com\n"},
|
||||
dir: "project",
|
||||
email: "alice@example.com",
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "subtree admin matches at level 1",
|
||||
files: map[string]string{
|
||||
"": "admins:\n - root@example.com\n",
|
||||
"project": "admins:\n - alice@example.com\n",
|
||||
},
|
||||
dir: "project",
|
||||
email: "alice@example.com",
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "deeper subtree admin matches at level 2",
|
||||
files: map[string]string{
|
||||
"": "admins:\n - root@example.com\n",
|
||||
"project": "admins:\n - alice@example.com\n",
|
||||
"project/sub": "admins:\n - bob@example.com\n",
|
||||
},
|
||||
dir: "project/sub",
|
||||
email: "bob@example.com",
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "no match returns -1",
|
||||
files: map[string]string{"": "admins:\n - root@example.com\n"},
|
||||
dir: "project",
|
||||
email: "stranger@other.org",
|
||||
want: -1,
|
||||
},
|
||||
{
|
||||
name: "empty email returns -1",
|
||||
files: map[string]string{"": "admins:\n - \"*\"\n"},
|
||||
dir: "",
|
||||
email: "",
|
||||
want: -1,
|
||||
},
|
||||
{
|
||||
name: "excludeLeaf hides deepest match — falls back to ancestor",
|
||||
files: map[string]string{
|
||||
"": "admins:\n - root@example.com\n",
|
||||
"project": "admins:\n - alice@example.com\n",
|
||||
},
|
||||
dir: "project",
|
||||
email: "root@example.com",
|
||||
excludeLeaf: true,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "excludeLeaf with only leaf match returns -1",
|
||||
files: map[string]string{
|
||||
"": "admins:\n - root@example.com\n",
|
||||
"project": "admins:\n - alice@example.com\n",
|
||||
},
|
||||
dir: "project",
|
||||
email: "alice@example.com",
|
||||
excludeLeaf: true,
|
||||
want: -1,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeZddcTree(t, root, tc.files)
|
||||
dir := filepath.Join(root, tc.dir)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
InvalidateCache(dir)
|
||||
chain, err := EffectivePolicy(root, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("EffectivePolicy: %v", err)
|
||||
}
|
||||
if got := AdminLevelInChain(chain, tc.email, tc.excludeLeaf); got != tc.want {
|
||||
t.Errorf("AdminLevelInChain(dir=%q, email=%q) = %d, want %d",
|
||||
tc.dir, tc.email, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue