ZDDC/zddc/internal/policy/policy.go
ZDDC e911806eda feat(server): pluggable OPA-compatible policy decider
Add an internal access-decision boundary that all handlers go through
instead of calling zddc.AllowedWithChain directly. Two implementations
ship:

  * InternalDecider — wraps the existing zddc.AllowedWithChain. The
    default. No new dependencies, identical semantics to the legacy
    code path. ZDDC_OPA_URL=internal (or unset).

  * HTTPDecider — POSTs the canonical OPA wire format
    (POST /v1/data/zddc/access/allow with {"input": {...}}, response
    {"result": true|false}) over HTTP, HTTPS, or a Unix-domain socket.
    For federal customers running their own audited Rego policies
    alongside zddc-server. ZDDC_OPA_URL=http(s)://… or unix:///….

External-mode failure semantics: unreachable / non-2xx / malformed
response → fail closed (deny) by default with a WARN log. Operators
who prefer availability over correctness flip with ZDDC_OPA_FAIL_OPEN=1.

The decider is constructed once at startup, plumbed through ACLMiddleware
into the request context. Handlers retrieve it via DeciderFromContext;
non-request callers (fs.ListDirectory, EnumerateProjects, enumerateAccess)
take it as an explicit parameter.

zddc.ZddcFile and zddc.ACLRules gain JSON tags so external Rego authors
get idiomatic input shape (acl.allow, admins, …) instead of Go field
names (ACL.Allow, Admins, …).

Test coverage:
  * InternalDecider parity tests against zddc.AllowedWithChain (every
    documented cascade scenario: empty chain, leaf-allow-wins, leaf-
    deny-beats-parent, leaf-allows-what-parent-denies, deepest-match-
    wins, etc.)
  * HTTPDecider happy-path test (canonical wire format)
  * Fail-closed / fail-open / malformed-response tests

Production binary size unchanged (no new deps; HTTP transport is
stdlib net/http). 11 ACL call sites migrated. End-to-end verified
against the worked-example layout in zddc/README.md.

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

225 lines
7.9 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.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/<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"
"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/<package>/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": <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.
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)
}