Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de1c2ef28e | |||
| e51fb87cc2 | |||
| 7ba340f4a2 | |||
| 08f58b2346 | |||
| 4cda8669a5 | |||
| 14c45b6db2 | |||
| eeddc8cd18 | |||
| 99e4583cd1 | |||
| cbd6b53182 | |||
| 259c7807cb | |||
| d6fa822e89 | |||
| a89377337b | |||
| 3b99370aac |
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+20
-2
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -40,8 +40,7 @@ To set up file sync with local with local files, you need to:
|
||||
Local file provisioning using **Administration** > **Provisioning** will eventually replace the traditional methods Grafana has used for referencing local file systems for dashboard files.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
For production system, we recommend using the `folderFromFilesStructure` capability instead of **Administration** > **Provisioning** to include dashboards from a local file system in your Grafana instance.
|
||||
Refer to [Provision Grafana](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#provision-folders-structure-from-filesystem-to-grafana) for more information.
|
||||
For production systems, use the `folderFromFilesStructure` capability instead of **Administration** > **Provisioning** to include dashboards from a local file system in your Grafana instance. Refer to [Provision Grafana](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#provision-folders-structure-from-filesystem-to-grafana) for more information.
|
||||
{{< /admonition >}}
|
||||
|
||||
### Limitations
|
||||
@@ -125,6 +124,18 @@ The set up process verifies the path and provides an error message if a problem
|
||||
|
||||
### Choose what to synchronize
|
||||
|
||||
#### Synchronization limitations
|
||||
|
||||
Full instance sync is not available in Grafana Cloud.
|
||||
|
||||
In Grafana OSS/Enterprise:
|
||||
|
||||
- If you try to perform a full instance sync with resources that contain alerts or panels, the connection will be blocked.
|
||||
- You won't be able to create new alerts or library panels after setup is completed.
|
||||
- If you opted for full instance sync and want to use alerts and library panels, you'll have to delete the provisioned repository and connect again with folder sync.
|
||||
|
||||
#### Set up synchronization
|
||||
|
||||
Choose to either sync your entire organization resources with external storage, or to sync certain resources to a new Grafana folder (with up to 10 connections).
|
||||
|
||||
- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. With this option, all of your dashboards are synced to that one repository. You can only have one provisioned connection with this selection, and you won't have the option of setting up additional repositories to connect to.
|
||||
|
||||
@@ -61,6 +61,8 @@ Refer to [Known limitations](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/
|
||||
|
||||
{{< /admonition >}}
|
||||
|
||||
### Requirements
|
||||
|
||||
To set up Git Sync, you need:
|
||||
|
||||
- Administration rights in your Grafana organization.
|
||||
@@ -128,34 +130,37 @@ To connect your GitHub repository, follow these steps:
|
||||
|
||||
### Choose what to synchronize
|
||||
|
||||
In this step you can decide which elements to synchronize. Keep in mind the available options depend on the status of your Grafana instance.
|
||||
|
||||
- If the instance contains resources in an incompatible data format, you'll have to migrate all the data using instance sync. Folder sync won't be supported.
|
||||
- If there is already another connection using folder sync, instance sync won't be offered.
|
||||
|
||||
#### Synchronization limitations
|
||||
|
||||
Git Sync only supports dashboards and folders. Alerts, panels, and other resources are not supported yet.
|
||||
|
||||
{{< admonition type="caution" >}}
|
||||
|
||||
Git Sync only works with specific folders for the moment. Full-instance sync is not currently supported. Refer to [Supported resources](/docs/grafana/<GRAFANA_VERSION>/observability-as-code/provision-resources/intro-git-sync#supported-resources) for more details about which resources you can sync.
|
||||
Refer to [Known limitations](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/observability-as-code/provision-resources/intro-git-sync#known-limitations/) before using Git Sync. Refer to [Supported resources](/docs/grafana/<GRAFANA_VERSION>/observability-as-code/provision-resources/intro-git-sync#supported-resources) for details about which resources you can sync.
|
||||
|
||||
{{< /admonition >}}
|
||||
|
||||
In this step you can decide which elements to synchronize. Keep in mind the available options depend on the status of your GitHub repository. The first time you connect Grafana with a GitHub repository, you need to synchronize with external storage. If you are syncing with a new or empty repository, you won't have an option to migrate dashboards.
|
||||
Full instance sync is not available in Grafana Cloud.
|
||||
|
||||
1. Choose to either sync your entire organization resources with external storage, or to sync certain resources to a new Grafana folder (with up to 10 connections).
|
||||
In Grafana OSS/Enterprise:
|
||||
|
||||
- If you try to perform a full instance sync with resources that contain alerts or panels, Git Sync will block the connection.
|
||||
- You won't be able to create new alerts or library panels after the setup is completed.
|
||||
- If you opted for full instance sync and want to use alerts and library panels, you'll have to delete the synced repository and connect again with folder sync.
|
||||
|
||||
#### Set up synchronization
|
||||
|
||||
To set up synchronization, choose to either sync your entire organization resources with external storage, or to sync certain resources to a new Grafana folder (with up to 10 connections).
|
||||
|
||||
- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. With this option, all of your dashboards are synced to that one repository. You can only have one provisioned connection with this selection, and you won't have the option of setting up additional repositories to connect to.
|
||||
|
||||
- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 connections.
|
||||
|
||||
1. Enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI.
|
||||
1. Click **Synchronize** to continue.
|
||||
|
||||
<!-- ### Synchronize with external storage
|
||||
|
||||
{{< admonition type="note">}}
|
||||
During the synchronization process, your dashboards will be temporarily unavailable.
|
||||
No data or configuration will be lost.
|
||||
However, no one will be able to create, edit, or delete resources during this process.
|
||||
In the last step, the resources will disappear and will reappear and be managed through external storage.
|
||||
{{< /admonition >}}
|
||||
|
||||
1. Select **History** to include commits for each historical value in the synchronized data.
|
||||
1. Select **Begin synchronization** to continue. -->
|
||||
Next, enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. Click **Synchronize** to continue.
|
||||
|
||||
### Choose additional settings
|
||||
|
||||
@@ -163,7 +168,6 @@ Finally, you can set up how often your configured storage is polled for updates.
|
||||
|
||||
1. For **Update instance interval (seconds)**, enter how often you want the instance to pull updates from GitHub. The default value is 60 seconds.
|
||||
1. Optional: Select **Read only** to ensure resources can't be modified in Grafana.
|
||||
<!-- No workflow option listed in the UI. 1. For **Workflows**, select the GitHub workflows that you want to allow to run in the repository. Both **Branch** and **Write** are selected by default. -->
|
||||
1. Optional: If you have the Grafana Image Renderer plugin configured, you can **Enable dashboards previews in pull requests**. If image rendering is not available, then you can't select this option. For more information, refer to the [Image Renderer service](https://github.com/grafana/grafana-image-renderer).
|
||||
1. Select **Finish** to proceed.
|
||||
|
||||
@@ -179,23 +183,11 @@ You can extend Git Sync by getting instant updates and pull requests using webho
|
||||
|
||||
### Set up webhooks for realtime notification and pull request integration
|
||||
|
||||
When connecting to a GitHub repository, Git Sync use webhooks to enable real-time updates from GitHub public repositories or enable the pull request integration.
|
||||
Without webhooks, the polling interval is set in the final configuration screen (default is 60 seconds).
|
||||
Your Grafana instance must be exposed to the public internet.
|
||||
You can do this via port forwarding and DNS, a tool such as `ngrok`, or any other method you prefer.
|
||||
When connecting to a GitHub repository, Git Sync uses webhooks to enable real-time updates from GitHub public repositories or enable pull request integrations. Without webhooks, the polling interval is set in the final configuration screen, and the default is 60 seconds. If you use local storage, then Git Sync only provides periodic pulling.
|
||||
|
||||
The permissions set in your GitHub access token provide the authorization for this communication.
|
||||
You can set up webhooks with whichever service or tooling you prefer. You can use Cloudflare Tunnels with a Cloudflare-managed domain, port-forwarding and DNS options, or a tool such as `ngrok`.
|
||||
|
||||
If you use local storage, then Git Sync only provides periodic pulling.
|
||||
|
||||
<!-- Grafana Cloud support not available yet
|
||||
{{< admonition type="note" >}}
|
||||
Webhooks are automatically available for Grafana Cloud users.
|
||||
{{< /admonition >}}
|
||||
-->
|
||||
|
||||
Set up webhooks with whichever service or tooling you prefer.
|
||||
For example, you can use Cloudflare Tunnels with a Cloudflare-managed domain, port-forwarding and DNS options, or a tool such as `ngrok`.
|
||||
To set up webhooks you need to expose your Grafana instance to the public Internet. You can do this via port forwarding and DNS, a tool such as `ngrok`, or any other method you prefer. The permissions set in your GitHub access token provide the authorization for this communication.
|
||||
|
||||
After you have the public URL, you can add it to your Grafana configuration file:
|
||||
|
||||
@@ -204,23 +196,22 @@ After you have the public URL, you can add it to your Grafana configuration file
|
||||
root_url = https://PUBLIC_DOMAIN.HERE
|
||||
```
|
||||
|
||||
You can check the configured webhooks in the **View** link for your GitHub repository from **Administration** > **Provisioning**.
|
||||
To check the configured webhooks, go to **Administration** > **Provisioning** and click the **View** link for your GitHub repository.
|
||||
|
||||
#### Necessary paths
|
||||
#### Expose necessary paths only
|
||||
|
||||
If your security setup does not permit publicly exposing the Grafana instance, you can either choose to allowlist the GitHub IP addresses, or expose only the necessary paths.
|
||||
If your security setup does not permit publicly exposing the Grafana instance, you can either choose to `allowlist` the GitHub IP addresses, or expose only the necessary paths.
|
||||
|
||||
The necessary paths required to be exposed are (RegExp):
|
||||
The necessary paths required to be exposed are, in RegExp:
|
||||
|
||||
- `/apis/provisioning\.grafana\.app/v0(alpha1)?/namespaces/[^/]+/repositories/[^/]+/(webhook|render/.*)$`
|
||||
<!-- TODO: Path for the blob storage for image rendering? @ryantxu would know this best. -->
|
||||
|
||||
### Set up image rendering for dashboard previews
|
||||
|
||||
By setting up image rendering, you can add visual previews of dashboard updates directly in pull requests.
|
||||
Image rendering also requires webhooks.
|
||||
Set up image rendering to add visual previews of dashboard updates directly in pull requests. Image rendering also requires webhooks.
|
||||
|
||||
You can enable this capability by installing the Grafana Image Renderer in your Grafana instance. For more information and installation instructions, refer to the [Image Renderer service](https://github.com/grafana/grafana-image-renderer).
|
||||
To enable this capability, install the Grafana Image Renderer in your Grafana instance. For more information and installation instructions, refer to the [Image Renderer service](https://github.com/grafana/grafana-image-renderer).
|
||||
|
||||
## Modify configurations after set up is complete
|
||||
|
||||
|
||||
@@ -63,14 +63,31 @@ With Git Sync, you can make changes to the files in the provisioned folder in Gi
|
||||
|
||||
## Known limitations
|
||||
|
||||
Git Sync is under development and the following limitations apply:
|
||||
{{< admonition type="caution" >}}
|
||||
|
||||
Refer to [Requirements](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/observability-as-code/provision-resources/git-sync-setup#requirements/) to learn what you need to use Git Sync.
|
||||
|
||||
{{< /admonition >}}
|
||||
|
||||
**Git Sync is under development and the following limitations apply.**
|
||||
|
||||
**Synced resources**
|
||||
|
||||
- You can only sync dashboards and folders. Refer to [Supported resources](#supported-resources) for more information.
|
||||
- If you're using Git Sync in Grafana OSS and Grafana Enterprise, some resources might be in an incompatible data format and can't be synced.
|
||||
- You can only authenticate in GitHub using your Personal Access Token token.
|
||||
- Support for native Git, Git app, and other providers, such as GitLab or Bitbucket, is on the roadmap.
|
||||
- If you're using Git Sync in Grafana OSS and Grafana Enterprise, some resources might be in an incompatible data format and won't be synced.
|
||||
- Full-instance sync is not available in Grafana Cloud and has limitations in Grafana OSS and Grafana Enterprise. Refer to [Choose what to synchronize](../git-sync-setup.md#choose-what-to-synchronize) for more details.
|
||||
- When migrating to full instance sync, during the synchronization process your resources will be temporarily unavailable. No one will be able to create, edit, or delete resources during this process.
|
||||
- If you want to manage existing resources with Git Sync, you need to save them as JSON files and commit them to the synced repository. Open a PR to import, copy, move, or save a dashboard.
|
||||
- Restoring resources from the UI is currently not possible. As an alternative, you can restore dashboards directly in your GitHub repository by raising a PR, and they will be updated in Grafana.
|
||||
|
||||
**Authentication**
|
||||
|
||||
- You can only authenticate in GitHub using your Personal Access Token token.
|
||||
|
||||
**Compatibility**
|
||||
|
||||
- Support for native Git, Git app, and other providers, such as GitLab or Bitbucket, is on the roadmap.
|
||||
|
||||
## Supported resources
|
||||
|
||||
Git Sync only supports dashboards and folders. Alerts, panels, and other resources are not supported yet.
|
||||
@@ -86,9 +103,9 @@ A resource can be:
|
||||
| **Supported** | The resource can be managed with Git Sync. | The resource is supported but has compatibility issues. It **cannot** be managed with Git Sync. |
|
||||
| **Unsupported** | The resource is **not** supported and **cannot** be managed with Git Sync. | Not applicable. |
|
||||
|
||||
### Instance states
|
||||
### Git Sync instance states
|
||||
|
||||
An instance can be in one of the following states:
|
||||
An instance can be in one of the following Git Sync states:
|
||||
|
||||
- **Unprovisioned**: None of the instance's resources are being managed by Git Sync.
|
||||
- **Partially provisioned**: Some of the resources are controlled by Git Sync.
|
||||
|
||||
@@ -74,6 +74,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `elasticsearchCrossClusterSearch` | Enables cross cluster search in the Elasticsearch data source | |
|
||||
| `lokiLabelNamesQueryApi` | Defaults to using the Loki `/labels` API instead of `/series` | Yes |
|
||||
| `improvedExternalSessionHandlingSAML` | Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly. | Yes |
|
||||
| `newLogsPanel` | Enables the new logs panel | Yes |
|
||||
| `alertingMigrationUI` | Enables the alerting migration UI, to migrate data source-managed rules to Grafana-managed rules | Yes |
|
||||
| `alertingImportYAMLUI` | Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules | Yes |
|
||||
| `unifiedNavbars` | Enables unified navbars | |
|
||||
|
||||
@@ -883,7 +883,8 @@ export interface FeatureToggles {
|
||||
*/
|
||||
fetchRulesUsingPost?: boolean;
|
||||
/**
|
||||
* Enables the new logs panel in Explore
|
||||
* Enables the new logs panel
|
||||
* @default true
|
||||
*/
|
||||
newLogsPanel?: boolean;
|
||||
/**
|
||||
|
||||
@@ -224,6 +224,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
|
||||
}
|
||||
|
||||
@@ -238,6 +246,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,8 @@ message MutateOperation {
|
||||
DeleteFolderOperation delete_folder = 2;
|
||||
CreatePermissionOperation create_permission = 3;
|
||||
DeletePermissionOperation delete_permission = 4;
|
||||
UpdateUserOrgRoleOperation update_user_org_role = 5;
|
||||
DeleteUserOrgRoleOperation delete_user_org_role = 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +63,20 @@ message DeletePermissionOperation {
|
||||
Permission permission = 2;
|
||||
}
|
||||
|
||||
message UpdateUserOrgRoleOperation {
|
||||
// User UID
|
||||
string user = 1;
|
||||
// Role name (e.g: "Admin", "Editor", "Viewer")
|
||||
string role = 2;
|
||||
}
|
||||
|
||||
message DeleteUserOrgRoleOperation {
|
||||
// User UID
|
||||
string user = 1;
|
||||
// Role name (e.g: "Admin", "Editor", "Viewer")
|
||||
string role = 2;
|
||||
}
|
||||
|
||||
message Resource {
|
||||
// group of the resource (e.g: "dashboard.grafana.app")
|
||||
string group = 1;
|
||||
|
||||
@@ -67,6 +67,13 @@ var resourceTranslations = map[string]resourceTranslation{
|
||||
"dashboards:write": newScopedMapping(RelationUpdate, dashboardGroup, dashboardResource, ""),
|
||||
"dashboards:create": newScopedMapping(RelationCreate, dashboardGroup, dashboardResource, ""),
|
||||
"dashboards:delete": newScopedMapping(RelationDelete, dashboardGroup, dashboardResource, ""),
|
||||
// Action sets
|
||||
"folders:view": newMapping(RelationSetView, ""),
|
||||
"folders:edit": newMapping(RelationSetEdit, ""),
|
||||
"folders:admin": newMapping(RelationSetAdmin, ""),
|
||||
"dashboards:view": newScopedMapping(RelationSetView, dashboardGroup, dashboardResource, ""),
|
||||
"dashboards:edit": newScopedMapping(RelationSetEdit, dashboardGroup, dashboardResource, ""),
|
||||
"dashboards:admin": newScopedMapping(RelationSetAdmin, dashboardGroup, dashboardResource, ""),
|
||||
},
|
||||
},
|
||||
KindDashboards: {
|
||||
@@ -78,6 +85,10 @@ var resourceTranslations = map[string]resourceTranslation{
|
||||
"dashboards:write": newMapping(RelationUpdate, ""),
|
||||
"dashboards:create": newMapping(RelationCreate, ""),
|
||||
"dashboards:delete": newMapping(RelationDelete, ""),
|
||||
// Action sets
|
||||
"dashboards:view": newMapping(RelationSetView, ""),
|
||||
"dashboards:edit": newMapping(RelationSetEdit, ""),
|
||||
"dashboards:admin": newMapping(RelationSetAdmin, ""),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -350,6 +350,14 @@ func NewTypedTuple(typ, subject, relation, name string) *openfgav1.TupleKey {
|
||||
}
|
||||
}
|
||||
|
||||
func NewTuple(subject, relation, object string) *openfgav1.TupleKey {
|
||||
return &openfgav1.TupleKey{
|
||||
User: subject,
|
||||
Relation: relation,
|
||||
Object: object,
|
||||
}
|
||||
}
|
||||
|
||||
func ToAuthzExtTupleKey(t *openfgav1.TupleKey) *authzextv1.TupleKey {
|
||||
tupleKey := &authzextv1.TupleKey{
|
||||
User: t.GetUser(),
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
type OperationGroup string
|
||||
|
||||
const (
|
||||
OperationGroupFolder OperationGroup = "folder"
|
||||
OperationGroupPermission OperationGroup = "permission"
|
||||
OperationGroupFolder OperationGroup = "folder"
|
||||
OperationGroupPermission OperationGroup = "permission"
|
||||
OperationGroupUserOrgRole OperationGroup = "user_org_role"
|
||||
)
|
||||
|
||||
func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
|
||||
@@ -58,6 +59,10 @@ func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
|
||||
if err := s.mutateResourcePermissions(ctx, storeInf, operations); err != nil {
|
||||
return nil, fmt.Errorf("failed to mutate resource permissions: %w", err)
|
||||
}
|
||||
case OperationGroupUserOrgRole:
|
||||
if err := s.mutateOrgRoles(ctx, storeInf, operations); err != nil {
|
||||
return nil, fmt.Errorf("failed to mutate org roles: %w", err)
|
||||
}
|
||||
default:
|
||||
s.logger.Warn("unsupported operation group", "operationGroup", operationGroup)
|
||||
}
|
||||
@@ -72,6 +77,8 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e
|
||||
return OperationGroupFolder, nil
|
||||
case *authzextv1.MutateOperation_CreatePermission, *authzextv1.MutateOperation_DeletePermission:
|
||||
return OperationGroupPermission, nil
|
||||
case *authzextv1.MutateOperation_UpdateUserOrgRole, *authzextv1.MutateOperation_DeleteUserOrgRole:
|
||||
return OperationGroupUserOrgRole, nil
|
||||
}
|
||||
return OperationGroup(""), errors.New("unsupported mutate operation type")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
zanzana "github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func (s *Server) mutateOrgRoles(ctx context.Context, store *storeInfo, operations []*authzextv1.MutateOperation) error {
|
||||
ctx, span := s.tracer.Start(ctx, "server.mutateOrgRoles")
|
||||
defer span.End()
|
||||
|
||||
writeTuples := make([]*openfgav1.TupleKey, 0)
|
||||
deleteTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
|
||||
|
||||
for _, operation := range operations {
|
||||
switch op := operation.Operation.(type) {
|
||||
case *authzextv1.MutateOperation_UpdateUserOrgRole:
|
||||
tuple, err := s.getUserOrgRoleWriteTuple(ctx, store, op.UpdateUserOrgRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeTuples = append(writeTuples, tuple)
|
||||
case *authzextv1.MutateOperation_DeleteUserOrgRole:
|
||||
tuple, err := s.getUserOrgRoleDeleteTuple(ctx, store, op.DeleteUserOrgRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteTuples = append(deleteTuples, tuple)
|
||||
default:
|
||||
s.logger.Debug("unsupported mutate operation", "operation", op)
|
||||
}
|
||||
}
|
||||
|
||||
if len(writeTuples) == 0 && len(deleteTuples) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
writeReq := &openfgav1.WriteRequest{
|
||||
StoreId: store.ID,
|
||||
AuthorizationModelId: store.ModelID,
|
||||
}
|
||||
if len(writeTuples) > 0 {
|
||||
writeReq.Writes = &openfgav1.WriteRequestWrites{
|
||||
TupleKeys: writeTuples,
|
||||
OnDuplicate: "ignore",
|
||||
}
|
||||
}
|
||||
if len(deleteTuples) > 0 {
|
||||
writeReq.Deletes = &openfgav1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuples,
|
||||
OnMissing: "ignore",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.openfga.Write(ctx, writeReq)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to write user org role tuples", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getUserOrgRoleWriteTuple(ctx context.Context, store *storeInfo, req *authzextv1.UpdateUserOrgRoleOperation) (*openfgav1.TupleKey, error) {
|
||||
basicRole := zanzana.TranslateBasicRole(req.GetRole())
|
||||
return &openfgav1.TupleKey{
|
||||
User: zanzana.NewTupleEntry(zanzana.TypeUser, req.GetUser(), ""),
|
||||
Relation: zanzana.RelationAssignee,
|
||||
Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) getUserOrgRoleDeleteTuple(ctx context.Context, store *storeInfo, req *authzextv1.DeleteUserOrgRoleOperation) (*openfgav1.TupleKeyWithoutCondition, error) {
|
||||
basicRole := zanzana.TranslateBasicRole(req.GetRole())
|
||||
return &openfgav1.TupleKeyWithoutCondition{
|
||||
User: zanzana.NewTupleEntry(zanzana.TypeUser, req.GetUser(), ""),
|
||||
Relation: zanzana.RelationAssignee,
|
||||
Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||
)
|
||||
|
||||
func setupMutateOrgRoles(t *testing.T, srv *Server) *Server {
|
||||
t.Helper()
|
||||
|
||||
// seed tuples
|
||||
tuples := []*openfgav1.TupleKey{
|
||||
common.NewTuple("user:1", common.RelationAssignee, "role:basic_editor"),
|
||||
}
|
||||
|
||||
return setupOpenFGADatabase(t, srv, tuples)
|
||||
}
|
||||
|
||||
func testMutateOrgRoles(t *testing.T, srv *Server) {
|
||||
setupMutateOrgRoles(t, srv)
|
||||
|
||||
t.Run("should update user org role and delete old role", func(t *testing.T) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
Operation: &v1.MutateOperation_UpdateUserOrgRole{
|
||||
UpdateUserOrgRole: &v1.UpdateUserOrgRoleOperation{
|
||||
User: "1",
|
||||
Role: "Admin",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: &v1.MutateOperation_DeleteUserOrgRole{
|
||||
DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{
|
||||
User: "1",
|
||||
Role: "Editor",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationAssignee,
|
||||
Object: "role:basic_admin",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 1)
|
||||
require.Equal(t, "user:1", res.Tuples[0].Key.User)
|
||||
|
||||
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
|
||||
Namespace: "default",
|
||||
TupleKey: &v1.ReadRequestTupleKey{
|
||||
Relation: common.RelationAssignee,
|
||||
Object: "role:basic_editor",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 0)
|
||||
})
|
||||
}
|
||||
@@ -128,6 +128,10 @@ func TestIntegrationServer(t *testing.T) {
|
||||
t.Run("test mutate resource permissions", func(t *testing.T) {
|
||||
testMutateResourcePermissions(t, srv)
|
||||
})
|
||||
|
||||
t.Run("test mutate org roles", func(t *testing.T) {
|
||||
testMutateOrgRoles(t, srv)
|
||||
})
|
||||
}
|
||||
|
||||
func setupOpenFGAServer(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1516,10 +1516,11 @@ var (
|
||||
},
|
||||
{
|
||||
Name: "newLogsPanel",
|
||||
Description: "Enables the new logs panel in Explore",
|
||||
Stage: FeatureStageExperimental,
|
||||
Description: "Enables the new logs panel",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "grafanaconThemes",
|
||||
|
||||
@@ -198,7 +198,7 @@ grafanaAdvisor,privatePreview,@grafana/plugins-platform-backend,false,false,fals
|
||||
elasticsearchImprovedParsing,experimental,@grafana/aws-datasources,false,false,false
|
||||
datasourceConnectionsTab,privatePreview,@grafana/plugins-platform-backend,false,false,true
|
||||
fetchRulesUsingPost,experimental,@grafana/alerting-squad,false,false,false
|
||||
newLogsPanel,experimental,@grafana/observability-logs,false,false,true
|
||||
newLogsPanel,GA,@grafana/observability-logs,false,false,true
|
||||
grafanaconThemes,GA,@grafana/grafana-frontend-platform,false,true,false
|
||||
alertingJiraIntegration,experimental,@grafana/alerting-squad,false,false,true
|
||||
alertingUseNewSimplifiedRoutingHashAlgorithm,preview,@grafana/alerting-squad,false,true,false
|
||||
|
||||
|
@@ -804,7 +804,7 @@ const (
|
||||
FlagFetchRulesUsingPost = "fetchRulesUsingPost"
|
||||
|
||||
// FlagNewLogsPanel
|
||||
// Enables the new logs panel in Explore
|
||||
// Enables the new logs panel
|
||||
FlagNewLogsPanel = "newLogsPanel"
|
||||
|
||||
// FlagGrafanaconThemes
|
||||
|
||||
@@ -2775,14 +2775,18 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "newLogsPanel",
|
||||
"resourceVersion": "1753448760331",
|
||||
"creationTimestamp": "2025-02-04T17:40:17Z"
|
||||
"resourceVersion": "1762166984808",
|
||||
"creationTimestamp": "2025-02-04T17:40:17Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-11-03 10:49:44.808226 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the new logs panel in Explore",
|
||||
"stage": "experimental",
|
||||
"description": "Enables the new logs panel",
|
||||
"stage": "GA",
|
||||
"codeowner": "@grafana/observability-logs",
|
||||
"frontend": true
|
||||
"frontend": true,
|
||||
"expression": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,19 +2,52 @@ package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/alerting/receivers"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
)
|
||||
|
||||
const (
|
||||
// placeholderEmailAddress is the default placeholder email address used when no real email is configured
|
||||
placeholderEmailAddress = "<example@email.com>"
|
||||
)
|
||||
|
||||
var logger = log.New("ngalert.notifier.sender")
|
||||
|
||||
type emailSender struct {
|
||||
ns notifications.Service
|
||||
}
|
||||
|
||||
// isPlaceholderEmail checks if the given email address is a placeholder that should not be sent
|
||||
func isPlaceholderEmail(email string) bool {
|
||||
trimmed := strings.TrimSpace(email)
|
||||
return trimmed == placeholderEmailAddress
|
||||
}
|
||||
|
||||
func (s emailSender) SendEmail(ctx context.Context, cmd *receivers.SendEmailSettings) error {
|
||||
// Filter out placeholder addresses from the recipient list (single loop)
|
||||
validRecipients := make([]string, 0, len(cmd.To))
|
||||
for _, addr := range cmd.To {
|
||||
if !isPlaceholderEmail(addr) {
|
||||
validRecipients = append(validRecipients, addr)
|
||||
} else {
|
||||
logger.Warn("Filtering out placeholder email address from recipients", "address", addr)
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid recipients remain, skip sending
|
||||
if len(validRecipients) == 0 {
|
||||
if len(cmd.To) > 0 {
|
||||
logger.Info("Skipping email notification to placeholder address(es). Please configure a valid email address in your contact point to receive alerts.", "addresses", cmd.To)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
sendEmailCommand := notifications.SendEmailCommand{
|
||||
To: cmd.To,
|
||||
To: validRecipients,
|
||||
SingleEmail: cmd.SingleEmail,
|
||||
Template: cmd.Template,
|
||||
Subject: cmd.Subject,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/alerting/receivers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
func TestIsPlaceholderEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "placeholder with angle brackets",
|
||||
email: "<example@email.com>",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "not a placeholder - example@email.com without angle brackets",
|
||||
email: "example@email.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "placeholder with spaces",
|
||||
email: " <example@email.com> ",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid email",
|
||||
email: "user@example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "another valid email",
|
||||
email: "admin@grafana.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
email: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isPlaceholderEmail(tt.email)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockNotificationService struct {
|
||||
sendEmailCalled bool
|
||||
lastCommand *notifications.SendEmailCommandSync
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) SendEmailCommandHandlerSync(ctx context.Context, cmd *notifications.SendEmailCommandSync) error {
|
||||
m.sendEmailCalled = true
|
||||
m.lastCommand = cmd
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) SendEmailCommandHandler(ctx context.Context, cmd *notifications.SendEmailCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) SendWebhookSync(ctx context.Context, cmd *notifications.SendWebhookSync) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) SendResetPasswordEmail(ctx context.Context, cmd *notifications.SendResetPasswordEmailCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) ValidateResetPasswordCode(ctx context.Context, query *notifications.ValidateResetPasswordCodeQuery, userByLogin notifications.GetUserByLoginFunc) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationService) SendVerificationEmail(ctx context.Context, cmd *notifications.SendVerifyEmailCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEmailSender_SendEmail_PlaceholderHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
recipients []string
|
||||
expectSend bool
|
||||
expectedRecips []string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "all placeholder addresses - skip gracefully",
|
||||
recipients: []string{"<example@email.com>"},
|
||||
expectSend: false,
|
||||
expectedRecips: nil,
|
||||
description: "Should skip sending when all recipients are placeholders",
|
||||
},
|
||||
{
|
||||
name: "mixed valid and placeholder addresses",
|
||||
recipients: []string{"<example@email.com>", "user@example.com"},
|
||||
expectSend: true,
|
||||
expectedRecips: []string{"user@example.com"},
|
||||
description: "Should filter out placeholders and send to valid addresses",
|
||||
},
|
||||
{
|
||||
name: "all valid addresses",
|
||||
recipients: []string{"user@example.com", "admin@grafana.com"},
|
||||
expectSend: true,
|
||||
expectedRecips: []string{"user@example.com", "admin@grafana.com"},
|
||||
description: "Should send to all valid addresses",
|
||||
},
|
||||
{
|
||||
name: "placeholder with angle brackets filtered, others sent",
|
||||
recipients: []string{"example@email.com", "<example@email.com>", "valid@example.com"},
|
||||
expectSend: true,
|
||||
expectedRecips: []string{"example@email.com", "valid@example.com"},
|
||||
description: "Should filter out only placeholder with angle brackets and send to other addresses",
|
||||
},
|
||||
{
|
||||
name: "example@email.com without angle brackets is valid",
|
||||
recipients: []string{"example@email.com"},
|
||||
expectSend: true,
|
||||
expectedRecips: []string{"example@email.com"},
|
||||
description: "Should send to example@email.com when it doesn't have angle brackets",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockNS := &mockNotificationService{}
|
||||
sender := emailSender{ns: mockNS}
|
||||
|
||||
cmd := &receivers.SendEmailSettings{
|
||||
To: tt.recipients,
|
||||
Subject: "Test Subject",
|
||||
SingleEmail: true,
|
||||
}
|
||||
|
||||
err := sender.SendEmail(context.Background(), cmd)
|
||||
require.NoError(t, err, tt.description)
|
||||
|
||||
if tt.expectSend {
|
||||
assert.True(t, mockNS.sendEmailCalled, "Expected email to be sent but it was not")
|
||||
assert.Equal(t, tt.expectedRecips, mockNS.lastCommand.SendEmailCommand.To, "Recipients mismatch")
|
||||
} else {
|
||||
assert.False(t, mockNS.sendEmailCalled, "Expected email not to be sent but it was")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailSender_SendEmail_EmptyRecipients(t *testing.T) {
|
||||
mockNS := &mockNotificationService{}
|
||||
sender := emailSender{ns: mockNS}
|
||||
|
||||
cmd := &receivers.SendEmailSettings{
|
||||
To: []string{},
|
||||
Subject: "Test Subject",
|
||||
SingleEmail: true,
|
||||
}
|
||||
|
||||
err := sender.SendEmail(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, mockNS.sendEmailCalled, "Should not send email with empty recipient list")
|
||||
}
|
||||
|
||||
func TestEmailSender_SendEmail_EmbeddedContents(t *testing.T) {
|
||||
mockNS := &mockNotificationService{}
|
||||
sender := emailSender{ns: mockNS}
|
||||
|
||||
cmd := &receivers.SendEmailSettings{
|
||||
To: []string{"user@example.com"},
|
||||
Subject: "Test with embedded content",
|
||||
SingleEmail: true,
|
||||
EmbeddedContents: []receivers.EmbeddedContent{
|
||||
{Name: "image.png", Content: []byte("fake image data")},
|
||||
},
|
||||
}
|
||||
|
||||
err := sender.SendEmail(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mockNS.sendEmailCalled, "Email should be sent")
|
||||
assert.Len(t, mockNS.lastCommand.SendEmailCommand.EmbeddedContents, 1, "Embedded content should be passed through")
|
||||
assert.Equal(t, "image.png", mockNS.lastCommand.SendEmailCommand.EmbeddedContents[0].Name)
|
||||
}
|
||||
@@ -74,6 +74,11 @@ func (sc *SmtpClient) sendMessage(ctx context.Context, dialer *gomail.Dialer, ms
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
// Skip sending if there are no recipients
|
||||
if len(msg.To) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
m := sc.buildEmail(ctx, msg)
|
||||
|
||||
err := dialer.DialAndSend(m)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
GrafanaChannelValues,
|
||||
ReceiverFormValues,
|
||||
} from '../../../types/receiver-form';
|
||||
import { hasPlaceholderEmail } from '../../../utils/receiver-form';
|
||||
import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
|
||||
|
||||
import { ChannelOptions } from './ChannelOptions';
|
||||
@@ -70,6 +71,10 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
const onCallIntegrationType = watch(`${settingsFieldPath}.integration_type`);
|
||||
const isTestAvailable = onCallIntegrationType !== OnCallIntegrationType.NewIntegration;
|
||||
|
||||
// Check if email integration has placeholder addresses
|
||||
const channelValues = getValues(channelFieldPath) as GrafanaChannelValues | undefined;
|
||||
const isPlaceholderEmail = hasPlaceholderEmail(channelValues);
|
||||
|
||||
useEffect(() => {
|
||||
register(`${channelFieldPath}.__id`);
|
||||
/* Need to manually register secureFields or else they'll
|
||||
@@ -230,7 +235,22 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
{isTestable && onTest && isTestAvailable && (
|
||||
<Button size="xs" variant="secondary" type="button" onClick={() => handleTest()} icon="message">
|
||||
<Button
|
||||
disabled={isPlaceholderEmail}
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => handleTest()}
|
||||
icon="message"
|
||||
tooltip={
|
||||
isPlaceholderEmail
|
||||
? t(
|
||||
'alerting.channel-sub-form.test-disabled-placeholder',
|
||||
'Please configure a valid email address before testing'
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="alerting.channel-sub-form.test">Test</Trans>
|
||||
</Button>
|
||||
)}
|
||||
@@ -257,6 +277,20 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
</div>
|
||||
{notifier && (
|
||||
<div className={styles.innerContent}>
|
||||
{isPlaceholderEmail && (
|
||||
<Alert
|
||||
title={t(
|
||||
'alerting.contact-points.email.placeholder-warning-title',
|
||||
'Configure a valid email address'
|
||||
)}
|
||||
severity="info"
|
||||
>
|
||||
<Trans i18nKey="alerting.contact-points.email.placeholder-warning-body">
|
||||
This contact point is using a placeholder email address (<Text variant="code">example@email.com</Text>
|
||||
). Please update it with a valid email address to receive alerts and enable testing.
|
||||
</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
{showTelegramWarning && (
|
||||
<Alert
|
||||
title={t(
|
||||
|
||||
@@ -185,6 +185,7 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
||||
isOpen={!!testReceivers}
|
||||
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||
receivers={testReceivers}
|
||||
channelValues={testReceivers[0]?.grafana_managed_receiver_configs?.[0]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
+42
-4
@@ -4,13 +4,15 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Alert, Button, Label, Modal, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, Button, Label, Modal, RadioButtonGroup, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Receiver, TestReceiversAlert } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { Annotations, Labels } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { useTestIntegrationMutation } from '../../../api/receiversApi';
|
||||
import { GrafanaChannelValues } from '../../../types/receiver-form';
|
||||
import { defaultAnnotations } from '../../../utils/constants';
|
||||
import { stringifyErrorLike } from '../../../utils/misc';
|
||||
import { hasPlaceholderEmail } from '../../../utils/receiver-form';
|
||||
import AnnotationsStep from '../../rule-editor/AnnotationsStep';
|
||||
import LabelsField from '../../rule-editor/labels/LabelsField';
|
||||
|
||||
@@ -19,6 +21,7 @@ interface Props {
|
||||
onDismiss: () => void;
|
||||
alertManagerSourceName: string;
|
||||
receivers: Receiver[];
|
||||
channelValues?: GrafanaChannelValues;
|
||||
}
|
||||
|
||||
type AnnoField = {
|
||||
@@ -43,15 +46,23 @@ const defaultValues: FormFields = {
|
||||
labels: [{ key: '', value: '' }],
|
||||
};
|
||||
|
||||
export const TestContactPointModal = ({ isOpen, onDismiss, alertManagerSourceName, receivers }: Props) => {
|
||||
export const TestContactPointModal = ({
|
||||
isOpen,
|
||||
onDismiss,
|
||||
alertManagerSourceName,
|
||||
receivers,
|
||||
channelValues,
|
||||
}: Props) => {
|
||||
const [notificationType, setNotificationType] = useState<NotificationType>(NotificationType.predefined);
|
||||
const styles = useStyles2(getStyles);
|
||||
const formMethods = useForm<FormFields>({ defaultValues, mode: 'onBlur' });
|
||||
const [testIntegration, { isLoading, error, isSuccess }] = useTestIntegrationMutation();
|
||||
|
||||
// Check if email integration has placeholder addresses
|
||||
const isPlaceholderEmail = hasPlaceholderEmail(channelValues);
|
||||
|
||||
const onSubmit = async (data: FormFields) => {
|
||||
let alert: TestReceiversAlert | undefined;
|
||||
|
||||
if (notificationType === NotificationType.custom) {
|
||||
alert = {
|
||||
annotations: data.annotations
|
||||
@@ -93,6 +104,22 @@ export const TestContactPointModal = ({ isOpen, onDismiss, alertManagerSourceNam
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPlaceholderEmail && (
|
||||
<div className={styles.section}>
|
||||
<Alert
|
||||
title={t(
|
||||
'alerting.test-contact-point-modal.placeholder-warning-title',
|
||||
'Configure a valid email address'
|
||||
)}
|
||||
severity="info"
|
||||
>
|
||||
<Trans i18nKey="alerting.test-contact-point-modal.placeholder-warning-body">
|
||||
This contact point is using a placeholder email address (<Text variant="code">example@email.com</Text>).
|
||||
Please update it with a valid email address before testing.
|
||||
</Trans>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.section}>
|
||||
<Label>
|
||||
<Trans i18nKey="alerting.test-contact-point-modal.notification-message">Notification message</Trans>
|
||||
@@ -132,7 +159,18 @@ export const TestContactPointModal = ({ isOpen, onDismiss, alertManagerSourceNam
|
||||
)}
|
||||
|
||||
<Modal.ButtonRow>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || isPlaceholderEmail}
|
||||
tooltip={
|
||||
isPlaceholderEmail
|
||||
? t(
|
||||
'alerting.test-contact-point-modal.test-disabled-placeholder',
|
||||
'Please configure a valid email address before testing'
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="alerting.test-contact-point-modal.send-test-notification">Send test notification</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { locationService, logMeasurement } from '@grafana/runtime';
|
||||
import { AlertManagerCortexConfig, AlertmanagerGroup, Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { appEvents } from 'app/core/core';
|
||||
import {
|
||||
AlertManagerCortexConfig,
|
||||
AlertmanagerGroup,
|
||||
Matcher,
|
||||
Receiver,
|
||||
TestReceiversAlert,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { ThunkResult } from 'app/types/store';
|
||||
import { RuleIdentifier, RuleNamespace, StateHistoryItem } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { withPromRulesMetadataLogging, withRulerRulesMetadataLogging } from '../Analytics';
|
||||
import { deleteAlertManagerConfig, fetchAlertGroups, updateAlertManagerConfig } from '../api/alertmanager';
|
||||
import {
|
||||
deleteAlertManagerConfig,
|
||||
fetchAlertGroups,
|
||||
testReceivers,
|
||||
updateAlertManagerConfig,
|
||||
} from '../api/alertmanager';
|
||||
import { alertmanagerApi } from '../api/alertmanagerApi';
|
||||
import { fetchAnnotations } from '../api/annotations';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
@@ -17,6 +30,7 @@ import { FetchRulerRulesFilter, fetchRulerRules } from '../api/ruler';
|
||||
import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager';
|
||||
import { getAllRulesSourceNames } from '../utils/datasource';
|
||||
import { makeAMLink } from '../utils/misc';
|
||||
import { receiverHasPlaceholderEmail } from '../utils/receiver-form';
|
||||
import { withAppEvents, withSerializedError } from '../utils/redux';
|
||||
import { getAlertInfo } from '../utils/rules';
|
||||
import { safeParsePrometheusDuration } from '../utils/time';
|
||||
@@ -253,6 +267,39 @@ export const deleteAlertManagerConfigAction = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
interface TestReceiversOptions {
|
||||
alertManagerSourceName: string;
|
||||
receivers: Receiver[];
|
||||
alert?: TestReceiversAlert;
|
||||
}
|
||||
|
||||
export const testReceiversAction = createAsyncThunk(
|
||||
'unifiedalerting/testReceivers',
|
||||
async ({ alertManagerSourceName, receivers, alert }: TestReceiversOptions): Promise<void> => {
|
||||
const usesPlaceholder = receiverHasPlaceholderEmail(receivers);
|
||||
|
||||
if (usesPlaceholder) {
|
||||
// Handle placeholder email case with custom warning message
|
||||
try {
|
||||
await withSerializedError(testReceivers(alertManagerSourceName, receivers, alert));
|
||||
appEvents.emit(AppEvents.alertWarning, [
|
||||
'Test completed, but no email was sent because a placeholder email address is configured. Please update your contact point with a valid email address to receive alerts.',
|
||||
]);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Unknown error';
|
||||
appEvents.emit(AppEvents.alertError, [`Failed to send test alert: ${msg}`]);
|
||||
throw e;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return withAppEvents(withSerializedError(testReceivers(alertManagerSourceName, receivers, alert)), {
|
||||
errorMessage: 'Failed to send test alert.',
|
||||
successMessage: 'Test alert sent.',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
|
||||
return rules.filter((rule: RulerRuleDTO) => {
|
||||
const { forDuration } = getAlertInfo(rule, everyDuration);
|
||||
|
||||
@@ -304,3 +304,42 @@ export function omitTemporaryIdentifiers<T>(object: Readonly<T>): T {
|
||||
|
||||
return objectCopy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder emails that ship with the default grafana-default-email contact point.
|
||||
* These should not trigger actual email sends or throw errors.
|
||||
*/
|
||||
const PLACEHOLDER_EMAILS = ['<example@email.com>'];
|
||||
|
||||
/**
|
||||
* Check if a single channel/integration has placeholder email addresses.
|
||||
* Used in UI components to disable test buttons and show warnings.
|
||||
*/
|
||||
export function hasPlaceholderEmail(channelValues?: GrafanaChannelValues): boolean {
|
||||
if (!channelValues || channelValues.type !== 'email') {
|
||||
return false;
|
||||
}
|
||||
const addresses = channelValues.settings?.addresses;
|
||||
if (!addresses) {
|
||||
return false;
|
||||
}
|
||||
const addressList = typeof addresses === 'string' ? [addresses] : addresses;
|
||||
return addressList.some((addr: string) => PLACEHOLDER_EMAILS.includes(addr?.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any receiver in a list has placeholder email addresses.
|
||||
* Used in actions to determine if warning messages should be shown.
|
||||
*/
|
||||
export function receiverHasPlaceholderEmail(receivers: Receiver[]): boolean {
|
||||
return receivers.some((receiver) =>
|
||||
receiver.grafana_managed_receiver_configs?.some((config) => {
|
||||
if (config.type === 'email' && config.settings?.addresses) {
|
||||
const addresses = config.settings.addresses;
|
||||
const addressList = typeof addresses === 'string' ? [addresses] : addresses;
|
||||
return addressList.some((addr: string) => PLACEHOLDER_EMAILS.includes(addr.trim()));
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,4 +105,32 @@ describe('ScopesNavigationTreeLink', () => {
|
||||
expect(matchingLink).toHaveAttribute('aria-current', 'page');
|
||||
expect(otherLink).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('shows correct icon for grafana-metricsdrilldown-app with query parameters', () => {
|
||||
renderWithRouter(
|
||||
<ScopesNavigationTreeLink
|
||||
to="/a/grafana-metricsdrilldown-app?from=now-1h&to=now"
|
||||
title="Metrics Drilldown"
|
||||
id="metrics-drilldown"
|
||||
/>
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('scopes-dashboards-metrics-drilldown');
|
||||
// Icon should be rendered (check for SVG element which is how Icon renders)
|
||||
const icon = link.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
// The link should contain the title text
|
||||
expect(link).toHaveTextContent('Metrics Drilldown');
|
||||
});
|
||||
|
||||
it('shows correct icon for grafana-metricsdrilldown-app without trailing slash', () => {
|
||||
renderWithRouter(
|
||||
<ScopesNavigationTreeLink to="/a/grafana-metricsdrilldown-app" title="Metrics Drilldown" id="metrics-drilldown" />
|
||||
);
|
||||
|
||||
const link = screen.getByTestId('scopes-dashboards-metrics-drilldown');
|
||||
const icon = link.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(link).toHaveTextContent('Metrics Drilldown');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Link, useLocation } from 'react-router-dom-v5-compat';
|
||||
import { GrafanaTheme2, IconName, locationUtil } from '@grafana/data';
|
||||
import { Icon, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { isCurrentPath } from './scopeNavgiationUtils';
|
||||
import { isCurrentPath, normalizePath } from './scopeNavgiationUtils';
|
||||
|
||||
export interface ScopesNavigationTreeLinkProps {
|
||||
to: string;
|
||||
@@ -36,25 +36,30 @@ export function ScopesNavigationTreeLink({ to, title, id }: ScopesNavigationTree
|
||||
}
|
||||
|
||||
function getLinkIcon(to: string) {
|
||||
// Strip base URL and normalize path
|
||||
const normalizedPath = locationUtil.stripBaseFromUrl(to);
|
||||
for (const [key, value] of linkMap.entries()) {
|
||||
if (normalizedPath.startsWith(key)) {
|
||||
return value;
|
||||
}
|
||||
// Check for external links before stripping base (stripBaseFromUrl removes http:// for same-origin URLs)
|
||||
if (to.startsWith('http')) {
|
||||
return 'external-link-alt';
|
||||
}
|
||||
|
||||
return 'link';
|
||||
// Strip base URL and normalize path (remove query params and hash)
|
||||
const baseStripped = locationUtil.stripBaseFromUrl(to);
|
||||
const normalizedPath = normalizePath(baseStripped);
|
||||
|
||||
// Check for dashboard paths with startsWith (e.g., /d/dashboard-id)
|
||||
if (normalizedPath.startsWith('/d')) {
|
||||
return 'apps';
|
||||
}
|
||||
|
||||
// Use direct Map lookup for exact path matches
|
||||
return linkMap.get(normalizedPath) ?? 'link';
|
||||
}
|
||||
|
||||
const linkMap = new Map<string, IconName>([
|
||||
['http', 'external-link-alt'],
|
||||
['/d', 'apps'],
|
||||
['/explore/metrics', 'drilldown'],
|
||||
['/a/grafana-metricsdrilldown-app/', 'drilldown'],
|
||||
['/a/grafana-lokiexplore-app/', 'drilldown'],
|
||||
['/a/grafana-exploretraces-app/', 'drilldown'],
|
||||
['/a/grafana-pyroscope-app/', 'drilldown'],
|
||||
['/a/grafana-metricsdrilldown-app', 'drilldown'],
|
||||
['/a/grafana-lokiexplore-app', 'drilldown'],
|
||||
['/a/grafana-exploretraces-app', 'drilldown'],
|
||||
['/a/grafana-pyroscope-app', 'drilldown'],
|
||||
]);
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
|
||||
Reference in New Issue
Block a user