Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Ryan McKinley committed 2024-11-20 16:47:40 +03:00
commit 34be977c99
216 files changed
+10915 -2863

No files matched your search

+12 -22
View File
@@ -1615,21 +1615,8 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "13"]
"public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/NeedHelpInfo.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
@@ -1682,9 +1669,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
@@ -2434,6 +2418,14 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "7"],
[0, 0, 0, "Do not use any type assertions.", "8"]
],
"public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
],
"public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
@@ -2564,9 +2556,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not re-export imported variable (\`./VersionHistoryButtons\`)", "3"],
[0, 0, 0, "Do not re-export imported variable (\`./VersionHistoryComparison\`)", "4"]
],
"public/app/features/dashboard-scene/sharing/ShareExportTab.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/dashboard-scene/sharing/public-dashboards/ConfigPublicDashboard.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
@@ -5637,7 +5626,8 @@ exports[`better eslint`] = {
],
"public/swagger/SwaggerPage.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/swagger/index.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
+1 -1
View File
@@ -40,7 +40,6 @@
/docs/sources/alerting/ @brendamuir
/docs/sources/dashboards/ @imatwawana
/docs/sources/explore/ @grafana/observability-traces-and-profiling @lwandz13
/docs/sources/panels-visualizations/ @imatwawana
/docs/sources/release-notes/ @irenerl24 @GrafanaWriter
/docs/sources/upgrade-guide/ @imatwawana
@@ -673,6 +672,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
# Kind definitions
/kinds/dashboard @grafana/dashboards-squad
/kinds/ @grafana/grafana-as-code
kindsv2/ @grafana/dashboards-squad
# Kind system and code generation
embed.go @grafana/grafana-as-code
+2 -1
View File
@@ -8,7 +8,7 @@ on:
type: string
latest:
required: false
default: false
default: "0"
description: Mark this release as latest (`1`) or not (`0`, default)
type: string
dry_run:
@@ -23,6 +23,7 @@ on:
type: string
latest:
required: false
default: "0"
description: Mark this release as latest (`1`) or not (`0`, default)
type: string
dry_run:
+5 -3
View File
@@ -8,10 +8,11 @@ on:
dry_run:
required: false
default: true
type: boolean
version:
required: true
latest:
type: bool
type: boolean
default: false
pull_request:
types:
@@ -30,17 +31,18 @@ jobs:
latest: ${{ steps.output.outputs.latest }}
runs-on: ubuntu-latest
steps:
# The github-release action expects a `LATEST` value of a string of either '1' or '0'
- if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
echo setting up GITHUB_ENV for ${{ github.event_name }}
echo "VERSION=${{ inputs.version }}" >> $GITHUB_ENV
echo "DRY_RUN=${{ inputs.dry_run }}" >> $GITHUB_ENV
echo "LATEST=${{ inputs.latest }}" >> $GITHUB_ENV
echo "LATEST=${{ inputs.latest && '1' || '0' }}" >> $GITHUB_ENV
- if: ${{ github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') }}
run: |
echo "VERSION=$(echo ${{ github.head_ref }} | sed -e 's/release\/.*\///g')" >> $GITHUB_ENV
echo "DRY_RUN=${{ contains(github.event.pull_request.labels.*.name, 'release/dry-run') }}" >> $GITHUB_ENV
echo "LATEST=${{ contains(github.event.pull_request.labels.*.name, 'release/latest') }}" >> $GITHUB_ENV
echo "LATEST=${{ contains(github.event.pull_request.labels.*.name, 'release/latest') && '1' || '0' }}" >> $GITHUB_ENV
- id: output
run: |
echo "dry_run: $DRY_RUN"
+111
View File
@@ -1,3 +1,114 @@
<!-- 11.3.1 START -->
# 11.3.1 (2024-11-19)
### Features and enhancements
- **Alerting:** Make context deadline on AlertNG service startup configurable [#96135](https://github.com/grafana/grafana/pull/96135), [@fayzal-g](https://github.com/fayzal-g)
- **MigrationAssistant:** Restrict dashboards, folders and datasources by the org id of the signed in user [#96345](https://github.com/grafana/grafana/pull/96345), [@leandro-deveikis](https://github.com/leandro-deveikis)
- **User:** Check SignedInUser OrgID in RevokeInvite [#95490](https://github.com/grafana/grafana/pull/95490), [@mgyongyosi](https://github.com/mgyongyosi)
### Bug fixes
- **Alerting:** Fix escaping of silence matchers in utf8 mode [#95347](https://github.com/grafana/grafana/pull/95347), [@tomratcliffe](https://github.com/tomratcliffe)
- **Alerting:** Fix overflow for long receiver names [#95133](https://github.com/grafana/grafana/pull/95133), [@gillesdemey](https://github.com/gillesdemey)
- **Alerting:** Fix saving advanced mode toggle state in the alert rule editor [#95981](https://github.com/grafana/grafana/pull/95981), [@alexander-akhmetov](https://github.com/alexander-akhmetov)
- **Alerting:** Fix setting datasource uid, when datasource is string in old version [#96273](https://github.com/grafana/grafana/pull/96273), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
- **Alerting:** Force refetch prom rules when refreshing panel [#96125](https://github.com/grafana/grafana/pull/96125), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
- **Anonymous User:** Adds validator service for anonymous users [#94994](https://github.com/grafana/grafana/pull/94994), [@leandro-deveikis](https://github.com/leandro-deveikis)
- **Anonymous User:** Adds validator service for anonymous users (Enterprise)
- **Azure Monitor:** Support metric namespaces fallback [#95155](https://github.com/grafana/grafana/pull/95155), [@aangelisc](https://github.com/aangelisc)
- **Azure:** Fix duplicated traces in multi-resource trace query [#95247](https://github.com/grafana/grafana/pull/95247), [@aangelisc](https://github.com/aangelisc)
- **Azure:** Handle namespace request rejection [#95909](https://github.com/grafana/grafana/pull/95909), [@aangelisc](https://github.com/aangelisc)
- **CloudWatch:** Interpolate region in log context query [#94990](https://github.com/grafana/grafana/pull/94990), [@iwysiu](https://github.com/iwysiu)
- **Dashboard datasource:** Return annotations as series when query topic is "annotations" [#95971](https://github.com/grafana/grafana/pull/95971), [@kaydelaney](https://github.com/kaydelaney)
- **Dashboard:** Append orgId to URL [#95963](https://github.com/grafana/grafana/pull/95963), [@bfmatei](https://github.com/bfmatei)
- **Dashboards:** Fixes performance issue expanding a row [#95321](https://github.com/grafana/grafana/pull/95321), [@torkelo](https://github.com/torkelo)
- **Flame Graph:** Fix crash when it receives empty data [#96211](https://github.com/grafana/grafana/pull/96211), [@yincongcyincong](https://github.com/yincongcyincong)
- **Folders:** Add admin permissions upon creation of a folder w. SA [#95365](https://github.com/grafana/grafana/pull/95365), [@eleijonmarck](https://github.com/eleijonmarck)
- **Folders:** Don't show error pop-up if the user can't fetch the root folder [#95600](https://github.com/grafana/grafana/pull/95600), [@IevaVasiljeva](https://github.com/IevaVasiljeva)
- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95232](https://github.com/grafana/grafana/pull/95232), [@kalleep](https://github.com/kalleep)
- **ServerLock:** Fix pg concurrency/locking issue [#95935](https://github.com/grafana/grafana/pull/95935), [@mgyongyosi](https://github.com/mgyongyosi)
- **Service Accounts:** Run service account creation in transaction [#94803](https://github.com/grafana/grafana/pull/94803), [@IevaVasiljeva](https://github.com/IevaVasiljeva)
- **Table:** Fix text wrapping applying to wrong field [#95425](https://github.com/grafana/grafana/pull/95425), [@codeincarnate](https://github.com/codeincarnate)
- **Unified Storage:** Use ssl_mode instead of sslmode [#95662](https://github.com/grafana/grafana/pull/95662), [@chaudyg](https://github.com/chaudyg)
<!-- 11.3.1 END -->
<!-- 11.2.4 START -->
# 11.2.4 (2024-11-19)
### Features and enhancements
- **Alerting:** Make context deadline on AlertNG service startup configurable [#96133](https://github.com/grafana/grafana/pull/96133), [@fayzal-g](https://github.com/fayzal-g)
- **MigrationAssistant:** Restrict dashboards, folders and datasources by the org id of the signed in user [#96344](https://github.com/grafana/grafana/pull/96344), [@leandro-deveikis](https://github.com/leandro-deveikis)
- **Transformations:** Add 'transpose' transform [#95076](https://github.com/grafana/grafana/pull/95076), [@jmdane](https://github.com/jmdane)
- **User:** Check SignedInUser OrgID in RevokeInvite [#95489](https://github.com/grafana/grafana/pull/95489), [@mgyongyosi](https://github.com/mgyongyosi)
### Bug fixes
- **Alerting:** Force refetch prom rules when refreshing panel [#96124](https://github.com/grafana/grafana/pull/96124), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
- **Anonymous User:** Adds validator service for anonymous users [#94993](https://github.com/grafana/grafana/pull/94993), [@leandro-deveikis](https://github.com/leandro-deveikis)
- **Anonymous User:** Adds validator service for anonymous users (Enterprise)
- **Azure Monitor:** Support metric namespaces fallback [#95154](https://github.com/grafana/grafana/pull/95154), [@aangelisc](https://github.com/aangelisc)
- **Azure:** Fix duplicated traces in multi-resource trace query [#95246](https://github.com/grafana/grafana/pull/95246), [@aangelisc](https://github.com/aangelisc)
- **Azure:** Handle namespace request rejection [#95908](https://github.com/grafana/grafana/pull/95908), [@aangelisc](https://github.com/aangelisc)
- **Folders:** Add admin permissions upon creation of a folder w. SA [#95416](https://github.com/grafana/grafana/pull/95416), [@eleijonmarck](https://github.com/eleijonmarck)
- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95231](https://github.com/grafana/grafana/pull/95231), [@kalleep](https://github.com/kalleep)
- **ServerLock:** Fix pg concurrency/locking issue [#95934](https://github.com/grafana/grafana/pull/95934), [@mgyongyosi](https://github.com/mgyongyosi)
- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94959](https://github.com/grafana/grafana/pull/94959), [@samjewell](https://github.com/samjewell)
<!-- 11.2.4 END -->
<!-- 11.1.9 START -->
# 11.1.9 (2024-11-19)
### Features and enhancements
- **Alerting:** Make context deadline on AlertNG service startup configurable [#96132](https://github.com/grafana/grafana/pull/96132), [@fayzal-g](https://github.com/fayzal-g)
- **User:** Check SignedInUser OrgID in RevokeInvite [#95488](https://github.com/grafana/grafana/pull/95488), [@mgyongyosi](https://github.com/mgyongyosi)
### Bug fixes
- **Alerting:** Force refetch prom rules when refreshing panel [#96123](https://github.com/grafana/grafana/pull/96123), [@soniaAguilarPeiron](https://github.com/soniaAguilarPeiron)
- **Anonymous User:** Adds validator service for anonymous users [#94992](https://github.com/grafana/grafana/pull/94992), [@leandro-deveikis](https://github.com/leandro-deveikis)
- **Anonymous User:** Adds validator service for anonymous users (Enterprise)
- **Azure Monitor:** Support metric namespaces fallback [#95153](https://github.com/grafana/grafana/pull/95153), [@aangelisc](https://github.com/aangelisc)
- **Azure:** Fix duplicated traces in multi-resource trace query [#95245](https://github.com/grafana/grafana/pull/95245), [@aangelisc](https://github.com/aangelisc)
- **Azure:** Handle namespace request rejection [#95907](https://github.com/grafana/grafana/pull/95907), [@aangelisc](https://github.com/aangelisc)
- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95230](https://github.com/grafana/grafana/pull/95230), [@kalleep](https://github.com/kalleep)
- **Prometheus:** Fix interpolating adhoc filters with template variables [#95977](https://github.com/grafana/grafana/pull/95977), [@cazeaux](https://github.com/cazeaux)
- **ServerLock:** Fix pg concurrency/locking issue [#95933](https://github.com/grafana/grafana/pull/95933), [@mgyongyosi](https://github.com/mgyongyosi)
- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94969](https://github.com/grafana/grafana/pull/94969), [@scottlepp](https://github.com/scottlepp)
<!-- 11.1.9 END -->
<!-- 11.0.8 START -->
# 11.0.8 (2024-11-19)
### Features and enhancements
- **Alerting:** Make context deadline on AlertNG service startup configurable [#96131](https://github.com/grafana/grafana/pull/96131), [@fayzal-g](https://github.com/fayzal-g)
- **User:** Check SignedInUser OrgID in RevokeInvite [#95487](https://github.com/grafana/grafana/pull/95487), [@mgyongyosi](https://github.com/mgyongyosi)
### Bug fixes
- **Anonymous User:** Adds validator service for anonymous users [#95151](https://github.com/grafana/grafana/pull/95151), [@leandro-deveikis](https://github.com/leandro-deveikis)
- **Anonymous User:** Adds validator service for anonymous users (Enterprise)
- **Azure Monitor:** Support metric namespaces fallback [#95152](https://github.com/grafana/grafana/pull/95152), [@aangelisc](https://github.com/aangelisc)
- **Azure:** Fix duplicated traces in multi-resource trace query [#95244](https://github.com/grafana/grafana/pull/95244), [@aangelisc](https://github.com/aangelisc)
- **Azure:** Handle namespace request rejection [#95906](https://github.com/grafana/grafana/pull/95906), [@aangelisc](https://github.com/aangelisc)
- **Migration:** Remove table aliasing in delete statement to make it work for mariadb [#95229](https://github.com/grafana/grafana/pull/95229), [@kalleep](https://github.com/kalleep)
- **Prometheus:** Fix interpolating adhoc filters with template variables [#95986](https://github.com/grafana/grafana/pull/95986), [@cazeaux](https://github.com/cazeaux)
- **ServerLock:** Fix pg concurrency/locking issue [#95932](https://github.com/grafana/grafana/pull/95932), [@mgyongyosi](https://github.com/mgyongyosi)
- **ServerSideExpressions:** Disable SQL Expressions to prevent RCE and LFI vulnerability [#94971](https://github.com/grafana/grafana/pull/94971), [@samjewell](https://github.com/samjewell)
<!-- 11.0.8 END -->
<!-- 10.4.13 START -->
# 10.4.13 (2024-11-19)
<!-- 10.4.13 END -->
<!-- 11.3.0+security-01 START -->
# 11.3.0+security-01 (2024-11-12)
+2 -1
View File
@@ -30,7 +30,7 @@ COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./
COPY scripts scripts
COPY emails emails
ENV NODE_ENV production
ENV NODE_ENV=production
RUN yarn build
FROM ${GO_IMAGE} as go-builder
@@ -77,6 +77,7 @@ RUN if [[ "$BINGO" = "true" ]]; then \
COPY embed.go Makefile build.go package.json ./
COPY cue.mod cue.mod
COPY kinds kinds
COPY kindsv2 kindsv2
COPY local local
COPY packages/grafana-schema packages/grafana-schema
COPY public/app/plugins public/app/plugins
+5
View File
@@ -146,6 +146,11 @@ gen-cue: ## Do all CUE/Thema code generation
go generate ./kinds/gen.go
go generate ./public/app/plugins/gen.go
.PHONY: gen-cuev2
gen-cuev2: ## Do all CUE code generation
@echo "generate code from .cue files (v2)"
go generate ./kindsv2/gen.go
.PHONY: gen-feature-toggles
gen-feature-toggles:
## First go test run fails because it will re-generate the feature toggles.
+1 -1
View File
@@ -1,6 +1,6 @@
FROM ubuntu:xenial
ENV DEBIAN_FRONTEND noninteractive
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get -y update
RUN apt-get -y install collectd curl python-pip
+4 -4
View File
@@ -2,18 +2,18 @@ FROM centos:6.6
RUN yum install -y initscripts curl tar gcc libc6-dev git
ENV GOLANG_VERSION 1.4.2
ENV GOLANG_VERSION=1.4.2
RUN curl -sSL https://golang.org/dl/go$GOLANG_VERSION.src.tar.gz \
| tar -v -C /usr/src -xz
RUN cd /usr/src/go/src && ./make.bash --no-clean 2>&1
ENV PATH /usr/src/go/bin:$PATH
ENV PATH=/usr/src/go/bin:$PATH
RUN mkdir -p /go/src /go/bin && chmod -R 777 /go
ENV GOPATH /go
ENV PATH /go/bin:$PATH
ENV GOPATH=/go
ENV PATH=/go/bin:$PATH
WORKDIR /go/src/github.com/grafana/grafana
@@ -7,6 +7,6 @@ WORKDIR /go/src/webhook
RUN mkdir /tmp/logs
RUN go build -o /bin webhook-listener.go
ENV PORT 8080
ENV PORT=8080
ENTRYPOINT [ "/bin/webhook-listener" ]
@@ -114,9 +114,13 @@ Each derived field consists of the following:
- **Type** - Defines the type of the derived field. It can be either:
- **Regex**: A regular expression to parse a part of the log message and capture it as the value of the new field. Can contain only one capture group.
{{% admonition type="caution" %}}
Using complex regular expressions in either type can impact browser performance when processing large volumes of logs. Consider using simpler patterns when possible.
{{% /admonition %}}
- **Label**: A label from the selected log line. This can be any type of label - indexed, parsed or structured metadata. The label's value will be used as the value of the derived field.
- **Regex**: A regular expression to parse a part of the log message and capture it as the value of the new field. Can contain only one capture group.
- **Label**: A label from the selected log line. This can be any type of label - indexed, parsed or structured metadata. When using this type, the input will match as a regular expression against label keys, allowing you to match variations like `traceid` and `trace_id` with a single regex pattern like `trace[_]?id`. The value of the matched label will be used as the value of the derived field.
- **URL/query** Sets the full link URL if the link is external, or a query for the target data source if the link is internal. You can interpolate the value from the field with the `${__value.raw}` macro.
+1 -11
View File
@@ -163,17 +163,7 @@ For details about these metrics, refer to [Internal Grafana metrics](ref:set-up-
## Amazon Managed Service for Prometheus
The Prometheus data source works with Amazon Managed Service for Prometheus.
If you use an AWS Identity and Access Management (IAM) policy to control access to your Amazon Elasticsearch Service domain, you must use AWS Signature Version 4 (AWS SigV4) to sign all requests to that domain.
For details on AWS SigV4, refer to the [AWS documentation](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).
### AWS Signature Version 4 authentication
To connect the Prometheus data source to Amazon Managed Service for Prometheus using SigV4 authentication, refer to the AWS guide to [Set up Grafana open source or Grafana Enterprise for use with AMP](https://docs.aws.amazon.com/prometheus/latest/userguide/AMP-onboard-query-standalone-grafana.html).
If you run Grafana in an Amazon EKS cluster, follow the AWS guide to [Query using Grafana running in an Amazon EKS cluster](https://docs.aws.amazon.com/prometheus/latest/userguide/AMP-onboard-query-grafana-7.3.html).
The Prometheus data source with Amazon Managed Service for Prometheus is deprecated. Please use the [Amazon Managed service for Prometheus data source](https://grafana.com/grafana/plugins/grafana-amazonprometheus-datasource/). Migrations steps are detailed in the link.
## Azure authentication settings
@@ -34,12 +34,14 @@ Grafana Enterprise includes integrations with more ways to authenticate your use
Supported auth providers:
- [Auth Proxy]({{< relref "../setup-grafana/configure-security/configure-authentication/auth-proxy#team-sync-enterprise-only" >}})
- [Azure AD OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/azuread#team-sync-enterprise-only" >}})
- [GitHub OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/github#configure-team-synchronization" >}})
- [GitLab OAuth]({{< relref "../setup-grafana/configure-security/configure-authentication/gitlab#configure-team-synchronization" >}})
- [LDAP]({{< relref "../setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization-for-teams" >}})
- [Okta]({{< relref "../setup-grafana/configure-security/configure-authentication/okta#configure-team-synchronization-enterprise-only" >}})
- [SAML]({{< relref "../setup-grafana/configure-security/configure-authentication/saml#configure-team-sync" >}})
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
- [Generic OAuth integration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/generic-oauth#configure-group-synchronization" >}})
- [GitHub OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/github#configure-group-synchronization)
- [GitLab OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/gitlab#configure-group-synchronization)
- [Google OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/google#configure-group-synchronization)
- [LDAP](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization)
- [Okta](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only)
- [SAML](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/saml#configure-group-synchronization)
### Enhanced LDAP integration
@@ -338,6 +338,7 @@ Use this transformation to select a source of data and extract content from it i
- **Format** - Choose one of the following:
- **JSON** - Parse JSON content from the source.
- **Key+value pairs** - Parse content in the format 'a=b' or 'c:d' from the source.
- **RegExp** - Parse content using a regular expression with [named capturing group(s)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group).
- **Auto** - Discover fields automatically.
- **Replace All Fields** - (Optional) Select this option to hide all other fields and display only your calculated field in the visualization.
- **Keep Time** - (Optional) Available only if **Replace All Fields** is true. Keeps the time field in the output.
@@ -25,7 +25,7 @@ Node graphs are useful when you need to visualize elements that are related to e
Node graphs display useful information about each node, as well as the relationships between them, allowing you to visualize complex infrastructure maps, hierarchies, or execution diagrams.
![Node graph visualization](/media/docs/grafana/data-sources/tempo/query-editor/tempo-ds-query-node-graph.png 'Node graph')
![Node graph visualization](/media/docs/grafana/panels-visualizations/screenshot-node-graph-v11.3.png 'Node graph')
The appearance of nodes and edges can also be customized in several ways including color, borders, and line style.
@@ -79,36 +79,7 @@ Similar to the nodes dataset, the edges dataset needs one unique ID field for ea
If a node lacks edge connections, it’s displayed on its own outside of the network.
## Panel options
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Node graph options
Use the following options to refine your node graph visualization.
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
## Nodes options
The **Nodes** options section provides configurations for node behaviors.
- **Main stat unit** - Choose which unit the main stat displays in the graph's nodes.
- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's nodes.
- **Arc sections** - Configure which fields define the size of the colored circle around the node and select a color for each. You can add multiple fields.
{{< admonition type="note" >}}
Defining arc sections overrides the automatic detection of `arc__*` and `color` fields described in the **Optional fields** section of [Nodes data frame structure](#nodes-data-frame-structure).
{{< /admonition >}}
## Edges options
The **Edges** options section provides configurations for node edges behaviors.
- **Main stat unit** - Choose which unit the main stat displays in the graph's edges.
- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's edges.
## Data requirements
### Data requirements
A node graph requires a specific shape of the data to be able to display its nodes and edges. This means not every data source or query can be visualized with this graph. If you want to use this as a data source developer see the section about data API.
@@ -119,7 +90,7 @@ A node graph consists of _nodes_ and _edges_.
Both nodes and edges can have associated metadata or statistics. The data source defines what information and values is shown, so different data sources can show different type of values or not show some values.
### Nodes
#### Nodes
{{% admonition type="note" %}}
Node graphs can show only 1,500 nodes. If this limit is crossed a warning will be visible in upper right corner, and some nodes will be hidden. You can expand hidden parts of the graph by clicking on the "Hidden nodes" markers in the graph.
@@ -131,47 +102,88 @@ For example, you can have the percentage of errors represented by a red portion
Additional details can be displayed in a context menu which is displayed when you click on the node.
There also can be additional links in the context menu that can target either other parts of Grafana or any external link.
![Node graph navigation](/media/docs/grafana/data-sources/tempo/query-editor/node-graph-navigation.png 'Node graph navigation')
![Node context menu](/media/docs/grafana/panels-visualizations/screenshot-node-links-v11.3.png 'Node context menu')
### Edges
#### Edges
Edges can also show statistics when you hover over the edge. Similar to nodes, you can open a context menu with additional details and links by clicking on the edge.
The first data source supporting this visualization is X-Ray data source for its Service map feature. For more information, refer to the [X-Ray plugin documentation](https://grafana.com/grafana/plugins/grafana-x-ray-datasource).
## Configuration options
{{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Panel options
{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
### Node graph options
Use the following options to refine your node graph visualization.
- **Zoom mode** - Choose how the node graph should handle zoom and scroll events.
### Nodes options
The **Nodes** options section provides configurations for node behaviors.
- **Main stat unit** - Choose which unit the main stat displays in the graph's nodes.
- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's nodes.
- **Arc sections** - Configure which fields define the size of the colored circle around the node and select a color for each. You can add multiple fields.
{{< admonition type="note" >}}
Defining arc sections overrides the automatic detection of `arc__*` and `color` fields described in the **Optional fields** section of [Nodes data frame structure](#nodes-data-frame-structure).
{{< /admonition >}}
### Edges options
The **Edges** options section provides configurations for node edges behaviors.
- **Main stat unit** - Choose which unit the main stat displays in the graph's edges.
- **Secondary stat unit** - Choose which unit the secondary stat displays in the graph's edges.
### Data links
{{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
In node graphs, some data fields may have pre-configured data links. To add a different data link in those cases, use a [field override](#field-overrides).
### Field overrides
{{< docs/shared lookup="visualizations/overrides-options.md" source="grafana" version="<GRAFANA_VERSION>" >}}
## Navigating the node graph
You can pan and zoom in or out a node graph.
You can use pan, zoom, and other functions to navigate a node graph.
### Pan
You can pan the view by clicking outside any node or edge and dragging your mouse.
### Zoom in or out
### Zoom
Use the buttons in the upper left corner or use the mouse wheel, touchpad scroll, together with either Ctrl or Cmd key to zoom in or out.
Use the buttons in the lower right corner to zoom in or out. You can also use the mouse wheel or touchpad scroll, together with either Ctrl or Cmd key to do so.
### Explore hidden nodes
### Hidden nodes
The number of nodes shown at a given time is limited to maintain a reasonable visualization performance. Nodes that are not currently visible are hidden behind clickable markers that show an approximate number of hidden nodes that are connected by a particular edge. You can click on the marker to expand the graph around that node.
![Node graph exploration](/static/img/docs/node-graph/node-graph-exploration-8-0.png 'Node graph exploration')
![Node graph exploration](/media/docs/grafana/panels-visualizations/node-graph-exploration-8.0-2.png 'Node graph exploration')
### Grid view
You can switch to the grid view to have a better overview of the most interesting nodes in the graph. Grid view shows nodes in a grid without edges and can be sorted by stats shown inside the node or by stats represented by the a colored border of the nodes.
<!-- Screenshot from v11.2 -->
![Node graph grid](/media/docs/grafana/data-sources/tempo/query-editor/node-graph-grid-view.png 'Node graph grid')
![Node graph grid](/media/docs/grafana/panels-visualizations/screenshot-node-graph-grid-v11.3.png 'Node graph grid')
To sort the nodes, click on the stats inside the legend. The marker next to the stat name shows which stat is currently used for sorting and sorting direction.
![Node graph legend](/static/img/docs/node-graph/node-graph-legend-8-0.png 'Node graph legend')
![Node graph legend](/media/docs/grafana/panels-visualizations/screenshot-node-graph-legend-v11.3.png 'Node graph legend')
Click on the node and select "Show in Graph layout" option to switch back to graph layout and focus on the selected node, to show it in context of the full graph.
![Node graph grid to default](/static/img/docs/node-graph/node-graph-grid-to-default-8-0.png 'Node graph grid to default')
![Node graph grid to default](/media/docs/grafana/panels-visualizations/screenshot-node-graph-view-v11.3.png 'Node graph grid to default')
## Data API
@@ -217,15 +229,15 @@ Required fields:
Optional fields:
| Field name | Type | Description |
| ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| title | string | Name of the node visible in just under the node. |
| subtitle | string | Additional, name, type or other identifier shown under the title. |
| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. |
| secondarystat | string/number | Same as mainStat, but shown under it inside the node. |
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behaviour depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana built in icons are allowed (see the available icons [here](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)). |
| nodeRadius | number | Radius value in pixels. Used to manage node size. |
| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |
| Field name | Type | Description |
| ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| title | string | Name of the node visible in just under the node. |
| subtitle | string | Additional, name, type or other identifier shown under the title. |
| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. |
| secondarystat | string/number | Same as mainStat, but shown under it inside the node. |
| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
| color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behavior depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. |
| icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana [built in icons](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)) are allowed. |
| nodeRadius | number | Radius value in pixels. Used to manage node size. |
| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` |
@@ -78,6 +78,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `openSearchBackendFlowEnabled` | Enables the backend query flow for Open Search datasource plugin | Yes |
| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes |
| `cloudwatchMetricInsightsCrossAccount` | Enables cross account observability for Cloudwatch Metric Insights query builder | Yes |
| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one | Yes |
| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | |
| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | Yes |
| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes |
@@ -115,7 +116,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector |
| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars |
| `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP |
| `singleTopNav` | Unifies the top search bar and breadcrumb bar into one |
| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login |
| `reportingUseRawTimeRange` | Uses the original report or dashboard time range instead of making an absolute transformation |
@@ -353,14 +353,14 @@ This setting is ignored if multiple auth providers are configured to use auto lo
auto_login = true
```
### Team Sync (Enterprise only)
### Group sync (Enterprise only)
With Team Sync you can map your Entra ID groups to teams in Grafana so that your users will automatically be added to
the correct teams.
With group sync you can map your Entra ID groups to teams and roles in Grafana. This allows users to automatically be added to
the correct teams and be granted the correct roles in Grafana.
You can reference Entra ID groups by group object ID, like `8bab1c86-8fba-33e5-2089-1d1c80ec267d`.
To learn more, refer to the [Team Sync]({{< relref "../../configure-team-sync" >}}) documentation.
To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync).
## Common troubleshooting
@@ -28,18 +28,16 @@ The enhanced LDAP integration adds additional functionality on top of the [LDAP
> To control user access with role-based permissions, refer to [role-based access control]({{< relref "../../../../administration/roles-and-permissions/access-control" >}}).
## LDAP group synchronization for teams
## LDAP group synchronization
With enhanced LDAP integration, you can set up synchronization between LDAP groups and teams. This enables LDAP users that are members
of certain LDAP groups to automatically be added or removed as members to certain teams in Grafana.
With enhanced LDAP integration, you can set up synchronization between LDAP groups and Grafana teams and roles. This enables users that are members
of certain LDAP groups to automatically be added to teams and gain roles in Grafana.
The below example shows an LDAP group member mapped to a Grafana team.
![LDAP group synchronization](/static/img/docs/enterprise/team_members_ldap.png)
Grafana keeps track of all synchronized users in teams, and you can see which users have been synchronized from LDAP in the team members list, see `LDAP` label in screenshot.
This mechanism allows Grafana to remove an existing synchronized user from a team when its LDAP group membership changes. This mechanism also allows you to manually add
a user as member of a team, and it will not be removed when the user signs in. This gives you flexibility to combine LDAP group memberships and Grafana team memberships.
[Learn more about team sync.]({{< relref "../../configure-team-sync" >}})
To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync).
<div class="clearfix"></div>
@@ -127,7 +127,7 @@ To integrate your OAuth2 provider with Grafana using our Generic OAuth authentic
c. Enable the refresh token on the provider if required.
1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}).
1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}).
1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}).
1. Restart Grafana.
You should now see a Generic OAuth login button on the login page and be able to log in or sign up with your OAuth2 provider.
@@ -328,19 +328,21 @@ org_attribute_path = info.roles
org_mapping = org_foo:org_foo:Viewer org_bar:org_bar:Editor *:org_baz:Editor
```
### Configure team synchronization
## Configure group synchronization
> **Note:** Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/).
{{< admonition type="note" >}}
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
{{< /admonition >}}
By using Team Sync, you can link your OAuth2 groups to teams within Grafana. This will automatically assign users to the appropriate teams.
Teams for each user are synchronized when the user logs in.
Grafana supports synchronization of OAuth2 groups with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or automatically granting them the mapped roles.
Teams and roles get synchronized when the user logs in.
Generic OAuth groups can be referenced by group ID, such as `8bab1c86-8fba-33e5-2089-1d1c80ec267d` or `myteam`.
For information on configuring OAuth2 groups with Grafana using the `groups_attribute_path` configuration option, refer to [configuration options]({{< relref "#configuration-options" >}}).
To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}).
To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync).
#### Team synchronization example
#### Group attribute synchronization example
Configuration:
@@ -110,7 +110,7 @@ To configure GitHub authentication with Grafana, follow these steps:
Review the list of other GitHub [configuration options]({{< relref "#configuration-options" >}}) and complete them, as necessary.
1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}).
1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}).
1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}).
1. Restart Grafana.
You should now see a GitHub login button on the login page and be able to log in or sign up with your GitHub accounts.
@@ -216,14 +216,14 @@ allowed_domains = mycompany.com mycompany.org
role_attribute_path = [login=='octocat'][0] && 'GrafanaAdmin' || 'Viewer'
```
## Configure team synchronization
## Configure group synchronization
{{< admonition type="note" >}}
Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and Grafana Cloud.
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
{{< /admonition >}}
By using Team Sync, you can map teams from your GitHub organization to teams within Grafana. This will automatically assign users to the appropriate teams.
Teams for each user are synchronized when the user logs in.
Grafana supports synchronization of teams from your GitHub organization with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or granting them the mapped roles.
Teams and roles get synchronized when the user logs in.
GitHub teams can be referenced in two ways:
@@ -232,7 +232,7 @@ GitHub teams can be referenced in two ways:
Examples: `https://github.com/orgs/grafana/teams/developers` or `@grafana/developers`.
To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}).
To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync).
## Configuration options
@@ -119,7 +119,7 @@ To configure GitLab authentication with Grafana, follow these steps:
a. Set `use_refresh_token` to `true` in `[auth.gitlab]` section in Grafana configuration file.
1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}).
1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization" >}}).
1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization" >}}).
1. Restart Grafana.
You should now see a GitLab login button on the login page and be able to log in or sign up with your GitLab accounts.
@@ -242,20 +242,20 @@ use_pkce = true
use_refresh_token = true
```
## Configure team synchronization
## Configure group synchronization
{{% admonition type="note" %}}
Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/).
{{% /admonition %}}
{{< admonition type="note" >}}
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
{{< /admonition >}}
By using Team Sync, you can map GitLab groups to teams within Grafana. This will automatically assign users to the appropriate teams.
Teams for each user are synchronized when the user logs in.
Grafana supports synchronization of GitLab groups with Grafana teams and roles. This allows automatically assigning users to the appropriate teams or granting them the mapped roles.
Teams and roles get synchronized when the user logs in.
GitLab groups are referenced by the group name. For example, `developers`. To reference a subgroup `frontend`, use `developers/frontend`.
Note that in GitLab, the group or subgroup name does not always match its display name, especially if the display name contains spaces or special characters.
Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup.
To learn more about Team Sync, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}).
To learn more about group synchronization, refer to [Configure team sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-team-sync) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync).
## Configuration options
@@ -165,9 +165,15 @@ This setting is ignored if multiple auth providers are configured to use auto lo
auto_login = true
```
### Configure team sync for Google OAuth
### Configure group synchronization
With team sync, you can easily add users to teams by utilizing their Google groups. To set up team sync for Google OAuth, refer to the following example.
{{< admonition type="note" >}}
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
{{< /admonition >}}
Grafana supports syncing users to teams and roles based on their Google groups.
To set up group sync for Google OAuth:
1. Enable the Google Cloud Identity API on your [organization's dashboard](https://console.cloud.google.com/apis/api/cloudidentity.googleapis.com/).
@@ -181,10 +187,9 @@ With team sync, you can easily add users to teams by utilizing their Google grou
scopes = openid email profile https://www.googleapis.com/auth/cloud-identity.groups.readonly
```
1. Configure team sync in your Grafana team's `External group sync` tab.
The external group ID for a Google group is the group's email address, such as `dev@grafana.com`.
The external group ID for a Google group is the group's email address, such as `dev@grafana.com`.
To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}).
To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
#### Configure allowed groups
@@ -23,6 +23,10 @@ This method of authentication is useful for integrating with other systems that
use JWKS but can't directly integrate with Grafana or if you want to use pass-through
authentication in an app embedding Grafana.
{{% admonition type="note" %}}
Grafana does not currently support refresh tokens.
{{% /admonition %}}
## Enable JWT
To use JWT authentication:
@@ -183,7 +187,8 @@ key_id = my-key-id
By default, only `"exp"`, `"nbf"` and `"iat"` claims are validated.
You might also want to validate that other claims are really what you expect them to be.
Consider validating that other claims match your expectations by using the `expect_claims` configuration option.
Token claims must match exactly the values set here.
```ini
# This can be seen as a required "subset" of a JWT Claims Set.
@@ -94,7 +94,7 @@ roles
```
{{% admonition type="warning" %}}
these scopes do not add group claims to the id_token. Without group claims, teamsync will not work. Teamsync is covered further down in this document.
These scopes do not add group claims to the id_token. Without group claims, group synchronization will not work. Group synchronization is covered further down in this document.
{{% /admonition %}}
3. For role mapping to work with the example configuration above,
@@ -106,16 +106,18 @@ editor
viewer
```
## Teamsync
## Group synchronization
{{% admonition type="note" %}}
Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud](/docs/grafana-cloud/).
{{% /admonition %}}
{{< admonition type="note" >}}
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
{{< /admonition >}}
[Teamsync]({{< relref "../../configure-team-sync" >}}) is a feature that allows you to map groups from your identity provider to Grafana teams. This is useful if you want to give your users access to specific dashboards or folders based on their group membership.
By using group synchronization, you can link your Keycloak groups to teams and roles within Grafana. This allows automatically assigning users to the appropriate teams or granting them the mapped roles.
This is useful if you want to give your users access to specific resources based on their group membership.
Teams and roles get synchronized when the user logs in.
To enable teamsync, you need to add a `groups` mapper to the client configuration in Keycloak.
This will add the `groups` claim to the id_token. You can then use the `groups` claim to map groups to teams in Grafana.
To enable group synchronization, you need to add a `groups` mapper to the client configuration in Keycloak.
This will add the `groups` claim to the id_token. You can then use the `groups` claim to map groups to teams and roles in Grafana.
1. In the client configuration, head to `Mappers` and create a mapper with the following settings:
@@ -141,6 +143,8 @@ If you use nested groups containing special characters such as quotes or colons,
groups_attribute_path = reverse("Global:department")
```
To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
## Enable Single Logout
To enable Single Logout, you need to add the following option to the configuration of Grafana:
@@ -169,7 +169,7 @@ To integrate your Okta OIDC provider with Grafana using our Okta OIDC integratio
1. Optional: [Configure a refresh token]({{< relref "#configure-a-refresh-token" >}}).
1. [Configure role mapping]({{< relref "#configure-role-mapping" >}}).
1. Optional: [Configure team synchronization]({{< relref "#configure-team-synchronization-enterprise-only" >}}).
1. Optional: [Configure group synchronization]({{< relref "#configure-group-synchronization-enterprise-only" >}}).
1. Restart Grafana.
You should now see a Okta OIDC login button on the login page and be able to log in or sign up with your OIDC provider.
@@ -243,20 +243,18 @@ org_attribute_path = groups
org_mapping = ["Group 1:org_foo:Viewer", "Group 2:org_bar:Editor", "*:3:Editor"]
```
### Configure team synchronization (Enterprise only)
### Configure group synchronization (Enterprise only)
{{% admonition type="note" %}}
Available in [Grafana Enterprise]({{< relref "../../../../introduction/grafana-enterprise" >}}) and [Grafana Cloud]({{< relref "../../../../introduction/grafana-cloud" >}}).
{{% /admonition %}}
{{< admonition type="note" >}}
Available in [Grafana Enterprise](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/).
{{< /admonition >}}
By using Team Sync, you can link your Okta groups to teams within Grafana. This will automatically assign users to the appropriate teams.
Map your Okta groups to teams in Grafana so that your users will automatically be added to
the correct teams.
By using group synchronization, you can link your Okta groups to teams and roles within Grafana. This allows automatically assigning users to the appropriate teams or granting them the mapped roles.
Teams and roles get synchronized when the user logs in.
Okta groups can be referenced by group names, like `Admins` or `Editors`.
To learn more about Team Sync, refer to [Configure Team Sync]({{< relref "../../configure-team-sync" >}}).
To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
## Configuration options
@@ -113,8 +113,8 @@ Sign in to Grafana and navigate to **Administration > Authentication > Configure
1. If you wish to [map user information from SAML assertions]({{< relref "../saml#assertion-mapping" >}}), complete the **Assertion attributes mappings** section.
You also need to configure the **Groups attribute** field if you want to use team sync. Team sync automatically maps users to Grafana teams based on their SAML group membership.
Learn more about [team sync]({{< relref "../../configure-team-sync" >}}) and [configuring team sync for SAML]({{< relref "../saml#configure-team-sync" >}}).
You also need to configure the **Groups attribute** field if you want to use group synchronization. Group sync allows you to automatically map users to Grafana teams or role-based access control roles based on their SAML group membership.
To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
1. If you want to automatically assign users' roles based on their SAML roles, complete the **Role mapping** section.
@@ -410,12 +410,15 @@ This setting is ignored if multiple auth providers are configured to use auto lo
auto_login = true
```
### Configure team sync
### Configure group synchronization
To use SAML Team sync, set [`assertion_attribute_groups`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_groups" >}}) to the attribute name where you store user groups. Then Grafana will use attribute values extracted from SAML assertion to add user into the groups with the same name configured on the External group sync tab.
Group synchronization allows you to map user groups from an identity provider to Grafana teams and roles.
To use SAML group synchronization, set [`assertion_attribute_groups`]({{< relref "../../../configure-grafana/enterprise-configuration#assertion_attribute_groups" >}}) to the attribute name where you store user groups.
Then Grafana will use attribute values extracted from SAML assertion to add user to Grafana teams and grant them roles.
{{% admonition type="note" %}}
Teamsync allows you sync users from SAML to Grafana teams. It does not automatically create teams in Grafana. You need to create teams in Grafana before you can use this feature.
Team sync allows you sync users from SAML to Grafana teams. It does not automatically create teams in Grafana. You need to create teams in Grafana before you can use this feature.
{{% /admonition %}}
Given the following partial SAML assertion:
@@ -445,12 +448,12 @@ The configuration would look like this:
assertion_attribute_groups = groups
```
The following `External Group ID`s would be valid for input in the desired team's _External group sync_ tab:
The following `External Group ID`s would be valid for configuring team sync or role sync in Grafana:
- `admins_group`
- `division_1`
[Learn more about Team Sync]({{< relref "../../configure-team-sync" >}})
To learn more about how to configure group synchronization, refer to [Configure team sync]({{< relref "../../configure-team-sync" >}}) and [Configure group attribute sync](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-group-attribute-sync) documentation.
### Configure role sync
@@ -26,6 +26,17 @@ If a group mapping is removed, the role assignment to users for the group mappin
Role mappings are tied to organizations, so you can have different mappings for different organizations.
## Supported providers
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
- [Generic OAuth integration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/generic-oauth#configure-group-synchronization)
- [GitHub OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/github#configure-group-synchronization)
- [GitLab OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/gitlab#configure-group-synchronization)
- [Google OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/google#configure-group-synchronization)
- [LDAP](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization)
- [Okta](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only)
- [SAML](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-security/configure-authentication/saml#configure-group-synchronization)
## Create role mappings for a new group
For information about creating group mappings via the API, refer to [create group mappings reference](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/developers/http_api/group_attribute_sync#create-group-mappings).
@@ -28,14 +28,15 @@ This mechanism allows Grafana to remove an existing synchronized user from a tea
## Supported providers
- [Auth Proxy]({{< relref "./configure-authentication/auth-proxy#team-sync-enterprise-only" >}})
- [Azure AD]({{< relref "./configure-authentication/azuread#team-sync-enterprise-only" >}})
- [Generic OAuth integration]({{< relref "./configure-authentication/generic-oauth#configure-team-synchronization" >}})
- [GitHub OAuth]({{< relref "./configure-authentication/github#configure-team-synchronization" >}})
- [GitLab OAuth]({{< relref "./configure-authentication/gitlab#configure-team-synchronization" >}})
- [Google OAuth]({{< relref "./configure-authentication/google#configure-team-sync-for-google-oauth" >}})
- [LDAP]({{< relref "./configure-authentication/enhanced-ldap#ldap-group-synchronization-for-teams" >}})
- [Okta]({{< relref "./configure-authentication/okta#configure-team-synchronization-enterprise-only" >}})
- [SAML]({{< relref "./configure-authentication/saml#configure-team-sync" >}})
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-authentication/azuread#group-sync-enterprise-only)
- [Azure AD](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/azuread#group-sync-enterprise-only)
- [Generic OAuth integration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/generic-oauth#configure-group-synchronization)
- [GitHub OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/github#configure-group-synchronization)
- [GitLab OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/gitlab#configure-group-synchronization)
- [Google OAuth](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/google#configure-group-synchronization)
- [LDAP](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/enhanced-ldap#ldap-group-synchronization)
- [Okta](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/okta#configure-group-synchronization-enterprise-only)
- [SAML](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/configure-security/configure-authentication/saml#configure-group-synchronization)
## Synchronize a Grafana team with an external group
@@ -82,7 +82,7 @@ To demonstrate the observation of data using the Grafana stack, download and run
1. Download and save a Docker compose file to run Grafana, Loki and Promtail.
```bash
wget https://raw.githubusercontent.com/grafana/loki/v2.8.0/production/docker-compose.yaml -O docker-compose.yaml
wget https://raw.githubusercontent.com/grafana/loki/refs/heads/main/production/docker-compose.yaml -O docker-compose.yaml
```
2. Run the Grafana stack.
@@ -234,7 +234,7 @@ In this section, we use the default options for Grafana-managed alert rule creat
It should return alert instances from log lines with a status code that is not 200 (OK), and that has met the alert condition. The condition for the alert rule to fire is any occurrence that goes over the threshold of `0`. Since the Loki query has returned more than zero alert instances, the alert rule is `Firing`.
{{< figure src="/media/docs/alerting/expression-loki-alert.png" max-width="1200px" caption="Preview of a firing alert instances" >}}
{{< figure src="/media/docs/alerting/firing-loki-alert-rule.png" max-width="1200px" caption="Preview of a firing alert instances" >}}
### Set evaluation behavior
@@ -82,7 +82,11 @@ During these times, no scheduled releases will be executed. However, this does n
We've enhanced our naming convention for security release versions to make it easier to clearly identify our security releases from our standard patching releases.
In the past, critical vulnerabilities triggered unscheduled releases that incremented the patch version (e.g., 10.3.0 to 10.3.1). However, we found that the naming convention for these releases didn't clearly communicate the nature of the update. For example, if there was a version change from 11.3.0 to 11.3.1, there was no indication whether it was a security fix, a bug fix, or a minor feature update. This lack of clarity led to confusion about the urgency and nature of the update.
In the past, critical vulnerabilities triggered unscheduled releases that incremented the patch version (e.g., 10.3.0 to 10.3.1). However, we found that the naming convention for these releases didn't clearly communicate the nature of the update. For example, if there was a version change from 11.3.0 to 11.3.1, there was no indication whether it was a security fix, a bug fix, or a minor feature update. This lack of clarity led to confusion about the urgency and nature of the update. <br>
{{< admonition type="note" >}}
Docker does not allow the plus sign (`+`) in image tag names. A plus sign (`+`) will be a rendered as a dash (`-`) in the docker tag.
{{< /admonition >}}
Our new approach directly addresses this issue. Going forward, security releases will be appended with "+security" to indicate that the release is the indicated version PLUS the security fix.
+65
View File
@@ -0,0 +1,65 @@
import { e2e } from '../utils';
import { fromBaseUrl } from '../utils/support/url';
describe('Verify i18n', () => {
const I18N_USER = 'i18n-test';
const I18N_PASSWORD = 'i18n-test';
// create a new user to isolate the language changes from other tests
before(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
cy.request({
method: 'POST',
url: fromBaseUrl('/api/admin/users'),
body: {
email: I18N_USER,
login: I18N_USER,
name: I18N_USER,
password: I18N_PASSWORD,
},
}).then((response) => {
cy.wrap(response.body.uid).as('uid');
});
});
// remove the user created in the before hook
after(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
cy.get('@uid').then((uid) => {
cy.request({
method: 'DELETE',
url: fromBaseUrl(`/api/admin/users/${uid}`),
});
});
});
beforeEach(() => {
e2e.flows.login(I18N_USER, I18N_PASSWORD);
});
// map between languages in the language picker and the corresponding translation of the 'Language' label
const languageMap: Record<string, string> = {
Deutsch: 'Sprache',
English: 'Language',
Español: 'Idioma',
Français: 'Langue',
'Português Brasileiro': 'Idioma',
'中文(简体)': '语言',
};
// basic test which loops through the defined languages in the picker
// and verifies that the corresponding label is translated correctly
it('loads all the languages correctly', () => {
cy.visit('/profile');
const LANGUAGE_SELECTOR = '[id="locale-select"]';
cy.wrap(Object.entries(languageMap)).each(([language, label]: [string, string]) => {
cy.get(LANGUAGE_SELECTOR).should('not.be.disabled');
cy.get(LANGUAGE_SELECTOR).click();
cy.get(LANGUAGE_SELECTOR).clear().type(language).type('{downArrow}{enter}');
e2e.components.UserProfile.preferencesSaveButton().click();
cy.contains('label', label).should('be.visible');
cy.get(LANGUAGE_SELECTOR).should('have.value', language);
});
});
});
+5 -5
View File
@@ -16,7 +16,7 @@ require (
cloud.google.com/go/kms v1.18.5 // @grafana/grafana-backend-group
cloud.google.com/go/storage v1.43.0 // @grafana/grafana-backend-group
connectrpc.com/connect v1.17.0 // @grafana/observability-traces-and-profiling
cuelang.org/go v0.6.0-0.dev // @grafana/grafana-as-code
cuelang.org/go v0.8.2 // @grafana/grafana-as-code
filippo.io/age v1.1.1 // @grafana/identity-access-team
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // @grafana/partner-datasources
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 // @grafana/grafana-backend-group
@@ -75,7 +75,7 @@ require (
github.com/grafana/alerting v0.0.0-20241021123319-be61d61f71e7 // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a // @grafana/identity-access-team
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d // @grafana/identity-access-team
github.com/grafana/codejen v0.0.3 // @grafana/dataviz-squad
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d // @grafana/dataviz-squad
github.com/grafana/cuetsy v0.1.11 // @grafana/grafana-as-code
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
github.com/grafana/dataplane/sdata v0.0.9 // @grafana/observability-metrics
@@ -109,7 +109,7 @@ require (
github.com/hashicorp/go-version v1.7.0 // @grafana/grafana-backend-group
github.com/hashicorp/golang-lru/v2 v2.0.7 // @grafana/alerting-backend
github.com/hashicorp/hcl/v2 v2.17.0 // @grafana/alerting-backend
github.com/huandu/xstrings v1.3.3 // @grafana/partner-datasources
github.com/huandu/xstrings v1.5.0 // @grafana/partner-datasources
github.com/influxdata/influxdb-client-go/v2 v2.13.0 // @grafana/observability-metrics
github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // @grafana/grafana-app-platform-squad
github.com/jmespath/go-jmespath v0.4.0 // indirect; @grafana/grafana-backend-group
@@ -266,7 +266,7 @@ require (
github.com/edsrzf/mmap-go v1.1.0 // indirect
github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/emicklei/proto v1.10.0 // indirect
github.com/emicklei/proto v1.13.2 // indirect
github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -382,7 +382,7 @@ require (
github.com/prometheus/common/sigv4 v0.1.0 // indirect
github.com/prometheus/exporter-toolkit v0.11.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 // indirect
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 // indirect
github.com/redis/rueidis v1.0.45 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+8 -7
View File
@@ -1833,8 +1833,8 @@ github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac/go.mod h1:gNh8
github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emicklei/proto v1.10.0 h1:pDGyFRVV5RvV+nkBK9iy3q67FBy9Xa7vwrOTE+g5aGw=
github.com/emicklei/proto v1.10.0/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY=
github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
@@ -2268,8 +2268,8 @@ github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a h1:X3mroOOXdryRY6S
github.com/grafana/authlib v0.0.0-20241024120339-84cd3a898e8a/go.mod h1:XFhcSCEDeOgV43x41we7mRBcizDpsTScb7XOuYipQZg=
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d h1:7nZfaXdC4Xc2ocMz5/Bx/3EsaEO34KsmA2RRcuogLnc=
github.com/grafana/authlib/claims v0.0.0-20241024115517-d30b00d7666d/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw=
github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
github.com/grafana/cue v0.0.0-20230926092038-971951014e3f h1:TmYAMnqg3d5KYEAaT6PtTguL2GjLfvr6wnAX8Azw6tQ=
github.com/grafana/cue v0.0.0-20230926092038-971951014e3f/go.mod h1:okjJBHFQFer+a41sAe2SaGm1glWS8oEb6CmJvn5Zdws=
github.com/grafana/cuetsy v0.1.11 h1:I3IwBhF+UaQxRM79HnImtrAn8REGdb5M3+C4QrYHoWk=
@@ -2472,8 +2472,9 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
@@ -3036,8 +3037,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/prometheus v0.52.0 h1:f7kHJgr7+zShpWdTCeKqbCWR7nKTScgLYQwRux9h1V0=
github.com/prometheus/prometheus v0.52.0/go.mod h1:3z74cVsmVH0iXOR5QBjB7Pa6A0KJeEAK5A6UsmAFb1g=
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 h1:sadMIsgmHpEOGbUs6VtHBXRR1OHevnj7hLx9ZcdNGW4=
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 h1:MAzmm+JtFxQwTPb1cVMLkemw2OxLy5AB/d/rxtAwGQQ=
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU=
github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+1
View File
@@ -6,6 +6,7 @@ go 1.23.1
use (
. // skip:golangci-lint
./apps/playlist
./kindsv2
./pkg/aggregator
./pkg/apimachinery
./pkg/apiserver
+52
View File
@@ -0,0 +1,52 @@
//go:generate go run gen.go
package main
import (
"context"
"os"
"cuelang.org/go/cue/cuecontext"
"github.com/grafana/cog"
)
type codegenTargets struct {
schemaPath string
outputPath string
}
func main() {
cueCtx := cuecontext.New()
targets := []codegenTargets{
{
schemaPath: "../packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue",
outputPath: "../packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts",
},
}
for _, target := range targets {
rawSchema, err := os.ReadFile(target.schemaPath)
if err != nil {
panic(err)
}
value := cueCtx.CompileBytes(rawSchema)
if value.Err() != nil {
panic(value.Err())
}
codegenPipeline := cog.TypesFromSchema().
CUEValue("dashboard", value).
Typescript()
tsBytes, err := codegenPipeline.Run(context.Background())
if err != nil {
panic(err)
}
if err := os.WriteFile(target.outputPath, tsBytes, 0644); err != nil {
panic(err)
}
}
}
+46
View File
@@ -0,0 +1,46 @@
module github.com/grafana/grafana/kindsv2
go 1.23.1
require (
cuelang.org/go v0.8.2
github.com/grafana/cog v0.0.0-20241029201114-f7f7db0c1070
)
require (
cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565 // indirect
github.com/cockroachdb/apd/v3 v3.2.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/proto v1.13.2 // indirect
github.com/expr-lang/expr v1.16.9 // indirect
github.com/getkin/kin-openapi v0.128.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yalue/merged_fs v1.3.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/tools v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+94
View File
@@ -0,0 +1,94 @@
cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565 h1:R5wwEcbEZSBmeyg91MJZTxfd7WpBo2jPof3AYjRbxwY=
cuelabs.dev/go/oci/ociregistry v0.0.0-20240906074133-82eb438dd565/go.mod h1:5A4xfTzHTXfeVJBU6RAUf+QrlfTCW+017q/QiW+sMLg=
cuelang.org/go v0.8.2 h1:vWfHI1kQlBvwkna7ktAqXjV5LUEAgU6vyMlJjvZZaDw=
cuelang.org/go v0.8.2/go.mod h1:CoDbYolfMms4BhWUlhD+t5ORnihR7wvjcfgyO9lL5FI=
github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg=
github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY=
github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI=
github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/getkin/kin-openapi v0.128.0 h1:jqq3D9vC9pPq1dGcOCv7yOp1DaEe7c/T1vzcLbITSp4=
github.com/getkin/kin-openapi v0.128.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
github.com/grafana/cog v0.0.0-20241029201114-f7f7db0c1070 h1:ncAkFmIq3UbPiPhQ2CuBzdTuwnDqLsFFNqEIXKaiPGA=
github.com/grafana/cog v0.0.0-20241029201114-f7f7db0c1070/go.mod h1:FqZi9WZ/Uzvs3tvo7l+OViThCpfvu3KDGDqHCB2LNbg=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 h1:MAzmm+JtFxQwTPb1cVMLkemw2OxLy5AB/d/rxtAwGQQ=
github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yalue/merged_fs v1.3.0 h1:qCeh9tMPNy/i8cwDsQTJ5bLr6IRxbs6meakNE5O+wyY=
github.com/yalue/merged_fs v1.3.0/go.mod h1:WqqchfVYQyclV2tnR7wtRhBddzBvLVR83Cjw9BKQw0M=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1 -1
View File
@@ -279,7 +279,7 @@
"@grafana/ui": "workspace:*",
"@hello-pangea/dnd": "17.0.0",
"@kusto/monaco-kusto": "^10.0.0",
"@leeoniya/ufuzzy": "1.0.14",
"@leeoniya/ufuzzy": "1.0.16",
"@lezer/common": "1.2.3",
"@lezer/highlight": "1.2.1",
"@lezer/lr": "1.4.2",
@@ -85,12 +85,30 @@ function createDebug(): GrafanaTheme2 {
border: '#ff000088',
text: '#cccc00',
contrastText: '#ffff00',
shade: '#9900dd',
},
secondary: {
...baseDarkColors.secondary,
border: '#ff000088',
text: '#cccc00',
contrastText: '#ffff00',
shade: '#9900dd',
},
info: {
...baseDarkColors.info,
shade: '#9900dd',
},
warning: {
...baseDarkColors.warning,
shade: '#9900dd',
},
success: {
...baseDarkColors.success,
shade: '#9900dd',
},
error: {
...baseDarkColors.error,
shade: '#9900dd',
},
action: {
hover: '#9900dd',
@@ -54,6 +54,7 @@ export interface ExploreLogsPanelState {
labelFieldName?: string;
// Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized
refId?: string;
displayedFields?: string[];
}
export interface SplitOpenOptions<T extends AnyQuery = AnyQuery> {
+1 -1
View File
@@ -46,7 +46,7 @@
"@emotion/css": "11.13.4",
"@grafana/data": "11.4.0-pre",
"@grafana/ui": "11.4.0-pre",
"@leeoniya/ufuzzy": "1.0.14",
"@leeoniya/ufuzzy": "1.0.16",
"d3": "^7.8.5",
"lodash": "4.17.21",
"react": "18.2.0",
+2 -2
View File
@@ -40,12 +40,12 @@
"@floating-ui/react": "0.26.28",
"@grafana/data": "11.4.0-pre",
"@grafana/experimental": "2.1.2",
"@grafana/faro-web-sdk": "1.12.0",
"@grafana/faro-web-sdk": "1.12.1",
"@grafana/runtime": "11.4.0-pre",
"@grafana/schema": "11.4.0-pre",
"@grafana/ui": "11.4.0-pre",
"@hello-pangea/dnd": "17.0.0",
"@leeoniya/ufuzzy": "1.0.14",
"@leeoniya/ufuzzy": "1.0.16",
"@lezer/common": "1.2.3",
"@lezer/highlight": "1.2.1",
"@lezer/lr": "1.4.2",
@@ -19,6 +19,7 @@ import {
AdHocVariableFilter,
} from '@grafana/data';
import { reportInteraction } from '../analytics/utils';
import { config } from '../config';
import {
BackendSrvRequest,
@@ -355,8 +356,17 @@ class DataSourceWithBackend<
headers: this.getRequestHeaders(),
})
)
.then((v: FetchResponse) => v.data)
.catch((err) => err.data);
.then((v: FetchResponse<HealthCheckResult>) => v.data)
.catch((err) => {
let properties: Record<string, string> = {
plugin_id: this.meta?.id || '',
plugin_version: this.meta?.info?.version || '',
datasource_healthcheck_status: err?.data?.status || 'error',
datasource_healthcheck_message: err?.data?.message || '',
};
reportInteraction('datasource_health_check_completed', properties);
return err.data;
});
}
/**
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,789 @@
package dashboard
DashboardV2Spec: {
// Unique numeric identifier for the dashboard.
// `id` is internal to a specific Grafana instance. `uid` should be used to identify a dashboard across Grafana instances.
id?: int64
// Title of dashboard.
title: string
// Description of dashboard.
description?: string
// Configuration of dashboard cursor sync behavior.
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
cursorSync: DashboardCursorSync
// When set to true, the dashboard will redraw panels at an interval matching the pixel width.
// This will keep data "moving left" regardless of the query refresh rate. This setting helps
// avoid dashboards presenting stale live data.
liveNow?: bool
// When set to true, the dashboard will load all panels in the dashboard when it's loaded.
preload: bool
// Whether a dashboard is editable or not.
editable?: bool | *true
// Links with references to other dashboards or external websites.
links: [...DashboardLink]
// Tags associated with dashboard.
tags?: [...string]
timeSettings: TimeSettingsSpec
// Configured template variables.
variables: [...QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind]
elements: [ElementReference.name]: PanelKind // |* more element types in the future
annotations: [...AnnotationQueryKind]
layout: GridLayoutKind
// Version of the JSON schema, incremented each time a Grafana update brings
// changes to said schema.
schemaVersion: uint16 | *39
// version: will rely on k8s resource versioning, via metadata.resorceVersion
// revision?: int // for plugins only
// gnetId?: string // ??? Wat is this used for?
}
AnnotationPanelFilter: {
// Should the specified panels be included or excluded
exclude?: bool | *false
// Panel IDs that should be included or excluded
ids: [...uint8]
}
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
DashboardCursorSync: "Off" | "Crosshair" | "Tooltip"
// Links with references to other dashboards or external resources
DashboardLink: {
// Title to display with the link
title: string
// Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
// FIXME: The type is generated as `type: DashboardLinkType | dashboardLinkType.Link;` but it should be `type: DashboardLinkType`
type: DashboardLinkType
// Icon name to be displayed with the link
icon: string
// Tooltip to display when the user hovers their mouse over it
tooltip: string
// Link URL. Only required/valid if the type is link
url?: string
// List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards
tags: [...string]
// If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards
asDropdown: bool | *false
// If true, the link will be opened in a new tab
targetBlank: bool | *false
// If true, includes current template variables values in the link as query params
includeVars: bool | *false
// If true, includes current time range in the link as query params
keepTime: bool | *false
}
DataSourceRef: {
// The plugin type-id
type?: string
// Specific datasource instance
uid?: string
}
// Transformations allow to manipulate data returned by a query before the system applies a visualization.
// Using transformations you can: rename fields, join time series data, perform mathematical operations across queries,
// use the output of one transformation as the input to another transformation, etc.
DataTransformerConfig: {
// Unique identifier of transformer
id: string
// Disabled transformations are skipped
disabled?: bool
// Optional frame matcher. When missing it will be applied to all results
filter?: MatcherConfig
// Where to pull DataFrames from as input to transformation
topic?: "series" | "annotations" | "alertStates" // replaced with common.DataTopic
// Options to be passed to the transformer
// Valid options depend on the transformer id
options: _
}
// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.
// Each column within this structure is called a field. A field can represent a single time series or table column.
// Field options allow you to change how the data is displayed in your visualizations.
FieldConfigSource: {
// Defaults are the options applied to all fields.
defaults: FieldConfig
// Overrides are the options applied to specific fields overriding the defaults.
overrides: [...{
matcher: MatcherConfig
properties: [...DynamicConfigValue]
}]
}
// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.
// Each column within this structure is called a field. A field can represent a single time series or table column.
// Field options allow you to change how the data is displayed in your visualizations.
FieldConfig: {
// The display value for this field. This supports template variables blank is auto
displayName?: string
// This can be used by data sources that return and explicit naming structure for values and labels
// When this property is configured, this value is used rather than the default naming strategy.
displayNameFromDS?: string
// Human readable field metadata
description?: string
// An explicit path to the field in the datasource. When the frame meta includes a path,
// This will default to `${frame.meta.path}/${field.name}
//
// When defined, this value can be used as an identifier within the datasource scope, and
// may be used to update the results
path?: string
// True if data source can write a value to the path. Auth/authz are supported separately
writeable?: bool
// True if data source field supports ad-hoc filters
filterable?: bool
// Unit a field should use. The unit you select is applied to all fields except time.
// You can use the units ID availables in Grafana or a custom unit.
// Available units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts
// As custom unit, you can use the following formats:
// `suffix:<suffix>` for custom unit that should go after value.
// `prefix:<prefix>` for custom unit that should go before value.
// `time:<format>` For custom date time formats type for example `time:YYYY-MM-DD`.
// `si:<base scale><unit characters>` for custom SI units. For example: `si: mF`. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.
// `count:<unit>` for a custom count unit.
// `currency:<unit>` for custom a currency unit.
unit?: string
// Specify the number of decimals Grafana includes in the rendered value.
// If you leave this field blank, Grafana automatically truncates the number of decimals based on the value.
// For example 1.1234 will display as 1.12 and 100.456 will display as 100.
// To display all decimals, set the unit to `String`.
decimals?: number
// The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.
min?: number
// The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.
max?: number
// Convert input values into a display string
mappings?: [...ValueMapping]
// Map numeric values to states
thresholds?: ThresholdsConfig
// Panel color configuration
color?: FieldColor
// The behavior when clicking on a result
links?: [...]
// Alternative to empty string
noValue?: string
// custom is specified by the FieldConfig field
// in panel plugin schemas.
custom?: {...}
}
DynamicConfigValue: {
id: string | *""
value?: _
}
// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.
// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.
MatcherConfig: {
// The matcher id. This is used to find the matcher implementation from registry.
id: string | *""
// The matcher options. This is specific to the matcher implementation.
options?: _
}
Threshold: {
value: number | null
color: string
}
ThresholdsMode: "absolute" | "percentage"
ThresholdsConfig: {
mode: ThresholdsMode
steps: [...Threshold]
}
ValueMapping: ValueMap | RangeMap | RegexMap | SpecialValueMap
// Supported value mapping types
// `value`: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.
MappingType: "value" | "range" | "regex" | "special"
// Maps text values to a color or different display text and color.
// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
ValueMap: {
type: MappingType & "value"
// Map with <value_to_match>: ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } }
options: [string]: ValueMappingResult
}
// Maps numerical ranges to a display text and color.
// For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
RangeMap: {
type: MappingType & "range"
// Range to match against and the result to apply when the value is within the range
options: {
// Min value of the range. It can be null which means -Infinity
from: float64 | null
// Max value of the range. It can be null which means +Infinity
to: float64 | null
// Config to apply when the value is within the range
result: ValueMappingResult
}
}
// Maps regular expressions to replacement text and a color.
// For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
RegexMap: {
type: MappingType & "regex"
// Regular expression to match against and the result to apply when the value matches the regex
options: {
// Regular expression to match against
pattern: string
// Config to apply when the value matches the regex
result: ValueMappingResult
}
}
// Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.
// See SpecialValueMatch to see the list of special values.
// For example, you can configure a special value mapping so that null values appear as N/A.
SpecialValueMap: {
type: MappingType & "special"
options: {
// Special value to match against
match: SpecialValueMatch
// Config to apply when the value matches the special value
result: ValueMappingResult
}
}
// Special value types supported by the `SpecialValueMap`
SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty"
// Result used as replacement with text and color when the value matches
ValueMappingResult: {
// Text to display when the value matches
text?: string
// Text to use when the value matches
color?: string
// Icon to display when the value matches. Only specific visualizations.
icon?: string
// Position in the mapping array. Only used internally.
index?: int32
}
// Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.
// Continuous color interpolates a color using the percentage of a value relative to min and max.
// Accepted values are:
// `thresholds`: From thresholds. Informs Grafana to take the color from the matching threshold
// `palette-classic`: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations
// `palette-classic-by-name`: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations
// `continuous-GrYlRd`: ontinuous Green-Yellow-Red palette mode
// `continuous-RdYlGr`: Continuous Red-Yellow-Green palette mode
// `continuous-BlYlRd`: Continuous Blue-Yellow-Red palette mode
// `continuous-YlRd`: Continuous Yellow-Red palette mode
// `continuous-BlPu`: Continuous Blue-Purple palette mode
// `continuous-YlBl`: Continuous Yellow-Blue palette mode
// `continuous-blues`: Continuous Blue palette mode
// `continuous-reds`: Continuous Red palette mode
// `continuous-greens`: Continuous Green palette mode
// `continuous-purples`: Continuous Purple palette mode
// `shades`: Shades of a single color. Specify a single color, useful in an override rule.
// `fixed`: Fixed color mode. Specify a single color, useful in an override rule.
FieldColorModeId: "thresholds" | "palette-classic" | "palette-classic-by-name" | "continuous-GrYlRd" | "continuous-RdYlGr" | "continuous-BlYlRd" | "continuous-YlRd" | "continuous-BlPu" | "continuous-YlBl" | "continuous-blues" | "continuous-reds" | "continuous-greens" | "continuous-purples" | "fixed" | "shades"
// Defines how to assign a series color from "by value" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.
FieldColorSeriesByMode: "min" | "max" | "last"
// Map a field to a color.
FieldColor: {
// The main color scheme mode.
mode: FieldColorModeId
// The fixed color value for fixed or shades color modes.
fixedColor?: string
// Some visualizations need to know how to assign a series color from by value color schemes.
seriesBy?: FieldColorSeriesByMode
}
// Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)
DashboardLinkType: "link" | "dashboards"
// --- Common types ---
Kind: {
kind: string,
spec: _
metadata?: _
}
// --- Kinds ---
VizConfigSpec: {
pluginVersion: string
options: [string]: _
fieldConfig: FieldConfigSource
}
VizConfigKind: {
// The kind of a VizConfigKind is the plugin ID
kind: string
spec: VizConfigSpec
}
AnnotationQuerySpec: {
datasource: DataSourceRef
query: DataQueryKind
// TODO: Should be figured out based on datasource (Grafana ds)
// builtIn?: int
// Below are currently existing options for annotation queries
enable: bool
filter: AnnotationPanelFilter
hide: bool
iconColor: string
name: string
}
AnnotationQueryKind: {
kind: "AnnotationQuery"
spec: AnnotationQuerySpec
}
QueryOptionsSpec: {
timeFrom?: string
maxDataPoints?: int
timeShift?: string
queryCachingTTL?: int
interval?: string
cacheTimeout?: string
}
DataQueryKind: {
// The kind of a DataQueryKind is the datasource type
kind: string
spec: [string]: _
}
PanelQuerySpec: {
query: DataQueryKind
datasource: DataSourceRef
refId: string
hidden: bool
}
PanelQueryKind: {
kind: "PanelQuery"
spec: PanelQuerySpec
}
TransformationKind: {
// The kind of a TransformationKind is the transformation ID
kind: string
spec: DataTransformerConfig
}
QueryGroupSpec: {
queries: [...PanelQueryKind]
transformations: [...TransformationKind]
queryOptions: QueryOptionsSpec
}
QueryGroupKind: {
kind: "QueryGroup"
spec: QueryGroupSpec
}
// Time configuration
// It defines the default time config for the time picker, the refresh picker for the specific dashboard.
TimeSettingsSpec: {
// Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc".
timezone?: string | *"browser"
// Start time range for dashboard.
// Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z".
from: string | *"now-6h"
// End time range for dashboard.
// Accepted values are relative time strings like "now-6h" or absolute time strings like "2020-07-10T08:00:00.000Z".
to: string | *"now"
// Refresh rate of dashboard. Represented via interval string, e.g. "5s", "1m", "1h", "1d".
autoRefresh: string // v1: refresh
// Interval options available in the refresh picker dropdown.
autoRefreshIntervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] // v1: timepicker.refresh_intervals
// Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.
quickRanges: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] // v1: timepicker.time_options , not exposed in the UI
// Whether timepicker is visible or not.
hideTimepicker: bool // v1: timepicker.hidden
// Day when the week starts. Expressed by the name of the day in lowercase, e.g. "monday".
weekStart: string
// The month that the fiscal year starts on. 0 = January, 11 = December
fiscalYearStartMonth: int
// Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.
nowDelay?: string // v1: timepicker.nowDelay
}
GridLayoutItemSpec: {
x: int
y: int
width: int
height: int
element: ElementReference // reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference
}
GridLayoutItemKind: {
kind: "GridLayoutItem"
spec: GridLayoutItemSpec
}
GridLayoutSpec: {
items: [...GridLayoutItemKind]
}
GridLayoutKind: {
kind: "GridLayout"
spec: GridLayoutSpec
}
PanelSpec: {
uid: string
title: string
description: string
links: [...DashboardLink]
data: QueryGroupKind
vizConfig: VizConfigKind
}
PanelKind: {
kind: "Panel"
spec: PanelSpec
}
ElementReference: {
kind: "ElementReference"
name: string
}
// Start FIXME: variables - in CUE PR - this are things that should be added into the cue schema
// TODO: properties such as `hide`, `skipUrlSync`, `multi` are type boolean, and in the old schema they are conditional,
// should we make them conditional in the new schema as well? or should we make them required but default to false?
// Variable types
VariableValue: VariableValueSingle | [...VariableValueSingle]
VariableValueSingle: string | bool | number | CustomVariableValue
// Custom formatter variable
CustomFormatterVariable: {
name: string
type: VariableType
multi: bool
includeAll: bool
}
// Custom variable value
CustomVariableValue: {
// The format name or function used in the expression
formatter: *null | string | VariableCustomFormatterFn
}
// Custom formatter function
VariableCustomFormatterFn: {
value: _
legacyVariableModel: {
name: string
type: VariableType
multi: bool
includeAll: bool
}
legacyDefaultFormatter?: VariableCustomFormatterFn
}
// Dashboard variable type
// `query`: Query-generated list of values such as metric names, server names, sensor IDs, data centers, and so on.
// `adhoc`: Key/value filters that are automatically added to all metric queries for a data source (Prometheus, Loki, InfluxDB, and Elasticsearch only).
// `constant`: Define a hidden constant.
// `datasource`: Quickly change the data source for an entire dashboard.
// `interval`: Interval variables represent time spans.
// `textbox`: Display a free text input field with an optional default value.
// `custom`: Define the variable options manually using a comma-separated list.
// `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables
VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" |
"system" | "snapshot"
// Sort variable options
// Accepted values are:
// `disabled`: No sorting
// `alphabeticalAsc`: Alphabetical ASC
// `alphabeticalDesc`: Alphabetical DESC
// `numericalAsc`: Numerical ASC
// `numericalDesc`: Numerical DESC
// `alphabeticalCaseInsensitiveAsc`: Alphabetical Case Insensitive ASC
// `alphabeticalCaseInsensitiveDesc`: Alphabetical Case Insensitive DESC
// `naturalAsc`: Natural ASC
// `naturalDesc`: Natural DESC
// VariableSort enum with default value
VariableSort: "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAsc" | "numericalDesc" | "alphabeticalCaseInsensitiveAsc" | "alphabeticalCaseInsensitiveDesc" | "naturalAsc" | "naturalDesc"
// Options to config when to refresh a variable
// `never`: Never refresh the variable
// `onDashboardLoad`: Queries the data source every time the dashboard loads.
// `onTimeRangeChanged`: Queries the data source when the dashboard time range changes.
VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged"
// Determine if the variable shows on dashboard
// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing).
VariableHide: *"dontHide" | "hideLabel" | "hideVariable"
// FIXME: should we introduce this? --- Variable value option
VariableValueOption: {
label: string
value: VariableValueSingle
group?: string
}
// Variable option specification
VariableOption: {
// Whether the option is selected or not
selected?: bool
// Text to be displayed for the option
text: string | [...string]
// Value of the option
value: string | [...string]
}
// Query variable specification
QueryVariableSpec: {
name: string | *""
current: VariableOption | *{
text: ""
value: ""
}
label?: string
hide: VariableHide
refresh: VariableRefresh
skipUrlSync: bool | *false
description?: string
datasource: DataSourceRef | *{}
query: string | DataQueryKind | *""
regex: string | *""
sort: VariableSort
definition?: string
options: [...VariableOption] | *[]
multi: bool | *false
includeAll: bool | *false
allValue?: string
placeholder?: string
}
// Query variable kind
QueryVariableKind: {
kind: "QueryVariable"
spec: QueryVariableSpec
}
// Text variable specification
TextVariableSpec: {
name: string | *""
current: VariableOption | *{
text: ""
value: ""
}
query: string | *""
label?: string
hide: VariableHide
skipUrlSync: bool | *false
description?: string
}
// Text variable kind
TextVariableKind: {
kind: "TextVariable"
spec: TextVariableSpec
}
// Constant variable specification
ConstantVariableSpec: {
name: string | *""
query: string | *""
current: VariableOption | *{
text: ""
value: ""
}
label?: string
hide: VariableHide
skipUrlSync: bool | *false
description?: string
}
// Constant variable kind
ConstantVariableKind: {
kind: "ConstantVariable"
spec: ConstantVariableSpec
}
// Datasource variable specification
DatasourceVariableSpec: {
name: string | *""
pluginId: string | *""
refresh: VariableRefresh
regex: string | *""
current: VariableOption | *{
text: ""
value: ""
}
defaultOptionEnabled: bool | *false
options: [...VariableOption] | *[]
multi: bool | *false
includeAll: bool | *false
allValue?: string
label?: string
hide: VariableHide
skipUrlSync: bool | *false
description?: string
}
// Datasource variable kind
DatasourceVariableKind: {
kind: "DatasourceVariable"
spec: DatasourceVariableSpec
}
// Interval variable specification
IntervalVariableSpec: {
name: string | *""
query: string | *""
current: VariableOption | *{
text: ""
value: ""
}
options: [...VariableOption] | *[]
auto: bool | *false
auto_min: string | *""
auto_count: int | *0
refresh: VariableRefresh
label?: string
hide: VariableHide
skipUrlSync: bool | *false
description?: string
}
// Interval variable kind
IntervalVariableKind: {
kind: "IntervalVariable"
spec: IntervalVariableSpec
}
// Custom variable specification
CustomVariableSpec: {
name: string | *""
query: string | *""
current: VariableOption
options: [...VariableOption] | *[]
multi: bool | *false
includeAll: bool | *false
allValue?: string
label?: string
hide: VariableHide
skipUrlSync: bool | *false
description?: string
}
// Custom variable kind
CustomVariableKind: {
kind: "CustomVariable"
spec: CustomVariableSpec
}
// GroupBy variable specification
GroupByVariableSpec: {
name: string | *""
datasource: DataSourceRef | *{}
current: VariableOption | *{
text: ""
value: ""
}
options: [...VariableOption] | *[]
multi: bool | *false
includeAll: bool | *false
allValue?: string
label?: string
hide: VariableHide
skipUrlSync: bool | *false
description?: string
}
// Group variable kind
GroupByVariableKind: {
kind: "GroupByVariable"
spec: GroupByVariableSpec
}
// Adhoc variable specification
AdhocVariableSpec: {
name: string | *""
datasource: DataSourceRef | *{}
baseFilters: [...AdHocFilterWithLabels] | *[]
filters: [...AdHocFilterWithLabels] | *[]
defaultKeys: [...MetricFindValue] | *[]
label?: string
hide: VariableHide
skipUrlSync: bool | *false
description?: string
}
// Define the MetricFindValue type
MetricFindValue: {
text: string
value?: string | number
group?: string
expandable?: bool
}
// Define the AdHocFilterWithLabels type
AdHocFilterWithLabels: {
key: string,
operator: string,
value: string,
values?: [...string],
keyLabel?: string,
valueLabels?: [...string],
forceEdit?: bool,
// @deprecated
condition?: string,
}
// Adhoc variable kind
AdhocVariableKind: {
kind: "AdhocVariable"
spec: AdhocVariableSpec
}
@@ -0,0 +1,150 @@
import { DashboardCursorSync, DashboardV2Spec } from './dashboard.gen';
export const handyTestingSchema: DashboardV2Spec = {
id: 1,
title: 'Default Dashboard',
description: 'This is a default dashboard',
cursorSync: DashboardCursorSync.Off,
liveNow: false,
preload: false,
editable: true,
links: [],
tags: [],
schemaVersion: 39,
timeSettings: {
timezone: 'browser',
from: 'now-6h',
to: 'now',
autoRefresh: '10s',
autoRefreshIntervals: ['10s', '1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d'],
quickRanges: ['now/d', 'now/w', 'now/M', 'now/y'],
hideTimepicker: false,
weekStart: 'sunday',
fiscalYearStartMonth: 1,
},
elements: {
timeSeriesTest: {
kind: 'Panel',
spec: {
title: 'Time Series Test',
description: 'This is a test panel',
uid: 'timeSeriesTest',
links: [],
data: {
kind: 'QueryGroup',
spec: {
queries: [
{
kind: 'PanelQuery',
spec: {
query: {
kind: 'prometheus',
spec: {
query: 'up',
},
},
datasource: { uid: 'gdev-prometheus', type: 'prometheus' },
hidden: false,
refId: 'A',
},
},
],
transformations: [
{
kind: 'limit',
spec: {
id: 'limit', // id is competing w/ kind
options: {
limit: 10,
},
},
},
],
queryOptions: {
maxDataPoints: 100,
cacheTimeout: '1m',
},
},
},
vizConfig: {
kind: 'timeseries',
spec: {
pluginVersion: '11.0.0',
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
},
},
},
},
},
layout: {
kind: 'GridLayout',
spec: {
items: [
{
kind: 'GridLayoutItem',
spec: {
element: { kind: 'ElementReference', name: 'timeSeriesTest' },
x: 0,
y: 0,
width: 12,
height: 6,
},
},
],
},
},
variables: [],
annotations: [
{
kind: 'AnnotationQuery',
spec: {
datasource: { type: 'datasource', uid: 'grafana' },
query: {
kind: 'grafana',
spec: {
queryType: 'timeRegions',
matchAny: false,
timeRegion: {
from: '12:27',
fromDayOfWeek: 2,
timezone: 'browser',
to: '11:30',
toDayOfWeek: 2,
},
},
},
enable: true,
filter: {
ids: [],
},
hide: false,
iconColor: 'blue',
name: 'Grafana annotations',
},
},
{
kind: 'AnnotationQuery',
spec: {
datasource: { uid: 'gdev-prometheus', type: 'prometheus' },
query: {
kind: 'prometheus',
spec: {
query: 'up',
},
},
enable: true,
filter: {
ids: [],
},
hide: false,
iconColor: 'red',
name: 'Prometheus annotations',
},
},
],
};
+2 -2
View File
@@ -56,7 +56,7 @@
"@grafana/faro-web-sdk": "^1.3.6",
"@grafana/schema": "11.4.0-pre",
"@hello-pangea/dnd": "17.0.0",
"@leeoniya/ufuzzy": "1.0.14",
"@leeoniya/ufuzzy": "1.0.16",
"@monaco-editor/react": "4.6.0",
"@popperjs/core": "2.11.8",
"@react-aria/dialog": "3.5.19",
@@ -92,7 +92,7 @@
"react-calendar": "5.1.0",
"react-colorful": "5.6.1",
"react-custom-scrollbars-2": "4.5.0",
"react-dropzone": "14.2.10",
"react-dropzone": "14.3.5",
"react-highlight-words": "0.20.0",
"react-hook-form": "^7.49.2",
"react-i18next": "^14.0.0",
@@ -4,16 +4,16 @@ import React, { ComponentProps, useCallback, useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { Alert } from '../Alert/Alert';
import { Divider } from '../Divider/Divider';
import { Field } from '../Forms/Field';
import { AsyncSelect, Select } from '../Select/Select';
import { AsyncSelect } from '../Select/Select';
import { Combobox, ComboboxOption } from './Combobox';
import mdx from './Combobox.mdx';
type PropsAndCustomArgs = ComponentProps<typeof Combobox> & { numberOfOptions: number };
type PropsAndCustomArgs<T extends string | number = string> = ComponentProps<typeof Combobox<T>> & {
numberOfOptions: number;
};
const meta: Meta<PropsAndCustomArgs> = {
title: 'Forms/Combobox',
@@ -27,6 +27,7 @@ const meta: Meta<PropsAndCustomArgs> = {
loading: undefined,
invalid: undefined,
width: undefined,
isClearable: false,
placeholder: 'Select an option...',
options: [
{ label: 'Apple', value: 'apple' },
@@ -45,12 +46,6 @@ const meta: Meta<PropsAndCustomArgs> = {
{ label: 'Honeydew', value: 'honeydew' },
{ label: 'Iceberg Lettuce', value: 'iceberg-lettuce' },
{ label: 'Jackfruit', value: 'jackfruit' },
{ label: '1', value: 1 },
{ label: '2', value: 2 },
{ label: '3', value: 3 },
{ label: '4', value: 4 },
{ label: '5', value: 5 },
{ label: '6', value: 6 },
],
value: 'banana',
},
@@ -59,16 +54,16 @@ const meta: Meta<PropsAndCustomArgs> = {
decorators: [InDevDecorator],
};
const BasicWithState: StoryFn<typeof Combobox> = (args) => {
const [value, setValue] = useState(args.value);
const BasicWithState: StoryFn<PropsAndCustomArgs> = (args) => {
const [value, setValue] = useState<string | null>();
return (
<Field label="Test input" description="Input with a few options">
<Combobox
id="test-combobox"
{...args}
value={value}
onChange={(val) => {
onChange={(val: ComboboxOption | null) => {
// TODO: Figure out how to update value on args
setValue(val?.value || null);
action('onChange')(val);
}}
@@ -88,7 +83,7 @@ async function generateOptions(amount: number): Promise<ComboboxOption[]> {
}));
}
const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...args }) => {
const ManyOptionsStory: StoryFn<PropsAndCustomArgs<string>> = ({ numberOfOptions, ...args }) => {
const [value, setValue] = useState<string | null>(null);
const [options, setOptions] = useState<ComboboxOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -103,13 +98,14 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...arg
}, 1000);
}, [numberOfOptions]);
const { onChange, ...rest } = args;
return (
<Combobox
{...args}
{...rest}
loading={isLoading}
options={options}
value={value}
onChange={(opt) => {
onChange={(opt: ComboboxOption | null) => {
setValue(opt?.value || null);
action('onChange')(opt);
}}
@@ -117,132 +113,6 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...arg
);
};
const SelectComparisonStory: StoryFn<typeof Combobox> = (args) => {
const [comboboxValue, setComboboxValue] = useState(args.value);
const theme = useTheme2();
if (typeof args.options === 'function') {
throw new Error('This story does not support async options');
}
return (
<div style={{ border: '1px solid ' + theme.colors.border.weak, padding: 16 }}>
<Field label="Combobox with default size">
<Combobox
{...args}
id="combobox-default-size"
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Field label="Select with default size">
<Select
id="select-default-size"
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Divider />
<Field label="Combobox with explicit size (25)">
{/*@ts-ignore minWidth and maxWidth has never, which is incompatible with args. It lacks the context that width=25 on the component*/}
<Combobox
{...args}
id="combobox-explicit-size"
width={25}
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Field label="Select with explicit size (25)">
<Select
id="select-explicit-size"
width={25}
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Divider />
<Field label="Combobox with auto width, minWidth 15">
<Combobox
{...args}
id="combobox-auto-size"
width="auto"
minWidth={15}
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Field label="Select with auto width">
<Select
id="select-auto-size"
width="auto"
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Field label="Combobox with auto width, minWidth 15, empty value">
<Combobox
{...args}
id="combobox-auto-size-empty"
width="auto"
minWidth={15}
value={null}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Field label="Select with auto width, empty value">
<Select
id="select-auto-size-empty"
width="auto"
value={null}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
</div>
);
};
export const AutoSize: StoryObj<PropsAndCustomArgs> = {
args: {
width: 'auto',
@@ -294,6 +164,8 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
}
}, []);
const { onChange, ...rest } = args;
return (
<>
<Field
@@ -301,12 +173,12 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
description="This tests when options have both a label and a value. Consumers are required to pass in a full ComboboxOption as a value with a label"
>
<Combobox
{...args}
{...rest}
id="test-combobox-one"
placeholder="Select an option"
options={loadOptionsWithLabels}
value={selectedOption}
onChange={(val) => {
onChange={(val: ComboboxOption | null) => {
action('onChange')(val);
setSelectedOption(val);
}}
@@ -324,7 +196,7 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
placeholder="Select an option"
options={loadOptionsOnlyValues}
value={selectedOption?.value ?? null}
onChange={(val) => {
onChange={(val: ComboboxOption | null) => {
action('onChange')(val);
setSelectedOption(val);
}}
@@ -366,7 +238,7 @@ const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
placeholder="Select an option"
options={loadOptionsWithErrors}
value={selectedOption}
onChange={(val) => {
onChange={(val: ComboboxOption | null) => {
action('onChange')(val);
setSelectedOption(val);
}}
@@ -426,13 +298,6 @@ export const PositioningTest: StoryObj<PropsAndCustomArgs> = {
render: PositioningTestStory,
};
export const ComparisonToSelect: StoryObj<PropsAndCustomArgs> = {
args: {
numberOfOptions: 100,
},
render: SelectComparisonStory,
};
export default meta;
function InDevDecorator(Story: React.ElementType) {
@@ -1,5 +1,6 @@
import { act, render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Combobox, ComboboxOption } from './Combobox';
@@ -49,7 +50,7 @@ describe('Combobox', () => {
expect(onChangeHandler).toHaveBeenCalledWith(options[0]);
});
it("shows the placeholder with the menu open when there's no value", async () => {
it('shows the placeholder with the menu open when value is null', async () => {
render(<Combobox options={options} value={null} onChange={onChangeHandler} placeholder="Select an option" />);
const input = screen.getByRole('combobox');
@@ -58,6 +59,15 @@ describe('Combobox', () => {
expect(input).toHaveAttribute('placeholder', 'Select an option');
});
it('shows the placeholder with the menu open when value is undefined', async () => {
render(<Combobox options={options} value={undefined} onChange={onChangeHandler} placeholder="Select an option" />);
const input = screen.getByRole('combobox');
await userEvent.click(input);
expect(input).toHaveAttribute('placeholder', 'Select an option');
});
it('selects value by clicking that needs scrolling', async () => {
render(<Combobox options={options} value={null} onChange={onChangeHandler} />);
@@ -106,6 +116,73 @@ describe('Combobox', () => {
expect(screen.queryByDisplayValue('Option 2')).not.toBeInTheDocument();
});
it.each(['very valid value', '', 0])('should handle an option with %p as a value', async (val) => {
const options = [
{ label: 'Second option', value: '2' },
{ label: 'Default', value: val },
];
const ControlledCombobox = () => {
const [value, setValue] = React.useState<string | number | null>(null);
return (
<Combobox
options={options}
value={value}
onChange={(opt) => {
setValue(opt.value);
}}
/>
);
};
render(<ControlledCombobox />);
const input = screen.getByRole('combobox');
await userEvent.click(input);
await userEvent.click(screen.getByRole('option', { name: 'Default' }));
expect(screen.queryByDisplayValue('Default')).toBeInTheDocument();
await userEvent.click(input);
expect(screen.getByRole('option', { name: 'Default' })).toHaveAttribute('aria-selected', 'true');
});
describe('size support', () => {
it('should require minWidth to be set with auto width', () => {
// @ts-expect-error
render(<Combobox options={options} value={null} onChange={onChangeHandler} width="auto" />);
});
it('should change width when typing things with auto width', async () => {
render(<Combobox options={options} value={null} onChange={onChangeHandler} width="auto" minWidth={2} />);
const input = screen.getByRole('combobox');
const inputWrapper = screen.getByTestId('input-wrapper');
const initialWidth = getComputedStyle(inputWrapper).width;
fireEvent.change(input, { target: { value: 'very very long value' } });
const newWidth = getComputedStyle(inputWrapper).width;
expect(initialWidth).not.toBe(newWidth);
});
it('should not change width when typing things with fixed width', async () => {
render(<Combobox options={options} value={null} onChange={onChangeHandler} width={2} />);
const input = screen.getByRole('combobox');
const inputWrapper = screen.getByTestId('input-wrapper');
const initialWidth = getComputedStyle(inputWrapper).width;
fireEvent.change(input, { target: { value: 'very very long value' } });
const newWidth = getComputedStyle(inputWrapper).width;
expect(initialWidth).toBe(newWidth);
});
});
describe('with a value already selected', () => {
it('shows an empty text input when opening the menu', async () => {
const selectedValue = options[0].value;
@@ -28,7 +28,10 @@ export type ComboboxOption<T extends string | number = string> = {
// TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async),
// then the onChange handler emits ComboboxOption with the label as non-undefined.
interface ComboboxBaseProps<T extends string | number>
extends Omit<InputProps, 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange' | 'width'> {
extends Pick<
InputProps,
'placeholder' | 'autoFocus' | 'id' | 'aria-labelledby' | 'disabled' | 'loading' | 'invalid'
> {
/**
* An `X` appears in the UI, which clears the input and sets the value to `null`. Do not use if you have no `null` case.
*/
@@ -38,20 +41,31 @@ interface ComboboxBaseProps<T extends string | number>
*/
createCustomValue?: boolean;
options: Array<ComboboxOption<T>> | ((inputValue: string) => Promise<Array<ComboboxOption<T>>>);
onChange: (option: ComboboxOption<T> | null) => void;
onChange: (option: ComboboxOption<T>) => void;
/**
* Most consumers should pass value in as a scalar string | number. However, sometimes with Async because we don't
* have the full options loaded to match the value to, consumers may also pass in an Option with a label to display.
*/
value: T | ComboboxOption<T> | null;
value?: T | ComboboxOption<T> | null;
/**
* Defaults to 100%. Number is a multiple of 8px. 'auto' will size the input to the content.
* */
width?: number | 'auto';
onBlur?: () => void;
}
const RECOMMENDED_ITEMS_AMOUNT = 100_000;
type ClearableConditionals<T extends number | string> =
| {
isClearable: true;
/**
* The onChange handler is called with `null` when clearing the Combobox.
*/
onChange: (option: ComboboxOption<T> | null) => void;
}
| { isClearable?: false; onChange: (option: ComboboxOption<T>) => void };
type AutoSizeConditionals =
| {
width: 'auto';
@@ -70,13 +84,16 @@ type AutoSizeConditionals =
maxWidth?: never;
};
type ComboboxProps<T extends string | number> = ComboboxBaseProps<T> & AutoSizeConditionals;
type ComboboxProps<T extends string | number> = ComboboxBaseProps<T> & AutoSizeConditionals & ClearableConditionals<T>;
function itemToString<T extends string | number>(item: ComboboxOption<T> | null) {
if (item?.label?.includes('Custom value: ')) {
return item?.value.toString();
function itemToString<T extends string | number>(item?: ComboboxOption<T> | null) {
if (!item) {
return '';
}
return item?.label ?? item?.value.toString() ?? '';
if (item.label?.includes('Custom value: ')) {
return item.value.toString();
}
return item.label ?? item.value.toString();
}
function itemFilter<T extends string | number>(inputValue: string) {
@@ -85,8 +102,8 @@ function itemFilter<T extends string | number>(inputValue: string) {
return (item: ComboboxOption<T>) => {
return (
!inputValue ||
item?.label?.toLowerCase().includes(lowerCasedInputValue) ||
item?.value?.toString().toLowerCase().includes(lowerCasedInputValue)
item.label?.toLowerCase().includes(lowerCasedInputValue) ||
item.value?.toString().toLowerCase().includes(lowerCasedInputValue)
);
};
}
@@ -109,8 +126,14 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
createCustomValue = false,
id,
width,
minWidth,
maxWidth,
'aria-labelledby': ariaLabelledBy,
...restProps
autoFocus,
onBlur,
disabled,
loading,
invalid,
} = props;
// Value can be an actual scalar Value (string or number), or an Option (value + label), so
@@ -158,7 +181,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
return null;
}
if (value === null) {
if (valueProp === undefined || valueProp === null) {
return null;
}
@@ -168,9 +191,13 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
}
return index;
}, [options, value, isAsync]);
}, [valueProp, options, value, isAsync]);
const selectedItem = useMemo(() => {
if (valueProp === undefined || valueProp === null) {
return null;
}
if (selectedItemIndex !== null && !isAsync) {
return options[selectedItemIndex];
}
@@ -329,7 +356,9 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
const { inputRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, rowVirtualizer.range, isOpen);
const InputComponent = width === 'auto' ? AutoSizeInput : Input;
const isAutoSize = width === 'auto';
const InputComponent = isAutoSize ? AutoSizeInput : Input;
const suffixIcon = asyncLoading
? 'spinner'
@@ -343,7 +372,13 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
return (
<div>
<InputComponent
width={width === 'auto' ? undefined : width}
width={isAutoSize ? undefined : width}
{...(isAutoSize ? { minWidth, maxWidth } : {})}
autoFocus={autoFocus}
onBlur={onBlur}
disabled={disabled}
loading={loading}
invalid={invalid}
className={styles.input}
suffix={
<>
@@ -368,7 +403,6 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
<Icon name={suffixIcon} />
</>
}
{...restProps}
{...getInputProps({
ref: inputRef,
/* Empty onCall to avoid TS error
@@ -3,6 +3,7 @@ import * as React from 'react';
import { measureText } from '../../utils/measureText';
import { AutoSizeInputContext } from './AutoSizeInputContext';
import { Input, Props as InputProps } from './Input';
export interface Props extends InputProps {
@@ -44,34 +45,38 @@ export const AutoSizeInput = React.forwardRef<HTMLInputElement, Props>((props, r
}, [placeholder, value, minWidth, maxWidth]);
return (
<Input
{...restProps}
placeholder={placeholder}
ref={ref}
value={value.toString()}
onChange={(event) => {
if (onChange) {
onChange(event);
}
setValue(event.currentTarget.value);
}}
width={inputWidth}
onBlur={(event) => {
if (onBlur) {
onBlur(event);
} else if (onCommitChange) {
onCommitChange(event);
}
}}
onKeyDown={(event) => {
if (onKeyDown) {
onKeyDown(event);
} else if (event.key === 'Enter' && onCommitChange) {
onCommitChange(event);
}
}}
data-testid={'autosize-input'}
/>
// Used to tell Input to increase the width properly of the input to fit the text.
// See comment in Input.tsx for more details
<AutoSizeInputContext.Provider value={true}>
<Input
{...restProps}
placeholder={placeholder}
ref={ref}
value={value.toString()}
onChange={(event) => {
if (onChange) {
onChange(event);
}
setValue(event.currentTarget.value);
}}
onBlur={(event) => {
if (onBlur) {
onBlur(event);
} else if (onCommitChange) {
onCommitChange(event);
}
}}
onKeyDown={(event) => {
if (onKeyDown) {
onKeyDown(event);
} else if (event.key === 'Enter' && onCommitChange) {
onCommitChange(event);
}
}}
width={inputWidth}
data-testid="autosize-input"
/>
</AutoSizeInputContext.Provider>
);
});
@@ -0,0 +1,6 @@
import React from 'react';
// Used to tell Input to increase the width properly of the input to fit the text.
// See comment in Input.tsx for more details
export const AutoSizeInputContext = React.createContext(false);
AutoSizeInputContext.displayName = 'AutoSizeInputContext';
@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import { forwardRef, HTMLProps, ReactNode } from 'react';
import { forwardRef, HTMLProps, ReactNode, useContext } from 'react';
import useMeasure from 'react-use/lib/useMeasure';
import { GrafanaTheme2 } from '@grafana/data';
@@ -8,6 +8,8 @@ import { stylesFactory, useTheme2 } from '../../themes';
import { getFocusStyle, sharedInputStyle } from '../Forms/commonStyles';
import { Spinner } from '../Spinner/Spinner';
import { AutoSizeInputContext } from './AutoSizeInputContext';
export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix' | 'size'> {
/** Sets the width to a multiple of 8px. Should only be used with inline forms. Setting width of the container is preferred in other cases.*/
width?: number;
@@ -32,7 +34,17 @@ interface StyleDeps {
}
export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
const { className, addonAfter, addonBefore, prefix, suffix, invalid, loading, width = 0, ...restProps } = props;
const {
className,
addonAfter,
addonBefore,
prefix,
suffix: suffixProp,
invalid,
loading,
width = 0,
...restProps
} = props;
/**
* Prefix & suffix are positioned absolutely within inputWrapper. We use client rects below to apply correct padding to the input
* when prefix/suffix is larger than default (28px = 16px(icon) + 12px(left/right paddings)).
@@ -41,13 +53,24 @@ export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
const [prefixRef, prefixRect] = useMeasure<HTMLDivElement>();
const [suffixRef, suffixRect] = useMeasure<HTMLDivElement>();
// Yes, this is gross - When Input is being wrapped by AutoSizeInput, add the suffix/prefix width to the overall width
// so the text content is not clipped. The intention is to make all the input's text appear without overflow/clipping,
// which isn't normally how width is used in this component.
// This behaviour is not controlled via a prop so we can limit API surface, and remove this as a 'breaking change' later
// if a better solution is found.
const isInAutoSizeInput = useContext(AutoSizeInputContext);
const accessoriesWidth = (prefixRect.width || 0) + (suffixRect.width || 0);
const finalWidth = isInAutoSizeInput && width ? width + accessoriesWidth / 8 : width;
const theme = useTheme2();
const styles = getInputStyles({ theme, invalid: !!invalid, width });
const styles = getInputStyles({ theme, invalid: !!invalid, width: finalWidth });
const suffix = suffixProp || (loading && <Spinner inline={true} />);
return (
<div className={cx(styles.wrapper, className)} data-testid={'input-wrapper'}>
<div className={cx(styles.wrapper, className)} data-testid="input-wrapper">
{!!addonBefore && <div className={styles.addon}>{addonBefore}</div>}
<div className={styles.inputWrapper}>
{prefix && (
<div className={styles.prefix} ref={prefixRef}>
@@ -65,14 +88,12 @@ export const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
}}
/>
{(suffix || loading) && (
{suffix && (
<div className={styles.suffix} ref={suffixRef}>
{loading && <Spinner className={styles.loadingIndicator} inline={true} />}
{suffix}
</div>
)}
</div>
{!!addonAfter && <div className={styles.addon}>{addonAfter}</div>}
</div>
);
+9
View File
@@ -381,6 +381,15 @@ func TraceIDFromContext(ctx context.Context, requireSampled bool) string {
return spanCtx.TraceID().String()
}
func ServerTimingForSpan(span trace.Span) string {
spanCtx := span.SpanContext()
if !spanCtx.HasTraceID() || !spanCtx.IsValid() {
return ""
}
return fmt.Sprintf("00-%s-%s-01", spanCtx.TraceID().String(), spanCtx.SpanID().String())
}
// Error sets the status to error and record the error as an exception in the provided span.
func Error(span trace.Span, err error) error {
attr := []attribute.KeyValue{}
+8
View File
@@ -94,6 +94,14 @@ func RequestTracing(tracer tracing.Tracer) web.Middleware {
), trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
// inject local root span context into the response via server-timing header
// we're doing it this early so that we can capture the root span context
// which is not available later-on.
serverTimingValue := tracing.ServerTimingForSpan(span)
if serverTimingValue != "" {
w.Header().Set("server-timing", fmt.Sprintf("traceparent;desc=\"%s\"", serverTimingValue))
}
req = req.WithContext(ctx)
// Ensure the response writer's status can be captured.
@@ -12,6 +12,7 @@ import (
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
@@ -114,14 +115,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
return err
}
defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{})
if err != nil {
return err
}
storageOpts := apistore.StorageOptions{
InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) {
internal := &dashboardinternal.Dashboard{}
obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal)
if err != nil {
return nil, err
}
err = scheme.Convert(obj, desiredObj, nil)
return desiredObj, err
}),
}
// Split dashboards when they are large
var largeObjects apistore.LargeObjectSupport
if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme)
opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{
LargeObjectSupport: largeObjects,
})
storageOpts.LargeObjectSupport = largeObjects
}
opts.StorageOptions(dash.GroupResource(), storageOpts)
storage := map[string]rest.Storage{}
storage[dash.StoragePath()] = legacyStore
@@ -10,6 +10,7 @@ import (
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv1alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
@@ -112,14 +113,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
return err
}
defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{})
if err != nil {
return err
}
storageOpts := apistore.StorageOptions{
InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) {
internal := &dashboardinternal.Dashboard{}
obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal)
if err != nil {
return nil, err
}
err = scheme.Convert(obj, desiredObj, nil)
return desiredObj, err
}),
}
// Split dashboards when they are large
var largeObjects apistore.LargeObjectSupport
if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme)
opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{
LargeObjectSupport: largeObjects,
})
storageOpts.LargeObjectSupport = largeObjects
}
opts.StorageOptions(dash.GroupResource(), storageOpts)
storage := map[string]rest.Storage{}
storage[dash.StoragePath()] = legacyStore
@@ -10,6 +10,7 @@ import (
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
dashboardinternal "github.com/grafana/grafana/pkg/apis/dashboard"
dashboardv2alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v2alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
@@ -112,14 +113,30 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
return err
}
defaultOpts, err := optsGetter.GetRESTOptions(b.legacy.Resource.GroupResource(), &dashboardinternal.Dashboard{})
if err != nil {
return err
}
storageOpts := apistore.StorageOptions{
InternalConversion: (func(b []byte, desiredObj runtime.Object) (runtime.Object, error) {
internal := &dashboardinternal.Dashboard{}
obj, _, err := defaultOpts.StorageConfig.Config.Codec.Decode(b, nil, internal)
if err != nil {
return nil, err
}
err = scheme.Convert(obj, desiredObj, nil)
return desiredObj, err
}),
}
// Split dashboards when they are large
var largeObjects apistore.LargeObjectSupport
if b.legacy.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageBigObjectsSupport) {
largeObjects = dashboard.NewDashboardLargeObjectSupport(scheme)
opts.StorageOptions(dash.GroupResource(), apistore.StorageOptions{
LargeObjectSupport: largeObjects,
})
storageOpts.LargeObjectSupport = largeObjects
}
opts.StorageOptions(dash.GroupResource(), storageOpts)
storage := map[string]rest.Storage{}
storage[dash.StoragePath()] = legacyStore
+2 -2
View File
@@ -35,8 +35,8 @@ func TestIntegrationWillRunInstrumentationServerWhenTargetHasNoHttpServer(t *tes
_, cfg := db.InitTestDBWithCfg(t)
cfg.HTTPPort = "3001"
cfg.GRPCServerNetwork = "tcp"
cfg.GRPCServerAddress = "localhost:10000"
cfg.GRPCServer.Network = "tcp"
cfg.GRPCServer.Address = "localhost:10000"
addStorageServerToConfig(t, cfg, dbType)
cfg.Target = []string{modules.StorageServer}
@@ -115,7 +115,7 @@ func folderTreeCollector(store db.DB) legacyTupleCollector {
func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector {
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
query := `
SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id
SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id, br.role as basic_role_name
FROM permission p
INNER JOIN role r ON p.role_id = r.id
LEFT JOIN user_role ur ON r.id = ur.role_id
@@ -128,12 +128,12 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector
AND p.kind = ?
`
type Permission struct {
RoleName string `xorm:"role_name"`
Action string `xorm:"action"`
Kind string
Identifier string
UserUID string `xorm:"user_uid"`
TeamUID string `xorm:"team_uid"`
Action string `xorm:"action"`
Kind string
Identifier string
UserUID string `xorm:"user_uid"`
TeamUID string `xorm:"team_uid"`
BasicRoleName string `xorm:"basic_role_name"`
}
var permissions []Permission
@@ -152,10 +152,9 @@ func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector
if len(p.UserUID) > 0 {
subject = zanzana.NewTupleEntry(zanzana.TypeUser, p.UserUID, "")
} else if len(p.TeamUID) > 0 {
subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, "member")
subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, zanzana.RelationTeamMember)
} else {
// FIXME(kalleep): Unsuported role binding (org role). We need to have basic roles in place
continue
subject = zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(p.BasicRoleName), zanzana.RelationAssignee)
}
tuple, ok := zanzana.TranslateToResourceTuple(subject, p.Action, p.Kind, p.Identifier)
@@ -195,53 +194,7 @@ func tupleStringWithoutCondition(tuple *openfgav1.TupleKey) string {
return s
}
// basicRolePermissionsCollector collects permissions for basic roles
func basicRolePermissionsCollector(store db.DB) legacyTupleCollector {
return func(ctx context.Context, _ int64) (map[string]map[string]*openfgav1.TupleKey, error) {
const query = `
SELECT r.uid as role_uid, p.action, p.kind, p.identifier
FROM permission p
INNER JOIN role r ON p.role_id = r.id
LEFT JOIN builtin_role br ON r.id = br.role_id
WHERE r.name LIKE 'basic:%'
`
type Permission struct {
Action string `xorm:"action"`
Kind string
Identifier string
RoleUID string `xorm:"role_uid"`
}
var permissions []Permission
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query).Find(&permissions)
})
if err != nil {
return nil, err
}
tuples := make(map[string]map[string]*openfgav1.TupleKey)
for _, p := range permissions {
subject := zanzana.NewTupleEntry(zanzana.TypeRole, p.RoleUID, "assignee")
tuple, ok := zanzana.TranslateToResourceTuple(subject, p.Action, p.Kind, p.Identifier)
if !ok {
continue
}
if tuples[tuple.Object] == nil {
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
}
tuples[tuple.Object][tuple.String()] = tuple
}
return tuples, nil
}
}
// basicRoleBindingsCollects collects role bindings for basic roles
// basicRoleBindingsCollector collects role bindings for basic roles
func basicRoleBindingsCollector(store db.DB) legacyTupleCollector {
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
query := `
@@ -269,10 +222,8 @@ func basicRoleBindingsCollector(store db.DB) legacyTupleCollector {
tuples := make(map[string]map[string]*openfgav1.TupleKey)
for _, b := range bindings {
subject := zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, "")
tuple := &openfgav1.TupleKey{
User: subject,
User: zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, ""),
Relation: zanzana.RelationAssignee,
Object: zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(b.OrgRole), ""),
}
@@ -288,6 +239,144 @@ func basicRoleBindingsCollector(store db.DB) legacyTupleCollector {
}
}
func teamRoleBindingsCollector(store db.DB) legacyTupleCollector {
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
query := `
SELECT t.uid AS team_uid, r.uid AS role_uid
FROM team_role tr
INNER JOIN team t ON tr.team_id = t.id
INNER JOIN role r ON tr.role_id = r.id
WHERE t.org_id = ?
AND r.name NOT LIKE 'managed:%'
`
type Binding struct {
TeamUID string `xorm:"team_uid"`
RoleUID string `xorm:"role_uid"`
}
var bindings []Binding
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query, orgID).Find(&bindings)
})
if err != nil {
return nil, err
}
tuples := make(map[string]map[string]*openfgav1.TupleKey)
for _, b := range bindings {
tuple := &openfgav1.TupleKey{
User: zanzana.NewTupleEntry(zanzana.TypeTeam, b.TeamUID, zanzana.RelationTeamMember),
Relation: zanzana.RelationAssignee,
Object: zanzana.NewTupleEntry(zanzana.TypeRole, b.RoleUID, ""),
}
if tuples[tuple.Object] == nil {
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
}
tuples[tuple.Object][tuple.String()] = tuple
}
return tuples, nil
}
}
func userRoleBindingsCollector(store db.DB) legacyTupleCollector {
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
query := `
SELECT u.uid AS user_uid, r.uid AS role_uid
FROM user_role ur
INNER JOIN ` + store.GetDialect().Quote("user") + ` u ON ur.user_id = u.id
INNER JOIN role r ON ur.role_id = r.id
WHERE (ur.org_id = 0 OR ur.org_id = ?)
AND r.name NOT LIKE 'managed:%'
`
type Binding struct {
UserUID string `xorm:"user_uid"`
RoleUID string `xorm:"role_uid"`
}
var bindings []Binding
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query, orgID).Find(&bindings)
})
if err != nil {
return nil, err
}
tuples := make(map[string]map[string]*openfgav1.TupleKey)
for _, b := range bindings {
tuple := &openfgav1.TupleKey{
User: zanzana.NewTupleEntry(zanzana.TypeUser, b.UserUID, ""),
Relation: zanzana.RelationAssignee,
Object: zanzana.NewTupleEntry(zanzana.TypeRole, b.RoleUID, ""),
}
if tuples[tuple.Object] == nil {
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
}
tuples[tuple.Object][tuple.String()] = tuple
}
return tuples, nil
}
}
func rolePermissionsCollector(store db.DB) legacyTupleCollector {
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
var query = `
SELECT r.uid as role_uid, p.action, p.kind, p.identifier
FROM permission p
INNER JOIN role r ON p.role_id = r.id
LEFT JOIN builtin_role br ON r.id = br.role_id
WHERE (r.org_id = 0 OR r.org_id = ?)
AND r.name NOT LIKE 'managed:%'
`
type Permission struct {
Action string `xorm:"action"`
Kind string
Identifier string
RoleUID string `xorm:"role_uid"`
}
var permissions []Permission
err := store.WithDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(query, orgID).Find(&permissions)
})
if err != nil {
return nil, err
}
tuples := make(map[string]map[string]*openfgav1.TupleKey)
for _, p := range permissions {
tuple, ok := zanzana.TranslateToResourceTuple(
zanzana.NewTupleEntry(zanzana.TypeRole, p.RoleUID, zanzana.RelationAssignee),
p.Action,
p.Kind,
p.Identifier,
)
if !ok {
continue
}
if tuples[tuple.Object] == nil {
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
}
tuples[tuple.Object][tuple.String()] = tuple
}
return tuples, nil
}
}
func zanzanaCollector(relations []string) zanzanaTupleCollector {
return func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) {
// list will use continuation token to collect all tuples for object and relation
@@ -65,8 +65,8 @@ func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB,
client,
),
newResourceReconciler(
"basic role permissions",
basicRolePermissionsCollector(store),
"role permissions",
rolePermissionsCollector(store),
zanzanaCollector(zanzana.FolderRelations),
client,
),
@@ -76,6 +76,18 @@ func NewZanzanaReconciler(cfg *setting.Cfg, client zanzana.Client, store db.DB,
zanzanaCollector([]string{zanzana.RelationAssignee}),
client,
),
newResourceReconciler(
"team role bindings",
teamRoleBindingsCollector(store),
zanzanaCollector([]string{zanzana.RelationAssignee}),
client,
),
newResourceReconciler(
"user role bindings",
userRoleBindingsCollector(store),
zanzanaCollector([]string{zanzana.RelationAssignee}),
client,
),
},
}
}
@@ -37,15 +37,13 @@ func (auth orgRoleAuthorizer) Authorize(ctx context.Context, a authorizer.Attrib
default:
return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil
}
case org.RoleViewer:
case org.RoleViewer, org.RoleNone:
switch a.GetVerb() {
case "get", "list", "watch":
return authorizer.DecisionAllow, "", nil
default:
return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil
}
case org.RoleNone:
return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil
}
return authorizer.DecisionDeny, "", nil
}
+1 -1
View File
@@ -17,7 +17,7 @@ func (a RuntimeConfig) String() string {
// Supported options are:
//
// <group>/<version>=true|false for a specific API group and version (e.g. dashboards.grafana.app/v0alpha1=true)
// <group>/<version>=true|false for a specific API group and version (e.g. dashboard.grafana.app/v0alpha1=true)
// api/all=true|false controls all API versions
// api/ga=true|false controls all API versions of the form v[0-9]+
// api/beta=true|false controls all API versions of the form v[0-9]+beta[0-9]+
@@ -8,11 +8,11 @@ import (
)
func TestReadRuntimeCOnfig(t *testing.T) {
out, err := ReadRuntimeConfig("all/all=true,dashboards.grafana.app/v0alpha1=false")
out, err := ReadRuntimeConfig("all/all=true,dashboard.grafana.app/v0alpha1=false")
require.NoError(t, err)
require.Equal(t, []RuntimeConfig{
{Group: "all", Version: "all", Enabled: true},
{Group: "dashboards.grafana.app", Version: "v0alpha1", Enabled: false},
{Group: "dashboard.grafana.app", Version: "v0alpha1", Enabled: false},
}, out)
require.Equal(t, "all/all=true", fmt.Sprintf("%v", out[0]))
+4 -1
View File
@@ -88,7 +88,10 @@ func ProvideStandaloneAuthZClient(
return nil, err
}
return newGrpcLegacyClient(authCfg)
if cfg.StackID == "" {
return newGrpcLegacyClient(authCfg)
}
return newCloudLegacyClient(authCfg)
}
func newInProcLegacyClient(server *legacyServer) (authzlib.AccessChecker, error) {
+4 -4
View File
@@ -2,10 +2,10 @@ package mappers
type VerbToAction map[string]string // e.g. "get" -> "read"
type ResourceVerbToAction map[string]VerbToAction // e.g. "dashboards" -> VerbToAction
type GroupResourceVerbToAction map[string]ResourceVerbToAction // e.g. "dashboards.grafana.app" -> ResourceVerbToAction
type GroupResourceVerbToAction map[string]ResourceVerbToAction // e.g. "dashboard.grafana.app" -> ResourceVerbToAction
type ResourceToAttribute map[string]string // e.g. "dashboards" -> "uid"
type GroupResourceToAttribute map[string]ResourceToAttribute // e.g. "dashboards.grafana.app" -> ResourceToAttribute
type GroupResourceToAttribute map[string]ResourceToAttribute // e.g. "dashboard.grafana.app" -> ResourceToAttribute
type K8sRbacMapper struct {
DefaultActions VerbToAction
@@ -28,8 +28,8 @@ func NewK8sRbacMapper() *K8sRbacMapper {
},
DefaultAttribute: "uid",
Actions: GroupResourceVerbToAction{
"dashboards.grafana.app": ResourceVerbToAction{"dashboards": VerbToAction{}},
"folders.grafana.app": ResourceVerbToAction{"folders": VerbToAction{}},
"dashboard.grafana.app": ResourceVerbToAction{"dashboards": VerbToAction{}},
"folder.grafana.app": ResourceVerbToAction{"folders": VerbToAction{}},
},
}
}
+37
View File
@@ -0,0 +1,37 @@
package rbac
import (
authzv1 "github.com/grafana/authlib/authz/proto/v1"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authz/rbac/store"
"github.com/grafana/grafana/pkg/storage/legacysql"
)
type Service struct {
authzv1.UnimplementedAuthzServiceServer
authzextv1.UnimplementedAuthzExtentionServiceServer
store *store.Store
logger log.Logger
tracer tracing.Tracer
}
func NewService(sql legacysql.LegacyDatabaseProvider, logger log.Logger, tracer tracing.Tracer) *Service {
return &Service{
store: store.NewStore(sql),
logger: logger,
tracer: tracer,
}
}
// TODO: Implement Check
// func (s *Service) Check(ctx context.Context, req *authzv1.CheckRequest) (*authzv1.CheckResponse, error) {
// This needs to be done for the database provider
// ns := req.GetNamespace()
// ctx = request.WithNamespace(ctx, ns)
// return nil, nil
// }
+15
View File
@@ -0,0 +1,15 @@
package store
import "github.com/grafana/grafana/pkg/storage/legacysql"
// TODO (gamab): Implement GetRoles, GetTeams, GetFolders, GetPermissions
type Store struct {
sql legacysql.LegacyDatabaseProvider
}
func NewStore(sql legacysql.LegacyDatabaseProvider) *Store {
return &Store{
sql: sql,
}
}
+11
View File
@@ -16,11 +16,22 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authz/mappers"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/rbac"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/grpcserver"
"github.com/grafana/grafana/pkg/storage/legacysql"
)
func RegisterRBACAuthZService(handler grpcserver.Provider, db legacysql.LegacyDatabaseProvider, tracer tracing.Tracer) {
server := rbac.NewService(db, log.New("authz-grpc-server"), tracer)
srv := handler.GetServer()
authzv1.RegisterAuthzServiceServer(srv, server)
authzextv1.RegisterAuthzExtentionServiceServer(srv, server)
}
var _ authzv1.AuthzServiceServer = (*legacyServer)(nil)
var _ grpc_auth.ServiceAuthFuncOverride = (*legacyServer)(nil)
var _ authzlib.ServiceAuthorizeFuncOverride = (*legacyServer)(nil)
+8 -8
View File
@@ -60,7 +60,7 @@ func Test_legacyServer_Check(t *testing.T) {
req: &authzv1.CheckRequest{
Subject: "user:1",
Verb: "get",
Group: "dashboards.grafana.app",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "dash1",
Namespace: "org-2",
@@ -74,7 +74,7 @@ func Test_legacyServer_Check(t *testing.T) {
req: &authzv1.CheckRequest{
Subject: "user:1",
Verb: "get",
Group: "dashboards.grafana.app",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "dash1",
Namespace: "org-2",
@@ -88,7 +88,7 @@ func Test_legacyServer_Check(t *testing.T) {
req: &authzv1.CheckRequest{
Subject: "user:1",
Verb: "get",
Group: "dashboards.grafana.app",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "dash1",
Namespace: "org-2",
@@ -106,7 +106,7 @@ func Test_legacyServer_Check(t *testing.T) {
req: &authzv1.CheckRequest{
Subject: "user:1",
Verb: "get",
Group: "dashboards.grafana.app",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Namespace: "org-2",
},
@@ -131,7 +131,7 @@ func Test_legacyServer_Check(t *testing.T) {
req: &authzv1.CheckRequest{
Subject: "user:1",
Verb: "get",
Group: "dashboards.grafana.app",
Group: "dashboard.grafana.app",
Name: "dash1",
Namespace: "org-2",
},
@@ -141,7 +141,7 @@ func Test_legacyServer_Check(t *testing.T) {
name: "should return error when verb is not set",
req: &authzv1.CheckRequest{
Subject: "user:1",
Group: "dashboards.grafana.app",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "dash1",
Namespace: "org-2",
@@ -152,7 +152,7 @@ func Test_legacyServer_Check(t *testing.T) {
name: "should return error when subject is not set",
req: &authzv1.CheckRequest{
Verb: "get",
Group: "dashboards.grafana.app",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "dash1",
Namespace: "org-2",
@@ -164,7 +164,7 @@ func Test_legacyServer_Check(t *testing.T) {
req: &authzv1.CheckRequest{
Subject: "user:1",
Verb: "get",
Group: "dashboards.grafana.app",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: "dash1",
Namespace: "stacks-2",
+2 -2
View File
@@ -11,14 +11,14 @@ type TypeInfo struct {
}
var typedResources = map[string]TypeInfo{
NewNamespaceResourceIdent(
FormatGroupResource(
folderalpha1.FolderResourceInfo.GroupResource().Group,
folderalpha1.FolderResourceInfo.GroupResource().Resource,
): {Type: "folder"},
}
func GetTypeInfo(group, resource string) (TypeInfo, bool) {
info, ok := typedResources[NewNamespaceResourceIdent(group, resource)]
info, ok := typedResources[FormatGroupResource(group, resource)]
return info, ok
}
+2 -2
View File
@@ -37,8 +37,8 @@ type Server struct {
}
type storeInfo struct {
Id string
AuthorizationModelId string
ID string
ModelID string
}
type ServerOption func(s *Server)
@@ -17,48 +17,58 @@ func (s *Server) BatchCheck(ctx context.Context, r *authzextv1.BatchCheckRequest
Groups: make(map[string]*authzextv1.BatchCheckGroupResource),
}
subject := r.GetSubject()
store, err := s.getStoreInfo(ctx, r.GetNamespace())
if err != nil {
return nil, err
}
for _, item := range r.Items {
groupPrefix := common.FormatGroupResource(item.GetGroup(), item.GetResource())
allowed, err := s.batchCheckItem(ctx, subject, r.Namespace, item)
groupResourceAccess := make(map[string]bool)
for _, item := range r.GetItems() {
res, err := s.batchCheckItem(ctx, r, item, store, groupResourceAccess)
if err != nil {
return nil, err
}
if _, ok := batchRes.Groups[groupPrefix]; !ok {
batchRes.Groups[groupPrefix] = &authzextv1.BatchCheckGroupResource{
groupResource := common.FormatGroupResource(item.GetGroup(), item.GetResource())
if _, ok := batchRes.Groups[groupResource]; !ok {
batchRes.Groups[groupResource] = &authzextv1.BatchCheckGroupResource{
Items: make(map[string]bool),
}
}
batchRes.Groups[groupPrefix].Items[item.GetName()] = allowed
batchRes.Groups[groupResource].Items[item.GetName()] = res.GetAllowed()
}
return batchRes, nil
}
func (s *Server) batchCheckItem(ctx context.Context, subject string, namespace string, item *authzextv1.BatchCheckItem) (bool, error) {
req := &authzv1.CheckRequest{
Namespace: namespace,
Subject: subject,
Verb: item.GetVerb(),
Group: item.GetGroup(),
Resource: item.GetResource(),
Name: item.GetName(),
Folder: item.GetFolder(),
Subresource: item.GetSubresource(),
func (s *Server) batchCheckItem(
ctx context.Context,
r *authzextv1.BatchCheckRequest,
item *authzextv1.BatchCheckItem,
store *storeInfo,
groupResourceAccess map[string]bool,
) (*authzv1.CheckResponse, error) {
var (
relation = common.VerbMapping[item.GetVerb()]
groupResource = common.FormatGroupResource(item.GetGroup(), item.GetResource())
)
allowed, ok := groupResourceAccess[groupResource]
if !ok {
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, item.GetGroup(), item.GetResource(), store)
if err != nil {
return nil, err
}
groupResourceAccess[groupResource] = res.GetAllowed()
}
if allowed {
return &authzv1.CheckResponse{Allowed: true}, nil
}
var res *authzv1.CheckResponse
var err error
if info, ok := common.GetTypeInfo(item.GetGroup(), item.GetResource()); ok {
res, err = s.checkTyped(ctx, req, info)
} else {
res, err = s.checkGeneric(ctx, req)
return s.checkTyped(ctx, r.GetSubject(), relation, item.GetName(), info, store)
}
if err != nil {
return false, err
}
return res.Allowed, nil
return s.checkGeneric(ctx, r.GetSubject(), relation, item.GetGroup(), item.GetResource(), item.GetName(), item.GetFolder(), store)
}
@@ -31,7 +31,7 @@ func newBatch(subject, group, resource string, items []*authzextv1.BatchCheckIte
}
func testBatchCheck(t *testing.T, server *Server) {
t.Run("user:1 should only be able to read resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) {
t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) {
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
res, err := server.BatchCheck(context.Background(), newBatch("user:1", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
@@ -44,7 +44,7 @@ func testBatchCheck(t *testing.T, server *Server) {
assert.False(t, res.Groups[groupPrefix].Items["2"])
})
t.Run("user:2 should be able to read resource:dashboards.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) {
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/{1,2} through namespace", func(t *testing.T) {
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
res, err := server.BatchCheck(context.Background(), newBatch("user:2", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
@@ -54,7 +54,7 @@ func testBatchCheck(t *testing.T, server *Server) {
assert.Len(t, res.Groups[groupPrefix].Items, 2)
})
t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
t.Run("user:3 should be able to read resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
res, err := server.BatchCheck(context.Background(), newBatch("user:3", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
@@ -67,7 +67,7 @@ func testBatchCheck(t *testing.T, server *Server) {
assert.False(t, res.Groups[groupPrefix].Items["2"])
})
t.Run("user:4 should be able to read all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
t.Run("user:4 should be able to read all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
res, err := server.BatchCheck(context.Background(), newBatch("user:4", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
@@ -82,7 +82,7 @@ func testBatchCheck(t *testing.T, server *Server) {
assert.False(t, res.Groups[groupPrefix].Items["3"])
})
t.Run("user:5 should be able to read resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
t.Run("user:5 should be able to read resource:dashboard.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
groupPrefix := zanzana.FormatGroupResource(dashboardGroup, dashboardResource)
res, err := server.BatchCheck(context.Background(), newBatch("user:5", dashboardGroup, dashboardResource, []*authzextv1.BatchCheckItem{
{Name: "1", Folder: "1"},
@@ -14,28 +14,39 @@ func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.C
ctx, span := tracer.Start(ctx, "authzServer.Check")
defer span.End()
if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok {
return s.checkTyped(ctx, r, info)
}
return s.checkGeneric(ctx, r)
}
// checkNamespace checks if subject has access through namespace
func (s *Server) checkNamespace(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) {
storeInf, err := s.getStoreInfo(ctx, r.Namespace)
store, err := s.getStoreInfo(ctx, r.GetNamespace())
if err != nil {
return nil, err
}
relation := common.VerbMapping[r.GetVerb()]
// Check if subject has access through namespace
res, err := s.checkNamespace(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
if err != nil {
return nil, err
}
if res.GetAllowed() {
return res, nil
}
if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok {
return s.checkTyped(ctx, r.GetSubject(), relation, r.GetName(), info, store)
}
return s.checkGeneric(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), r.GetName(), r.GetFolder(), store)
}
// checkTyped performes check on the root "namespace". If subject has access through the namespace they have access to
// every resource for that "GroupResource".
func (s *Server) checkNamespace(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzv1.CheckResponse, error) {
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
TupleKey: &openfgav1.CheckRequestTupleKey{
User: r.GetSubject(),
User: subject,
Relation: relation,
Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
Object: common.NewNamespaceResourceIdent(group, resource),
},
})
if err != nil {
@@ -45,22 +56,16 @@ func (s *Server) checkNamespace(ctx context.Context, r *authzv1.CheckRequest) (*
return &authzv1.CheckResponse{Allowed: res.GetAllowed()}, nil
}
func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info common.TypeInfo) (*authzv1.CheckResponse, error) {
storeInf, err := s.getStoreInfo(ctx, r.Namespace)
if err != nil {
return nil, err
}
relation := common.VerbMapping[r.GetVerb()]
// 1. check if subject has direct access to resource
// checkTyped performes checks on our typed resources e.g. folder.
func (s *Server) checkTyped(ctx context.Context, subject, relation, name string, info common.TypeInfo, store *storeInfo) (*authzv1.CheckResponse, error) {
// Check if subject has direct access to resource
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
TupleKey: &openfgav1.CheckRequestTupleKey{
User: r.GetSubject(),
User: subject,
Relation: relation,
Object: common.NewTypedIdent(info.Type, r.GetName()),
Object: common.NewTypedIdent(info.Type, name),
},
})
if err != nil {
@@ -71,40 +76,30 @@ func (s *Server) checkTyped(ctx context.Context, r *authzv1.CheckRequest, info c
return &authzv1.CheckResponse{Allowed: true}, nil
}
// 2. check if subject has access through namespace
nsRes, err := s.checkNamespace(ctx, r)
if err != nil {
return nil, err
}
return &authzv1.CheckResponse{Allowed: nsRes.GetAllowed()}, nil
return &authzv1.CheckResponse{Allowed: false}, nil
}
func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) {
storeInf, err := s.getStoreInfo(ctx, r.Namespace)
if err != nil {
return nil, err
}
// checkGeneric check our generic "resource" type.
func (s *Server) checkGeneric(ctx context.Context, subject, relation, group, resource, name, folder string, store *storeInfo) (*authzv1.CheckResponse, error) {
groupResource := structpb.NewStringValue(common.FormatGroupResource(group, resource))
relation := common.VerbMapping[r.GetVerb()]
// 1. check if subject has direct access to resource
// Check if subject has direct access to resource
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
TupleKey: &openfgav1.CheckRequestTupleKey{
User: r.GetSubject(),
User: subject,
Relation: relation,
Object: common.NewResourceIdent(r.GetGroup(), r.GetResource(), r.GetName()),
Object: common.NewResourceIdent(group, resource, name),
},
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
"requested_group": groupResource,
},
},
})
if err != nil {
// FIXME: wrap error
return nil, err
}
@@ -112,32 +107,22 @@ func (s *Server) checkGeneric(ctx context.Context, r *authzv1.CheckRequest) (*au
return &authzv1.CheckResponse{Allowed: true}, nil
}
// 2. check if subject has access through namespace
nsRes, err := s.checkNamespace(ctx, r)
if err != nil {
return nil, err
}
if nsRes.GetAllowed() {
return &authzv1.CheckResponse{Allowed: true}, nil
}
if r.Folder == "" {
if folder == "" {
return &authzv1.CheckResponse{Allowed: false}, nil
}
// 3. check if subject has access as a sub resource for the folder
// Check if subject has access as a sub resource for the folder
res, err = s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
TupleKey: &openfgav1.CheckRequestTupleKey{
User: r.GetSubject(),
User: subject,
Relation: common.FolderResourceRelation(relation),
Object: common.NewFolderIdent(r.GetFolder()),
Object: common.NewFolderIdent(folder),
},
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
"requested_group": groupResource,
},
},
})
@@ -24,7 +24,7 @@ func testCheck(t *testing.T, server *Server) {
}
}
t.Run("user:1 should only be able to read resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) {
t.Run("user:1 should only be able to read resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) {
res, err := server.Check(context.Background(), newRead("user:1", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
@@ -35,13 +35,13 @@ func testCheck(t *testing.T, server *Server) {
assert.False(t, res.GetAllowed())
})
t.Run("user:2 should be able to read resource:dashboards.grafana.app/dashboards/1 through namespace", func(t *testing.T) {
t.Run("user:2 should be able to read resource:dashboard.grafana.app/dashboards/1 through namespace", func(t *testing.T) {
res, err := server.Check(context.Background(), newRead("user:2", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
})
t.Run("user:3 should be able to read resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
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.Check(context.Background(), newRead("user:3", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
@@ -52,7 +52,7 @@ func testCheck(t *testing.T, server *Server) {
assert.False(t, res.GetAllowed())
})
t.Run("user:4 should be able to read all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
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.Check(context.Background(), newRead("user:4", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
@@ -71,7 +71,7 @@ func testCheck(t *testing.T, server *Server) {
assert.False(t, res.GetAllowed())
})
t.Run("user:5 should be able to read resource:dashboards.grafana.app/dashboards/1 through folder with set relation", func(t *testing.T) {
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.Check(context.Background(), newRead("user:5", dashboardGroup, dashboardResource, "1", "1"))
require.NoError(t, err)
assert.True(t, res.GetAllowed())
@@ -16,31 +16,22 @@ func (s *Server) List(ctx context.Context, r *authzextv1.ListRequest) (*authzext
ctx, span := tracer.Start(ctx, "authzServer.List")
defer span.End()
if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok {
return s.listTyped(ctx, r, info)
}
return s.listGeneric(ctx, r)
}
func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info common.TypeInfo) (*authzextv1.ListResponse, error) {
storeInf, err := s.getStoreInfo(ctx, r.Namespace)
store, err := s.getStoreInfo(ctx, r.Namespace)
if err != nil {
return nil, err
}
relation := common.VerbMapping[r.GetVerb()]
// 1. check if subject has access through namespace because then they can read all of them
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
TupleKey: &openfgav1.CheckRequestTupleKey{
User: r.GetSubject(),
Relation: relation,
Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
},
})
res, err := s.checkNamespace(
ctx,
r.GetSubject(),
relation,
r.GetGroup(),
r.GetResource(),
store,
)
if err != nil {
return nil, err
}
@@ -49,13 +40,21 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info
return &authzextv1.ListResponse{All: true}, nil
}
// 2. List all resources user has access too
if info, ok := common.GetTypeInfo(r.GetGroup(), r.GetResource()); ok {
return s.listTyped(ctx, r.GetSubject(), relation, info, store)
}
return s.listGeneric(ctx, r.GetSubject(), relation, r.GetGroup(), r.GetResource(), store)
}
func (s *Server) listTyped(ctx context.Context, subject, relation string, info common.TypeInfo, store *storeInfo) (*authzextv1.ListResponse, error) {
// List all resources user has access too
listRes, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Type: info.Type,
Relation: relation,
User: r.GetSubject(),
User: subject,
})
if err != nil {
return nil, err
@@ -66,42 +65,19 @@ func (s *Server) listTyped(ctx context.Context, r *authzextv1.ListRequest, info
}, nil
}
func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*authzextv1.ListResponse, error) {
storeInf, err := s.getStoreInfo(ctx, r.Namespace)
if err != nil {
return nil, err
}
func (s *Server) listGeneric(ctx context.Context, subject, relation, group, resource string, store *storeInfo) (*authzextv1.ListResponse, error) {
groupResource := structpb.NewStringValue(common.FormatGroupResource(group, resource))
relation := common.VerbMapping[r.GetVerb()]
// 1. check if subject has access through namespace because then they can read all of them
res, err := s.openfga.Check(ctx, &openfgav1.CheckRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
TupleKey: &openfgav1.CheckRequestTupleKey{
User: r.GetSubject(),
Relation: relation,
Object: common.NewNamespaceResourceIdent(r.GetGroup(), r.GetResource()),
},
})
if err != nil {
return nil, err
}
if res.Allowed {
return &authzextv1.ListResponse{All: true}, nil
}
// 2. List all folders subject has access to resource type in
// 1. List all folders subject has access to resource type in
folders, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Type: common.TypeFolder,
Relation: common.FolderResourceRelation(relation),
User: r.GetSubject(),
User: subject,
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
"requested_group": groupResource,
},
},
})
@@ -109,16 +85,16 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a
return nil, err
}
// 3. List all resource directly assigned to subject
// 2. List all resource directly assigned to subject
direct, err := s.openfga.ListObjects(ctx, &openfgav1.ListObjectsRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
StoreId: store.ID,
AuthorizationModelId: store.ModelID,
Type: common.TypeResource,
Relation: relation,
User: r.GetSubject(),
User: subject,
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"requested_group": structpb.NewStringValue(common.FormatGroupResource(r.GetGroup(), r.GetResource())),
"requested_group": groupResource,
},
},
})
@@ -128,7 +104,7 @@ func (s *Server) listGeneric(ctx context.Context, r *authzextv1.ListRequest) (*a
return &authzextv1.ListResponse{
Folders: folderObject(folders.GetObjects()),
Items: directObjects(r.GetGroup(), r.GetResource(), direct.GetObjects()),
Items: directObjects(group, resource, direct.GetObjects()),
}, nil
}
@@ -22,7 +22,7 @@ func testList(t *testing.T, server *Server) {
}
}
t.Run("user:1 should list resource:dashboards.grafana.app/dashboards/1", func(t *testing.T) {
t.Run("user:1 should list resource:dashboard.grafana.app/dashboards/1", func(t *testing.T) {
res, err := server.List(context.Background(), newList("user:1", dashboardGroup, dashboardResource))
require.NoError(t, err)
assert.Len(t, res.GetItems(), 1)
@@ -38,7 +38,7 @@ func testList(t *testing.T, server *Server) {
assert.Len(t, res.GetFolders(), 0)
})
t.Run("user:3 should be able to list resource:dashboards.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
t.Run("user:3 should be able to list resource:dashboard.grafana.app/dashboards/1 with set relation", func(t *testing.T) {
res, err := server.List(context.Background(), newList("user:3", dashboardGroup, dashboardResource))
require.NoError(t, err)
@@ -47,7 +47,7 @@ func testList(t *testing.T, server *Server) {
assert.Equal(t, res.GetItems()[0], "1")
})
t.Run("user:4 should be able to list all dashboards.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
t.Run("user:4 should be able to list all dashboard.grafana.app/dashboards in folder 1 and 3", func(t *testing.T) {
res, err := server.List(context.Background(), newList("user:4", dashboardGroup, dashboardResource))
require.NoError(t, err)
assert.Len(t, res.GetItems(), 0)
@@ -64,7 +64,7 @@ func testList(t *testing.T, server *Server) {
assert.Equal(t, second, "3")
})
t.Run("user:5 should be get list all dashboards.grafana.app/dashboards in folder 1 with set relation", func(t *testing.T) {
t.Run("user:5 should be get list all dashboard.grafana.app/dashboards in folder 1 with set relation", func(t *testing.T) {
res, err := server.List(context.Background(), newList("user:5", dashboardGroup, dashboardResource))
require.NoError(t, err)
assert.Len(t, res.GetItems(), 0)
@@ -19,7 +19,7 @@ func (s *Server) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authze
}
res, err := s.openfga.Read(ctx, &openfgav1.ReadRequest{
StoreId: storeInf.Id,
StoreId: storeInf.ID,
TupleKey: &openfgav1.ReadRequestTupleKey{
User: req.GetTupleKey().GetUser(),
Relation: req.GetTupleKey().GetRelation(),
@@ -30,8 +30,8 @@ func (s *Server) getStoreInfo(ctx context.Context, namespace string) (*storeInfo
}
info = storeInfo{
Id: store.GetId(),
AuthorizationModelId: modelID,
ID: store.GetId(),
ModelID: modelID,
}
s.stores[namespace] = info
@@ -71,8 +71,8 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server {
// seed tuples
_, err = openfga.Write(context.Background(), &openfgav1.WriteRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
StoreId: storeInf.ID,
AuthorizationModelId: storeInf.ModelID,
Writes: &openfgav1.WriteRequestWrites{
TupleKeys: []*openfgav1.TupleKey{
common.NewResourceTuple("user:1", "read", dashboardGroup, dashboardResource, "1"),
@@ -29,8 +29,8 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth
}
writeReq := &openfgav1.WriteRequest{
StoreId: storeInf.Id,
AuthorizationModelId: storeInf.AuthorizationModelId,
StoreId: storeInf.ID,
AuthorizationModelId: storeInf.ModelID,
}
if len(writeTuples) > 0 {
writeReq.Writes = &openfgav1.WriteRequestWrites{
+2 -1
View File
@@ -1448,9 +1448,10 @@ var (
{
Name: "singleTopNav",
Description: "Unifies the top search bar and breadcrumb bar into one",
Stage: FeatureStagePublicPreview,
Stage: FeatureStageGeneralAvailability,
FrontendOnly: true,
Owner: grafanaFrontendPlatformSquad,
Expression: "true",
},
{
Name: "exploreLogsShardSplitting",
+1 -1
View File
@@ -190,7 +190,7 @@ dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,
newFiltersUI,experimental,@grafana/dashboards-squad,false,false,false
lokiSendDashboardPanelNames,experimental,@grafana/observability-logs,false,false,false
alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true
singleTopNav,preview,@grafana/grafana-frontend-platform,false,false,true
singleTopNav,GA,@grafana/grafana-frontend-platform,false,false,true
exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true
exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true
exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
190 newFiltersUI experimental @grafana/dashboards-squad false false false
191 lokiSendDashboardPanelNames experimental @grafana/observability-logs false false false
192 alertingPrometheusRulesPrimary experimental @grafana/alerting-squad false false true
193 singleTopNav preview GA @grafana/grafana-frontend-platform false false true
194 exploreLogsShardSplitting experimental @grafana/observability-logs false false true
195 exploreLogsAggregatedMetrics experimental @grafana/observability-logs false false true
196 exploreLogsLimitedTimeRange experimental @grafana/observability-logs false false true
+5 -4
View File
@@ -3117,17 +3117,18 @@
{
"metadata": {
"name": "singleTopNav",
"resourceVersion": "1731664029942",
"resourceVersion": "1732104041490",
"creationTimestamp": "2024-08-29T08:48:32Z",
"annotations": {
"grafana.app/updatedTimestamp": "2024-11-15 09:47:09.942158 +0000 UTC"
"grafana.app/updatedTimestamp": "2024-11-20 12:00:41.490792 +0000 UTC"
}
},
"spec": {
"description": "Unifies the top search bar and breadcrumb bar into one",
"stage": "preview",
"stage": "GA",
"codeowner": "@grafana/grafana-frontend-platform",
"frontend": true
"frontend": true,
"expression": "true"
}
},
{
@@ -4,11 +4,10 @@ import (
"context"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
"google.golang.org/grpc"
)
func LoggingUnaryInterceptor(cfg *setting.Cfg, logger log.Logger) grpc.UnaryServerInterceptor {
func LoggingUnaryInterceptor(logger log.Logger, enabled bool) grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req any,
@@ -16,7 +15,7 @@ func LoggingUnaryInterceptor(cfg *setting.Cfg, logger log.Logger) grpc.UnaryServ
handler grpc.UnaryHandler,
) (resp any, err error) {
resp, err = handler(ctx, req)
if cfg.GRPCServerEnableLogging {
if enabled {
ctxLogger := logger.FromContext(ctx)
if err != nil {
ctxLogger.Error("gRPC call", "method", info.FullMethod, "req", req, "err", err)
+12 -12
View File
@@ -34,7 +34,7 @@ type Provider interface {
}
type gPRCServerService struct {
cfg *setting.Cfg
cfg setting.GRPCServerSettings
logger log.Logger
server *grpc.Server
address string
@@ -44,9 +44,9 @@ type gPRCServerService struct {
func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authenticator interceptors.Authenticator, tracer tracing.Tracer, registerer prometheus.Registerer) (Provider, error) {
s := &gPRCServerService{
cfg: cfg,
cfg: cfg.GRPCServer,
logger: log.New("grpc-server"),
enabled: features.IsEnabledGlobally(featuremgmt.FlagGrpcServer),
enabled: features.IsEnabledGlobally(featuremgmt.FlagGrpcServer), // TODO: replace with cfg.GRPCServer.Enabled when we remove feature toggle.
startedChan: make(chan struct{}),
}
@@ -75,7 +75,7 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe
grpc.StatsHandler(otelgrpc.NewServerHandler()),
grpc.ChainUnaryInterceptor(
grpcAuth.UnaryServerInterceptor(authenticator.Authenticate),
interceptors.LoggingUnaryInterceptor(s.cfg, s.logger), // needs to be registered after tracing interceptor to get trace id
interceptors.LoggingUnaryInterceptor(s.logger, s.cfg.EnableLogging), // needs to be registered after tracing interceptor to get trace id
middleware.UnaryServerInstrumentInterceptor(grpcRequestDuration),
),
grpc.ChainStreamInterceptor(
@@ -85,16 +85,16 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe
),
}
if s.cfg.GRPCServerTLSConfig != nil {
opts = append(opts, grpc.Creds(credentials.NewTLS(cfg.GRPCServerTLSConfig)))
if s.cfg.TLSConfig != nil {
opts = append(opts, grpc.Creds(credentials.NewTLS(s.cfg.TLSConfig)))
}
if s.cfg.GRPCServerMaxRecvMsgSize > 0 {
opts = append(opts, grpc.MaxRecvMsgSize(s.cfg.GRPCServerMaxRecvMsgSize))
if s.cfg.MaxRecvMsgSize > 0 {
opts = append(opts, grpc.MaxRecvMsgSize(s.cfg.MaxRecvMsgSize))
}
if s.cfg.GRPCServerMaxSendMsgSize > 0 {
opts = append(opts, grpc.MaxSendMsgSize(s.cfg.GRPCServerMaxSendMsgSize))
if s.cfg.MaxSendMsgSize > 0 {
opts = append(opts, grpc.MaxSendMsgSize(s.cfg.MaxSendMsgSize))
}
s.server = grpc.NewServer(opts...)
@@ -102,9 +102,9 @@ func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, authe
}
func (s *gPRCServerService) Run(ctx context.Context) error {
s.logger.Info("Running GRPC server", "address", s.cfg.GRPCServerAddress, "network", s.cfg.GRPCServerNetwork, "tls", s.cfg.GRPCServerTLSConfig != nil, "max_recv_msg_size", s.cfg.GRPCServerMaxRecvMsgSize, "max_send_msg_size", s.cfg.GRPCServerMaxSendMsgSize)
s.logger.Info("Running GRPC server", "address", s.cfg.Address, "network", s.cfg.Network, "tls", s.cfg.TLSConfig != nil, "max_recv_msg_size", s.cfg.MaxRecvMsgSize, "max_send_msg_size", s.cfg.MaxSendMsgSize)
listener, err := net.Listen(s.cfg.GRPCServerNetwork, s.cfg.GRPCServerAddress)
listener, err := net.Listen(s.cfg.Network, s.cfg.Address)
if err != nil {
return fmt.Errorf("GRPC server: failed to listen: %w", err)
}
+1 -7
View File
@@ -281,6 +281,7 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode
Limit: perPage,
Filter: filter,
SignedInUser: c.SignedInUser,
CountTokens: true,
}
serviceAccountSearch, err := api.service.SearchOrgServiceAccounts(ctx, &q)
if err != nil {
@@ -296,13 +297,6 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode
saIDs[saIDString] = true
metadata := api.getAccessControlMetadata(c, map[string]bool{saIDString: true})
sa.AccessControl = metadata[strconv.FormatInt(sa.Id, 10)]
tokens, err := api.service.ListTokens(ctx, &serviceaccounts.GetSATokensQuery{
OrgID: &sa.OrgId, ServiceAccountID: &sa.Id,
})
if err != nil {
api.log.Warn("Failed to list tokens for service account", "serviceAccount", sa.Id)
}
sa.Tokens = int64(len(tokens))
}
return response.JSON(http.StatusOK, serviceAccountSearch)
@@ -402,6 +402,43 @@ func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(ctx context.Context,
if err := sess.Find(&searchResult.ServiceAccounts); err != nil {
return err
}
// stop here if we don't want to count the number of tokens per service account
if !query.CountTokens {
return nil
}
// Fetch tokens count for each service account
accountIDs := make([]int64, len(searchResult.ServiceAccounts))
for i, serviceAccount := range searchResult.ServiceAccounts {
accountIDs[i] = serviceAccount.Id
}
tokensSess := dbSession.Table("api_key").
Select("service_account_id, COUNT(id) AS token_count").
Where("org_id = ?", query.OrgID).
In("service_account_id", accountIDs).
GroupBy("service_account_id")
type tokenCount struct {
AccountId int64 `xorm:"service_account_id"`
TokenCount int64 `xorm:"token_count"`
}
var tokens []tokenCount
if err = tokensSess.Find(&tokens); err != nil {
return err
}
tokenMap := make(map[int64]int64)
for _, token := range tokens {
tokenMap[token.AccountId] = token.TokenCount
}
for i, account := range searchResult.ServiceAccounts {
searchResult.ServiceAccounts[i].Tokens = tokenMap[account.Id]
}
return nil
})
if err != nil {
@@ -488,41 +488,86 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
t.Skip("skipping test in short mode")
}
db, store := setupTestDatabase(t)
initUsers := []tests.TestUser{
{Name: "satest-1", Role: string(org.RoleViewer), Login: "sa-1-satest-1", IsServiceAccount: true},
{Name: "extsvc-test-1", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-1", IsServiceAccount: true},
{Name: "usertest-2", Role: string(org.RoleEditor), Login: "usertest-2", IsServiceAccount: false},
{Name: "satest-3", Role: string(org.RoleEditor), Login: "sa-1-satest-3", IsServiceAccount: true},
{Name: "satest-4", Role: string(org.RoleAdmin), Login: "sa-1-satest-4", IsServiceAccount: true},
{Name: "extsvc-test-3", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-3", IsServiceAccount: true},
{Name: "extsvc-test-4", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-4", IsServiceAccount: true},
{Name: "extsvc-test-5", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-5", IsServiceAccount: true},
{Name: "extsvc-test-6", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-6", IsServiceAccount: true},
{Name: "extsvc-test-7", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-7", IsServiceAccount: true},
{Name: "extsvc-test-8", Role: string(org.RoleNone), Login: "sa-1-extsvc-test-8", IsServiceAccount: true},
{Name: "satest-6", Role: string(org.RoleViewer), Login: "sa-1-satest-6", IsServiceAccount: true},
{Name: "satest-7", Role: string(org.RoleEditor), Login: "sa-1-satest-7", IsServiceAccount: true},
{Name: "satest-8", Role: string(org.RoleAdmin), Login: "sa-1-satest-8", IsServiceAccount: true},
}
db, store := setupTestDatabase(t)
orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers)
users, orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers)
apiKeys := []tests.TestApiKey{
{Name: "sa-01-apikey-01", OrgId: orgID, Key: "key01", IsExpired: false, ServiceAccountID: &users[0].ID},
{Name: "sa-01-apikey-02", OrgId: orgID, Key: "key02", IsExpired: false, ServiceAccountID: &users[0].ID},
{Name: "sa-01-apikey-03", OrgId: orgID, Key: "key03", IsExpired: false, ServiceAccountID: &users[0].ID},
{Name: "sa-02-apikey-01", OrgId: orgID, Key: "key04", IsExpired: false, ServiceAccountID: &users[2].ID},
{Name: "sa-02-apikey-02", OrgId: orgID, Key: "key05", IsExpired: false, ServiceAccountID: &users[2].ID},
{Name: "sa-03-apikey-01", OrgId: orgID, Key: "key06", IsExpired: false, ServiceAccountID: &users[3].ID},
}
tests.SetupApiKeys(t, db, store.cfg, apiKeys)
userWithPerm := &user.SignedInUser{
OrgID: orgID,
Permissions: map[int64]map[string][]string{orgID: {serviceaccounts.ActionRead: {serviceaccounts.ScopeAll}}},
}
expectedServiceAccount := func(i int, tokens int64) *serviceaccounts.ServiceAccountDTO {
return &serviceaccounts.ServiceAccountDTO{
Id: users[i].ID, UID: users[i].UID, Name: users[i].Name, Login: users[i].Login, OrgId: orgID, Role: "None", Tokens: tokens,
}
}
tt := []struct {
desc string
query *serviceaccounts.SearchOrgServiceAccountsQuery
expectedTotal int64 // Value of the result.TotalCount
expectedCount int // Length of the result.ServiceAccounts slice
expectedErr error
desc string
query *serviceaccounts.SearchOrgServiceAccountsQuery
expectedTotal int64 // Value of the result.TotalCount
expectedServiceAccounts []*serviceaccounts.ServiceAccountDTO
expectedErr error
}{
{
desc: "should list all service accounts",
desc: "should list all service accounts with tokens count",
query: &serviceaccounts.SearchOrgServiceAccountsQuery{
OrgID: orgID,
SignedInUser: userWithPerm,
Filter: serviceaccounts.FilterIncludeAll,
CountTokens: true,
},
expectedTotal: 7,
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
expectedServiceAccount(0, 3),
expectedServiceAccount(2, 2),
expectedServiceAccount(3, 1),
expectedServiceAccount(4, 0),
expectedServiceAccount(5, 0),
expectedServiceAccount(6, 0),
expectedServiceAccount(7, 0),
},
},
{
desc: "should list all service accounts with no tokens count",
query: &serviceaccounts.SearchOrgServiceAccountsQuery{
OrgID: orgID,
SignedInUser: userWithPerm,
Filter: serviceaccounts.FilterIncludeAll,
},
expectedTotal: 7,
expectedCount: 7,
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
expectedServiceAccount(0, 0),
expectedServiceAccount(2, 0),
expectedServiceAccount(3, 0),
expectedServiceAccount(4, 0),
expectedServiceAccount(5, 0),
expectedServiceAccount(6, 0),
expectedServiceAccount(7, 0),
},
},
{
desc: "should list no service accounts without permissions",
@@ -534,8 +579,8 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
},
Filter: serviceaccounts.FilterIncludeAll,
},
expectedTotal: 0,
expectedCount: 0,
expectedTotal: 0,
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{},
},
{
desc: "should list one service accounts with restricted permissions",
@@ -551,7 +596,10 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
Filter: serviceaccounts.FilterIncludeAll,
},
expectedTotal: 2,
expectedCount: 2,
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
expectedServiceAccount(0, 0),
expectedServiceAccount(6, 0),
},
},
{
desc: "should list only external service accounts",
@@ -559,9 +607,15 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
OrgID: orgID,
SignedInUser: userWithPerm,
Filter: serviceaccounts.FilterOnlyExternal,
CountTokens: true,
},
expectedTotal: 4,
expectedCount: 4,
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
expectedServiceAccount(0, 3),
expectedServiceAccount(2, 2),
expectedServiceAccount(3, 1),
expectedServiceAccount(4, 0),
},
},
{
desc: "should return service accounts with sa-1-satest login",
@@ -570,9 +624,14 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
Query: "sa-1-satest",
SignedInUser: userWithPerm,
Filter: serviceaccounts.FilterIncludeAll,
CountTokens: true,
},
expectedTotal: 3,
expectedCount: 3,
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
expectedServiceAccount(5, 0),
expectedServiceAccount(6, 0),
expectedServiceAccount(7, 0),
},
},
{
desc: "should only count service accounts",
@@ -582,8 +641,8 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
Filter: serviceaccounts.FilterIncludeAll,
CountOnly: true,
},
expectedTotal: 7,
expectedCount: 0,
expectedTotal: 7,
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{},
},
{
desc: "should paginate result",
@@ -595,7 +654,9 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
Filter: serviceaccounts.FilterIncludeAll,
},
expectedTotal: 7,
expectedCount: 1,
expectedServiceAccounts: []*serviceaccounts.ServiceAccountDTO{
expectedServiceAccount(7, 0),
},
},
}
for _, tc := range tt {
@@ -609,7 +670,10 @@ func TestIntegrationServiceAccountsStoreImpl_SearchOrgServiceAccounts(t *testing
}
require.Equal(t, tc.expectedTotal, got.TotalCount)
require.Len(t, got.ServiceAccounts, tc.expectedCount)
require.Len(t, got.ServiceAccounts, len(tc.expectedServiceAccounts))
for i, sa := range got.ServiceAccounts {
require.EqualValues(t, tc.expectedServiceAccounts[i], sa)
}
})
}
}
@@ -628,7 +692,7 @@ func TestIntegrationServiceAccountsStoreImpl_EnableServiceAccounts(t *testing.T)
}
db, store := setupTestDatabase(t)
orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers)
_, orgID := tests.SetupUsersServiceAccounts(t, db, store.cfg, initUsers)
fetchStates := func() map[int64]bool {
sa1, err := store.RetrieveServiceAccount(ctx, &serviceaccounts.GetServiceAccountQuery{OrgID: orgID, ID: 1})
+1
View File
@@ -120,6 +120,7 @@ type SearchOrgServiceAccountsQuery struct {
Page int
Limit int
CountOnly bool
CountTokens bool
SignedInUser identity.Requester
}
+15 -3
View File
@@ -107,9 +107,18 @@ func SetupApiKey(t *testing.T, store db.DB, cfg *setting.Cfg, testKey TestApiKey
return key
}
func SetupApiKeys(t *testing.T, store db.DB, cfg *setting.Cfg, testKeys []TestApiKey) []*apikey.APIKey {
result := make([]*apikey.APIKey, len(testKeys))
for i, testKey := range testKeys {
result[i] = SetupApiKey(t, store, cfg, testKey)
}
return result
}
// SetupUsersServiceAccounts creates in "test org" all users or service accounts passed in parameter
// To achieve this, it sets the AutoAssignOrg and AutoAssignOrgId settings.
func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (orgID int64) {
func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (users []user.User, orgID int64) {
role := string(org.RoleNone)
quotaService := quotaimpl.ProvideService(sqlStore, cfg)
@@ -129,8 +138,9 @@ func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, t
cfg.AutoAssignOrg = true
cfg.AutoAssignOrgId = int(org.ID)
users = make([]user.User, len(testUsers))
for i := range testUsers {
_, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{
newUser, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{
Login: testUsers[i].Login,
IsServiceAccount: testUsers[i].IsServiceAccount,
DefaultOrgRole: role,
@@ -138,6 +148,8 @@ func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, t
OrgID: org.ID,
})
require.NoError(t, err)
users[i] = *newUser
}
return org.ID
return users, org.ID
}
+1 -73
View File
@@ -5,11 +5,9 @@ package setting
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
@@ -480,12 +478,7 @@ type Cfg struct {
Zanzana ZanzanaSettings
// GRPC Server.
GRPCServerNetwork string
GRPCServerAddress string
GRPCServerTLSConfig *tls.Config
GRPCServerEnableLogging bool // log request and response of each unary gRPC call
GRPCServerMaxRecvMsgSize int
GRPCServerMaxSendMsgSize int
GRPCServer GRPCServerSettings
CustomResponseHeaders map[string]string
@@ -1799,71 +1792,6 @@ func (cfg *Cfg) readAlertingSettings(iniFile *ini.File) error {
return nil
}
func readGRPCServerSettings(cfg *Cfg, iniFile *ini.File) error {
server := iniFile.Section("grpc_server")
errPrefix := "grpc_server:"
useTLS := server.Key("use_tls").MustBool(false)
certFile := server.Key("cert_file").String()
keyFile := server.Key("cert_key").String()
if useTLS {
serverCert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("%s error loading X509 key pair: %w", errPrefix, err)
}
cfg.GRPCServerTLSConfig = &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.NoClientCert,
}
}
cfg.GRPCServerNetwork = valueAsString(server, "network", "tcp")
cfg.GRPCServerAddress = valueAsString(server, "address", "")
cfg.GRPCServerEnableLogging = server.Key("enable_logging").MustBool(false)
cfg.GRPCServerMaxRecvMsgSize = server.Key("max_recv_msg_size").MustInt(0)
cfg.GRPCServerMaxSendMsgSize = server.Key("max_send_msg_size").MustInt(0)
switch cfg.GRPCServerNetwork {
case "unix":
if cfg.GRPCServerAddress != "" {
// Explicitly provided path for unix domain socket.
if stat, err := os.Stat(cfg.GRPCServerAddress); os.IsNotExist(err) {
// File does not exist - nice, nothing to do.
} else if err != nil {
return fmt.Errorf("%s error getting stat for a file: %s", errPrefix, cfg.GRPCServerAddress)
} else {
if stat.Mode()&fs.ModeSocket == 0 {
return fmt.Errorf("%s file %s already exists and is not a unix domain socket", errPrefix, cfg.GRPCServerAddress)
}
// Unix domain socket file, should be safe to remove.
err := os.Remove(cfg.GRPCServerAddress)
if err != nil {
return fmt.Errorf("%s can't remove unix socket file: %s", errPrefix, cfg.GRPCServerAddress)
}
}
} else {
// Use temporary file path for a unix domain socket.
tf, err := os.CreateTemp("", "gf_grpc_server_api")
if err != nil {
return fmt.Errorf("%s error creating tmp file: %v", errPrefix, err)
}
unixPath := tf.Name()
if err := tf.Close(); err != nil {
return fmt.Errorf("%s error closing tmp file: %v", errPrefix, err)
}
if err := os.Remove(unixPath); err != nil {
return fmt.Errorf("%s error removing tmp file: %v", errPrefix, err)
}
cfg.GRPCServerAddress = unixPath
}
case "tcp":
if cfg.GRPCServerAddress == "" {
cfg.GRPCServerAddress = "127.0.0.1:10000"
}
default:
return fmt.Errorf("%s unsupported network %s", errPrefix, cfg.GRPCServerNetwork)
}
return nil
}
func readSnapshotsSettings(cfg *Cfg, iniFile *ini.File) error {
snapshots := iniFile.Section("snapshots")
+137
View File
@@ -0,0 +1,137 @@
package setting
import (
"crypto/tls"
"fmt"
"io/fs"
"os"
"github.com/spf13/pflag"
"gopkg.in/ini.v1"
)
type GRPCServerSettings struct {
Enabled bool
Network string
Address string // with flags, call Process to fill this field defaults
TLSConfig *tls.Config // with flags, call Process to fill this field
EnableLogging bool // log request and response of each unary gRPC call
MaxRecvMsgSize int
MaxSendMsgSize int
// Internal fields
useTLS bool
certFile string
keyFile string
}
func gRPCServerSettingsError(msg string, args ...interface{}) error {
return fmt.Errorf("grpc_server: "+msg, args...)
}
func (c *GRPCServerSettings) Process() error {
err := c.processTLSConfig()
if err != nil {
return err
}
return c.processAddress()
}
func (c *GRPCServerSettings) processTLSConfig() error {
if !c.useTLS {
return nil
}
serverCert, err := tls.LoadX509KeyPair(c.certFile, c.keyFile)
if err != nil {
return gRPCServerSettingsError("error loading X509 key pair: %w", err)
}
c.TLSConfig = &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.NoClientCert,
}
return nil
}
func (c *GRPCServerSettings) processAddress() error {
switch c.Network {
case "unix":
if c.Address != "" {
// Explicitly provided path for unix domain socket.
if stat, err := os.Stat(c.Address); os.IsNotExist(err) {
// File does not exist - nice, nothing to do.
} else if err != nil {
return gRPCServerSettingsError("error getting stat for a file: %s", c.Address)
} else {
if stat.Mode()&fs.ModeSocket == 0 {
return gRPCServerSettingsError("file %s already exists and is not a unix domain socket", c.Address)
}
// Unix domain socket file, should be safe to remove.
err := os.Remove(c.Address)
if err != nil {
return gRPCServerSettingsError("can't remove unix socket file: %s", c.Address)
}
}
} else {
// Use temporary file path for a unix domain socket.
tf, err := os.CreateTemp("", "gf_grpc_server_api")
if err != nil {
return gRPCServerSettingsError("error creating tmp file: %v", err)
}
unixPath := tf.Name()
if err := tf.Close(); err != nil {
return gRPCServerSettingsError("error closing tmp file: %v", err)
}
if err := os.Remove(unixPath); err != nil {
return gRPCServerSettingsError("error removing tmp file: %v", err)
}
c.Address = unixPath
}
return nil
case "tcp":
if c.Address == "" {
c.Address = "127.0.0.1:10000"
}
return nil
default:
return gRPCServerSettingsError("unsupported network %s", c.Network)
}
}
func readGRPCServerSettings(cfg *Cfg, iniFile *ini.File) error {
server := iniFile.Section("grpc_server")
// This setting can be used to replace the `FlagGrpcServer` feature flag
cfg.GRPCServer.Enabled = server.Key("enabled").MustBool(false)
cfg.GRPCServer.useTLS = server.Key("use_tls").MustBool(false)
cfg.GRPCServer.certFile = server.Key("cert_file").String()
cfg.GRPCServer.keyFile = server.Key("cert_key").String()
if err := cfg.GRPCServer.processTLSConfig(); err != nil {
return err
}
cfg.GRPCServer.Network = valueAsString(server, "network", "tcp")
cfg.GRPCServer.Address = valueAsString(server, "address", "")
cfg.GRPCServer.EnableLogging = server.Key("enable_logging").MustBool(false)
cfg.GRPCServer.MaxRecvMsgSize = server.Key("max_recv_msg_size").MustInt(0)
cfg.GRPCServer.MaxSendMsgSize = server.Key("max_send_msg_size").MustInt(0)
return cfg.GRPCServer.processAddress()
}
func (c *GRPCServerSettings) AddFlags(fs *pflag.FlagSet) {
fs.BoolVar(&c.Enabled, "grpc-server-enabled", false, "Enable gRPC server")
fs.StringVar(&c.Network, "grpc-server-network", "tcp", "Network type for the gRPC server (tcp, unix)")
fs.StringVar(&c.Address, "grpc-server-address", "", "Address for the gRPC server")
fs.BoolVar(&c.EnableLogging, "grpc-server-enable-logging", false, "Enable logging of gRPC requests and responses")
fs.IntVar(&c.MaxRecvMsgSize, "grpc-server-max-recv-msg-size", 0, "Maximum size of a gRPC request message in bytes")
fs.IntVar(&c.MaxSendMsgSize, "grpc-server-max-send-msg-size", 0, "Maximum size of a gRPC response message in bytes")
// Internal flags, we need to call ProcessTLSConfig
fs.BoolVar(&c.useTLS, "grpc-server-use-tls", false, "Enable TLS for the gRPC server")
fs.StringVar(&c.certFile, "grpc-server-cert-file", "", "Path to the certificate file for the gRPC server")
fs.StringVar(&c.keyFile, "grpc-server-key-file", "", "Path to the certificate key file for the gRPC server")
}
+12 -3
View File
@@ -44,6 +44,7 @@ var _ storage.Interface = (*Storage)(nil)
// Optional settings that apply to a single resource
type StorageOptions struct {
LargeObjectSupport LargeObjectSupport
InternalConversion func([]byte, runtime.Object) (runtime.Object, error)
}
// Storage implements storage.Interface and storage resources as JSON files on disk.
@@ -132,6 +133,14 @@ func (s *Storage) Versioner() storage.Versioner {
return s.versioner
}
func (s *Storage) convertToObject(data []byte, obj runtime.Object) (runtime.Object, error) {
if s.opts.InternalConversion != nil {
return s.opts.InternalConversion(data, obj)
}
obj, _, err := s.codec.Decode(data, nil, obj)
return obj, err
}
// Create adds a new object at a key unless it already exists. 'ttl' is time-to-live
// in seconds (0 means forever). If no error is returned and out is not nil, out will be
// set to the read value from database.
@@ -310,7 +319,7 @@ func (s *Storage) Get(ctx context.Context, key string, opts storage.GetOptions,
return resource.GetError(rsp.Error)
}
_, _, err = s.codec.Decode(rsp.Value, nil, objPtr)
_, err = s.convertToObject(rsp.Value, objPtr)
if err != nil {
return err
}
@@ -360,7 +369,7 @@ func (s *Storage) GetList(ctx context.Context, key string, opts storage.ListOpti
}
for _, item := range rsp.Items {
obj, _, err := s.codec.Decode(item.Value, nil, s.newFunc())
obj, err := s.convertToObject(item.Value, s.newFunc())
if err != nil {
return err
}
@@ -461,7 +470,7 @@ func (s *Storage) GuaranteedUpdate(
existingObj = s.newFunc()
if len(rsp.Value) > 0 {
created = false
_, _, err = s.codec.Decode(rsp.Value, nil, existingObj)
_, err = s.convertToObject(rsp.Value, existingObj)
if err != nil {
return err
}
+3 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/apiserver/options"
"github.com/grafana/grafana/pkg/services/authn/grpcutils"
"github.com/grafana/grafana/pkg/services/authz"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@@ -32,6 +33,7 @@ func ProvideUnifiedStorageClient(
db infraDB.DB,
tracer tracing.Tracer,
reg prometheus.Registerer,
authzc authz.Client,
) (resource.ResourceClient, error) {
// See: apiserver.ApplyGrafanaConfig(cfg, features, o)
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
@@ -95,7 +97,7 @@ func ProvideUnifiedStorageClient(
// Use the local SQL
default:
server, err := sql.NewResourceServer(ctx, db, cfg, features, tracer, reg)
server, err := sql.NewResourceServer(ctx, db, cfg, features, tracer, reg, authzc)
if err != nil {
return nil, err
}
+81
View File
@@ -2,6 +2,8 @@ package resource
import (
"context"
"log/slog"
"time"
"github.com/grafana/authlib/authz"
"github.com/grafana/authlib/claims"
@@ -24,3 +26,82 @@ func (c *staticAuthzClient) Compile(ctx context.Context, id claims.AuthInfo, req
}
var _ authz.AccessClient = &staticAuthzClient{}
type groupResource map[string]map[string]interface{}
// authzLimitedClient is a client that enforces RBAC for the limited number of groups and resources.
// This is a temporary solution until the authz service is fully implemented.
// The authz service will be responsible for enforcing RBAC.
// For now, it makes one call to the authz service for each list items. This is known to be inefficient.
type authzLimitedClient struct {
client authz.AccessChecker
// whitelist is a map of group to resources that are compatible with RBAC.
whitelist groupResource
logger *slog.Logger
}
// NewAuthzLimitedClient creates a new authzLimitedClient.
func NewAuthzLimitedClient(client authz.AccessChecker) authz.AccessClient {
logger := slog.Default().With("logger", "limited-authz-client")
return &authzLimitedClient{
client: client,
whitelist: groupResource{
"dashboard.grafana.app": map[string]interface{}{"dashboards": nil},
"folder.grafana.app": map[string]interface{}{"folders": nil},
},
logger: logger,
}
}
// Check implements authz.AccessClient.
func (c authzLimitedClient) Check(ctx context.Context, id claims.AuthInfo, req authz.CheckRequest) (authz.CheckResponse, error) {
if !c.IsCompatibleWithRBAC(req.Group, req.Resource) {
c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "rbac", false, "allowed", true)
return authz.CheckResponse{Allowed: true}, nil
}
t := time.Now()
resp, err := c.client.Check(ctx, id, req)
if err != nil {
c.logger.Error("Check", "group", req.Group, "resource", req.Resource, "rbac", true, "error", err, "duration", time.Since(t))
return resp, err
}
c.logger.Debug("Check", "group", req.Group, "resource", req.Resource, "rbac", true, "allowed", resp.Allowed, "duration", time.Since(t))
return resp, nil
}
// Compile implements authz.AccessClient.
func (c authzLimitedClient) Compile(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (authz.ItemChecker, error) {
return func(namespace string, name, folder string) bool {
// TODO: Implement For now we perform the check for each item.
if !c.IsCompatibleWithRBAC(req.Group, req.Resource) {
c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", false, "allowed", true)
return true
}
t := time.Now()
r, err := c.client.Check(ctx, id, authz.CheckRequest{
Verb: "get",
Group: req.Group,
Resource: req.Resource,
Namespace: namespace,
Name: name,
Folder: folder,
})
if err != nil {
c.logger.Error("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", true, "error", err, "duration", time.Since(t))
return false
}
c.logger.Debug("Compile.Check", "group", req.Group, "resource", req.Resource, "namespace", namespace, "name", name, "folder", folder, "rbac", true, "allowed", r.Allowed, "duration", time.Since(t))
return r.Allowed
}, nil
}
func (c authzLimitedClient) IsCompatibleWithRBAC(group, resource string) bool {
if _, ok := c.whitelist[group]; ok {
if _, ok := c.whitelist[group][resource]; ok {
return true
}
}
return false
}
var _ authz.AccessClient = &authzLimitedClient{}
@@ -0,0 +1,62 @@
package resource
import (
"context"
"testing"
"github.com/grafana/authlib/authz"
"github.com/stretchr/testify/assert"
)
func TestAuthzLimitedClient_Check(t *testing.T) {
mockClient := &staticAuthzClient{allowed: false}
client := NewAuthzLimitedClient(mockClient)
tests := []struct {
group string
resource string
expected bool
}{
{"dashboard.grafana.app", "dashboards", false},
{"folder.grafana.app", "folders", false},
{"unknown.group", "unknown.resource", true},
}
for _, test := range tests {
req := authz.CheckRequest{
Group: test.group,
Resource: test.resource,
}
resp, err := client.Check(context.Background(), nil, req)
assert.NoError(t, err)
assert.Equal(t, test.expected, resp.Allowed)
}
}
func TestAuthzLimitedClient_Compile(t *testing.T) {
mockClient := &staticAuthzClient{allowed: false}
client := NewAuthzLimitedClient(mockClient)
tests := []struct {
group string
resource string
expected bool
}{
{"dashboard.grafana.app", "dashboards", false},
{"folder.grafana.app", "folders", false},
{"unknown.group", "unknown.resource", true},
}
for _, test := range tests {
req := authz.ListRequest{
Group: test.group,
Resource: test.resource,
}
checker, err := client.Compile(context.Background(), nil, req)
assert.NoError(t, err)
assert.NotNil(t, checker)
result := checker("namespace", "name", "folder")
assert.Equal(t, test.expected, result)
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more