Compare commits

..

10 Commits

Author SHA1 Message Date
colin-stuart
3f140eef4b remove passwordlessMagicLinkAuthentication feature flag 2026-01-08 10:39:41 -06:00
Kristina Demeshchik
29b04bd2ed Dashboards: Use ISO 8601 format for panel time range state (#115901)
date convertion
2026-01-06 14:29:58 -05:00
Paul Marbach
c0fe27406b Table: Clamp Safari exclusions to 26.0 and 26.1 (#114454) 2026-01-06 13:30:03 -05:00
Will Browne
9409af6f1c Plugins: Add plugin.isFullyInstalled to usePluginConfig hook (#115898)
add isfullyinstalled to hook
2026-01-06 17:56:53 +00:00
Isabella Siu
e1c60e0a83 Cloudwatch: Update grafana-aws-sdk to 1.4.2 (#115855) 2026-01-06 12:17:20 -05:00
Stephanie Hingtgen
1f88aeb91f Provisioning: Update docs around file watches (#115803) 2026-01-06 10:54:12 -06:00
Stephanie Hingtgen
901360dca4 Access Control: Re-add OSS Seeding (#115858) 2026-01-06 10:48:31 -06:00
Paul Marbach
2cf485f6bf Gauge: Fix Safari issues in SVG rendering (#115756)
* Sparkline: Restore to a function component

* fix whitespace lint issue

* swap from clipPath to mask to help Safari

* Gauge: Fix SVG issues in Safari

* more steps in the right direction

* don't set filters which don't exist

* fix a couple other text and baseline stuff

* fix tests after changes

* clean up effects as follow-up to other PR

* fix issue with threshold bars, and also simplify non-gradient case
2026-01-06 11:25:40 -05:00
Matt Cowley
8ff88036e7 UI: Allow lastActiveAt to be optional for UserIcon UserView (#115887)
Allow lastActiveAt to be optional for UserIcon UserView
2026-01-06 16:18:25 +00:00
Matthew Jacobson
5eb0e6f432 Alerting: Update RuleGroupConfig definitions with missing fields (#115850)
* Alerting: Update RuleGroupConfig definitions with missing fields

This update adds previously missing fields to the `RuleGroupConfig` structs to
ensure compatibility with external Prometheus-like rulers.

Includes:
- `labels`: per https://github.com/prometheus/prometheus/pull/11474
- `remote_write`: per https://github
.com/grafana/mimir/blob/56f33fed6254fee5a53bde1eab36c604863e3d5f/pkg/mimirtool/rules/rwrulefmt/rulefmt.go#L16

Note: This does not add full support in Grafana; it only allows these fields to
pass through the alerting proxy without causing unmarshal errors when using
external rulers.

* Update OpenAPI spec
2026-01-06 11:05:01 -05:00
68 changed files with 3126 additions and 2666 deletions

View File

@@ -69,12 +69,12 @@ require (
github.com/at-wat/mqtt-go v0.19.6 // indirect
github.com/aws/aws-sdk-go v1.55.7 // indirect
github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
@@ -162,14 +162,14 @@ require (
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect
github.com/grafana/grafana-aws-sdk v1.3.0 // indirect
github.com/grafana/grafana-aws-sdk v1.4.2 // indirect
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect
github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect
github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect
github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect
github.com/grafana/otel-profiling-go v0.5.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/grafana/sqlds/v4 v4.2.7 // indirect
github.com/grafana/sqlds/v5 v5.0.3 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect

View File

@@ -177,38 +177,38 @@ github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrK
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY=
github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4=
github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8=
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI=
github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4=
github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U=
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -637,8 +637,8 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU
github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k=
github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw=
github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU=
github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM=
github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk=
github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM=
github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY=
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk=
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc=
github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws=
@@ -655,8 +655,8 @@ github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasn
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA=
github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU=
github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU=
github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=

View File

@@ -108,22 +108,22 @@ require (
github.com/aws/aws-sdk-go v1.55.7 // indirect
github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
@@ -229,7 +229,7 @@ require (
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect
github.com/grafana/grafana-aws-sdk v1.3.0 // indirect
github.com/grafana/grafana-aws-sdk v1.4.2 // indirect
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect
github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect
github.com/grafana/grafana/apps/dashboard v0.0.0 // indirect
@@ -242,7 +242,7 @@ require (
github.com/grafana/otel-profiling-go v0.5.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/grafana/sqlds/v4 v4.2.7 // indirect
github.com/grafana/sqlds/v5 v5.0.3 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect

View File

@@ -242,20 +242,20 @@ github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrK
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY=
github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4=
github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8=
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI=
github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4=
github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw=
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 h1:Nn3qce+OHZuMj/edx4its32uxedAmquCDxtZkrdeiD4=
@@ -264,12 +264,12 @@ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 h1:e5cbPZYTIY2nUEFie
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0/go.mod h1:UseIHRfrm7PqeZo6fcTb6FUCXzCnh1KJbQbmOfxArGM=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 h1:IfMb3Ar8xEaWjgH/zeVHYD8izwJdQgRP5mKCTDt4GNk=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2/go.mod h1:35jGWx7ECvCwTsApqicFYzZ7JFEnBc6oHUuOQ3xIS54=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U=
github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo=
@@ -282,12 +282,12 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZ
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 h1:w6a0H79HrHf3lr+zrw+pSzR5B+caiQFAKiNHlrUcnoc=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1ctzIu4V3bfCNJ39BiOPSm1gHFlFjTkRE0=
@@ -853,8 +853,8 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU
github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k=
github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw=
github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU=
github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM=
github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk=
github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM=
github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY=
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk=
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc=
github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI=
@@ -891,8 +891,8 @@ github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae h1:35W3Wjp
github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA=
github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU=
github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU=
github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew=
github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec h1:wnzJov9RhSHGaTYGzTygL4qq986fLen8xSqnQgaMd28=
github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec/go.mod h1:j1IY7J2rUz7TcTjFVVx6HCpyTlYOJPtXuGRZ7sI+vSo=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=

View File

@@ -31,12 +31,12 @@ require (
github.com/apache/arrow-go/v18 v18.4.1 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -97,14 +97,14 @@ require (
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect
github.com/grafana/grafana-aws-sdk v1.3.0 // indirect
github.com/grafana/grafana-aws-sdk v1.4.2 // indirect
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect
github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect
github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect
github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect
github.com/grafana/otel-profiling-go v0.5.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/grafana/sqlds/v4 v4.2.7 // indirect
github.com/grafana/sqlds/v5 v5.0.3 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect

View File

@@ -30,18 +30,18 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI=
github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0=
@@ -229,8 +229,8 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU
github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k=
github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw=
github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU=
github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM=
github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk=
github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM=
github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY=
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk=
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc=
github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws=
@@ -243,8 +243,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA=
github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU=
github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU=
github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=

View File

@@ -428,12 +428,25 @@ Or using a Kubernetes format, for example `kubernetes-dashboard.json`:
You _must_ use the Kubernetes resource format to provision dashboards v2 / dynamic dashboards.
It later polls that path every `updateIntervalSeconds` for updates to the dashboard files and updates its database.
{{< admonition type="note" >}}
Grafana installs dashboards at the root level if you don't set the `folder` field.
{{< /admonition >}}
#### Detect updates to provisioned dashboards files
After Grafana provisions your dashboards, it checks the filesystem for changes and updates dashboards as needed.
The mechanism Grafana uses to do this depends on your `updateIntervalSeconds` value:
- **More than 10 seconds**: Grafana polls the path at that interval.
- **10 seconds or less**: Grafana watches the filesystem for changes and updates dashboards when it detects them.
{{< admonition type="note" >}}
When `updateIntervalSeconds` is 10 or less, Grafana relies on filesystem watch events to detect changes.
Depending on your filesystem and how you mount or sync dashboard files (for example, Docker bind mounts or some network filesystems), those events might not reach Grafana.
To work around this, set `updateIntervalSeconds` to more than 10 to force polling, or update your setup so filesystem watch events are propagated.
{{< /admonition >}}
#### Make changes to a provisioned dashboard
You can make changes to a provisioned dashboard in the Grafana UI but its not possible to automatically save the changes back to the provisioning source.

44
go.mod
View File

@@ -89,18 +89,18 @@ require (
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20260106131612-bb61e476969f // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20260106131612-bb61e476969f // @grafana/identity-access-team
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
github.com/grafana/dataplane/sdata v0.0.9 // @grafana/observability-metrics
github.com/grafana/dskit v0.0.0-20251204003651-27988664e6ff // @grafana/grafana-backend-group
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // @grafana/grafana-backend-group
github.com/grafana/e2e v0.1.1 // @grafana-app-platform-squad
github.com/grafana/gofpdf v0.0.0-20250307124105-3b9c5d35577f // @grafana/sharing-squad
github.com/grafana/gomemcache v0.0.0-20251127154401-74f93547077b // @grafana/grafana-operator-experience-squad
github.com/grafana/gomemcache v0.0.0-20250318131618-74242eea118d // @grafana/grafana-operator-experience-squad
github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend
github.com/grafana/grafana-app-sdk v0.48.7 // @grafana/grafana-app-platform-squad
github.com/grafana/grafana-app-sdk/logging v0.48.7 // @grafana/grafana-app-platform-squad
github.com/grafana/grafana-aws-sdk v1.3.0 // @grafana/aws-datasources
github.com/grafana/grafana-aws-sdk v1.4.2 // @grafana/aws-datasources
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // @grafana/partner-datasources
github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad
github.com/grafana/grafana-google-sdk-go v0.4.2 // @grafana/partner-datasources
@@ -151,7 +151,7 @@ require (
github.com/openfga/api/proto v0.0.0-20250909172242-b4b2a12f5c67 // @grafana/identity-access-team
github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20251027165255-0f8f255e5f6c // @grafana/identity-access-team
github.com/openfga/openfga v1.11.1 // @grafana/identity-access-team
github.com/opentracing-contrib/go-grpc v0.1.2 // @grafana/grafana-search-and-storage
github.com/opentracing-contrib/go-grpc v0.1.1 // @grafana/grafana-search-and-storage
github.com/opentracing/opentracing-go v1.2.0 // @grafana/grafana-search-and-storage
github.com/openzipkin/zipkin-go v0.4.3 // @grafana/oss-big-tent
github.com/patrickmn/go-cache v2.1.0+incompatible // @grafana/alerting-backend
@@ -342,23 +342,23 @@ require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/at-wat/mqtt-go v0.19.6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
@@ -456,7 +456,6 @@ require (
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/grafana/sqlds/v4 v4.2.7 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect
github.com/hashicorp/consul/api v1.31.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -472,7 +471,7 @@ require (
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/hashicorp/memberlist v0.5.3 // indirect
github.com/hashicorp/memberlist v0.5.2 // indirect
github.com/hashicorp/serf v0.10.2 // indirect
github.com/hashicorp/vault/api v1.20.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
@@ -517,7 +516,7 @@ require (
github.com/mdlayher/socket v0.4.1 // indirect
github.com/mdlayher/vsock v1.2.1 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.68 // indirect
github.com/miekg/dns v1.1.63 // indirect
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
@@ -550,17 +549,17 @@ require (
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/jaeger v0.124.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opentracing-contrib/go-stdlib v1.1.0 // indirect
github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/pires/go-proxyproto v0.7.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/common/sigv4 v0.1.0 // indirect
github.com/prometheus/exporter-toolkit v0.15.0 // indirect
github.com/prometheus/exporter-toolkit v0.14.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/protocolbuffers/txtpbfmt v0.0.0-20251124094003-fcb97cc64c7b // indirect
github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect
@@ -574,7 +573,7 @@ require (
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/sercand/kuberesolver/v6 v6.0.1 // indirect
github.com/sercand/kuberesolver/v6 v6.0.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shadowspore/fossil-delta v0.0.0-20241213113458-1d797d70cbe3 // indirect
@@ -683,6 +682,7 @@ require (
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/gophercloud/gophercloud/v2 v2.9.0 // indirect
github.com/grafana/sqlds/v5 v5.0.3 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/go-archive v0.1.0 // indirect

58
go.sum
View File

@@ -854,20 +854,20 @@ github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrK
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY=
github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4=
github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8=
github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI=
github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4=
github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw=
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 h1:Nn3qce+OHZuMj/edx4its32uxedAmquCDxtZkrdeiD4=
@@ -876,12 +876,12 @@ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 h1:e5cbPZYTIY2nUEFie
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0/go.mod h1:UseIHRfrm7PqeZo6fcTb6FUCXzCnh1KJbQbmOfxArGM=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 h1:IfMb3Ar8xEaWjgH/zeVHYD8izwJdQgRP5mKCTDt4GNk=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2/go.mod h1:35jGWx7ECvCwTsApqicFYzZ7JFEnBc6oHUuOQ3xIS54=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U=
github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo=
@@ -894,12 +894,12 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZ
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 h1:w6a0H79HrHf3lr+zrw+pSzR5B+caiQFAKiNHlrUcnoc=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f/go.mod h1:2stgcRjl6QmW+gU2h5E7BQXg4HU0gzxKWDuT5HviN9s=
@@ -1629,20 +1629,14 @@ github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopK
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib v0.0.0-20260106131612-bb61e476969f h1:OfVtnO3+Ficm7W69dFD5IaZWlMvOLIWBBnppE99dVkU=
github.com/grafana/authlib v0.0.0-20260106131612-bb61e476969f/go.mod h1:KUNx2Qz7mgh2tm2/TJXx0+uq5SkCrquCFI+dHln2Q50=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw=
github.com/grafana/authlib/types v0.0.0-20251203163023-dd5a97c606e3/go.mod h1:CZ5McGzO/q6lnRb8xvTODCC2bJniQoQ+gho0AVZC/zY=
github.com/grafana/authlib/types v0.0.0-20260106131612-bb61e476969f h1:5ZI6e22sGdg36MAIMJkH6PUHtZU/QuwAScNfgWNlK0I=
github.com/grafana/authlib/types v0.0.0-20260106131612-bb61e476969f/go.mod h1:j+YTXmAcD4zCNyl4QSNqYSEe/q9KgrH1btodnhK29hI=
github.com/grafana/dataplane/examples v0.0.1 h1:K9M5glueWyLoL4//H+EtTQq16lXuHLmOhb6DjSCahzA=
github.com/grafana/dataplane/examples v0.0.1/go.mod h1:h5YwY8s407/17XF5/dS8XrUtsTVV2RnuW8+m1Mp46mg=
github.com/grafana/dataplane/sdata v0.0.9 h1:AGL1LZnCUG4MnQtnWpBPbQ8ZpptaZs14w6kE/MWfg7s=
github.com/grafana/dataplane/sdata v0.0.9/go.mod h1:Jvs5ddpGmn6vcxT7tCTWAZ1mgi4sbcdFt9utQx5uMAU=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
github.com/grafana/dskit v0.0.0-20251204003651-27988664e6ff/go.mod h1:/pHIcyeZJBZbtboXOjRtPaMl5KK+2VRdNJbCHDkpDYs=
github.com/grafana/e2e v0.1.1 h1:/b6xcv5BtoBnx8cZnCiey9DbjEc8z7gXHO5edoeRYxc=
github.com/grafana/e2e v0.1.1/go.mod h1:RpNLgae5VT+BUHvPE+/zSypmOXKwEu4t+tnEMS1ATaE=
github.com/grafana/go-mysql-server v0.20.1-grafana1 h1:yA4Mzt+tTdIlQutBUaiPnepULPQ7CS4hMu2GOpHqT6s=
@@ -1651,15 +1645,14 @@ github.com/grafana/gofpdf v0.0.0-20250307124105-3b9c5d35577f h1:5xkjl5Y/j2QefJKO
github.com/grafana/gofpdf v0.0.0-20250307124105-3b9c5d35577f/go.mod h1:+O5QxOwwgP10jedZHapzXY+IPKTnzHBtIs5UUb9G+kI=
github.com/grafana/gomemcache v0.0.0-20250318131618-74242eea118d h1:oXRJlb9UjVsl6LhqBdbyAQ9YFhExwsj4bjh5vwMNRZY=
github.com/grafana/gomemcache v0.0.0-20250318131618-74242eea118d/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw=
github.com/grafana/gomemcache v0.0.0-20251127154401-74f93547077b/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw=
github.com/grafana/grafana-api-golang-client v0.27.0 h1:zIwMXcbCB4n588i3O2N6HfNcQogCNTd/vPkEXTr7zX8=
github.com/grafana/grafana-api-golang-client v0.27.0/go.mod h1:uNLZEmgKtTjHBtCQMwNn3qsx2mpMb8zU+7T4Xv3NR9Y=
github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo=
github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k=
github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw=
github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU=
github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM=
github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk=
github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM=
github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY=
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk=
github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc=
github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI=
@@ -1698,8 +1691,8 @@ github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrR
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 h1:SDGrP81Vcd102L3UJEryRd1eestRw73wt+b8vnVEFe0=
github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56/go.mod h1:S4+611dxnKt8z/ulbvaJzcgSHsuhjVc1QHNTcr1R7Fw=
github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA=
github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU=
github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU=
github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew=
github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec h1:wnzJov9RhSHGaTYGzTygL4qq986fLen8xSqnQgaMd28=
github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec/go.mod h1:j1IY7J2rUz7TcTjFVVx6HCpyTlYOJPtXuGRZ7sI+vSo=
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
@@ -1804,7 +1797,6 @@ github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0=
github.com/hashicorp/memberlist v0.5.2 h1:rJoNPWZ0juJBgqn48gjy59K5H4rNgvUoM1kUD7bXiuI=
github.com/hashicorp/memberlist v0.5.2/go.mod h1:Ri9p/tRShbjYnpNf4FFPXG7wxEGY4Nrcn6E7jrVa//4=
github.com/hashicorp/memberlist v0.5.3/go.mod h1:h60o12SZn/ua/j0B6iKAZezA4eDaGsIuPO70eOaJ6WE=
github.com/hashicorp/nomad/api v0.0.0-20241218080744-e3ac00f30eec h1:+YBzb977VrmffaCX/OBm17dEVJUcWn5dW+eqs3aIJ/A=
github.com/hashicorp/nomad/api v0.0.0-20241218080744-e3ac00f30eec/go.mod h1:svtxn6QnrQ69P23VvIWMR34tg3vmwLz4UdUzm1dSCgE=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
@@ -2059,7 +2051,6 @@ github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKju
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/migueleliasweb/go-github-mock v1.1.0 h1:GKaOBPsrPGkAKgtfuWY8MclS1xR6MInkx1SexJucMwE=
github.com/migueleliasweb/go-github-mock v1.1.0/go.mod h1:pYe/XlGs4BGMfRY4vmeixVsODHnVDDhJ9zoi0qzSMHc=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
@@ -2214,11 +2205,9 @@ github.com/openfga/openfga v1.11.1 h1:+cJBPi/J+RWPRg+cXOjwWEwjauiW8rdE3kEzcFy1ME
github.com/openfga/openfga v1.11.1/go.mod h1:MuTGr/ghY7t2sEGwS/59pq9SkqO0QY1kQLIe8Upt+G8=
github.com/opentracing-contrib/go-grpc v0.1.1 h1:Ws7IN1zyiL1DFqKQPhRXuKe5pLYzMfdxnC1qtajE2PE=
github.com/opentracing-contrib/go-grpc v0.1.1/go.mod h1:Nu6sz+4zzgxXu8rvKfnwjBEmHsuhTigxRwV2RhELrS8=
github.com/opentracing-contrib/go-grpc v0.1.2/go.mod h1:glU6rl1Fhfp9aXUHkE36K2mR4ht8vih0ekOVlWKEUHM=
github.com/opentracing-contrib/go-stdlib v0.0.0-20190519235532-cf7a6c988dc9/go.mod h1:PLldrQSroqzH70Xl+1DQcGnefIbqsKR7UDaiux3zV+w=
github.com/opentracing-contrib/go-stdlib v1.0.0 h1:TBS7YuVotp8myLon4Pv7BtCBzOTo1DeZCld0Z63mW2w=
github.com/opentracing-contrib/go-stdlib v1.0.0/go.mod h1:qtI1ogk+2JhVPIXVc6q+NHziSmy2W5GbdQZFUHADCBU=
github.com/opentracing-contrib/go-stdlib v1.1.0/go.mod h1:S0p+X9p6dcBkoMTL+Qq2VOvxKs9ys5PpYWXWqlCS0bQ=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
@@ -2252,7 +2241,6 @@ github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -2331,7 +2319,6 @@ github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57J
github.com/prometheus/exporter-toolkit v0.11.0/go.mod h1:BVnENhnNecpwoTLiABx7mrPB/OLRIgN74qlQbV+FK1Q=
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/exporter-toolkit v0.15.0/go.mod h1:OyRWd2iTo6Xge9Kedvv0IhCrJSBu36JCfJ2yVniRIYk=
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=
@@ -2416,7 +2403,6 @@ github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQcc
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sercand/kuberesolver/v6 v6.0.0 h1:ScvS2Ga9snVkpOahln/BCLySr3/iBAHJf25u66DweZ0=
github.com/sercand/kuberesolver/v6 v6.0.0/go.mod h1:Dxkqms3OJadP5zirIBPLi9FV8Qpys3T3w40XPEcVsu0=
github.com/sercand/kuberesolver/v6 v6.0.1/go.mod h1:C0tsTuRMONSY+Xf7pv7RMW1/JlewY1+wS8SZE+1lf1s=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=

View File

@@ -317,7 +317,6 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.29.
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator v0.53.0 h1:RAHqDHJmNMLe6JvDoRIlXmb72w+62Ue/k5p/qP9yfAg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator v0.53.0/go.mod h1:dtCRwgvytbGKWdlrjMOg9geBoRwRpCYWIOM/JhVsDIc=
github.com/HdrHistogram/hdrhistogram-go v1.2.0/go.mod h1:CiIeGiHSd06zjX+FypuEJ5EQ07KKtxZ+8J6hszwVQig=
github.com/IBM/go-sdk-core/v5 v5.17.4 h1:VGb9+mRrnS2HpHZFM5hy4J6ppIWnwNrw0G+tLSgcJLc=
github.com/IBM/go-sdk-core/v5 v5.17.4/go.mod h1:KsAAI7eStAWwQa4F96MLy+whYSh39JzNjklZRbN/8ns=
github.com/IBM/ibm-cos-sdk-go v1.11.0 h1:Jp55NLN3OvBwucMGpP5wNybyjncsmTZ9+GPHai/1cE8=
@@ -425,23 +424,30 @@ github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1/go.mod h1:MVYeeOhILFFemC/XlYTCl
github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0=
github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8=
github.com/aws/aws-sdk-go-v2/config v1.31.2/go.mod h1:17ft42Yb2lF6OigqSYiDAiUcX4RIkEMY6XxEMJsrAes=
github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8=
github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc=
github.com/aws/aws-sdk-go-v2/credentials v1.18.6/go.mod h1:/jdQkh1iVPa01xndfECInp1v1Wnp70v3K4MvtlLGVEc=
github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5 h1:oUEqVqonG3xuarrsze1KVJ30KagNYDemikTbdu8KlN8=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5/go.mod h1:VNM08cHlOsIbSHRqb6D/M2L4kKXfJv3A2/f0GNbOQSc=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.87 h1:oDPArGgCrG/4aTi86ij3S2PB59XXkTSKYVNQlmqRHXQ=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.87/go.mod h1:ZeQC4gVarhdcWeM1c90DyBLaBCNhEeAbKUXwVI/byvw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQGC/8rrvgNXU6ZoYM3sAIJCIrXJxY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69/go.mod h1:GJj8mmO6YT6EqgduWocwhMoxTLFitkhIrK+owzrYL2I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0 h1:A99gjqZDbdhjtjJVZrmVzVKO2+p3MSg35bDWtbMQVxw=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0/go.mod h1:mWB0GE1bqcVSvpW7OtFA0sKuHk52+IqtnsYU2jUfYAs=
@@ -449,11 +455,13 @@ github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0 h1:0wOCTKrmwkyC8Bk7
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0/go.mod h1:He/RikglWUczbkV+fkdpcV/3GdL/rTRNVy7VaUiezMo=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17 h1:x187MqiHwBGjMGAed8Y8K1VGuCtFvQvXb24r+bwmSdo=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17/go.mod h1:mC9qMbA6e1pwEq6X3zDGtZRXMG2YaElJkbJlMVHLs5I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4/go.mod h1:nLEfLnVMmLvyIG58/6gsSA03F1voKGaCfHV7+lR8S7s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0 h1:JPXkrQk5OS/+Q81fKH97Ll/Vmmy0p9vwHhxw+V+tVjg=
github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0/go.mod h1:dJngkoVMrq0K7QvRkdRZYM4NUp6cdWa2GBdpm8zoY8U=
@@ -487,10 +495,13 @@ github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 h1:OwMzNDe5VVTXD4kGmeK/FtqAITiV
github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1/go.mod h1:IyVabkWrs8SNdOEZLyFFcW9bUltV4G6OQS0s6H20PHg=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc=
github.com/aws/aws-sdk-go-v2/service/sso v1.28.2/go.mod h1:n9bTZFZcBa9hGGqVz3i/a6+NG0zmZgtkB9qVVFDqPA8=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2/go.mod h1:eknndR9rU8UpE/OmFpqU78V1EcXPKFTTm5l/buZYgvM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA=
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.0/go.mod h1:bEPcjW7IbolPfK67G1nilqWyoxYMSPrDiIQ3RdIdKgo=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0=
github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
@@ -880,6 +891,7 @@ github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQ
github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gophercloud/gophercloud v1.13.0 h1:8iY9d1DAbzMW6Vok1AxbbK5ZaUjzMp0tdyt4fX9IeJ0=
github.com/gophercloud/gophercloud v1.13.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
@@ -890,23 +902,19 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls=
github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae/go.mod h1:VGjS5gDwWEADPP6pF/drqLxEImgeuHlEW5u8E5EfIrM=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI=
github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw=
github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw=
github.com/grafana/authlib/types v0.0.0-20251203163023-dd5a97c606e3 h1:T4AMrL8ZB1U25m/+FOmkqWPnz0X7u/Oqj1ISg4OrS2c=
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I=
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
github.com/grafana/cog v0.0.43/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38=
github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914/go.mod h1:OiN4P4aC6LwLzLbEupH3Ue83VfQoNMfG48rsna8jI/E=
github.com/grafana/dskit v0.0.0-20250818234656-8ff9c6532e85/go.mod h1:kImsvJ1xnmeT9Z6StK+RdEKLzlpzBsKwJbEQfmBJdFs=
github.com/grafana/dskit v0.0.0-20251204003651-27988664e6ff h1:eDbrQsfY1Y3vMfuy5suGI2DRNC1DFBcZMFMlNbPrdiE=
github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak=
github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90=
github.com/grafana/go-mysql-server v0.20.1-0.20251027172658-317a8d46ffa4/go.mod h1:EeYR0apo+8j2Dyxmn2ghkPlirO2S5mT1xHBrA+Efys8=
github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw=
github.com/grafana/gomemcache v0.0.0-20251127154401-74f93547077b h1:5qp8/5YPt/Z2RW5QHsxvwE05+LWQYIXydP2MwOkMfb8=
github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM=
github.com/grafana/grafana-app-sdk v0.40.2/go.mod h1:BbNXPNki3mtbkWxYqJsyA1Cj9AShSyaY33z8WkyfVv0=
github.com/grafana/grafana-app-sdk v0.41.0 h1:SYHN3U7B1myRKY3UZZDkFsue9TDmAOap0UrQVTqtYBU=
@@ -938,6 +946,7 @@ github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9
github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE=
github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8=
github.com/grafana/grafana-aws-sdk v1.2.0/go.mod h1:bBo7qOmM3f61vO+2JxTolNUph1l2TmtzmWcU9/Im+8A=
github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk=
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo=
github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8=
github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw=
@@ -985,6 +994,7 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU=
github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grafana/sqlds/v4 v4.2.4/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU=
github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU=
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU=
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
@@ -1028,7 +1038,6 @@ github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI
github.com/hashicorp/mdns v1.0.5 h1:1M5hW1cunYeoXOqHwEb/GBDDHAFo0Yqb/uz/beC6LbE=
github.com/hashicorp/mdns v1.0.5/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
github.com/hashicorp/memberlist v0.3.1/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/memberlist v0.5.3 h1:tQ1jOCypD0WvMemw/ZhhtH+PWpzcftQvgCorLu0hndk=
github.com/hashicorp/raft v1.7.0 h1:4u24Qn6lQ6uwziM++UgsyiT64Q8GyRn43CV41qPiz1o=
github.com/hashicorp/raft v1.7.0/go.mod h1:N1sKh6Vn47mrWvEArQgILTyng8GoDRNYlgKyK7PMjs0=
github.com/hashicorp/raft-wal v0.4.1 h1:aU8XZ6x8R9BAIB/83Z1dTDtXvDVmv9YVYeXxd/1QBSA=
@@ -1189,7 +1198,6 @@ github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxW
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/minio/minio-go/v7 v7.0.75/go.mod h1:qydcVzV8Hqtj1VtEocfxbmVFa2siu6HGa+LDEPogjD8=
github.com/minio/sha256-simd v0.1.1 h1:5QHSlgo3nt5yKOJrC7W8w7X+NFl8cMPZm96iu8kKUJU=
@@ -1349,8 +1357,6 @@ github.com/openfga/api/proto v0.0.0-20250127102726-f9709139a369/go.mod h1:m74TNg
github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20250428093642-7aeebe78bbfe/go.mod h1:5Z0pbTT7Jz/oQFLfadb+C5t5NwHrduAO7j7L07Ec1GM=
github.com/openfga/openfga v1.10.0/go.mod h1:6/m4GTwQsqECsGYQVD3t5sCX97rh3smnmxbMa3YAtJk=
github.com/opentracing-contrib/go-grpc v0.0.0-20210225150812-73cb765af46e/go.mod h1:DYR5Eij8rJl8h7gblRrOZ8g0kW1umSpKqYIBTgeDtLo=
github.com/opentracing-contrib/go-grpc v0.1.2 h1:MP16Ozc59kqqwn1v18aQxpeGZhsBanJ2iurZYaQSZ+g=
github.com/opentracing-contrib/go-stdlib v1.1.0 h1:cZBWc4pA4e65tqTJddbflK435S0tDImj6c9BMvkdUH0=
github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
@@ -1373,7 +1379,6 @@ github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2
github.com/phpdave11/gofpdf v1.4.2 h1:KPKiIbfwbvC/wOncwhrpRdXVj2CZTCFlw4wnoyjtHfQ=
github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A=
github.com/pkg/sftp v1.13.1 h1:I2qBYMChEhIjOgazfJmV3/mZM256btk6wkCDRmW7JYs=
@@ -1398,7 +1403,6 @@ github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8N
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
github.com/prometheus/common/assets v0.2.0 h1:0P5OrzoHrYBOSM1OigWL3mY8ZvV2N4zIE/5AahrSrfM=
github.com/prometheus/exporter-toolkit v0.10.1-0.20230714054209-2f4150c63f97/go.mod h1:LoBCZeRh+5hX+fSULNyFnagYlQG/gBsyA/deNzROkq8=
github.com/prometheus/exporter-toolkit v0.15.0 h1:Pcle5sSViwR1x0gdPd0wtYrPQENBieQAM7TmT0qtb2U=
github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/statsd_exporter v0.21.0/go.mod h1:rbT83sZq2V+p73lHhPZfMc3MLCHmSHelCh9hSGYNLTQ=
github.com/prometheus/statsd_exporter v0.26.1 h1:ucbIAdPmwAUcA+dU+Opok8Qt81Aw8HanlO+2N/Wjv7w=
@@ -1460,7 +1464,6 @@ github.com/sercand/kuberesolver v2.4.0+incompatible h1:WE2OlRf6wjLxHwNkkFLQGaZcV
github.com/sercand/kuberesolver v2.4.0+incompatible/go.mod h1:lWF3GL0xptCB/vCiJPl/ZshwPsX/n4Y7u0CW9E7aQIQ=
github.com/sercand/kuberesolver/v5 v5.1.1 h1:CYH+d67G0sGBj7q5wLK61yzqJJ8gLLC8aeprPTHb6yY=
github.com/sercand/kuberesolver/v5 v5.1.1/go.mod h1:Fs1KbKhVRnB2aDWN12NjKCB+RgYMWZJ294T3BtmVCpQ=
github.com/sercand/kuberesolver/v6 v6.0.1 h1:XZUTA0gy/lgDYp/UhEwv7Js24F1j8NJ833QrWv0Xux4=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
@@ -1851,6 +1854,7 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.4
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0/go.mod h1:CosX/aS4eHnG9D7nESYpV753l4j9q5j3SL/PUYd2lR8=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0/go.mod h1:HfvuU0kW9HewH14VCOLImqKvUgONodURG7Alj/IrnGI=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.62.0/go.mod h1:WfEApdZDMlLUAev/0QQpr8EJ/z0VWDKYZ5tF5RH5T1U=
@@ -1959,7 +1963,6 @@ golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sU
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
@@ -1972,7 +1975,7 @@ golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5N
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e h1:qyrTQ++p1afMkO4DPEeLGq/3oTsdlvdH4vqZUBWzUKM=
golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
@@ -2066,6 +2069,7 @@ golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
@@ -2144,7 +2148,6 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822 h1:zWFRixYR5QlotL+Uv3YfsPRENIrQFXiGs+iwqel6fOQ=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y=

View File

@@ -699,10 +699,6 @@ export interface FeatureToggles {
*/
playlistsReconciler?: boolean;
/**
* Enable passwordless login via magic link authentication
*/
passwordlessMagicLinkAuthentication?: boolean;
/**
* Display Related Logs in Grafana Metrics Drilldown
*/
exploreMetricsRelatedLogs?: boolean;

View File

@@ -1,4 +1,4 @@
import { useId, memo, HTMLAttributes, ReactNode } from 'react';
import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react';
import { FieldDisplay } from '@grafana/data';
@@ -50,14 +50,13 @@ export const RadialArcPath = memo(
}: RadialArcPathProps) => {
const id = useId();
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: '100%', height: '100%' };
if ('color' in rest) {
bgDivStyle.backgroundColor = rest.color;
} else {
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
}
const isGradient = 'gradient' in rest;
const { radius, centerX, centerY, barWidth } = dimensions;
const { vizWidth, vizHeight, radius, centerX, centerY, barWidth } = dimensions;
const pad = Math.ceil(Math.max(2, barWidth / 2)); // pad to cover stroke caps and glow in Safari
const boxX = Math.round(centerX - radius - barWidth - pad);
const boxY = Math.round(centerY - radius - barWidth - pad);
const boxSize = Math.round((radius + barWidth) * 2 + pad * 2);
const path = drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars);
@@ -72,9 +71,14 @@ export const RadialArcPath = memo(
const dotRadius =
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: boxSize, height: vizHeight, marginLeft: boxX };
const pathProps: SVGProps<SVGPathElement> = {};
let barEndcapColors: [string, string] | undefined;
let endpointMarks: ReactNode = null;
if ('gradient' in rest) {
if (isGradient) {
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
if (endpointMarker && (rest.gradient?.length ?? 0) > 0) {
switch (endpointMarker) {
case 'point':
@@ -115,25 +119,39 @@ export const RadialArcPath = memo(
if (barEndcaps) {
barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent);
}
pathProps.fill = 'none';
pathProps.stroke = 'white';
} else {
bgDivStyle.backgroundColor = rest.color;
pathProps.fill = 'none';
pathProps.stroke = rest.color;
}
const pathEl = (
<path d={path} strokeWidth={barWidth} strokeLinecap={roundedBars ? 'round' : 'butt'} {...pathProps} />
);
return (
<>
{/* FIXME: optimize this by only using clippath + foreign obj for gradients */}
<clipPath id={id}>
<path d={path} />
</clipPath>
{isGradient && (
<defs>
<mask id={id} maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse">
<rect x={boxX} y={boxY} width={boxSize} height={boxSize} fill="black" />
{pathEl}
</mask>
</defs>
)}
<g filter={glowFilter}>
<foreignObject
x={centerX - radius - barWidth}
y={centerY - radius - barWidth}
width={(radius + barWidth) * 2}
height={(radius + barWidth) * 2}
clipPath={`url(#${id})`}
>
<div style={bgDivStyle} />
</foreignObject>
{isGradient ? (
<foreignObject x={0} y={0} width={vizWidth} height={vizHeight} mask={`url(#${id})`}>
<div style={bgDivStyle} />
</foreignObject>
) : (
pathEl
)}
{barEndcapColors?.[0] && <circle cx={xStart} cy={yStart} r={barWidth / 2} fill={barEndcapColors[0]} />}
{barEndcapColors?.[1] && (
<circle cx={xEnd} cy={yEnd} r={barWidth / 2} fill={barEndcapColors[1]} opacity={0.5} />

View File

@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import { useId } from 'react';
import { useId, ReactNode } from 'react';
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -107,14 +107,14 @@ export function RadialGauge(props: RadialGaugeProps) {
const startAngle = shape === 'gauge' ? 250 : 0;
const endAngle = shape === 'gauge' ? 110 : 360;
const defs: React.ReactNode[] = [];
const graphics: React.ReactNode[] = [];
let sparklineElement: React.ReactNode | null = null;
const defs: ReactNode[] = [];
const graphics: ReactNode[] = [];
let sparklineElement: ReactNode | null = null;
for (let barIndex = 0; barIndex < values.length; barIndex++) {
const displayValue = values[barIndex];
const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle);
const gradientStops = buildGradientColors(gradient, theme, displayValue);
const gradientStops = gradient ? buildGradientColors(theme, displayValue) : undefined;
const color = displayValue.display.color ?? FALLBACK_COLOR;
const dimensions = calculateDimensions(
width,
@@ -131,7 +131,9 @@ export function RadialGauge(props: RadialGaugeProps) {
// FIXME: I want to move the ids for these filters into a context which the children
// can reference via a hook, rather than passing them down as props
const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`;
const spotlightGradientRef = endpointMarker === 'glow' ? `url(#${spotlightGradientId})` : undefined;
const glowFilterId = `glow-${gaugeId}`;
const glowFilterRef = glowBar ? `url(#${glowFilterId})` : undefined;
if (endpointMarker === 'glow') {
defs.push(
@@ -154,7 +156,7 @@ export function RadialGauge(props: RadialGaugeProps) {
fieldDisplay={displayValue}
angleRange={angleRange}
startAngle={startAngle}
glowFilter={`url(#${glowFilterId})`}
glowFilter={glowFilterRef}
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
shape={shape}
@@ -170,8 +172,8 @@ export function RadialGauge(props: RadialGaugeProps) {
angleRange={angleRange}
startAngle={startAngle}
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
endpointMarkerGlowFilter={`url(#${spotlightGradientId})`}
glowFilter={glowFilterRef}
endpointMarkerGlowFilter={spotlightGradientRef}
shape={shape}
gradient={gradientStops}
fieldDisplay={displayValue}
@@ -183,7 +185,7 @@ export function RadialGauge(props: RadialGaugeProps) {
// These elements are only added for first value / bar
if (barIndex === 0) {
if (glowBar) {
defs.push(<GlowGradient key="glow-filter" id={glowFilterId} barWidth={dimensions.barWidth} />);
defs.push(<GlowGradient key={glowFilterId} id={glowFilterId} barWidth={dimensions.barWidth} />);
}
if (glowCenter) {
@@ -234,7 +236,7 @@ export function RadialGauge(props: RadialGaugeProps) {
endAngle={endAngle}
angleRange={angleRange}
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
glowFilter={glowFilterRef}
shape={shape}
gradient={gradientStops}
/>
@@ -260,7 +262,7 @@ export function RadialGauge(props: RadialGaugeProps) {
const body = (
<>
<svg width={width} height={height} role="img" aria-label={t('gauge.category-gauge', 'Gauge')}>
<defs>{defs}</defs>
{defs.length > 0 && <defs>{defs}</defs>}
{graphics}
</svg>
{sparklineElement}

View File

@@ -1,4 +1,3 @@
import { css } from '@emotion/css';
import { memo } from 'react';
import {
@@ -9,7 +8,6 @@ import {
GrafanaTheme2,
} from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { calculateFontSize } from '../../utils/measureText';
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
@@ -50,7 +48,6 @@ export const RadialText = memo(
valueManualFontSize,
nameManualFontSize,
}: RadialTextProps) => {
const styles = useStyles2(getStyles);
const { centerX, centerY, radius, barWidth } = dimensions;
if (textMode === 'none') {
@@ -106,10 +103,9 @@ export const RadialText = memo(
const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY;
const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY;
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2;
// adjust the text up on gauges and when sparklines are present
let yOffset = 0;
let yOffset = valueFontSize / 4;
if (shape === 'gauge') {
// we render from the center of the gauge, so move up by half of half of the total height
yOffset -= (valueHeight + nameHeight) / 4;
@@ -126,15 +122,12 @@ export const RadialText = memo(
y={valueY}
fontSize={valueFontSize}
fill={theme.colors.text.primary}
className={styles.text}
textAnchor="middle"
dominantBaseline="middle"
dominantBaseline="text-bottom"
>
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
<tspan>{displayValue.text}</tspan>
<tspan className={styles.text} fontSize={unitFontSize} dy={suffixShift}>
{displayValue.suffix ?? ''}
</tspan>
<tspan fontSize={unitFontSize}>{displayValue.suffix ?? ''}</tspan>
</text>
)}
{showName && (
@@ -143,7 +136,7 @@ export const RadialText = memo(
x={centerX}
y={nameY}
textAnchor="middle"
dominantBaseline="middle"
dominantBaseline="text-bottom"
fill={nameColor}
>
{displayValue.title}
@@ -155,9 +148,3 @@ export const RadialText = memo(
);
RadialText.displayName = 'RadialText';
const getStyles = (_theme: GrafanaTheme2) => ({
text: css({
verticalAlign: 'bottom',
}),
});

View File

@@ -1,17 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 120 A 80 80 0 1 1 149.98603736605492 120.00000121846968"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 50 A 50 50 0 0 1 100 150"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 20 A 80 80 0 0 1 180 100"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 20 A 80 80 0 1 1 20 100.00000000000001"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 20 A 80 80 0 1 1 20 100.00000000000001"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`;

View File

@@ -1,6 +1,6 @@
import { defaultsDeep } from 'lodash';
import { createTheme, FALLBACK_COLOR, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data';
import { createTheme, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema';
import {
@@ -50,35 +50,9 @@ describe('RadialGauge color utils', () => {
},
});
it('should return the baseColor if gradient is false-y', () => {
expect(
buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000')
).toEqual([
{ color: '#FF0000', percent: 0 },
{ color: '#FF0000', percent: 1 },
]);
expect(
buildGradientColors(undefined, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000')
).toEqual([
{ color: '#FF0000', percent: 0 },
{ color: '#FF0000', percent: 1 },
]);
});
it('uses the fallback color if no baseColor is set', () => {
expect(buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)))).toEqual(
[
{ color: FALLBACK_COLOR, percent: 0 },
{ color: FALLBACK_COLOR, percent: 1 },
]
);
});
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
expect(
buildGradientColors(
true,
createTheme(),
buildFieldDisplay(createField(FieldColorModeId.Thresholds), {
view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) },
@@ -89,14 +63,13 @@ describe('RadialGauge color utils', () => {
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
expect(
buildGradientColors(true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000')
buildGradientColors(createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000')
).toMatchSnapshot();
});
it('should return gradient colors for continuous color modes', () => {
expect(
buildGradientColors(
true,
createTheme(),
buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)),
'#00FF00'
@@ -107,7 +80,6 @@ describe('RadialGauge color utils', () => {
it.each(['dark', 'light'] as const)('should return gradient colors for by-value color mode in %s theme', (mode) => {
expect(
buildGradientColors(
true,
createTheme({ colors: { mode } }),
buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues))
)
@@ -117,7 +89,6 @@ describe('RadialGauge color utils', () => {
it.each(['dark', 'light'] as const)('should return gradient colors for fixed color mode in %s theme', (mode) => {
expect(
buildGradientColors(
true,
createTheme({ colors: { mode } }),
buildFieldDisplay(createField(FieldColorModeId.Fixed)),
'#442299'

View File

@@ -7,18 +7,10 @@ import { GradientStop, RadialShape } from './types';
import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils';
export function buildGradientColors(
gradient = false,
theme: GrafanaTheme2,
fieldDisplay: FieldDisplay,
baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR
): GradientStop[] {
if (!gradient) {
return [
{ color: baseColor, percent: 0 },
{ color: baseColor, percent: 1 },
];
}
const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode);
// thresholds get special handling

View File

@@ -2,14 +2,20 @@ import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
import { RadialGaugeDimensions } from './types';
// some utility transparent white colors for gradients
const TRANSPARENT_WHITE = '#ffffff00';
const MOSTLY_TRANSPARENT_WHITE = '#ffffff88';
const MOSTLY_OPAQUE_WHITE = '#ffffffbb';
const OPAQUE_WHITE = '#ffffff';
const MIN_GLOW_SIZE = 0.75;
const GLOW_FACTOR = 0.08;
export interface GlowGradientProps {
id: string;
barWidth: number;
}
const MIN_GLOW_SIZE = 0.75;
const GLOW_FACTOR = 0.08;
export function GlowGradient({ id, barWidth }: GlowGradientProps) {
// 0.75 is the minimum glow size, and it scales with bar width
const glowSize = MIN_GLOW_SIZE + barWidth * GLOW_FACTOR;
@@ -27,16 +33,6 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) {
const CENTER_GLOW_OPACITY = 0.25;
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY);
return (
<radialGradient id={`circle-glow-${gaugeId}`} r="50%" fr="0%">
<stop offset="0%" stopColor={transparentColor} />
<stop offset="90%" stopColor={'#ffffff00'} />
</radialGradient>
);
}
export interface CenterGlowProps {
dimensions: RadialGaugeDimensions;
gaugeId: string;
@@ -52,7 +48,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
<defs>
<radialGradient id={gradientId} r="50%" fr="0%">
<stop offset="0%" stopColor={transparentColor} />
<stop offset="90%" stopColor="#ffffff00" />
<stop offset="90%" stopColor={TRANSPARENT_WHITE} />
</radialGradient>
</defs>
<g>
@@ -62,19 +58,15 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
);
}
export function SpotlightGradient({
id,
dimensions,
roundedBars,
angle,
theme,
}: {
interface SpotlightGradientProps {
id: string;
dimensions: RadialGaugeDimensions;
angle: number;
roundedBars: boolean;
theme: GrafanaTheme2;
}) {
}
export function SpotlightGradient({ id, dimensions, roundedBars, angle, theme }: SpotlightGradientProps) {
if (theme.isLight) {
return null;
}
@@ -88,9 +80,9 @@ export function SpotlightGradient({
return (
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#ffffff00" />
<stop offset="95%" stopColor="#ffffff88" />
{roundedBars && <stop offset="100%" stopColor={roundedBars ? '#ffffffbb' : 'white'} />}
<stop offset="0%" stopColor={TRANSPARENT_WHITE} />
<stop offset="95%" stopColor={MOSTLY_TRANSPARENT_WHITE} />
{roundedBars && <stop offset="100%" stopColor={roundedBars ? MOSTLY_OPAQUE_WHITE : OPAQUE_WHITE} />}
</linearGradient>
);
}

View File

@@ -2,6 +2,8 @@ export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'non
export type RadialShape = 'circle' | 'gauge';
export interface RadialGaugeDimensions {
vizHeight: number;
vizWidth: number;
margin: number;
radius: number;
centerX: number;

View File

@@ -283,7 +283,9 @@ describe('RadialGauge utils', () => {
});
describe('drawRadialArcPath', () => {
const defaultDims: RadialGaugeDimensions = Object.freeze({
const defaultDims = Object.freeze({
vizHeight: 220,
vizWidth: 220,
centerX: 100,
centerY: 100,
radius: 80,
@@ -297,7 +299,7 @@ describe('RadialGauge utils', () => {
scaleLabelsSpacing: 0,
scaleLabelsRadius: 0,
gaugeBottomY: 0,
});
}) satisfies RadialGaugeDimensions;
it.each([
{ description: 'quarter arc', startAngle: 0, endAngle: 90 },
@@ -324,11 +326,6 @@ describe('RadialGauge utils', () => {
expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims));
expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims));
});
it('should return empty string if inner radius collapses to zero or below', () => {
const smallRadiusDims = { ...defaultDims, radius: 5, barWidth: 20 };
expect(drawRadialArcPath(0, 180, smallRadiusDims)).toBe('');
});
});
});
@@ -341,7 +338,9 @@ describe('RadialGauge utils', () => {
describe('getOptimalSegmentCount', () => {
it('should adjust segment count based on dimensions and spacing', () => {
const dimensions: RadialGaugeDimensions = {
const dimensions = {
vizHeight: 220,
vizWidth: 220,
centerX: 100,
centerY: 100,
radius: 80,
@@ -355,7 +354,7 @@ describe('RadialGauge utils', () => {
scaleLabelsSpacing: 0,
scaleLabelsRadius: 0,
gaugeBottomY: 0,
};
} satisfies RadialGaugeDimensions;
expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8);
expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5);

View File

@@ -155,6 +155,8 @@ export function calculateDimensions(
}
return {
vizWidth: width,
vizHeight: height,
margin,
gaugeBottomY: centerY + belowCenterY,
radius: innerRadius,
@@ -185,7 +187,7 @@ export function drawRadialArcPath(
dimensions: RadialGaugeDimensions,
roundedBars?: boolean
): string {
const { radius, centerX, centerY, barWidth } = dimensions;
const { radius, centerX, centerY } = dimensions;
// For some reason a 100% full arc cannot be rendered
if (endAngle >= 360) {
@@ -197,66 +199,12 @@ export function drawRadialArcPath(
const largeArc = endAngle > 180 ? 1 : 0;
const outerR = radius + barWidth / 2;
const innerR = Math.max(0, radius - barWidth / 2);
if (innerR <= 0) {
return ''; // cannot draw arc with 0 inner radius
}
let x1 = centerX + radius * Math.cos(startRadians);
let y1 = centerY + radius * Math.sin(startRadians);
let x2 = centerX + radius * Math.cos(endRadians);
let y2 = centerY + radius * Math.sin(endRadians);
// get points for both an inner and outer arc. we draw
// the arc entirely with a path's fill instead of using stroke
// so that it can be used as a clip-path.
const ox1 = centerX + outerR * Math.cos(startRadians);
const oy1 = centerY + outerR * Math.sin(startRadians);
const ox2 = centerX + outerR * Math.cos(endRadians);
const oy2 = centerY + outerR * Math.sin(endRadians);
const ix1 = centerX + innerR * Math.cos(startRadians);
const iy1 = centerY + innerR * Math.sin(startRadians);
const ix2 = centerX + innerR * Math.cos(endRadians);
const iy2 = centerY + innerR * Math.sin(endRadians);
// calculate the cap width in case we're drawing rounded bars
const capR = barWidth / 2;
const pathParts = [
// start at outer start
'M',
ox1,
oy1,
// outer arc from start to end (clockwise)
'A',
outerR,
outerR,
0,
largeArc,
1,
ox2,
oy2,
];
if (roundedBars) {
// rounded end cap: small arc connecting outer end to inner end
pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2);
} else {
// straight line to inner end (square butt)
pathParts.push('L', ix2, iy2);
}
// inner arc from end back to start (counter-clockwise)
pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1);
if (roundedBars) {
// rounded start cap: small arc connecting inner start back to outer start
pathParts.push('A', capR, capR, 0, 0, 1, ox1, oy1);
} else {
// straight line back to outer start (square butt)
pathParts.push('L', ox1, oy1);
}
pathParts.push('Z');
return pathParts.join(' ');
return ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' ');
}
export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) {

View File

@@ -1108,12 +1108,18 @@ export function parseStyleJson(rawValue: unknown): CSSProperties | void {
}
}
// Safari 26 introduced rendering bugs which require us to disable several features of the table.
// Safari 26.0 introduced rendering bugs which require us to disable several features of the table.
// The bugs were later fixed in Safari 26.2.
export const IS_SAFARI_26 = (() => {
if (navigator == null) {
return false;
}
const userAgent = navigator.userAgent;
const safariVersionMatch = userAgent.match(/Version\/(\d+)\./);
return safariVersionMatch && parseInt(safariVersionMatch[1], 10) === 26;
const safariVersionMatch = userAgent.match(/Version\/(\d+)\.(\d+)/);
if (!safariVersionMatch) {
return false;
}
const majorVersion = +safariVersionMatch[1];
const minorVersion = +safariVersionMatch[2];
return majorVersion === 26 && minorVersion <= 1;
})();

View File

@@ -66,6 +66,6 @@ export interface UserView {
avatarUrl?: string;
};
/** Datetime string when the user was last active */
lastActiveAt: DateTimeInput;
lastActiveAt?: DateTimeInput;
}
```

View File

@@ -10,7 +10,7 @@ import { Tooltip } from '../Tooltip/Tooltip';
import { UserView } from './types';
export interface UserIconProps {
/** An object that contains the user's details and 'lastActiveAt' status */
/** An object that contains the user's details and an optional 'lastActiveAt' status */
userView: UserView;
/** A boolean value that determines whether the tooltip should be shown or not */
showTooltip?: boolean;
@@ -64,7 +64,8 @@ export const UserIcon = ({
showTooltip = true,
}: PropsWithChildren<UserIconProps>) => {
const { user, lastActiveAt } = userView;
const isActive = dateTime(lastActiveAt).diff(dateTime(), 'minutes', true) >= -15;
const hasActive = lastActiveAt !== undefined && lastActiveAt !== null;
const isActive = hasActive && dateTime(lastActiveAt).diff(dateTime(), 'minutes', true) >= -15;
const theme = useTheme2();
const styles = useMemo(() => getStyles(theme, isActive), [theme, isActive]);
const content = (
@@ -88,18 +89,20 @@ export const UserIcon = ({
const tooltip = (
<div className={styles.tooltipContainer}>
<div className={styles.tooltipName}>{user.name}</div>
<div className={styles.tooltipDate}>
{isActive ? (
<div className={styles.dotContainer}>
<span>
<Trans i18nKey="grafana-ui.user-icon.active-text">Active last 15m</Trans>
</span>
<span className={styles.dot}></span>
</div>
) : (
formatViewed(lastActiveAt)
)}
</div>
{hasActive && (
<div className={styles.tooltipDate}>
{isActive ? (
<div className={styles.dotContainer}>
<span>
<Trans i18nKey="grafana-ui.user-icon.active-text">Active last 15m</Trans>
</span>
<span className={styles.dot}></span>
</div>
) : (
formatViewed(lastActiveAt)
)}
</div>
)}
</div>
);

View File

@@ -60,6 +60,6 @@ export interface UserView {
avatarUrl?: string;
};
/** Datetime string when the user was last active */
lastActiveAt: DateTimeInput;
lastActiveAt?: DateTimeInput;
}
```

View File

@@ -9,7 +9,7 @@ import { UserIcon } from './UserIcon';
import { UserView } from './types';
export interface UsersIndicatorProps {
/** An object that contains the user's details and 'lastActiveAt' status */
/** An object that contains the user's details and an optional 'lastActiveAt' status */
users: UserView[];
/** A limit of how many user icons to show before collapsing them and showing a number of users instead */
limit?: number;
@@ -40,7 +40,7 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp
aria-label={t('grafana-ui.users-indicator.container-label', 'Users indicator container')}
>
{limitReached && (
<UserIcon onClick={onClick} userView={{ user: { name: 'Extra users' }, lastActiveAt: '' }} showTooltip={false}>
<UserIcon onClick={onClick} userView={{ user: { name: 'Extra users' } }} showTooltip={false}>
{tooManyUsers
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'...'

View File

@@ -8,5 +8,5 @@ export interface UserView {
avatarUrl?: string;
};
/** Datetime string when the user was last active */
lastActiveAt: DateTimeInput;
lastActiveAt?: DateTimeInput;
}

View File

@@ -226,8 +226,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Post("/api/user/email/start-verify", reqSignedInNoAnonymous, routing.Wrap(hs.StartEmailVerificaton))
}
//nolint:staticcheck // not yet migrated to OpenFeature
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabledGlobally(featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled {
r.Post("/api/login/passwordless/start", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), hs.StartPasswordless)
r.Post("/api/login/passwordless/authenticate", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPasswordless))
}

View File

@@ -410,8 +410,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
DisableSignoutMenu: hs.Cfg.DisableSignoutMenu,
}
//nolint:staticcheck // not yet migrated to OpenFeature
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled {
hasEnabledProviders := hs.samlEnabled() || hs.authnService.IsClientEnabled(authn.ClientLDAP)
if !hasEnabledProviders {

View File

@@ -0,0 +1,44 @@
package acimpl
import (
"context"
"time"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
const (
ossBasicRoleSeedLockName = "oss-ac-basic-role-seeder"
ossBasicRoleSeedTimeout = 2 * time.Minute
)
// refreshBasicRolePermissionsInDB ensures basic role permissions are fully derived from in-memory registrations
func (s *Service) refreshBasicRolePermissionsInDB(ctx context.Context, rolesSnapshot map[string][]accesscontrol.Permission) error {
if s.sql == nil || s.seeder == nil {
return nil
}
run := func(ctx context.Context) error {
desired := map[accesscontrol.SeedPermission]struct{}{}
for role, permissions := range rolesSnapshot {
for _, permission := range permissions {
desired[accesscontrol.SeedPermission{BuiltInRole: role, Action: permission.Action, Scope: permission.Scope}] = struct{}{}
}
}
s.seeder.SetDesiredPermissions(desired)
return s.seeder.Seed(ctx)
}
if s.serverLock == nil {
return run(ctx)
}
var err error
errLock := s.serverLock.LockExecuteAndRelease(ctx, ossBasicRoleSeedLockName, ossBasicRoleSeedTimeout, func(ctx context.Context) {
err = run(ctx)
})
if errLock != nil {
return errLock
}
return err
}

View File

@@ -0,0 +1,128 @@
package acimpl
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/database"
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestIntegration_OSSBasicRolePermissions_PersistAndRefreshOnRegisterFixedRoles(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
ctx := context.Background()
sql := db.InitTestDB(t)
store := database.ProvideService(sql)
svc := ProvideOSSService(
setting.NewCfg(),
store,
&resourcepermissions.FakeActionSetSvc{},
localcache.ProvideService(),
featuremgmt.WithFeatures(),
tracing.InitializeTracerForTest(),
sql,
permreg.ProvidePermissionRegistry(),
nil,
)
require.NoError(t, svc.DeclareFixedRoles(accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:test:role",
Permissions: []accesscontrol.Permission{
{Action: "test:read", Scope: ""},
},
},
Grants: []string{string(org.RoleViewer)},
}))
require.NoError(t, svc.RegisterFixedRoles(ctx))
// verify permission is persisted to DB for basic:viewer
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
var role accesscontrol.Role
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
require.NoError(t, err)
require.True(t, ok)
var count int64
count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count()
require.NoError(t, err)
require.Equal(t, int64(1), count)
return nil
}))
// ensure RegisterFixedRoles refreshes it back to defaults
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
ts := time.Now()
var role accesscontrol.Role
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
require.NoError(t, err)
require.True(t, ok)
_, err = sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID)
require.NoError(t, err)
p := accesscontrol.Permission{
RoleID: role.ID,
Action: "custom:keep",
Scope: "",
Created: ts,
Updated: ts,
}
p.Kind, p.Attribute, p.Identifier = accesscontrol.SplitScope(p.Scope)
_, err = sess.Table("permission").Insert(&p)
return err
}))
svc2 := ProvideOSSService(
setting.NewCfg(),
store,
&resourcepermissions.FakeActionSetSvc{},
localcache.ProvideService(),
featuremgmt.WithFeatures(),
tracing.InitializeTracerForTest(),
sql,
permreg.ProvidePermissionRegistry(),
nil,
)
require.NoError(t, svc2.DeclareFixedRoles(accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:test:role",
Permissions: []accesscontrol.Permission{
{Action: "test:read", Scope: ""},
},
},
Grants: []string{string(org.RoleViewer)},
}))
require.NoError(t, svc2.RegisterFixedRoles(ctx))
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
var role accesscontrol.Role
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
require.NoError(t, err)
require.True(t, ok)
var count int64
count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count()
require.NoError(t, err)
require.Equal(t, int64(1), count)
count, err = sess.Table("permission").Where("role_id = ? AND action = ?", role.ID, "custom:keep").Count()
require.NoError(t, err)
require.Equal(t, int64(0), count)
return nil
}))
}

View File

@@ -30,6 +30,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/migrator"
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
"github.com/grafana/grafana/pkg/services/accesscontrol/seeding"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@@ -96,6 +97,12 @@ func ProvideOSSService(
roles: accesscontrol.BuildBasicRoleDefinitions(),
store: store,
permRegistry: permRegistry,
sql: db,
serverLock: lock,
}
if backend, ok := store.(*database.AccessControlStore); ok {
s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend)
}
return s
@@ -112,8 +119,11 @@ type Service struct {
rolesMu sync.RWMutex
roles map[string]*accesscontrol.RoleDTO
store accesscontrol.Store
seeder *seeding.Seeder
permRegistry permreg.PermissionRegistry
isInitialized bool
sql db.DB
serverLock *serverlock.ServerLockService
}
func (s *Service) GetUsageStats(_ context.Context) map[string]any {
@@ -431,17 +441,54 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error {
defer span.End()
s.rolesMu.Lock()
defer s.rolesMu.Unlock()
registrations := s.registrations.Slice()
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
s.registerRolesLocked(registration)
return true
})
s.isInitialized = true
rolesSnapshot := s.getBasicRolePermissionsLocked()
s.rolesMu.Unlock()
if s.seeder != nil {
if err := s.seeder.SeedRoles(ctx, registrations); err != nil {
return err
}
if err := s.seeder.RemoveAbsentRoles(ctx); err != nil {
return err
}
}
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
return err
}
return nil
}
// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the
// current registration list, using the shared seeding registration logic.
//
// it has to be called while holding the roles lock
func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission {
desired := map[accesscontrol.SeedPermission]struct{}{}
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
seeding.AppendDesiredPermissions(desired, s.log, &registration.Role, registration.Grants, registration.Exclude)
return true
})
out := make(map[string][]accesscontrol.Permission)
for sp := range desired {
out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{
Action: sp.Action,
Scope: sp.Scope,
})
}
return out
}
// registerRolesLocked processes a single role registration and adds permissions to basic roles.
// Must be called with s.rolesMu locked.
func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) {
@@ -474,6 +521,7 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
defer span.End()
acRegs := pluginutils.ToRegistrations(ID, name, regs)
updatedBasicRoles := false
for _, r := range acRegs {
if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil {
return err
@@ -500,11 +548,23 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
if initialized {
s.rolesMu.Lock()
s.registerRolesLocked(r)
updatedBasicRoles = true
s.rolesMu.Unlock()
s.cache.Flush()
}
}
if updatedBasicRoles {
s.rolesMu.RLock()
rolesSnapshot := s.getBasicRolePermissionsLocked()
s.rolesMu.RUnlock()
// plugin roles can be declared after startup - keep DB in sync
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
return err
}
}
return nil
}

View File

@@ -167,99 +167,3 @@ func (c *LegacyAccessClient) Compile(ctx context.Context, id claims.AuthInfo, re
return check(fmt.Sprintf("%s:%s:%s", opts.Resource, opts.Attr, name))
}, claims.NoopZookie{}, nil
}
func (c *LegacyAccessClient) BatchCheck(ctx context.Context, id claims.AuthInfo, req claims.BatchCheckRequest) (claims.BatchCheckResponse, error) {
ident, ok := id.(identity.Requester)
if !ok {
return claims.BatchCheckResponse{}, errors.New("expected identity.Requester for legacy access control")
}
results := make(map[string]claims.BatchCheckResult, len(req.Checks))
// Cache checkers by action to avoid recreating them for each check
checkerCache := make(map[string]func(scopes ...string) bool)
for _, check := range req.Checks {
opts, ok := c.opts[check.Resource]
if !ok {
// For now w fallback to grafana admin if no options are found for resource.
if ident.GetIsGrafanaAdmin() {
results[check.CorrelationID] = claims.BatchCheckResult{Allowed: true}
} else {
results[check.CorrelationID] = claims.BatchCheckResult{Allowed: false}
}
continue
}
// Check if verb should be skipped
if opts.Unchecked[check.Verb] {
results[check.CorrelationID] = claims.BatchCheckResult{Allowed: true}
continue
}
action, ok := opts.Mapping[check.Verb]
if !ok {
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: false,
Error: fmt.Errorf("missing action for %s %s", check.Verb, check.Resource),
}
continue
}
// Get or create cached checker for this action
checker, ok := checkerCache[action]
if !ok {
checker = Checker(ident, action)
checkerCache[action] = checker
}
// Handle list and create verbs (no specific name)
// TODO: Should we allow list/create without name in a BatchCheck request?
if check.Name == "" {
if check.Verb == utils.VerbList || check.Verb == utils.VerbCreate {
// For list/create without name, check if user has the action at all
// TODO: Is this correct for Create?
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: len(ident.GetPermissions()[action]) > 0,
}
} else {
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: false,
Error: fmt.Errorf("unhandled authorization: %s %s", check.Group, check.Verb),
}
}
continue
}
// Check with resolver or direct scope
var allowed bool
if opts.Resolver != nil {
ns, err := claims.ParseNamespace(check.Namespace)
if err != nil {
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: false,
Error: err,
}
continue
}
scopes, err := opts.Resolver.Resolve(ctx, ns, check.Name)
if err != nil {
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: false,
Error: err,
}
continue
}
allowed = checker(scopes...)
} else {
allowed = checker(fmt.Sprintf("%s:%s:%s", opts.Resource, opts.Attr, check.Name))
}
results[check.CorrelationID] = claims.BatchCheckResult{Allowed: allowed}
}
return claims.BatchCheckResponse{
Results: results,
Zookie: claims.NoopZookie{},
}, nil
}

View File

@@ -136,220 +136,6 @@ func TestLegacyAccessClient_Check(t *testing.T) {
})
}
func TestLegacyAccessClient_BatchCheck(t *testing.T) {
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
t.Run("should return empty results for empty checks", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac)
res, err := a.BatchCheck(context.Background(), &identity.StaticRequester{}, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{},
})
assert.NoError(t, err)
assert.Empty(t, res.Results)
})
t.Run("should reject unknown resource for non-admin", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac)
res, err := a.BatchCheck(context.Background(), &identity.StaticRequester{}, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{CorrelationID: "check-1", Verb: "get", Resource: "unknown", Name: "1"},
},
})
assert.NoError(t, err)
assert.False(t, res.Results["check-1"].Allowed)
})
t.Run("should allow unknown resource for grafana admin", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac)
res, err := a.BatchCheck(context.Background(), &identity.StaticRequester{IsGrafanaAdmin: true}, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{CorrelationID: "check-1", Verb: "get", Resource: "unknown", Name: "1"},
},
})
assert.NoError(t, err)
assert.True(t, res.Results["check-1"].Allowed)
})
t.Run("should allow unchecked verbs", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Unchecked: map[string]bool{"get": true},
})
res, err := a.BatchCheck(context.Background(), &identity.StaticRequester{}, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{CorrelationID: "check-1", Verb: "get", Resource: "dashboards", Name: "1"},
},
})
assert.NoError(t, err)
assert.True(t, res.Results["check-1"].Allowed)
})
t.Run("should return error for missing action mapping", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Mapping: map[string]string{}, // Empty mapping
})
res, err := a.BatchCheck(context.Background(), &identity.StaticRequester{}, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{CorrelationID: "check-1", Verb: "get", Resource: "dashboards", Name: "1"},
},
})
assert.NoError(t, err)
assert.False(t, res.Results["check-1"].Allowed)
assert.Error(t, res.Results["check-1"].Error)
})
t.Run("should allow when user has correct scope", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Mapping: map[string]string{"get": "dashboards:read"},
})
ident := newIdent(accesscontrol.Permission{Action: "dashboards:read", Scope: "dashboards:uid:1"})
res, err := a.BatchCheck(context.Background(), ident, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{CorrelationID: "check-1", Verb: "get", Resource: "dashboards", Name: "1"},
},
})
assert.NoError(t, err)
assert.True(t, res.Results["check-1"].Allowed)
})
t.Run("should reject when user has wrong scope", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Mapping: map[string]string{"get": "dashboards:read"},
})
ident := newIdent(accesscontrol.Permission{Action: "dashboards:read", Scope: "dashboards:uid:2"})
res, err := a.BatchCheck(context.Background(), ident, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{CorrelationID: "check-1", Verb: "get", Resource: "dashboards", Name: "1"},
},
})
assert.NoError(t, err)
assert.False(t, res.Results["check-1"].Allowed)
})
t.Run("should handle list without name", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Mapping: map[string]string{"list": "dashboards:read"},
})
ident := newIdent(accesscontrol.Permission{Action: "dashboards:read", Scope: "dashboards:uid:*"})
res, err := a.BatchCheck(context.Background(), ident, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{CorrelationID: "check-1", Verb: "list", Resource: "dashboards", Name: ""},
},
})
assert.NoError(t, err)
assert.True(t, res.Results["check-1"].Allowed)
})
t.Run("should handle multiple checks with mixed results", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Mapping: map[string]string{"get": "dashboards:read"},
})
ident := newIdent(
accesscontrol.Permission{Action: "dashboards:read", Scope: "dashboards:uid:1"},
accesscontrol.Permission{Action: "dashboards:read", Scope: "dashboards:uid:3"},
)
res, err := a.BatchCheck(context.Background(), ident, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{CorrelationID: "check-1", Verb: "get", Resource: "dashboards", Name: "1"},
{CorrelationID: "check-2", Verb: "get", Resource: "dashboards", Name: "2"},
{CorrelationID: "check-3", Verb: "get", Resource: "dashboards", Name: "3"},
},
})
assert.NoError(t, err)
assert.True(t, res.Results["check-1"].Allowed)
assert.False(t, res.Results["check-2"].Allowed)
assert.True(t, res.Results["check-3"].Allowed)
})
t.Run("should use resolver when provided", func(t *testing.T) {
resolver := accesscontrol.ResourceResolverFunc(func(ctx context.Context, ns authlib.NamespaceInfo, name string) ([]string, error) {
// Resolve dashboard name to folder scope
return []string{"folders:uid:folder-a"}, nil
})
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Mapping: map[string]string{"get": "dashboards:read"},
Resolver: resolver,
})
ident := newIdent(accesscontrol.Permission{Action: "dashboards:read", Scope: "folders:uid:folder-a"})
res, err := a.BatchCheck(context.Background(), ident, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{CorrelationID: "check-1", Verb: "get", Resource: "dashboards", Name: "1", Namespace: "default"},
},
})
assert.NoError(t, err)
assert.True(t, res.Results["check-1"].Allowed)
})
t.Run("should cache checker by action", func(t *testing.T) {
a := accesscontrol.NewLegacyAccessClient(ac, accesscontrol.ResourceAuthorizerOptions{
Resource: "dashboards",
Attr: "uid",
Mapping: map[string]string{"get": "dashboards:read", "update": "dashboards:write"},
})
ident := newIdent(
accesscontrol.Permission{Action: "dashboards:read", Scope: "dashboards:uid:*"},
accesscontrol.Permission{Action: "dashboards:write", Scope: "dashboards:uid:1"},
)
res, err := a.BatchCheck(context.Background(), ident, authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{CorrelationID: "read-1", Verb: "get", Resource: "dashboards", Name: "1"},
{CorrelationID: "read-2", Verb: "get", Resource: "dashboards", Name: "2"},
{CorrelationID: "write-1", Verb: "update", Resource: "dashboards", Name: "1"},
{CorrelationID: "write-2", Verb: "update", Resource: "dashboards", Name: "2"},
},
})
assert.NoError(t, err)
// Read with wildcard scope should allow all
assert.True(t, res.Results["read-1"].Allowed)
assert.True(t, res.Results["read-2"].Allowed)
// Write only has scope for uid:1
assert.True(t, res.Results["write-1"].Allowed)
assert.False(t, res.Results["write-2"].Allowed)
})
}
func newIdent(permissions ...accesscontrol.Permission) *identity.StaticRequester {
pmap := map[string][]string{}
for _, p := range permissions {

View File

@@ -0,0 +1,623 @@
package database
import (
"context"
"strings"
"time"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/seeding"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/util/xorm/core"
)
const basicRolePermBatchSize = 500
// LoadRoles returns all fixed and plugin roles (global org) with permissions, indexed by role name.
func (s *AccessControlStore) LoadRoles(ctx context.Context) (map[string]*accesscontrol.RoleDTO, error) {
out := map[string]*accesscontrol.RoleDTO{}
err := s.sql.WithDbSession(ctx, func(sess *db.Session) error {
type roleRow struct {
ID int64 `xorm:"id"`
OrgID int64 `xorm:"org_id"`
Version int64 `xorm:"version"`
UID string `xorm:"uid"`
Name string `xorm:"name"`
DisplayName string `xorm:"display_name"`
Description string `xorm:"description"`
Group string `xorm:"group_name"`
Hidden bool `xorm:"hidden"`
Updated time.Time `xorm:"updated"`
Created time.Time `xorm:"created"`
}
roles := []roleRow{}
if err := sess.Table("role").
Where("org_id = ?", accesscontrol.GlobalOrgID).
Where("(name LIKE ? OR name LIKE ?)", accesscontrol.FixedRolePrefix+"%", accesscontrol.PluginRolePrefix+"%").
Find(&roles); err != nil {
return err
}
if len(roles) == 0 {
return nil
}
roleIDs := make([]any, 0, len(roles))
roleByID := make(map[int64]*accesscontrol.RoleDTO, len(roles))
for _, r := range roles {
dto := &accesscontrol.RoleDTO{
ID: r.ID,
OrgID: r.OrgID,
Version: r.Version,
UID: r.UID,
Name: r.Name,
DisplayName: r.DisplayName,
Description: r.Description,
Group: r.Group,
Hidden: r.Hidden,
Updated: r.Updated,
Created: r.Created,
}
out[dto.Name] = dto
roleByID[dto.ID] = dto
roleIDs = append(roleIDs, dto.ID)
}
type permRow struct {
RoleID int64 `xorm:"role_id"`
Action string `xorm:"action"`
Scope string `xorm:"scope"`
}
perms := []permRow{}
if err := sess.Table("permission").In("role_id", roleIDs...).Find(&perms); err != nil {
return err
}
for _, p := range perms {
dto := roleByID[p.RoleID]
if dto == nil {
continue
}
dto.Permissions = append(dto.Permissions, accesscontrol.Permission{
RoleID: p.RoleID,
Action: p.Action,
Scope: p.Scope,
})
}
return nil
})
return out, err
}
func (s *AccessControlStore) SetRole(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error {
if existingRole == nil {
return nil
}
return s.sql.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.Table("role").
Where("id = ? AND org_id = ?", existingRole.ID, accesscontrol.GlobalOrgID).
Update(map[string]any{
"display_name": wantedRole.DisplayName,
"description": wantedRole.Description,
"group_name": wantedRole.Group,
"hidden": wantedRole.Hidden,
"updated": time.Now(),
})
return err
})
}
func (s *AccessControlStore) SetPermissions(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error {
if existingRole == nil {
return nil
}
type key struct{ Action, Scope string }
existing := map[key]struct{}{}
for _, p := range existingRole.Permissions {
existing[key{p.Action, p.Scope}] = struct{}{}
}
desired := map[key]struct{}{}
for _, p := range wantedRole.Permissions {
desired[key{p.Action, p.Scope}] = struct{}{}
}
toAdd := make([]accesscontrol.Permission, 0)
toRemove := make([]accesscontrol.SeedPermission, 0)
now := time.Now()
for k := range desired {
if _, ok := existing[k]; ok {
continue
}
perm := accesscontrol.Permission{
RoleID: existingRole.ID,
Action: k.Action,
Scope: k.Scope,
Created: now,
Updated: now,
}
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
toAdd = append(toAdd, perm)
}
for k := range existing {
if _, ok := desired[k]; ok {
continue
}
toRemove = append(toRemove, accesscontrol.SeedPermission{Action: k.Action, Scope: k.Scope})
}
if len(toAdd) == 0 && len(toRemove) == 0 {
return nil
}
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
if len(toRemove) > 0 {
if err := DeleteRolePermissionTuples(sess, s.sql.GetDBType(), existingRole.ID, toRemove); err != nil {
return err
}
}
if len(toAdd) > 0 {
_, err := sess.InsertMulti(toAdd)
return err
}
return nil
})
}
func (s *AccessControlStore) CreateRole(ctx context.Context, role accesscontrol.RoleDTO) error {
now := time.Now()
uid := role.UID
if uid == "" && (strings.HasPrefix(role.Name, accesscontrol.FixedRolePrefix) || strings.HasPrefix(role.Name, accesscontrol.PluginRolePrefix)) {
uid = accesscontrol.PrefixedRoleUID(role.Name)
}
r := accesscontrol.Role{
OrgID: accesscontrol.GlobalOrgID,
Version: role.Version,
UID: uid,
Name: role.Name,
DisplayName: role.DisplayName,
Description: role.Description,
Group: role.Group,
Hidden: role.Hidden,
Created: now,
Updated: now,
}
if r.Version == 0 {
r.Version = 1
}
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
if _, err := sess.Insert(&r); err != nil {
return err
}
if len(role.Permissions) == 0 {
return nil
}
// De-duplicate permissions on (action, scope) to avoid unique constraint violations.
// Some role definitions may accidentally include duplicates.
type permKey struct{ Action, Scope string }
seen := make(map[permKey]struct{}, len(role.Permissions))
perms := make([]accesscontrol.Permission, 0, len(role.Permissions))
for _, p := range role.Permissions {
k := permKey{Action: p.Action, Scope: p.Scope}
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
perm := accesscontrol.Permission{
RoleID: r.ID,
Action: p.Action,
Scope: p.Scope,
Created: now,
Updated: now,
}
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
perms = append(perms, perm)
}
_, err := sess.InsertMulti(perms)
return err
})
}
func (s *AccessControlStore) DeleteRoles(ctx context.Context, roleUIDs []string) error {
if len(roleUIDs) == 0 {
return nil
}
uids := make([]any, 0, len(roleUIDs))
for _, uid := range roleUIDs {
uids = append(uids, uid)
}
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
type row struct {
ID int64 `xorm:"id"`
UID string `xorm:"uid"`
}
rows := []row{}
if err := sess.Table("role").
Where("org_id = ?", accesscontrol.GlobalOrgID).
In("uid", uids...).
Find(&rows); err != nil {
return err
}
if len(rows) == 0 {
return nil
}
roleIDs := make([]any, 0, len(rows))
for _, r := range rows {
roleIDs = append(roleIDs, r.ID)
}
// Remove permissions and assignments first to avoid FK issues (if enabled).
{
args := append([]any{"DELETE FROM permission WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
if _, err := sess.Exec(args...); err != nil {
return err
}
}
{
args := append([]any{"DELETE FROM user_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
if _, err := sess.Exec(args...); err != nil {
return err
}
}
{
args := append([]any{"DELETE FROM team_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
if _, err := sess.Exec(args...); err != nil {
return err
}
}
{
args := append([]any{"DELETE FROM builtin_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
if _, err := sess.Exec(args...); err != nil {
return err
}
}
args := append([]any{"DELETE FROM role WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")", accesscontrol.GlobalOrgID}, uids...)
_, err := sess.Exec(args...)
return err
})
}
// OSS basic-role permission refresh uses seeding.Seeder.Seed() with a desired set computed in memory.
// These methods implement the permission seeding part of seeding.SeedingBackend against the current permission table.
func (s *AccessControlStore) LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
var out map[accesscontrol.SeedPermission]struct{}
err := s.sql.WithDbSession(ctx, func(sess *db.Session) error {
rows, err := LoadBasicRoleSeedPermissions(sess)
if err != nil {
return err
}
out = make(map[accesscontrol.SeedPermission]struct{}, len(rows))
for _, r := range rows {
r.Origin = ""
out[r] = struct{}{}
}
return nil
})
return out, err
}
func (s *AccessControlStore) Apply(ctx context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error {
rolesToUpgrade := seeding.RolesToUpgrade(added, removed)
// Run the same OSS apply logic as ossBasicRoleSeedBackend.Apply inside a single transaction.
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
defs := accesscontrol.BuildBasicRoleDefinitions()
builtinToRoleID, err := EnsureBasicRolesExist(sess, defs)
if err != nil {
return err
}
backend := &ossBasicRoleSeedBackend{
sess: sess,
now: time.Now(),
builtinToRoleID: builtinToRoleID,
desired: nil,
dbType: s.sql.GetDBType(),
}
if err := backend.Apply(ctx, added, removed, updated); err != nil {
return err
}
return BumpBasicRoleVersions(sess, rolesToUpgrade)
})
}
// EnsureBasicRolesExist ensures the built-in basic roles exist in the role table and are bound in builtin_role.
// It returns a mapping from builtin role name (for example "Admin") to role ID.
func EnsureBasicRolesExist(sess *db.Session, defs map[string]*accesscontrol.RoleDTO) (map[string]int64, error) {
uidToBuiltin := make(map[string]string, len(defs))
uids := make([]any, 0, len(defs))
for builtin, def := range defs {
uidToBuiltin[def.UID] = builtin
uids = append(uids, def.UID)
}
type roleRow struct {
ID int64 `xorm:"id"`
UID string `xorm:"uid"`
}
rows := []roleRow{}
if err := sess.Table("role").
Where("org_id = ?", accesscontrol.GlobalOrgID).
In("uid", uids...).
Find(&rows); err != nil {
return nil, err
}
ts := time.Now()
builtinToRoleID := make(map[string]int64, len(defs))
for _, r := range rows {
br, ok := uidToBuiltin[r.UID]
if !ok {
continue
}
builtinToRoleID[br] = r.ID
}
for builtin, def := range defs {
roleID, ok := builtinToRoleID[builtin]
if !ok {
role := accesscontrol.Role{
OrgID: def.OrgID,
Version: def.Version,
UID: def.UID,
Name: def.Name,
DisplayName: def.DisplayName,
Description: def.Description,
Group: def.Group,
Hidden: def.Hidden,
Created: ts,
Updated: ts,
}
if _, err := sess.Insert(&role); err != nil {
return nil, err
}
roleID = role.ID
builtinToRoleID[builtin] = roleID
}
has, err := sess.Table("builtin_role").
Where("role_id = ? AND role = ? AND org_id = ?", roleID, builtin, accesscontrol.GlobalOrgID).
Exist()
if err != nil {
return nil, err
}
if !has {
br := accesscontrol.BuiltinRole{
RoleID: roleID,
OrgID: accesscontrol.GlobalOrgID,
Role: builtin,
Created: ts,
Updated: ts,
}
if _, err := sess.Table("builtin_role").Insert(&br); err != nil {
return nil, err
}
}
}
return builtinToRoleID, nil
}
// DeleteRolePermissionTuples deletes permissions for a single role by (action, scope) pairs.
//
// It uses a row-constructor IN clause where supported (MySQL, Postgres, SQLite) and falls back
// to a WHERE ... OR ... form for MSSQL.
func DeleteRolePermissionTuples(sess *db.Session, dbType core.DbType, roleID int64, perms []accesscontrol.SeedPermission) error {
if len(perms) == 0 {
return nil
}
if dbType == migrator.MSSQL {
// MSSQL doesn't support (action, scope) IN ((?,?),(?,?)) row constructors.
where := make([]string, 0, len(perms))
args := make([]any, 0, 1+len(perms)*2)
args = append(args, roleID)
for _, p := range perms {
where = append(where, "(action = ? AND scope = ?)")
args = append(args, p.Action, p.Scope)
}
_, err := sess.Exec(
append([]any{
"DELETE FROM permission WHERE role_id = ? AND (" + strings.Join(where, " OR ") + ")",
}, args...)...,
)
return err
}
args := make([]any, 0, 1+len(perms)*2)
args = append(args, roleID)
for _, p := range perms {
args = append(args, p.Action, p.Scope)
}
sql := "DELETE FROM permission WHERE role_id = ? AND (action, scope) IN (" +
strings.Repeat("(?, ?),", len(perms)-1) + "(?, ?))"
_, err := sess.Exec(append([]any{sql}, args...)...)
return err
}
type ossBasicRoleSeedBackend struct {
sess *db.Session
now time.Time
builtinToRoleID map[string]int64
desired map[accesscontrol.SeedPermission]struct{}
dbType core.DbType
}
func (b *ossBasicRoleSeedBackend) LoadPrevious(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
rows, err := LoadBasicRoleSeedPermissions(b.sess)
if err != nil {
return nil, err
}
out := make(map[accesscontrol.SeedPermission]struct{}, len(rows))
for _, r := range rows {
// Ensure the key matches what OSS seeding uses (Origin is always empty for basic role refresh).
r.Origin = ""
out[r] = struct{}{}
}
return out, nil
}
func (b *ossBasicRoleSeedBackend) LoadDesired(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
return b.desired, nil
}
func (b *ossBasicRoleSeedBackend) Apply(_ context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error {
// Delete removed permissions (this includes user-defined permissions that aren't in desired).
if len(removed) > 0 {
permsByRoleID := map[int64][]accesscontrol.SeedPermission{}
for _, p := range removed {
roleID, ok := b.builtinToRoleID[p.BuiltInRole]
if !ok {
continue
}
permsByRoleID[roleID] = append(permsByRoleID[roleID], p)
}
for roleID, perms := range permsByRoleID {
// Chunk to keep statement sizes and parameter counts bounded.
if err := batch(len(perms), basicRolePermBatchSize, func(start, end int) error {
return DeleteRolePermissionTuples(b.sess, b.dbType, roleID, perms[start:end])
}); err != nil {
return err
}
}
}
// Insert added permissions and updated-target permissions.
toInsertSeed := make([]accesscontrol.SeedPermission, 0, len(added)+len(updated))
toInsertSeed = append(toInsertSeed, added...)
for _, v := range updated {
toInsertSeed = append(toInsertSeed, v)
}
if len(toInsertSeed) == 0 {
return nil
}
// De-duplicate on (role_id, action, scope). This avoids unique constraint violations when:
// - the same permission appears in both added and updated
// - multiple plugin origins grant the same permission (Origin is not persisted in permission table)
type permKey struct {
RoleID int64
Action string
Scope string
}
seen := make(map[permKey]struct{}, len(toInsertSeed))
toInsert := make([]accesscontrol.Permission, 0, len(toInsertSeed))
for _, p := range toInsertSeed {
roleID, ok := b.builtinToRoleID[p.BuiltInRole]
if !ok {
continue
}
k := permKey{RoleID: roleID, Action: p.Action, Scope: p.Scope}
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
perm := accesscontrol.Permission{
RoleID: roleID,
Action: p.Action,
Scope: p.Scope,
Created: b.now,
Updated: b.now,
}
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
toInsert = append(toInsert, perm)
}
return batch(len(toInsert), basicRolePermBatchSize, func(start, end int) error {
// MySQL: ignore conflicts to make seeding idempotent under retries/concurrency.
// Conflicts can happen if the same permission already exists (unique on role_id, action, scope).
if b.dbType == migrator.MySQL {
args := make([]any, 0, (end-start)*8)
for i := start; i < end; i++ {
p := toInsert[i]
args = append(args, p.RoleID, p.Action, p.Scope, p.Kind, p.Attribute, p.Identifier, p.Updated, p.Created)
}
sql := append([]any{`INSERT IGNORE INTO permission (role_id, action, scope, kind, attribute, identifier, updated, created) VALUES ` +
strings.Repeat("(?, ?, ?, ?, ?, ?, ?, ?),", end-start-1) + "(?, ?, ?, ?, ?, ?, ?, ?)"}, args...)
_, err := b.sess.Exec(sql...)
return err
}
_, err := b.sess.InsertMulti(toInsert[start:end])
return err
})
}
func batch(count, size int, eachFn func(start, end int) error) error {
for i := 0; i < count; {
end := i + size
if end > count {
end = count
}
if err := eachFn(i, end); err != nil {
return err
}
i = end
}
return nil
}
// BumpBasicRoleVersions increments the role version for the given builtin basic roles (Viewer/Editor/Admin/Grafana Admin).
// Unknown role names are ignored.
func BumpBasicRoleVersions(sess *db.Session, basicRoles []string) error {
if len(basicRoles) == 0 {
return nil
}
defs := accesscontrol.BuildBasicRoleDefinitions()
uids := make([]any, 0, len(basicRoles))
for _, br := range basicRoles {
def, ok := defs[br]
if !ok {
continue
}
uids = append(uids, def.UID)
}
if len(uids) == 0 {
return nil
}
sql := "UPDATE role SET version = version + 1 WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")"
_, err := sess.Exec(append([]any{sql, accesscontrol.GlobalOrgID}, uids...)...)
return err
}
// LoadBasicRoleSeedPermissions returns the current (builtin_role, action, scope) permissions granted to basic roles.
// It sets Origin to empty.
func LoadBasicRoleSeedPermissions(sess *db.Session) ([]accesscontrol.SeedPermission, error) {
rows := []accesscontrol.SeedPermission{}
err := sess.SQL(
`SELECT role.display_name AS builtin_role, p.action, p.scope, '' AS origin
FROM role INNER JOIN permission AS p ON p.role_id = role.id
WHERE role.org_id = ? AND role.name LIKE 'basic:%'`,
accesscontrol.GlobalOrgID,
).Find(&rows)
return rows, err
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@@ -130,6 +131,9 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error {
// Reconcile schedules as job that will run and reconcile resources between
// legacy access control and zanzana.
func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
// Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions.
// This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service.
r.waitForBasicRolesSeeded(ctx)
r.reconcile(ctx)
// FIXME:
@@ -145,6 +149,57 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
}
}
func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool {
var count int64
// Basic role permissions are stored on "basic:%" roles in the global org (0).
// In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs.
type row struct {
Count int64 `xorm:"count"`
}
_ = r.store.WithDbSession(ctx, func(sess *db.Session) error {
var rr row
_, err := sess.SQL(
`SELECT COUNT(*) AS count
FROM role INNER JOIN permission AS p ON p.role_id = role.id
WHERE role.org_id = ? AND role.name LIKE ?`,
accesscontrol.GlobalOrgID,
accesscontrol.BasicRolePrefix+"%",
).Get(&rr)
if err != nil {
return err
}
count = rr.Count
return nil
})
return count > 0
}
func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) {
// Best-effort: don't block forever. If we can't observe basic roles, proceed anyway.
const (
maxWait = 15 * time.Second
interval = 1 * time.Second
)
deadline := time.NewTimer(maxWait)
defer deadline.Stop()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
if r.hasBasicRolePermissions(ctx) {
return
}
select {
case <-ctx.Done():
return
case <-deadline.C:
return
case <-ticker.C:
}
}
}
func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
run := func(ctx context.Context, namespace string) (ok bool) {
now := time.Now()

View File

@@ -0,0 +1,67 @@
package dualwrite
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
func TestZanzanaReconciler_hasBasicRolePermissions(t *testing.T) {
env := setupTestEnv(t)
r := &ZanzanaReconciler{
store: env.db,
}
ctx := context.Background()
require.False(t, r.hasBasicRolePermissions(ctx))
err := env.db.WithDbSession(ctx, func(sess *db.Session) error {
now := time.Now()
_, err := sess.Exec(
`INSERT INTO role (org_id, uid, name, display_name, group_name, description, hidden, version, created, updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
accesscontrol.GlobalOrgID,
"basic_viewer_uid_test",
accesscontrol.BasicRolePrefix+"viewer",
"Viewer",
"Basic",
"Viewer role",
false,
1,
now,
now,
)
if err != nil {
return err
}
var roleID int64
if _, err := sess.SQL(`SELECT id FROM role WHERE org_id = ? AND uid = ?`, accesscontrol.GlobalOrgID, "basic_viewer_uid_test").Get(&roleID); err != nil {
return err
}
_, err = sess.Exec(
`INSERT INTO permission (role_id, action, scope, kind, attribute, identifier, created, updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
roleID,
"dashboards:read",
"dashboards:*",
"",
"",
"",
now,
now,
)
return err
})
require.NoError(t, err)
require.True(t, r.hasBasicRolePermissions(ctx))
}

View File

@@ -1,6 +1,7 @@
package accesscontrol
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -594,3 +595,18 @@ type QueryWithOrg struct {
OrgId *int64 `json:"orgId"`
Global bool `json:"global"`
}
type SeedPermission struct {
BuiltInRole string `xorm:"builtin_role"`
Action string `xorm:"action"`
Scope string `xorm:"scope"`
Origin string `xorm:"origin"`
}
type RoleStore interface {
LoadRoles(ctx context.Context) (map[string]*RoleDTO, error)
SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
CreateRole(ctx context.Context, role RoleDTO) error
DeleteRoles(ctx context.Context, roleUIDs []string) error
}

View File

@@ -0,0 +1,452 @@
package seeding
import (
"context"
"fmt"
"regexp"
"slices"
"strings"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
)
type Seeder struct {
log log.Logger
roleStore accesscontrol.RoleStore
backend SeedingBackend
builtinsPermissions map[accesscontrol.SeedPermission]struct{}
seededFixedRoles map[string]bool
seededPluginRoles map[string]bool
seededPlugins map[string]bool
hasSeededAlready bool
}
// SeedingBackend provides the seed-set specific operations needed to seed.
type SeedingBackend interface {
// LoadPrevious returns the currently stored permissions for previously seeded roles.
LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error)
// Apply updates the database to match the desired permissions.
Apply(ctx context.Context,
added, removed []accesscontrol.SeedPermission,
updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission,
) error
}
func New(log log.Logger, roleStore accesscontrol.RoleStore, backend SeedingBackend) *Seeder {
return &Seeder{
log: log,
roleStore: roleStore,
backend: backend,
builtinsPermissions: map[accesscontrol.SeedPermission]struct{}{},
seededFixedRoles: map[string]bool{},
seededPluginRoles: map[string]bool{},
seededPlugins: map[string]bool{},
hasSeededAlready: false,
}
}
// SetDesiredPermissions replaces the in-memory desired permission set used by Seed().
func (s *Seeder) SetDesiredPermissions(desired map[accesscontrol.SeedPermission]struct{}) {
if desired == nil {
s.builtinsPermissions = map[accesscontrol.SeedPermission]struct{}{}
return
}
s.builtinsPermissions = desired
}
// Seed loads current and desired permissions, diffs them (including scope updates), applies changes, and bumps versions.
func (s *Seeder) Seed(ctx context.Context) error {
previous, err := s.backend.LoadPrevious(ctx)
if err != nil {
return err
}
// - Do not remove plugin permissions when the plugin didn't register this run (Origin set but not in seededPlugins).
// - Preserve legacy plugin app access permissions in the persisted seed set (these are granted by default).
if len(previous) > 0 {
filtered := make(map[accesscontrol.SeedPermission]struct{}, len(previous))
for p := range previous {
// Legacy plugin app access permissions (Origin set) are granted by default and managed outside seeding.
// Keep them out of the diff so seeding doesn't try to remove or "re-add" them on every run.
if p.Action == pluginaccesscontrol.ActionAppAccess && p.Origin != "" {
continue
}
if p.Origin != "" && !s.seededPlugins[p.Origin] {
continue
}
filtered[p] = struct{}{}
}
previous = filtered
}
added, removed, updated := s.permissionDiff(previous, s.builtinsPermissions)
if err := s.backend.Apply(ctx, added, removed, updated); err != nil {
return err
}
return nil
}
// SeedRoles populates the database with the roles and their assignments
// It will create roles that do not exist and update roles that have changed
// Do not use for provisioning. Validation is not enforced.
func (s *Seeder) SeedRoles(ctx context.Context, registrationList []accesscontrol.RoleRegistration) error {
roleMap, err := s.roleStore.LoadRoles(ctx)
if err != nil {
return err
}
missingRoles := make([]accesscontrol.RoleRegistration, 0, len(registrationList))
// Diff existing roles with the ones we want to seed.
// If a role is missing, we add it to the missingRoles list
for _, registration := range registrationList {
registration := registration
role, ok := roleMap[registration.Role.Name]
switch {
case registration.Role.IsFixed():
s.seededFixedRoles[registration.Role.Name] = true
case registration.Role.IsPlugin():
s.seededPluginRoles[registration.Role.Name] = true
// To be resilient to failed plugin loadings, we remember the plugins that have registered,
// later we'll ignore permissions and roles of other plugins
s.seededPlugins[pluginutils.PluginIDFromName(registration.Role.Name)] = true
}
s.rememberPermissionAssignments(&registration.Role, registration.Grants, registration.Exclude)
if !ok {
missingRoles = append(missingRoles, registration)
continue
}
if needsRoleUpdate(role, registration.Role) {
if err := s.roleStore.SetRole(ctx, role, registration.Role); err != nil {
return err
}
}
if needsPermissionsUpdate(role, registration.Role) {
if err := s.roleStore.SetPermissions(ctx, role, registration.Role); err != nil {
return err
}
}
}
for _, registration := range missingRoles {
if err := s.roleStore.CreateRole(ctx, registration.Role); err != nil {
return err
}
}
return nil
}
func needsPermissionsUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool {
if existingRole == nil {
return true
}
if len(existingRole.Permissions) != len(wantedRole.Permissions) {
return true
}
for _, p := range wantedRole.Permissions {
found := false
for _, ep := range existingRole.Permissions {
if ep.Action == p.Action && ep.Scope == p.Scope {
found = true
break
}
}
if !found {
return true
}
}
return false
}
func needsRoleUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool {
if existingRole == nil {
return true
}
if existingRole.Name != wantedRole.Name {
return false
}
if existingRole.DisplayName != wantedRole.DisplayName {
return true
}
if existingRole.Description != wantedRole.Description {
return true
}
if existingRole.Group != wantedRole.Group {
return true
}
if existingRole.Hidden != wantedRole.Hidden {
return true
}
return false
}
// Deprecated: SeedRole is deprecated and should not be used.
// SeedRoles only does boot up seeding and should not be used for runtime seeding.
func (s *Seeder) SeedRole(ctx context.Context, role accesscontrol.RoleDTO, builtInRoles []string) error {
addedPermissions := make(map[string]struct{}, len(role.Permissions))
permissions := make([]accesscontrol.Permission, 0, len(role.Permissions))
for _, p := range role.Permissions {
key := fmt.Sprintf("%s:%s", p.Action, p.Scope)
if _, ok := addedPermissions[key]; !ok {
addedPermissions[key] = struct{}{}
permissions = append(permissions, accesscontrol.Permission{Action: p.Action, Scope: p.Scope})
}
}
wantedRole := accesscontrol.RoleDTO{
OrgID: accesscontrol.GlobalOrgID,
Version: role.Version,
UID: role.UID,
Name: role.Name,
DisplayName: role.DisplayName,
Description: role.Description,
Group: role.Group,
Permissions: permissions,
Hidden: role.Hidden,
}
roleMap, err := s.roleStore.LoadRoles(ctx)
if err != nil {
return err
}
existingRole := roleMap[wantedRole.Name]
if existingRole == nil {
if err := s.roleStore.CreateRole(ctx, wantedRole); err != nil {
return err
}
} else {
if needsRoleUpdate(existingRole, wantedRole) {
if err := s.roleStore.SetRole(ctx, existingRole, wantedRole); err != nil {
return err
}
}
if needsPermissionsUpdate(existingRole, wantedRole) {
if err := s.roleStore.SetPermissions(ctx, existingRole, wantedRole); err != nil {
return err
}
}
}
// Remember seeded roles
if wantedRole.IsFixed() {
s.seededFixedRoles[wantedRole.Name] = true
}
isPluginRole := wantedRole.IsPlugin()
if isPluginRole {
s.seededPluginRoles[wantedRole.Name] = true
// To be resilient to failed plugin loadings, we remember the plugins that have registered,
// later we'll ignore permissions and roles of other plugins
s.seededPlugins[pluginutils.PluginIDFromName(role.Name)] = true
}
s.rememberPermissionAssignments(&wantedRole, builtInRoles, []string{})
return nil
}
func (s *Seeder) rememberPermissionAssignments(role *accesscontrol.RoleDTO, builtInRoles []string, excludedRoles []string) {
AppendDesiredPermissions(s.builtinsPermissions, s.log, role, builtInRoles, excludedRoles)
}
// AppendDesiredPermissions accumulates permissions from a role registration onto basic roles (Viewer/Editor/Admin/Grafana Admin).
// - It expands parents via accesscontrol.BuiltInRolesWithParents.
// - It can optionally ignore plugin app access permissions (which are granted by default).
func AppendDesiredPermissions(
out map[accesscontrol.SeedPermission]struct{},
logger log.Logger,
role *accesscontrol.RoleDTO,
builtInRoles []string,
excludedRoles []string,
) {
if out == nil || role == nil {
return
}
for builtInRole := range accesscontrol.BuiltInRolesWithParents(builtInRoles) {
// Skip excluded grants
if slices.Contains(excludedRoles, builtInRole) {
continue
}
for _, perm := range role.Permissions {
if role.IsPlugin() && perm.Action == pluginaccesscontrol.ActionAppAccess {
logger.Debug("Role is attempting to grant access permission, but this permission is already granted by default and will be ignored",
"role", role.Name, "permission", perm.Action, "scope", perm.Scope)
continue
}
sp := accesscontrol.SeedPermission{
BuiltInRole: builtInRole,
Action: perm.Action,
Scope: perm.Scope,
}
if role.IsPlugin() {
sp.Origin = pluginutils.PluginIDFromName(role.Name)
}
out[sp] = struct{}{}
}
}
}
// permissionDiff returns:
// - added: present in desired permissions, not in previous permissions
// - removed: present in previous permissions, not in desired permissions
// - updated: same role + action, but scope changed
func (s *Seeder) permissionDiff(previous, desired map[accesscontrol.SeedPermission]struct{}) (added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) {
addedSet := make(map[accesscontrol.SeedPermission]struct{}, 0)
for n := range desired {
if _, already := previous[n]; !already {
addedSet[n] = struct{}{}
} else {
delete(previous, n)
}
}
// Check if any of the new permissions is actually an old permission with an updated scope
updated = make(map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, 0)
for n := range addedSet {
for p := range previous {
if n.BuiltInRole == p.BuiltInRole && n.Action == p.Action {
updated[p] = n
delete(addedSet, n)
}
}
}
for p := range addedSet {
added = append(added, p)
}
for p := range previous {
if p.Action == pluginaccesscontrol.ActionAppAccess &&
p.Scope != pluginaccesscontrol.ScopeProvider.GetResourceAllScope() {
// Allows backward compatibility with plugins that have been seeded before the grant ignore rule was added
s.log.Info("This permission already existed so it will not be removed",
"role", p.BuiltInRole, "permission", p.Action, "scope", p.Scope)
continue
}
removed = append(removed, p)
}
return added, removed, updated
}
func (s *Seeder) ClearBasicRolesPluginPermissions(ID string) {
removable := []accesscontrol.SeedPermission{}
for key := range s.builtinsPermissions {
if matchPermissionByPluginID(key, ID) {
removable = append(removable, key)
}
}
for _, perm := range removable {
delete(s.builtinsPermissions, perm)
}
}
func matchPermissionByPluginID(perm accesscontrol.SeedPermission, pluginID string) bool {
if perm.Origin != pluginID {
return false
}
actionTemplate := regexp.MustCompile(fmt.Sprintf("%s[.:]", pluginID))
scopeTemplate := fmt.Sprintf(":%s", pluginID)
return actionTemplate.MatchString(perm.Action) || strings.HasSuffix(perm.Scope, scopeTemplate)
}
// RolesToUpgrade returns the unique basic roles that should have their version incremented.
func RolesToUpgrade(added, removed []accesscontrol.SeedPermission) []string {
set := map[string]struct{}{}
for _, p := range added {
set[p.BuiltInRole] = struct{}{}
}
for _, p := range removed {
set[p.BuiltInRole] = struct{}{}
}
out := make([]string, 0, len(set))
for r := range set {
out = append(out, r)
}
return out
}
func (s *Seeder) ClearPluginRoles(ID string) {
expectedPrefix := fmt.Sprintf("%s%s:", accesscontrol.PluginRolePrefix, ID)
for roleName := range s.seededPluginRoles {
if strings.HasPrefix(roleName, expectedPrefix) {
delete(s.seededPluginRoles, roleName)
}
}
}
func (s *Seeder) MarkSeededAlready() {
s.hasSeededAlready = true
}
func (s *Seeder) HasSeededAlready() bool {
return s.hasSeededAlready
}
func (s *Seeder) RemoveAbsentRoles(ctx context.Context) error {
roleMap, errGet := s.roleStore.LoadRoles(ctx)
if errGet != nil {
s.log.Error("failed to get fixed roles from store", "err", errGet)
return errGet
}
toRemove := []string{}
for _, r := range roleMap {
if r == nil {
continue
}
if r.IsFixed() {
if !s.seededFixedRoles[r.Name] {
s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name)
toRemove = append(toRemove, r.UID)
}
continue
}
if r.IsPlugin() {
if !s.seededPlugins[pluginutils.PluginIDFromName(r.Name)] {
// To be resilient to failed plugin loadings
// ignore stored roles related to plugins that have not registered this time
s.log.Debug("plugin role has not been registered on this run skipping its removal", "role", r.Name)
continue
}
if !s.seededPluginRoles[r.Name] {
s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name)
toRemove = append(toRemove, r.UID)
}
}
}
if errDelete := s.roleStore.DeleteRoles(ctx, toRemove); errDelete != nil {
s.log.Error("failed to delete absent fixed and plugin roles", "err", errDelete)
return errDelete
}
return nil
}

View File

@@ -1,8 +1,6 @@
package authnimpl
import (
"context"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -83,8 +81,7 @@ func ProvideRegistration(
}
}
//nolint:staticcheck // not yet migrated to OpenFeature
if cfg.PasswordlessMagicLinkAuth.Enabled && features.IsEnabled(context.Background(), featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
if cfg.PasswordlessMagicLinkAuth.Enabled {
hasEnabledProviders := authnSvc.IsClientEnabled(authn.ClientSAML) || authnSvc.IsClientEnabled(authn.ClientLDAP)
if !hasEnabledProviders {
oauthInfos := socialService.GetOAuthInfoProviders()

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@ import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
service AuthzExtentionService {
rpc BatchCheck(BatchCheckRequest) returns (BatchCheckResponse);
rpc Read(ReadRequest) returns (ReadResponse);
rpc Write(WriteRequest) returns (WriteResponse);
@@ -229,6 +231,29 @@ message WriteRequest {
message WriteResponse {}
message BatchCheckRequest {
string subject = 1;
string namespace = 2;
repeated BatchCheckItem items = 3;
}
message BatchCheckItem {
string verb = 1;
string group = 2;
string resource = 3;
string name = 4;
string subresource = 5;
string folder = 6;
}
message BatchCheckResponse {
map<string, BatchCheckGroupResource> groups = 1;
}
message BatchCheckGroupResource {
map<string, bool> items = 1;
}
message QueryRequest {
string namespace = 1;
QueryOperation operation = 2;

View File

@@ -19,16 +19,18 @@ import (
const _ = grpc.SupportPackageIsVersion8
const (
AuthzExtentionService_Read_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Read"
AuthzExtentionService_Write_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Write"
AuthzExtentionService_Mutate_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Mutate"
AuthzExtentionService_Query_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Query"
AuthzExtentionService_BatchCheck_FullMethodName = "/authz.extention.v1.AuthzExtentionService/BatchCheck"
AuthzExtentionService_Read_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Read"
AuthzExtentionService_Write_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Write"
AuthzExtentionService_Mutate_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Mutate"
AuthzExtentionService_Query_FullMethodName = "/authz.extention.v1.AuthzExtentionService/Query"
)
// AuthzExtentionServiceClient is the client API for AuthzExtentionService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type AuthzExtentionServiceClient interface {
BatchCheck(ctx context.Context, in *BatchCheckRequest, opts ...grpc.CallOption) (*BatchCheckResponse, error)
Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error)
Write(ctx context.Context, in *WriteRequest, opts ...grpc.CallOption) (*WriteResponse, error)
Mutate(ctx context.Context, in *MutateRequest, opts ...grpc.CallOption) (*MutateResponse, error)
@@ -43,6 +45,16 @@ func NewAuthzExtentionServiceClient(cc grpc.ClientConnInterface) AuthzExtentionS
return &authzExtentionServiceClient{cc}
}
func (c *authzExtentionServiceClient) BatchCheck(ctx context.Context, in *BatchCheckRequest, opts ...grpc.CallOption) (*BatchCheckResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchCheckResponse)
err := c.cc.Invoke(ctx, AuthzExtentionService_BatchCheck_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authzExtentionServiceClient) Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ReadResponse)
@@ -87,6 +99,7 @@ func (c *authzExtentionServiceClient) Query(ctx context.Context, in *QueryReques
// All implementations should embed UnimplementedAuthzExtentionServiceServer
// for forward compatibility
type AuthzExtentionServiceServer interface {
BatchCheck(context.Context, *BatchCheckRequest) (*BatchCheckResponse, error)
Read(context.Context, *ReadRequest) (*ReadResponse, error)
Write(context.Context, *WriteRequest) (*WriteResponse, error)
Mutate(context.Context, *MutateRequest) (*MutateResponse, error)
@@ -97,6 +110,9 @@ type AuthzExtentionServiceServer interface {
type UnimplementedAuthzExtentionServiceServer struct {
}
func (UnimplementedAuthzExtentionServiceServer) BatchCheck(context.Context, *BatchCheckRequest) (*BatchCheckResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchCheck not implemented")
}
func (UnimplementedAuthzExtentionServiceServer) Read(context.Context, *ReadRequest) (*ReadResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Read not implemented")
}
@@ -121,6 +137,24 @@ func RegisterAuthzExtentionServiceServer(s grpc.ServiceRegistrar, srv AuthzExten
s.RegisterService(&AuthzExtentionService_ServiceDesc, srv)
}
func _AuthzExtentionService_BatchCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchCheckRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthzExtentionServiceServer).BatchCheck(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthzExtentionService_BatchCheck_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthzExtentionServiceServer).BatchCheck(ctx, req.(*BatchCheckRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthzExtentionService_Read_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReadRequest)
if err := dec(in); err != nil {
@@ -200,6 +234,10 @@ var AuthzExtentionService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "authz.extention.v1.AuthzExtentionService",
HandlerType: (*AuthzExtentionServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "BatchCheck",
Handler: _AuthzExtentionService_BatchCheck_Handler,
},
{
MethodName: "Read",
Handler: _AuthzExtentionService_Read_Handler,

View File

@@ -186,150 +186,6 @@ func (s *Service) Check(ctx context.Context, req *authzv1.CheckRequest) (*authzv
return &authzv1.CheckResponse{Allowed: allowed}, nil
}
// BatchCheck implements authzv1.AuthzServiceServer.BatchCheck
// This performs multiple access checks in a single request with optimized batching.
// 1. Validates the subject once
// 2. Groups checks by (namespace, action) to load permissions once per group
// 3. Reuses the folder tree across checks
func (s *Service) BatchCheck(ctx context.Context, req *authzv1.BatchCheckRequest) (*authzv1.BatchCheckResponse, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.BatchCheck")
defer span.End()
checks := req.GetChecks()
span.SetAttributes(attribute.Int("check_count", len(checks)))
ctxLogger := s.logger.FromContext(ctx).New(
"subject", req.GetSubject(),
"check_count", len(checks),
)
defer func(start time.Time) {
ctxLogger.Debug("BatchCheck execution time", "duration", time.Since(start).Milliseconds())
}(time.Now())
// Early check for auth info - required for namespace validation
if _, has := types.AuthInfoFrom(ctx); !has {
return nil, status.Error(codes.Internal, "could not get auth info from context")
}
if len(checks) == 0 {
return &authzv1.BatchCheckResponse{
Results: make(map[string]*authzv1.BatchCheckResult),
Zookie: &authzv1.Zookie{Timestamp: time.Now().UnixMilli()},
}, nil
}
// Validate subject once for all checks
userUID, idType, err := s.validateSubject(ctx, req.GetSubject())
if err != nil {
ctxLogger.Error("invalid subject", "error", err)
// Return all checks as denied with the same error
results := make(map[string]*authzv1.BatchCheckResult, len(checks))
for _, item := range checks {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{
Allowed: false,
Error: err.Error(),
}
}
return &authzv1.BatchCheckResponse{Results: results, Zookie: &authzv1.Zookie{Timestamp: time.Now().UnixMilli()}}, nil
}
results := make(map[string]*authzv1.BatchCheckResult, len(checks))
// Group checks by (namespace, action) to batch permission lookups
type checkGroup struct {
namespace types.NamespaceInfo
action string
actionSets []string
items []*authzv1.BatchCheckItem
checkReqs []*checkRequest
}
groups := make(map[string]*checkGroup)
// First pass: validate and group checks
for _, item := range checks {
ns, err := validateNamespace(ctx, item.GetNamespace())
if err != nil {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: false, Error: err.Error()}
continue
}
action, actionSets, err := s.validateAction(ctx, item.GetGroup(), item.GetResource(), item.GetVerb())
if err != nil {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: false, Error: err.Error()}
continue
}
// Create the internal check request
checkReq := &checkRequest{
Namespace: ns,
UserUID: userUID,
IdentityType: idType,
Action: action,
ActionSets: actionSets,
Group: item.GetGroup(),
Resource: item.GetResource(),
Verb: item.GetVerb(),
Name: item.GetName(),
ParentFolder: item.GetFolder(),
}
// Group by namespace + action
groupKey := ns.Value + ":" + action
if g, ok := groups[groupKey]; ok {
g.items = append(g.items, item)
g.checkReqs = append(g.checkReqs, checkReq)
} else {
groups[groupKey] = &checkGroup{
namespace: ns,
action: action,
actionSets: actionSets,
items: []*authzv1.BatchCheckItem{item},
checkReqs: []*checkRequest{checkReq},
}
}
}
// Second pass: process each group with shared permissions
for _, group := range groups {
// Set namespace in context for this group (required by store methods)
groupCtx := request.WithNamespace(ctx, group.namespace.Value)
// Try to get cached permissions first, then fall back to store
permissions, err := s.getCachedIdentityPermissions(groupCtx, group.namespace, idType, userUID, group.action)
if err != nil {
// Cache miss - fetch from store
permissions, err = s.getIdentityPermissions(groupCtx, group.namespace, idType, userUID, group.action, group.actionSets)
if err != nil {
ctxLogger.Error("could not get permissions", "namespace", group.namespace.Value, "action", group.action, "error", err)
for _, item := range group.items {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: false, Error: err.Error()}
}
continue
}
}
// Check each item in the group using the shared permissions
for i, item := range group.items {
checkReq := group.checkReqs[i]
allowed, err := s.checkPermission(groupCtx, permissions, checkReq)
if err != nil {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: false, Error: err.Error()}
continue
}
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: allowed}
}
}
span.SetAttributes(attribute.Int("groups_processed", len(groups)))
return &authzv1.BatchCheckResponse{
Results: results,
Zookie: &authzv1.Zookie{Timestamp: time.Now().UnixMilli()},
}, nil
}
func (s *Service) List(ctx context.Context, req *authzv1.ListRequest) (*authzv1.ListResponse, error) {
ctx, span := s.tracer.Start(ctx, "authz_direct_db.service.List")
defer span.End()

View File

@@ -1829,613 +1829,6 @@ func TestService_CacheList(t *testing.T) {
})
}
func TestService_BatchCheck(t *testing.T) {
callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
Claims: jwt.Claims{
Subject: types.NewTypeID(types.TypeAccessPolicy, "some-service"),
Audience: []string{"authzservice"},
},
Rest: authn.AccessTokenClaims{Namespace: "org-12"},
})
t.Run("Require auth info", func(t *testing.T) {
s := setupService()
ctx := context.Background()
_, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "could not get auth info")
})
t.Run("Empty checks returns empty results", func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: []*authzv1.BatchCheckItem{},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Empty(t, resp.Results)
})
type batchCheckTestCase struct {
name string
checks []*authzv1.BatchCheckItem
permissions []accesscontrol.Permission
folders []store.Folder
expectedResults map[string]bool
expectedErrors map[string]bool // true if error expected for this correlation ID
expectGlobalError bool
}
t.Run("Request validation", func(t *testing.T) {
testCases := []batchCheckTestCase{
{
name: "should return error for invalid namespace",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
expectedResults: map[string]bool{"check1": false},
expectedErrors: map[string]bool{"check1": true},
},
{
name: "should return error for namespace mismatch",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-13",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
expectedResults: map[string]bool{"check1": false},
expectedErrors: map[string]bool{"check1": true},
},
{
name: "should return error for unknown group",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "unknown.grafana.app",
Resource: "unknown",
Verb: "get",
Name: "u1",
CorrelationId: "check1",
},
},
expectedResults: map[string]bool{"check1": false},
expectedErrors: map[string]bool{"check1": true},
},
{
name: "should return error for unknown verb",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "unknown",
Name: "dash1",
CorrelationId: "check1",
},
},
expectedResults: map[string]bool{"check1": false},
expectedErrors: map[string]bool{"check1": true},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
store := &fakeStore{
userID: userID,
userPermissions: tc.permissions,
}
s.store = store
s.permissionStore = store
s.identityStore = &fakeIdentityStore{}
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: tc.checks,
})
require.NoError(t, err)
require.NotNil(t, resp)
for corrID, expectedAllowed := range tc.expectedResults {
result, ok := resp.Results[corrID]
require.True(t, ok, "result for %s not found", corrID)
require.Equal(t, expectedAllowed, result.Allowed, "unexpected allowed for %s", corrID)
if tc.expectedErrors[corrID] {
require.NotEmpty(t, result.Error, "expected error for %s", corrID)
}
}
})
}
})
t.Run("User permission checks", func(t *testing.T) {
testCases := []batchCheckTestCase{
{
name: "should allow user with permission on single resource",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash1"}},
expectedResults: map[string]bool{"check1": true},
},
{
name: "should deny user without permission",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash2"}},
expectedResults: map[string]bool{"check1": false},
},
{
name: "should handle multiple checks with mixed results",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash2",
CorrelationId: "check2",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash3",
CorrelationId: "check3",
},
},
permissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "dashboards:read", Scope: "dashboards:uid:dash3"},
},
expectedResults: map[string]bool{
"check1": true,
"check2": false,
"check3": true,
},
},
{
name: "should handle wildcard permission",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash2",
CorrelationId: "check2",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "*", Kind: "*"}},
expectedResults: map[string]bool{"check1": true, "check2": true},
},
{
name: "should handle folder inheritance",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
Folder: "child",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "folders:uid:parent", Kind: "folders", Attribute: "uid", Identifier: "parent"},
},
folders: []store.Folder{
{UID: "parent"},
{UID: "child", ParentUID: strPtr("parent")},
},
expectedResults: map[string]bool{"check1": true},
},
{
name: "should handle action sets",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:admin", Scope: "dashboards:uid:dash1"}},
expectedResults: map[string]bool{"check1": true},
},
{
name: "should handle checks across different resources",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
{
Namespace: "org-12",
Group: "folder.grafana.app",
Resource: "folders",
Verb: "get",
Name: "fold1",
CorrelationId: "check2",
},
},
permissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "folders:read", Scope: "folders:uid:fold1"},
},
expectedResults: map[string]bool{"check1": true, "check2": true},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
store := &fakeStore{
userID: userID,
userPermissions: tc.permissions,
folders: tc.folders,
}
s.store = store
s.permissionStore = store
s.folderStore = store
s.identityStore = &fakeIdentityStore{}
if tc.folders != nil {
s.folderCache.Set(ctx, folderCacheKey("org-12"), newFolderTree(tc.folders))
}
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: tc.checks,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Len(t, resp.Results, len(tc.expectedResults))
for corrID, expectedAllowed := range tc.expectedResults {
result, ok := resp.Results[corrID]
require.True(t, ok, "result for %s not found", corrID)
require.Equal(t, expectedAllowed, result.Allowed, "unexpected allowed for %s", corrID)
}
})
}
})
t.Run("Anonymous permission checks", func(t *testing.T) {
testCases := []batchCheckTestCase{
{
name: "should allow anonymous with permission",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash1"}},
expectedResults: map[string]bool{"check1": true},
},
{
name: "should deny anonymous without permission",
checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
permissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash2"}},
expectedResults: map[string]bool{"check1": false},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
store := &fakeStore{userPermissions: tc.permissions}
s.store = store
s.permissionStore = store
s.identityStore = &fakeIdentityStore{}
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "anonymous:0",
Checks: tc.checks,
})
require.NoError(t, err)
require.NotNil(t, resp)
for corrID, expectedAllowed := range tc.expectedResults {
result, ok := resp.Results[corrID]
require.True(t, ok, "result for %s not found", corrID)
require.Equal(t, expectedAllowed, result.Allowed, "unexpected allowed for %s", corrID)
}
})
}
})
t.Run("Rendering permission checks", func(t *testing.T) {
t.Run("should allow rendering with permission", func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "render:0",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.True(t, resp.Results["check1"].Allowed)
})
t.Run("should deny rendering access to another app resources", func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "render:0",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "another.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.False(t, resp.Results["check1"].Allowed)
require.NotEmpty(t, resp.Results["check1"].Error)
})
})
t.Run("Invalid subject returns errors for all checks", func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
store := &fakeStore{}
s.store = store
s.permissionStore = store
s.identityStore = &fakeIdentityStore{}
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "invalid:12",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash2",
CorrelationId: "check2",
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.Len(t, resp.Results, 2)
for _, result := range resp.Results {
require.False(t, result.Allowed)
require.NotEmpty(t, result.Error)
}
})
t.Run("Grouping optimization", func(t *testing.T) {
t.Run("should batch permission lookups for same action", func(t *testing.T) {
s := setupService()
ctx := types.WithAuthInfo(context.Background(), callingService)
userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
fStore := &fakeStore{
userID: userID,
userPermissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "dashboards:read", Scope: "dashboards:uid:dash2"},
},
}
s.store = fStore
s.permissionStore = fStore
s.identityStore = &fakeIdentityStore{}
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash2",
CorrelationId: "check2",
},
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash3",
CorrelationId: "check3",
},
},
})
require.NoError(t, err)
require.NotNil(t, resp)
require.True(t, resp.Results["check1"].Allowed)
require.True(t, resp.Results["check2"].Allowed)
require.False(t, resp.Results["check3"].Allowed)
// Verify permissions were fetched only once (1 call for userID + 1 call for basicRole + 1 call for permissions)
require.Equal(t, 3, fStore.calls)
})
})
}
func TestService_CacheBatchCheck(t *testing.T) {
callingService := authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{
Claims: jwt.Claims{
Subject: types.NewTypeID(types.TypeAccessPolicy, "some-service"),
Audience: []string{"authzservice"},
},
Rest: authn.AccessTokenClaims{Namespace: "org-12"},
})
ctx := types.WithAuthInfo(context.Background(), callingService)
userID := &store.UserIdentifiers{UID: "test-uid", ID: 1}
t.Run("Allow based on cached permissions", func(t *testing.T) {
s := setupService()
s.idCache.Set(ctx, userIdentifierCacheKey("org-12", "test-uid"), *userID)
s.permCache.Set(ctx, userPermCacheKey("org-12", "test-uid", "dashboards:read"), map[string]bool{"dashboards:uid:dash1": true})
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash1",
CorrelationId: "check1",
},
},
})
require.NoError(t, err)
require.True(t, resp.Results["check1"].Allowed)
})
t.Run("Fallback to database on cache miss", func(t *testing.T) {
s := setupService()
// Populate database but not cache
fStore := &fakeStore{
userID: userID,
userPermissions: []accesscontrol.Permission{{Action: "dashboards:read", Scope: "dashboards:uid:dash2"}},
}
s.store = fStore
s.permissionStore = fStore
s.identityStore = &fakeIdentityStore{}
s.idCache.Set(ctx, userIdentifierCacheKey("org-12", "test-uid"), *userID)
resp, err := s.BatchCheck(ctx, &authzv1.BatchCheckRequest{
Subject: "user:test-uid",
Checks: []*authzv1.BatchCheckItem{
{
Namespace: "org-12",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: "get",
Name: "dash2",
CorrelationId: "check1",
},
},
})
require.NoError(t, err)
require.True(t, resp.Results["check1"].Allowed)
})
}
func setupService() *Service {
cache := cache.NewLocalCache(cache.Config{Expiry: 5 * time.Minute, CleanupInterval: 5 * time.Minute})
logger := log.New("authz-rbac-service")

View File

@@ -13,6 +13,7 @@ type Client interface {
authlib.AccessClient
Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error)
Write(ctx context.Context, req *authzextv1.WriteRequest) error
BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error)
Mutate(ctx context.Context, req *authzextv1.MutateRequest) error
Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error)

View File

@@ -68,11 +68,11 @@ func (c *Client) Write(ctx context.Context, req *authzextv1.WriteRequest) error
return err
}
func (c *Client) BatchCheck(ctx context.Context, id authlib.AuthInfo, req authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error) {
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.BatchCheck")
func (c *Client) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) {
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Check")
defer span.End()
return c.authzlibclient.BatchCheck(ctx, id, req)
return c.authzext.BatchCheck(ctx, req)
}
func (c *Client) WriteNew(ctx context.Context, req *authzextv1.WriteRequest) error {

View File

@@ -34,11 +34,8 @@ func (nc NoopClient) Write(ctx context.Context, req *authzextv1.WriteRequest) er
return nil
}
func (nc NoopClient) BatchCheck(ctx context.Context, id authlib.AuthInfo, req authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error) {
return authlib.BatchCheckResponse{
Results: make(map[string]authlib.BatchCheckResult),
Zookie: authlib.NoopZookie{},
}, nil
func (nc NoopClient) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) {
return nil, nil
}
func (nc NoopClient) Mutate(ctx context.Context, req *authzextv1.MutateRequest) error {

View File

@@ -132,54 +132,3 @@ func (c *ShadowClient) Compile(ctx context.Context, id authlib.AuthInfo, req aut
return shadowItemChecker, authlib.NoopZookie{}, err
}
func (c *ShadowClient) BatchCheck(ctx context.Context, id authlib.AuthInfo, req authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error) {
acResChan := make(chan authlib.BatchCheckResponse, 1)
acErrChan := make(chan error, 1)
go func() {
if c.zanzanaClient == nil {
return
}
zanzanaCtx := context.WithoutCancel(ctx)
zanzanaCtxTimeout, cancel := context.WithTimeout(zanzanaCtx, zanzanaTimeout)
defer cancel()
timer := prometheus.NewTimer(c.metrics.evaluationsSeconds.WithLabelValues("zanzana"))
res, err := c.zanzanaClient.BatchCheck(zanzanaCtxTimeout, id, req)
if err != nil {
c.logger.Error("Failed to run zanzana batch check", "error", err)
}
timer.ObserveDuration()
acRes := <-acResChan
acErr := <-acErrChan
if acErr == nil {
// Compare results for each correlation ID
for corrID, acResult := range acRes.Results {
zanzanaResult, exists := res.Results[corrID]
if !exists {
c.metrics.evaluationStatusTotal.WithLabelValues("error").Inc()
c.logger.Warn("Zanzana batch check missing result", "correlationId", corrID, "user", id.GetUID())
continue
}
if zanzanaResult.Allowed != acResult.Allowed {
c.metrics.evaluationStatusTotal.WithLabelValues("error").Inc()
c.logger.Warn("Zanzana batch check result does not match", "expected", acResult.Allowed, "actual", zanzanaResult.Allowed, "correlationId", corrID, "user", id.GetUID())
} else {
c.metrics.evaluationStatusTotal.WithLabelValues("success").Inc()
}
}
}
}()
timer := prometheus.NewTimer(c.metrics.evaluationsSeconds.WithLabelValues("rbac"))
res, err := c.accessClient.BatchCheck(ctx, id, req)
timer.ObserveDuration()
acResChan <- res
acErrChan <- err
return res, err
}

View File

@@ -10,6 +10,7 @@ import (
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/accesscontrol"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
type typeInfo struct {
@@ -72,7 +73,7 @@ func NewResourceInfoFromCheck(r *authzv1.CheckRequest) ResourceInfo {
return resource
}
func NewResourceInfoFromBatchItem(i *authzv1.BatchCheckItem) ResourceInfo {
func NewResourceInfoFromBatchItem(i *authzextv1.BatchCheckItem) ResourceInfo {
typ, relations := getTypeAndRelations(i.GetGroup(), i.GetResource())
return newResource(
typ,

View File

@@ -2,463 +2,97 @@ package server
import (
"context"
"fmt"
"time"
authzv1 "github.com/grafana/authlib/authz/proto/v1"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"google.golang.org/protobuf/types/known/structpb"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
// checkKey represents a unique check to be performed
type checkKey struct {
relation string
object string
}
// batchCheckBuilder encapsulates state for building OpenFGA batch checks
type batchCheckBuilder struct {
subject string
contextuals *openfgav1.ContextualTupleKeys
checks []*openfgav1.BatchCheckItem
checksSeen map[checkKey]bool
checkMapping map[string]checkKey
counter int
}
func newBatchCheckBuilder(subject string, contextuals *openfgav1.ContextualTupleKeys) *batchCheckBuilder {
return &batchCheckBuilder{
subject: subject,
contextuals: contextuals,
checks: make([]*openfgav1.BatchCheckItem, 0),
checksSeen: make(map[checkKey]bool),
checkMapping: make(map[string]checkKey),
counter: 0,
}
}
func (b *batchCheckBuilder) addCheck(relation, object string, context *structpb.Struct) {
if object == "" {
return
}
key := checkKey{relation: relation, object: object}
if b.checksSeen[key] {
return
}
b.checksSeen[key] = true
correlationID := fmt.Sprintf("c%d", b.counter)
b.counter++
b.checks = append(b.checks, &openfgav1.BatchCheckItem{
TupleKey: &openfgav1.CheckRequestTupleKey{
User: b.subject,
Relation: relation,
Object: object,
},
ContextualTuples: b.contextuals,
Context: context,
CorrelationId: correlationID,
})
b.checkMapping[correlationID] = key
}
// BatchCheck implements authzv1.AuthzServiceServer.BatchCheck
// This performs multiple access checks in a single request using OpenFGA's native BatchCheck API.
func (s *Server) BatchCheck(ctx context.Context, r *authzv1.BatchCheckRequest) (*authzv1.BatchCheckResponse, error) {
func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) {
ctx, span := s.tracer.Start(ctx, "server.BatchCheck")
defer span.End()
span.SetAttributes(attribute.Int("check_count", len(r.GetChecks())))
if err := authorize(ctx, r.GetNamespace(), s.cfg); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
defer func(t time.Time) {
s.metrics.requestDurationSeconds.WithLabelValues("server.BatchCheck", "").Observe(time.Since(t).Seconds())
}(time.Now())
batchRes := &authzextv1.BatchCheckResponse{
Groups: make(map[string]*authzextv1.BatchCheckGroupResource),
}
res, err := s.batchCheck(ctx, r)
store, err := s.getStoreInfo(ctx, r.GetNamespace())
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.Error("failed to perform batch check request", "error", err)
return nil, fmt.Errorf("failed to perform batch check request: %w", err)
}
return res, nil
}
func (s *Server) batchCheck(ctx context.Context, r *authzv1.BatchCheckRequest) (*authzv1.BatchCheckResponse, error) {
items := r.GetChecks()
if len(items) == 0 {
return &authzv1.BatchCheckResponse{
Results: make(map[string]*authzv1.BatchCheckResult),
}, nil
}
// Group items by namespace
itemsByNamespace := make(map[string][]*authzv1.BatchCheckItem)
for _, item := range items {
ns := item.GetNamespace()
itemsByNamespace[ns] = append(itemsByNamespace[ns], item)
}
// Authorize and get store info for each namespace
stores := make(map[string]*storeInfo)
for namespace := range itemsByNamespace {
if err := authorize(ctx, namespace, s.cfg); err != nil {
return nil, err
}
store, err := s.getStoreInfo(ctx, namespace)
if err != nil {
return nil, err
}
stores[namespace] = store
return nil, err
}
contextuals, err := s.getContextuals(r.GetSubject())
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
results := make(map[string]*authzv1.BatchCheckResult, len(items))
subject := r.GetSubject()
groupResourceAccess := make(map[string]bool)
// Process each namespace separately
for namespace, nsItems := range itemsByNamespace {
store := stores[namespace]
// Phase 1: Check GroupResource access (broadest permissions)
// Example: user has "get" on "dashboards" group_resource → all dashboards allowed
s.runGroupResourcePhase(ctx, store, subject, nsItems, contextuals, results)
// Phase 2: Check folder permission inheritance (can_get, can_create, etc. on parent folder)
// Example: user has "can_get" on folder-A → all dashboards in folder-A allowed
s.runFolderPermissionPhase(ctx, store, subject, nsItems, contextuals, results)
// Phase 3: Check folder subresource access (folder_get, folder_create, etc.)
// Example: user has "folder_get" on folder-A → dashboards in folder-A allowed via subresource
s.runFolderSubresourcePhase(ctx, store, subject, nsItems, contextuals, results)
// Phase 4: Check direct resource access
// Example: user has "get" directly on dashboard-123
s.runDirectResourcePhase(ctx, store, subject, nsItems, contextuals, results)
}
// Mark any remaining unresolved items as denied
for _, item := range items {
if _, resolved := results[item.GetCorrelationId()]; !resolved {
results[item.GetCorrelationId()] = &authzv1.BatchCheckResult{Allowed: false}
}
}
return s.buildResponse(results), nil
}
func (s *Server) buildResponse(results map[string]*authzv1.BatchCheckResult) *authzv1.BatchCheckResponse {
return &authzv1.BatchCheckResponse{
Results: results,
Zookie: &authzv1.Zookie{Timestamp: time.Now().UnixMilli()},
}
}
// runGroupResourcePhase checks if the user has GroupResource-level access.
// This is the broadest permission - if allowed, all items in that group are allowed.
func (s *Server) runGroupResourcePhase(
ctx context.Context,
store *storeInfo,
subject string,
items []*authzv1.BatchCheckItem,
contextuals *openfgav1.ContextualTupleKeys,
results map[string]*authzv1.BatchCheckResult,
) {
// Group items by their GroupResource
type grInfo struct {
relation string
grIdent string
items []string // correlation IDs
}
groupedItems := make(map[string]*grInfo) // groupResource -> info
for _, item := range items {
relation := common.VerbMapping[item.GetVerb()]
if !common.IsGroupResourceRelation(relation) {
continue
}
resource := common.NewResourceInfoFromBatchItem(item)
gr := resource.GroupResource()
if _, exists := groupedItems[gr]; !exists {
groupedItems[gr] = &grInfo{
relation: relation,
grIdent: resource.GroupResourceIdent(),
items: make([]string, 0),
}
}
groupedItems[gr].items = append(groupedItems[gr].items, item.GetCorrelationId())
}
if len(groupedItems) == 0 {
return
}
// Build batch check for unique GroupResources
builder := newBatchCheckBuilder(subject, contextuals)
grCheckMapping := make(map[string]string) // OpenFGA correlationID -> groupResource
for gr, info := range groupedItems {
correlationID := fmt.Sprintf("gr%d", builder.counter)
builder.counter++
builder.checks = append(builder.checks, &openfgav1.BatchCheckItem{
TupleKey: &openfgav1.CheckRequestTupleKey{
User: subject,
Relation: info.relation,
Object: info.grIdent,
},
ContextualTuples: contextuals,
CorrelationId: correlationID,
})
grCheckMapping[correlationID] = gr
}
openfgaRes, err := s.openfgaClient.BatchCheck(ctx, &openfgav1.BatchCheckRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Checks: builder.checks,
})
if err != nil {
s.logger.Warn("Failed to check group resource access", "error", err)
return
}
// Mark all items in allowed GroupResources
for correlationID, result := range openfgaRes.GetResult() {
gr := grCheckMapping[correlationID]
if allowed, ok := result.GetCheckResult().(*openfgav1.BatchCheckSingleResult_Allowed); ok && allowed.Allowed {
for _, itemCorrelationID := range groupedItems[gr].items {
results[itemCorrelationID] = &authzv1.BatchCheckResult{Allowed: true}
}
}
}
}
// runFolderPermissionPhase checks folder permission inheritance (can_get, can_create, etc.).
// This applies to folder-based resources like dashboards, panels, etc.
func (s *Server) runFolderPermissionPhase(
ctx context.Context,
store *storeInfo,
subject string,
items []*authzv1.BatchCheckItem,
contextuals *openfgav1.ContextualTupleKeys,
results map[string]*authzv1.BatchCheckResult,
) {
builder := newBatchCheckBuilder(subject, contextuals)
checkToItems := make(map[checkKey][]string) // checkKey -> correlation IDs
for _, item := range items {
if _, resolved := results[item.GetCorrelationId()]; resolved {
continue
}
resource := common.NewResourceInfoFromBatchItem(item)
folderIdent := resource.FolderIdent()
// Only folder-based generic resources use folder permission inheritance
if !resource.IsGeneric() || folderIdent == "" || !isFolderPermissionBasedResource(resource.GroupResource()) {
continue
}
relation := common.VerbMapping[item.GetVerb()]
rel := common.FolderPermissionRelation(relation)
key := checkKey{relation: rel, object: folderIdent}
checkToItems[key] = append(checkToItems[key], item.GetCorrelationId())
builder.addCheck(rel, folderIdent, resource.Context())
}
if len(builder.checks) == 0 {
return
}
checkResults, err := s.executeOpenFGABatchChecks(ctx, store, builder)
if err != nil {
s.logger.Warn("Failed folder permission phase", "error", err)
return
}
// Mark items allowed by folder permissions
for key, allowed := range checkResults {
if allowed {
for _, correlationID := range checkToItems[key] {
results[correlationID] = &authzv1.BatchCheckResult{Allowed: true}
}
}
}
}
// runFolderSubresourcePhase checks folder subresource access (folder_get, folder_create, etc.).
func (s *Server) runFolderSubresourcePhase(
ctx context.Context,
store *storeInfo,
subject string,
items []*authzv1.BatchCheckItem,
contextuals *openfgav1.ContextualTupleKeys,
results map[string]*authzv1.BatchCheckResult,
) {
builder := newBatchCheckBuilder(subject, contextuals)
checkToItems := make(map[checkKey][]string)
for _, item := range items {
if _, resolved := results[item.GetCorrelationId()]; resolved {
continue
}
resource := common.NewResourceInfoFromBatchItem(item)
relation := common.VerbMapping[item.GetVerb()]
var objectIdent string
var subresRel string
if resource.IsGeneric() {
// Generic resources: check subresource on folder
folderIdent := resource.FolderIdent()
if folderIdent == "" {
continue
}
subresRel = common.SubresourceRelation(relation)
if !common.IsSubresourceRelation(subresRel) {
continue
}
objectIdent = folderIdent
} else {
// Typed resources: check subresource on the resource itself
if !resource.HasSubresource() || !resource.IsValidRelation(relation) {
continue
}
objectIdent = resource.ResourceIdent()
if objectIdent == "" {
continue
}
subresRel = common.SubresourceRelation(relation)
}
key := checkKey{relation: subresRel, object: objectIdent}
checkToItems[key] = append(checkToItems[key], item.GetCorrelationId())
builder.addCheck(subresRel, objectIdent, resource.Context())
}
if len(builder.checks) == 0 {
return
}
checkResults, err := s.executeOpenFGABatchChecks(ctx, store, builder)
if err != nil {
s.logger.Warn("Failed folder subresource phase", "error", err)
return
}
for key, allowed := range checkResults {
if allowed {
for _, correlationID := range checkToItems[key] {
results[correlationID] = &authzv1.BatchCheckResult{Allowed: true}
}
}
}
}
// runDirectResourcePhase checks direct resource access.
func (s *Server) runDirectResourcePhase(
ctx context.Context,
store *storeInfo,
subject string,
items []*authzv1.BatchCheckItem,
contextuals *openfgav1.ContextualTupleKeys,
results map[string]*authzv1.BatchCheckResult,
) {
builder := newBatchCheckBuilder(subject, contextuals)
checkToItems := make(map[checkKey][]string)
for _, item := range items {
if _, resolved := results[item.GetCorrelationId()]; resolved {
continue
}
resource := common.NewResourceInfoFromBatchItem(item)
relation := common.VerbMapping[item.GetVerb()]
if !resource.IsValidRelation(relation) {
continue
}
resourceIdent := resource.ResourceIdent()
if resourceIdent == "" {
continue
}
// For folders, use the computed permission relation
checkRelation := relation
if resource.Type() == common.TypeFolder {
checkRelation = common.FolderPermissionRelation(relation)
}
key := checkKey{relation: checkRelation, object: resourceIdent}
checkToItems[key] = append(checkToItems[key], item.GetCorrelationId())
builder.addCheck(checkRelation, resourceIdent, resource.Context())
}
if len(builder.checks) == 0 {
return
}
checkResults, err := s.executeOpenFGABatchChecks(ctx, store, builder)
if err != nil {
s.logger.Warn("Failed direct resource phase", "error", err)
return
}
for key, allowed := range checkResults {
if allowed {
for _, correlationID := range checkToItems[key] {
results[correlationID] = &authzv1.BatchCheckResult{Allowed: true}
}
}
}
}
// executeOpenFGABatchChecks executes the OpenFGA batch checks in chunks and returns results
func (s *Server) executeOpenFGABatchChecks(ctx context.Context, store *storeInfo, builder *batchCheckBuilder) (map[checkKey]bool, error) {
const maxChecksPerBatch = 50
checkResults := make(map[checkKey]bool)
for i := 0; i < len(builder.checks); i += maxChecksPerBatch {
end := i + maxChecksPerBatch
if end > len(builder.checks) {
end = len(builder.checks)
}
openfgaRes, err := s.openfgaClient.BatchCheck(ctx, &openfgav1.BatchCheckRequest{
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Checks: builder.checks[i:end],
})
for _, item := range r.GetItems() {
res, err := s.batchCheckItem(ctx, r, item, contextuals, store, groupResourceAccess)
if err != nil {
return nil, fmt.Errorf("failed to perform OpenFGA batch check: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Process results
for correlationID, result := range openfgaRes.GetResult() {
key, ok := builder.checkMapping[correlationID]
if !ok {
continue
}
if allowed, ok := result.GetCheckResult().(*openfgav1.BatchCheckSingleResult_Allowed); ok {
checkResults[key] = allowed.Allowed
groupResource := common.FormatGroupResource(item.GetGroup(), item.GetResource(), item.GetSubresource())
if _, ok := batchRes.Groups[groupResource]; !ok {
batchRes.Groups[groupResource] = &authzextv1.BatchCheckGroupResource{
Items: make(map[string]bool),
}
}
batchRes.Groups[groupResource].Items[item.GetName()] = res.GetAllowed()
}
return checkResults, nil
return batchRes, nil
}
func (s *Server) batchCheckItem(
ctx context.Context,
r *authzextv1.BatchCheckRequest,
item *authzextv1.BatchCheckItem,
contextuals *openfgav1.ContextualTupleKeys,
store *storeInfo,
groupResourceAccess map[string]bool,
) (*authzv1.CheckResponse, error) {
var (
relation = common.VerbMapping[item.GetVerb()]
resource = common.NewResourceInfoFromBatchItem(item)
groupResource = resource.GroupResource()
)
allowed, ok := groupResourceAccess[groupResource]
if !ok {
res, err := s.checkGroupResource(ctx, r.GetSubject(), relation, resource, contextuals, store)
if err != nil {
return nil, err
}
allowed = res.GetAllowed()
groupResourceAccess[groupResource] = res.GetAllowed()
}
if allowed {
return &authzv1.CheckResponse{Allowed: true}, nil
}
if resource.IsGeneric() {
return s.checkGeneric(ctx, r.GetSubject(), relation, resource, contextuals, store)
}
return s.checkTyped(ctx, r.GetSubject(), relation, resource, contextuals, store)
}

View File

@@ -1,302 +1,193 @@
package server
import (
"fmt"
"testing"
authzv1 "github.com/grafana/authlib/authz/proto/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/utils"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
func testBatchCheck(t *testing.T, server *Server) {
// Helper to create a batch check request
newReq := func(subject string, items []*authzv1.BatchCheckItem) *authzv1.BatchCheckRequest {
return &authzv1.BatchCheckRequest{
Subject: subject,
Checks: items,
newReq := func(subject, verb, group, resource, subresource string, items []*authzextv1.BatchCheckItem) *authzextv1.BatchCheckRequest {
for i, item := range items {
items[i] = &authzextv1.BatchCheckItem{
Verb: verb,
Group: group,
Resource: resource,
Subresource: subresource,
Name: item.GetName(),
Folder: item.GetFolder(),
}
}
}
// Helper to create a batch check item with correlation ID (uses default namespace)
newItem := func(verb, group, resource, subresource, folder, name string) *authzv1.BatchCheckItem {
correlationID := fmt.Sprintf("%s-%s-%s-%s", group, resource, folder, name)
return &authzv1.BatchCheckItem{
Namespace: namespace,
Verb: verb,
Group: group,
Resource: resource,
Subresource: subresource,
Name: name,
Folder: folder,
CorrelationId: correlationID,
return &authzextv1.BatchCheckRequest{
Namespace: namespace,
Subject: subject,
Items: items,
}
}
t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:1", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:1", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
{Name: "2", Folder: "2"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 2)
require.Len(t, res.Groups[groupResource].Items, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "2", "2")].Allowed)
assert.True(t, res.Groups[groupResource].Items["1"])
assert.False(t, res.Groups[groupResource].Items["2"])
})
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/{1,2} through group_resource", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:2", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:2", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
{Name: "2", Folder: "2"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 2)
// user:2 has group_resource access, so both should be allowed
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "2", "2")].Allowed)
assert.Len(t, res.Groups[groupResource].Items, 2)
})
t.Run("user:3 should be able to read resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:3", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:3", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
{Name: "2", Folder: "2"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 2)
require.Len(t, res.Groups[groupResource].Items, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "2", "2")].Allowed)
assert.True(t, res.Groups[groupResource].Items["1"])
assert.False(t, res.Groups[groupResource].Items["2"])
})
t.Run("user:4 should be able to read all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:4", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "3", "2"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "3"),
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:4", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
{Name: "2", Folder: "3"},
{Name: "3", Folder: "2"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 3)
require.Len(t, res.Groups[groupResource].Items, 3)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "3", "2")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "2", "3")].Allowed)
assert.True(t, res.Groups[groupResource].Items["1"])
assert.True(t, res.Groups[groupResource].Items["2"])
assert.False(t, res.Groups[groupResource].Items["3"])
})
t.Run("user:5 should be able to read resource:dashboard.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:5", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:5", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
{Name: "2", Folder: "2"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 2)
require.Len(t, res.Groups[groupResource].Items, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "2", "2")].Allowed)
assert.True(t, res.Groups[groupResource].Items["1"])
assert.False(t, res.Groups[groupResource].Items["2"])
})
t.Run("user:6 should be able to read folder 1", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:6", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, folderGroup, folderResource, "", "", "1"),
newItem(utils.VerbGet, folderGroup, folderResource, "", "", "2"),
groupResource := common.FormatGroupResource(folderGroup, folderResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:6", utils.VerbGet, folderGroup, folderResource, "", []*authzextv1.BatchCheckItem{
{Name: "1"},
{Name: "2"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 2)
require.Len(t, res.Groups[groupResource].Items, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", folderGroup, folderResource, "", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", folderGroup, folderResource, "", "2")].Allowed)
assert.True(t, res.Groups[groupResource].Items["1"])
assert.False(t, res.Groups[groupResource].Items["2"])
})
t.Run("user:7 should be able to read folder {1,2} through group_resource access", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:7", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, folderGroup, folderResource, "", "", "1"),
newItem(utils.VerbGet, folderGroup, folderResource, "", "", "2"),
groupResource := common.FormatGroupResource(folderGroup, folderResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:7", utils.VerbGet, folderGroup, folderResource, "", []*authzextv1.BatchCheckItem{
{Name: "1"},
{Name: "2"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", folderGroup, folderResource, "", "1")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", folderGroup, folderResource, "", "2")].Allowed)
require.Len(t, res.Groups[groupResource].Items, 2)
require.True(t, res.Groups[groupResource].Items["1"])
require.True(t, res.Groups[groupResource].Items["2"])
})
t.Run("user:8 should be able to read all resource:dashboard.grafana.app/dashboards in folder 6 through folder 5", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:8", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "6", "10"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "6", "20"),
t.Run("user:8 should be able to read all resoruce:dashboard.grafana.app/dashboards in folder 6 through folder 5", func(t *testing.T) {
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:8", utils.VerbGet, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "10", Folder: "6"},
{Name: "20", Folder: "6"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "10")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "20")].Allowed)
require.Len(t, res.Groups[groupResource].Items, 2)
require.True(t, res.Groups[groupResource].Items["10"])
require.True(t, res.Groups[groupResource].Items["20"])
})
t.Run("user:9 should be able to create dashboards in folder 6 through folder 5", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:9", []*authzv1.BatchCheckItem{
newItem(utils.VerbCreate, dashboardGroup, dashboardResource, "", "6", "10"),
newItem(utils.VerbCreate, dashboardGroup, dashboardResource, "", "6", "20"),
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, "")
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:9", utils.VerbCreate, dashboardGroup, dashboardResource, "", []*authzextv1.BatchCheckItem{
{Name: "10", Folder: "6"},
{Name: "20", Folder: "6"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "10")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "20")].Allowed)
t.Log(res.Groups)
require.Len(t, res.Groups[groupResource].Items, 2)
require.True(t, res.Groups[groupResource].Items["10"])
require.True(t, res.Groups[groupResource].Items["20"])
})
t.Run("user:10 should be able to get dashboard status for 10 and 11", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:10", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "10"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "11"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "12"),
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, statusSubresource)
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:10", utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, []*authzextv1.BatchCheckItem{
{Name: "10", Folder: "6"},
{Name: "11", Folder: "6"},
{Name: "12", Folder: "6"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 3)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "10")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "11")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "12")].Allowed)
t.Log(res.Groups)
require.Len(t, res.Groups[groupResource].Items, 3)
require.True(t, res.Groups[groupResource].Items["10"])
require.True(t, res.Groups[groupResource].Items["11"])
require.False(t, res.Groups[groupResource].Items["12"])
})
t.Run("user:11 should be able to get dashboard status for 10, 11 and 12 through group_resource", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:11", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "10"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "11"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "12"),
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, statusSubresource)
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:11", utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, []*authzextv1.BatchCheckItem{
{Name: "10", Folder: "6"},
{Name: "11", Folder: "6"},
{Name: "12", Folder: "6"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 3)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "10")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "11")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "12")].Allowed)
t.Log(res.Groups)
require.Len(t, res.Groups[groupResource].Items, 3)
require.True(t, res.Groups[groupResource].Items["10"])
require.True(t, res.Groups[groupResource].Items["11"])
require.True(t, res.Groups[groupResource].Items["12"])
})
t.Run("user:12 should be able to get dashboard status in folder 5 and 6", func(t *testing.T) {
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:12", []*authzv1.BatchCheckItem{
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "5", "10"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "11"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "6", "12"),
newItem(utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, "1", "13"),
groupResource := common.FormatGroupResource(dashboardGroup, dashboardResource, statusSubresource)
res, err := server.BatchCheck(newContextWithNamespace(), newReq("user:12", utils.VerbGet, dashboardGroup, dashboardResource, statusSubresource, []*authzextv1.BatchCheckItem{
{Name: "10", Folder: "5"},
{Name: "11", Folder: "6"},
{Name: "12", Folder: "6"},
{Name: "13", Folder: "1"},
}))
require.NoError(t, err)
require.Len(t, res.Results, 4)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "5", "10")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "11")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "6", "12")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "13")].Allowed)
})
// Cross-namespace tests
t.Run("cross-namespace: items with explicit namespace should be authorized against their own namespace", func(t *testing.T) {
// Helper to create item with explicit namespace
newItemWithNamespace := func(ns, verb, group, resource, subresource, folder, name string) *authzv1.BatchCheckItem {
correlationID := fmt.Sprintf("%s-%s-%s-%s-%s", ns, group, resource, folder, name)
return &authzv1.BatchCheckItem{
Namespace: ns,
Verb: verb,
Group: group,
Resource: resource,
Subresource: subresource,
Name: name,
Folder: folder,
CorrelationId: correlationID,
}
}
// user:1 has access to dashboard 1 in folder 1 in "default" namespace
// Both items use explicit namespace
res, err := server.BatchCheck(newContextWithNamespace(), &authzv1.BatchCheckRequest{
Subject: "user:1",
Checks: []*authzv1.BatchCheckItem{
// Item in default namespace (should be allowed - user:1 has access)
newItemWithNamespace(namespace, utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
// Another item in default namespace with different correlation ID
newItem(utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
},
})
require.NoError(t, err)
require.Len(t, res.Results, 2)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", namespace, dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s", dashboardGroup, dashboardResource, "1", "1")].Allowed)
})
t.Run("cross-namespace: items from different namespaces in same batch", func(t *testing.T) {
newItemWithNamespace := func(ns, verb, group, resource, subresource, folder, name string) *authzv1.BatchCheckItem {
correlationID := fmt.Sprintf("%s-%s-%s-%s-%s", ns, group, resource, folder, name)
return &authzv1.BatchCheckItem{
Namespace: ns,
Verb: verb,
Group: group,
Resource: resource,
Subresource: subresource,
Name: name,
Folder: folder,
CorrelationId: correlationID,
}
}
// user:2 has group_resource access in "default" namespace
// They should have access in default but not in other-namespace (no tuples there)
res, err := server.BatchCheck(newContextWithNamespace(), &authzv1.BatchCheckRequest{
Subject: "user:2",
Checks: []*authzv1.BatchCheckItem{
// Items in default namespace (should be allowed - user:2 has group_resource access)
newItemWithNamespace(namespace, utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItemWithNamespace(namespace, utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
// Items in other-namespace (should be denied - no tuples in other-namespace)
newItemWithNamespace("other-namespace", utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
newItemWithNamespace("other-namespace", utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
},
})
require.NoError(t, err)
require.Len(t, res.Results, 4)
// Default namespace items should be allowed
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", namespace, dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", namespace, dashboardGroup, dashboardResource, "2", "2")].Allowed)
// Other namespace items should be denied (no permissions in that namespace)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", "other-namespace", dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", "other-namespace", dashboardGroup, dashboardResource, "2", "2")].Allowed)
})
t.Run("cross-namespace: mixed results across multiple namespaces", func(t *testing.T) {
newItemWithNamespace := func(ns, verb, group, resource, subresource, folder, name string) *authzv1.BatchCheckItem {
correlationID := fmt.Sprintf("%s-%s-%s-%s-%s", ns, group, resource, folder, name)
return &authzv1.BatchCheckItem{
Namespace: ns,
Verb: verb,
Group: group,
Resource: resource,
Subresource: subresource,
Name: name,
Folder: folder,
CorrelationId: correlationID,
}
}
// user:1 has specific access to dashboard 1 in folder 1
// user:2 would have broader access, but we're testing user:1
res, err := server.BatchCheck(newContextWithNamespace(), &authzv1.BatchCheckRequest{
Subject: "user:1",
Checks: []*authzv1.BatchCheckItem{
// Allowed in default namespace
newItemWithNamespace(namespace, utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
// Denied in default namespace (user:1 doesn't have access to dashboard 2)
newItemWithNamespace(namespace, utils.VerbGet, dashboardGroup, dashboardResource, "", "2", "2"),
// Denied in other-namespace (no tuples)
newItemWithNamespace("other-namespace", utils.VerbGet, dashboardGroup, dashboardResource, "", "1", "1"),
},
})
require.NoError(t, err)
require.Len(t, res.Results, 3)
assert.True(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", namespace, dashboardGroup, dashboardResource, "1", "1")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", namespace, dashboardGroup, dashboardResource, "2", "2")].Allowed)
assert.False(t, res.Results[fmt.Sprintf("%s-%s-%s-%s-%s", "other-namespace", dashboardGroup, dashboardResource, "1", "1")].Allowed)
require.Len(t, res.Groups[groupResource].Items, 4)
require.True(t, res.Groups[groupResource].Items["10"])
require.True(t, res.Groups[groupResource].Items["11"])
require.True(t, res.Groups[groupResource].Items["12"])
require.False(t, res.Groups[groupResource].Items["13"])
})
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
"github.com/grafana/grafana/pkg/services/authz/zanzana/store"
"github.com/grafana/grafana/pkg/services/sqlstore"
@@ -36,14 +37,14 @@ const (
// Timeout for List operations
listTimeout = 30 * time.Second
// BenchmarkBatchCheck measures the performance of BatchCheck requests with 50 items per batch.
batchCheckSize = 50
// Resource type constants for benchmarks
benchDashboardGroup = "dashboard.grafana.app"
benchDashboardResource = "dashboards"
benchFolderGroup = "folder.grafana.app"
benchFolderResource = "folders"
// BenchmarkBatchCheck measures the performance of BatchCheck requests with 50 items per batch.
batchCheckSize = 50
)
// benchmarkData holds all the generated test data for benchmarks
@@ -337,14 +338,6 @@ func setupBenchmarkServer(b *testing.B) (*Server, *benchmarkData) {
}
cfg := setting.NewCfg()
cfg.ZanzanaServer.CacheSettings.CheckCacheLimit = 100000 // Cache check results
cfg.ZanzanaServer.CacheSettings.CheckQueryCacheEnabled = true // Cache check subproblems
cfg.ZanzanaServer.CacheSettings.CheckIteratorCacheEnabled = true // Cache DB iterators for checks
cfg.ZanzanaServer.CacheSettings.CheckIteratorCacheMaxResults = 10000 // Max results per iterator
cfg.ZanzanaServer.CacheSettings.SharedIteratorEnabled = true // Share iterators across concurrent checks
cfg.ZanzanaServer.CacheSettings.SharedIteratorLimit = 10000 // Max shared iterators
testStore := sqlstore.NewTestStore(b, sqlstore.WithCfg(cfg))
openFGAStore, err := store.NewEmbeddedStore(cfg, testStore, log.NewNopLogger())
@@ -580,64 +573,58 @@ func BenchmarkCheck(b *testing.B) {
})
}
// BenchmarkBatchCheck measures the performance of BatchCheck requests
func BenchmarkBatchCheck(b *testing.B) {
srv, data := setupBenchmarkServer(b)
ctx := newContextWithNamespace()
// Helper to create batch check requests using the new authzv1 API
newBatchCheckReq := func(subject string, items []*authzv1.BatchCheckItem) *authzv1.BatchCheckRequest {
return &authzv1.BatchCheckRequest{
Subject: subject,
Checks: items,
// Helper to create batch check requests
newBatchCheckReq := func(subject string, items []*authzextv1.BatchCheckItem) *authzextv1.BatchCheckRequest {
return &authzextv1.BatchCheckRequest{
Namespace: benchNamespace,
Subject: subject,
Items: items,
}
}
// Helper to create batch items for resources in folders
createBatchItems := func(resources []string, resourceFolders map[string]string) []*authzv1.BatchCheckItem {
items := make([]*authzv1.BatchCheckItem, 0, batchCheckSize)
createBatchItems := func(resources []string, resourceFolders map[string]string) []*authzextv1.BatchCheckItem {
items := make([]*authzextv1.BatchCheckItem, 0, batchCheckSize)
for i := 0; i < batchCheckSize && i < len(resources); i++ {
resource := resources[i]
items = append(items, &authzv1.BatchCheckItem{
Namespace: benchNamespace,
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: resource,
Folder: resourceFolders[resource],
CorrelationId: fmt.Sprintf("item-%d", i),
items = append(items, &authzextv1.BatchCheckItem{
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: resource,
Folder: resourceFolders[resource],
})
}
return items
}
// Helper to create batch items for folders at a specific depth
createFolderBatchItems := func(folders []string, depth int, folderDepths map[string]int) []*authzv1.BatchCheckItem {
items := make([]*authzv1.BatchCheckItem, 0, batchCheckSize)
createFolderBatchItems := func(folders []string, depth int, folderDepths map[string]int) []*authzextv1.BatchCheckItem {
items := make([]*authzextv1.BatchCheckItem, 0, batchCheckSize)
for _, folder := range folders {
if folderDepths[folder] == depth && len(items) < batchCheckSize {
items = append(items, &authzv1.BatchCheckItem{
Namespace: benchNamespace,
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: fmt.Sprintf("resource-in-%s", folder),
Folder: folder,
CorrelationId: fmt.Sprintf("item-%d", len(items)),
items = append(items, &authzextv1.BatchCheckItem{
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: fmt.Sprintf("resource-in-%s", folder),
Folder: folder,
})
}
}
// Fill remaining slots if needed
for len(items) < batchCheckSize && len(folders) > 0 {
folder := folders[len(items)%len(folders)]
items = append(items, &authzv1.BatchCheckItem{
Namespace: benchNamespace,
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: fmt.Sprintf("resource-%d", len(items)),
Folder: folder,
CorrelationId: fmt.Sprintf("item-%d", len(items)),
items = append(items, &authzextv1.BatchCheckItem{
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: fmt.Sprintf("resource-%d", len(items)),
Folder: folder,
})
}
return items
@@ -649,7 +636,6 @@ func BenchmarkBatchCheck(b *testing.B) {
// User with group_resource permission - should have access to everything
user := data.users[0]
items := createBatchItems(data.resources, data.resourceFolders)
b.Logf("Testing BatchCheck with %d items, user has group_resource permission (all access)", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -657,7 +643,7 @@ func BenchmarkBatchCheck(b *testing.B) {
if err != nil {
b.Fatal(err)
}
_ = res.Results
_ = res.Groups
}
})
@@ -665,7 +651,6 @@ func BenchmarkBatchCheck(b *testing.B) {
// User with folder permission on shallow folder
user := data.users[usersPerPattern]
items := createFolderBatchItems(data.folders, 1, data.folderDepths)
b.Logf("Testing BatchCheck with %d items at depth 1", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -673,7 +658,7 @@ func BenchmarkBatchCheck(b *testing.B) {
if err != nil {
b.Fatal(err)
}
_ = res.Results
_ = res.Groups
}
})
@@ -681,7 +666,6 @@ func BenchmarkBatchCheck(b *testing.B) {
// User with folder permission on mid-depth folder
user := data.users[2*usersPerPattern]
items := createFolderBatchItems(data.folders, 4, data.folderDepths)
b.Logf("Testing BatchCheck with %d items at depth 4", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -689,7 +673,22 @@ func BenchmarkBatchCheck(b *testing.B) {
if err != nil {
b.Fatal(err)
}
_ = res.Results
_ = res.Groups
}
})
b.Run("FolderInheritance/Depth7", func(b *testing.B) {
// Check access on deepest folders (worst case for inheritance traversal)
user := data.users[usersPerPattern]
items := createFolderBatchItems(data.folders, data.maxDepth, data.folderDepths)
b.ResetTimer()
for i := 0; i < b.N; i++ {
res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items))
if err != nil {
b.Fatal(err)
}
_ = res.Groups
}
})
@@ -697,7 +696,6 @@ func BenchmarkBatchCheck(b *testing.B) {
// User with direct resource permission
user := data.users[4*usersPerPattern]
items := createBatchItems(data.resources, data.resourceFolders)
b.Logf("Testing BatchCheck with %d items, user has direct resource permission", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -705,7 +703,22 @@ func BenchmarkBatchCheck(b *testing.B) {
if err != nil {
b.Fatal(err)
}
_ = res.Results
_ = res.Groups
}
})
b.Run("TeamMembership", func(b *testing.B) {
// User who is a team member, team has folder permission
user := data.users[5*usersPerPattern]
items := createBatchItems(data.resources, data.resourceFolders)
b.ResetTimer()
for i := 0; i < b.N; i++ {
res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items))
if err != nil {
b.Fatal(err)
}
_ = res.Groups
}
})
@@ -713,7 +726,6 @@ func BenchmarkBatchCheck(b *testing.B) {
// User with no permissions - tests denial path
user := data.users[len(data.users)-1]
items := createBatchItems(data.resources, data.resourceFolders)
b.Logf("Testing BatchCheck with %d items, user has NO permissions (denial case)", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -721,29 +733,24 @@ func BenchmarkBatchCheck(b *testing.B) {
if err != nil {
b.Fatal(err)
}
_ = res.Results
_ = res.Groups
}
})
b.Run("MixedAccess", func(b *testing.B) {
// Create items from different folders - user has access to some but not all
user := data.users[3*usersPerPattern] // folder-scoped resource permission
items := make([]*authzv1.BatchCheckItem, 0, batchCheckSize)
// Mix of accessible and inaccessible resources
b.Run("MixedFolders", func(b *testing.B) {
// Batch of items across different folder depths
user := data.users[usersPerPattern]
items := make([]*authzextv1.BatchCheckItem, 0, batchCheckSize)
for i := 0; i < batchCheckSize; i++ {
folder := data.folders[i%len(data.folders)]
items = append(items, &authzv1.BatchCheckItem{
Namespace: benchNamespace,
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: fmt.Sprintf("resource-%d", i),
Folder: folder,
CorrelationId: fmt.Sprintf("item-%d", i),
items = append(items, &authzextv1.BatchCheckItem{
Verb: utils.VerbGet,
Group: benchDashboardGroup,
Resource: benchDashboardResource,
Name: fmt.Sprintf("resource-%d", i),
Folder: folder,
})
}
b.Logf("Testing BatchCheck with %d items, user has mixed access (some allowed, some denied)", len(items))
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -751,31 +758,9 @@ func BenchmarkBatchCheck(b *testing.B) {
if err != nil {
b.Fatal(err)
}
_ = res.Results
_ = res.Groups
}
})
// Test BatchCheck at various folder depths
for depth := 0; depth <= data.maxDepth; depth++ {
depth := depth // capture for closure
if len(data.foldersByDepth[depth]) == 0 {
continue
}
b.Run(fmt.Sprintf("ByDepth/Depth%d", depth), func(b *testing.B) {
user := fmt.Sprintf("user:depth-%d-access", depth)
items := createFolderBatchItems(data.folders, depth, data.folderDepths)
b.Logf("Testing BatchCheck with %d items at depth %d", len(items), depth)
b.ResetTimer()
for i := 0; i < b.N; i++ {
res, err := srv.BatchCheck(ctx, newBatchCheckReq(user, items))
if err != nil {
b.Fatal(err)
}
_ = res.Results
}
})
}
}
// BenchmarkList measures the performance of List requests (Compile equivalent)

View File

@@ -1155,14 +1155,7 @@ var (
Owner: grafanaAppPlatformSquad,
RequiresRestart: true,
},
{
Name: "passwordlessMagicLinkAuthentication",
Description: "Enable passwordless login via magic link authentication",
Stage: FeatureStageExperimental,
Owner: identityAccessTeam,
HideFromDocs: true,
},
{
{
Name: "exploreMetricsRelatedLogs",
Description: "Display Related Logs in Grafana Metrics Drilldown",
Stage: FeatureStageExperimental,

View File

@@ -160,7 +160,6 @@ timeRangePan,experimental,@grafana/dataviz-squad,false,false,true
newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true
prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,false,true
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
160 newTimeRangeZoomShortcuts experimental @grafana/dataviz-squad false false true
161 azureMonitorDisableLogLimit GA @grafana/partner-datasources false false false
162 playlistsReconciler experimental @grafana/grafana-app-platform-squad false true false
passwordlessMagicLinkAuthentication experimental @grafana/identity-access-team false false false
163 exploreMetricsRelatedLogs experimental @grafana/observability-metrics false false true
164 prometheusSpecialCharsInLabelValues experimental @grafana/oss-big-tent false false true
165 enableExtensionsAdminPage experimental @grafana/plugins-platform-backend false true false

View File

@@ -483,10 +483,6 @@ const (
// Enables experimental reconciler for playlists
FlagPlaylistsReconciler = "playlistsReconciler"
// FlagPasswordlessMagicLinkAuthentication
// Enable passwordless login via magic link authentication
FlagPasswordlessMagicLinkAuthentication = "passwordlessMagicLinkAuthentication"
// FlagEnableExtensionsAdminPage
// Enables the extension admin page regardless of development mode
FlagEnableExtensionsAdminPage = "enableExtensionsAdminPage"

View File

@@ -2661,7 +2661,8 @@
"metadata": {
"name": "passwordlessMagicLinkAuthentication",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-11-14T13:50:55Z"
"creationTimestamp": "2024-11-14T13:50:55Z",
"deletionTimestamp": "2026-01-08T15:33:29Z"
},
"spec": {
"description": "Enable passwordless login via magic link authentication",

View File

@@ -647,12 +647,6 @@
},
"BacktestConfig": {
"properties": {
"annotations": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"condition": {
"type": "string"
},
@@ -662,8 +656,16 @@
},
"type": "array"
},
"exec_err_state": {
"enum": [
"OK",
"Alerting",
"Error"
],
"type": "string"
},
"for": {
"$ref": "#/definitions/Duration"
"type": "string"
},
"from": {
"format": "date-time",
@@ -672,12 +674,22 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"keep_firing_for": {
"type": "string"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"missing_series_evals_to_resolve": {
"format": "int64",
"type": "integer"
},
"namespace_uid": {
"type": "string"
},
"no_data_state": {
"enum": [
"Alerting",
@@ -686,12 +698,18 @@
],
"type": "string"
},
"rule_group": {
"type": "string"
},
"title": {
"type": "string"
},
"to": {
"format": "date-time",
"type": "string"
},
"uid": {
"type": "string"
}
},
"type": "object"
@@ -1813,6 +1831,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"limit": {
"format": "int64",
"type": "integer"
@@ -1823,6 +1847,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
},
"type": "array"
},
"rules": {
"items": {
"$ref": "#/definitions/GettableExtendedRuleNode"
@@ -3142,6 +3172,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"limit": {
"format": "int64",
"type": "integer"
@@ -3152,6 +3188,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
},
"type": "array"
},
"rules": {
"items": {
"$ref": "#/definitions/PostableExtendedRuleNode"
@@ -3817,6 +3859,14 @@
},
"type": "object"
},
"RemoteWriteConfig": {
"properties": {
"url": {
"type": "string"
}
},
"type": "object"
},
"ResponseDetails": {
"properties": {
"msg": {
@@ -4093,6 +4143,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"limit": {
"format": "int64",
"type": "integer"
@@ -4103,6 +4159,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
},
"type": "array"
},
"rules": {
"items": {
"$ref": "#/definitions/GettableExtendedRuleNode"

View File

@@ -284,11 +284,20 @@ type PostableRuleGroupConfig struct {
// fields below are used by Mimir/Loki rulers
SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"`
EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"`
QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"`
AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"`
Limit int `yaml:"limit,omitempty" json:"limit,omitempty"`
SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"`
EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"`
QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"`
AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"`
Limit int `yaml:"limit,omitempty" json:"limit,omitempty"`
Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"`
// GEM Ruler.
RWConfigs []RemoteWriteConfig `yaml:"remote_write,omitempty" json:"remote_write,omitempty"`
}
type RemoteWriteConfig struct {
URL string `yaml:"url,omitempty" json:"url,omitempty"`
}
func (c *PostableRuleGroupConfig) UnmarshalJSON(b []byte) error {
@@ -328,8 +337,8 @@ func (c *PostableRuleGroupConfig) validate() error {
return fmt.Errorf("cannot mix Grafana & Prometheus style rules")
}
if hasGrafRules && (len(c.SourceTenants) > 0 || c.EvaluationDelay != nil || c.QueryOffset != nil || c.AlignEvaluationTimeOnInterval || c.Limit > 0) {
return fmt.Errorf("fields source_tenants, evaluation_delay, query_offset, align_evaluation_time_on_interval and limit are not supported for Grafana rules")
if hasGrafRules && (len(c.SourceTenants) > 0 || c.EvaluationDelay != nil || c.QueryOffset != nil || c.AlignEvaluationTimeOnInterval || c.Limit > 0 || len(c.Labels) > 0 || len(c.RWConfigs) > 0) {
return fmt.Errorf("fields source_tenants, evaluation_delay, query_offset, align_evaluation_time_on_interval, limit, labels, and remote_write are not supported for Grafana rules")
}
return nil
}
@@ -345,11 +354,16 @@ type GettableRuleGroupConfig struct {
// fields below are used by Mimir/Loki rulers
SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"`
EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"`
QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"`
AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"`
Limit int `yaml:"limit,omitempty" json:"limit,omitempty"`
SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"`
EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"`
QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"`
AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"`
Limit int `yaml:"limit,omitempty" json:"limit,omitempty"`
Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"`
// GEM Ruler.
RWConfigs []RemoteWriteConfig `yaml:"remote_write,omitempty" json:"remote_write,omitempty"`
}
func (c *GettableRuleGroupConfig) UnmarshalJSON(b []byte) error {

View File

@@ -647,12 +647,6 @@
},
"BacktestConfig": {
"properties": {
"annotations": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"condition": {
"type": "string"
},
@@ -662,8 +656,16 @@
},
"type": "array"
},
"exec_err_state": {
"enum": [
"OK",
"Alerting",
"Error"
],
"type": "string"
},
"for": {
"$ref": "#/definitions/Duration"
"type": "string"
},
"from": {
"format": "date-time",
@@ -672,12 +674,22 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"keep_firing_for": {
"type": "string"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"missing_series_evals_to_resolve": {
"format": "int64",
"type": "integer"
},
"namespace_uid": {
"type": "string"
},
"no_data_state": {
"enum": [
"Alerting",
@@ -686,12 +698,18 @@
],
"type": "string"
},
"rule_group": {
"type": "string"
},
"title": {
"type": "string"
},
"to": {
"format": "date-time",
"type": "string"
},
"uid": {
"type": "string"
}
},
"type": "object"
@@ -1813,6 +1831,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"limit": {
"format": "int64",
"type": "integer"
@@ -1823,6 +1847,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
},
"type": "array"
},
"rules": {
"items": {
"$ref": "#/definitions/GettableExtendedRuleNode"
@@ -3142,6 +3172,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"limit": {
"format": "int64",
"type": "integer"
@@ -3152,6 +3188,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
},
"type": "array"
},
"rules": {
"items": {
"$ref": "#/definitions/PostableExtendedRuleNode"
@@ -3817,6 +3859,14 @@
},
"type": "object"
},
"RemoteWriteConfig": {
"properties": {
"url": {
"type": "string"
}
},
"type": "object"
},
"ResponseDetails": {
"properties": {
"msg": {
@@ -4093,6 +4143,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"limit": {
"format": "int64",
"type": "integer"
@@ -4103,6 +4159,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
},
"type": "array"
},
"rules": {
"items": {
"$ref": "#/definitions/GettableExtendedRuleNode"

View File

@@ -5072,12 +5072,6 @@
"BacktestConfig": {
"type": "object",
"properties": {
"annotations": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"condition": {
"type": "string"
},
@@ -5087,8 +5081,16 @@
"$ref": "#/definitions/AlertQuery"
}
},
"exec_err_state": {
"type": "string",
"enum": [
"OK",
"Alerting",
"Error"
]
},
"for": {
"$ref": "#/definitions/Duration"
"type": "string"
},
"from": {
"type": "string",
@@ -5097,12 +5099,22 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"keep_firing_for": {
"type": "string"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"missing_series_evals_to_resolve": {
"type": "integer",
"format": "int64"
},
"namespace_uid": {
"type": "string"
},
"no_data_state": {
"type": "string",
"enum": [
@@ -5111,12 +5123,18 @@
"OK"
]
},
"rule_group": {
"type": "string"
},
"title": {
"type": "string"
},
"to": {
"type": "string",
"format": "date-time"
},
"uid": {
"type": "string"
}
}
},
@@ -6239,6 +6257,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"limit": {
"type": "integer",
"format": "int64"
@@ -6249,6 +6273,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"type": "array",
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
}
},
"rules": {
"type": "array",
"items": {
@@ -7569,6 +7599,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"limit": {
"type": "integer",
"format": "int64"
@@ -7579,6 +7615,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"type": "array",
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
}
},
"rules": {
"type": "array",
"items": {
@@ -8243,6 +8285,14 @@
}
}
},
"RemoteWriteConfig": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
}
},
"ResponseDetails": {
"type": "object",
"properties": {
@@ -8520,6 +8570,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"limit": {
"type": "integer",
"format": "int64"
@@ -8530,6 +8586,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"type": "array",
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
}
},
"rules": {
"type": "array",
"items": {

View File

@@ -152,67 +152,6 @@ func (c authzLimitedClient) Check(ctx context.Context, id claims.AuthInfo, req c
return resp, nil
}
// BatchCheck implements claims.AccessClient.
func (c authzLimitedClient) BatchCheck(ctx context.Context, id claims.AuthInfo, req claims.BatchCheckRequest) (claims.BatchCheckResponse, error) {
ctx, span := tracer.Start(ctx, "resource.authzLimitedClient.BatchCheck", trace.WithAttributes(
attribute.Int("num_checks", len(req.Checks)),
attribute.Bool("fallback_used", FallbackUsed(ctx)),
))
defer span.End()
if FallbackUsed(ctx) {
span.SetStatus(codes.Error, "BatchCheck not supported with fallback")
return claims.BatchCheckResponse{}, fmt.Errorf("BatchCheck not supported when fallback is used")
}
// Filter checks to only those that require RBAC and validate namespace
rbacChecks := make([]claims.BatchCheckItem, 0, len(req.Checks))
allowedByDefault := make(map[string]bool, len(req.Checks))
for _, check := range req.Checks {
if !claims.NamespaceMatches(id.GetNamespace(), check.Namespace) {
span.SetStatus(codes.Error, "Namespace mismatch")
span.RecordError(claims.ErrNamespaceMismatch)
return claims.BatchCheckResponse{}, claims.ErrNamespaceMismatch
}
if c.IsCompatibleWithRBAC(check.Group, check.Resource) {
rbacChecks = append(rbacChecks, check)
} else {
allowedByDefault[check.CorrelationID] = true
}
}
// If all checks are allowed by default, return early
if len(rbacChecks) == 0 {
results := make(map[string]claims.BatchCheckResult, len(req.Checks))
for _, check := range req.Checks {
results[check.CorrelationID] = claims.BatchCheckResult{
Allowed: true,
}
}
return claims.BatchCheckResponse{Results: results}, nil
}
// Call the underlying client with RBAC checks
resp, err := c.client.BatchCheck(ctx, id, claims.BatchCheckRequest{Checks: rbacChecks})
if err != nil {
c.logger.FromContext(ctx).Error("BatchCheck failed", "error", err, "num_checks", len(rbacChecks))
span.SetStatus(codes.Error, fmt.Sprintf("batch check failed: %v", err))
span.RecordError(err)
return resp, err
}
// Merge results with allowed-by-default checks
for correlationID := range allowedByDefault {
resp.Results[correlationID] = claims.BatchCheckResult{
Allowed: true,
}
}
return resp, nil
}
// Compile implements claims.AccessClient.
func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req claims.ListRequest) (claims.ItemChecker, claims.Zookie, error) {
t := time.Now()

View File

@@ -159,97 +159,6 @@ func TestNamespaceMatching(t *testing.T) {
}
}
func TestAuthzLimitedClient_BatchCheck(t *testing.T) {
mockClient := authlib.FixedAccessClient(true)
client := NewAuthzLimitedClient(mockClient, AuthzOptions{})
t.Run("returns error when fallback is used", func(t *testing.T) {
ctx := WithFallback(context.Background())
req := authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{
CorrelationID: "0",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: utils.VerbGet,
Namespace: "stacks-1",
Name: "test-dashboard",
},
},
}
_, err := client.BatchCheck(ctx, &identity.StaticRequester{Namespace: "stacks-1"}, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "fallback")
})
t.Run("works normally without fallback", func(t *testing.T) {
ctx := context.Background()
req := authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{
CorrelationID: "0",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: utils.VerbGet,
Namespace: "stacks-1",
Name: "test-dashboard",
},
},
}
resp, err := client.BatchCheck(ctx, &identity.StaticRequester{Namespace: "stacks-1"}, req)
require.NoError(t, err)
require.Len(t, resp.Results, 1)
assert.True(t, resp.Results["0"].Allowed)
})
t.Run("returns error on namespace mismatch", func(t *testing.T) {
ctx := context.Background()
req := authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{
CorrelationID: "0",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Verb: utils.VerbGet,
Namespace: "stacks-2", // Different namespace
Name: "test-dashboard",
},
},
}
_, err := client.BatchCheck(ctx, &identity.StaticRequester{Namespace: "stacks-1"}, req)
require.Error(t, err)
assert.ErrorIs(t, err, authlib.ErrNamespaceMismatch)
})
t.Run("allows non-RBAC resources by default", func(t *testing.T) {
// Use a client that would deny if checked
denyClient := authlib.FixedAccessClient(false)
client := NewAuthzLimitedClient(denyClient, AuthzOptions{})
ctx := context.Background()
req := authlib.BatchCheckRequest{
Checks: []authlib.BatchCheckItem{
{
CorrelationID: "0",
Group: "unknown.group",
Resource: "unknown.resource",
Verb: utils.VerbGet,
Namespace: "stacks-1",
Name: "test",
},
},
}
resp, err := client.BatchCheck(ctx, &identity.StaticRequester{Namespace: "stacks-1"}, req)
require.NoError(t, err)
require.Len(t, resp.Results, 1)
assert.True(t, resp.Results["0"].Allowed, "non-RBAC resources should be allowed by default")
})
}
// TestNamespaceMatchingFallback tests namespace matching in Check and Compile methods when fallback is used
func TestNamespaceMatchingFallback(t *testing.T) {
// Create a mock client that always returns allowed=true

View File

@@ -33,8 +33,6 @@ import (
)
func TestIntegrationFolderTreeZanzana(t *testing.T) {
// TODO: Add back OSS seeding and enable this test
t.Skip("Skipping folder tree test with Zanzana")
testutil.SkipIntegrationTestInShortMode(t)
runIntegrationFolderTree(t, testinfra.GrafanaOpts{

76
public/api-merged.json generated
View File

@@ -13829,12 +13829,6 @@
"BacktestConfig": {
"type": "object",
"properties": {
"annotations": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"condition": {
"type": "string"
},
@@ -13844,8 +13838,16 @@
"$ref": "#/definitions/AlertQuery"
}
},
"exec_err_state": {
"type": "string",
"enum": [
"OK",
"Alerting",
"Error"
]
},
"for": {
"$ref": "#/definitions/Duration"
"type": "string"
},
"from": {
"type": "string",
@@ -13854,12 +13856,22 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"keep_firing_for": {
"type": "string"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"missing_series_evals_to_resolve": {
"type": "integer",
"format": "int64"
},
"namespace_uid": {
"type": "string"
},
"no_data_state": {
"type": "string",
"enum": [
@@ -13868,12 +13880,18 @@
"OK"
]
},
"rule_group": {
"type": "string"
},
"title": {
"type": "string"
},
"to": {
"type": "string",
"format": "date-time"
},
"uid": {
"type": "string"
}
}
},
@@ -16778,6 +16796,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"limit": {
"type": "integer",
"format": "int64"
@@ -16788,6 +16812,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"type": "array",
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
}
},
"rules": {
"type": "array",
"items": {
@@ -19263,6 +19293,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"limit": {
"type": "integer",
"format": "int64"
@@ -19273,6 +19309,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"type": "array",
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
}
},
"rules": {
"type": "array",
"items": {
@@ -20310,6 +20352,14 @@
}
}
},
"RemoteWriteConfig": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
}
},
"Report": {
"type": "object",
"properties": {
@@ -21009,6 +21059,12 @@
"interval": {
"$ref": "#/definitions/Duration"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"limit": {
"type": "integer",
"format": "int64"
@@ -21019,6 +21075,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"type": "array",
"items": {
"$ref": "#/definitions/RemoteWriteConfig"
}
},
"rules": {
"type": "array",
"items": {

View File

@@ -70,8 +70,8 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
// set initial values on activate
this.setState({
value: timeRange,
from: timeRange.raw.from.toString(),
to: timeRange.raw.to.toString(),
from: typeof timeRange.raw.from === 'string' ? timeRange.raw.from : timeRange.raw.from.toISOString(),
to: typeof timeRange.raw.to === 'string' ? timeRange.raw.to : timeRange.raw.to.toISOString(),
});
}

View File

@@ -17,5 +17,5 @@ export const usePluginConfig = (plugin?: CatalogPlugin) => {
return loadPlugin(plugin.id);
}
return null;
}, [plugin?.id, plugin?.isInstalled, plugin?.isDisabled]);
}, [plugin?.id, plugin?.isInstalled, plugin?.isDisabled, plugin?.isFullyInstalled]);
};

76
public/openapi3.json generated
View File

@@ -3364,12 +3364,6 @@
},
"BacktestConfig": {
"properties": {
"annotations": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"condition": {
"type": "string"
},
@@ -3379,8 +3373,16 @@
},
"type": "array"
},
"exec_err_state": {
"enum": [
"OK",
"Alerting",
"Error"
],
"type": "string"
},
"for": {
"$ref": "#/components/schemas/Duration"
"type": "string"
},
"from": {
"format": "date-time",
@@ -3389,12 +3391,22 @@
"interval": {
"$ref": "#/components/schemas/Duration"
},
"keep_firing_for": {
"type": "string"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"missing_series_evals_to_resolve": {
"format": "int64",
"type": "integer"
},
"namespace_uid": {
"type": "string"
},
"no_data_state": {
"enum": [
"Alerting",
@@ -3403,12 +3415,18 @@
],
"type": "string"
},
"rule_group": {
"type": "string"
},
"title": {
"type": "string"
},
"to": {
"format": "date-time",
"type": "string"
},
"uid": {
"type": "string"
}
},
"type": "object"
@@ -6313,6 +6331,12 @@
"interval": {
"$ref": "#/components/schemas/Duration"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"limit": {
"format": "int64",
"type": "integer"
@@ -6323,6 +6347,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"items": {
"$ref": "#/components/schemas/RemoteWriteConfig"
},
"type": "array"
},
"rules": {
"items": {
"$ref": "#/components/schemas/GettableExtendedRuleNode"
@@ -8798,6 +8828,12 @@
"interval": {
"$ref": "#/components/schemas/Duration"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"limit": {
"format": "int64",
"type": "integer"
@@ -8808,6 +8844,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"items": {
"$ref": "#/components/schemas/RemoteWriteConfig"
},
"type": "array"
},
"rules": {
"items": {
"$ref": "#/components/schemas/PostableExtendedRuleNode"
@@ -9846,6 +9888,14 @@
},
"type": "object"
},
"RemoteWriteConfig": {
"properties": {
"url": {
"type": "string"
}
},
"type": "object"
},
"Report": {
"properties": {
"created": {
@@ -10544,6 +10594,12 @@
"interval": {
"$ref": "#/components/schemas/Duration"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"limit": {
"format": "int64",
"type": "integer"
@@ -10554,6 +10610,12 @@
"query_offset": {
"type": "string"
},
"remote_write": {
"items": {
"$ref": "#/components/schemas/RemoteWriteConfig"
},
"type": "array"
},
"rules": {
"items": {
"$ref": "#/components/schemas/GettableExtendedRuleNode"