Answers "can roles reset as well as add?" — yes, both now.
Role membership UNIONS across the cascade:
- A deeper .zddc that defines an inherited role again with one
extra member ADDS that member (was: deepest definition shadowed
the ancestor's entirely).
- New `reset: true` on a role definition breaks the union — that
level's members are authoritative, ancestor definitions above
are excluded; descendants below still union on top. Use it to
give a project its own team independent of a deployment-wide
default.
- lookupRoleMembers / RoleMembers reworked: walk deep→shallow,
union members, stop at the first reset:true; finally fold in
chain.Embedded.Roles as the baseline so a role declared only in
defaults.zddc.yaml is "defined" (and a deployment's on-disk
redefinition unions on top).
Admin checks are now role-aware:
- IsSubtreeAdmin / CanEditZddc's strict-ancestor scan use
MatchesPrincipal instead of MatchesPattern, so `admins:
[document_controller]` resolves to the role's members. The
strict-ancestor scan resolves roles only up to level i, so a
role defined at the deepest level (= dirPath) never confers
self-edit rights.
Two standard roles ship in defaults.zddc.yaml (empty members — a
fresh deployment grants nothing until they're populated):
document_controller — files into the WORM zones. Gets:
- rw at the project level (read + overwrite-existing; NOT c, so
it can't make arbitrary folders)
- rwc at archive/ (can create party subfolders)
- subtree-admin at working/ and staging/ (full create + manage,
including taking over a fenced per-user home) — scoped HERE,
not at the project root, so the WORM constraint still binds
it in archive/<party>/received|issued
- listed in worm: on received/ and issued/ → write-once-create
survives the WORM mask
project_team — read-only across the project. The per-user
working home's fenced auto-own .zddc (rwcda for the creator)
wins via deepest-match, so "read-only except what I own" falls
out of the cascade with no special rule. Inside received/issued
their r is preserved (worm: doesn't strip read).
archive/<party>/ gains `auto_own: true` (UNFENCED) so whoever
creates a party subtree (normally the doc controller) owns it and
can set up that counterparty's .zddc afterward — without fencing,
project_team:r still cascades through to received/issued.
Tests: roles_test (union + reset), standardroles_test (the
doc-controller scoped-create matrix + project-team read-only-except-
owned), ensure_test updated for the new party-folder auto-own.
fileapi_test's WORM doc-controller test already uses worm: [role].
All Go + 248 Playwright tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
3.7 KiB
Go
112 lines
3.7 KiB
Go
package zddc
|
|
|
|
import "path/filepath"
|
|
|
|
// IsAdmin reports whether email 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.allow / acl.deny (see
|
|
// MatchesPattern). Returns false if the root file does not exist, has an
|
|
// empty Admins list, or no entry matches. An empty email never matches.
|
|
func IsAdmin(fsRoot, email string) bool {
|
|
if email == "" {
|
|
return false
|
|
}
|
|
zf, err := ParseFile(filepath.Join(fsRoot, ".zddc"))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, pattern := range zf.Admins {
|
|
if MatchesPattern(pattern, 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.
|
|
//
|
|
// 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.
|
|
//
|
|
// This is the read-side check — "can email *see* admin tools for this
|
|
// subtree?". For write authority over a specific .zddc file, use
|
|
// CanEditZddc, which adds the strict-ancestor rule that prevents
|
|
// self-elevation.
|
|
func IsSubtreeAdmin(fsRoot, dirPath, email string) bool {
|
|
if email == "" {
|
|
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, email, chain, i, ModeDelegated) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CanEditZddc reports whether email may write or delete <dirPath>/.zddc.
|
|
//
|
|
// The strict-ancestor rule: authority must come from a .zddc file STRICTLY
|
|
// ABOVE dirPath. An admin granted in <dirPath>/.zddc cannot edit that same
|
|
// file (which is what grants their own authority) — they can only edit
|
|
// .zddc files in deeper subtrees. This prevents a subtree admin from
|
|
// adding peers at their own level, removing their delegator, or otherwise
|
|
// elevating themselves.
|
|
//
|
|
// The root file <fsRoot>/.zddc is the bootstrap exception: it has no
|
|
// strict ancestor, so it is governed by its own Admins list (the same
|
|
// allowlist IsAdmin checks). The very first super-admin is created by
|
|
// hand-editing this file at server install time.
|
|
func CanEditZddc(fsRoot, dirPath, email string) bool {
|
|
if email == "" {
|
|
return false
|
|
}
|
|
fsRoot = filepath.Clean(fsRoot)
|
|
dirPath = filepath.Clean(dirPath)
|
|
|
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
if err != nil || len(chain.Levels) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Bootstrap: the root file is governed by its own Admins.
|
|
if dirPath == fsRoot {
|
|
for _, pattern := range chain.Levels[0].Admins {
|
|
if MatchesPattern(pattern, email) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Strict-ancestor: scan all levels EXCEPT the deepest, which IS dirPath.
|
|
// EffectivePolicy returns levels ordered root (index 0) → leaf (last).
|
|
// Admins entries may be email globs or role references (resolved
|
|
// against the chain up to level i — so a role defined at the
|
|
// deepest level, which is dirPath, never confers self-edit rights).
|
|
for i := 0; i < len(chain.Levels)-1; i++ {
|
|
for _, principal := range chain.Levels[i].Admins {
|
|
if MatchesPrincipal(principal, email, chain, i, ModeDelegated) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|