diff --git a/apps/advisor/Makefile b/apps/advisor/Makefile index fdffcfd4fbf..010b283b65a 100644 --- a/apps/advisor/Makefile +++ b/apps/advisor/Makefile @@ -1,5 +1,9 @@ include ../sdk.mk +.PHONY: etcd +etcd: + @docker run -d --name etcd --env ALLOW_NONE_AUTHENTICATION=yes -p 22379:2379 bitnamilegacy/etcd:latest + .PHONY: generate # Run Grafana App SDK code generation generate: install-app-sdk update-app-sdk @$(APP_SDK_BIN) generate \ @@ -7,3 +11,7 @@ generate: install-app-sdk update-app-sdk --gogenpath=./pkg/apis \ --grouping=group \ --defencoding=none + +.PHONY: run +run: + @go run ./pkg/standalone/server.go --etcd-servers=http://127.0.0.1:22379 --secure-port 7445 diff --git a/apps/advisor/README.md b/apps/advisor/README.md index c7929d9db62..aa48ef8e4e4 100644 --- a/apps/advisor/README.md +++ b/apps/advisor/README.md @@ -152,3 +152,14 @@ Check [`security_config_step.go`](./pkg/app/checks/configchecks/security_config_ ## Testing Create tests for your check and its steps to ensure they work as expected. Test both successful and failure scenarios. + +## Running the Standalone Mode + +To run the standalone mode, you can use the `make run` command. This will start the advisor app in standalone mode, which means it will not be running in a Kubernetes cluster. + +```bash +make etcd # Start etcd in a docker container +make run # Start the advisor app in standalone mode +``` + +This will start the advisor app on port 7445. You can then access the advisor app at `http://localhost:7445`. diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 1bfc3c9671a..4b7e830796b 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -15,6 +15,8 @@ require ( github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.1 k8s.io/apiserver v0.34.1 + k8s.io/client-go v0.34.1 + k8s.io/component-base v0.34.1 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) @@ -43,6 +45,7 @@ replace github.com/grafana/grafana/apps/plugins => ../plugins replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 require ( + cel.dev/expr v0.24.0 // indirect cloud.google.com/go/compute/metadata v0.7.0 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect @@ -55,6 +58,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect @@ -85,6 +89,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect + github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -101,6 +106,7 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gchaincl/sqlhooks v1.3.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect @@ -143,6 +149,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.7.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.26.1 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -162,6 +169,7 @@ require ( github.com/grafana/sqlds/v4 v4.2.7 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect @@ -176,6 +184,7 @@ require ( github.com/hashicorp/memberlist v0.5.2 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jaegertracing/jaeger-idl v0.5.0 // indirect github.com/jessevdk/go-flags v1.6.1 // indirect github.com/jmespath-community/go-jmespath v1.1.1 // indirect @@ -250,7 +259,9 @@ require ( github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tetratelabs/wazero v1.8.2 // indirect github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect @@ -262,6 +273,9 @@ require ( github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect + go.etcd.io/etcd/api/v3 v3.6.4 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect + go.etcd.io/etcd/client/v3 v3.6.4 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect @@ -280,6 +294,8 @@ require ( go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/mock v0.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.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.43.0 // indirect @@ -302,24 +318,26 @@ require ( google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/mail.v2 v2.3.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect gopkg.in/telebot.v3 v3.3.8 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.1 // indirect k8s.io/apiextensions-apiserver v0.34.1 // indirect - k8s.io/client-go v0.34.1 // indirect - k8s.io/component-base v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kms v0.34.1 // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.39.1 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 8594bd52e94..657be70c25b 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -335,6 +335,7 @@ github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -463,6 +464,8 @@ 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/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA= github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw= github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs= @@ -668,6 +671,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae h1:NLPwY3tIP0lg0g9wTRiMcypm6VRXW6W+MOLBsq8JSVA= github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae/go.mod h1:VGjS5gDwWEADPP6pF/drqLxEImgeuHlEW5u8E5EfIrM= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= @@ -811,6 +816,8 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= @@ -1048,6 +1055,7 @@ github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSg github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng= github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= @@ -1065,6 +1073,7 @@ github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6T github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 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.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= @@ -1079,6 +1088,7 @@ github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57J github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= @@ -1135,6 +1145,8 @@ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PX github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -1148,6 +1160,7 @@ github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= @@ -1173,6 +1186,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -1190,6 +1204,8 @@ github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+Xq github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tjhop/slog-gokit v0.1.3 h1:6SdexP3UIeg93KLFeiM1Wp1caRwdTLgsD/THxBUy1+o= github.com/tjhop/slog-gokit v0.1.3/go.mod h1:Bbu5v2748qpAWH7k6gse/kw3076IJf6owJmh7yArmJs= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= @@ -1217,6 +1233,8 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1241,6 +1259,12 @@ go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+ go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= go.etcd.io/etcd/client/v3 v3.6.4 h1:YOMrCfMhRzY8NgtzUsHl8hC2EBSnuqbR3dh84Uryl7A= go.etcd.io/etcd/client/v3 v3.6.4/go.mod h1:jaNNHCyg2FdALyKWnd7hxZXZxZANb0+KGY+YQaEMISo= +go.etcd.io/etcd/pkg/v3 v3.6.4 h1:fy8bmXIec1Q35/jRZ0KOes8vuFxbvdN0aAFqmEfJZWA= +go.etcd.io/etcd/pkg/v3 v3.6.4/go.mod h1:kKcYWP8gHuBRcteyv6MXWSN0+bVMnfgqiHueIZnKMtE= +go.etcd.io/etcd/server/v3 v3.6.4 h1:LsCA7CzjVt+8WGrdsnh6RhC0XqCsLkBly3ve5rTxMAU= +go.etcd.io/etcd/server/v3 v3.6.4/go.mod h1:aYCL/h43yiONOv0QIR82kH/2xZ7m+IWYjzRmyQfnCAg= +go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= @@ -1393,6 +1417,7 @@ golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1805,6 +1830,7 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= diff --git a/apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go b/apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go new file mode 100644 index 00000000000..b751b606cd8 --- /dev/null +++ b/apps/advisor/pkg/app/checkregistry/mockchecks/checkregistry.go @@ -0,0 +1,12 @@ +package mockchecks + +import "github.com/grafana/grafana/apps/advisor/pkg/app/checks" + +// mockchecks.CheckRegistry is a mock implementation of the checkregistry.CheckService interface +// TODO: Add mocked checks here +type CheckRegistry struct { +} + +func (m *CheckRegistry) Checks() []checks.Check { + return []checks.Check{} +} diff --git a/apps/advisor/pkg/standalone/server.go b/apps/advisor/pkg/standalone/server.go new file mode 100644 index 00000000000..6e2ad32cc8b --- /dev/null +++ b/apps/advisor/pkg/standalone/server.go @@ -0,0 +1,58 @@ +package main + +import ( + "log/slog" + "os" + + "k8s.io/apiserver/pkg/admission" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/client-go/rest" + "k8s.io/component-base/cli" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/k8s/apiserver" + "github.com/grafana/grafana-app-sdk/k8s/apiserver/cmd/server" + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana-app-sdk/simple" + "github.com/grafana/grafana/apps/advisor/pkg/apis" + advisorapp "github.com/grafana/grafana/apps/advisor/pkg/app" + "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" + "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry/mockchecks" +) + +func main() { + logging.DefaultLogger = logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + provider := simple.NewAppProvider(apis.LocalManifest(), nil, advisorapp.New) + config := app.Config{ + KubeConfig: rest.Config{}, // this will be replaced by the apiserver loopback config + ManifestData: *apis.LocalManifest().ManifestData, + SpecificConfig: checkregistry.AdvisorAppConfig{ + CheckRegistry: &mockchecks.CheckRegistry{}, + PluginConfig: map[string]string{}, + StackID: "1", // Numeric stack ID for standalone mode + OrgService: nil, // Not needed when StackID is set + }, + } + installer, err := apiserver.NewDefaultAppInstaller(provider, config, &apis.GoTypeAssociator{}) + if err != nil { + panic(err) + } + ctx := genericapiserver.SetupSignalContext() + opts := apiserver.NewOptions([]apiserver.AppInstaller{installer}) + opts.RecommendedOptions.Authentication = nil + opts.RecommendedOptions.Authorization = nil + opts.RecommendedOptions.CoreAPI = nil + opts.RecommendedOptions.EgressSelector = nil + opts.RecommendedOptions.Admission.Plugins = admission.NewPlugins() + opts.RecommendedOptions.Admission.RecommendedPluginOrder = []string{} + opts.RecommendedOptions.Admission.EnablePlugins = []string{} + opts.RecommendedOptions.Features.EnablePriorityAndFairness = false + opts.RecommendedOptions.ExtraAdmissionInitializers = func(_ *genericapiserver.RecommendedConfig) ([]admission.PluginInitializer, error) { + return nil, nil + } + cmd := server.NewCommandStartServer(ctx, opts) + code := cli.Run(cmd) + os.Exit(code) +} diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a3292e1a102..18367de0c3f 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3328,23 +3328,6 @@ "count": 2 } }, - "public/app/features/query/components/QueryEditorRow.tsx": { - "@grafana/no-aria-label-selectors": { - "count": 1 - }, - "@typescript-eslint/consistent-type-assertions": { - "count": 3 - }, - "no-restricted-syntax": { - "count": 1 - }, - "react-hooks/rules-of-hooks": { - "count": 1 - }, - "react-prefer-function-component/react-prefer-function-component": { - "count": 1 - } - }, "public/app/features/query/components/QueryEditorRowHeader.tsx": { "@grafana/no-aria-label-selectors": { "count": 1 diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 147f9802c71..752fd949b35 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -226,6 +226,14 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge return err } + // Only teamBindingStore exposes the AfterCreate, AfterDelete, and BeginUpdate hooks + if enableZanzanaSync { + b.logger.Info("Enabling hooks for TeamBinding to sync to Zanzana") + teamBindingStore.AfterCreate = b.AfterTeamBindingCreate + teamBindingStore.AfterDelete = b.AfterTeamBindingDelete + teamBindingStore.BeginUpdate = b.BeginTeamBindingUpdate + } + storage[teamBindingResource.StoragePath()] = teamBindingDW } @@ -240,6 +248,13 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge return err } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for User to sync basic role assignments to Zanzana") + store.AfterCreate = b.AfterUserCreate + store.BeginUpdate = b.BeginUserUpdate + store.AfterDelete = b.AfterUserDelete + } + dw, err := opts.DualWriteBuilder(userResource.GroupResource(), legacyStore, store) if err != nil { return err diff --git a/pkg/registry/apis/iam/team_binding_hooks.go b/pkg/registry/apis/iam/team_binding_hooks.go new file mode 100644 index 00000000000..c43c3a91ab1 --- /dev/null +++ b/pkg/registry/apis/iam/team_binding_hooks.go @@ -0,0 +1,348 @@ +package iam + +import ( + "context" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic/registry" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" +) + +// convertTeamBindingToTuple converts a TeamBinding to a v1 TupleKey format +// TeamBinding represents a user's membership in a team with a specific permission level +func convertTeamBindingToTuple(tb *iamv0.TeamBinding) (*v1.TupleKey, error) { + if tb.Spec.Subject.Name == "" { + return nil, errEmptyName + } + + if tb.Spec.TeamRef.Name == "" { + return nil, errEmptyName + } + + // Map permission to relation + var relation string + switch tb.Spec.Permission { + case iamv0.TeamBindingTeamPermissionAdmin: + relation = zanzana.RelationTeamAdmin + case iamv0.TeamBindingTeamPermissionMember: + relation = zanzana.RelationTeamMember + default: + // Default to member if unknown permission + relation = zanzana.RelationTeamMember + } + + // Create tuple: user:{subjectUID} has {relation} relation to team:{teamUID} + tuple := &v1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, tb.Spec.Subject.Name, ""), + Relation: relation, + Object: zanzana.NewTupleEntry(zanzana.TypeTeam, tb.Spec.TeamRef.Name, ""), + } + + return tuple, nil +} + +// AfterTeamBindingCreate is a post-create hook that writes the team binding to Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) { + if b.zClient == nil { + return + } + + tb, ok := obj.(*iamv0.TeamBinding) + if !ok { + b.logger.Error("failed to convert object to TeamBinding type", "object", obj) + return + } + + resourceType := "teambinding" + operation := "create" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(tb *iamv0.TeamBinding) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() + }() + + tuple, err := convertTeamBindingToTuple(tb) + if err != nil { + b.logger.Error("failed to convert team binding to tuple", + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + "err", err, + ) + status = "failure" + return + } + + b.logger.Debug("writing team binding to zanzana", + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + "permission", tb.Spec.Permission, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err = b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: tb.Namespace, + Writes: &v1.WriteRequestWrites{ + TupleKeys: []*v1.TupleKey{tuple}, + }, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to write team binding to zanzana", + "err", err, + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + ) + } else { + // Record successful tuple write + hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Inc() + } + }(tb.DeepCopy()) // Pass a copy of the object +} + +// BeginTeamBindingUpdate is a pre-update hook that prepares zanzana updates +// It converts old and new team bindings to tuples and performs the zanzana write after K8s update succeeds +func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { + if b.zClient == nil { + return nil, nil + } + + // Extract team bindings from both old and new objects + oldTB, ok := oldObj.(*iamv0.TeamBinding) + if !ok { + return nil, nil + } + + newTB, ok := obj.(*iamv0.TeamBinding) + if !ok { + return nil, nil + } + + // Convert old team binding to tuple for deletion + var oldTuple *v1.TupleKey + var oldErr error + if oldTB.Spec.Subject.Name != "" && oldTB.Spec.TeamRef.Name != "" { + oldTuple, oldErr = convertTeamBindingToTuple(oldTB) + if oldErr != nil { + b.logger.Error("failed to convert old team binding to tuple", + "namespace", oldTB.Namespace, + "name", oldTB.Name, + "err", oldErr, + ) + } + } + + // Convert new team binding to tuple for writing + var newTuple *v1.TupleKey + var newErr error + if newTB.Spec.Subject.Name != "" && newTB.Spec.TeamRef.Name != "" { + newTuple, newErr = convertTeamBindingToTuple(newTB) + if newErr != nil { + b.logger.Error("failed to convert new team binding to tuple", + "namespace", newTB.Namespace, + "name", newTB.Name, + "err", newErr, + ) + } + } + + // Return a finish function that performs the zanzana write only on success + return func(ctx context.Context, success bool) { + if !success { + // Update failed, don't write to zanzana + return + } + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues("teambinding", "update").Observe(time.Since(wait).Seconds()) + + go func() { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues("teambinding", "update", status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues("teambinding", "update", status).Inc() + }() + + b.logger.Debug("updating team binding in zanzana", + "namespace", newTB.Namespace, + "name", newTB.Name, + "oldSubject", oldTB.Spec.Subject.Name, + "newSubject", newTB.Spec.Subject.Name, + "oldTeamRef", oldTB.Spec.TeamRef.Name, + "newTeamRef", newTB.Spec.TeamRef.Name, + "oldPermission", oldTB.Spec.Permission, + "newPermission", newTB.Spec.Permission, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + // Prepare write request + req := &v1.WriteRequest{ + Namespace: newTB.Namespace, + } + + // Add delete for old tuple + if oldTuple != nil && oldErr == nil { + deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{oldTuple}) + req.Deletes = &v1.WriteRequestDeletes{ + TupleKeys: deleteTuple, + } + b.logger.Debug("deleting existing team binding from zanzana", + "namespace", newTB.Namespace, + "subject", oldTB.Spec.Subject.Name, + "teamRef", oldTB.Spec.TeamRef.Name, + ) + } + + // Add write for new tuple + if newTuple != nil && newErr == nil { + req.Writes = &v1.WriteRequestWrites{ + TupleKeys: []*v1.TupleKey{newTuple}, + } + b.logger.Debug("writing new team binding to zanzana", + "namespace", newTB.Namespace, + "subject", newTB.Spec.Subject.Name, + "teamRef", newTB.Spec.TeamRef.Name, + ) + } + + // Only make the request if there are deletes or writes + if (req.Deletes != nil && len(req.Deletes.TupleKeys) > 0) || (req.Writes != nil && len(req.Writes.TupleKeys) > 0) { + err := b.zClient.Write(ctx, req) + if err != nil { + status = "failure" + b.logger.Error("failed to update team binding in zanzana", + "err", err, + "namespace", newTB.Namespace, + "name", newTB.Name, + ) + } else { + // Record successful tuple operations + if oldTuple != nil && oldErr == nil { + hooksTuplesCounter.WithLabelValues("teambinding", "update", "delete").Inc() + } + if newTuple != nil && newErr == nil { + hooksTuplesCounter.WithLabelValues("teambinding", "update", "write").Inc() + } + } + } else { + b.logger.Debug("no tuples to update in zanzana", "namespace", newTB.Namespace, "name", newTB.Name) + } + }() + }, nil +} + +// AfterTeamBindingDelete is a post-delete hook that removes the team binding from Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + if b.zClient == nil { + return + } + + tb, ok := obj.(*iamv0.TeamBinding) + if !ok { + b.logger.Error("failed to convert object to TeamBinding type", "object", obj) + return + } + + resourceType := "teambinding" + operation := "delete" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(tb *iamv0.TeamBinding) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() + }() + + tuple, err := convertTeamBindingToTuple(tb) + if err != nil { + b.logger.Error("failed to convert team binding to tuple for deletion", + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + "err", err, + ) + status = "failure" + return + } + + // Convert tuple to TupleKeyWithoutCondition for deletion + deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{tuple}) + + b.logger.Debug("deleting team binding from zanzana", + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + "permission", tb.Spec.Permission, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err = b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: tb.Namespace, + Deletes: &v1.WriteRequestDeletes{ + TupleKeys: deleteTuple, + }, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to delete team binding from zanzana", + "err", err, + "namespace", tb.Namespace, + "name", tb.Name, + "subject", tb.Spec.Subject.Name, + "teamRef", tb.Spec.TeamRef.Name, + ) + } else { + // Record successful tuple deletion + hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Inc() + } + }(tb.DeepCopy()) // Pass a copy of the object +} diff --git a/pkg/registry/apis/iam/team_binding_hooks_test.go b/pkg/registry/apis/iam/team_binding_hooks_test.go new file mode 100644 index 00000000000..869060bcacb --- /dev/null +++ b/pkg/registry/apis/iam/team_binding_hooks_test.go @@ -0,0 +1,749 @@ +package iam + +import ( + "context" + "sync" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/stretchr/testify/require" +) + +func TestAfterTeamBindingCreate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should create zanzana entry for team binding with member permission", func(t *testing.T) { + wg.Add(1) + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + External: false, + }, + } + + testMemberBinding := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal(t, "org-1", req.Namespace) + require.Nil(t, req.Deletes) + + expectedTuple := &v1.TupleKey{ + User: "user:user-1", + Relation: "member", + Object: "team:team-1", + } + + actualTuple := req.Writes.TupleKeys[0] + require.Equal(t, expectedTuple.User, actualTuple.User) + require.Equal(t, expectedTuple.Relation, actualTuple.Relation) + require.Equal(t, expectedTuple.Object, actualTuple.Object) + require.Nil(t, actualTuple.Condition) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testMemberBinding} + b.AfterTeamBindingCreate(&teamBinding, nil) + wg.Wait() + }) + + t.Run("should create zanzana entry for team binding with admin permission", func(t *testing.T) { + wg.Add(1) + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-2", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + External: true, + }, + } + + testAdminBinding := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal(t, "org-2", req.Namespace) + require.Nil(t, req.Deletes) + + expectedTuple := &v1.TupleKey{ + User: "user:user-2", + Relation: "admin", + Object: "team:team-2", + } + + actualTuple := req.Writes.TupleKeys[0] + require.Equal(t, expectedTuple.User, actualTuple.User) + require.Equal(t, expectedTuple.Relation, actualTuple.Relation) + require.Equal(t, expectedTuple.Object, actualTuple.Object) + require.Nil(t, actualTuple.Condition) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testAdminBinding} + b.AfterTeamBindingCreate(&teamBinding, nil) + wg.Wait() + }) + + t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-3", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-3", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + // Should not panic or error when zClient is nil + builder.AfterTeamBindingCreate(&teamBinding, nil) + }) + + t.Run("should handle conversion error gracefully", func(t *testing.T) { + // TeamBinding with empty subject name should fail conversion + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-4", + Namespace: "org-4", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "", // Empty name should cause error + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-4", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + writeCalled := false + testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error { + writeCalled = true + // Should not be called due to conversion error + require.Fail(t, "Write should not be called when conversion fails") + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling} + b.AfterTeamBindingCreate(&teamBinding, nil) + // Wait a bit to ensure the goroutine has time to process + // The goroutine will complete but won't call the write callback + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when conversion fails") + }) +} + +func TestBeginTeamBindingUpdate(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should update zanzana entry when permission changes from member to admin", func(t *testing.T) { + wg.Add(1) + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + }, + } + + testPermissionUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Should delete old member permission + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, + ) + + // Should write new admin permission + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal( + t, + req.Writes.TupleKeys[0], + &v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-1"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testPermissionUpdate} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should update zanzana entry when user changes", func(t *testing.T) { + wg.Add(1) + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + testUserUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-2", req.Namespace) + + // Should delete old user binding + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, + ) + + // Should write new user binding + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal( + t, + req.Writes.TupleKeys[0], + &v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testUserUpdate} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should update zanzana entry when team changes", func(t *testing.T) { + wg.Add(1) + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-2", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + }, + } + + testTeamUpdate := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-3", req.Namespace) + + // Should delete old team binding + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "admin", Object: "team:team-1"}, + ) + + // Should write new team binding + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal( + t, + req.Writes.TupleKeys[0], + &v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-2"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testTeamUpdate} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should not write to zanzana when update fails", func(t *testing.T) { + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-4", + Namespace: "org-4", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-4", + Namespace: "org-4", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + testNoWriteOnFailure := func(ctx context.Context, req *v1.WriteRequest) error { + // Should not be called when success=false + require.Fail(t, "Write should not be called when update fails") + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnFailure} + + finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call finish function with success=false + finishFunc(context.Background(), false) + // No wait needed since write should not be called + }) + + t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + oldBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-5", + Namespace: "org-5", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + newBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-5", + Namespace: "org-5", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + finishFunc, err := builder.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when zClient is nil + }) +} + +func TestAfterTeamBindingDelete(t *testing.T) { + var wg sync.WaitGroup + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should delete zanzana entry for team binding with member permission", func(t *testing.T) { + wg.Add(1) + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-1", + Namespace: "org-1", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + External: false, + }, + } + + testMemberDelete := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Should have deletes but no writes + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Nil(t, req.Writes) + + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testMemberDelete} + b.AfterTeamBindingDelete(&teamBinding, nil) + wg.Wait() + }) + + t.Run("should delete zanzana entry for team binding with admin permission", func(t *testing.T) { + wg.Add(1) + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-2", + Namespace: "org-2", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-2", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + External: true, + }, + } + + testAdminDelete := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-2", req.Namespace) + + // Should have deletes but no writes + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Nil(t, req.Writes) + + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:user-2", Relation: "admin", Object: "team:team-2"}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testAdminDelete} + b.AfterTeamBindingDelete(&teamBinding, nil) + wg.Wait() + }) + + t.Run("should not delete from zanzana when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-3", + Namespace: "org-3", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-3", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-3", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + // Should not panic or error when zClient is nil + builder.AfterTeamBindingDelete(&teamBinding, nil) + }) + + t.Run("should handle conversion error gracefully", func(t *testing.T) { + // TeamBinding with empty team ref name should fail conversion + teamBinding := iamv0.TeamBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding-4", + Namespace: "org-4", + }, + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-4", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "", // Empty name should cause error + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + writeCalled := false + testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error { + writeCalled = true + // Should not be called due to conversion error + require.Fail(t, "Write should not be called when conversion fails") + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling} + b.AfterTeamBindingDelete(&teamBinding, nil) + // Wait a bit to ensure the goroutine has time to process + // The goroutine will complete but won't call the write callback + time.Sleep(100 * time.Millisecond) + require.False(t, writeCalled, "Write callback should not be called when conversion fails") + }) +} + +func TestConvertTeamBindingToTuple(t *testing.T) { + t.Run("should convert member permission correctly", func(t *testing.T) { + tb := &iamv0.TeamBinding{ + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + tuple, err := convertTeamBindingToTuple(tb) + require.NoError(t, err) + require.NotNil(t, tuple) + require.Equal(t, "user:user-1", tuple.User) + require.Equal(t, "member", tuple.Relation) + require.Equal(t, "team:team-1", tuple.Object) + require.Nil(t, tuple.Condition) + }) + + t.Run("should convert admin permission correctly", func(t *testing.T) { + tb := &iamv0.TeamBinding{ + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-2", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-2", + }, + Permission: iamv0.TeamBindingTeamPermissionAdmin, + }, + } + + tuple, err := convertTeamBindingToTuple(tb) + require.NoError(t, err) + require.NotNil(t, tuple) + require.Equal(t, "user:user-2", tuple.User) + require.Equal(t, "admin", tuple.Relation) + require.Equal(t, "team:team-2", tuple.Object) + require.Nil(t, tuple.Condition) + }) + + t.Run("should return error for empty subject name", func(t *testing.T) { + tb := &iamv0.TeamBinding{ + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + tuple, err := convertTeamBindingToTuple(tb) + require.Error(t, err) + require.Nil(t, tuple) + require.Equal(t, errEmptyName, err) + }) + + t.Run("should return error for empty team ref name", func(t *testing.T) { + tb := &iamv0.TeamBinding{ + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "", + }, + Permission: iamv0.TeamBindingTeamPermissionMember, + }, + } + + tuple, err := convertTeamBindingToTuple(tb) + require.Error(t, err) + require.Nil(t, tuple) + require.Equal(t, errEmptyName, err) + }) + + t.Run("should default to member for unknown permission", func(t *testing.T) { + tb := &iamv0.TeamBinding{ + Spec: iamv0.TeamBindingSpec{ + Subject: iamv0.TeamBindingspecSubject{ + Name: "user-1", + }, + TeamRef: iamv0.TeamBindingTeamRef{ + Name: "team-1", + }, + Permission: "unknown", // Invalid permission + }, + } + + tuple, err := convertTeamBindingToTuple(tb) + require.NoError(t, err) + require.NotNil(t, tuple) + // Should default to member relation + require.Equal(t, "member", tuple.Relation) + }) +} diff --git a/pkg/registry/apis/iam/user_org_hooks.go b/pkg/registry/apis/iam/user_org_hooks.go new file mode 100644 index 00000000000..380d98ceac2 --- /dev/null +++ b/pkg/registry/apis/iam/user_org_hooks.go @@ -0,0 +1,303 @@ +package iam + +import ( + "context" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic/registry" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" +) + +// createUserBasicRoleTuple creates a tuple for a user's basic role assignment +func createUserBasicRoleTuple(userUID, orgRole string) *v1.TupleKey { + if orgRole == "" { + return nil + } + + basicRole := zanzana.TranslateBasicRole(orgRole) + if basicRole == "" { + return nil + } + + return &v1.TupleKey{ + User: zanzana.NewTupleEntry(zanzana.TypeUser, userUID, ""), + Relation: zanzana.RelationAssignee, + Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""), + } +} + +// AfterUserCreate is a post-create hook that writes the user's basic role assignment to Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterUserCreate(obj runtime.Object, _ *metav1.CreateOptions) { + if b.zClient == nil { + return + } + + user, ok := obj.(*iamv0.User) + if !ok { + b.logger.Error("failed to convert object to User type", "object", obj) + return + } + + resourceType := "user" + operation := "create" + + // Skip if user has no role assigned + if user.Spec.Role == "" { + b.logger.Debug("user has no role assigned, skipping basic role sync", + "namespace", user.Namespace, + "userUID", user.Name, + ) + return + } + + // Grab a ticket to write to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(u *iamv0.User) { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() + }() + + tuple := createUserBasicRoleTuple(u.Name, u.Spec.Role) + if tuple == nil { + b.logger.Warn("failed to create user basic role tuple", + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + status = "failure" + return + } + + b.logger.Debug("writing user basic role to zanzana", + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err := b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: u.Namespace, + Writes: &v1.WriteRequestWrites{ + TupleKeys: []*v1.TupleKey{tuple}, + }, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to write user basic role to zanzana", + "err", err, + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + } else { + hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Inc() + } + }(user.DeepCopy()) +} + +// BeginUserUpdate is a pre-update hook that gets called on user updates +// It compares old and new roles and performs the zanzana write after K8s update succeeds +func (b *IdentityAccessManagementAPIBuilder) BeginUserUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { + if b.zClient == nil { + return nil, nil + } + + oldUser, ok := oldObj.(*iamv0.User) + if !ok { + return nil, nil + } + + newUser, ok := obj.(*iamv0.User) + if !ok { + return nil, nil + } + + // If role hasn't changed, no need to update + if oldUser.Spec.Role == newUser.Spec.Role { + return nil, nil + } + + // Return a finish function that performs the zanzana write only on success + return func(ctx context.Context, success bool) { + if !success { + return + } + + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues("user", "update").Observe(time.Since(wait).Seconds()) + + go func(old, new *iamv0.User) { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + hooksDurationHistogram.WithLabelValues("user", "update", status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues("user", "update", status).Inc() + }() + + b.logger.Debug("updating user basic role in zanzana", + "namespace", new.Namespace, + "userUID", new.Name, + "oldRole", old.Spec.Role, + "newRole", new.Spec.Role, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + req := &v1.WriteRequest{ + Namespace: new.Namespace, + } + + // Delete old role tuple if it existed + if old.Spec.Role != "" { + oldTuple := createUserBasicRoleTuple(old.Name, old.Spec.Role) + if oldTuple != nil { + deleteTuple := tupleToTupleKeyWithoutCondition(oldTuple) + req.Deletes = &v1.WriteRequestDeletes{ + TupleKeys: []*v1.TupleKeyWithoutCondition{deleteTuple}, + } + b.logger.Debug("deleting old user basic role from zanzana", + "namespace", new.Namespace, + "userUID", new.Name, + "role", old.Spec.Role, + ) + } + } + + // Write new role tuple if it exists + if new.Spec.Role != "" { + newTuple := createUserBasicRoleTuple(new.Name, new.Spec.Role) + if newTuple != nil { + req.Writes = &v1.WriteRequestWrites{ + TupleKeys: []*v1.TupleKey{newTuple}, + } + b.logger.Debug("writing new user basic role to zanzana", + "namespace", new.Namespace, + "userUID", new.Name, + "role", new.Spec.Role, + ) + } + } + + // Only make the request if there are deletes or writes + if (req.Deletes != nil && len(req.Deletes.TupleKeys) > 0) || (req.Writes != nil && len(req.Writes.TupleKeys) > 0) { + err := b.zClient.Write(ctx, req) + if err != nil { + status = "failure" + b.logger.Error("failed to update user basic role in zanzana", + "err", err, + "namespace", new.Namespace, + "userUID", new.Name, + ) + } else { + if req.Deletes != nil && len(req.Deletes.TupleKeys) > 0 { + hooksTuplesCounter.WithLabelValues("user", "update", "delete").Inc() + } + if req.Writes != nil && len(req.Writes.TupleKeys) > 0 { + hooksTuplesCounter.WithLabelValues("user", "update", "write").Inc() + } + } + } else { + b.logger.Debug("no tuples to update in zanzana", "namespace", new.Namespace) + } + }(oldUser.DeepCopy(), newUser.DeepCopy()) + }, nil +} + +// AfterUserDelete is a post-delete hook that removes the user's basic role assignment from Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterUserDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + if b.zClient == nil { + return + } + + user, ok := obj.(*iamv0.User) + if !ok { + b.logger.Error("failed to convert object to User type", "object", obj) + return + } + + resourceType := "user" + operation := "delete" + + // Skip if user had no role assigned + if user.Spec.Role == "" { + b.logger.Debug("user had no role assigned, skipping basic role sync", + "namespace", user.Namespace, + "userUID", user.Name, + ) + return + } + + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) + + go func(u *iamv0.User) { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() + }() + + tuple := createUserBasicRoleTuple(u.Name, u.Spec.Role) + if tuple == nil { + b.logger.Warn("failed to create user basic role tuple for deletion", + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + status = "failure" + return + } + + deleteTuple := tupleToTupleKeyWithoutCondition(tuple) + + b.logger.Debug("deleting user basic role from zanzana", + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err := b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: u.Namespace, + Deletes: &v1.WriteRequestDeletes{ + TupleKeys: []*v1.TupleKeyWithoutCondition{deleteTuple}, + }, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to delete user basic role from zanzana", + "err", err, + "namespace", u.Namespace, + "userUID", u.Name, + "role", u.Spec.Role, + ) + } else { + hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Inc() + } + }(user.DeepCopy()) +} diff --git a/pkg/registry/apis/iam/user_org_hooks_test.go b/pkg/registry/apis/iam/user_org_hooks_test.go new file mode 100644 index 00000000000..516b66e380f --- /dev/null +++ b/pkg/registry/apis/iam/user_org_hooks_test.go @@ -0,0 +1,609 @@ +package iam + +import ( + "context" + "sync" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/stretchr/testify/require" +) + +func TestAfterUserCreate(t *testing.T) { + var wg sync.WaitGroup + + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should create zanzana entry for user with Admin role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "df2p421det1q8c", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + testAdminRole := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal(t, "org-1", req.Namespace) + + tuple := req.Writes.TupleKeys[0] + require.Equal(t, "user:df2p421det1q8c", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_admin", tuple.Object) + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testAdminRole} + b.AfterUserCreate(&user, nil) + wg.Wait() + }) + + t.Run("should create zanzana entry for user with Editor role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user123", + Namespace: "org-2", + }, + Spec: iamv0.UserSpec{ + Role: "Editor", + }, + } + + testEditorRole := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal(t, "org-2", req.Namespace) + + tuple := req.Writes.TupleKeys[0] + require.Equal(t, "user:user123", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_editor", tuple.Object) + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testEditorRole} + b.AfterUserCreate(&user, nil) + wg.Wait() + }) + + t.Run("should create zanzana entry for user with Viewer role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "viewer456", + Namespace: "org-3", + }, + Spec: iamv0.UserSpec{ + Role: "Viewer", + }, + } + + testViewerRole := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + require.Equal(t, "org-3", req.Namespace) + + tuple := req.Writes.TupleKeys[0] + require.Equal(t, "user:viewer456", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_viewer", tuple.Object) + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testViewerRole} + b.AfterUserCreate(&user, nil) + wg.Wait() + }) + + t.Run("should skip when user has no role", func(t *testing.T) { + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "norole789", + Namespace: "org-4", + }, + Spec: iamv0.UserSpec{ + Role: "", + }, + } + + // Should not call zanzana client + b.zClient = nil + b.AfterUserCreate(&user, nil) + // If we get here without panic, the test passes + }) + + t.Run("should skip when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + // Should return early without calling zanzana + builder.AfterUserCreate(&user, nil) + // If we get here without panic, the test passes + }) +} + +func TestBeginUserUpdate(t *testing.T) { + var wg sync.WaitGroup + + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should update zanzana entry when role changes from Viewer to Admin", func(t *testing.T) { + wg.Add(1) + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Viewer", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + testRoleChange := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Should delete old role + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + deleteTuple := req.Deletes.TupleKeys[0] + require.Equal(t, "user:testuser", deleteTuple.User) + require.Equal(t, "assignee", deleteTuple.Relation) + require.Equal(t, "role:basic_viewer", deleteTuple.Object) + + // Should write new role + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + writeTuple := req.Writes.TupleKeys[0] + require.Equal(t, "user:testuser", writeTuple.User) + require.Equal(t, "assignee", writeTuple.Relation) + require.Equal(t, "role:basic_admin", writeTuple.Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testRoleChange} + + finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should delete old role when new role is empty", func(t *testing.T) { + wg.Add(1) + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser2", + Namespace: "org-2", + }, + Spec: iamv0.UserSpec{ + Role: "Editor", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser2", + Namespace: "org-2", + }, + Spec: iamv0.UserSpec{ + Role: "", + }, + } + + testRemoveRole := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-2", req.Namespace) + + // Should delete old role + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + deleteTuple := req.Deletes.TupleKeys[0] + require.Equal(t, "user:testuser2", deleteTuple.User) + require.Equal(t, "assignee", deleteTuple.Relation) + require.Equal(t, "role:basic_editor", deleteTuple.Object) + + // Should not write new role + require.Nil(t, req.Writes) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testRemoveRole} + + finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should be able to add a new role when old role was empty", func(t *testing.T) { + wg.Add(1) + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser3", + Namespace: "org-3", + }, + Spec: iamv0.UserSpec{ + Role: "", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser3", + Namespace: "org-3", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + testAddRole := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-3", req.Namespace) + + // Should not delete old role (was empty) + require.Nil(t, req.Deletes) + + // Should write new role + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + writeTuple := req.Writes.TupleKeys[0] + require.Equal(t, "user:testuser3", writeTuple.User) + require.Equal(t, "assignee", writeTuple.Relation) + require.Equal(t, "role:basic_admin", writeTuple.Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testAddRole} + + finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + finishFunc(context.Background(), true) + wg.Wait() + }) + + t.Run("should skip update when role hasn't changed", func(t *testing.T) { + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser4", + Namespace: "org-4", + }, + Spec: iamv0.UserSpec{ + Role: "Editor", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser4", + Namespace: "org-4", + }, + Spec: iamv0.UserSpec{ + Role: "Editor", + }, + } + + finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when no update needed + }) + + t.Run("should not call zanzana when update fails", func(t *testing.T) { + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser5", + Namespace: "org-5", + }, + Spec: iamv0.UserSpec{ + Role: "Viewer", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser5", + Namespace: "org-5", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + callCount := 0 + testNoCall := func(ctx context.Context, req *v1.WriteRequest) error { + callCount++ + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testNoCall} + + finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call with success=false - should not trigger zanzana write + finishFunc(context.Background(), false) + require.Equal(t, 0, callCount, "zanzana should not be called when update fails") + }) + + t.Run("should skip when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + oldUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Viewer", + }, + } + + newUser := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + finishFunc, err := builder.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil) + require.NoError(t, err) + require.Nil(t, finishFunc) // Should return nil when zClient is nil + }) +} + +func TestAfterUserDelete(t *testing.T) { + var wg sync.WaitGroup + + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should delete zanzana entry for user with Admin role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "df2p421det1q8c", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + testDeleteAdmin := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-1", req.Namespace) + + // Should have deletes but no writes + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Nil(t, req.Writes) + + deleteTuple := req.Deletes.TupleKeys[0] + require.Equal(t, "user:df2p421det1q8c", deleteTuple.User) + require.Equal(t, "assignee", deleteTuple.Relation) + require.Equal(t, "role:basic_admin", deleteTuple.Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testDeleteAdmin} + b.AfterUserDelete(&user, nil) + wg.Wait() + }) + + t.Run("should delete zanzana entry for user with Editor role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "editor123", + Namespace: "org-2", + }, + Spec: iamv0.UserSpec{ + Role: "Editor", + }, + } + + testDeleteEditor := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-2", req.Namespace) + + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + deleteTuple := req.Deletes.TupleKeys[0] + require.Equal(t, "user:editor123", deleteTuple.User) + require.Equal(t, "assignee", deleteTuple.Relation) + require.Equal(t, "role:basic_editor", deleteTuple.Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testDeleteEditor} + b.AfterUserDelete(&user, nil) + wg.Wait() + }) + + t.Run("should delete zanzana entry for user with Viewer role", func(t *testing.T) { + wg.Add(1) + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "viewer456", + Namespace: "org-3", + }, + Spec: iamv0.UserSpec{ + Role: "Viewer", + }, + } + + testDeleteViewer := func(ctx context.Context, req *v1.WriteRequest) error { + defer wg.Done() + require.NotNil(t, req) + require.Equal(t, "org-3", req.Namespace) + + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + deleteTuple := req.Deletes.TupleKeys[0] + require.Equal(t, "user:viewer456", deleteTuple.User) + require.Equal(t, "assignee", deleteTuple.Relation) + require.Equal(t, "role:basic_viewer", deleteTuple.Object) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testDeleteViewer} + b.AfterUserDelete(&user, nil) + wg.Wait() + }) + + t.Run("should skip when user has no role", func(t *testing.T) { + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "norole789", + Namespace: "org-4", + }, + Spec: iamv0.UserSpec{ + Role: "", + }, + } + + // Should not call zanzana client + b.zClient = nil + b.AfterUserDelete(&user, nil) + // If we get here without panic, the test passes + }) + + t.Run("should skip when zClient is nil", func(t *testing.T) { + builder := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + zClient: nil, + } + + user := iamv0.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testuser", + Namespace: "org-1", + }, + Spec: iamv0.UserSpec{ + Role: "Admin", + }, + } + + // Should return early without calling zanzana + builder.AfterUserDelete(&user, nil) + // If we get here without panic, the test passes + }) +} + +func TestCreateUserBasicRoleTuple(t *testing.T) { + t.Run("should create tuple for Admin role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user123", "Admin") + require.NotNil(t, tuple) + require.Equal(t, "user:user123", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_admin", tuple.Object) + }) + + t.Run("should create tuple for Editor role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user456", "Editor") + require.NotNil(t, tuple) + require.Equal(t, "user:user456", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_editor", tuple.Object) + }) + + t.Run("should create tuple for Viewer role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user789", "Viewer") + require.NotNil(t, tuple) + require.Equal(t, "user:user789", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_viewer", tuple.Object) + }) + + t.Run("should create tuple for None role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user000", "None") + require.NotNil(t, tuple) + require.Equal(t, "user:user000", tuple.User) + require.Equal(t, "assignee", tuple.Relation) + require.Equal(t, "role:basic_none", tuple.Object) + }) + + t.Run("should return nil for empty role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user123", "") + require.Nil(t, tuple) + }) + + t.Run("should return nil for invalid role", func(t *testing.T) { + tuple := createUserBasicRoleTuple("user123", "InvalidRole") + require.Nil(t, tuple) + }) +} diff --git a/pkg/services/caching/service.go b/pkg/services/caching/service.go index ad494bcaee4..c5bc577b122 100644 --- a/pkg/services/caching/service.go +++ b/pkg/services/caching/service.go @@ -84,7 +84,7 @@ func (s *OSSCachingService) HandleResourceRequest(ctx context.Context, req *back var _ CachingService = &OSSCachingService{} // GetKey creates a prefixed cache key and uses the internal `encoder` to encode the query into a string -func GetKey(prefix string, query interface{}) (string, error) { +func GetKey(namespace, prefix string, query interface{}) (string, error) { keybuf := bytes.NewBuffer(nil) encoder := &JSONEncoder{} @@ -98,6 +98,12 @@ func GetKey(prefix string, query interface{}) (string, error) { return "", err } + // The namespace is empty only when this function is used by the legacy caching module. + // This case can be removed when the legacy caching module is not being used anymore. + if namespace != "" { + return strings.Join([]string{namespace, prefix, key}, ":"), nil + } + return strings.Join([]string{prefix, key}, ":"), nil } diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 90d557abf0d..d1ac3f59cd4 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -1460,6 +1460,148 @@ func TestIntegrationRuleGroupsCaseSensitive(t *testing.T) { }) } +// To address issues arising from case-insensitive collations in some databases (e.g., MySQL/MariaDB), +func TestIntegrationListAlertRulesByGroupCaseSensitiveOrdering(t *testing.T) { + tutil.SkipIntegrationTestInShortMode(t) + + usr := models.UserUID("test") + + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + cfg.UnifiedAlerting.BaseInterval = 1 * time.Second + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + b := &fakeBus{} + logger := log.New("test-dbstore") + store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) + store.FeatureToggles = featuremgmt.WithFeatures() + + gen := models.RuleGen.With(models.RuleMuts.WithOrgID(1)) + + // Create namespace and base group key + groupKey := models.GenerateGroupKey(1) + + // Create groups with case-sensitive names: "TEST", "Test", "test" + groupKeyUpper := groupKey + groupKeyUpper.RuleGroup = "TEST" + + groupKeyMixed := groupKey + groupKeyMixed.RuleGroup = "Test" + + groupKeyLower := groupKey + groupKeyLower.RuleGroup = "test" + + // Generate rules for each group + groupUpper := gen.With(gen.WithGroupKey(groupKeyUpper)).GenerateMany(2) + groupMixed := gen.With(gen.WithGroupKey(groupKeyMixed)).GenerateMany(2) + groupLower := gen.With(gen.WithGroupKey(groupKeyLower)).GenerateMany(2) + + // Insert all rules + allRules := append(append(groupUpper, groupMixed...), groupLower...) + _, err := store.InsertAlertRules(context.Background(), &usr, allRules) + require.NoError(t, err) + + t.Run("should order groups case-sensitively", func(t *testing.T) { + result, _, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: 1}, + }) + require.NoError(t, err) + require.Len(t, result, 6, "should return all 6 rules") + + // Extract group names in order + var groupOrder []string + for _, rule := range result { + if len(groupOrder) == 0 || groupOrder[len(groupOrder)-1] != rule.RuleGroup { + groupOrder = append(groupOrder, rule.RuleGroup) + } + } + + // Verify case-sensitive alphabetical ordering + // different databases may sort uppercase before lowercase or vice versa depending on character set, the important part is that the order is consistent and case-sensitive + expectedOrder := []string{"test", "Test", "TEST"} + alternateExpectedOrder := []string{"TEST", "Test", "test"} + if !slices.Equal(groupOrder, expectedOrder) && !slices.Equal(groupOrder, alternateExpectedOrder) { + t.Fatalf("groups are not ordered case-sensitively as expected. got: %v, want: %v or %v", groupOrder, expectedOrder, alternateExpectedOrder) + } + + // Verify each group contains the correct rules + groupRules := make(map[string][]*models.AlertRule) + for _, rule := range result { + groupRules[rule.RuleGroup] = append(groupRules[rule.RuleGroup], rule) + } + + require.Len(t, groupRules["TEST"], 2, "TEST group should have 2 rules") + require.Len(t, groupRules["Test"], 2, "Test group should have 2 rules") + require.Len(t, groupRules["test"], 2, "test group should have 2 rules") + }) + + t.Run("should respect group limit with case-sensitive ordering", func(t *testing.T) { + // Test with limit of 2 groups - should get first 2 groups in case-sensitive order + result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: 1}, + Limit: 2, + }) + require.NoError(t, err) + require.Len(t, result, 4, "should return 4 rules (2 rules from first 2 groups)") + require.NotEmpty(t, continueToken, "should have continue token when limit is reached") + + // Extract group names from limited result + var limitedGroupOrder []string + for _, rule := range result { + if len(limitedGroupOrder) == 0 || limitedGroupOrder[len(limitedGroupOrder)-1] != rule.RuleGroup { + limitedGroupOrder = append(limitedGroupOrder, rule.RuleGroup) + } + } + + // Should get first 2 groups in case-sensitive order: "TEST", "Test" or "test", "Test" + expectedLimitedOrder := []string{"TEST", "Test"} + alternateExpectedOrder := []string{"test", "Test"} + matchesDescLexOrder := slices.Equal(limitedGroupOrder, expectedLimitedOrder) + matchesAscLexOrder := slices.Equal(limitedGroupOrder, alternateExpectedOrder) + if !matchesDescLexOrder && !matchesAscLexOrder { + t.Fatalf("limited groups are not ordered case-sensitively as expected. got: %v, want: %v or %v", limitedGroupOrder, expectedLimitedOrder, alternateExpectedOrder) + } + + // Continue from token to get remaining groups + remainingResult, nextToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: 1}, + ContinueToken: continueToken, + }) + require.NoError(t, err) + require.Len(t, remainingResult, 2, "should return 2 rules from remaining group") + require.Empty(t, nextToken, "should not have continue token when all groups are fetched") + + lastGroup := "test" + if matchesAscLexOrder { + lastGroup = "TEST" + } + + // Verify the remaining group is "test" + for _, rule := range remainingResult { + require.Equal(t, lastGroup, rule.RuleGroup, "remaining group should be 'test'") + } + }) + + t.Run("should handle group limit of 1 correctly", func(t *testing.T) { + result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: 1}, + Limit: 1, + }) + require.NoError(t, err) + require.Len(t, result, 2, "should return 2 rules from first group") + require.NotEmpty(t, continueToken, "should have continue token") + + // Should only get the first group which can be "TEST" or "test" depending on charset + expectedGroup := "TEST" + if result[0].RuleGroup == "test" { + expectedGroup = "test" + } + + for _, rule := range result { + require.Equal(t, expectedGroup, rule.RuleGroup, "all rules should be from the first group") + } + }) +} + func TestIntegrationIncreaseVersionForAllRulesInNamespaces(t *testing.T) { tutil.SkipIntegrationTestInShortMode(t) diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 04bcbc98f31..1762d98a075 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -156,4 +156,8 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) { ualert.DropTitleUniqueIndexMigration(mg) ualert.AddStateFiredAtColumn(mg) + + ualert.CollateAlertRuleGroup(mg) + + ualert.AddAlertRuleGroupIndexMigration(mg) } diff --git a/pkg/services/sqlstore/migrations/ualert/alert_rule_group_collation.go b/pkg/services/sqlstore/migrations/ualert/alert_rule_group_collation.go new file mode 100644 index 00000000000..5d7f7e3598a --- /dev/null +++ b/pkg/services/sqlstore/migrations/ualert/alert_rule_group_collation.go @@ -0,0 +1,9 @@ +package ualert + +import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +// CollateAlertRuleGroup ensures that rule_group column collates. +func CollateAlertRuleGroup(mg *migrator.Migrator) { + mg.AddMigration("ensure rule_group column is case sensitive in returned results", migrator.NewRawSQLMigration(""). + Mysql("ALTER TABLE alert_rule MODIFY rule_group VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL;")) +} diff --git a/pkg/services/sqlstore/migrations/ualert/alert_rule_group_index.go b/pkg/services/sqlstore/migrations/ualert/alert_rule_group_index.go new file mode 100644 index 00000000000..e7df19a324d --- /dev/null +++ b/pkg/services/sqlstore/migrations/ualert/alert_rule_group_index.go @@ -0,0 +1,14 @@ +package ualert + +import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +// AddAlertRuleGroupIndexMigration adds an index on org_id, namespace_uid, rule_group, and rule_group_idx columns to alert_rule table. +func AddAlertRuleGroupIndexMigration(mg *migrator.Migrator) { + mg.AddMigration("add index in alert_rule on org_id, namespace_uid, rule_group and rule_group_idx columns", migrator.NewAddIndexMigration( + migrator.Table{Name: "alert_rule"}, + &migrator.Index{ + Name: "IDX_alert_rule_org_id_namespace_uid_rule_group_rule_group_idx", + Cols: []string{"org_id", "namespace_uid", "rule_group", "rule_group_idx"}, + }, + )) +} diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index 767b0a934e2..0e23d53e6fa 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -222,6 +222,10 @@ function getStyles(theme: GrafanaTheme2) { flexDirection: 'column-reverse', alignItems: 'stretch', }, + '&:hover .dashboard-canvas-add-button': { + opacity: 1, + filter: 'unset', + }, }), controlsPanelEdit: css({ flexWrap: 'wrap-reverse', diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index f28ad7d174d..470b20fea23 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -27,7 +27,11 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { .map((variable) => ( ))} - {config.featureToggles.dashboardNewLayouts ? : null} + {config.featureToggles.dashboardNewLayouts ? ( +
+ +
+ ) : null} ); } diff --git a/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx b/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx index 84e4f456578..9d20b740030 100644 --- a/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControlsAddButton.tsx @@ -26,7 +26,7 @@ export function AddVariableButton({ dashboard }: { dashboard: DashboardScene }) return ( ); } diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 79bf7b30ee2..5612ce37e0b 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -85,6 +85,7 @@ interface State { showingHelp: boolean; } +// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component export class QueryEditorRow extends PureComponent, State> { dataSourceSrv = getDataSourceSrv(); id = ''; @@ -135,6 +136,7 @@ export class QueryEditorRow extends PureComponent, queriedDataSourceIdentifier: interpolatedUID, }); @@ -230,6 +232,17 @@ export class QueryEditorRow extends PureComponent { const { onRemoveQuery, query, onQueryRemoved } = this.props; + + // Track expression query removal + const isExpressionQuery = query.datasource?.uid === ExpressionDatasourceUID; + if (isExpressionQuery && 'type' in query && query.type) { + reportInteraction('dashboards_expression_interaction', { + action: 'remove_expression', + expression_type: query.type, + context: 'panel_query_section', + }); + } + onRemoveQuery(query); if (onQueryRemoved) { @@ -365,6 +378,7 @@ export class QueryEditorRow extends PureComponent void, dataSource, key: index, @@ -483,6 +497,7 @@ export class QueryEditorRow extends PureComponent e.refId === query.refId); const rowClasses = classNames('query-editor-row', { 'query-editor-row--disabled': isHidden, + // eslint-disable-next-line no-restricted-syntax 'gf-form-disabled': isHidden, }); @@ -524,6 +539,7 @@ export class QueryEditorRow extends PureComponent {queryLibraryRef && ( ({ extensionPointId: PluginExtensionPoints.QueryEditorRowAdaptiveTelemetryV1, }); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 50ddc6b897b..dc7eeab17c4 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6326,7 +6326,7 @@ "content-variable-referenced-other-variables-dashboard": "This variable is referenced by other variables or dashboard." }, "variable-controls": { - "add-variable": "Add" + "add-variable": "Add variable" }, "variable-editor-form": { "aria-label-variable-editor-form": "Variable editor form",