diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index 8066502..9b7d365 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -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 diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 83d521d..a52ef39 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -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.