ZDDC/zddc/internal/zddc/admin.go
ZDDC f196205622 refactor(audit): pre-release cleanup pass
Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.

Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
  to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
  to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
  instead of allow/deny lists.

Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
  own their own .zddc; the policy decider's IsActiveAdmin short-circuit
  is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
  same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
  true after the retirement). Profile page renders AdminSubtrees
  directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
  IsAdminForChain — no production caller passed true.

Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).

ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
  branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
  InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
  GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
  EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
  MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
  /.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
  "deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
  is the parent-deny-is-absolute variant. The in-process Go evaluator
  implements only the commercial cascade.

Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
  .zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.

.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
  .zddc out of the dot-prefix guard so PUT/DELETE/POST reach
  ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
  the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
  API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
  (matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
  parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
  body is designed to materialize on PUT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:07 -05:00

164 lines
5.6 KiB
Go

package zddc
import "path/filepath"
// Principal is the caller identity passed to admin-authority checks.
//
// Email is the authenticated user's email (or token-issued email for
// machine clients). Elevated reports whether the caller has opted into
// their admin powers for THIS request. The opt-in is sourced upstream
// (in handler.ACLMiddleware): machine clients with bearer tokens are
// implicitly elevated; browser clients elevate via the zddc-elevate
// cookie set by the UI's elevation toggle. The Principal is built once
// per request — every admin function takes it, so the type system
// enforces the gate at every call site.
//
// Sudo-style semantics: an admin who hasn't elevated is treated as a
// regular user. Their normal ACL grants still apply (those use the
// email directly, not this Principal); only admin escape hatches
// (WORM bypass, auto-own takeover, .zddc edit authority, profile
// admin scaffolds) are gated.
type Principal struct {
Email string
Elevated bool
}
// gate is the common short-circuit for every admin-authority check:
// an empty email never matches, and an admin who hasn't elevated is
// treated as a non-admin regardless of what the .zddc files name.
func (p Principal) gate() bool {
return p.Email != "" && p.Elevated
}
// 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.
//
// Role lookups inside this walk are bounded to the levels visible
// AT the matching level (via MatchesPrincipal's third/fourth args
// and the fence-aware VisibleStart helper).
//
// 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) int {
if email == "" {
return -1
}
for i, level := range chain.Levels {
for _, principal := range level.Admins {
if MatchesPrincipal(principal, email, chain, i) {
return i
}
}
}
return -1
}
// IsAdminForChain is the boolean shortcut over AdminLevelInChain.
// Returns true iff some level grants admin authority.
func IsAdminForChain(chain PolicyChain, email string) bool {
return AdminLevelInChain(chain, email) >= 0
}
// HasAnyAdminGrant reports whether email is named as an admin somewhere
// in the cascade — either the root's admins: list (super-admin) or any
// subtree-admin grant via paths:.<dir>.admins. ELEVATION-INDEPENDENT:
// answers "could this user opt into admin powers if they wanted to?",
// which the header elevation toggle reads to decide whether to render
// itself. The elevation-AWARE checks (IsAdmin, IsSubtreeAdmin) take a
// Principal and short-circuit on !Elevated; this function just asks
// the cascade.
//
// Returns false for an empty email so anonymous callers can't probe.
func HasAnyAdminGrant(fsRoot, email string) bool {
if email == "" {
return false
}
// Root super-admin.
if zf, err := ParseFile(filepath.Join(fsRoot, ".zddc")); err == nil {
for _, pattern := range zf.Admins {
if MatchesPattern(pattern, email) {
return true
}
}
}
// Subtree-admin anywhere — walk every .zddc and check its
// effective chain.
dirs, _ := ScanZddcFiles(fsRoot)
for _, d := range dirs {
chain, err := EffectivePolicy(fsRoot, d)
if err != nil {
continue
}
for i, level := range chain.Levels {
for _, principal := range level.Admins {
if MatchesPrincipal(principal, email, chain, i) {
return true
}
}
}
}
return false
}
// IsAdmin reports whether p is listed in the admins entry of the ROOT
// .zddc file (<fsRoot>/.zddc). Subdirectory .zddc files' admins keys are
// deliberately ignored by this function — it gates the server-wide debug
// admin role (/.profile/{whoami,config,logs}) which only the bootstrap
// super-admin should reach.
//
// Subtree-scoped admin authority (the "fiefdom" model) is checked via
// IsSubtreeAdmin / CanEditZddc instead.
//
// Patterns use the same glob syntax as acl.permissions keys (see
// MatchesPattern). Returns false if the root file does not exist, has an
// empty Admins list, no entry matches, or the principal hasn't elevated.
func IsAdmin(fsRoot string, p Principal) bool {
if !p.gate() {
return false
}
zf, err := ParseFile(filepath.Join(fsRoot, ".zddc"))
if err != nil {
return false
}
for _, pattern := range zf.Admins {
if MatchesPattern(pattern, p.Email) {
return true
}
}
return false
}
// IsSubtreeAdmin reports whether email administers the subtree rooted at
// dirPath. Authority cascades: a match against any Admins entry on the chain
// from fsRoot down to dirPath (inclusive) confers admin rights for dirPath.
// Subtree admins own their own .zddc — both reading admin tools and
// writing the file itself are gated by this same check (the file API's
// ActionAdmin path on .zddc edits).
//
// Admins entries may be email-glob patterns OR role references (a bare
// role name, or @role:<name>) — resolved the same way acl.permissions
// keys are, so `admins: [document_controller]` works once a deployment
// populates that role.
func IsSubtreeAdmin(fsRoot, dirPath string, p Principal) bool {
if !p.gate() {
return false
}
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
for i, level := range chain.Levels {
for _, principal := range level.Admins {
if MatchesPrincipal(principal, p.Email, chain, i) {
return true
}
}
}
return false
}