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>
164 lines
5.6 KiB
Go
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
|
|
}
|
|
|