diff --git a/README.md b/README.md index 4668690..b750236 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,77 @@ On the client we redirect git to the proxy: As Git itself isn't aware of the snapshots, Git-specific code in the Cachew CLI can be used to reconstruct a repository. +## Authorization (OPA) + +Cachew uses [Open Policy Agent](https://www.openpolicyagent.org/) (OPA) for request authorization. A default policy is +always active even without any configuration, allowing any request from 127.0.0.1 and `GET` and `HEAD` requests from +elsewhere. + +To customise the policy, add an `opa` block to your configuration with either an inline policy or a path to a `.rego` file: + +```hcl +# Inline policy +opa { + policy = <')."` + Platform bool `help:"Prefix keys with platform ($${os}-$${arch}-)."` + Daily bool `help:"Prefix keys with date ($${YYYY}-$${MM}-$${DD}-). Mutually exclusive with --hourly." xor:"timeprefix"` + Hourly bool `help:"Prefix keys with date and hour ($${YYYY}-$${MM}-$${DD}-$${HH}-). Mutually exclusive with --daily." xor:"timeprefix"` Get GetCmd `cmd:"" help:"Download object from cache." group:"Operations:"` Stat StatCmd `cmd:"" help:"Show metadata for cached object." group:"Operations:"` @@ -42,7 +43,13 @@ func main() { ctx := context.Background() _, ctx = logging.Configure(ctx, cli.LoggingConfig) - remote := cache.NewRemote(cli.URL) + var headerFunc cache.HeaderFunc + if cli.Authorization != "" { + headerFunc = func() http.Header { + return http.Header{"Authorization": {cli.Authorization}} + } + } + remote := cache.NewRemote(cli.URL, headerFunc) defer remote.Close() kctx.BindTo(ctx, (*context.Context)(nil)) diff --git a/cmd/cachewd/main.go b/cmd/cachewd/main.go index 3f56bc2..26d8625 100644 --- a/cmd/cachewd/main.go +++ b/cmd/cachewd/main.go @@ -27,6 +27,7 @@ import ( "github.com/block/cachew/internal/jobscheduler" "github.com/block/cachew/internal/logging" "github.com/block/cachew/internal/metrics" + "github.com/block/cachew/internal/opa" "github.com/block/cachew/internal/reaper" "github.com/block/cachew/internal/strategy" "github.com/block/cachew/internal/strategy/git" @@ -42,6 +43,7 @@ type GlobalConfig struct { MetricsConfig metrics.Config `hcl:"metrics,block"` GitCloneConfig gitclone.Config `hcl:"git-clone,block"` GithubAppConfigs []githubapp.Config `hcl:"github-app,block,optional"` + OPAConfig opa.Config `hcl:"opa,block"` } type CLI struct { @@ -102,7 +104,9 @@ func main() { logger.InfoContext(ctx, "Starting cachewd", "bind", globalConfig.Bind) - server := newServer(ctx, mux, globalConfig.Bind, globalConfig.MetricsConfig) + server, err := newServer(ctx, mux, globalConfig.Bind, globalConfig.MetricsConfig, globalConfig.OPAConfig) + kctx.FatalIfErrorf(err) + err = server.ListenAndServe() kctx.FatalIfErrorf(err) } @@ -191,15 +195,18 @@ func extractPathPrefix(path string) string { return prefix } -func newServer(ctx context.Context, muxHandler http.Handler, bind string, metricsConfig metrics.Config) *http.Server { - logger := logging.FromContext(ctx) - +func newServer(ctx context.Context, muxHandler http.Handler, bind string, metricsConfig metrics.Config, opaConfig opa.Config) (*http.Server, error) { var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { labeler, _ := otelhttp.LabelerFromContext(r.Context()) labeler.Add(attribute.String("cachew.http.path.prefix", extractPathPrefix(r.URL.Path))) muxHandler.ServeHTTP(w, r) }) + handler, err := opa.Middleware(ctx, opaConfig, handler) + if err != nil { + return nil, errors.Errorf("initialise OPA middleware: %w", err) + } + // Add standard otelhttp middleware handler = otelhttp.NewMiddleware(metricsConfig.ServiceName, otelhttp.WithMeterProvider(otel.GetMeterProvider()), @@ -208,6 +215,7 @@ func newServer(ctx context.Context, muxHandler http.Handler, bind string, metric handler = httputil.LoggingMiddleware(handler) + logger := logging.FromContext(ctx) return &http.Server{ Addr: bind, Handler: handler, @@ -220,7 +228,7 @@ func newServer(ctx context.Context, muxHandler http.Handler, bind string, metric ConnContext: func(ctx context.Context, c net.Conn) context.Context { return logging.ContextWithLogger(ctx, logger.With("client", c.RemoteAddr().String())) }, - } + }, nil } // loadGlobalConfig unmarshals the global config from HCL, using a two-pass diff --git a/go.mod b/go.mod index aadae17..082a180 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/goproxy/goproxy v0.25.0 github.com/lmittmann/tint v1.1.3 github.com/minio/minio-go/v7 v7.0.98 + github.com/open-policy-agent/opa v1.14.1 github.com/prometheus/client_golang v1.23.2 go.etcd.io/bbolt v1.4.3 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 @@ -22,22 +23,33 @@ require ( ) require ( + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/aofei/backoff v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/klauspost/crc32 v1.3.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -46,21 +58,32 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/tinylib/msgp v1.6.1 // indirect + github.com/valyala/fastjson v1.6.7 // indirect + github.com/vektah/gqlparser/v2 v2.5.32 // 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/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 94acd4e..eeb5da2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= @@ -12,22 +14,41 @@ github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WS github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/aofei/backoff v1.1.0 h1:7ey7Ydpx/eFIyyrBNKPbgvTzvIuUOHcwkR3gPjjY9ag= github.com/aofei/backoff v1.1.0/go.mod h1:IHCkMdd5vGP6dcDHD+uLn6lVuBw7+rKYaS7e7QIQwYA= +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/v39 v39.0.1 h1:RibaT47yiyCRxMOj/l2cvL8cWiWBSqDXHyqsa9sGcCE= +github.com/bytecodealliance/wasmtime-go/v39 v39.0.1/go.mod h1:miR4NYIEBXeDNamZIzpskhJ0z/p8al+lwMWylQ/ZJb4= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/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/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w= +github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= 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= @@ -35,10 +56,16 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -62,8 +89,24 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.2 h1:7u4HUaD0NQbf2/n5+fyp+T10hNCsAnwKfqn4A4Baif0= +github.com/lestrrat-go/httprc/v3 v3.0.2/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= +github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +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/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -72,10 +115,13 @@ github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRi github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= 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 v1.14.1 h1:MhurLB9mSbXmojYFCmGbiC1Uagu1+aFAV4XVotDA86M= +github.com/open-policy-agent/opa v1.14.1/go.mod h1:B5gykwJ2l0g0wZS4ClCcpfSSEx51n4NHpTsWfuPwqnQ= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -86,14 +132,36 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= +github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= +github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +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.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -126,10 +194,10 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= @@ -149,5 +217,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j 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/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.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/cache/remote.go b/internal/cache/remote.go index f3507fe..053bf3b 100644 --- a/internal/cache/remote.go +++ b/internal/cache/remote.go @@ -26,16 +26,39 @@ type Remote struct { var _ Cache = (*Remote)(nil) -// NewRemote creates a new remote cache client. -func NewRemote(baseURL string) *Remote { +// HeaderFunc returns headers to attach to each outgoing request. +type HeaderFunc func() http.Header + +// NewRemote creates a new remote cache client. If headerFunc is non-nil, +// its returned headers are added to every outgoing request. +func NewRemote(baseURL string, headerFunc HeaderFunc) *Remote { transport := http.DefaultTransport.(*http.Transport).Clone() //nolint:errcheck transport.MaxIdleConns = 100 transport.MaxIdleConnsPerHost = 100 + var rt http.RoundTripper = transport + if headerFunc != nil { + rt = &headerTransport{base: transport, headerFunc: headerFunc} + } + return &Remote{ baseURL: baseURL + "/api/v1", - client: &http.Client{Transport: transport}, + client: &http.Client{Transport: rt}, + } +} + +type headerTransport struct { + base http.RoundTripper + headerFunc HeaderFunc +} + +func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + for key, values := range t.headerFunc() { + for _, value := range values { + req.Header.Add(key, value) + } } + return t.base.RoundTrip(req) //nolint:wrapcheck } func (c *Remote) String() string { return "remote:" + c.baseURL } diff --git a/internal/cache/remote_test.go b/internal/cache/remote_test.go index 4b27fa2..942047f 100644 --- a/internal/cache/remote_test.go +++ b/internal/cache/remote_test.go @@ -32,7 +32,7 @@ func TestRemoteCache(t *testing.T) { ts := httptest.NewServer(mux) t.Cleanup(ts.Close) - client := cache.NewRemote(ts.URL) + client := cache.NewRemote(ts.URL, nil) return client }) } @@ -57,7 +57,7 @@ func TestRemoteCacheSoak(t *testing.T) { ts := httptest.NewServer(mux) defer ts.Close() - client := cache.NewRemote(ts.URL) + client := cache.NewRemote(ts.URL, nil) defer client.Close() cachetest.Soak(t, client, cachetest.SoakConfig{ diff --git a/internal/opa/opa.go b/internal/opa/opa.go new file mode 100644 index 0000000..5f82e8d --- /dev/null +++ b/internal/opa/opa.go @@ -0,0 +1,138 @@ +// Package opa provides OPA-based HTTP request authorization middleware. +package opa + +import ( + "context" + "encoding/json" + "net/http" + "os" + "strings" + + "github.com/alecthomas/errors" + "github.com/open-policy-agent/opa/v1/rego" + + "github.com/block/cachew/internal/logging" +) + +// DefaultPolicy allows only GET and HEAD requests. +const DefaultPolicy = `package cachew.authz + +default allow := false + +allow if input.method == "GET" +allow if input.method == "HEAD" +allow if startswith(input.remote_addr, "127.0.0.1:") +` + +// Config for OPA policy evaluation. If neither Policy nor PolicyFile is set, +// a default policy allowing only GET and HEAD requests is used. +type Config struct { + Policy string `hcl:"policy,optional" help:"Inline Rego policy."` + PolicyFile string `hcl:"policy-file,optional" help:"Path to a Rego policy file."` + Data string `hcl:"data,optional" help:"Inline JSON object loaded as OPA data.*"` + DataFile string `hcl:"data-file,optional" help:"Path to a JSON file loaded as OPA data.*"` +} + +// Middleware returns an http.Handler that evaluates OPA policy before delegating to next. +// The policy must define a boolean "allow" rule under package cachew.authz. +func Middleware(ctx context.Context, cfg Config, next http.Handler) (http.Handler, error) { + policy, err := loadPolicy(cfg) + if err != nil { + return nil, err + } + + opts := []func(*rego.Rego){ + rego.Query("data.cachew.authz.allow"), + rego.Module("policy.rego", policy), + } + + if cfg.Data != "" || cfg.DataFile != "" { + opaData, err := loadData(cfg) + if err != nil { + return nil, err + } + opts = append(opts, rego.Data(opaData)) + } + + prepared, err := rego.New(opts...).PrepareForEval(ctx) + if err != nil { + return nil, errors.Errorf("compile OPA policy: %w", err) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + input := buildInput(r) + logger := logging.FromContext(r.Context()) + results, err := prepared.Eval(r.Context(), rego.EvalInput(input)) + if err != nil { + logger.Error("OPA evaluation failed", "error", err) + http.Error(w, "policy evaluation error", http.StatusInternalServerError) + return + } + if !results.Allowed() { + logger.Warn("OPA denied request", "method", r.Method, "path", r.URL.Path, "remote_addr", r.RemoteAddr) + http.Error(w, "forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }), nil +} + +func loadPolicy(cfg Config) (string, error) { + if cfg.Policy != "" && cfg.PolicyFile != "" { + return "", errors.New("OPA config: only one of policy or policy-file may be set") + } + if cfg.PolicyFile != "" { + data, err := os.ReadFile(cfg.PolicyFile) + if err != nil { + return "", errors.Errorf("read OPA policy file: %w", err) + } + return string(data), nil + } + if cfg.Policy != "" { + return cfg.Policy, nil + } + return DefaultPolicy, nil +} + +func loadData(cfg Config) (map[string]any, error) { + if cfg.Data != "" && cfg.DataFile != "" { + return nil, errors.New("OPA config: only one of data or data-file may be set") + } + var raw []byte + switch { + case cfg.DataFile != "": + var err error + raw, err = os.ReadFile(cfg.DataFile) + if err != nil { + return nil, errors.Errorf("read OPA data file: %w", err) + } + case cfg.Data != "": + raw = []byte(cfg.Data) + default: + return nil, errors.New("OPA config: one of data or data-file must be set") + } + var data map[string]any + if err := json.Unmarshal(raw, &data); err != nil { + return nil, errors.Errorf("parse OPA data: %w", err) + } + return data, nil +} + +func buildInput(r *http.Request) map[string]any { + path := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(path) == 1 && path[0] == "" { + path = []string{} + } + + headers := make(map[string]string, len(r.Header)) + for k, v := range r.Header { + headers[strings.ToLower(k)] = v[0] + } + + return map[string]any{ + "method": r.Method, + "path": path, + "headers": headers, + "remote_addr": r.RemoteAddr, + } +} diff --git a/internal/opa/opa_test.go b/internal/opa/opa_test.go new file mode 100644 index 0000000..c88f23b --- /dev/null +++ b/internal/opa/opa_test.go @@ -0,0 +1,248 @@ +package opa_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/block/cachew/internal/logging" + "github.com/block/cachew/internal/opa" +) + +func newRequest(method, target string) *http.Request { + r := httptest.NewRequest(method, target, nil) + return r.WithContext(logging.ContextWithLogger(r.Context(), slog.Default())) +} + +func TestMiddlewareDefaultPolicy(t *testing.T) { + tests := []struct { + Name string + Method string + ExpectedStatus int + }{ + {"GETAllowed", http.MethodGet, http.StatusOK}, + {"HEADAllowed", http.MethodHead, http.StatusOK}, + {"POSTDenied", http.MethodPost, http.StatusForbidden}, + {"PUTDenied", http.MethodPut, http.StatusForbidden}, + {"DELETEDenied", http.MethodDelete, http.StatusForbidden}, + } + + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + handler, err := opa.Middleware(t.Context(), opa.Config{}, next) + assert.NoError(t, err) + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + r := newRequest(test.Method, "/some/path") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + assert.Equal(t, test.ExpectedStatus, w.Code) + }) + } +} + +func TestMiddlewareInlinePolicy(t *testing.T) { + policy := `package cachew.authz +default allow := false +allow if input.method == "POST" +` + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + handler, err := opa.Middleware(t.Context(), opa.Config{Policy: policy}, next) + assert.NoError(t, err) + + tests := []struct { + Name string + Method string + ExpectedStatus int + }{ + {"POSTAllowed", http.MethodPost, http.StatusOK}, + {"GETDenied", http.MethodGet, http.StatusForbidden}, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + r := newRequest(test.Method, "/") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + assert.Equal(t, test.ExpectedStatus, w.Code) + }) + } +} + +func TestMiddlewarePolicyFile(t *testing.T) { + policy := `package cachew.authz +default allow := false +allow if input.path[0] == "public" +` + dir := t.TempDir() + path := filepath.Join(dir, "policy.rego") + assert.NoError(t, os.WriteFile(path, []byte(policy), 0o644)) + + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + handler, err := opa.Middleware(t.Context(), opa.Config{PolicyFile: path}, next) + assert.NoError(t, err) + + tests := []struct { + Name string + Path string + ExpectedStatus int + }{ + {"PublicAllowed", "/public/file", http.StatusOK}, + {"PrivateDenied", "/private/file", http.StatusForbidden}, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + r := newRequest(http.MethodGet, test.Path) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + assert.Equal(t, test.ExpectedStatus, w.Code) + }) + } +} + +func TestMiddlewarePathBasedPolicy(t *testing.T) { + policy := `package cachew.authz +default allow := false +allow if input.path[0] == "api" +allow if input.path[0] == "_liveness" +` + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + handler, err := opa.Middleware(t.Context(), opa.Config{Policy: policy}, next) + assert.NoError(t, err) + + tests := []struct { + Name string + Path string + ExpectedStatus int + }{ + {"APIAllowed", "/api/v1/object", http.StatusOK}, + {"LivenessAllowed", "/_liveness", http.StatusOK}, + {"AdminDenied", "/admin/pprof/", http.StatusForbidden}, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + r := newRequest(http.MethodGet, test.Path) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + assert.Equal(t, test.ExpectedStatus, w.Code) + }) + } +} + +func TestMiddlewareInlineData(t *testing.T) { + policy := `package cachew.authz +default allow := false +allow if data.allowed_methods[input.method] +` + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + handler, err := opa.Middleware(t.Context(), opa.Config{ + Policy: policy, + Data: `{"allowed_methods": {"DELETE": true}}`, + }, next) + assert.NoError(t, err) + + tests := []struct { + Name string + Method string + ExpectedStatus int + }{ + {"DELETEAllowed", http.MethodDelete, http.StatusOK}, + {"GETDenied", http.MethodGet, http.StatusForbidden}, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + r := newRequest(test.Method, "/") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + assert.Equal(t, test.ExpectedStatus, w.Code) + }) + } +} + +func TestMiddlewareBothDataAndDataFileError(t *testing.T) { + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + _, err := opa.Middleware(t.Context(), opa.Config{Data: "{}", DataFile: "x"}, next) + assert.Error(t, err) + assert.Contains(t, err.Error(), "only one of") +} + +func TestMiddlewareInlineDataInvalidJSON(t *testing.T) { + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + _, err := opa.Middleware(t.Context(), opa.Config{Data: "{not json}"}, next) + assert.Error(t, err) +} + +func TestMiddlewareDataFile(t *testing.T) { + policy := `package cachew.authz +default allow := false +allow if data.allowed_methods[input.method] +` + dataJSON := `{"allowed_methods": {"POST": true, "PUT": true}}` + + dir := t.TempDir() + policyPath := filepath.Join(dir, "policy.rego") + dataPath := filepath.Join(dir, "data.json") + assert.NoError(t, os.WriteFile(policyPath, []byte(policy), 0o644)) + assert.NoError(t, os.WriteFile(dataPath, []byte(dataJSON), 0o644)) + + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + handler, err := opa.Middleware(t.Context(), opa.Config{PolicyFile: policyPath, DataFile: dataPath}, next) + assert.NoError(t, err) + + tests := []struct { + Name string + Method string + ExpectedStatus int + }{ + {"POSTAllowed", http.MethodPost, http.StatusOK}, + {"PUTAllowed", http.MethodPut, http.StatusOK}, + {"GETDenied", http.MethodGet, http.StatusForbidden}, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + r := newRequest(test.Method, "/") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + assert.Equal(t, test.ExpectedStatus, w.Code) + }) + } +} + +func TestMiddlewareDataFileMissing(t *testing.T) { + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + _, err := opa.Middleware(t.Context(), opa.Config{DataFile: "/nonexistent"}, next) + assert.Error(t, err) +} + +func TestMiddlewareDataFileInvalidJSON(t *testing.T) { + dir := t.TempDir() + dataPath := filepath.Join(dir, "bad.json") + assert.NoError(t, os.WriteFile(dataPath, []byte("{not json}"), 0o644)) + + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + _, err := opa.Middleware(t.Context(), opa.Config{DataFile: dataPath}, next) + assert.Error(t, err) +} + +func TestMiddlewareBothPolicyAndFileError(t *testing.T) { + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + _, err := opa.Middleware(t.Context(), opa.Config{Policy: "x", PolicyFile: "y"}, next) + assert.Error(t, err) + assert.Contains(t, err.Error(), "only one of") +} + +func TestMiddlewarePolicyFileMissing(t *testing.T) { + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + _, err := opa.Middleware(t.Context(), opa.Config{PolicyFile: "/nonexistent"}, next) + assert.Error(t, err) +} + +func TestMiddlewareInvalidPolicy(t *testing.T) { + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + _, err := opa.Middleware(t.Context(), opa.Config{Policy: "not valid rego {"}, next) + assert.Error(t, err) +} diff --git a/lefthook.yml b/lefthook.yml index 328d6d0..67f9209 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,7 +4,7 @@ output: - failure pre-commit: commands: - format: + fmt: run: just fmt stage_fixed: true pre-push: