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 }