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>
354 lines
16 KiB
Go
354 lines
16 KiB
Go
package zddc
|
|
|
|
import (
|
|
"os"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ACLRules holds the access-control rules at one cascade level.
|
|
//
|
|
// Three input forms, all merged at parse time into a single map keyed
|
|
// by principal (Permissions):
|
|
//
|
|
// - acl.permissions: { principal → verb-set } — the canonical form.
|
|
// Principal is an email pattern (contains "@") or a role name
|
|
// (no "@"); roles are looked up via ZddcFile.Roles in this file
|
|
// or any ancestor. Verb-set is a string drawn from {r,w,c,d,a};
|
|
// empty string is an explicit deny.
|
|
//
|
|
// - acl.allow: [pattern, ...] — legacy. Each pattern becomes
|
|
// Permissions[pattern] = "rwcd" at parse time.
|
|
//
|
|
// - acl.deny: [pattern, ...] — legacy. Each pattern becomes
|
|
// Permissions[pattern] = "" at parse time (explicit deny).
|
|
//
|
|
// Allow and Deny are retained on the struct for round-trip fidelity
|
|
// (and so existing operator-authored .zddc files render unchanged in
|
|
// the admin UI); the cascade evaluator reads only Permissions.
|
|
//
|
|
// Inherit controls whether this level imports grants and roles from
|
|
// its ancestors. The default (when the field is absent — represented
|
|
// here as a nil pointer) is "inherit normally." Setting `inherit: false`
|
|
// makes this level a fence: grants and roles defined in any ancestor
|
|
// .zddc are invisible at-and-below this point in the cascade. Useful
|
|
// for "complete reset, then add back specific principals" patterns
|
|
// (e.g. a vendor folder where only the vendor and the doc controller
|
|
// should have access regardless of broader project-level grants).
|
|
//
|
|
// In strict cascade mode (federal / NIST AC-6), inherit:false is
|
|
// REFUSED — a leaf-level directive cannot widen access an ancestor
|
|
// refused. The internal decider silently treats it as inherit:true;
|
|
// the cascade tracer (/.profile/effective-policy) reports both
|
|
// `cascade_mode` and `chain.visible_start` so an operator can see
|
|
// that a configured fence is being ignored under the active mode.
|
|
// Operators running the federal Rego preset get the same behaviour
|
|
// from policy enforcement.
|
|
//
|
|
// Inherit is per-level and not itself cascading: an ancestor's
|
|
// `inherit: false` does not transitively block descendants from
|
|
// adding their own grants — it only fences off ANCESTORS of the
|
|
// fenced level from the descendant subtree.
|
|
//
|
|
// JSON tags are present so this type round-trips cleanly when included
|
|
// in the external-OPA input body (see internal/policy). The canonical
|
|
// in-repo serialization is YAML; JSON is only used for OPA queries.
|
|
type ACLRules struct {
|
|
Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
|
|
Deny []string `yaml:"deny,omitempty" json:"deny,omitempty"`
|
|
Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
|
|
// Inherit *bool: nil = unset (inherit normally), &true = same,
|
|
// &false = fence ancestors. Using a pointer so the default is
|
|
// distinguishable from an explicit `inherit: true`.
|
|
Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`
|
|
}
|
|
|
|
// InheritsAncestors reports whether this level imports grants and
|
|
// roles from ancestors. True when Inherit is unset or explicitly true;
|
|
// false only when explicitly set to false.
|
|
func (r ACLRules) InheritsAncestors() bool {
|
|
return r.Inherit == nil || *r.Inherit
|
|
}
|
|
|
|
// Role is the named principal-grouping primitive. Members are email
|
|
// patterns (same syntax as the legacy allow/deny entries — see
|
|
// MatchesPattern). A role defined at level L is in scope at L and all
|
|
// descendants.
|
|
//
|
|
// Role membership UNIONS across the cascade: if the same role name is
|
|
// defined at multiple levels, the effective member set is the union
|
|
// of all those definitions. So a deeper .zddc that lists one extra
|
|
// member ADDS it to the inherited role rather than replacing the
|
|
// whole list.
|
|
//
|
|
// Reset breaks the union: when true, this level's definition is
|
|
// authoritative for the role — ancestor (shallower) definitions are
|
|
// ignored. Descendants that also define the role (without reset)
|
|
// still union on top. Use reset to start a role's membership fresh at
|
|
// a subtree boundary (e.g. a project that wants its own project_team
|
|
// independent of the deployment-wide default).
|
|
type Role struct {
|
|
Members []string `yaml:"members,omitempty" json:"members,omitempty"`
|
|
Reset bool `yaml:"reset,omitempty" json:"reset,omitempty"`
|
|
}
|
|
|
|
// ZddcFile represents the parsed contents of a .zddc configuration file.
|
|
//
|
|
// Admins is honored only in the root .zddc file (<ZDDC_ROOT>/.zddc); subdir
|
|
// .zddc files have their Admins entry ignored by IsAdmin so that someone who
|
|
// can write into a subtree cannot grant themselves admin access. ACL on the
|
|
// other hand cascades — see EffectivePolicy / AllowedWithChain.
|
|
//
|
|
// Title is read only from per-project .zddc files (the file directly inside
|
|
// each project root) by ServeProjectList; it surfaces a human-readable name
|
|
// for the project on the landing-page picker. Optional — projects without a
|
|
// title fall back to displaying the directory name.
|
|
//
|
|
// Apps is a per-directory cascade override mapping app name → source spec.
|
|
// The spec is one of: "stable" / "beta" / "alpha" (channel on the canonical
|
|
// upstream), "v0.0.4" / "v0.0" / "v0" (version pin on the canonical
|
|
// upstream), an absolute "https://..." URL (custom mirror), or a relative
|
|
// or absolute filesystem path (./local.html, /opt/zddc/foo.html).
|
|
//
|
|
// On a request for a tool HTML, zddc-server walks .zddc files leaf→root
|
|
// looking for an Apps entry; first match wins. With no entry anywhere, the
|
|
// server serves the version baked into the binary at compile time (//go:embed).
|
|
// Fetched URL sources are cached in <ZDDC_ROOT>/_app/; the cache is fetch-once
|
|
// and never re-validates — operators delete the file to force a refetch.
|
|
//
|
|
// AppsPubKey is the inline PEM of the Ed25519 public key used to verify
|
|
// signatures on URL-fetched apps artifacts. Honored only at the root
|
|
// .zddc file (same root-only treatment as Admins, for the same reason:
|
|
// it's a trust anchor; subtree write authority must not be able to
|
|
// re-anchor it). Lower priority than --apps-pubkey / ZDDC_APPS_PUBKEY:
|
|
// when both are set, the env/flag (file path) wins. Empty in either
|
|
// place = URL-fetched apps refused (only embedded + local-path apps
|
|
// work). See zddc-server's setupApps.
|
|
type ZddcFile struct {
|
|
ACL ACLRules `yaml:"acl" json:"acl"`
|
|
Admins []string `yaml:"admins" json:"admins,omitempty"`
|
|
Title string `yaml:"title" json:"title,omitempty"`
|
|
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
|
|
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
|
|
|
|
// Tables declares directory-of-YAML table views available at this
|
|
// directory. The map key becomes the URL stem: tables[MDL] is served
|
|
// at <dir>/MDL.table.html. The value is a path (relative to this
|
|
// .zddc) to a *.table.yaml spec describing columns and the rows
|
|
// directory. There is no upward cascade for tables in v1 — each
|
|
// directory that hosts a table declares it directly.
|
|
Tables map[string]string `yaml:"tables,omitempty" json:"tables,omitempty"`
|
|
|
|
// Display maps a child entry's on-disk name to a human-friendly
|
|
// label rendered by browse / archive / landing in place of the raw
|
|
// folder name. The on-disk name remains canonical (lowercase for
|
|
// the project-root folders); only the rendered string changes.
|
|
//
|
|
// Match is case-insensitive on the key. Example, on Project-3/.zddc:
|
|
//
|
|
// display:
|
|
// archive: "Records"
|
|
// working: "In-Progress"
|
|
//
|
|
// Effect: project-3 listings show "Records" and "In-Progress" in
|
|
// the tree, but URLs still resolve at /Project-3/archive/ and
|
|
// /Project-3/working/. No upward cascade in v1 — a parent .zddc
|
|
// doesn't relabel grand-children. Operators set display: on the
|
|
// directory whose entries they want renamed.
|
|
Display map[string]string `yaml:"display,omitempty" json:"display,omitempty"`
|
|
|
|
// Roles are named principal groups available at this level and below.
|
|
// See Role for member syntax.
|
|
Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"`
|
|
|
|
// CreatedBy records the email of the user who triggered the .zddc's
|
|
// creation via the file API's mkdir post-hook (Incoming/Working/Staging
|
|
// only). It is an audit field; the cascade evaluator does not consult
|
|
// it. The auto-generated .zddc grants the creator's email directly via
|
|
// ACL.Permissions, the same way operators grant access to anyone else.
|
|
CreatedBy string `yaml:"created_by,omitempty" json:"created_by,omitempty"`
|
|
|
|
// Inherit controls whether the cascade walker descends below this
|
|
// .zddc into lower (ancestor) levels — including the embedded
|
|
// defaults that sit at the bottom. Defaults to true (cascade walks
|
|
// to the root + embedded layer).
|
|
//
|
|
// Set to false on a .zddc to stop the descent: that level becomes
|
|
// the bottom of the effective cascade. Use this at the on-disk
|
|
// root /.zddc to fully ignore the embedded defaults (the operator
|
|
// takes full responsibility for spelling out every rule from
|
|
// scratch). Useful at deeper levels too — e.g. a sandbox subtree
|
|
// that wants none of the project's policy.
|
|
//
|
|
// Pointer so an unset value (nil) is distinguishable from explicit
|
|
// false. nil == defaults to true.
|
|
Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`
|
|
|
|
// DefaultTool is the tool name served at this directory's
|
|
// no-slash URL form (e.g. /Project/working without trailing slash
|
|
// → mdedit). Empty means "no default" — the slash convention's
|
|
// browse listing wins and the no-slash form 302s. Cascades
|
|
// through Paths: an ancestor's Paths entry can set DefaultTool
|
|
// for a virtual descendant without anyone creating that dir.
|
|
DefaultTool string `yaml:"default_tool,omitempty" json:"default_tool,omitempty"`
|
|
|
|
// AutoOwn controls whether the file API's mkdir post-hook writes
|
|
// an auto-owned .zddc granting the creator rwcda at the new
|
|
// directory. Useful for working/staging/incoming-style drafting
|
|
// surfaces where the first creator should "own" what they
|
|
// created. Empty (nil) inherits via cascade.
|
|
AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"`
|
|
|
|
// AutoOwnFenced augments AutoOwn: when true, the generated .zddc
|
|
// is written with `inherit: false` so the new directory is
|
|
// private to its creator (ancestor ACL grants don't apply). Used
|
|
// for per-user home folders under working/<email>/. Default
|
|
// (nil/false) writes a non-fenced auto-own .zddc — ancestor
|
|
// admin grants still apply.
|
|
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
|
|
|
|
// Virtual marks a directory as never-materialise-on-disk. The
|
|
// server treats requests under such a path as virtual routes
|
|
// rather than triggering EnsureCanonicalAncestors. The reviewing
|
|
// aggregator is the canonical example. Empty (nil) inherits via
|
|
// cascade.
|
|
Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"`
|
|
|
|
// DropTarget marks this directory as a destination for drag-drop
|
|
// uploads in the browse client. The directory listing's response
|
|
// header (X-ZDDC-Drop-Target) surfaces this to the SPA, which
|
|
// shows the drop-zone overlay only at scopes where the cascade
|
|
// permits uploads. Leaf-only — the property describes THIS dir,
|
|
// not its descendants. Defaults (nil): no drop zone.
|
|
DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"`
|
|
|
|
// Worm marks this directory (and its descendants) as
|
|
// write-once-read-many. A non-nil Worm list — even an empty one —
|
|
// puts the path into a WORM zone with these effects, applied AFTER
|
|
// the normal cascade ACL and BEFORE any admin escape hatch:
|
|
//
|
|
// - write (w) and delete (d) are stripped for everyone
|
|
// - create (c) is stripped for everyone EXCEPT the principals
|
|
// listed here — they get read + write-once-create ("cr")
|
|
// - read (r) for non-listed principals is whatever the normal
|
|
// cascade ACL granted (the WORM list does not itself confer
|
|
// read to outsiders, only to its own members)
|
|
//
|
|
// Each entry is an email-glob pattern (or @role:<name> / a bare
|
|
// role name). An empty list ([]) is a WORM zone with no
|
|
// create-capable principals — the embedded baseline ships this
|
|
// on received/ and issued/ with the `document_controller` role
|
|
// named but member-empty, so a deployment enables filing simply
|
|
// by populating that role. Worm lists UNION across the cascade —
|
|
// a deeper .zddc adds more controllers.
|
|
//
|
|
// Admins (root or subtree) bypass the WORM constraint entirely;
|
|
// the handler does the IsAdmin / IsSubtreeAdmin check before
|
|
// invoking the policy evaluator. WORM is a normal-user
|
|
// constraint, not an absolute one — mis-filed documents still
|
|
// need a human escape.
|
|
Worm []string `yaml:"worm,omitempty" json:"worm,omitempty"`
|
|
|
|
// AvailableTools restricts which tools the server will auto-serve
|
|
// at this directory and its descendants. The effective list is the
|
|
// concat-dedupe union of all AvailableTools across the cascade
|
|
// (leaf → root → embedded); a tool not in that union is denied
|
|
// auto-route at this path.
|
|
//
|
|
// Empty list at every level means "no tools available" (effectively
|
|
// blocks all auto-serving); the embedded defaults seed the
|
|
// universal baseline of archive/browse/landing at root. Operators
|
|
// can add tools at deeper levels (working/ adds mdedit + classifier,
|
|
// staging/ adds transmittal + classifier, etc.).
|
|
//
|
|
// This does NOT gate explicit static files: an on-disk
|
|
// <dir>/transmittal.html is always served. It gates only the
|
|
// apps-subsystem auto-route.
|
|
AvailableTools []string `yaml:"available_tools,omitempty" json:"available_tools,omitempty"`
|
|
|
|
// Paths declares virtual sub-directory rules without those
|
|
// directories needing to exist on disk. Each key is a single path
|
|
// segment — either a literal name or `*` (matches any segment).
|
|
// The value is a nested ZddcFile that applies at the matching
|
|
// child directory.
|
|
//
|
|
// Recursive: a Paths entry's value may itself have a Paths map,
|
|
// matching further-down segments.
|
|
//
|
|
// Example, for a tree where every project should treat archive/
|
|
// as the workflow archive and a party's incoming/ as the
|
|
// classifier landing zone — without anyone creating those folders
|
|
// on disk:
|
|
//
|
|
// paths:
|
|
// "*": # project name (any)
|
|
// paths:
|
|
// archive:
|
|
// paths:
|
|
// "*": # party name
|
|
// paths:
|
|
// incoming:
|
|
// default_tool: classifier
|
|
//
|
|
// Match: literal-segment key first (case-insensitive); fall back
|
|
// to a "*" key if present. Multi-segment keys (e.g. "a/b") are
|
|
// NOT supported in v1 — express depth via nested Paths blocks.
|
|
//
|
|
// Virtual contributions from ancestor Paths are merged into the
|
|
// effective ZddcFile at each level by EffectivePolicy. On-disk
|
|
// .zddc at the matching directory wins per-field (most specific
|
|
// overrides). An inherit:false on any level drops the ancestor
|
|
// contributions from that level and below.
|
|
Paths map[string]ZddcFile `yaml:"paths,omitempty" json:"paths,omitempty"`
|
|
}
|
|
|
|
// ParseFile reads and parses a .zddc YAML file.
|
|
// Returns an empty ZddcFile (no rules) if the file does not exist.
|
|
func ParseFile(path string) (ZddcFile, error) {
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return ZddcFile{}, nil
|
|
}
|
|
if err != nil {
|
|
return ZddcFile{}, err
|
|
}
|
|
return parseBytes(data)
|
|
}
|
|
|
|
// parseBytes is the shared YAML→ZddcFile path used by ParseFile and
|
|
// EmbeddedDefaults. Returns a zero ZddcFile if data is empty.
|
|
func parseBytes(data []byte) (ZddcFile, error) {
|
|
if len(data) == 0 {
|
|
return ZddcFile{}, nil
|
|
}
|
|
var zf ZddcFile
|
|
if err := yaml.Unmarshal(data, &zf); err != nil {
|
|
return ZddcFile{}, err
|
|
}
|
|
mergeLegacyACL(&zf.ACL)
|
|
return zf, nil
|
|
}
|
|
|
|
// mergeLegacyACL folds legacy acl.allow / acl.deny lists into the
|
|
// canonical ACL.Permissions map so cascade evaluators only need to
|
|
// consult one place. Existing entries in Permissions take precedence
|
|
// (operators who specified both forms get the new form's value);
|
|
// allow entries become "rwcd" grants, deny entries become "" denies.
|
|
func mergeLegacyACL(rules *ACLRules) {
|
|
if len(rules.Allow) == 0 && len(rules.Deny) == 0 {
|
|
return
|
|
}
|
|
if rules.Permissions == nil {
|
|
rules.Permissions = make(map[string]string, len(rules.Allow)+len(rules.Deny))
|
|
}
|
|
for _, pat := range rules.Allow {
|
|
if _, present := rules.Permissions[pat]; !present {
|
|
rules.Permissions[pat] = "rwcd"
|
|
}
|
|
}
|
|
for _, pat := range rules.Deny {
|
|
if _, present := rules.Permissions[pat]; !present {
|
|
rules.Permissions[pat] = ""
|
|
}
|
|
}
|
|
}
|