ZDDC/zddc/internal/zddc/special.go
ZDDC 3115e388fc feat(server): authenticated CRUD + verb-based RBAC with WORM archive folders
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>
2026-05-05 15:58:04 -05:00

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
}