// 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" "encoding/json" "errors" "fmt" "io" "log/slog" "net" "net/http" "net/url" "strings" "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) // - 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. type AllowInput struct { User struct { Email string `json:"email"` } `json:"user"` Path string `json:"path"` PolicyChain *SerializableChain `json:"policy_chain,omitempty"` } // 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 } // New constructs a Decider per cfg.URL semantics. // - "" or "internal" → InternalDecider // - "http(s)://..." → HTTPDecider // - "unix:///..." → HTTPDecider 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 } if strings.HasPrefix(cfg.URL, "http://") || strings.HasPrefix(cfg.URL, "https://") { return newHTTPDecider(cfg.URL, cfg.FailOpen, nil) } if 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) }, } // HTTP host part is unused but the URL still has to parse. return newHTTPDecider("http://opa-unix-socket", cfg.FailOpen, transport) } return nil, fmt.Errorf("unrecognized ZDDC_OPA_URL %q (want \"internal\", http(s)://..., or unix:///...)", cfg.URL) } // InternalDecider routes Allow through zddc.AllowedWithChain. No // network, no Rego, no new dependencies — same Go evaluator the // existing test suite covers. 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 } return zddc.AllowedWithChain(chain, input.User.Email), 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. func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, email, path string) (bool, error) { in := AllowInput{Path: path, PolicyChain: chainToSerializable(chain)} in.User.Email = email return d.Allow(ctx, in) }