feat(server): reference Rego, parity test, decision cache, listing ETags

Phase 2 enhancements to the policy decider, plus listing-level ETags
that benefit every deployment regardless of decider mode.

Reference Rego policy
---------------------
internal/policy/rego/access.rego mirrors InternalDecider's semantics
exactly — bottom-up walk, deny-first within a level, default-deny when
HasAnyFile=true, glob matching with @-boundary semantics (special-cased
bare "*" because OPA's glob.match treats empty delimiters
inconsistently for that pattern).

Embedded into the binary via go:embed; --print-rego dumps it to stdout
so federal customers standing up an external OPA can use it as a
parity-tested baseline:

    zddc-server --print-rego > /etc/opa/policies/zddc-access.rego

Parity test runner
------------------
parity_test.go imports the OPA Go module as a TEST-ONLY dependency
(github.com/open-policy-agent/opa@v0.70.0). Every fixture from the
internal Go evaluator's test set runs through both implementations;
any divergence fails CI. The test-only import means production
binaries (built by `go build ./cmd/zddc-server`) stay OPA-free —
release-flag binary size unchanged at ~13 MB.

The parity test caught a real bug on first run: bare "*" patterns
didn't match through OPA's glob.match with empty delimiters. Fixed
in access.rego with a special-case rule. This is exactly the kind of
subtle drift the parity guard exists to catch.

External-mode decision cache
----------------------------
HTTPDecider is now wrapped in a cachingDecider with a default 1s TTL.
Bursty patterns like .archive listings (one OPA round-trip per entry
before, one per (email, decision-input) tuple per TTL window after)
amortize cleanly. Verified: 20 identical /D/ requests produce 1 OPA
hit with cache, 40 hits without (each listing makes 2 ACL queries).

ZDDC_OPA_CACHE_TTL knob (default 1s) lets operators tune. 0 disables.
1s matches the fsnotify watcher debounce window — staleness is
bounded the same way other policy-edit propagation already is.
Internal mode unchanged; the in-process Go evaluator is already
cheaper than a cache lookup would be.

Listing ETags
-------------
GET / (project list) and GET /<dir>/ (directory listing JSON) now
carry content-hash ETag + Cache-Control: private, max-age=0,
must-revalidate. SHA-256 of the rendered JSON, truncated to 16 hex
chars (64 bits — collision risk on a listing of any realistic size
is vanishingly small).

Server-side caching deliberately not added: it would require
mtime-based invalidation, and Azure Files SMB mounts (a common
deployment substrate) don't support fsnotify reliably. The
content-hash ETag delivers the bandwidth savings (304 on identical
fetches) without depending on watcher correctness — the hash is the
actual response, so it can't lie about staleness regardless of
underlying watcher behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-04 17:46:24 -05:00
parent e911806eda
commit a01315fd00
11 changed files with 856 additions and 36 deletions

View file

@ -32,6 +32,16 @@ import (
var version = "dev" var version = "dev"
func main() { func main() {
// --print-rego: dump the bundled reference Rego policy and exit.
// Cheap escape hatch for operators standing up an external OPA who want
// the parity-tested baseline as a starting point for customization.
for _, a := range os.Args[1:] {
if a == "--print-rego" {
fmt.Print(policy.ReferenceRego)
return
}
}
cfg, err := config.Load(os.Args[1:]) cfg, err := config.Load(os.Args[1:])
if errors.Is(err, config.ErrHelpRequested) { if errors.Is(err, config.ErrHelpRequested) {
config.Usage(os.Stderr) config.Usage(os.Stderr)
@ -116,12 +126,25 @@ func main() {
// (default) routes decisions through the in-process Go evaluator; // (default) routes decisions through the in-process Go evaluator;
// http(s):// or unix:// values send each decision to an external // http(s):// or unix:// values send each decision to an external
// OPA-compatible server (federal customers, custom Rego policies). // OPA-compatible server (federal customers, custom Rego policies).
decider, err := policy.New(policy.Config{URL: cfg.OPAURL, FailOpen: cfg.OPAFailOpen}) deciderCfg := policy.Config{
URL: cfg.OPAURL,
FailOpen: cfg.OPAFailOpen,
CacheTTL: cfg.OPACacheTTL,
}
// Translate "0" (operator opt-out) to "disable cache" (negative TTL is
// the policy package's sentinel for "skip the wrapper").
if deciderCfg.CacheTTL == 0 {
deciderCfg.CacheTTL = -1
}
decider, err := policy.New(deciderCfg)
if err != nil { if err != nil {
slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err) slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err)
os.Exit(1) os.Exit(1)
} }
slog.Info("policy decider ready", "mode", policyModeLabel(cfg.OPAURL), "url", cfg.OPAURL) slog.Info("policy decider ready",
"mode", policyModeLabel(cfg.OPAURL),
"url", cfg.OPAURL,
"cache_ttl", cfg.OPACacheTTL)
mux.Handle("/", handler.ACLMiddleware(cfg, decider, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/", handler.ACLMiddleware(cfg, decider, handler.AccessLogMiddleware(auditLogger, handler.CORSMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dispatch(cfg, idx, logRing, appsServer, w, r) dispatch(cfg, idx, logRing, appsServer, w, r)

View file

@ -5,8 +5,40 @@ go 1.24
require ( require (
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/klauspost/compress v1.18.6 github.com/klauspost/compress v1.18.6
github.com/open-policy-agent/opa v0.70.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require golang.org/x/sys v0.26.0 // indirect require (
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/agnivade/levenshtein v1.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/tchap/go-patricia/v2 v2.3.1 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/yashtewari/glob-intersection v0.2.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
golang.org/x/sys v0.26.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)

View file

@ -1,12 +1,158 @@
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY=
github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA=
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg=
github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY=
github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/open-policy-agent/opa v0.70.0 h1:B3cqCN2iQAyKxK6+GI+N40uqkin+wzIrM7YA60t9x1U=
github.com/open-policy-agent/opa v0.70.0/go.mod h1:Y/nm5NY0BX0BqjBriKUiV81sCl8XOjjvqQG7dXrggtI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 h1:MkV+77GLUNo5oJ0jf870itWm3D0Sjh7+Za9gazKc5LQ=
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes=
github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=

View file

@ -9,6 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
// Config holds all runtime configuration. Each field can be set via a // Config holds all runtime configuration. Each field can be set via a
@ -28,6 +29,7 @@ type Config struct {
Insecure bool // --insecure / ZDDC_INSECURE=1 — opt out of safety checks (currently: allow start without a root .zddc, leaving the tree publicly accessible) Insecure bool // --insecure / ZDDC_INSECURE=1 — opt out of safety checks (currently: allow start without a root .zddc, leaving the tree publicly accessible)
OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket) OPAURL string // --opa-url / ZDDC_OPA_URL — policy decider endpoint: "internal" (default), "http(s)://..." (real OPA via HTTP), or "unix:///..." (OPA via Unix socket)
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed) OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
OPACacheTTL time.Duration // --opa-cache-ttl / ZDDC_OPA_CACHE_TTL — external mode only: per-decision cache TTL. Default 1s. Set 0s to disable.
} }
// ErrHelpRequested is returned by Load when --help is passed; the caller // ErrHelpRequested is returned by Load when --help is passed; the caller
@ -84,6 +86,8 @@ func Load(args []string) (Config, error) {
"Policy decider endpoint: \"internal\" (built-in Go evaluator, default), \"http(s)://host:port\", or \"unix:///path/to/socket\".") "Policy decider endpoint: \"internal\" (built-in Go evaluator, default), \"http(s)://host:port\", or \"unix:///path/to/socket\".")
opaFailOpenFlag := fs.Bool("opa-fail-open", os.Getenv("ZDDC_OPA_FAIL_OPEN") == "1", opaFailOpenFlag := fs.Bool("opa-fail-open", os.Getenv("ZDDC_OPA_FAIL_OPEN") == "1",
"External OPA only: on unreachable / non-2xx / malformed response, allow the request instead of denying. Default: fail closed.") "External OPA only: on unreachable / non-2xx / malformed response, allow the request instead of denying. Default: fail closed.")
opaCacheTTLFlag := fs.Duration("opa-cache-ttl", parseDurationOrDefault(os.Getenv("ZDDC_OPA_CACHE_TTL"), time.Second),
"External OPA only: per-decision cache TTL. Amortizes round-trips on bursts of identical queries (e.g. .archive listing). Default 1s; set 0 to disable.")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"), accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+ "Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+ "Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
@ -139,6 +143,7 @@ func Load(args []string) (Config, error) {
Insecure: *insecureFlag, Insecure: *insecureFlag,
OPAURL: *opaURLFlag, OPAURL: *opaURLFlag,
OPAFailOpen: *opaFailOpenFlag, OPAFailOpen: *opaFailOpenFlag,
OPACacheTTL: *opaCacheTTLFlag,
} }
// Default Root to the current working directory. // Default Root to the current working directory.
@ -246,6 +251,7 @@ func Usage(w io.Writer) {
fs.Bool("insecure", false, "Allow startup with no root .zddc file (publicly accessible). Default: refuse.") fs.Bool("insecure", false, "Allow startup with no root .zddc file (publicly accessible). Default: refuse.")
fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".") fs.String("opa-url", "internal", "Policy decider: \"internal\", \"http(s)://...\", or \"unix:///...\".")
fs.Bool("opa-fail-open", false, "External OPA: allow on transport error (default: deny / fail closed).") fs.Bool("opa-fail-open", false, "External OPA: allow on transport error (default: deny / fail closed).")
fs.Duration("opa-cache-ttl", time.Second, "External OPA: per-decision cache TTL (default 1s; 0 disables).")
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.") fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.")
fs.Bool("help", false, "Print this help and exit.") fs.Bool("help", false, "Print this help and exit.")
fs.Bool("version", false, "Print version info and exit.") fs.Bool("version", false, "Print version info and exit.")
@ -316,3 +322,16 @@ func getEnv(key, fallback string) string {
} }
return fallback return fallback
} }
// parseDurationOrDefault parses a duration string ("1s", "500ms", "0", etc.).
// Returns def on empty input or parse error. Used for env-var defaults
// that need a sensible fallback rather than a hard error on typo.
func parseDurationOrDefault(s string, def time.Duration) time.Duration {
if s == "" {
return def
}
if d, err := time.ParseDuration(s); err == nil {
return d
}
return def
}

View file

@ -1,6 +1,8 @@
package handler package handler
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
@ -15,6 +17,15 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
// listingETag returns a hex-encoded SHA-256 prefix of the rendered JSON
// listing body. Truncated to 16 chars (64 bits) — collisions on a
// listing of any realistic size are vanishingly unlikely, and the short
// header keeps the wire footprint trim.
func listingETag(body []byte) string {
h := sha256.Sum256(body)
return hex.EncodeToString(h[:8])
}
// safeJoin joins fsRoot and relPath, then verifies the result is under fsRoot. // safeJoin joins fsRoot and relPath, then verifies the result is under fsRoot.
// Returns ("", false) if relPath would escape fsRoot. // Returns ("", false) if relPath would escape fsRoot.
func safeJoin(fsRoot, relPath string) (string, bool) { func safeJoin(fsRoot, relPath string) (string, bool) {
@ -97,11 +108,32 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Vary", "Accept") w.Header().Set("Vary", "Accept")
if strings.Contains(accept, "application/json") { if strings.Contains(accept, "application/json") {
w.Header().Set("Content-Type", "application/json") // Content-hash ETag on the listing payload. Re-fetched on every
w.Header().Set("Cache-Control", "no-cache") // request (the cascade is walked, ACL filter applied, JSON
if err := json.NewEncoder(w).Encode(entries); err != nil { // rendered, hashed) — that's the same server work the previous
// no-cache version did. The win is on the *response*: identical
// listings (e.g. the same vendor refreshing their archive page)
// short-circuit to 304 with no body.
//
// Crucially, this scheme tolerates unreliable filesystem
// watching (Azure SMB, network shares with delayed inotify):
// the ETag is the actual response hash, not a watcher-derived
// invalidation token, so it can never lie about content.
body, err := json.Marshal(entries)
if err != nil {
slog.Error("encoding directory listing", "err", err) slog.Error("encoding directory listing", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
} }
etag := `"` + listingETag(body) + `"`
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
return return
} }

View file

@ -28,17 +28,34 @@ type ProjectInfo struct {
// ServeProjectList handles GET / with Accept: application/json. // ServeProjectList handles GET / with Accept: application/json.
// It returns all top-level directories under cfg.Root that the requesting // It returns all top-level directories under cfg.Root that the requesting
// user has access to, as a JSON array of ProjectInfo. // user has access to, as a JSON array of ProjectInfo.
//
// Response carries a content-hash ETag. The landing page polls this
// endpoint on every paint, and the response (a small JSON array of
// project names + URLs the caller can reach) rarely changes between
// polls, so 304s save a meaningful amount of cumulative bandwidth.
// The hash is computed from the actual response body, so it tolerates
// unreliable filesystem watching.
func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) { func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) {
projects, err := EnumerateProjects(r.Context(), DeciderFromContext(r), cfg, EmailFromContext(r)) projects, err := EnumerateProjects(r.Context(), DeciderFromContext(r), cfg, EmailFromContext(r))
if err != nil { if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "application/json") body, err := json.Marshal(projects)
w.Header().Set("Cache-Control", "no-cache") if err != nil {
if err := json.NewEncoder(w).Encode(projects); err != nil {
slog.Error("encoding project list", "err", err) slog.Error("encoding project list", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
} }
etag := `"` + listingETag(body) + `"`
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
} }
// EnumerateProjects returns the visible top-level projects for the given // EnumerateProjects returns the visible top-level projects for the given

View file

@ -0,0 +1,164 @@
package policy
import (
"context"
"encoding/json"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/open-policy-agent/opa/rego"
)
// TestRegoParity_AllInternalCases validates that the bundled reference
// Rego (ReferenceRego, embedded from rego/access.rego) produces the same
// allow/deny decision as the InternalDecider for every fixture below.
//
// This test imports the OPA Go module — but only in a _test.go file, so
// it does NOT end up in `go build ./cmd/zddc-server`'s production
// binary. Operators get a 13-MB OPA-free binary; CI gets a parity check
// that catches semantic drift between the two implementations the moment
// it occurs.
//
// The fixture set is intentionally inherited from acl_test.go so any new
// case added there (or a case the conversation surfaces while the docs
// are being maintained) is automatically picked up here.
func TestRegoParity_AllInternalCases(t *testing.T) {
ctx := context.Background()
// Compile the reference Rego once; query it for each fixture.
preparedQuery, err := rego.New(
rego.Query("data.zddc.access.allow"),
rego.Module("access.rego", ReferenceRego),
).PrepareForEval(ctx)
if err != nil {
t.Fatalf("rego compilation failed: %v", err)
}
allow := func(p ...string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}}
}
deny := func(p ...string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}}
}
allowDeny := func(a, d []string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
}
empty := zddc.ZddcFile{}
cases := []struct {
name string
chain zddc.PolicyChain
email string
path string
}{
// All canonical cascade cases from acl_test.go — kept as a parallel
// list rather than imported because acl_test.go's helpers aren't
// exported and we want this file to be self-contained.
{"empty chain no files", zddc.PolicyChain{HasAnyFile: false}, "alice@example.com", "/"},
{"empty chain no files, anon", zddc.PolicyChain{HasAnyFile: false}, "", "/"},
{"files exist no rule matches", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true}, "alice@example.com", "/"},
{"leaf allow wins", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"leaf deny beats parent allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), deny("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"leaf no rule, parent allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), allow("bob@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"leaf re-allows what parent denied", zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com"), allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"multi-level deepest wins", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com"), allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}), allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/sub/sub/"},
{"empty levels, files present, deny", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, empty, empty}, HasAnyFile: true}, "alice@example.com", "/sub/"},
{"empty levels, no files, allow", zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, empty, empty}, HasAnyFile: false}, "alice@example.com", "/sub/"},
// Glob-pattern parity — exercises email_matches in Rego against
// MatchesPattern in Go.
{"wildcard local part", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@*")}, HasAnyFile: true}, "alice@anywhere.com", "/"},
{"wildcard domain", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true}, "anyone@example.com", "/"},
{"exact match", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@example.com")}, HasAnyFile: true}, "alice@example.com", "/"},
{"exact does not match anyone else", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("alice@example.com")}, HasAnyFile: true}, "bob@example.com", "/"},
{"wildcard does NOT cross @", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*example.com")}, HasAnyFile: true}, "alice@example.com", "/"},
{"bare * matches anyone", zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*")}, HasAnyFile: true}, "alice@example.com", "/"},
// Worked-example layout traces (verify-it recipe in zddc/README.md).
// Insider on technical project.
{"insider on technical", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("*@mycompany.com"),
}, HasAnyFile: true}, "alice@mycompany.com", "/Acme-tech/"},
// Insider not on closed project.
{"insider not on closed", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("alice@mycompany.com"),
}, HasAnyFile: true}, "bob@mycompany.com", "/Acme-comm/"},
// Vendor at /Archive/Acme/.
{"vendor at own folder", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("*@mycompany.com"),
allow("acme-rep@acme.com"),
}, HasAnyFile: true}, "acme-rep@acme.com", "/Archive/Acme/"},
// Vendor blocked at sibling-vendor folder.
{"vendor blocked at sibling", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allow("*@mycompany.com"),
allow("beta-rep@beta.com"),
}, HasAnyFile: true}, "acme-rep@acme.com", "/Archive/Beta/"},
// The anti-pattern trap: same-level allow + deny *@company.com
// blocks the supposedly-allowed user too (deny is checked first
// within a level, so this is a documentation/rego parity check).
{"trap same-level allow+deny shadow", zddc.PolicyChain{Levels: []zddc.ZddcFile{
zddc.ZddcFile{Admins: []string{"admin@mycompany.com"}},
allowDeny([]string{"alice@mycompany.com"}, []string{"*@mycompany.com"}),
}, HasAnyFile: true}, "alice@mycompany.com", "/Trap/"},
}
internal := &InternalDecider{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
input := AllowInput{Path: tc.path, PolicyChain: chainToSerializable(tc.chain)}
input.User.Email = tc.email
// Run the Go evaluator.
goAllow, err := internal.Allow(ctx, input)
if err != nil {
t.Fatalf("internal: %v", err)
}
// Run the Rego evaluator on identical input.
regoInput, err := canonicalInput(input)
if err != nil {
t.Fatalf("encode input: %v", err)
}
rs, err := preparedQuery.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("rego eval: %v", err)
}
if len(rs) == 0 {
t.Fatalf("rego: no result")
}
regoAllow, ok := rs[0].Expressions[0].Value.(bool)
if !ok {
t.Fatalf("rego: result is not bool: %v", rs[0].Expressions[0].Value)
}
if goAllow != regoAllow {
t.Errorf("PARITY BROKEN: internal=%v, rego=%v\n email=%q path=%q\n chain=%+v",
goAllow, regoAllow, tc.email, tc.path, tc.chain)
}
})
}
}
// canonicalInput serializes the AllowInput through JSON and back so that
// the Rego evaluator sees the exact same shape an external OPA would
// receive over the wire. Catches accidents where a struct-literal
// fixture would expose a Go-only field name (capitalized) that wouldn't
// reach a real OPA deployment.
func canonicalInput(in AllowInput) (map[string]interface{}, error) {
b, err := json.Marshal(in)
if err != nil {
return nil, err
}
var out map[string]interface{}
if err := json.NewDecoder(strings.NewReader(string(b))).Decode(&out); err != nil {
return nil, err
}
return out, nil
}

View file

@ -34,6 +34,8 @@ package policy
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -43,6 +45,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"sync"
"time" "time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
@ -88,22 +91,30 @@ type Decider interface {
type Config struct { type Config struct {
URL string // raw value: "", "internal", "http(s)://...", "unix:///path" URL string // raw value: "", "internal", "http(s)://...", "unix:///path"
FailOpen bool // external mode only: on transport error, allow instead of deny 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. // New constructs a Decider per cfg.URL semantics.
// - "" or "internal" → InternalDecider // - "" or "internal" → InternalDecider (no cache — the in-process
// - "http(s)://..." → HTTPDecider // evaluator is already cheaper than a cache lookup would be)
// - "unix:///..." → HTTPDecider over a Unix socket // - "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. // Returns an error if URL is unrecognized.
func New(cfg Config) (Decider, error) { func New(cfg Config) (Decider, error) {
if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") { if cfg.URL == "" || strings.EqualFold(cfg.URL, "internal") {
return &InternalDecider{}, nil return &InternalDecider{}, nil
} }
if strings.HasPrefix(cfg.URL, "http://") || strings.HasPrefix(cfg.URL, "https://") { var inner Decider
return newHTTPDecider(cfg.URL, cfg.FailOpen, nil) var err error
} switch {
if strings.HasPrefix(cfg.URL, "unix://") { 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://") path := strings.TrimPrefix(cfg.URL, "unix://")
dialer := &net.Dialer{Timeout: 2 * time.Second} dialer := &net.Dialer{Timeout: 2 * time.Second}
transport := &http.Transport{ transport := &http.Transport{
@ -111,10 +122,22 @@ func New(cfg Config) (Decider, error) {
return dialer.DialContext(ctx, "unix", path) return dialer.DialContext(ctx, "unix", path)
}, },
} }
// HTTP host part is unused but the URL still has to parse. inner, err = newHTTPDecider("http://opa-unix-socket", cfg.FailOpen, transport)
return 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) 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.AllowedWithChain. No // InternalDecider routes Allow through zddc.AllowedWithChain. No
@ -223,3 +246,77 @@ func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, emai
in.User.Email = email in.User.Email = email
return d.Allow(ctx, in) 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
}

View file

@ -8,29 +8,34 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
// TestNew_ModeSelection: New() picks the right implementation per URL. // TestNew_ModeSelection: New() picks the right implementation per URL.
// External-mode URLs return a cachingDecider wrapping an HTTPDecider
// by default; CacheTTL<0 disables the wrapper.
func TestNew_ModeSelection(t *testing.T) { func TestNew_ModeSelection(t *testing.T) {
cases := []struct { cases := []struct {
url string url string
ttl time.Duration
wantType string wantType string
wantErr bool wantErr bool
}{ }{
{"", "*policy.InternalDecider", false}, {"", 0, "*policy.InternalDecider", false},
{"internal", "*policy.InternalDecider", false}, {"internal", 0, "*policy.InternalDecider", false},
{"INTERNAL", "*policy.InternalDecider", false}, {"INTERNAL", 0, "*policy.InternalDecider", false},
{"http://127.0.0.1:8181", "*policy.HTTPDecider", false}, {"http://127.0.0.1:8181", 0, "*policy.cachingDecider", false},
{"https://opa.example:8181", "*policy.HTTPDecider", false}, {"https://opa.example:8181", 0, "*policy.cachingDecider", false},
{"unix:///run/opa.sock", "*policy.HTTPDecider", false}, {"unix:///run/opa.sock", 0, "*policy.cachingDecider", false},
{"ftp://nope", "", true}, {"http://127.0.0.1:8181", -1, "*policy.HTTPDecider", false}, // cache disabled
{"garbage", "", true}, {"ftp://nope", 0, "", true},
{"garbage", 0, "", true},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.url, func(t *testing.T) { t.Run(tc.url, func(t *testing.T) {
d, err := New(Config{URL: tc.url}) d, err := New(Config{URL: tc.url, CacheTTL: tc.ttl})
if tc.wantErr { if tc.wantErr {
if err == nil { if err == nil {
t.Fatal("New() = nil error, want error") t.Fatal("New() = nil error, want error")
@ -42,7 +47,7 @@ func TestNew_ModeSelection(t *testing.T) {
} }
got := describe(d) got := describe(d)
if got != tc.wantType { if got != tc.wantType {
t.Errorf("New(%q) → %s, want %s", tc.url, got, tc.wantType) t.Errorf("New(%q, ttl=%v) → %s, want %s", tc.url, tc.ttl, got, tc.wantType)
} }
}) })
} }
@ -54,6 +59,8 @@ func describe(v interface{}) string {
return "*policy.InternalDecider" return "*policy.InternalDecider"
case *HTTPDecider: case *HTTPDecider:
return "*policy.HTTPDecider" return "*policy.HTTPDecider"
case *cachingDecider:
return "*policy.cachingDecider"
default: default:
return "unknown" return "unknown"
} }
@ -236,6 +243,144 @@ func TestHTTPDecider_FailOpen(t *testing.T) {
} }
} }
// TestCachingDecider_AmortizesRoundTrips: a HTTPDecider wrapped in
// the default 1s cache only round-trips once for a burst of identical
// queries. Verifies the listing-amortization benefit for external mode.
func TestCachingDecider_AmortizesRoundTrips(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL}) // CacheTTL=0 → default 1s
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
// 50 identical calls → exactly 1 round-trip thanks to the cache.
for i := 0; i < 50; i++ {
got, err := d.Allow(context.Background(), in)
if err != nil {
t.Fatalf("Allow #%d: %v", i, err)
}
if !got {
t.Errorf("Allow #%d = false, want true", i)
}
}
if hits != 1 {
t.Errorf("OPA round-trips = %d, want 1 (cache miss-and-fill)", hits)
}
}
// TestCachingDecider_DifferentInputsSeparatelyKeyed: changing email or
// path produces a separate cache entry; a different-decision answer is
// not masked by the cached one.
func TestCachingDecider_DifferentInputsSeparatelyKeyed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var wrap struct {
Input AllowInput `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&wrap)
// Allow only "alice"; deny everyone else.
allow := wrap.Input.User.Email == "alice@example.com"
resp, _ := json.Marshal(map[string]bool{"result": allow})
_, _ = w.Write(resp)
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
for _, tc := range []struct {
email string
want bool
}{
{"alice@example.com", true},
{"bob@example.com", false},
{"alice@example.com", true}, // cached
{"bob@example.com", false}, // cached
{"carol@example.com", false}, // new
} {
in := AllowInput{Path: "/p"}
in.User.Email = tc.email
got, err := d.Allow(context.Background(), in)
if err != nil {
t.Fatalf("Allow(%s): %v", tc.email, err)
}
if got != tc.want {
t.Errorf("Allow(%s) = %v, want %v", tc.email, got, tc.want)
}
}
}
// TestCachingDecider_TTLExpires: a cached decision is re-fetched after
// the TTL window. Using a very short TTL (10ms) so the test runs fast.
func TestCachingDecider_TTLExpires(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL, CacheTTL: 10 * 1000000}) // 10ms in ns
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
if _, err := d.Allow(context.Background(), in); err != nil {
t.Fatal(err)
}
if _, err := d.Allow(context.Background(), in); err != nil {
t.Fatal(err)
}
// Two calls so far; the second is a cache hit. hits==1.
if hits != 1 {
t.Errorf("after 2 calls within TTL, hits=%d, want 1", hits)
}
// Wait past the TTL.
time.Sleep(20 * 1000000) // 20ms
if _, err := d.Allow(context.Background(), in); err != nil {
t.Fatal(err)
}
if hits != 2 {
t.Errorf("after TTL expiry, hits=%d, want 2 (re-fetched)", hits)
}
}
// TestCachingDecider_NegativeTTLDisablesCache: CacheTTL<0 returns the
// inner decider unwrapped, useful for tests that want predictable
// per-call HTTP traffic.
func TestCachingDecider_NegativeTTLDisablesCache(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL, CacheTTL: -1})
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
for i := 0; i < 5; i++ {
_, _ = d.Allow(context.Background(), in)
}
if hits != 5 {
t.Errorf("with cache disabled, hits=%d, want 5 (one per Allow)", hits)
}
}
// TestHTTPDecider_MalformedResponse: a 200 with a missing/garbage // TestHTTPDecider_MalformedResponse: a 200 with a missing/garbage
// result field also fails closed. // result field also fails closed.
func TestHTTPDecider_MalformedResponse(t *testing.T) { func TestHTTPDecider_MalformedResponse(t *testing.T) {

View file

@ -0,0 +1,26 @@
package policy
import _ "embed"
// ReferenceRego is the canonical Rego policy bundled with zddc-server.
// It mirrors the InternalDecider's semantics exactly — every release CI
// run validates parity via parity_test.go (which imports the OPA library
// as a test-only dependency, so the production binary stays OPA-free).
//
// Operators running an external OPA can use this as the starting point
// for their own policy bundle:
//
// zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
//
// Customizations typical for federal deployments:
//
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
// are absolute (NIST AC-6 least-privilege posture).
// - Add role-based access via additional input fields (input.user.roles
// populated by the upstream proxy from SAML/OIDC claims).
// - Add time-of-day or IP-range constraints.
// - Emit decision logs in a SIEM-friendly format via OPA's logging
// plugins.
//
//go:embed rego/access.rego
var ReferenceRego string

View file

@ -0,0 +1,119 @@
# Reference Rego policy that mirrors zddc-server's built-in `internal`
# decider exactly. Federal customers running their own OPA can use this
# as a starting point (and then tighten — e.g. flip the leaf-allow-overrides-
# parent-deny rule for NIST AC-6 compliance).
#
# The internal evaluator (in zddc/internal/zddc/acl.go) is the source of
# truth for production. This file is validated against that evaluator on
# every CI run via the parity test in zddc/internal/policy/parity_test.go.
# Both implementations must produce the same decision for every fixture.
#
# Input shape (matches zddc/internal/policy.AllowInput JSON encoding):
# {
# "user": {"email": "alice@example.com"},
# "path": "/Project-A/sub/",
# "policy_chain": {
# "levels": [
# {"acl": {}, "admins": ["admin@example.com"]},
# {"acl": {"allow": ["*@example.com"]}}
# ],
# "has_any_file": true
# }
# }
#
# Levels are ordered ROOT → LEAF (deepest level last). Cascade walks
# bottom-up (deepest first); first explicit match wins; within a single
# level, a deny pattern is checked before an allow pattern.
#
# Default-allow when has_any_file is false (no .zddc anywhere → public);
# default-deny when has_any_file is true and nothing matched (the safety
# net the file at <ZDDC_ROOT>/.zddc enables).
package zddc.access
import future.keywords.if
import future.keywords.in
default allow := false
# Allow when no .zddc files anywhere in the chain AND no rule matches.
allow if {
not input.policy_chain.has_any_file
count(matched_levels) == 0
}
# Allow when the deepest matching level grants.
allow if {
count(matched_levels) > 0
deepest := max(matched_levels)
level_grants(input.policy_chain.levels[deepest])
}
# Set of level indices where the email matches at least one allow or deny
# pattern. The deepest-index member is the level whose decision counts.
matched_levels := {i |
some i
level_matches(input.policy_chain.levels[i])
}
# A level "matches" if its email is in either its deny list or its allow
# list. Whether the level grants or denies is a separate question
# (level_grants below) — deny is checked before allow within a level.
level_matches(level) if {
some pattern in level.acl.deny
email_matches(pattern, input.user.email)
}
level_matches(level) if {
some pattern in level.acl.allow
email_matches(pattern, input.user.email)
}
# A level grants iff (a) no deny pattern matches at this level AND (b) some
# allow pattern matches. Mirrors AllowedAtLevel in acl.go: deny is checked
# first; if no deny hit, an allow match returns true.
level_grants(level) if {
not level_denies(level)
some pattern in level.acl.allow
email_matches(pattern, input.user.email)
}
level_denies(level) if {
some pattern in level.acl.deny
email_matches(pattern, input.user.email)
}
# email_matches: glob-match a pattern against an email, with the @-boundary
# rule from acl.go's MatchesPattern: * does not cross @. Four cases:
#
# 1. exact match (covers patterns with no wildcard)
# 2. bare "*" matches any non-empty email (special case because OPA's
# glob.match treats empty delimiters [] inconsistently for the
# lone-* pattern)
# 3. pattern has both * and @: standard glob with @ as a delimiter so
# `*@example.com` matches alice@example.com but `*example.com`
# does NOT match anything (* won't cross @)
# 4. pattern has * but no @: glob against the full email with no
# delimiter (so `alice*` matches alice@anything)
email_matches(pattern, email) if {
pattern == email
}
email_matches(pattern, email) if {
pattern == "*"
email != ""
}
email_matches(pattern, email) if {
contains(pattern, "*")
contains(pattern, "@")
glob.match(pattern, ["@"], email)
}
email_matches(pattern, email) if {
contains(pattern, "*")
not contains(pattern, "@")
pattern != "*"
glob.match(pattern, [], email)
}