Completes the migration. The embedded per-depth tree (internal/zddc/defaults/)
is now the sole source of the shipped baseline; defaults.zddc.yaml is deleted.
- EmbeddedDefaults() assembles the tree (no yaml). show-defaults now emits a
.zddc.zip (per-depth, "*" wildcard members) via EmbeddedDefaultsZip() —
operators redirect it to <ROOT>/.zddc.zip (or any directory) and edit/add/
delete individual members.
- Dropped EmbeddedDefaultsBytes; reworked the dumpable test to validate the
emitted zip; removed the now-redundant tree-vs-yaml oracle (the Layer-2
matrix is the ongoing behavioral guarantee, and it stays green).
- Swept stale "defaults.zddc.yaml" comment references to the embedded tree.
- GRAMMAR.md §1/§6 updated: .zddc.zip is a policy bundle mountable at ANY
directory (subtree mount; inherit:false + acl.inherit:false = island); the
shipped baseline is the embedded bundle at the root.
Net of the 6-phase migration: policy is per-depth .zddc files in a .zddc.zip
that an operator can drop at any level to override the cascade; the engine
(Assemble + the unchanged walker) enforces it. Full Go suite + matrix green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
257 lines
8.3 KiB
Go
257 lines
8.3 KiB
Go
package zddc
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// VerbSet is a bitmask over the five permission verbs r, w, c, d, a.
|
|
// Construct via ParseVerbSet (tolerant of any letter order, ignores
|
|
// duplicates and whitespace, rejects unknown letters as a deny). The
|
|
// canonical string form sorts to "rwcda" — see VerbSet.String.
|
|
type VerbSet uint8
|
|
|
|
const (
|
|
VerbR VerbSet = 1 << iota // read file bytes / list directory
|
|
VerbW // overwrite existing / rename existing
|
|
VerbC // create new file or directory
|
|
VerbD // delete file
|
|
VerbA // modify ACL of this subtree
|
|
|
|
VerbAll = VerbR | VerbW | VerbC | VerbD | VerbA
|
|
|
|
// VerbsRWCD is the every-non-admin verb set — granted to a principal
|
|
// that holds read+write+create+delete but not admin authority.
|
|
VerbsRWCD = VerbR | VerbW | VerbC | VerbD
|
|
|
|
// VerbsRC is the WORM-mask survivor: read + create only. Drop boxes
|
|
// (doc controller filing into Issued/Received) and any other principal
|
|
// with cascade-derived broader rights end up here once the mask runs.
|
|
VerbsRC = VerbR | VerbC
|
|
)
|
|
|
|
// ParseVerbSet parses a verb-set string like "rwcd" or "cra". Empty
|
|
// string returns an explicit-deny (zero VerbSet). Any unknown letter
|
|
// returns ok=false; callers that round-trip operator-authored YAML
|
|
// should surface this as a parse error rather than silently dropping
|
|
// the entry.
|
|
func ParseVerbSet(s string) (VerbSet, bool) {
|
|
var v VerbSet
|
|
for _, r := range s {
|
|
switch r {
|
|
case 'r', 'R':
|
|
v |= VerbR
|
|
case 'w', 'W':
|
|
v |= VerbW
|
|
case 'c', 'C':
|
|
v |= VerbC
|
|
case 'd', 'D':
|
|
v |= VerbD
|
|
case 'a', 'A':
|
|
v |= VerbA
|
|
case ' ', '\t':
|
|
// tolerate whitespace
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// String returns the canonical "rwcda" ordering with only the verbs
|
|
// present in the set. The empty set serializes to "" — round-trippable
|
|
// as the explicit-deny entry.
|
|
func (v VerbSet) String() string {
|
|
var b strings.Builder
|
|
if v&VerbR != 0 {
|
|
b.WriteByte('r')
|
|
}
|
|
if v&VerbW != 0 {
|
|
b.WriteByte('w')
|
|
}
|
|
if v&VerbC != 0 {
|
|
b.WriteByte('c')
|
|
}
|
|
if v&VerbD != 0 {
|
|
b.WriteByte('d')
|
|
}
|
|
if v&VerbA != 0 {
|
|
b.WriteByte('a')
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// Has reports whether the set contains every verb in mask.
|
|
func (v VerbSet) Has(mask VerbSet) bool { return v&mask == mask }
|
|
|
|
// Union returns the verb-wise union.
|
|
func (v VerbSet) Union(o VerbSet) VerbSet { return v | o }
|
|
|
|
// Intersect returns the verb-wise intersection.
|
|
func (v VerbSet) Intersect(o VerbSet) VerbSet { return v & o }
|
|
|
|
// IsPrincipalRole reports whether a Permissions key is a role
|
|
// reference (no "@") rather than a direct email pattern. This is the
|
|
// disambiguation rule: any principal containing "@" is treated as an
|
|
// email pattern matched via MatchesPattern; everything else is a role
|
|
// name looked up via Roles maps in the cascade.
|
|
func IsPrincipalRole(principal string) bool {
|
|
return !strings.Contains(principal, "@")
|
|
}
|
|
|
|
// RoleMembers returns the effective member-pattern list for roleName
|
|
// as visible at chain.Levels[levelIdx] — the UNION of every level's
|
|
// definition in the visible chain, with a role.Reset=true level
|
|
// stopping the walk (its members plus anything deeper; ancestors
|
|
// above the reset excluded). The visible-chain lower bound is
|
|
// chain.VisibleStart(levelIdx) — an inherit:false fence at-or-below
|
|
// levelIdx hides definitions above it. Returns nil if no level in the
|
|
// visible chain defines the role.
|
|
//
|
|
// Levels are stored root (index 0) → leaf (last index), matching the
|
|
// EffectivePolicy convention.
|
|
func RoleMembers(chain PolicyChain, levelIdx int, roleName string) []string {
|
|
members, _ := lookupRoleMembers(chain, levelIdx, roleName)
|
|
return members
|
|
}
|
|
|
|
// MatchesPrincipal reports whether email satisfies the given Permissions
|
|
// key at chain.Levels[levelIdx].
|
|
//
|
|
// Resolution order:
|
|
//
|
|
// 1. Principals containing "@" are always email patterns; dispatch to
|
|
// MatchesPattern.
|
|
// 2. Principals without "@" are role-or-pattern. Look up the name in
|
|
// the cascade's roles, honoring fences. If a role definition is
|
|
// found in the visible chain, match the user against the role's
|
|
// members. If no role definition exists in the visible chain, fall
|
|
// back to MatchesPattern so bare wildcards like "*" still match.
|
|
func MatchesPrincipal(principal, email string, chain PolicyChain, levelIdx int) bool {
|
|
if !IsPrincipalRole(principal) {
|
|
return MatchesPattern(principal, email)
|
|
}
|
|
members, defined := lookupRoleMembers(chain, levelIdx, principal)
|
|
if !defined {
|
|
// Bare wildcards / unqualified strings still match via the
|
|
// email-pattern matcher when no role of that name exists.
|
|
return MatchesPattern(principal, email)
|
|
}
|
|
for _, m := range members {
|
|
if MatchesPattern(m, email) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// lookupRoleMembers returns the member list and whether the role was
|
|
// defined anywhere in the visible chain. Distinguishes "role exists
|
|
// but is empty" (defined=true, empty members) from "role not defined"
|
|
// (defined=false), which the principal-fallback logic depends on. The
|
|
// visible-chain bound is determined by chain.VisibleStart(levelIdx).
|
|
//
|
|
// Members UNION across every level that defines the role. Walking
|
|
// deep→shallow, a level with role.Reset=true stops the walk: its
|
|
// members (plus anything deeper that already accumulated) are the
|
|
// final set; ancestor definitions above the reset are excluded.
|
|
func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]string, bool) {
|
|
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
|
return nil, false
|
|
}
|
|
floor := chain.VisibleStart(levelIdx)
|
|
var members []string
|
|
seen := make(map[string]struct{})
|
|
defined := false
|
|
addAll := func(ms []string) {
|
|
for _, m := range ms {
|
|
if _, dup := seen[m]; dup {
|
|
continue
|
|
}
|
|
seen[m] = struct{}{}
|
|
members = append(members, m)
|
|
}
|
|
}
|
|
for i := levelIdx; i >= floor; i-- {
|
|
role, ok := chain.Levels[i].Roles[roleName]
|
|
if !ok {
|
|
continue
|
|
}
|
|
defined = true
|
|
addAll(role.Members)
|
|
if role.Reset {
|
|
return members, true // authoritative; ignore ancestors + embedded
|
|
}
|
|
}
|
|
// The embedded defaults sit below chain.Levels[0] in the cascade.
|
|
// Fold its role definitions in as the baseline (so a role declared
|
|
// only in internal/zddc/defaults/ is "defined", and a deployment's
|
|
// on-disk redefinition unions on top). Skipped above only if a
|
|
// reset:true level already returned.
|
|
if role, ok := chain.Embedded.Roles[roleName]; ok {
|
|
defined = true
|
|
addAll(role.Members)
|
|
}
|
|
return members, defined
|
|
}
|
|
|
|
// MatchingPrincipals returns the keys of level.ACL.Permissions whose
|
|
// principal matches email at chain.Levels[levelIdx]. Output is sorted
|
|
// for stable iteration in tests and audit logs.
|
|
func MatchingPrincipals(chain PolicyChain, levelIdx int, email string) []string {
|
|
if levelIdx < 0 || levelIdx >= len(chain.Levels) {
|
|
return nil
|
|
}
|
|
level := chain.Levels[levelIdx]
|
|
if len(level.ACL.Permissions) == 0 {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for principal := range level.ACL.Permissions {
|
|
if MatchesPrincipal(principal, email, chain, levelIdx) {
|
|
out = append(out, principal)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// RolesForPrincipalInChain returns the sorted, de-duplicated role names
|
|
// that email is a member of, as roles resolve at the chain's leaf level —
|
|
// honouring inherit:false fences and role resets via MatchesPrincipal.
|
|
// Role names declared anywhere in the visible chain OR in the embedded
|
|
// defaults are considered (so a standard role like document_controller
|
|
// that ships empty but gains members from an on-disk .zddc is reported).
|
|
// Returns nil for an empty email or empty chain.
|
|
//
|
|
// This is "which roles do I hold HERE" — roles are cascade-scoped, so the
|
|
// answer can differ between locations. The file handler surfaces it via
|
|
// /.profile/access?path=… (AccessView.PathRoles).
|
|
func RolesForPrincipalInChain(chain PolicyChain, email string) []string {
|
|
if email == "" || len(chain.Levels) == 0 {
|
|
return nil
|
|
}
|
|
leaf := len(chain.Levels) - 1
|
|
seen := make(map[string]struct{})
|
|
var out []string
|
|
consider := func(name string) {
|
|
if _, dup := seen[name]; dup {
|
|
return
|
|
}
|
|
seen[name] = struct{}{}
|
|
if MatchesPrincipal(name, email, chain, leaf) {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
floor := chain.VisibleStart(leaf)
|
|
for i := leaf; i >= floor; i-- {
|
|
for name := range chain.Levels[i].Roles {
|
|
consider(name)
|
|
}
|
|
}
|
|
for name := range chain.Embedded.Roles {
|
|
consider(name)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|