diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index a18a5ba..6e9f9a5 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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) diff --git a/zddc/go.mod b/zddc/go.mod index 8210fbe..ba88c1a 100644 --- a/zddc/go.mod +++ b/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 +) diff --git a/zddc/go.sum b/zddc/go.sum index be0d307..82aba98 100644 --- a/zddc/go.sum +++ b/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= diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index f8b78d0..481f8b7 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -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.d/logs/access-.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.d/logs/access-.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 +} diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index 5069898..5954f27 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -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 } diff --git a/zddc/internal/handler/projectshandler.go b/zddc/internal/handler/projectshandler.go index d3fe9f6..65e5bdd 100644 --- a/zddc/internal/handler/projectshandler.go +++ b/zddc/internal/handler/projectshandler.go @@ -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 diff --git a/zddc/internal/policy/parity_test.go b/zddc/internal/policy/parity_test.go new file mode 100644 index 0000000..927a4e2 --- /dev/null +++ b/zddc/internal/policy/parity_test.go @@ -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 +} diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go index 4faef4b..516c203 100644 --- a/zddc/internal/policy/policy.go +++ b/zddc/internal/policy/policy.go @@ -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 +} diff --git a/zddc/internal/policy/policy_test.go b/zddc/internal/policy/policy_test.go index 93b7a6a..9f8b0cf 100644 --- a/zddc/internal/policy/policy_test.go +++ b/zddc/internal/policy/policy_test.go @@ -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) { diff --git a/zddc/internal/policy/rego.go b/zddc/internal/policy/rego.go new file mode 100644 index 0000000..32023da --- /dev/null +++ b/zddc/internal/policy/rego.go @@ -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 diff --git a/zddc/internal/policy/rego/access.rego b/zddc/internal/policy/rego/access.rego new file mode 100644 index 0000000..1db0394 --- /dev/null +++ b/zddc/internal/policy/rego/access.rego @@ -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 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) +}