ZDDC/zddc/internal/policy/policy.go
ZDDC f196205622 refactor(audit): pre-release cleanup pass
Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.

Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
  to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
  to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
  instead of allow/deny lists.

Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
  own their own .zddc; the policy decider's IsActiveAdmin short-circuit
  is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
  same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
  true after the retirement). Profile page renders AdminSubtrees
  directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
  IsAdminForChain — no production caller passed true.

Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).

ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
  branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
  InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
  GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
  EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
  MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
  /.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
  "deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
  is the parent-deny-is-absolute variant. The in-process Go evaluator
  implements only the commercial cascade.

Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
  .zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.

.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
  .zddc out of the dot-prefix guard so PUT/DELETE/POST reach
  ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
  the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
  API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
  (matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
  parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
  body is designed to materialize on PUT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:07 -05:00

470 lines
18 KiB
Go

// Package policy is the access-decision boundary for zddc-server.
//
// All ACL checks in handlers go through Decider.Allow rather than
// calling zddc.AllowedWithChain directly. This lets a deployment
// route policy decisions to an external OPA-compatible server
// (for federal customers running their own audited Rego policies)
// without changing handler code.
//
// Two implementations:
//
// - InternalDecider — wraps zddc.GrantedVerbsAtLevel walked over a
// PolicyChain. The default; no external dependencies. This is what
// the docs in zddc/README.md describe.
//
// - HTTPDecider — POSTs to OPA's canonical /v1/data/<package>/allow
// endpoint over HTTP or a Unix-domain socket. Federal customers
// deploy real OPA alongside zddc-server, write their own Rego,
// and point ZDDC_OPA_URL at it.
//
// Configuration knob:
//
// ZDDC_OPA_URL= # internal (default)
// ZDDC_OPA_URL=internal # internal (explicit)
// ZDDC_OPA_URL=http://127.0.0.1:8181 # external HTTP
// ZDDC_OPA_URL=https://opa.example:8181 # external HTTPS
// ZDDC_OPA_URL=unix:///run/opa/opa.sock # external Unix socket
//
// Failure mode (external only): unreachable / non-2xx / malformed
// response → fail closed (deny), with a WARN log. Operators who
// prefer availability over correctness can set ZDDC_OPA_FAIL_OPEN=1
// to flip to fail-open with a WARN log instead.
package policy
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// AllowInput is the canonical input shape for Decider.Allow. It
// matches OPA's input convention: a JSON object passed as the
// "input" field of a /v1/data/<package>/allow query.
//
// External Rego policies can:
// - read input.user.email (string)
// - read input.path (string)
// - read input.action ("read" | "write"); empty/absent ≡ "read"
// - walk input.policy_chain.levels[].acl.{allow,deny} for
// custom cascade semantics, or read the pre-resolved
// input.policy_chain.has_any_file when implementing the
// same default-deny rule we use internally.
//
// Action distinguishes read (GET/HEAD on listings, files, app HTML)
// from write (PUT, DELETE, POST/move on the file API). The internal
// decider treats both identically — any allow grants full CRUD,
// matching the model in place before the file API existed (anyone
// with read access also had OS-level write via the mounted share).
// External Rego policies can split the two by inspecting input.action.
type AllowInput struct {
User struct {
Email string `json:"email"`
// IsActiveAdmin is true when the caller has admin authority on
// THIS chain AND has opted into admin powers for this request.
// The CALLER is responsible for computing it (it depends on the
// chain and on Principal.Elevated, both known at the call site);
// the decider consults it as a short-circuit at the top of
// Allow. This is the single bypass point — every write that
// should ignore WORM/ACL for an elevated admin flows through it,
// every read that should ditto.
//
// Callers using the email-only entry points (AllowFromChain,
// AllowActionFromChain) get IsActiveAdmin=false implicitly —
// they're saying "ignore admin bypass for this lookup." Callers
// that want the bypass use AllowActionFromChainP.
IsActiveAdmin bool `json:"is_active_admin,omitempty"`
} `json:"user"`
Path string `json:"path"`
Action string `json:"action,omitempty"`
PolicyChain *SerializableChain `json:"policy_chain,omitempty"`
}
// Action constants used in AllowInput.Action. Empty string is treated
// as ActionRead for callers that don't specify a verb.
const (
ActionRead = "read" // listing + reading file bytes
ActionWrite = "write" // overwriting an existing file
ActionCreate = "create" // creating a new file or directory
ActionDelete = "delete" // deleting a file
ActionAdmin = "admin" // modifying ACL / .zddc / role definitions
)
// actionVerb maps an Action string to the zddc.VerbSet bit it requires.
// Returns the read verb for unrecognized values so the internal
// decider stays restrictive on unknown action labels.
func actionVerb(action string) zddc.VerbSet {
switch action {
case ActionWrite:
return zddc.VerbW
case ActionCreate:
return zddc.VerbC
case ActionDelete:
return zddc.VerbD
case ActionAdmin:
return zddc.VerbA
default:
return zddc.VerbR
}
}
// SerializableChain is a JSON-friendly view of zddc.PolicyChain.
// We don't tag zddc.PolicyChain itself because it's tightly coupled
// to the parser; the duplication is one struct.
type SerializableChain struct {
Levels []zddc.ZddcFile `json:"levels"`
HasAnyFile bool `json:"has_any_file"`
}
func chainToSerializable(c zddc.PolicyChain) *SerializableChain {
return &SerializableChain{Levels: c.Levels, HasAnyFile: c.HasAnyFile}
}
// Decider is the access-decision interface every handler uses.
type Decider interface {
Allow(ctx context.Context, input AllowInput) (bool, error)
}
// Config selects and parameterizes the decider.
type Config struct {
URL string // raw value: "", "internal", "http(s)://...", "unix:///path"
FailOpen bool // external mode only: on transport error, allow instead of deny
CacheTTL time.Duration // external mode only: per-decision cache TTL. Zero = default 1s. Negative = no cache.
}
// New constructs a Decider per cfg.URL semantics.
// - "" or "internal" → InternalDecider (no cache — the in-process
// evaluator is already cheaper than a cache lookup would be)
// - "http(s)://..." → HTTPDecider wrapped in a small per-decision
// cache (default 1s TTL — short enough that staleness is bounded
// to the same window as fsnotify-debounced index refresh, long
// enough to amortize bursty listings like .archive enumeration
// into one OPA round-trip per (email, decision-input))
// - "unix:///..." → same as http(s), over a Unix socket
//
// Returns an error if URL is unrecognized.
func New(cfg Config) (Decider, error) {
if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") {
return &InternalDecider{}, nil
}
var inner Decider
var err error
switch {
case strings.HasPrefix(cfg.URL, "http://"), strings.HasPrefix(cfg.URL, "https://"):
inner, err = newHTTPDecider(cfg.URL, cfg.FailOpen, nil)
case strings.HasPrefix(cfg.URL, "unix://"):
path := strings.TrimPrefix(cfg.URL, "unix://")
dialer := &net.Dialer{Timeout: 2 * time.Second}
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, "unix", path)
},
}
inner, err = newHTTPDecider("http://opa-unix-socket", cfg.FailOpen, transport)
default:
return nil, fmt.Errorf("unrecognized ZDDC_OPA_URL %q (want \"internal\", http(s)://..., or unix:///...)", cfg.URL)
}
if err != nil {
return nil, err
}
ttl := cfg.CacheTTL
if ttl == 0 {
ttl = time.Second
}
if ttl < 0 {
// Negative TTL = caching disabled (test seam).
return inner, nil
}
return &cachingDecider{inner: inner, ttl: ttl}, nil
}
// AllowAllDecider unconditionally permits every request. Used when the
// operator runs zddc-server with --no-auth — that flag declares the
// instance is NOT the ACL boundary (master in a trusted-LAN deployment,
// or client mode where the upstream master enforced ACLs and the local
// instance trusts those filtering decisions). Swap into the decider
// slot at startup; all existing handlers continue to call Allow* and
// see allowed=true.
type AllowAllDecider struct{}
// Allow always returns true with nil error.
func (AllowAllDecider) Allow(_ context.Context, _ AllowInput) (bool, error) {
return true, nil
}
// InternalDecider routes Allow through zddc.AllowedAction and applies
// the Issued/Received WORM mask post-decision. No network, no Rego, no
// new dependencies.
//
// The decider's admin bypass fires when AllowInput.User.IsActiveAdmin is
// true (the handler middleware sets that flag for elevated principals
// named in an admins: list anywhere on the chain). All other decisions
// flow through the normal cascade + WORM mask.
type InternalDecider struct{}
func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, error) {
chain := zddc.PolicyChain{}
if input.PolicyChain != nil {
chain.Levels = input.PolicyChain.Levels
chain.HasAnyFile = input.PolicyChain.HasAnyFile
}
verb := actionVerb(input.Action)
email := input.User.Email
// Single admin-bypass site. The caller has already verified that
// the principal (a) holds an admins: grant somewhere in this chain
// and (b) has opted into admin powers (Elevated). When both are
// true, any action is permitted — WORM zones included — preserving
// the human escape hatch for mis-filed documents. No other site in
// the codebase grants admin authority; every write that should
// bypass ACL/WORM flows through this one branch.
if input.User.IsActiveAdmin {
return true, nil
}
// WORM zone: a directory whose cascade declares `worm:` (see
// defaults.zddc.yaml — archive/<party>/received and issued carry
// `worm: {}`) is write-locked. Inside it, the effective verbs
// for a non-admin principal are:
//
// (normal cascade grant & VerbR) | (worm: grant & VerbsRC)
//
// so write/delete/admin are always stripped, create survives only
// via the worm: map (the deployment names its document
// controller there), and read survives via either the normal ACL
// or the worm: map.
if wormGrant, inWorm := zddc.WormZoneGrant(chain, email); inWorm {
normalRead := zddc.EffectiveVerbs(chain, email) & zddc.VerbR
return (normalRead | (wormGrant & zddc.VerbsRC)).Has(verb), nil
}
return zddc.AllowedAction(chain, email, verb), nil
}
// HTTPDecider POSTs to /v1/data/zddc/access/allow on the configured
// endpoint. Spec:
// - request body {"input": <AllowInput>}
// - response body {"result": true|false}
// - 5-second per-request timeout
// - non-2xx, transport error, missing/malformed result → policy
// decision is "deny" unless FailOpen=true
//
// The path "/v1/data/zddc/access/allow" is the OPA convention; the
// "zddc.access" Rego package on an external server would expose
// `allow` for this endpoint.
type HTTPDecider struct {
endpoint string
client *http.Client
failOpen bool
}
func newHTTPDecider(endpoint string, failOpen bool, transport http.RoundTripper) (*HTTPDecider, error) {
if _, err := url.Parse(endpoint); err != nil {
return nil, fmt.Errorf("invalid OPA URL %q: %w", endpoint, err)
}
c := &http.Client{Timeout: 5 * time.Second}
if transport != nil {
c.Transport = transport
}
return &HTTPDecider{
endpoint: strings.TrimRight(endpoint, "/") + "/v1/data/zddc/access/allow",
client: c,
failOpen: failOpen,
}, nil
}
type opaResponse struct {
Result *bool `json:"result"`
}
func (d *HTTPDecider) Allow(ctx context.Context, input AllowInput) (bool, error) {
body, err := json.Marshal(struct {
Input AllowInput `json:"input"`
}{Input: input})
if err != nil {
return d.failResult(fmt.Errorf("marshal input: %w", err))
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.endpoint, bytes.NewReader(body))
if err != nil {
return d.failResult(fmt.Errorf("build request: %w", err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := d.client.Do(req)
if err != nil {
return d.failResult(fmt.Errorf("opa request: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Read up to 512 bytes of the error body for the log without
// blowing up on a verbose OPA error page.
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return d.failResult(fmt.Errorf("opa returned %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))))
}
var parsed opaResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return d.failResult(fmt.Errorf("decode opa response: %w", err))
}
if parsed.Result == nil {
return d.failResult(errors.New("opa response missing 'result' field"))
}
return *parsed.Result, nil
}
// failResult logs the failure and returns the configured fail-mode
// decision. Logged at WARN so a healthy run is silent but a sick OPA
// is loud.
func (d *HTTPDecider) failResult(err error) (bool, error) {
if d.failOpen {
slog.Warn("policy decision failed; failing open (allow)", "endpoint", d.endpoint, "err", err)
return true, nil
}
slog.Warn("policy decision failed; failing closed (deny)", "endpoint", d.endpoint, "err", err)
return false, nil
}
// AllowFromChain is a convenience for callers that already have a
// PolicyChain in hand. Equivalent to constructing AllowInput manually
// from (chain, email, path) and calling d.Allow. Implies "read".
//
// New callers should use AllowActionFromChain with an explicit verb so
// the audit/policy stream records intent and the internal decider can
// apply the right verb-specific check.
func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) {
return AllowActionFromChain(ctx, d, chain, email, path, ActionRead)
}
// AllowActionFromChain is the canonical access-decision helper.
// External Rego policies can branch on input.action to differentiate
// among the five verbs (read / write / create / delete / admin). The
// internal decider maps each action to its zddc.VerbSet bit and walks
// the cascade in the configured mode (delegated / strict).
func AllowActionFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path, action string) (bool, error) {
in := AllowInput{Path: path, Action: action, PolicyChain: chainToSerializable(chain)}
in.User.Email = email
return d.Allow(ctx, in)
}
// AllowFromChainP is the principal-aware read shortcut. Equivalent to
// AllowActionFromChainP with ActionRead. Use this when you want the
// decider's admin-bypass branch to fire on reads (apps resolution,
// directory listings, file GETs, archive index, profile pages) for
// elevated admins — without it, an admin who'd otherwise have no
// explicit read grant on a path 403s.
func AllowFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChain, p zddc.Principal, path string) (bool, error) {
return AllowActionFromChainP(ctx, d, chain, p, path, ActionRead)
}
// AllowActionFromChainP is the principal-aware entry point. Computes
// IsActiveAdmin from the chain + Principal.Elevated and threads it
// into AllowInput, so the decider's single admin-bypass branch fires
// when (and only when) the caller actually holds elevated admin
// authority on this chain.
//
// Strict-ancestor rule: not applied. A subtree admin whose admins:
// entry lives in <dir>/.zddc CAN edit that file — they own the
// directory and everything it grants. Footgun: they can also remove
// themselves from the admins list (recoverable: a super-admin always
// retains authority via the cascade from the root .zddc and can
// restore the grant). The prior strict-ancestor mode protected
// against peer-addition / delegator-removal but was always partial
// (deeper .zddc files were freely editable) and made the common case
// — "project creator wants to add a collaborator" — friction-y enough
// to be unusable.
func AllowActionFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChain, p zddc.Principal, path, action string) (bool, error) {
isAdmin := p.Elevated && p.Email != "" &&
zddc.IsAdminForChain(chain, p.Email)
in := AllowInput{Path: path, Action: action, PolicyChain: chainToSerializable(chain)}
in.User.Email = p.Email
in.User.IsActiveAdmin = isAdmin
return d.Allow(ctx, in)
}
// cachingDecider wraps another Decider with a small per-decision cache.
// Designed for the external-OPA hot path: a single .archive listing or
// directory enumeration can hit the same (email, dir-policy) tuple
// dozens of times in milliseconds, and a remote OPA round-trip per
// query would dominate latency. The 1s default TTL bounds staleness to
// the same window as the fsnotify watcher's debounce, so a `.zddc` edit
// is reflected in the next listing rather than carried over indefinitely.
//
// Key shape: SHA-256 of the canonical JSON-serialized AllowInput. This
// makes the cache safe across all input variations (different paths,
// different chains, different users) without us having to enumerate
// the dimensions.
type cachingDecider struct {
inner Decider
ttl time.Duration
mu sync.Mutex
entries map[string]cacheEntry
}
type cacheEntry struct {
expires time.Time
allow bool
}
func (d *cachingDecider) Allow(ctx context.Context, input AllowInput) (bool, error) {
key, err := cacheKey(input)
if err != nil {
// Couldn't key — fall through to inner without caching. Should
// never happen in practice; AllowInput marshals as plain JSON.
return d.inner.Allow(ctx, input)
}
now := time.Now()
d.mu.Lock()
if d.entries == nil {
d.entries = make(map[string]cacheEntry)
}
if e, ok := d.entries[key]; ok && now.Before(e.expires) {
d.mu.Unlock()
return e.allow, nil
}
d.mu.Unlock()
allow, err := d.inner.Allow(ctx, input)
if err != nil {
return allow, err
}
d.mu.Lock()
// Best-effort eviction of expired entries — keeps the map from
// growing unbounded under high cardinality. O(n) but capped to
// occasional sweeps; fine for this scale.
if len(d.entries) > 4096 {
for k, e := range d.entries {
if now.After(e.expires) {
delete(d.entries, k)
}
}
}
d.entries[key] = cacheEntry{expires: now.Add(d.ttl), allow: allow}
d.mu.Unlock()
return allow, nil
}
func cacheKey(input AllowInput) (string, error) {
b, err := json.Marshal(input)
if err != nil {
return "", err
}
h := sha256.Sum256(b)
return hex.EncodeToString(h[:]), nil
}