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) => (