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:
parent
e911806eda
commit
a01315fd00
11 changed files with 856 additions and 36 deletions
|
|
@ -32,6 +32,16 @@ import (
|
|||
var version = "dev"
|
||||
|
||||
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:])
|
||||
if errors.Is(err, config.ErrHelpRequested) {
|
||||
config.Usage(os.Stderr)
|
||||
|
|
@ -116,12 +126,25 @@ func main() {
|
|||
// (default) routes decisions through the in-process Go evaluator;
|
||||
// http(s):// or unix:// values send each decision to an external
|
||||
// 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 {
|
||||
slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err)
|
||||
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) {
|
||||
dispatch(cfg, idx, logRing, appsServer, w, r)
|
||||
|
|
|
|||
34
zddc/go.mod
34
zddc/go.mod
|
|
@ -5,8 +5,40 @@ go 1.24
|
|||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
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/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
|
||||
)
|
||||
|
|
|
|||
148
zddc/go.sum
148
zddc/go.sum
|
|
@ -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/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/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/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 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/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/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=
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all runtime configuration. Each field can be set via a
|
||||
|
|
@ -26,8 +27,9 @@ type Config struct {
|
|||
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default empty (CORS disabled); explicit value enables
|
||||
AccessLog string // --access-log / ZDDC_ACCESS_LOG — file path for tee'd JSON access log; empty = stderr only
|
||||
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)
|
||||
OPAFailOpen bool // --opa-fail-open / ZDDC_OPA_FAIL_OPEN=1 — when external OPA is unreachable, allow instead of deny (default: fail closed)
|
||||
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)
|
||||
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
|
||||
|
|
@ -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\".")
|
||||
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.")
|
||||
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"),
|
||||
"Tee structured access logs to this file (JSON, size-rotated). "+
|
||||
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
|
||||
|
|
@ -139,6 +143,7 @@ func Load(args []string) (Config, error) {
|
|||
Insecure: *insecureFlag,
|
||||
OPAURL: *opaURLFlag,
|
||||
OPAFailOpen: *opaFailOpenFlag,
|
||||
OPACacheTTL: *opaCacheTTLFlag,
|
||||
}
|
||||
|
||||
// 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.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.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.Bool("help", false, "Print this help and exit.")
|
||||
fs.Bool("version", false, "Print version info and exit.")
|
||||
|
|
@ -316,3 +322,16 @@ func getEnv(key, fallback string) string {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
|
@ -15,6 +17,15 @@ import (
|
|||
"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.
|
||||
// Returns ("", false) if relPath would escape fsRoot.
|
||||
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")
|
||||
|
||||
if strings.Contains(accept, "application/json") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if err := json.NewEncoder(w).Encode(entries); err != nil {
|
||||
// Content-hash ETag on the listing payload. Re-fetched on every
|
||||
// request (the cascade is walked, ACL filter applied, JSON
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,17 +28,34 @@ type ProjectInfo struct {
|
|||
// ServeProjectList handles GET / with Accept: application/json.
|
||||
// It returns all top-level directories under cfg.Root that the requesting
|
||||
// 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) {
|
||||
projects, err := EnumerateProjects(r.Context(), DeciderFromContext(r), cfg, EmailFromContext(r))
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if err := json.NewEncoder(w).Encode(projects); err != nil {
|
||||
body, err := json.Marshal(projects)
|
||||
if err != nil {
|
||||
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
|
||||
|
|
|
|||
164
zddc/internal/policy/parity_test.go
Normal file
164
zddc/internal/policy/parity_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ package policy
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -43,6 +45,7 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
|
|
@ -71,8 +74,8 @@ type AllowInput struct {
|
|||
// 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"`
|
||||
Levels []zddc.ZddcFile `json:"levels"`
|
||||
HasAnyFile bool `json:"has_any_file"`
|
||||
}
|
||||
|
||||
func chainToSerializable(c zddc.PolicyChain) *SerializableChain {
|
||||
|
|
@ -86,24 +89,32 @@ type Decider interface {
|
|||
|
||||
// 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
|
||||
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
|
||||
// - "http(s)://..." → HTTPDecider
|
||||
// - "unix:///..." → HTTPDecider over a Unix socket
|
||||
// - "" 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
|
||||
}
|
||||
if strings.HasPrefix(cfg.URL, "http://") || strings.HasPrefix(cfg.URL, "https://") {
|
||||
return newHTTPDecider(cfg.URL, cfg.FailOpen, nil)
|
||||
}
|
||||
if strings.HasPrefix(cfg.URL, "unix://") {
|
||||
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{
|
||||
|
|
@ -111,10 +122,22 @@ func New(cfg Config) (Decider, 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)
|
||||
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)
|
||||
}
|
||||
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
|
||||
|
|
@ -223,3 +246,77 @@ func AllowFromChain(ctx context.Context, d Decider, chain zddc.PolicyChain, emai
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,29 +8,34 @@ import (
|
|||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
cases := []struct {
|
||||
url string
|
||||
ttl time.Duration
|
||||
wantType string
|
||||
wantErr bool
|
||||
}{
|
||||
{"", "*policy.InternalDecider", false},
|
||||
{"internal", "*policy.InternalDecider", false},
|
||||
{"INTERNAL", "*policy.InternalDecider", false},
|
||||
{"http://127.0.0.1:8181", "*policy.HTTPDecider", false},
|
||||
{"https://opa.example:8181", "*policy.HTTPDecider", false},
|
||||
{"unix:///run/opa.sock", "*policy.HTTPDecider", false},
|
||||
{"ftp://nope", "", true},
|
||||
{"garbage", "", true},
|
||||
{"", 0, "*policy.InternalDecider", false},
|
||||
{"internal", 0, "*policy.InternalDecider", false},
|
||||
{"INTERNAL", 0, "*policy.InternalDecider", false},
|
||||
{"http://127.0.0.1:8181", 0, "*policy.cachingDecider", false},
|
||||
{"https://opa.example:8181", 0, "*policy.cachingDecider", false},
|
||||
{"unix:///run/opa.sock", 0, "*policy.cachingDecider", false},
|
||||
{"http://127.0.0.1:8181", -1, "*policy.HTTPDecider", false}, // cache disabled
|
||||
{"ftp://nope", 0, "", true},
|
||||
{"garbage", 0, "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
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 err == nil {
|
||||
t.Fatal("New() = nil error, want error")
|
||||
|
|
@ -42,7 +47,7 @@ func TestNew_ModeSelection(t *testing.T) {
|
|||
}
|
||||
got := describe(d)
|
||||
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"
|
||||
case *HTTPDecider:
|
||||
return "*policy.HTTPDecider"
|
||||
case *cachingDecider:
|
||||
return "*policy.cachingDecider"
|
||||
default:
|
||||
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
|
||||
// result field also fails closed.
|
||||
func TestHTTPDecider_MalformedResponse(t *testing.T) {
|
||||
|
|
|
|||
26
zddc/internal/policy/rego.go
Normal file
26
zddc/internal/policy/rego.go
Normal 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
|
||||
119
zddc/internal/policy/rego/access.rego
Normal file
119
zddc/internal/policy/rego/access.rego
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue