// 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.AllowedWithChain. The default; // no new dependencies, identical semantics to the legacy code // path. This is what the docs in zddc/README.md describe. // // - HTTPDecider — POSTs to OPA's canonical /v1/data//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//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"` } `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 also // accepted for back-compat with callers that don't specify a verb. const ( ActionRead = "read" // listing + reading file bytes ActionWrite = "write" // overwriting an existing file (legacy alias for the historical write-vs-read split) 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. // CascadeMode controls how the InternalDecider walks the ACL chain: // "delegated" (default — leaf grants override ancestor denies) or // "strict" (ancestor explicit-deny is absolute; NIST AC-6). // External deciders ignore this — Rego policies access the chain // directly and implement either semantic themselves. CascadeMode string } // 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) { mode, _ := zddc.ParseCascadeMode(cfg.CascadeMode) if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") { return &InternalDecider{Mode: mode}, 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 } // InternalDecider routes Allow through zddc.AllowedAction with the // configured cascade mode and applies the Issued/Received WORM mask // post-decision. No network, no Rego, no new dependencies. // // The decider does NOT consult the admins:/IsAdmin escape hatch — // callers in the handler package wire IsAdmin / IsSubtreeAdmin around // the decision. Admins bypass the WORM mask there as well. type InternalDecider struct { Mode zddc.CascadeMode } 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 // WORM split: in Issued/Received, ancestor grants are read-only; // only an explicit .zddc placed at-or-below the WORM folder can // restore `c` (write-once) for principals it names. Admins are // excluded from this code path by callers (handler package does // the IsAdmin / IsSubtreeAdmin bypass before invoking Allow). // // EffectiveVerbsRange (rather than slicing chain.Levels) keeps the // FULL chain visible to role-membership lookups so an ancestor's // role definition still applies inside the sub-range walk. if zddc.IsWormPath(input.Path) { wormIdx := zddc.WormFolderLevelIndex(input.Path, len(chain.Levels)) if wormIdx >= 0 { grantAbove := zddc.EffectiveVerbsRange(chain, 0, wormIdx, email, d.Mode) & zddc.VerbR grantBelow := zddc.EffectiveVerbsRange(chain, wormIdx, len(chain.Levels), email, d.Mode) & zddc.VerbsRC return (grantAbove | grantBelow).Has(verb), nil } } return zddc.AllowedAction(chain, email, verb, d.Mode), nil } // HTTPDecider POSTs to /v1/data/zddc/access/allow on the configured // endpoint. Spec: // - request body {"input": } // - 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) } // AllowWriteFromChain is the legacy write-action helper. Newer callers // should pick the specific verb (ActionCreate / ActionWrite / // ActionDelete / ActionAdmin) via AllowActionFromChain instead. func AllowWriteFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) { return AllowActionFromChain(ctx, d, chain, email, path, ActionWrite) } // 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) } // 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 }