ZDDC/zddc/internal/zddc/admin.go
ZDDC 54dff4dcd3 feat(zddc): standard roles (document_controller, project_team) + role union/reset
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>
2026-05-12 10:17:46 -05:00

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
}