Replaces the binary acl.allow/deny model with five permission verbs
(r/w/c/d/a) and first-class roles, and adds an authenticated file API
(PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over
HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps.
File API (zddc/internal/handler/fileapi.go)
- PUT <new> → action c
- PUT <existing> → action w
- PUT <.zddc> → action a (CanEditZddc strict-ancestor rule)
- DELETE → action d
- POST mkdir → action c (auto-writes creator-owned .zddc when the
parent is Incoming/Working/Staging)
- POST move → action w on src + c on dst, atomic via os.Rename
- Optional If-Match for optimistic concurrency, --max-write-bytes cap,
audit log emits a structured file_write event per operation.
Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go)
- acl.permissions: { principal → verb-set } map; principals are email
patterns or role names. Empty verb set is an explicit deny.
- roles: { name → members } definitions, available at the level they
declare and all descendants. Closer-to-leaf shadows ancestor.
- Legacy acl.allow/deny still work; they fold into permissions at
parse time (allow → "rwcd", deny → "").
- Cascade walks leaf→root; first level with any matching entry wins;
the union of matching verb sets at that level decides.
- --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an
ancestor explicit-deny is absolute (NIST AC-6). Default delegated
preserves the existing commercial behavior.
Special folders (zddc/internal/zddc/special.go)
- Incoming / Working / Staging: mkdir auto-writes a .zddc into the new
subdir granting created_by + that email rwcda directly. Same form
operators write by hand; creator can edit it later to add others.
- Issued / Received: server-enforced WORM split. Cascade grants
inherited from above the WORM folder are masked to r only; grants
placed at-or-below the WORM folder retain r,c. Operators grant
write-once (cr) to the doc controller via an explicit .zddc at the
Issued/Received folder. Admins exempt — only escape hatch.
Browser polyfill (shared/zddc-source.js)
- HttpDirectoryHandle + HttpFileHandle implement the FS Access API
surface (values, getFileHandle, createWritable, removeEntry,
queryPermission/requestPermission) over zddc-server's listing JSON
and file API. Existing tools written against showDirectoryPicker
work unchanged.
- detectServerRoot() returns { handle, status }: tools auto-load on
HTTP, surface a clear "no permission to list" message on 403, and
fall back to the welcome screen on 0.
- classifier renames take the atomic POST move path on HTTP-backed
handles; mdedit and transmittal route reads/writes through the
polyfill so prior FS-API code paths cover both modes.
Tests
- zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover
delegated vs strict, role membership / shadowing / legacy fallback,
WORM split semantics, verb-set parser round-trip.
- zddc/internal/handler/fileapi_test.go now also covers role-based
vendor scenarios, WORM blocking vendor & doc controller writes,
explicit Issued .zddc unlocking the cr drop-box, admin bypass,
auto-ownership on mkdir, and strict-mode lockouts.
Docs
- ARCHITECTURE.md + zddc/README.md document the verb model, role
syntax, special-folder behaviors, cascade-mode flag, and full file
API surface. Federal-readiness gap analysis strikes AC-3(7) and
AC-6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
4.5 KiB
Go
122 lines
4.5 KiB
Go
package zddc
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// SpecialFolderNames is the canonical list of folder names that drive
|
|
// per-tool availability rules and post-cascade access-decision behaviors.
|
|
// Centralized here so apps/availability and the access-control evaluator
|
|
// share one source of truth.
|
|
//
|
|
// - "Incoming" — vendor drop point; mkdir auto-ownership applies (creator
|
|
// becomes the new subtree's admin).
|
|
// - "Working" — internal pre-publication workspace; mkdir auto-ownership.
|
|
// - "Staging" — outbound transmittal staging; mkdir auto-ownership.
|
|
// - "Issued" — immutable archive of documents we sent out. WORM mask
|
|
// strips w/d/a from non-admin principals.
|
|
// - "Received" — immutable archive of documents we accepted. Same WORM
|
|
// semantics as Issued.
|
|
//
|
|
// Names are case-sensitive and exactly capitalized — operators name their
|
|
// folders this way by convention. A folder spelled differently (e.g.
|
|
// "incoming") is just a regular folder with no special semantics.
|
|
var SpecialFolderNames = []string{
|
|
"Incoming",
|
|
"Working",
|
|
"Staging",
|
|
"Issued",
|
|
"Received",
|
|
}
|
|
|
|
// AutoOwnFolderNames is the subset of SpecialFolderNames where the file
|
|
// API's mkdir post-hook auto-writes a creator-owned .zddc into the new
|
|
// subdirectory. Issued / Received are deliberately excluded — filing in
|
|
// the immutable archive should not create owned subtrees inside it.
|
|
var AutoOwnFolderNames = []string{"Incoming", "Working", "Staging"}
|
|
|
|
// WormFolderNames is the subset of SpecialFolderNames covered by the
|
|
// post-cascade WORM mask. Any path whose chain crosses one of these
|
|
// names has w/d/a stripped from non-admin principals.
|
|
var WormFolderNames = []string{"Issued", "Received"}
|
|
|
|
// IsAutoOwnParent reports whether a folder named name should trigger
|
|
// the mkdir auto-ownership .zddc write when a child is created inside
|
|
// it. Used by the file API's mkdir handler.
|
|
func IsAutoOwnParent(name string) bool {
|
|
for _, n := range AutoOwnFolderNames {
|
|
if name == n {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsWormPath reports whether requestPath is inside an "Issued" or
|
|
// "Received" subtree. The check is purely on path segments — a file
|
|
// named "Issued.txt" does not trigger WORM, but
|
|
// "/Project/Vendor/Issued/foo.pdf" does, as does
|
|
// "/Project/Vendor/Issued/" itself. requestPath may be a URL path
|
|
// ("/foo/bar") or a filesystem path; only segment names matter.
|
|
func IsWormPath(requestPath string) bool {
|
|
clean := strings.Trim(filepath.ToSlash(requestPath), "/")
|
|
if clean == "" {
|
|
return false
|
|
}
|
|
for _, seg := range strings.Split(clean, "/") {
|
|
for _, name := range WormFolderNames {
|
|
if seg == name {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// WormMask reduces a verb set to the subset that survives the WORM
|
|
// constraint: the bitwise AND with VerbsRC. Removes w, d, and a.
|
|
//
|
|
// Callers apply this only when IsWormPath(path) is true AND the
|
|
// principal is NOT an admin (root admin or subtree admin) — admins
|
|
// are the deliberate escape hatch for mis-filed documents.
|
|
//
|
|
// The WORM mask is split-aware via WormFolderLevelIndex: grants
|
|
// inherited from ancestors above the Issued/Received folder are
|
|
// masked to read only ({r}), while grants at-or-below the WORM
|
|
// folder retain {r, c} so an operator can place a .zddc at the
|
|
// Issued folder explicitly granting `_doc_controller: cr`.
|
|
func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC }
|
|
|
|
// WormFolderLevelIndex returns the chain index of the deepest
|
|
// "Issued" or "Received" segment in requestPath. The chain
|
|
// corresponds to the directory tree from root (index 0) to the
|
|
// requested directory; level i is the .zddc at path segment depth i.
|
|
//
|
|
// numLevels is len(chain.Levels); used to clamp results to the
|
|
// chain's actual range (e.g. a request to a file inside an Issued
|
|
// folder has a chain that only covers up to the Issued directory,
|
|
// not the file itself).
|
|
//
|
|
// Returns -1 if no WORM segment is in the request path or the
|
|
// computed index is out of range. The returned index satisfies
|
|
// 0 <= index < numLevels.
|
|
func WormFolderLevelIndex(requestPath string, numLevels int) int {
|
|
clean := strings.Trim(filepath.ToSlash(requestPath), "/")
|
|
if clean == "" || numLevels <= 0 {
|
|
return -1
|
|
}
|
|
deepest := -1
|
|
for i, seg := range strings.Split(clean, "/") {
|
|
for _, name := range WormFolderNames {
|
|
if seg == name {
|
|
// URL segment i lives at chain index i+1 (root is index 0).
|
|
idx := i + 1
|
|
if idx < numLevels && idx > deepest {
|
|
deepest = idx
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return deepest
|
|
}
|