feat(handler): expose inherit fence in /.profile/effective-policy
The cascade tracer's JSON response now carries: - Top-level `cascade_mode` (string): the active mode (delegated / strict). Helps reviewers correlate the visible_start with the mode. - Top-level `chain.visible_start` (int): chain.VisibleStart(leaf, mode) — the lowest level whose grants the leaf can see, accounting for any inherit:false fence in delegated mode (always 0 in strict mode). - Per-level `inherit` (*bool, omitempty): the level's explicit inherit value, nil when absent. A reviewer can scan the levels and see which one fences ancestors. The level's `exists` flag now also fires for `permissions:` and `inherit:` entries (previously it only checked Allow/Deny/Admins), so the response correctly reflects modern .zddc files that use the permissions map. Test: TestServeProfileEffectivePolicy_InheritFence builds a vendor- folder layout, asks the tracer about a my-company user, confirms decision=false, visible_start=1 (fence at /Vendor/), leaf.Inherit= &false, root.Inherit=nil. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2ccd72fa35
commit
8ffbcb90d1
2 changed files with 85 additions and 2 deletions
|
|
@ -371,6 +371,10 @@ 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
|
||||||
|
|
@ -384,8 +388,14 @@ 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"`
|
||||||
}{
|
}{
|
||||||
|
|
@ -393,8 +403,11 @@ 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).
|
||||||
|
|
@ -425,7 +438,8 @@ 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,
|
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 {
|
if entry.Exists {
|
||||||
entry.Acl = &lvl.ACL
|
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) {
|
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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue