ZDDC/zddc/internal/zddc/file.go
ZDDC 6d132572d3 chore(server): drop the federal reference Rego (bring-your-own-policy)
Decision: external OPA is a bring-your-own-policy escape hatch, not a
supported turnkey mode — so stop shipping access_federal.rego. A verb-blind
read-ACL policy under NIST AC-6 branding is a liability to hand a federal
evaluator, and (like access.rego before the fail-close) it over-granted writes
and ignored WORM. The HTTPDecider + Decider interface stay: operators who want
an AC-6 ancestor-deny-absolute posture write their own Rego.

- Delete rego/access_federal.rego, FederalRego, --print-rego=federal, and
  federal_parity_test.go; trim the federal cases from rego_failclosed_test.go.
- Reframe every doc reference (rego.go, main.go, file.go, ARCHITECTURE.md,
  README.md) to "operators write their own Rego"; rewrite the README
  "Reference Rego policy" section to describe the single fail-closed read-ACL
  skeleton accurately (it also still carried the now-removed "mirrors exactly"
  parity claim).

Out of scope (flagged): the broader federal-readiness narrative
(FedRAMP/FIPS/IdP) and the separate website page federal.html still discuss
federal posture — the OPA bring-your-own-Rego path stays valid, but a
deliberate review with the federal go-to-market in mind is warranted.

go vet + full go test ./... green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:45:21 -05:00

487 lines
24 KiB
Go

package zddc
import (
"os"
"gopkg.in/yaml.v3"
)
// ACLRules holds the access-control rules at one cascade level.
//
// One input form, keyed by principal (Permissions):
//
// - acl.permissions: { principal → verb-set }. 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.
//
// 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).
//
// A deployment running an external OPA with ancestor-deny-absolute
// (NIST AC-6) semantics should avoid the directive's fence-style "reset",
// since under that posture it would let a leaf widen access an ancestor
// refused. (zddc-server ships only the read-ACL skeleton at --print-rego;
// an AC-6 policy is the operator's own Rego.) The cascade tracer at
// /.profile/effective-policy reports `chain.visible_start` so an operator
// can verify which level a fence is actually cutting off.
//
// 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 {
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 (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"`
}
// ConvertMetadata supplies per-project template variables for the
// server-side MD→{docx,html,pdf} conversion endpoint. The handler
// resolves the effective set by walking the .zddc cascade leaf→root
// with per-key latest-wins (an empty deeper value does NOT clear an
// ancestor value — operators write the explicit string they want).
//
// Variables are passed to pandoc as -V key=value flags and consumed by
// pandoc/viewer-template.html's $if(client)$ / $if(project)$ /
// $if(contractor)$ / $if(project_number)$ blocks.
type ConvertMetadata struct {
Client string `yaml:"client,omitempty" json:"client,omitempty"`
Project string `yaml:"project,omitempty" json:"project,omitempty"`
Contractor string `yaml:"contractor,omitempty" json:"contractor,omitempty"`
ProjectNumber string `yaml:"project_number,omitempty" json:"project_number,omitempty"`
}
// ViewSpec is one entry in ZddcFile.Views: which tool renders a given URL
// shape, and the filename (under <dir>/.zddc.d/) of its supporting config.
// Config is optional (e.g. browse needs none). Both are plain data — no
// behaviour. See ZddcFile.Views.
type ViewSpec struct {
Tool string `yaml:"tool,omitempty" json:"tool,omitempty"`
Config string `yaml:"config,omitempty" json:"config,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.
//
// Tool HTML is resolved LOCALLY (no .zddc key): a real file on disk at the
// path → an "<app>.html" member of <ZDDC_ROOT>/.zddc.zip → the embedded
// default. There is no `apps:` / `apps_pubkey:` key and no upstream fetch.
type ZddcFile struct {
ACL ACLRules `yaml:"acl,omitempty" json:"acl,omitempty"`
Admins []string `yaml:"admins,omitempty" json:"admins,omitempty"`
Title string `yaml:"title,omitempty" json:"title,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"`
// Convert supplies template variables for the server-side
// MD→{docx,html,pdf} conversion endpoint (see internal/convert).
// Cascades leaf→root with per-key latest-wins. Pointer-to-struct
// so unset is distinguishable from "explicitly empty" — relevant
// because the cascade merger needs to know whether a deeper .zddc
// is contributing a value or just inheriting.
//
// Filename-derived variables (title, tracking_number, revision,
// status) come from zddc.ParseFilename and are NOT in this struct.
Convert *ConvertMetadata `yaml:"convert,omitempty" json:"convert,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/staging without trailing slash
// → transmittal). Empty means "no default" — the no-slash form
// 302s to the slash form, which serves DirTool (browse by default).
// Cascades through Paths: an ancestor's Paths entry can set
// DefaultTool for a virtual descendant without anyone creating
// that dir. This is the "specialized app" half of the slash/no-
// slash convention; see DirTool for the other half.
DefaultTool string `yaml:"default_tool,omitempty" json:"default_tool,omitempty"`
// DirTool is the tool name served at this directory's TRAILING-
// SLASH URL form (e.g. /Project/working/ → the directory view).
// Empty resolves to "browse" — the file-tree navigator — which is
// the site-wide default. An operator can override it per subtree
// (rare; e.g. a folder whose slash form should render some other
// directory-oriented view). JSON listing requests are unaffected:
// they always get the raw listing regardless of DirTool, so the
// browse SPA (or any client) can still enumerate entries.
// Cascades leaf→root like DefaultTool.
DirTool string `yaml:"dir_tool,omitempty" json:"dir_tool,omitempty"`
// Views declares, per URL shape, which tool renders and where its
// supporting config lives — the generalization of default_tool/dir_tool
// plus the form/table recognizers. Keys are URL shapes:
// "dir" — GET <dir> (no slash) e.g. {tool: tables, config: table.yaml}
// "dir_slash" — GET <dir>/ e.g. {tool: browse}
// "file" — GET <dir>/<file> (no slash) e.g. {tool: form, config: form.yaml}
// config is a filename resolved under <dir>/.zddc.d/ (the supporting-files
// reserve), server-resolved and injected (#view-context) since .zddc.d/ is
// not client-fetchable. A view is presentation/routing ONLY — it never
// grants access; ACL/WORM/admin stay server-enforced. default_tool /
// dir_tool are normalized into views.dir / views.dir_slash (kept as sugar).
// Cascades leaf→root like DefaultTool. No arbitrary code: tool ∈ the known
// app set, config is a path-bounded relative name.
Views map[string]ViewSpec `yaml:"views,omitempty" json:"views,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 cascade in). It is an OPT-IN an
// operator can set on any auto_own position; the current embedded default
// tree does NOT set it anywhere — the working/ staging/ incoming/
// reviewing/ <party> homes are auto-owned but UNFENCED, so ancestor grants
// (e.g. `project_team: cr` at working/) still cascade in, making them
// shared team folders rather than private per-user sandboxes. Default
// (nil/false) writes a non-fenced auto-own .zddc.
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
// AutoOwnRoles augments AutoOwn with role-level grants: when set,
// the auto-own .zddc written at a new child directory grants each
// listed role `rwcda` ALONGSIDE the creator email. Lets the schema
// express "the creator owns it AND any member of these roles has
// full authority" without resorting to a separate admins: list
// (which would be subtree-admin and bypass WORM / fences via
// elevation — too strong for typical workflows).
//
// Example: archive/<party>/ sets `auto_own_roles: [document_controller]`
// so any DC has rwcda at every party folder a peer created, not
// just at parties they personally mkdir'd.
//
// Grants are written as plain permissions in the new .zddc — they
// have no special semantic beyond what `rwcda` already means in
// the cascade. A fence (auto_own_fenced) still binds them.
AutoOwnRoles []string `yaml:"auto_own_roles,omitempty" json:"auto_own_roles,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"`
// PartySource names the registry that gates party-folder creation
// under THIS peer. When set (e.g. "ssr"), a new <party> segment may
// be created here only if the party is registered — i.e. the registry
// entry <project>/<party_source>/<party>.yaml (or .../<party>/) exists
// — otherwise the server 409s. The authoritative party registry is
// ssr/ (a party exists iff ssr/<party>.yaml exists); ssr/ itself sets
// no party_source. Leaf-only — the property describes THIS peer dir,
// checked via PartyRegistered at party-folder-creating entrypoints.
// Empty: no party gating.
PartySource string `yaml:"party_source,omitempty" json:"party_source,omitempty"`
// History enables server-managed edit-history versioning for text
// (markdown) writes in this subtree. When true, each save of a
// history-eligible file (see handler.IsTextHistoryCandidate) snapshots
// the content into <dir>/.history/<stem>/ and appends a server-stamped
// audit line (timestamp + authenticated email) to that folder's
// log.jsonl, then writes the live file. The live file at its natural
// path stays the source of truth; the .history/ store is immutable and
// auto-hidden (dot-prefixed).
//
// Unlike DropTarget (leaf-only), History is a SUBTREE behavior: a
// `history: true` at an ancestor (e.g. archive/<party>/working/)
// applies to every descendant, and it deliberately inherits through
// inherit:false ACL fences (per-user homes under working/ still record
// history) — versioning is a write behavior, not a permission. A
// deeper level may set `history: false` to opt a subtree out. Resolved
// by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade.
History *bool `yaml:"history,omitempty" json:"history,omitempty"`
// HistoryGlobs selects WHICH files get text edit-history by basename
// glob (e.g. ["*.md", "*.txt"]). The History flag gates whether
// snapshots are recorded; this says which file types qualify.
// Subtree behavior, deepest non-empty wins (PolicyChain.
// EffectiveHistoryGlobs); defaults to ["*.md"] when unset.
HistoryGlobs []string `yaml:"history_globs,omitempty" json:"history_globs,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 classifier,
// staging/ adds transmittal + classifier, etc.). browse hosts the
// markdown editor as a plugin so no extra tool is needed under
// working/ or reviewing/.
//
// 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"`
// ReceivedPath links a workflow folder (under reviewing/ or staging/)
// back to its canonical submittal in received/. Populated by the
// Plan Review composite endpoint at scaffold time and travels with
// the folder through the reviewing/ → staging/ → issued/ lifecycle.
// The path is relative to the master root (e.g. "archive/Acme/
// received/Acme-0042"), so it survives the workflow folder being
// moved between parents.
//
// When this field is non-empty, the listing handler synthesises
// a virtual `received/` child whose contents come from this path,
// and serveFilePut rewrites writes through that virtual prefix to
// `<workflow>/<base>+C<n><suffix>` comment files in the workflow
// folder itself (the canonical submittal is WORM).
ReceivedPath string `yaml:"received_path,omitempty" json:"received_path,omitempty"`
// PlannedReviewDate / PlannedResponseDate are the doc-controller's
// committed dates for this submittal's review-completion and
// response-issuance, set by Plan Review and stored on the
// CANONICAL submittal's .zddc (received/<tracking>/.zddc) — NOT on
// the workflow folders' .zddc files. The sub-admins (review lead,
// approver) manage ACLs in their respective workflow folders but
// cannot edit these dates, since the cascade does not grant them
// admin authority over received/.
//
// Both fields are ISO date strings (YYYY-MM-DD). Distinct from the
// workflow folder names' date prefixes, which are *forecast* dates
// — mutable via direct folder rename as estimates shift. Folder
// name = live forecast; .zddc planned date = original commitment.
// Comparing the issued/ folder's actual date against these planned
// dates after publish yields planned-vs-actual on-time analysis.
PlannedReviewDate string `yaml:"planned_review_date,omitempty" json:"planned_review_date,omitempty"`
PlannedResponseDate string `yaml:"planned_response_date,omitempty" json:"planned_response_date,omitempty"`
// FieldCodes declares the vocabulary of "field codes" used as
// components of tracking numbers and as constrained body fields
// on record YAMLs (mdl rows, rsk rows, ssr rows). The map key is
// the field name (originator, project, discipline, type,
// sequence, phase, area, suffix, row, …); the value declares the
// allowed shape via a discriminated union — see FieldCode.
//
// Cascade semantics: map-merge per top-level key (child entirely
// replaces parent's entry for the same code). This mirrors the
// Apps: cascade: a sub-tree can narrow or override a single
// field-code's vocabulary without dropping unrelated codes.
//
// Operators define field_codes at the project root (or higher),
// and lower-level .zddc files inherit. Empty in the embedded
// defaults — every deployment populates this per project.
FieldCodes map[string]FieldCode `yaml:"field_codes,omitempty" json:"field_codes,omitempty"`
// Records declares per-record-type rules keyed by filename
// pattern (literal basename like "ssr.yaml" or a glob like
// "*.yaml"). The entry describes the rules that apply to files
// matching that pattern in the directory the .zddc controls.
//
// See RecordRule for the structure: FilenameFormat (composition
// template), FieldDefaults (per-folder defaults), Locked (fields
// that must not be overridden), RowField + RowScopeFields (for
// table-with-rows record types like RSK).
//
// Cascade semantics: map-merge keyed by pattern; each RecordRule
// merges per-field (scalars overwrite, FieldDefaults map-merge,
// Locked concat-dedupe — see mergeRecordRule).
//
// Filename-pattern scoping is what lets SSR rules live at the
// party-folder level without bleeding onto the mdl/ or rsk/
// subfolders in the same folder: the SSR entry keys on
// "ssr.yaml", which only that one file matches.
Records map[string]RecordRule `yaml:"records,omitempty" json:"records,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
}
return zf, nil
}