From 0d7cd62d589eccc6effd4a32a9a6e029e9ce278f Mon Sep 17 00:00:00 2001 From: Brad Egler Date: Mon, 13 Apr 2026 16:35:06 -0400 Subject: [PATCH 1/2] feat: add server side policy enforcement --- README.md | 30 +++- go.mod | 73 ++++++---- go.sum | 211 ++++++++++++++++++++-------- pkg/config/config_evaluator.go | 32 ++++- pkg/config/config_evaluator_test.go | 210 ++++++++++++++++++++++++++- pkg/config/config_loader.go | 23 +++ pkg/config/config_loader_test.go | 4 + pkg/mintycfg/runner.go | 4 + pkg/policy/README.md | 47 +++++++ pkg/policy/policy.go | 81 +++++++++++ pkg/policy/policy_test.go | 62 ++++++++ pkg/server/config.go | 20 +-- pkg/server/runner.go | 13 +- pkg/server/token_minter.go | 29 +--- pkg/server/token_minter_test.go | 38 ++--- policy/README.md | 48 +++++++ policy/centralized_cross_repo.rego | 10 ++ policy/fail_safe.rego | 28 ++++ policy/read_only.rego | 9 ++ 19 files changed, 819 insertions(+), 153 deletions(-) create mode 100644 pkg/policy/README.md create mode 100644 pkg/policy/policy.go create mode 100644 pkg/policy/policy_test.go create mode 100644 policy/README.md create mode 100644 policy/centralized_cross_repo.rego create mode 100644 policy/fail_safe.rego create mode 100644 policy/read_only.rego diff --git a/README.md b/README.md index 43d02b1..15a4ee3 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,34 @@ GITHUB_REQUEST_MAX_BACKOFF | The maximum backoff duration for GitHub API req GITHUB_REQUEST_MULTIPLIER | The backoff multiplier for GitHub API requests. Defaults to 2.0. GITHUB_REQUEST_RETRY_404 | Whether to retry GitHub API requests that return a 404 Not Found status. Defaults to true. GITHUB_REQUEST_RETRY_422 | Whether to retry GitHub API requests that return a 422 Unprocessable Entity status. Defaults to true. -ENFORCE_READ_ONLY | Whether to enforce read-only permissions for all minted tokens. Defaults to false. +MINTY_POLICY_DIR | The directory containing Rego policies for validation. Defaults to `policy`. +## Policy Validation + +Minty supports Rego-based policy validation using the Open Policy Agent (OPA) SDK. This allows service operators to enforce security guardrails at deployment time, independent of individual repository configurations. + +Policies are loaded from the directory specified by `MINTY_POLICY_DIR` (defaults to `policy`). All `.rego` files in this directory are evaluated for every token request. + +Policies should define rules in the `data.minty.policy` package and add denial messages to the `deny` set. For example: + +```rego +package minty.policy + +deny contains msg if { + input.config.source != "central" + # ... check for cross-repo access + msg := "cross-repo access only allowed in central config" +} +``` + +The policy evaluator receives an `input` object containing: +- `config`: The parsed `minty.yaml` configuration. +- `source`: The source type of the configuration (`local`, `repo`, or `central`). +- `repo`: The target repository for the token. +- `org`: The target organization. +- `token`: The OIDC token claims from the caller. + +Example policies are provided in the [`policy`](./policy) directory. ### CLI Usage @@ -126,7 +152,7 @@ This command starts the GitHub Token Minter server. | `--github-request-multiplier` | `GITHUB_REQUEST_MULTIPLIER` | The backoff multiplier for GitHub API requests. Defaults to 2.0. | | `--github-request-retry-404` | `GITHUB_REQUEST_RETRY_404` | Whether to retry GitHub API requests that return a 404 Not Found status. Defaults to true. | | `--github-request-retry-422` | `GITHUB_REQUEST_RETRY_422` | Whether to retry GitHub API requests that return a 422 Unprocessable Entity status. Defaults to true. | -| `--enforce-read-only` | `ENFORCE_READ_ONLY` | Whether to enforce read-only permissions for all minted tokens. Defaults to false. | +| `--policy-dir` | `MINTY_POLICY_DIR` | The directory containing Rego policies for validation. Defaults to `policy`. | #### `minty tools validate-cfg` diff --git a/go.mod b/go.mod index 428fd65..3290bfe 100644 --- a/go.mod +++ b/go.mod @@ -1,69 +1,88 @@ module github.com/abcxyz/github-token-minter -go 1.24 - -toolchain go1.24.0 +go 1.25.0 require ( cloud.google.com/go/kms v1.20.5 github.com/abcxyz/pkg v1.4.0 github.com/google/cel-go v0.23.2 - github.com/google/go-cmp v0.6.0 + github.com/google/go-cmp v0.7.0 github.com/google/go-github/v64 v64.0.0 github.com/google/tink/go v1.7.0 github.com/lestrrat-go/jwx/v2 v2.1.3 + github.com/open-policy-agent/opa v1.15.2 github.com/sethvargo/go-gcpkms v0.3.0 github.com/sethvargo/go-retry v0.3.0 google.golang.org/api v0.221.0 - google.golang.org/genproto/googleapis/api v0.0.0-20250212204824-5a70512c5d8b - google.golang.org/grpc v1.70.0 + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 + google.golang.org/grpc v1.79.3 gopkg.in/yaml.v3 v3.0.1 ) require ( - cel.dev/expr v0.20.0 // indirect + cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.118.2 // indirect cloud.google.com/go/auth v0.14.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.4.0 // indirect cloud.google.com/go/longrunning v0.6.4 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.2 // 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/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/kr/text v0.2.0 // indirect - github.com/lestrrat-go/blackmagic v1.0.2 // 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 v1.0.6 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/posener/complete/v2 v2.1.0 // indirect github.com/posener/script v1.2.0 // indirect - github.com/segmentio/asm v1.2.0 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect - go.opentelemetry.io/otel v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect - golang.org/x/crypto v0.36.0 // indirect + github.com/tchap/go-patricia/v2 v2.3.3 // 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/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/time v0.10.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.34.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 + golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20250212204824-5a70512c5d8b // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b // indirect - google.golang.org/protobuf v1.36.5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/protobuf v1.36.11 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index d3bd972..a8aafde 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ -cel.dev/expr v0.20.0 h1:OunBvVCfvpWlt4dN7zg3FM6TDkzOePe1+foGJ9AXeeI= -cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.118.2 h1:bKXO7RXMFDkniAAvvuMrAPtQ/VHrs9e7J5UT3yrGdTY= cloud.google.com/go v0.118.2/go.mod h1:CFO4UPEPi8oV21xoezZCrd3d81K4fFkDTEJu4R8K+9M= cloud.google.com/go/auth v0.14.1 h1:AwoJbzUdxA/whv1qj3TLKwh3XX5sikny2fc40wUl+h0= cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM= cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.4.0 h1:ZNfy/TYfn2uh/ukvhp783WhnbVluqf/tzOaqVUPlIPA= cloud.google.com/go/iam v1.4.0/go.mod h1:gMBgqPaERlriaOV0CUl//XUzDhSfXevn4OEUbg6VRs4= cloud.google.com/go/kms v1.20.5 h1:aQQ8esAIVZ1atdJRxihhdxGQ64/zEbJoJnCz/ydSmKg= @@ -16,30 +16,66 @@ cloud.google.com/go/longrunning v0.6.4 h1:3tyw9rO3E2XVXzSApn1gyEEnH2K9SynNQjMlBi cloud.google.com/go/longrunning v0.6.4/go.mod h1:ttZpLCe6e7EXvn9OxpBRx7kZEB0efv8yBO6YnVMfhJs= github.com/abcxyz/pkg v1.4.0 h1:Epu/NLwJrtDHSDLCJg8tziidCMfzRSHnqAmIOJnBZOY= github.com/abcxyz/pkg v1.4.0/go.mod h1:kHalPZbch7VBVRmz/7t4Tzcn7YB2zlhZWHgjpm15eFI= +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/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/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +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/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/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= 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-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/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/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= +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.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v64 v64.0.0 h1:4G61sozmY3eiPAjjoOHponXDBONm+utovTKbyUb2Qdg= github.com/google/go-github/v64 v64.0.0/go.mod h1:xB3vqMQNdHzilXBiO2I+M7iEFtHf+DP/omBOv6tQzVo= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -54,38 +90,73 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gT github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= -github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +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 v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +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/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo= github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo= +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 v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +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 v1.15.2 h1:dS9q+0Yvruq/VNvWJc5qCvCchn715OWc3HLHXn/UCCc= +github.com/open-policy-agent/opa v1.15.2/go.mod h1:c6SN+7jSsUcKJLQc5P4yhwx8YYDRbjpAiGkBOTqxaa4= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/posener/complete/v2 v2.1.0 h1:IpAWxMyiJ6zDSoq+QmEBF0thpOramC0kYuEFBTcQeTI= github.com/posener/complete/v2 v2.1.0/go.mod h1:AkzsSVGx4ysH/4OhZf57dr4yszGXgFmXsP/VNwlaW7U= github.com/posener/script v1.2.0 h1:DrZz0qFT8lCLkYNi1PleLDANFnKxJ2VmlNPJbAkVLsE= github.com/posener/script v1.2.0/go.mod h1:s4sVvRXtdc/1aK6otTSeW2BVXndO8MsoOVUwK74zcg4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +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= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +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/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/sethvargo/go-gcpkms v0.3.0 h1:eYBNlMGOQ2EcqSQZMxwWYGJiqxFeU5jeFOuXPHzkhuo= github.com/sethvargo/go-gcpkms v0.3.0/go.mod h1:GL2QgumjGh1Bvt9seC3nA9s+RnqS4pxRA/4X/ySHF7E= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -95,57 +166,81 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= -go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +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/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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +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/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.221.0 h1:qzaJfLhDsbMeFee8zBRdt/Nc+xmOuafD/dbdgGfutOU= google.golang.org/api v0.221.0/go.mod h1:7sOU2+TL4TxUTdbi0gWgAIg7tH5qBXxoyhtL+9x3biQ= google.golang.org/genproto v0.0.0-20250212204824-5a70512c5d8b h1:TdBaFxGAABTI8sz9jYHPtjje677pS4XXup9vJMlj8hQ= google.golang.org/genproto v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:0TrvLFkilZy+XULmuoWfiTbTRXLWXJ1S44jQTW3lWwE= -google.golang.org/genproto/googleapis/api v0.0.0-20250212204824-5a70512c5d8b h1:i+d0RZa8Hs2L/MuaOQYI+krthcxdEbEM2N+Tf3kJ4zk= -google.golang.org/genproto/googleapis/api v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b h1:FQtJ1MxbXoIIrZHZ33M+w5+dAP9o86rgpjoKr/ZmT7k= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk= -google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= -google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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/pkg/config/config_evaluator.go b/pkg/config/config_evaluator.go index 8e37c47..4add51a 100644 --- a/pkg/config/config_evaluator.go +++ b/pkg/config/config_evaluator.go @@ -23,6 +23,7 @@ import ( "github.com/google/cel-go/cel" "github.com/google/go-github/v64/github" + "github.com/abcxyz/github-token-minter/pkg/policy" "github.com/abcxyz/github-token-minter/pkg/server/source" ) @@ -47,9 +48,10 @@ type ConfigEvaluator interface { type configEvaluator struct { loaders []ConfigFileLoader + policy *policy.Evaluator } -func NewConfigEvaluator(expireAt time.Duration, localConfigDir, repoConfigPath, orgConfigRepo, orgConfigPath, ref string, sourceSystem source.System) (*configEvaluator, error) { +func NewConfigEvaluator(expireAt time.Duration, localConfigDir, repoConfigPath, orgConfigRepo, orgConfigPath, ref, policyDir string, sourceSystem source.System) (*configEvaluator, error) { // create an environment to compile any cel expressions env, err := cel.NewEnv( cel.Variable(AssertionKey, cel.DynType), @@ -58,6 +60,16 @@ func NewConfigEvaluator(expireAt time.Duration, localConfigDir, repoConfigPath, if err != nil { return nil, fmt.Errorf("failed to create CEL environment: %w", err) } + + var policyEval *policy.Evaluator + if policyDir != "" { + var err error + policyEval, err = policy.LoadPolicies(policyDir) + if err != nil { + return nil, fmt.Errorf("failed to load policies: %w", err) + } + } + // create and configure all of the file loaders localLoader := newCachingConfigLoader(expireAt, NewCompilingConfigLoader(env, &localConfigFileLoader{configDir: localConfigDir})) @@ -82,6 +94,7 @@ func NewConfigEvaluator(expireAt time.Duration, localConfigDir, repoConfigPath, inRepoLoader, orgLoader, }, + policy: policyEval, }, nil } @@ -96,6 +109,23 @@ func (l *configEvaluator) Eval(ctx context.Context, org, repo, scope string, tok continue } if contents != nil { + if l.policy != nil { + input := map[string]any{ + "config": contents, + "source": loader.SourceType(), + "repo": repo, + "org": org, + "token": token, + } + denies, err := l.policy.Evaluate(ctx, input) + if err != nil { + return nil, source, fmt.Errorf("policy evaluation failed: %w", err) + } + if len(denies) > 0 { + return nil, source, fmt.Errorf("policy violation: %s", strings.Join(denies, ", ")) + } + } + s, decision, err := contents.Eval(scope, token) if err != nil { return nil, source, fmt.Errorf("error evaluating scope: %w", err) diff --git a/pkg/config/config_evaluator_test.go b/pkg/config/config_evaluator_test.go index d45294c..0490cae 100644 --- a/pkg/config/config_evaluator_test.go +++ b/pkg/config/config_evaluator_test.go @@ -17,17 +17,22 @@ package config import ( "context" "fmt" + "os" + "path/filepath" + "strings" "testing" "github.com/google/cel-go/cel" "github.com/google/go-cmp/cmp" + "github.com/abcxyz/github-token-minter/pkg/policy" "github.com/abcxyz/pkg/testutil" ) type testConfigFileLoader struct { - result *Config - err error + result *Config + err error + sourceType string } func (l *testConfigFileLoader) Load(ctx context.Context, org, repo string) (*Config, error) { @@ -38,6 +43,13 @@ func (l *testConfigFileLoader) Source(org, repo string) string { return fmt.Sprintf("mem://%s/%s", org, repo) } +func (l *testConfigFileLoader) SourceType() string { + if l.sourceType != "" { + return l.sourceType + } + return "test" +} + func TestOrderedConfigFileLoader(t *testing.T) { t.Parallel() @@ -388,3 +400,197 @@ func TestOrderedConfigFileLoader(t *testing.T) { }) } } + +func TestConfigEvaluator_Policy(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + rego := ` +package minty.policy +deny contains "test source not allowed" if { + input.source == "test" +} +` + err := os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(rego), 0o600) + if err != nil { + t.Fatal(err) + } + + eval, err := policy.LoadPolicies(dir) + if err != nil { + t.Fatal(err) + } + + ce := &configEvaluator{ + loaders: []ConfigFileLoader{ + &testConfigFileLoader{ + result: &Config{Scopes: map[string]*Scope{"test_scope": {}}}, + err: nil, + }, + }, + policy: eval, + } + + ctx := t.Context() + + _, _, err = ce.Eval(ctx, "test_org", "test_repo", "test_scope", map[string]string{}) + if err == nil { + t.Fatal("expected error due to policy violation, got nil") + } + + if !strings.Contains(err.Error(), "policy violation: test source not allowed") { + t.Errorf("expected error to contain 'policy violation: test source not allowed', got %v", err) + } +} + +func TestConfigEvaluator_SpecificPolicies(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load policies from the top-level directory + policyDir := "../../policy" + eval, err := policy.LoadPolicies(policyDir) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + sourceType string + config *Config + requestRepo string + token any + wantErr string + }{ + { + name: "read_only_ok", + sourceType: "local", + config: &Config{ + Scopes: map[string]*Scope{ + "test": { + Permissions: map[string]string{"contents": "read"}, + }, + }, + }, + requestRepo: "test_repo", + wantErr: "", + }, + { + name: "read_only_violation", + sourceType: "local", + config: &Config{ + Scopes: map[string]*Scope{ + "test": { + Permissions: map[string]string{"contents": "write"}, + }, + }, + }, + requestRepo: "test_repo", + wantErr: "requests non-read permission", + }, + { + name: "centralized_ok_same_repo", + sourceType: "local", + config: &Config{ + Scopes: map[string]*Scope{ + "test": { + Repositories: []string{"test_repo"}, + }, + }, + }, + requestRepo: "test_repo", + wantErr: "", + }, + { + name: "centralized_violation_cross_repo", + sourceType: "local", + config: &Config{ + Scopes: map[string]*Scope{ + "test": { + Repositories: []string{"other_repo"}, + }, + }, + }, + requestRepo: "test_repo", + wantErr: "requests access to other repository", + }, + { + name: "centralized_ok_cross_repo_from_central", + sourceType: "central", + config: &Config{ + Scopes: map[string]*Scope{ + "test": { + Repositories: []string{"other_repo"}, + }, + }, + }, + requestRepo: "test_repo", + wantErr: "", + }, + { + name: "fail_safe_ok", + sourceType: "local", + config: &Config{ + Scopes: map[string]*Scope{ + "test": {}, + }, + }, + requestRepo: "test_repo", + token: map[string]any{ + "enterprise_id": "YOUR_ENTERPRISE_ID", + "repository_owner_id": "YOUR_ORG_ID", + "repository_id": "YOUR_REPO_ID", + }, + wantErr: "", + }, + { + name: "fail_safe_violation_org", + sourceType: "local", + config: &Config{ + Scopes: map[string]*Scope{ + "test": {}, + }, + }, + requestRepo: "test_repo", + token: map[string]any{ + "enterprise_id": "YOUR_ENTERPRISE_ID", + "repository_owner_id": "WRONG_ORG_ID", + "repository_id": "YOUR_REPO_ID", + }, + wantErr: "invalid org ID", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ce := &configEvaluator{ + loaders: []ConfigFileLoader{ + &testConfigFileLoader{ + result: tc.config, + err: nil, + sourceType: tc.sourceType, + }, + }, + policy: eval, + } + + _, _, err := ce.Eval(ctx, "test_org", tc.requestRepo, "test", tc.token) + + if tc.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("expected error to contain %q, got %v", tc.wantErr, err) + } + } + }) + } +} diff --git a/pkg/config/config_loader.go b/pkg/config/config_loader.go index 1337800..c335e1b 100644 --- a/pkg/config/config_loader.go +++ b/pkg/config/config_loader.go @@ -38,6 +38,9 @@ type ConfigFileLoader interface { // Load reads the configuration for an org and repo and marshals // it into a Config object. Load(ctx context.Context, org, repo string) (*Config, error) + // SourceType returns a string identifier for the type of source + // (e.g., "local", "repo", "central"). + SourceType() string } type cachingConfigFileLoader struct { @@ -81,6 +84,10 @@ func (l *compilingConfigLoader) Source(org, repo string) string { return l.loader.Source(org, repo) } +func (l *compilingConfigLoader) SourceType() string { + return l.loader.SourceType() +} + func (l *cachingConfigFileLoader) Load(ctx context.Context, org, repo string) (*Config, error) { key := fmt.Sprintf("%s/%s", org, repo) // first look for the config object in cache @@ -103,6 +110,10 @@ func (l *cachingConfigFileLoader) Source(org, repo string) string { return l.loader.Source(org, repo) } +func (l *cachingConfigFileLoader) SourceType() string { + return l.loader.SourceType() +} + // localConfigFileLoader is a configFileLoader implementation that // reads files from the local file system. type localConfigFileLoader struct { @@ -131,6 +142,10 @@ func (l *localConfigFileLoader) Source(org, repo string) string { return fmt.Sprintf("file://%s/%s/%s.yaml", l.configDir, org, repo) } +func (l *localConfigFileLoader) SourceType() string { + return "local" +} + // inRepoConfigFileLoader reads a configuration file from a specific // path within the requested repository. type inRepoConfigFileLoader struct { @@ -161,6 +176,10 @@ func (l *inRepoConfigFileLoader) Source(org, repo string) string { return fmt.Sprintf("%s/%s/%s/%s", l.sourceSystem.BaseURL(), org, repo, l.configPath) } +func (l *inRepoConfigFileLoader) SourceType() string { + return "repo" +} + // fixedRepoConfigFileLoader reads the contents of a configuration file from // a specific repository, not from the target repository. It wraps another // loader and delegates the retrieval to that implementation after replacing @@ -184,6 +203,10 @@ func (l *fixedRepoConfigFileLoader) Source(org, repo string) string { return l.loader.Source(org, l.repo) } +func (l *fixedRepoConfigFileLoader) SourceType() string { + return "central" +} + func Read(contents []byte) (*Config, error) { type probe struct { Version string `yaml:"version"` diff --git a/pkg/config/config_loader_test.go b/pkg/config/config_loader_test.go index 878f2a8..526d7fe 100644 --- a/pkg/config/config_loader_test.go +++ b/pkg/config/config_loader_test.go @@ -486,3 +486,7 @@ func (m *mockLoader) Load(ctx context.Context, org, repo string) (*Config, error func (m *mockLoader) Source(org, repo string) string { return "mock" } + +func (m *mockLoader) SourceType() string { + return "mock" +} diff --git a/pkg/mintycfg/runner.go b/pkg/mintycfg/runner.go index 476c09c..054a144 100644 --- a/pkg/mintycfg/runner.go +++ b/pkg/mintycfg/runner.go @@ -48,6 +48,10 @@ func (l *singleFileConfigLoader) Source(org, repo string) string { return fmt.Sprintf("file://%s", l.filePath) } +func (l *singleFileConfigLoader) SourceType() string { + return "local" +} + func Run(ctx context.Context, cfg *Config) error { // create an environment to compile any cel expressions env, err := cel.NewEnv( diff --git a/pkg/policy/README.md b/pkg/policy/README.md new file mode 100644 index 0000000..dc67ba9 --- /dev/null +++ b/pkg/policy/README.md @@ -0,0 +1,47 @@ +# Policy Package + +This package implements the Rego policy evaluation engine for Minty. It uses the OPA (Open Policy Agent) Go SDK to evaluate policies against token requests. + +## Usage + +```go +import "github.com/abcxyz/github-token-minter/pkg/policy" + +// Load policies from a directory +evaluator, err := policy.LoadPolicies("/path/to/policy/dir") +if err != nil { + // handle error +} + +// Evaluate a request +input := map[string]any{ + "config": ..., + "source": "local", + "repo": "my-repo", + "org": "my-org", + "token": claims, +} + +denies, err := evaluator.Evaluate(ctx, input) +if err != nil { + // handle error +} + +if len(denies) > 0 { + // request denied + fmt.Println("Denied:", denies) +} +``` + +## Rego Package Convention + +Policies must use the package `minty.policy` and define a rule named `deny` that evaluates to a set or list of strings. + +```rego +package minty.policy + +deny contains msg if { + # conditions + msg := "denial message" +} +``` diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go new file mode 100644 index 0000000..c87868d --- /dev/null +++ b/pkg/policy/policy.go @@ -0,0 +1,81 @@ +// Copyright 2026 The Authors (see AUTHORS file) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "context" + "fmt" + "os" + + "github.com/open-policy-agent/opa/v1/rego" +) + +// Evaluator wraps a prepared OPA query for policy evaluation. +type Evaluator struct { + query rego.PreparedEvalQuery +} + +// LoadPolicies loads all Rego policies from the specified directory +// and prepares a query for `data.minty.policy.deny`. +func LoadPolicies(dir string) (*Evaluator, error) { + if _, err := os.Stat(dir); err != nil { + return nil, fmt.Errorf("failed to stat policy dir: %w", err) + } + + ctx := context.Background() + + r := rego.New( + rego.Query("data.minty.policy.deny"), + rego.Load([]string{dir}, nil), + ) + + pq, err := r.PrepareForEval(ctx) + if err != nil { + return nil, fmt.Errorf("failed to prepare query: %w", err) + } + + return &Evaluator{query: pq}, nil +} + +// Evaluate runs the prepared policy query against the provided input. +// It returns a list of denial messages. If the list is empty, the request is allowed. +func (e *Evaluator) Evaluate(ctx context.Context, input any) ([]string, error) { + results, err := e.query.Eval(ctx, rego.EvalInput(input)) + if err != nil { + return nil, fmt.Errorf("failed to evaluate policy: %w", err) + } + + if len(results) == 0 || len(results[0].Expressions) == 0 { + return nil, nil + } + + val := results[0].Expressions[0].Value + + list, ok := val.([]any) + if !ok { + return nil, fmt.Errorf("unexpected result type from policy: expected []any, got %T", val) + } + + denies := make([]string, 0, len(list)) + for _, item := range list { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("unexpected item type in deny list: expected string, got %T", item) + } + denies = append(denies, s) + } + + return denies, nil +} diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go new file mode 100644 index 0000000..fbd8fc1 --- /dev/null +++ b/pkg/policy/policy_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Authors (see AUTHORS file) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadPolicies_NonExistentDir(t *testing.T) { + t.Parallel() + + _, err := LoadPolicies("/non-existent-dir") + if err == nil { + t.Fatal("expected error loading from non-existent dir, got nil") + } +} + +func TestEvaluator_Evaluate_Deny(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + rego := ` +package minty.policy +deny contains "not allowed" if { + input.allowed == false +} +` + err := os.WriteFile(filepath.Join(dir, "policy.rego"), []byte(rego), 0o600) + if err != nil { + t.Fatal(err) + } + + eval, err := LoadPolicies(dir) + if err != nil { + t.Fatal(err) + } + + input := map[string]any{"allowed": false} + denies, err := eval.Evaluate(t.Context(), input) + if err != nil { + t.Fatal(err) + } + + if len(denies) != 1 || denies[0] != "not allowed" { + t.Errorf("expected [\"not allowed\"], got %v", denies) + } +} diff --git a/pkg/server/config.go b/pkg/server/config.go index 7a95d72..da5eb64 100644 --- a/pkg/server/config.go +++ b/pkg/server/config.go @@ -42,6 +42,7 @@ type Config struct { OrgConfigPath string Ref string ConfigCacheSeconds string + PolicyDir string JWKSCacheDuration time.Duration IssuerAllowlist []string @@ -52,7 +53,6 @@ type Config struct { GitHubRequestMultiplier float64 GitHubRequestRetry404 bool GitHubRequestRetry422 bool - EnforceReadOnly bool } // LogValue implements slog.LogValuer and returns a grouped value @@ -75,6 +75,7 @@ func (cfg Config) LogValue() slog.Value { slog.String("org_config_path", cfg.OrgConfigPath), slog.String("ref", cfg.Ref), slog.String("config_cache_seconds", cfg.ConfigCacheSeconds), + slog.String("policy_dir", cfg.PolicyDir), slog.Any("jwks_cache_duration", cfg.JWKSCacheDuration), slog.Any("issuer_allowlist", cfg.IssuerAllowlist), slog.Any("source_system_auth", ssas), @@ -84,7 +85,6 @@ func (cfg Config) LogValue() slog.Value { slog.Float64("github_request_multiplier", cfg.GitHubRequestMultiplier), slog.Bool("github_request_retry_404", cfg.GitHubRequestRetry404), slog.Bool("github_request_retry_422", cfg.GitHubRequestRetry422), - slog.Bool("enforce_read_only", cfg.EnforceReadOnly), ) } @@ -224,6 +224,14 @@ func (cfg *Config) ToFlags(set *cli.FlagSet) *cli.FlagSet { Usage: `The number of seconds to cache configuration files before retrieving fresh ones. Defaults to 900 seconds.`, }) + f.StringVar(&cli.StringVar{ + Name: "policy-dir", + Target: &cfg.PolicyDir, + EnvVar: "MINTY_POLICY_DIR", + Usage: `The directory containing Rego policies for validation.`, + Default: "policy", + }) + f.DurationVar(&cli.DurationVar{ Name: "jwks-cache-duration", Target: &cfg.JWKSCacheDuration, @@ -288,13 +296,5 @@ func (cfg *Config) ToFlags(set *cli.FlagSet) *cli.FlagSet { Usage: `Whether to retry GitHub API requests that return a 422 Unprocessable Entity status.`, }) - f.BoolVar(&cli.BoolVar{ - Name: "enforce-read-only", - Target: &cfg.EnforceReadOnly, - EnvVar: "ENFORCE_READ_ONLY", - Default: false, - Usage: `Whether to enforce read-only permissions for all minted tokens.`, - }) - return set } diff --git a/pkg/server/runner.go b/pkg/server/runner.go index 3c4a9b1..b11b6cd 100644 --- a/pkg/server/runner.go +++ b/pkg/server/runner.go @@ -18,6 +18,7 @@ import ( "context" "crypto" "fmt" + "os" "strconv" "time" @@ -28,6 +29,7 @@ import ( "github.com/abcxyz/github-token-minter/pkg/config" "github.com/abcxyz/github-token-minter/pkg/server/source" "github.com/abcxyz/pkg/githubauth" + "github.com/abcxyz/pkg/logging" "github.com/abcxyz/pkg/serving" ) @@ -65,6 +67,14 @@ func Run(ctx context.Context, cfg *Config) error { cacheSeconds = 1 } + policyDir := cfg.PolicyDir + if policyDir == "policy" { + if _, err := os.Stat(policyDir); os.IsNotExist(err) { + logging.FromContext(ctx).WarnContext(ctx, "default policy directory 'policy' does not exist, skipping policy enforcement") + policyDir = "" + } + } + store, err := config.NewConfigEvaluator( time.Duration(cacheSeconds)*time.Second, cfg.ConfigDir, @@ -72,6 +82,7 @@ func Run(ctx context.Context, cfg *Config) error { cfg.OrgConfigRepo, cfg.OrgConfigPath, cfg.Ref, + policyDir, sourceSystem, ) if err != nil { @@ -85,7 +96,7 @@ func Run(ctx context.Context, cfg *Config) error { jwkResolver := NewOIDCResolver(ctx, cfg.IssuerAllowlist, cfg.JWKSCacheDuration) // Create the Router for the token minting server. - tokenServer, err := NewRouter(ctx, sourceSystem, store, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: jwkResolver}, cfg.EnforceReadOnly) + tokenServer, err := NewRouter(ctx, sourceSystem, store, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: jwkResolver}) if err != nil { return fmt.Errorf("failed to start token mint server: %w", err) } diff --git a/pkg/server/token_minter.go b/pkg/server/token_minter.go index b4322e6..b99b383 100644 --- a/pkg/server/token_minter.go +++ b/pkg/server/token_minter.go @@ -42,10 +42,9 @@ const ( // TokenMinterServer is the implementation of an HTTP server that exchanges // a GitHub OIDC token for a GitHub application token with eleveated privlidges. type TokenMinterServer struct { - sourceSystem source.System - configStore config.ConfigEvaluator - parser *JWTParser - enforceReadOnly bool + sourceSystem source.System + configStore config.ConfigEvaluator + parser *JWTParser } // TokenRequest is a struct that contains the list of repositories and the @@ -69,12 +68,11 @@ type apiResponse struct { // NewRouter creates a new HTTP server implementation that will exchange // a GitHub OIDC token for a GitHub application token with eleveated privlidges. -func NewRouter(ctx context.Context, sourceSystem source.System, configStore config.ConfigEvaluator, parser *JWTParser, enforceReadOnly bool) (*TokenMinterServer, error) { +func NewRouter(ctx context.Context, sourceSystem source.System, configStore config.ConfigEvaluator, parser *JWTParser) (*TokenMinterServer, error) { return &TokenMinterServer{ - sourceSystem: sourceSystem, - configStore: configStore, - parser: parser, - enforceReadOnly: enforceReadOnly, + sourceSystem: sourceSystem, + configStore: configStore, + parser: parser, }, nil } @@ -163,19 +161,6 @@ func (s *TokenMinterServer) processRequest(r *http.Request) *apiResponse { } request.OrgName = requestOrgName - // If the server is configured to enforce read-only permissions, then - // overwrite the requested permissions with a read-only set. - if s.enforceReadOnly { - logger.DebugContext(ctx, "enforcing read-only permissions", - "original_permissions", request.Permissions, - "new_permissions", map[string]string{"contents": "read", "metadata": "read"}, - ) - request.Permissions = map[string]string{ - "contents": "read", - "metadata": "read", - } - } - // If no repositories are requested, default to the repository from the // OIDC token claims. If neither exist then throw an error. if len(request.Repositories) == 0 { diff --git a/pkg/server/token_minter_test.go b/pkg/server/token_minter_test.go index 7e3a333..32bcc41 100644 --- a/pkg/server/token_minter_test.go +++ b/pkg/server/token_minter_test.go @@ -129,13 +129,12 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { }) cases := []struct { - name string - req *http.Request - expCode int - expResp string - expErr string - resolver mockJwksResolver - enforceReadOnly bool + name string + req *http.Request + expCode int + expResp string + expErr string + resolver mockJwksResolver }{ { name: "no_token_header", @@ -551,27 +550,6 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { expResp: "requested scope \"test\" is not found for repository \"abcxyz\"/\"no-config-repo\"", expErr: "error reading configuration for repository abcxyz/no-config-repo", }, - { - name: "enforce_read_only", - req: func() *http.Request { - body := strings.NewReader(`{"scope":"test-perms","permissions":{"contents":"write","issues":"write"}}`) - r := httptest.NewRequest("GET", "/", body).WithContext(ctx) - - signed := testTokenBuilder(t, signer, func(b *jwt.Builder) { - b.Issuer(config.GitHubIssuer) - b.Claim("repository", "abcxyz/pkg") - b.Claim("workflow_ref", "abcxyz/pkg/.github/workflows/test.yml") - }) - r.Header.Set("X-OIDC-Token", signed) - return r - }(), - resolver: mockJwksResolver{ - keySet: jwkCachedSet, - }, - enforceReadOnly: true, - expCode: 200, - expResp: "[contents=read metadata=read]", - }, } for _, tc := range cases { @@ -593,12 +571,12 @@ func TestTokenMintServer_ProcessRequest(t *testing.T) { t.Fatal(err) } - configStore, err := config.NewConfigEvaluator(1*time.Hour, "../../testdata/configs", ".github/minty.yaml", ".minty", "minty.yaml", "main", sourceSystem) + configStore, err := config.NewConfigEvaluator(1*time.Hour, "../../testdata/configs", ".github/minty.yaml", ".minty", "minty.yaml", "main", "", sourceSystem) if err != nil { t.Fatal(err) } - server, err := NewRouter(ctx, sourceSystem, configStore, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: &tc.resolver}, tc.enforceReadOnly) + server, err := NewRouter(ctx, sourceSystem, configStore, &JWTParser{ParseOptions: jwtParseOptions, JWKResolver: &tc.resolver}) if err != nil { t.Fatal(err) } diff --git a/policy/README.md b/policy/README.md new file mode 100644 index 0000000..3171caf --- /dev/null +++ b/policy/README.md @@ -0,0 +1,48 @@ +# Minty Policies + +This directory contains Rego policies used by Minty to enforce security guardrails at deployment time. + +## Usage + +Policies are loaded from this directory by default if it exists. The directory can be configured using the `MINTY_POLICY_DIR` environment variable or `--policy-dir` flag. + +All `.rego` files in this directory are loaded and evaluated. Policies must define rules in the `data.minty.policy` package and add denial messages to the `deny` set. + +## Included Policies + +### [read_only.rego](./read_only.rego) + +Enforces that all requested permissions are read-only. This is useful for deployments where Minty should never grant write access, regardless of what is configured in repository `minty.yaml` files. + +### [centralized_cross_repo.rego](./centralized_cross_repo.rego) + +Enforces that cross-repository token requests (where a repo requests access to another repo) are only allowed if the configuration comes from a "central" source (e.g., a central admin repository). This prevents individual repositories from granting themselves access to other repositories without central approval. + +### [fail_safe.rego](./fail_safe.rego) + +Provides a template for enterprise, organization, and repository ID allow-listing. This acts as a fail-safe to ensure that tokens are only minted for authorized entities, even if a request somehow bypasses other protections. + +**IMPORTANT**: You must replace the placeholder values (`YOUR_ENTERPRISE_ID`, `YOUR_ORG_ID`, `YOUR_REPO_ID`) with your actual IDs before enabling this policy! + +## Policy Input Schema + +The policy evaluator passes an object to Rego as `input` with the following structure: + +```json +{ + "config": { + "version": "minty.abcxyz.dev/v2", + "rule": { ... }, + "scopes": { ... } + }, + "source": "local|repo|central", + "repo": "target-repo-name", + "org": "target-org-name", + "token": { + "enterprise_id": "...", + "repository_owner_id": "...", + "repository_id": "...", + ... (other OIDC claims) + } +} +``` diff --git a/policy/centralized_cross_repo.rego b/policy/centralized_cross_repo.rego new file mode 100644 index 0000000..864fa63 --- /dev/null +++ b/policy/centralized_cross_repo.rego @@ -0,0 +1,10 @@ +package minty.policy + +# Ensure cross-repo tokens are only defined in the central configuration. +deny contains msg if { + input.source != "central" + some scope_name, scope in input.config.scope + some repo in scope.repositories + repo != input.repo + msg := sprintf("scope %q requests access to other repository %q from non-central config", [scope_name, repo]) +} diff --git a/policy/fail_safe.rego b/policy/fail_safe.rego new file mode 100644 index 0000000..de31bbe --- /dev/null +++ b/policy/fail_safe.rego @@ -0,0 +1,28 @@ +package minty.policy + +# Fail-safe: Only allow token requests from a specific enterprise, org, and repo ID. +# Replace these values with your actual allowed IDs. +allowed_enterprise_id := "YOUR_ENTERPRISE_ID" +allowed_org_id := "YOUR_ORG_ID" +allowed_repo_id := "YOUR_REPO_ID" + +# Deny if enterprise ID is present and does not match +deny contains msg if { + ent_id := input.token.enterprise_id + ent_id != allowed_enterprise_id + msg := sprintf("invalid enterprise ID %q", [ent_id]) +} + +# Deny if org ID does not match +deny contains msg if { + org_id := input.token.repository_owner_id + org_id != allowed_org_id + msg := sprintf("invalid org ID %q", [org_id]) +} + +# Deny if repo ID does not match +deny contains msg if { + repo_id := input.token.repository_id + repo_id != allowed_repo_id + msg := sprintf("invalid repo ID %q", [repo_id]) +} diff --git a/policy/read_only.rego b/policy/read_only.rego new file mode 100644 index 0000000..0c92d2f --- /dev/null +++ b/policy/read_only.rego @@ -0,0 +1,9 @@ +package minty.policy + +# Ensure no configuration allows "write" permissions in this deployment. +deny contains msg if { + some scope_name, scope in input.config.scope + some perm_name, perm_value in scope.permissions + perm_value != "read" + msg := sprintf("scope %q requests non-read permission %q: %q", [scope_name, perm_name, perm_value]) +} From 86375eab7795d6d1c7c2eb34469daadbfe57f795 Mon Sep 17 00:00:00 2001 From: Brad Egler Date: Thu, 16 Apr 2026 13:31:17 -0400 Subject: [PATCH 2/2] updated docs, agent skills and example policies --- .agents/skills/generate-minty-config/SKILL.md | 4 +- .agents/skills/validate-minty-config/SKILL.md | 27 ++++- README.md | 6 +- pkg/cli/root.go | 2 +- pkg/config/config_evaluator_test.go | 28 +++++ pkg/mintycfg/README.md | 9 +- pkg/mintycfg/config.go | 18 +++- pkg/mintycfg/runner.go | 101 ++++++++++++++---- policy/fail_safe.rego | 8 +- policy/read_only.rego | 13 +++ 10 files changed, 174 insertions(+), 42 deletions(-) diff --git a/.agents/skills/generate-minty-config/SKILL.md b/.agents/skills/generate-minty-config/SKILL.md index e833bdb..e8a32a8 100644 --- a/.agents/skills/generate-minty-config/SKILL.md +++ b/.agents/skills/generate-minty-config/SKILL.md @@ -53,7 +53,7 @@ Create or update `.github/minty.yaml`. **Template for Service Account**: ```yaml -version: 'minty.abcxyz.dev/v1' +version: 'minty.abcxyz.dev/v2' rule: if: 'assertion.email == ""' scope: @@ -65,7 +65,7 @@ scope: **Template for GitHub Action**: ```yaml -version: 'minty.abcxyz.dev/v1' +version: 'minty.abcxyz.dev/v2' rule: if: 'assertion.repository_id == "" && assertion.repository_owner_id == "" && assertion.workflow == "" && assertion.ref == ""' scope: diff --git a/.agents/skills/validate-minty-config/SKILL.md b/.agents/skills/validate-minty-config/SKILL.md index aaca5ef..cc262d0 100644 --- a/.agents/skills/validate-minty-config/SKILL.md +++ b/.agents/skills/validate-minty-config/SKILL.md @@ -1,6 +1,6 @@ --- name: validate-minty-config -description: Validates Minty configuration files using the `tools validate-cfg` command. +description: Validates Minty configuration files using the `tools validate` command. --- # Validate Minty Configuration @@ -11,7 +11,7 @@ This skill provides instructions for validating Minty configuration files using To validate a configuration file, run: ```bash -go run github.com/abcxyz/github-token-minter/cmd/minty@main tools validate-cfg --minty-file +go run github.com/abcxyz/github-token-minter/cmd/minty@main tools validate --minty-file ``` ### Optional Arguments @@ -19,10 +19,29 @@ go run github.com/abcxyz/github-token-minter/cmd/minty@main tools validate-cfg - - `--scope `: Validate against a specific scope. - `--token `: Validate using a specific token (JSON string). +- `--policy `: Path to a policy file or directory to validate. > [!NOTE] > If any required arguments (like the config file path) or optional arguments (like scope or token) are not provided or known from context, **ask the user for them** before running the command. +### Policy Validation + +The command can also validate Rego policies. + +**Syntax Check Only:** +```bash +go run github.com/abcxyz/github-token-minter/cmd/minty@main tools validate --policy +``` +Output on success: `policy compiled`. + +**Combined Validation:** +```bash +go run github.com/abcxyz/github-token-minter/cmd/minty@main tools validate \ + --minty-file \ + --policy +``` +You can also provide `--token` to simulate the token input for policy evaluation. + ### Using Log Entries If the user provides a server log entry (JSON), extract the following fields: @@ -44,7 +63,7 @@ Given a log entry: Run: ```bash -go run github.com/abcxyz/github-token-minter/cmd/minty@main tools validate-cfg \ +go run github.com/abcxyz/github-token-minter/cmd/minty@main tools validate \ --minty-file \ --scope "my-scope" \ --token '{"iss": "..."}' @@ -73,7 +92,7 @@ If a log entry is provided, you can also fetch the configuration file directly f ### Example ```bash -go run github.com/abcxyz/github-token-minter/cmd/minty@main tools validate-cfg --minty-file config.yaml --scope "my-scope" --token '{"iss":"..."}' +go run github.com/abcxyz/github-token-minter/cmd/minty@main tools validate --minty-file config.yaml --scope "my-scope" --token '{"iss":"..."}' ``` ## Output Interpretation diff --git a/README.md b/README.md index 15a4ee3..f2294fb 100644 --- a/README.md +++ b/README.md @@ -154,15 +154,17 @@ This command starts the GitHub Token Minter server. | `--github-request-retry-422` | `GITHUB_REQUEST_RETRY_422` | Whether to retry GitHub API requests that return a 422 Unprocessable Entity status. Defaults to true. | | `--policy-dir` | `MINTY_POLICY_DIR` | The directory containing Rego policies for validation. Defaults to `policy`. | -#### `minty tools validate-cfg` +#### `minty tools validate` -This command validates a minty configuration file. +This command validates a minty configuration file or policy. | Flag | Environment Variable | Description | |---|---|---| | `--minty-file` | `MINTY_FILE` | The minty config file to inspect. | | `--scope` | `SCOPE` | The scope to test. | | `--token` | `TOKEN` | The token to test with. | +| `--policy` | `POLICY_PATH` | The path to the policy file or directory to validate. | + #### `minty tools mint` diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 722f60a..4e6103c 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -43,7 +43,7 @@ var rootCmd = func() cli.Command { Name: "tools", Description: "Minty tools", Commands: map[string]cli.CommandFactory{ - "validate-cfg": func() cli.Command { + "validate": func() cli.Command { return &MintyCfgCommand{} }, "mint": func() cli.Command { diff --git a/pkg/config/config_evaluator_test.go b/pkg/config/config_evaluator_test.go index 0490cae..bc3c46f 100644 --- a/pkg/config/config_evaluator_test.go +++ b/pkg/config/config_evaluator_test.go @@ -490,6 +490,19 @@ func TestConfigEvaluator_SpecificPolicies(t *testing.T) { requestRepo: "test_repo", wantErr: "requests non-read permission", }, + { + name: "read_only_violation_empty_permissions", + sourceType: "local", + config: &Config{ + Scopes: map[string]*Scope{ + "test": { + Permissions: map[string]string{}, + }, + }, + }, + requestRepo: "test_repo", + wantErr: "has empty permissions", + }, { name: "centralized_ok_same_repo", sourceType: "local", @@ -561,6 +574,21 @@ func TestConfigEvaluator_SpecificPolicies(t *testing.T) { }, wantErr: "invalid org ID", }, + { + name: "fail_safe_violation_missing_org", + sourceType: "local", + config: &Config{ + Scopes: map[string]*Scope{ + "test": {}, + }, + }, + requestRepo: "test_repo", + token: map[string]any{ + "enterprise_id": "YOUR_ENTERPRISE_ID", + "repository_id": "YOUR_REPO_ID", + }, + wantErr: "invalid org ID", + }, } for _, tc := range cases { diff --git a/pkg/mintycfg/README.md b/pkg/mintycfg/README.md index 280cbf8..0a6d742 100644 --- a/pkg/mintycfg/README.md +++ b/pkg/mintycfg/README.md @@ -1,16 +1,17 @@ # Mintycfg Package -This package implements the `validate-cfg` tool, which is used to validate `github-token-minter` configuration files. +This package implements the `validate` tool, which is used to validate `github-token-minter` configuration files and policies. ## Purpose -The `mintycfg` package provides the logic for the `tools validate-cfg` CLI command. It: +The `mintycfg` package provides the logic for the `tools validate` CLI command. It: - Loads a specific configuration file. - Compiles all CEL expressions to ensure they are valid. - Prints the structure of the configuration. - Optionally evaluates a token against a scope to test the configuration. +- Validates Rego policies (syntax check and evaluation against config). ## Files -- **`config.go`**: Defines the configuration for the validate-cfg command (MintyFile, Scope, Token). -- **`runner.go`**: Implements the logic to load, compile, and optionally evaluate the configuration. +- **`config.go`**: Defines the configuration for the validate command (MintyFile, Scope, Token, PolicyPath). +- **`runner.go`**: Implements the logic to load, compile, and optionally evaluate the configuration and policies. diff --git a/pkg/mintycfg/config.go b/pkg/mintycfg/config.go index 7a4e79c..37da3b0 100644 --- a/pkg/mintycfg/config.go +++ b/pkg/mintycfg/config.go @@ -23,15 +23,16 @@ import ( // Config defines the set of environment variables required // for running the minty configuration validator. type Config struct { - MintyFile string - Scope string - Token string + MintyFile string + Scope string + Token string + PolicyPath string } // Validate validates the artifacts config after load. func (cfg *Config) Validate() error { - if cfg.MintyFile == "" { - return fmt.Errorf("MINTY_FILE is required") + if cfg.MintyFile == "" && cfg.PolicyPath == "" { + return fmt.Errorf("either minty-file or policy must be specified") } return nil } @@ -61,5 +62,12 @@ func (cfg *Config) ToFlags(set *cli.FlagSet) *cli.FlagSet { Usage: `The token to test with.`, }) + f.StringVar(&cli.StringVar{ + Name: "policy", + Target: &cfg.PolicyPath, + EnvVar: "POLICY_PATH", + Usage: `The path to the policy file or directory to validate.`, + }) + return set } diff --git a/pkg/mintycfg/runner.go b/pkg/mintycfg/runner.go index 054a144..bfd694f 100644 --- a/pkg/mintycfg/runner.go +++ b/pkg/mintycfg/runner.go @@ -25,6 +25,7 @@ import ( "github.com/google/cel-go/cel" "github.com/abcxyz/github-token-minter/pkg/config" + "github.com/abcxyz/github-token-minter/pkg/policy" ) type singleFileConfigLoader struct { @@ -61,32 +62,92 @@ func Run(ctx context.Context, cfg *Config) error { if err != nil { return fmt.Errorf("failed to create CEL environment: %w", err) } - // load the requested configuration file and attempte to compile all CEL expressions - loader := config.NewCompilingConfigLoader(env, &singleFileConfigLoader{filePath: cfg.MintyFile}) - mintyConfig, err := loader.Load(ctx, "", "") - if err != nil { - fmt.Printf("\n\n-- Error --\n%v\n\n", err) - return fmt.Errorf("configuration failed to compile") + + var mintyConfig *config.Config + if cfg.MintyFile != "" { + loader := config.NewCompilingConfigLoader(env, &singleFileConfigLoader{filePath: cfg.MintyFile}) + var err error + mintyConfig, err = loader.Load(ctx, "", "") + if err != nil { + fmt.Printf("\n\n-- Error --\n%v\n\n", err) + return fmt.Errorf("configuration failed to compile") + } + + // render the configuration information + fmt.Printf("\n#################\n\n") + fmt.Printf("\nConfiguration and all rules compiled successfully\n") + fmt.Printf("Config:\n") + fmt.Printf("- Version: %s\n", mintyConfig.Version) + if mintyConfig.Rule != nil { + fmt.Printf("- Rule.If: %s\n", mintyConfig.Rule.If) + } else { + fmt.Printf("- Rule.If: \n") + } + fmt.Printf("- Scopes: \n") + for key, s := range mintyConfig.Scopes { + fmt.Printf(" - %s:\n", key) + if s.Rule != nil { + fmt.Printf(" - Rule.If: %s\n", s.Rule.If) + } else { + fmt.Printf(" - Rule.If: \n") + } + fmt.Printf(" - Repositories: %v\n", s.Repositories) + fmt.Printf(" - Permissions: %v\n", s.Permissions) + } + fmt.Printf("\n#################\n\n") + } + + var policyEval *policy.Evaluator + if cfg.PolicyPath != "" { + var err error + policyEval, err = policy.LoadPolicies(cfg.PolicyPath) + if err != nil { + return fmt.Errorf("policy loading failed: %w", err) + } + fmt.Printf("\nPolicy loaded successfully\n\n") } - // render the configuration information - fmt.Printf("\n#################\n\n") - fmt.Printf("\nConfiguration and all rules compiled successfully\n") - fmt.Printf("Config:\n") - fmt.Printf("- Version: %s\n", mintyConfig.Version) - fmt.Printf("- Rule.If: %s\n", mintyConfig.Rule.If) - fmt.Printf("- Scopes: \n") - for key, s := range mintyConfig.Scopes { - fmt.Printf(" - %s:\n", key) - fmt.Printf(" - Rule.If: %s\n", s.Rule.If) - fmt.Printf(" - Repositories: %v\n", s.Repositories) - fmt.Printf(" - Permissions: %v\n", s.Permissions) + if mintyConfig != nil && policyEval != nil { + input := map[string]any{ + "config": mintyConfig, + "source": "local", + "repo": "test-repo", + "org": "test-org", + } + if cfg.Token != "" { + var token map[string]any + if err := json.Unmarshal([]byte(cfg.Token), &token); err != nil { + return fmt.Errorf("error unmarshalling token content: %w", err) + } + input["token"] = token + + // Get org/repo from token if available + if org, ok := token["repository_owner"].(string); ok { + input["org"] = org + } + if repo, ok := token["repository"].(string); ok { + input["repo"] = repo + } + } + + denies, err := policyEval.Evaluate(ctx, input) + if err != nil { + return fmt.Errorf("policy evaluation failed: %w", err) + } + if len(denies) > 0 { + fmt.Printf("\nPolicy violations found:\n") + for _, d := range denies { + fmt.Printf("- %s\n", d) + } + fmt.Printf("\nPolicy validation failed\n\n") + } else { + fmt.Printf("\nPolicy validation passed\n\n") + } } - fmt.Printf("\n#################\n\n") // if a scope and a token were provided, run evaluation against them to // determine if there is a scope match and then output the scope contents - if cfg.Scope != "" && cfg.Token != "" { + if cfg.Scope != "" && cfg.Token != "" && mintyConfig != nil { var token map[string]any if err := json.Unmarshal([]byte(cfg.Token), &token); err != nil { return fmt.Errorf("error unmarshalling token content: %w", err) diff --git a/policy/fail_safe.rego b/policy/fail_safe.rego index de31bbe..f8ae590 100644 --- a/policy/fail_safe.rego +++ b/policy/fail_safe.rego @@ -6,23 +6,23 @@ allowed_enterprise_id := "YOUR_ENTERPRISE_ID" allowed_org_id := "YOUR_ORG_ID" allowed_repo_id := "YOUR_REPO_ID" -# Deny if enterprise ID is present and does not match +# Deny if enterprise ID does not match deny contains msg if { - ent_id := input.token.enterprise_id + ent_id := object.get(input.token, "enterprise_id", "MISSING") ent_id != allowed_enterprise_id msg := sprintf("invalid enterprise ID %q", [ent_id]) } # Deny if org ID does not match deny contains msg if { - org_id := input.token.repository_owner_id + org_id := object.get(input.token, "repository_owner_id", "MISSING") org_id != allowed_org_id msg := sprintf("invalid org ID %q", [org_id]) } # Deny if repo ID does not match deny contains msg if { - repo_id := input.token.repository_id + repo_id := object.get(input.token, "repository_id", "MISSING") repo_id != allowed_repo_id msg := sprintf("invalid repo ID %q", [repo_id]) } diff --git a/policy/read_only.rego b/policy/read_only.rego index 0c92d2f..c02dd96 100644 --- a/policy/read_only.rego +++ b/policy/read_only.rego @@ -7,3 +7,16 @@ deny contains msg if { perm_value != "read" msg := sprintf("scope %q requests non-read permission %q: %q", [scope_name, perm_name, perm_value]) } + +# Guard against empty or missing permissions which might default to "all" +deny contains msg if { + some scope_name, scope in input.config.scope + not scope.permissions + msg := sprintf("scope %q has no permissions defined", [scope_name]) +} + +deny contains msg if { + some scope_name, scope in input.config.scope + count(scope.permissions) == 0 + msg := sprintf("scope %q has empty permissions", [scope_name]) +}