Merge remote-tracking branch 'origin/main' into zoltan/mssql-migration

pull/51765/head
Zoltán Bedi 3 years ago
commit f14b45bc86
  1. 36
      .betterer.results
  2. 2
      .github/workflows/bump-version.yml
  3. 5
      .github/workflows/cloud-data-sources-code-coverage.yml
  4. 2
      .github/workflows/publish.yml
  5. 2
      .pa11yci-pr.conf.js
  6. 5
      conf/defaults.ini
  7. 5
      conf/sample.ini
  8. 2
      contribute/localization.md
  9. 29
      docs/sources/administration/api-keys/_index.md
  10. 18
      docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles.md
  11. 49
      docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes.md
  12. 3
      docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions.md
  13. 57
      docs/sources/administration/service-accounts/_index.md
  14. 18
      docs/sources/administration/team-management/_index.md
  15. 2
      docs/sources/alerting/alerting-limitations.md
  16. 1
      docs/sources/alerting/fundamentals/annotation-label/how-to-use-labels.md
  17. 4
      docs/sources/alerting/fundamentals/data-source-alerting.md
  18. 1
      docs/sources/alerting/migrating-alerts/_index.md
  19. 6
      docs/sources/alerting/silences/linking-to-silence-form.md
  20. 4
      docs/sources/best-practices/best-practices-for-managing-dashboards.md
  21. 2
      docs/sources/best-practices/dashboard-management-maturity-levels.md
  22. 88
      docs/sources/dashboards/manage-dashboard-links/index.md
  23. 64
      docs/sources/datasources/azuremonitor/template-variables.md
  24. 2
      docs/sources/datasources/postgres.md
  25. 274
      docs/sources/developers/http_api/access_control.md
  26. 12
      docs/sources/developers/http_api/serviceaccount.md
  27. 31
      docs/sources/linking/_index.md
  28. 62
      docs/sources/linking/data-link-variables.md
  29. 54
      docs/sources/linking/data-links.md
  30. 39
      docs/sources/linking/linking-overview.md
  31. 52
      docs/sources/linking/panel-links.md
  32. 114
      docs/sources/panels/configure-data-links/index.md
  33. 2
      docs/sources/panels/standard-field-definitions.md
  34. 13
      docs/sources/panels/working-with-panels/add-link-to-panel.md
  35. 12
      docs/sources/setup-grafana/configure-grafana/_index.md
  36. 21
      docs/sources/setup-grafana/configure-security/configure-authentication/ldap.md
  37. 48
      docs/sources/visualizations/table/filter-table-columns.md
  38. 45
      docs/sources/visualizations/table/index.md
  39. 2
      docs/sources/whatsnew/whats-new-in-v8-0.md
  40. 2
      docs/sources/whatsnew/whats-new-in-v8-1.md
  41. 4
      package.json
  42. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  43. 2
      packages/grafana-data/src/utils/datasource.ts
  44. 3
      packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts
  45. 2
      packages/grafana-toolkit/src/cli/tasks/plugin.sign.ts
  46. 8
      packages/grafana-toolkit/src/config/utils/pluginValidation.ts
  47. 4
      packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx
  48. 4
      packages/grafana-ui/src/components/Logs/LogRowMessage.tsx
  49. 13
      packages/grafana-ui/src/components/Logs/LogRowMessageDetectedFields.tsx
  50. 4
      packages/grafana-ui/src/components/Tags/Tag.tsx
  51. 25
      packages/grafana-ui/src/components/Tags/TagList.story.tsx
  52. 7
      packages/grafana-ui/src/components/Tags/TagList.tsx
  53. 2
      packages/grafana-ui/src/components/index.ts
  54. 7
      packages/grafana-ui/src/themes/GlobalStyles/page.ts
  55. 1
      packages/jaeger-ui-components/package.json
  56. 7
      packages/jaeger-ui-components/src/TraceTimelineViewer/index.test.js
  57. 18
      packages/jaeger-ui-components/src/TraceTimelineViewer/index.tsx
  58. 154
      pkg/api/alerting.go
  59. 4
      pkg/api/alerting_test.go
  60. 6
      pkg/api/api.go
  61. 10
      pkg/api/datasources.go
  62. 177
      pkg/api/docs/definitions/playlists.go
  63. 2
      pkg/api/http_server.go
  64. 6
      pkg/api/index.go
  65. 9
      pkg/api/metrics.go
  66. 48
      pkg/api/metrics_test.go
  67. 16
      pkg/api/response/response.go
  68. 127
      pkg/api/response/response_test.go
  69. 8
      pkg/events/events.go
  70. 10
      pkg/expr/transform.go
  71. 181
      pkg/middleware/csrf/csrf.go
  72. 117
      pkg/middleware/csrf/csrf_test.go
  73. 27
      pkg/models/dashboard_queries.go
  74. 54
      pkg/models/dashboard_queries_test.go
  75. 13
      pkg/plugins/adapters/adapters.go
  76. 10
      pkg/plugins/plugincontext/plugincontext.go
  77. 45
      pkg/server/server.go
  78. 9
      pkg/server/server_test.go
  79. 4
      pkg/server/wire.go
  80. 55
      pkg/services/dashboards/service/dashboard_service.go
  81. 21
      pkg/services/dashboards/service/dashboard_service_test.go
  82. 3
      pkg/services/datasources/datasources.go
  83. 13
      pkg/services/datasources/fakes/fake_datasource_service.go
  84. 4
      pkg/services/datasources/models.go
  85. 81
      pkg/services/datasources/service/datasource_service.go
  86. 100
      pkg/services/datasources/service/secrets_mig.go
  87. 340
      pkg/services/datasources/service/secrets_mig_test.go
  88. 1
      pkg/services/export/commit_helper.go
  89. 51
      pkg/services/export/export_alerts.go
  90. 34
      pkg/services/export/export_anno.go
  91. 150
      pkg/services/export/export_auth.go
  92. 4
      pkg/services/export/export_dash.go
  93. 80
      pkg/services/export/export_dash_thumbs.go
  94. 2
      pkg/services/export/export_ds.go
  95. 4
      pkg/services/export/export_live.go
  96. 53
      pkg/services/export/export_plugins.go
  97. 2
      pkg/services/export/export_snapshots.go
  98. 2
      pkg/services/export/export_sys_playlists.go
  99. 72
      pkg/services/export/export_sys_short_url.go
  100. 85
      pkg/services/export/export_usage.go
  101. Some files were not shown because too many files have changed in this diff Show More

@ -62,7 +62,7 @@ exports[`no enzyme tests`] = {
"packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.test.js:551014442": [
[13, 26, 13, "RegExp match", "2409514259"]
],
"packages/jaeger-ui-components/src/TraceTimelineViewer/index.test.js:276996587": [
"packages/jaeger-ui-components/src/TraceTimelineViewer/index.test.js:1541367299": [
[14, 19, 13, "RegExp match", "2409514259"]
],
"public/app/core/components/Select/MetricSelect.test.tsx:1074737147": [
@ -3910,9 +3910,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/alerting/unified/components/silences/SilencesEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
@ -7778,10 +7775,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/plugins/datasource/mssql/config_ctrl.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/plugins/datasource/mysql/datasource.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
@ -7802,8 +7795,7 @@ exports[`better eslint`] = {
],
"public/app/plugins/datasource/mysql/module.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/plugins/datasource/mysql/mysql_query_model.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
@ -8006,15 +7998,6 @@ exports[`better eslint`] = {
"public/app/plugins/datasource/opentsdb/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/datasource/postgres/config_ctrl.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/plugins/datasource/postgres/datasource.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
@ -8035,9 +8018,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "11"],
[0, 0, 0, "Unexpected any. Specify a different type.", "12"],
[0, 0, 0, "Unexpected any. Specify a different type.", "13"],
[0, 0, 0, "Unexpected any. Specify a different type.", "14"],
[0, 0, 0, "Unexpected any. Specify a different type.", "15"],
[0, 0, 0, "Unexpected any. Specify a different type.", "16"]
[0, 0, 0, "Unexpected any. Specify a different type.", "14"]
],
"public/app/plugins/datasource/postgres/module.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
@ -8922,6 +8903,9 @@ exports[`better eslint`] = {
"public/app/plugins/panel/canvas/editor/PlacementEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/canvas/editor/TreeNavigationEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/canvas/editor/elementEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
@ -9390,13 +9374,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
[0, 0, 0, "Unexpected any. Specify a different type.", "12"]
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/plugins/panel/graph/time_regions_form.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],

@ -74,7 +74,7 @@ jobs:
repository: "grafana/grafana-github-actions"
path: ./actions
ref: main
- uses: actions/setup-node@v3.3.0
- uses: actions/setup-node@v3.4.0
with:
node-version: '16'
- name: Install Actions

@ -14,4 +14,7 @@ on:
jobs:
workflow-call:
uses: grafana/code-coverage/.github/workflows/code-coverage.yml@v0.1.2
uses: grafana/code-coverage/.github/workflows/code-coverage.yml@v0.1.6
with:
frontend-path-regexp: public\/app\/plugins\/datasource\/(grafana-azure-monitor-datasource|cloud-monitoring|cloudwatch)
backend-path-regexp: pkg\/tsdb\/(azuremonitor|cloudmonitoring|cloudwatch)

@ -17,7 +17,7 @@ jobs:
- uses: actions/checkout@v3
- run: git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync
- name: generate-packages-docs
uses: actions/setup-node@v3.3.0
uses: actions/setup-node@v3.4.0
id: generate-docs
with:
node-version: '16'

@ -96,7 +96,7 @@ var config = {
url: '${HOST}/org/apikeys',
wait: 500,
rootElement: '.main-view',
threshold: 0,
threshold: 4,
},
{
url: '${HOST}/dashboards',

@ -877,6 +877,11 @@ max_concurrent_screenshots = 5
# screenshots will be persisted to disk for up to temp_data_lifetime.
upload_external_image_storage = false
[unified_alerting.reserved_labels]
# Comma-separated list of reserved labels added by the Grafana Alerting engine that should be disabled.
# For example: `disabled_labels=grafana_folder`
disabled_labels =
#################################### Alerting ############################
[alerting]
# Enable the legacy alerting sub-system and interface. If Unified Alerting is already enabled and you try to go back to legacy alerting, all data that is part of Unified Alerting will be deleted. When this configuration section and flag are not defined, the state is defined at runtime. See the documentation for more details.

@ -847,6 +847,11 @@
# The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m.
;min_interval = 10s
[unified_alerting.reserved_labels]
# Comma-separated list of reserved labels added by the Grafana Alerting engine that should be disabled.
# For example: `disabled_labels=grafana_folder`
;disabled_labels =
#################################### Alerting ############################
[alerting]
# Disable legacy alerting engine & UI features

@ -191,4 +191,4 @@ import { Plural } from "@lingui/macro"
## Documentation
[Grafana's documentation](https://grafana.com/docs/grafana/latest/) is not yet open for translation and should be authored in English only.
[Grafana's documentation](https://grafana.com/docs/grafana/latest/) is not yet open for translation and should be authored in American English only.

@ -18,7 +18,7 @@ An API key is a randomly generated string that external systems use to interact
When you create an API key, you specify a **Role** that determines the permissions associated with the API key. Role permissions control that actions the API key can perform on Grafana resources.
> **Note:** If you use Grafana v8.5 or newer, use service accounts instead of API keys. For more information, refer to [Service accounts]({{< relref "../service-accounts/" >}}).
> **Note:** If you use Grafana v8.5 or newer, use service accounts instead of API keys. For more information, refer to [Grafana service accounts]({{< relref "../service-accounts/" >}}).
{{< section >}}
@ -30,7 +30,7 @@ This topic shows you how to create an API key using the Grafana UI. You can also
### Before you begin:
- Ensure you have permission to create and edit API keys. For more information about permissions, refer to [About users and permissions]({{< relref "../roles-and-permissions/#" >}}).
- Ensure you have permission to create and edit API keys. For more information about permissions, refer to [Roles and permissions]({{< relref "../roles-and-permissions/#" >}}).
**To create an API key:**
@ -44,3 +44,28 @@ This topic shows you how to create an API key using the Grafana UI. You can also
- The maximum length of time is 30 days (one month). You enter a number and a letter. Valid letters include `s` for seconds,`m` for minutes, `h` for hours, `d `for days, `w` for weeks, and `M `for month. For example, `12h` is 12 hours and `1M` is 1 month (30 days).
- If you are unsure about how long an API key should be valid, we recommend that you choose a short duration, such as a few hours. This approach limits the risk of having API keys that are valid for a long time.
1. Click **Add**.
## Migrate API Keys to Grafana service accounts
You can migrate one or all API keys to [Grafana service accounts]({{< relref "../service-accounts/" >}}). When you migrate an API key to a service account, a service account will be created with a service account token.
The API key will continue to work, and you can find it in the [Grafana service account tokens]({{< relref "../service-accounts/#service-account-benefits/#service-account-tokens" >}}) details.
For more information about benefits of service accounts, refer to [Grafana service account benefits]({{< relref "../service-accounts/#service-account-benefits" >}}).
You can choose to migrate a single API key or all API keys. Note that when you migrate all API keys, you can't create new API keys anymore and will have to use service accounts instead.
### Before you begin
- Ensure you have permission to create Grafana service accounts. For more information about permissions, refer to [Roles and permissions]({{< relref "../roles-and-permissions/#" >}}).
**To migrate all API keys to service accounts:**
1. Sign in to Grafana, hover your cursor over **Configuration** (the gear icon), and click **API Keys**.
1. In the top of the page, find the section which says **Switch from API keys to service accounts**
1. Click **Migrate now**.
1. Once migration is successful, you can choose to forever hide the API keys tab and use service accounts from there on. Click **Go to service accounts tab and never show API keys tab again** if you want to do that.
**To migrate single API key to a service account:**
1. Sign in to Grafana, hover your cursor over **Configuration** (the gear icon), and click **API Keys**.
1. Find the API Key you want to migrate.
1. Click **Migrate**.

@ -17,39 +17,37 @@ In this topic you'll learn how to use the role picker, provisioning, and the HTT
This section describes how to:
- Assign a fixed role to a user or team as an organization administrator.
- Assign a fixed role to a user, team or service account as an organization administrator.
- Assign a fixed role to a user as a server administrator. This approach enables you to assign a fixed role to a user in multiple organizations, without needing to switch organizations.
In both cases, the assignment applies only to the user or team within the affected organization, and no other organizations. For example, if you grant the user the **Data source editor** role in the **Main** organization, then the user can edit data sources in the **Main** organization, but not in other organizations.
> **Note:** After you apply your changes, user and team permissions update immediately, and the UI reflects the new permissions the next time they reload their browser or visit another page.
In both cases, the assignment applies only to the user, team or service account within the affected organization, and no other organizations. For example, if you grant the user the **Data source editor** role in the **Main** organization, then the user can edit data sources in the **Main** organization, but not in other organizations.
<br/>
**Before you begin:**
- [Plan your RBAC rollout strategy]({{< relref "./plan-rbac-rollout-strategy/" >}}).
- Identify the fixed roles that you want to assign to the user or team.
- Identify the fixed roles that you want to assign to the user, team or service account.
For more information about available fixed roles, refer to [RBAC role definitions]({{< relref "./rbac-fixed-basic-role-definitions/" >}}).
- Ensure that your own user account has the correct permissions:
- If you are assigning permissions to a user or team within an organization, you must have organization administrator or server administrator permissions.
- If you are assigning permissions to a user, team or service account within an organization, you must have organization administrator or server administrator permissions.
- If you are assigning permissions to a user who belongs to multiple organizations, you must have server administrator permissions.
- Your Grafana user can also assign fixed role if it has either the `fixed:roles:writer` fixed role assigned to the same organization to which you are assigning RBAC to a user, or a custom role with `users.roles:add` and `users.roles:remove` permissions.
- Your own user account must have the roles you are granting. For example, if you would like to grant the `fixed:users:writer` role to a team, you must have that role yourself.
<br/>
**To assign a fixed role to a user or team:**
**To assign a fixed role to a user, team or service account:**
1. Sign in to Grafana.
2. Switch to the organization that contains the user or team.
2. Switch to the organization that contains the user, team or service account.
For more information about switching organizations, refer to [Switch organizations]({{< relref "../../user-management/user-preferences/_index.md#switch-organizations" >}}).
3. Hover your cursor over **Configuration** (the gear icon) in the left navigation menu, and click **Users** or **Teams**.
4. In the **Role** column, select the fixed role that you want to assign to the user or team.
3. Hover your cursor over **Configuration** (the gear icon) in the left navigation menu, and click **Users** or **Teams** or **Service Accounts**.
4. In the **Role** column, select the fixed role that you want to assign to the user, team or service account.
5. Click **Update**.
![User role picker in an organization](/static/img/docs/enterprise/user_role_picker_in_org.png)

@ -101,6 +101,12 @@ The following list contains role-based access control actions.
| `roles:write` | `permissions:type:delegate` | Create or update a custom role. |
| `roles:write` | `permissions:type:escalate` | Reset basic roles to their default permissions. |
| `server.stats:read` | n/a | Read Grafana instance statistics. |
| `serviceaccounts:write` | `serviceaccounts:*` | Create Grafana service accounts. |
| `serviceaccounts:create` | n/a | Update Grafana service accounts. |
| `serviceaccounts:delete` | `serviceaccounts:*` | Delete Grafana service accounts. |
| `serviceaccounts:read` | `serviceaccounts:*` | Read Grafana service accounts. |
| `serviceaccounts.permissions:write` | `serviceaccounts:*` | Update Grafana service account permissions to control who can do what with the service account. |
| `serviceaccounts.permissions:read` | `serviceaccounts:*` | Read Grafana service account permissions to see who can do what with the service account. |
| `settings:read` | `settings:*`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Read the [Grafana configuration settings]({{< relref "../../../setup-grafana/configure-grafana/" >}}) |
| `settings:write` | `settings:*`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Update any Grafana configuration settings that can be [updated at runtime]({{< relref "../../../enterprise/settings-updates/" >}}). |
| `status:accesscontrol` | `services:accesscontrol` | Get access-control enabled status. |
@ -120,9 +126,9 @@ The following list contains role-based access control actions.
| `users.permissions:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s organization-level permissions. |
| `users.quotas:read` | `global.users:*` <br> `global.users:id:*` | List a user’s quotas. |
| `users.quotas:write` | `global.users:*` <br> `global.users:id:*` | Update a user’s quotas. |
| `users.roles:add` | `permissions:type:delegate` | Assign a role to a user. |
| `users.roles:read` | `users:*` | List roles assigned directly to a user. |
| `users.roles:remove` | `permissions:type:delegate` | Unassign a role from a user. |
| `users.roles:add` | `permissions:type:delegate` | Assign a role to a user or a service account. |
| `users.roles:read` | `users:*` | List roles assigned directly to a user or a service account. |
| `users.roles:remove` | `permissions:type:delegate` | Unassign a role from a user or a service account. |
| `users:create` | n/a | Create a user. |
| `users:delete` | `global.users:*` <br> `global.users:id:*` | Delete a user. |
| `users:disable` | `global.users:*` <br> `global.users:id:*` | Disable a user. |
@ -135,21 +141,22 @@ The following list contains role-based access control actions.
The following list contains role-based access control scopes.
| Scopes | Descriptions |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `annotations:*`<br>`annotations:type:*` | Restrict an action to a set of annotations. For example, `annotations:*` matches any annotation, `annotations:type:dashboard` matches annotations associated with dashboards and `annotations:type:organization` matches organization annotations. |
| `apikeys:*`<br>`apikeys:id:*` | Restrict an action to a set of API keys. For example, `apikeys:*` matches any API key, `apikey:id:1` matches the API key whose id is `1`. |
| `dashboards:*`<br>`dashboards:uid:*` | Restrict an action to a set of dashboards. For example, `dashboards:*` matches any dashboard, and `dashboards:uid:1` matches the dashboard whose UID is `1`. |
| `datasources:*`<br>`datasources:uid:*` | Restrict an action to a set of data sources. For example, `datasources:*` matches any data source, and `datasources:uid:1` matches the data source whose UID is `1`. |
| `folders:*`<br>`folders:uid:*` | Restrict an action to a set of folders. For example, `folders:*` matches any folder, and `folders:uid:1` matches the folder whose UID is `1`. |
| `global.users:*` <br> `global.users:id:*` | Restrict an action to a set of global users. For example, `global.users:*` matches any user and `global.users:id:1` matches the user whose ID is `1`. |
| `orgs:*` <br> `orgs:id:*` | Restrict an action to a set of organizations. For example, `orgs:*` matches any organization and `orgs:id:1` matches the organization whose ID is `1`. |
| `permissions:type:delegate` | The scope is only applicable for roles associated with the Access Control itself and indicates that you can delegate your permissions only, or a subset of it, by creating a new role or making an assignment. |
| `permissions:type:escalate` | The scope is required to trigger the reset of basic roles permissions. It indicates that users might acquire additional permissions they did not previously have. |
| `provisioners:*` | Restrict an action to a set of provisioners. For example, `provisioners:*` matches any provisioner, and `provisioners:accesscontrol` matches the role-based access control [provisioner]({{< relref "./rbac-provisioning/" >}}). |
| `reports:*` <br> `reports:id:*` | Restrict an action to a set of reports. For example, `reports:*` matches any report and `reports:id:1` matches the report whose ID is `1`. |
| `roles:*` <br> `roles:uid:*` | Restrict an action to a set of roles. For example, `roles:*` matches any role and `roles:uid:randomuid` matches only the role whose UID is `randomuid`. |
| `services:accesscontrol` | Restrict an action to target only the role-based access control service. You can use this in conjunction with the `status:accesscontrol` actions. |
| `settings:*` | Restrict an action to a subset of settings. For example, `settings:*` matches all settings, `settings:auth.saml:*` matches all SAML settings, and `settings:auth.saml:enabled` matches the enable property on the SAML settings. |
| `teams:*` <br> `teams:id:*` | Restrict an action to a set of teams from an organization. For example, `teams:*` matches any team and `teams:id:1` matches the team whose ID is `1`. |
| `users:*` <br> `users:id:*` | Restrict an action to a set of users from an organization. For example, `users:*` matches any user and `users:id:1` matches the user whose ID is `1`. |
| Scopes | Descriptions |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `annotations:*`<br>`annotations:type:*` | Restrict an action to a set of annotations. For example, `annotations:*` matches any annotation, `annotations:type:dashboard` matches annotations associated with dashboards and `annotations:type:organization` matches organization annotations. |
| `apikeys:*`<br>`apikeys:id:*` | Restrict an action to a set of API keys. For example, `apikeys:*` matches any API key, `apikey:id:1` matches the API key whose id is `1`. |
| `dashboards:*`<br>`dashboards:uid:*` | Restrict an action to a set of dashboards. For example, `dashboards:*` matches any dashboard, and `dashboards:uid:1` matches the dashboard whose UID is `1`. |
| `datasources:*`<br>`datasources:uid:*` | Restrict an action to a set of data sources. For example, `datasources:*` matches any data source, and `datasources:uid:1` matches the data source whose UID is `1`. |
| `folders:*`<br>`folders:uid:*` | Restrict an action to a set of folders. For example, `folders:*` matches any folder, and `folders:uid:1` matches the folder whose UID is `1`. |
| `global.users:*` <br> `global.users:id:*` | Restrict an action to a set of global users. For example, `global.users:*` matches any user and `global.users:id:1` matches the user whose ID is `1`. |
| `orgs:*` <br> `orgs:id:*` | Restrict an action to a set of organizations. For example, `orgs:*` matches any organization and `orgs:id:1` matches the organization whose ID is `1`. |
| `permissions:type:delegate` | The scope is only applicable for roles associated with the Access Control itself and indicates that you can delegate your permissions only, or a subset of it, by creating a new role or making an assignment. |
| `permissions:type:escalate` | The scope is required to trigger the reset of basic roles permissions. It indicates that users might acquire additional permissions they did not previously have. |
| `provisioners:*` | Restrict an action to a set of provisioners. For example, `provisioners:*` matches any provisioner, and `provisioners:accesscontrol` matches the role-based access control [provisioner]({{< relref "./rbac-provisioning/" >}}). |
| `reports:*` <br> `reports:id:*` | Restrict an action to a set of reports. For example, `reports:*` matches any report and `reports:id:1` matches the report whose ID is `1`. |
| `roles:*` <br> `roles:uid:*` | Restrict an action to a set of roles. For example, `roles:*` matches any role and `roles:uid:randomuid` matches only the role whose UID is `randomuid`. |
| `services:accesscontrol` | Restrict an action to target only the role-based access control service. You can use this in conjunction with the `status:accesscontrol` actions. |
| `serviceaccounts:*` <br> `serviceaccounts:id:*` | Restrict an action to a set of service account from an organization. For example, `serviceaccounts:*` matches any service account and `serviceaccount:id:1` matches the service account whose ID is `1`. |
| `settings:*` | Restrict an action to a subset of settings. For example, `settings:*` matches all settings, `settings:auth.saml:*` matches all SAML settings, and `settings:auth.saml:enabled` matches the enable property on the SAML settings. |
| `teams:*` <br> `teams:id:*` | Restrict an action to a set of teams from an organization. For example, `teams:*` matches any team and `teams:id:1` matches the team whose ID is `1`. |
| `users:*` <br> `users:id:*` | Restrict an action to a set of users from an organization. For example, `users:*` matches any user and `users:id:1` matches the user whose ID is `1`. |

@ -71,6 +71,9 @@ The following tables list permissions associated with basic and fixed roles.
| `fixed:roles:reader` | `roles:read`<br>`teams.roles:read`<br>`users.roles:read`<br>`users.permissions:read` | Read all access control roles, roles and permissions assigned to users, teams. |
| `fixed:roles:writer` | All permissions from `fixed:roles:reader` and <br>`roles:write`<br>`roles:delete`<br>`teams.roles:add`<br>`teams.roles:remove`<br>`users.roles:add`<br>`users.roles:remove` | Create, read, update, or delete all roles, assign or unassign roles to users, teams. |
| `fixed:roles:resetter` | `roles:write` with scope `permissions:type:escalate` | Reset basic roles to their default. |
| `fixed:serviceaccounts:reader` | `serviceaccounts:read` | Read Grafana service accounts. |
| `fixed:serviceaccounts:creator` | `serviceaccounts:create` | Create Grafana service accounts. |
| `fixed:serviceaccounts:writer` | `serviceaccounts:read`<br>`serviceaccounts:create`<br>`serviceaccounts:write`<br>`serviceaccounts:delete`<br>`serviceaccounts.permissions:read`<br>`serviceaccounts.permissions:write` | Create, update, read and delete all Grafana service accounts and manage service account permissions. |
| `fixed:settings:reader` | `settings:read` | Read Grafana instance settings. |
| `fixed:settings:writer` | All permissions from `fixed:settings:reader` and<br>`settings:write` | Read and update Grafana instance settings. |
| `fixed:stats:reader` | `server.stats:read` | Read Grafana instance statistics. |

@ -24,7 +24,7 @@ You can use service accounts to run automated or compute workloads.
A service account can be used to run automated workloads in Grafana, like dashboard provisioning, configuration, or report generation. Create service accounts and tokens to authenticate applications like Terraform with the Grafana API.
> **Note:** Service accounts are available in Grafana 8.5+ as a beta feature. To enable service accounts, refer to the [Enable service accounts]({{< ref "#enable-service-accounts" >}}) section. Service accounts will eventually replace [API keys]({{< relref "../api-keys/" >}}) as the primary way to authenticate applications that interact with Grafana.
> **Note:** Service accounts will eventually replace [API keys]({{< relref "../api-keys/" >}}) as the primary way to authenticate applications that interact with Grafana.
A common use case for creating a service account is to perform operations on automated or triggered tasks. You can use service accounts to:
@ -59,47 +59,16 @@ The added benefits of service accounts to API keys include:
- Unlike API keys, service account tokens are not associated with a specific user, which means that applications can be authenticated even if a Grafana user is deleted.
- You can grant granular permissions to service accounts by leveraging [role-based access control]({{< relref "../roles-and-permissions/access-control/" >}}). For more information about permissions, refer to [About users and permissions]({{< relref "../roles-and-permissions/" >}}).
## Enable service accounts in Grafana
Service accounts are available behind the `serviceAccounts` feature toggle, available in Grafana 8.5+.
You can enable service accounts by:
- modifying the Grafana configuration file, or
- configuring an environment variable
### Enable service accounts in the Grafana configuration file
This topic shows you how to enable service accounts by modifying the Grafana configuration file.
1. Sign in to the Grafana server and locate the configuration file. For more information about finding the configuration file, refer to LINK.
2. Open the configuration file and locate the [feature toggles section]({{< relref "../../setup-grafana/configure-grafana/#feature_toggles" >}}). Add `serviceAccounts` as a [feature_toggle]({{< relref "../../setup-grafana/configure-grafana/#feature_toggle" >}}).
```
[feature_toggles]
# enable features, separated by spaces
enable = serviceAccounts
```
1. Save your changes, Grafana should recognize your changes; in case of any issues we recommend restarting the Grafana server.
### Enable service accounts with an environment variable
This topic shows you how to enable service accounts by setting environment variables before starting Grafana.
Follow the instructions to [override configuration with environment variables]({{< relref "../../setup-grafana/configure-grafana/#override-configuration-with-environment-variables" >}}). Set the following environment variable: `GF_FEATURE_TOGGLES_ENABLE = serviceAccounts`.
> **Note:** Environment variables override configuration file settings.
## Create a service account in Grafana
A service account can be used to run automated workloads in Grafana, like dashboard provisioning, configuration, or report generation. For more information about how you can use service accounts, refer to [About service accounts]({{< ref "#about-service-accounts" >}}).
For more information about creating service accounts via the API, refer to [Create a service account in the HTTP API]({{< relref "../../developers/http_api/serviceaccount/#create-service-account" >}}).
Note that the user who created a service account will also be able to read, update and delete the service account that they created, as well as permissions associated with that service account.
### Before you begin
- Ensure you have added the feature toggle for service accounts `serviceAccounts`. For more information about adding the feature toggle, refer to [Enable service accounts]({{< ref "#enable-service-accounts" >}}).
- Ensure you have permission to create and edit service accounts. By default, the organization administrator role is required to create and edit service accounts. For more information about user permissions, refer to [About users and permissions]({{< relref "../roles-and-permissions/#" >}}).
### To create a service account
@ -121,7 +90,6 @@ You can create a service account token using the Grafana UI or via the API. For
### Before you begin
- Ensure you have added the `serviceAccounts` feature toggle to Grafana. For more information about adding the feature toggle, refer to [Enable service accounts]({{< ref "#enable-service-accounts" >}}).
- Ensure you have permission to create and edit service accounts. By default, the organization administrator role is required to create and edit service accounts. For more information about user permissions, refer to [About users and permissions]({{< relref "../roles-and-permissions/#" >}}).
### To add a token to a service account
@ -135,3 +103,22 @@ You can create a service account token using the Grafana UI or via the API. For
- The expiry date specifies how long you want the key to be valid.
- If you are unsure of an expiration date, we recommend that you set the token to expire after a short time, such as a few hours or less. This limits the risk associated with a token that is valid for a long time.
1. Click **Generate service account token**.
## Assign roles to a service account in Grafana
You can assign roles to a Grafana service account to control access for the associated service account tokens.
You can assign roles to a service account using the Grafana UI or via the API. For more information about assigning a role to a service account via the API, refer to [Update service account using the HTTP API]({{< relref "../../developers/http_api/serviceaccount/#update-service-account" >}}).
In [Grafana Enterprise]({{< relref "../../enterprise/" >}}), you can also [assign RBAC roles]({{< relref "../roles-and-permissions/access-control/assign-rbac-roles" >}}) to grant very specific permissions to applications that interact with Grafana.
### Before you begin
- Ensure you have permission to update service accounts permissions. By default, the organization administrator role is required to update service accounts permissions. For more information about user permissions, refer to [About users and permissions]({{< relref "../roles-and-permissions/#" >}}).
### To assign a role to a service account
1. Sign in to Grafana, then hover your cursor over **Configuration** (the gear icon) in the sidebar.
1. Click **Service accounts**.
1. Click the service account to which you want to assign a role. As an alternative, find the service account in the list view,
1. Assign a role using the role picker.
1. Click **Update**.

@ -10,9 +10,9 @@ weight: 400
# Team management
A team is a group of users within an organization that have common dashboard and data source permission needs. For example, instead of assigning five users access to the same dashboard, you can create a team that consists of those users and assign dashboard permissions to the team. A user can belong to multiple teams.
A team is a group of users or service accounts within an organization that have common dashboard and data source permission needs. For example, instead of assigning five users access to the same dashboard, you can create a team that consists of those users and assign dashboard permissions to the team. A user or a service account can belong to multiple teams.
A user can be a Member or an Administrator for a given team. Members of a team inherit permissions from the team, but they cannot edit the team itself. Team Administrators can add members to a team and update its settings, such as the team name, team member's team roles, UI preferences, and home dashboard.
A user or a service account can be a Member or an Administrator for a given team. Members of a team inherit permissions from the team, but they cannot edit the team itself. Team Administrators can add members to a team and update its settings, such as the team name, team member's team roles, UI preferences, and home dashboard.
For more information about teams, refer to [Teams and permissions]({{< relref "../roles-and-permissions/#teams-and-permissions" >}}).
@ -39,7 +39,7 @@ A user can belong to multiple teams.
## Add a team member
Add a team member to an existing team whenever you want to provide access to team dashboards and folders to another user.
Add a team member to an existing team whenever you want to provide access to team dashboards and folders to another user or a service account.
### Before you begin
@ -51,7 +51,9 @@ Add a team member to an existing team whenever you want to provide access to tea
1. Sign in to Grafana as an organization administrator.
1. Hover your cursor over the **Configuration** (gear) icon in the side menu and click **Teams**.
1. Click the name of the team to which you want to add members, and click **Add member**.
1. In the **Add team member** field, locate and select a user.
1. In the **Add team member** field, choose if you want to add a user or a service account.
1. Locate and select a user or a service account.
1. Choose if you want to add a user or a service account as a team Member or an Admin.
1. Click **Add to team**.
![Add team member](/static/img/docs/manage-users/add-team-member-7-3.png)
@ -69,14 +71,14 @@ Complete this task when you want to add or modify team member permissions.
1. Sign in to Grafana as an organization administrator or a team administrator.
1. Hover your cursor over the **Configuration** (gear) icon in the side menu and click **Teams**.
1. Click the name of the team for which you want to add or modify team member permissions.
1. In the team member list, find and click the user account that you want to change. You can use the search field to filter the list if necessary.
1. In the team member list, find and click the user or service account that you want to change. You can use the search field to filter the list if necessary.
1. Click the **Permission** list, and then click the new user permission level.
![Change team member permissions](/static/img/docs/manage-users/change-team-permissions-7-3.png)
## Remove a team member
You can remove a team member when you no longer want to apply team permissions to the user.
You can remove a team member when you no longer want to apply team permissions to the user or service account.
### Before you begin
@ -86,8 +88,8 @@ You can remove a team member when you no longer want to apply team permissions t
1. Sign in to Grafana as an organization administrator or team administrator.
1. Hover your cursor over the **Configuration** (gear) icon in the side menu and click **Teams**.
1. Click a team from which you want to remove a user.
1. Click the **X** next to the name of the user.
1. Click a team from which you want to remove a user or a service account.
1. Click the **X** next to the name of the user or service account.
1. Click **Delete**.
## Delete a team

@ -21,7 +21,7 @@ As an example, if the current Prometheus version is `2.31.1`, we support >= `2.2
## Grafana is not an alert receiver
Grafana is not an alert receiver; is it an alert generator. This means that Grafana cannot receive alerts from anything other than its internal alert generator.
Grafana is not an alert receiver; it is an alert generator. This means that Grafana cannot receive alerts from anything other than its internal alert generator.
Receiving alerts from Prometheus (or anything else) is not supported at the time.

@ -26,6 +26,7 @@ This topic explains why labels are a fundamental component of alerting.
# Grafana reserved labels
> **Note:** Labels prefixed with `grafana_` are reserved by Grafana for special use. If a manually configured label is added beginning with `grafana_` it may be overwritten in case of collision.
> To stop the Grafana Alerting engine from adding a reserved label, you can disable it via the `disabled_labels` option in [unified_alerting.reserved_labels]({{< relref "../../../setup-grafana/configure-grafana/#unified_alertingreserved_labels" >}}) configuration.
Grafana reserved labels can be used in the same way as manually configured labels. The current list of available reserved labels are:

@ -19,11 +19,11 @@ These are the data sources that are compatible with and supported by Grafana Ale
- [AWS CloudWatch]({{< relref "../../datasources/aws-cloudwatch/" >}})
- [Azure Monitor]({{< relref "../../datasources/azuremonitor/" >}})
- [Elasticsearch]({{< relref "../../datasources/elasticsearch/" >}})
- [Google Cloud Monitoring]({{< relref "../../google-cloud-monitoring/" >}})
- [Google Cloud Monitoring]({{< relref "../../datasources/google-cloud-monitoring/" >}})
- [Graphite]({{< relref "../../datasources/graphite/" >}})
- [InfluxDB]({{< relref "influxdb/" >}})
- [Loki]({{< relref "../../datasources/loki/" >}})
- ]Microsoft SQL Server (MSSQL)]({{< relref "../../datasources/mssql/" >}})
- [Microsoft SQL Server (MSSQL)]({{< relref "../../datasources/mssql/" >}})
- [MySQL]({{< relref "../../datasources/mysql/" >}})
- [Open TSDB]({{< relref "../../datasources/opentsdb/" >}})
- [PostgreSQL]({{< relref "../../datasources/postgres/" >}})

@ -3,6 +3,7 @@ aliases:
- /docs/grafana/latest/alerting/migrating-alerts/
- /docs/grafana/latest/alerting/unified-alerting/
- /docs/grafana/latest/alerting/unified-alerting/difference-old-new/
- /docs/grafana/latest/alerting/difference-old-new/
description: Upgrade Grafana alerts
title: Upgrade to Grafana Alerting
weight: 101

@ -14,8 +14,8 @@ weight: 451
# Create a URL to link to a silence form
When linking to a silence form, provide the default matching labels and comment via `matchers` and `comment` query parameters. The `matchers` parameter requires one more matching labels of the type `[label][operator][value]` joined by a comma while the `operator` parameter can be one of the following: `=` (equals, not regex), `!=` (not equals, not regex), `=~` (equals, regex), `!~` (not equals, regex).
For example, to link to silence form with matching labels `severity=critical` & `cluster!~europe-.*` and comment `Silence critical EU alerts`, create a URL `https://mygrafana/alerting/silence/new?matchers=severity%3Dcritical%2Ccluster!~europe-*&comment=Silence%20critical%20EU%20alert`.
When linking to a silence form, provide the default matching labels and comment via `matcher` and `comment` query parameters. The `matcher` parameter should be in the following format `[label][operator][value]` where the `operator` parameter can be one of the following: `=` (equals, not regex), `!=` (not equals, not regex), `=~` (equals, regex), `!~` (not equals, regex).
The URL can contain many query parameters with the key `matcher`.
For example, to link to silence form with matching labels `severity=critical` & `cluster!~europe-.*` and comment `Silence critical EU alerts`, create a URL `https://mygrafana/alerting/silence/new?matcher=severity%3Dcritical&matcher=cluster!~europe-*&comment=Silence%20critical%20EU%20alert`.
To link to a new silence page for an [external Alertmanager]({{< relref "../../datasources/alertmanager/" >}}), add a `alertmanager` query parameter with the Alertmanager data source name.

@ -31,9 +31,9 @@ What is your dashboard maturity level? Analyze your current dashboard setup and
- If you create a temporary dashboard, perhaps to test something, prefix the name with `TEST: `. Delete the dashboard when you are finished.
- Copying dashboards with no significant changes is not a good idea.
- You miss out on updates to the original dashboard, such as documentation changes, bug fixes, or additions to metrics.
- In many cases copies are being made to simply customize the view by setting template parameters. This should instead be done by maintaining a link to the master dashboard and customizing the view with [URL parameters]({{< relref "../linking/data-link-variables/" >}}).
- In many cases copies are being made to simply customize the view by setting template parameters. This should instead be done by maintaining a link to the master dashboard and customizing the view with [URL parameters]({{< relref "../panels/configure-data-links/#data-link-variables" >}}).
- When you must copy a dashboard, clearly rename it and _do not_ copy the dashboard tags. Tags are important metadata for dashboards that are used during search. Copying tags can result in false matches.
- Maintain a dashboard of dashboards or cross-reference dashboards. This can be done in several ways:
- Create dashboard links, panel, or data links. Links can go to other dashboards or to external systems. For more information, refer to [Linking]({{< relref "../linking/" >}}).
- Create dashboard links, panel, or data links. Links can go to other dashboards or to external systems. For more information, refer to [Manage dashboard links]({{< relref "../dashboards/manage-dashboard-links/" >}}).
- Add a [Dashboard list panel]({{< relref "../visualizations/dashboard-list-panel/" >}}). You can then customize what you see by doing tag or folder searches.
- Add a [Text panel]({{< relref "../visualizations/text-panel/" >}}) and use markdown to customize the display.

@ -53,7 +53,7 @@ How can you tell you are here?
- Directed browsing cuts down on "guessing."
- Template variables make it harder to “just browse” randomly or aimlessly.
- Most dashboards should be linked to by alerts.
- Browsing is directed with links. For more information, refer to [Linking]({{< relref "../linking/" >}}).
- Browsing is directed with links. For more information, refer to [Manage dashboard links]({{< relref "../dashboards/manage-dashboard-links/" >}}).
- Version-controlled dashboard JSON.
## High - optimized use

@ -1,20 +1,53 @@
---
aliases:
- /docs/grafana/latest/linking/
- /docs/grafana/latest/features/navigation-links/
- /docs/grafana/latest/linking/linking-overview/
- /docs/grafana/latest/linking/dashboard-links/
description: ''
- /docs/grafana/latest/dashboards/manage-dashboard-links/
- /docs/grafana/latest/panels/working-with-panels/add-link-to-panel/
description: How to link Grafana dashboards.
keywords:
- link
- dashboard
- grafana
- linking
- create links
- link dashboards
- navigate
title: Dashboard links
weight: 200
title: Manage dashboard links
menuTitle: Manage dashboard links
weight: 400
---
# Dashboard links
# Manage dasboard links
When you create a dashboard link, you can include the time range and current template variables to directly jump to the same context in another dashboard. This way, you don’t have to worry whether the person you send the link to is looking at the right data. For other types of links, refer to [Data link variables]({{< relref "data-link-variables/" >}}).
You can use links to navigate between commonly-used dashboards or to connect others to your visualizations. Links let you create shortcuts to other dashboards, panels, and even external websites.
Grafana supports dashboard links, panel links, and data links. Dashboard links are displayed at the top of the dashboard. Panel links are accessible by clicking an icon on the top left corner of the panel.
## Which link should you use?
Start by figuring out how you're currently navigating between dashboards. If you're often jumping between a set of dashboards and struggling to find the same context in each, links can help optimize your workflow.
The next step is to figure out which link type is right for your workflow. Even though all the link types in Grafana are used to create shortcuts to other dashboards or external websites, they work in different contexts.
- If the link relates to most if not all of the panels in the dashboard, use [dashboard links]({{< relref "#dashboard-links" >}}).
- If you want to drill down into specific panels, use [panel links]({{< relref "#panel-links" >}}).
- If you want to link to an external site, you can use either a dashboard link or a panel link.
- If you want to drill down into a specific series, or even a single measurement, use [data links]({{< relref "../../panels/configure-data-links/#data-links" >}}).
## Controlling time range using the URL
You can control the time range of a panel or dashboard by providing following query parameters in dashboard URL:
- `from` - defines lower limit of the time range, specified in ms epoch
- `to` - defines upper limit of the time range, specified in ms epoch
- `time` and `time.window` - defines a time range from `time-time.window/2` to `time+time.window/2`. Both params should be specified in ms. For example `?time=1500000000000&time.window=10000` will result in 10s time range from 1499999995000 to 1500000005000
## Dashboard links
When you create a dashboard link, you can include the time range and current template variables to directly jump to the same context in another dashboard. This way, you don’t have to worry whether the person you send the link to is looking at the right data. For other types of links, refer to [Data link variables]({{< relref "../../panels/configure-data-links/#data-link-variables/" >}}).
Dashboard links can also be used as shortcuts to external systems, such as submitting [a GitHub issue with the current dashboard name](https://github.com/grafana/grafana/issues/new?title=Dashboard%3A%20HTTP%20Requests).
@ -25,7 +58,7 @@ To see an example of dashboard links in action, check out:
Once you've added a dashboard link, it appears in the upper right corner of your dashboard.
## Add links to dashboards
### Add links to dashboards
Add links to other dashboards at the top of your current dashboard.
@ -40,7 +73,7 @@ Add links to other dashboards at the top of your current dashboard.
- **Open in new tab** – Select this option if you want the dashboard link to open in a new tab or window.
1. Click **Add**.
## Add a URL link to a dashboard
### Add a URL link to a dashboard
Add a link to a URL at the top of your current dashboard. You can link to any available URL, including dashboards, panels, or external sites. You can even control the time range to ensure the user is zoomed in on the right data in Grafana.
@ -60,7 +93,7 @@ Add a link to a URL at the top of your current dashboard. You can link to any av
- **Open in new tab** – Select this option if you want the dashboard link to open in a new tab or window.
1. Click **Add**.
## Update a dashboard link
### Update a dashboard link
To change or update an existing dashboard link, follow this procedure.
@ -71,6 +104,43 @@ To change or update an existing dashboard link, follow this procedure.
To duplicate an existing dashboard link, click the duplicate icon next to the existing link that you want to duplicate.
## Delete a dashboard link
### Delete a dashboard link
To delete an existing dashboard link, click the trash icon next to the duplicate icon that you want to delete.
## Panel links
Each panel can have its own set of links that are shown in the upper left corner of the panel. You can link to any available URL, including dashboards, panels, or external sites. You can even control the time range to ensure the user is zoomed in on the right data in Grafana.
Click the icon on the top left corner of a panel to see available panel links.
{{< figure src="/static/img/docs/linking/panel-links.png" width="200px" >}}
### Add a panel link
1. Hover your cursor over the panel that you want to add a link to and then press `e`. Or click the dropdown arrow next to the panel title and then click **Edit**.
1. On the Panel tab, scroll down to the Links section.
1. Expand Links and then click **Add link**.
1. Enter a **Title**. **Title** is a human-readable label for the link that will be displayed in the UI.
1. Enter the **URL** you want to link to.
You can even add one of the template variables defined in the dashboard. Press Ctrl+Space or Cmd+Space and click in the **URL** field to see the available variables. By adding template variables to your panel link, the link sends the user to the right context, with the relevant variables already set. You can also use time variables:
- `from` - Defines the lower limit of the time range, specified in ms epoch.
- `to` - Defines the upper limit of the time range, specified in ms epoch.
- `time` and `time.window` - Define a time range from `time-time.window/2` to `time+time.window/2`. Both params should be specified in ms. For example `?time=1500000000000&time.window=10000` will result in 10s time range from 1499999995000 to 1500000005000.
1. If you want the link to open in a new tab, then select **Open in a new tab**.
1. Click **Save** to save changes and close the window.
1. Click **Save** in the upper right to save your changes to the dashboard.
### Update a panel link
1. On the Panel tab, find the link that you want to make changes to.
1. Click the Edit (pencil) icon to open the Edit link window.
1. Make any necessary changes.
1. Click **Save** to save changes and close the window.
1. Click **Save** in the upper right to save your changes to the dashboard.
### Delete a panel link
1. On the Panel tab, find the link that you want to delete.
1. Click the **X** icon next to the link you want to delete.
1. Click **Save** in the upper right to save your changes to the dashboard.

@ -62,3 +62,67 @@ Perf
| summarize avg(CounterValue) by bin(TimeGenerated, $__interval), Computer
| order by TimeGenerated asc
```
## Limitations
As of Grafana 9.0, a resource URI is constructed to identify resources using the resource picker. On dashboards created prior to Grafana 9.0, Grafana automatically migrates any queries using the prior resource-picking mechanism to use this method.
Some resource types use nested namespaces and resource names, such as `Microsoft.Storage/storageAccounts/tableServices` and `storageAccount/default`, or `Microsoft.Sql/servers/databases` and `serverName/databaseName`. Such template variables cannot be used because the result could be a malformed resource URI.
### Supported cases
#### Standard namespaces and resource names
```kusto
metricDefinition = $ns
$ns = Microsoft.Compute/virtualMachines
resourceName = $rs
$rs = testvirtualmachine
```
#### Namespaces with a non-templated sub-namespace
```kusto
metricDefinition = $ns/tableServices
$ns = Microsoft.Storage/storageAccounts
resourceName = $rs/default
$rs = storageaccount
```
#### Storage namespaces missing the `default` keyword
```kusto
metricDefinition = $ns/tableServices
$ns = Microsoft.Storage/storageAccounts
resourceName = $rs
$rs = storageaccount
```
#### Namespaces with a templated sub-namespace
```kusto
metricDefinition = $ns/$sns
$ns = Microsoft.Storage/storageAccounts
$sns = tableServices
resourceName = $rs
$rs = storageaccount
```
### Unsupported case
If a dashboard uses this unsupported case, migrate it to one of the [supported cases](#supported-cases).
If a namespace or resource name template variable contains multiple segments, Grafana will construct the resource URI incorrectly because the template variable cannot be appropriately split.
For example:
```kusto
metricDefinition = $ns
resourceName = $rs
$ns = 'Microsoft.Storage/storageAccounts/tableServices'
$rs = 'storageaccount/default'
```
This would result in an incorrect resource URI containing `Microsoft.Storage/storageAccounts/tableServices/storageaccount/default`. However, the correct URI would have the format `Microsoft.Storage/storageAccounts/storageaccount/tableServices/default`.
An appropriate fix would be to update the template variable that does not match a supported case. If the namespace variable `$ns` is of the form `Microsoft.Storage/storageAccounts/tableServices` this could be split into two variables: `$ns1 = Microsoft.Storage/storageAccounts` and `$ns2 = tableServices`. The metric definition would then take the form `$ns1/$ns2` which leads to a correctly formatted URI.

@ -462,7 +462,7 @@ datasources:
timescaledb: false
```
> **Note:** In the above code, the `postgresVersion` value of `10` refers to version PotgreSQL 10 and above.
> **Note:** In the above code, the `postgresVersion` value of `10` refers to version PostgreSQL 10 and above.
If you encounter metric request errors or other issues:

@ -747,6 +747,280 @@ Content-Type: application/json; charset=UTF-8
| 404 | Role not found. |
| 500 | Unexpected error. Refer to body and/or server logs for more details. |
## Create and remove service account role assignments
### List roles assigned to a service account
`GET /api/access-control/users/:serviceAccountId/roles`
Lists the roles that have been directly assigned to a given service account. The list does not include basic roles (Viewer, Editor, Admin or Grafana Admin), and it does not include roles that have been inherited from a team.
Query Parameters:
- `includeHidden`: Optional. Set to `true` to include roles that are `hidden`.
#### Required permissions
| Action | Scope |
| ---------------- | ------------------------------- |
| users.roles:read | users:id:`<service account ID>` |
#### Example request
```http
GET /api/access-control/users/1/roles
Accept: application/json
```
#### Example response
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
[
{
"version": 4,
"uid": "6dNwJq57z",
"name": "fixed:reports:writer",
"displayName": "Report writer",
"description": "Create, read, update, or delete all reports and shared report settings.",
"group": "Reports",
"updated": "2021-11-19T10:48:00+01:00",
"created": "2021-11-19T10:48:00+01:00",
"global": false
}
]
```
#### Status codes
| Code | Description |
| ---- | -------------------------------------------------------------------- |
| 200 | Set of assigned roles is returned. |
| 403 | Access denied. |
| 500 | Unexpected error. Refer to body and/or server logs for more details. |
### List permissions assigned to a service account
`GET /api/access-control/users/:serviceAccountId/permissions`
Lists the permissions that a given service account has.
#### Required permissions
| Action | Scope |
| ---------------------- | ------------------------------- |
| users.permissions:read | users:id:`<service account ID>` |
#### Example request
```http
GET /api/access-control/users/1/permissions
Accept: application/json
```
#### Example response
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
[
{
"action": "ldap.status:read",
"scope": ""
},
{
"action": "ldap.user:read",
"scope": ""
}
]
```
#### Status codes
| Code | Description |
| ---- | -------------------------------------------------------------------- |
| 200 | Set of assigned permissions is returned. |
| 403 | Access denied. |
| 500 | Unexpected error. Refer to body and/or server logs for more details. |
### Add a service account role assignment
`POST /api/access-control/users/:serviceAccountId/roles`
Assign a role to a specific service account.
For bulk updates consider
[Set service account role assignments]({{< ref "#set-service-account-role-assignments" >}}).
#### Required permissions
`permissions:type:delegate` scope ensures that users can only assign roles which have same, or a subset of permissions which the user has.
For example, if a user does not have required permissions for creating users, they won't be able to assign a role which will allow to do that. This is done to prevent escalation of privileges.
| Action | Scope |
| --------------- | ------------------------- |
| users.roles:add | permissions:type:delegate |
#### Example request
```http
POST /api/access-control/users/1/roles
Accept: application/json
Content-Type: application/json
{
"global": false,
"roleUid": "XvHQJq57z"
}
```
#### JSON body schema
| Field Name | Data Type | Required | Description |
| ---------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| roleUid | string | Yes | UID of the role. |
| global | boolean | No | A flag indicating if the assignment is global or not. If set to `false`, the default org ID of the authenticated user will be used from the request to create organization local assignment. |
#### Example response
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
{
"message": "Role added to the user."
}
```
#### Status codes
| Code | Description |
| ---- | -------------------------------------------------------------------- |
| 200 | Role is assigned to a user. |
| 403 | Access denied. |
| 404 | Role not found. |
| 500 | Unexpected error. Refer to body and/or server logs for more details. |
## Remove a service account role assignment
`DELETE /api/access-control/users/:serviceAccountId/roles/:roleUID`
Revoke a role from a service account.
For bulk updates consider
[Set service account role assignments]({{< ref "#set-service-account-role-assignments" >}}).
#### Required permissions
`permissions:type:delegate` scope ensures that users can only unassign roles which have same, or a subset of permissions which the user has.
For example, if a user does not have required permissions for creating users, they won't be able to unassign a role which will allow to do that. This is done to prevent escalation of privileges.
| Action | Scope |
| ------------------ | ------------------------- |
| users.roles:remove | permissions:type:delegate |
#### Query parameters
| Param | Type | Required | Description |
| ------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| global | boolean | No | A flag indicating if the assignment is global or not. If set to `false`, the default org ID of the authenticated user will be used from the request to remove assignment. |
#### Example request
```http
DELETE /api/access-control/users/1/roles/AFUXBHKnk
Accept: application/json
```
#### Example response
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
{
"message": "Role removed from user."
}
```
#### Status codes
| Code | Description |
| ---- | -------------------------------------------------------------------- |
| 200 | Role is unassigned. |
| 403 | Access denied. |
| 500 | Unexpected error. Refer to body and/or server logs for more details. |
### Set service account role assignments
`PUT /api/access-control/users/:serviceAccountId/roles`
Update the service accounts's role assignments to match the provided set of UIDs.
This will remove any assigned roles that aren't in the request and add
roles that are in the set but are not already assigned to the service account.
If you want to add or remove a single role, consider using
[Add a service account role assignment]({{< ref "#add-a-service-account-role-assignment" >}}) or
[Remove a service account role assignment]({{< ref "#remove-a-service-account-role-assignment" >}})
instead.
#### Required permissions
`permissions:type:delegate` scope ensures that users can only assign or unassign roles which have same, or a subset of permissions which the user has.
For example, if a user does not have required permissions for creating users, they won't be able to assign or unassign a role which will allow to do that. This is done to prevent escalation of privileges.
| Action | Scope |
| ------------------ | ------------------------- |
| users.roles:add | permissions:type:delegate |
| users.roles:remove | permissions:type:delegate |
#### Example request
```http
PUT /api/access-control/users/1/roles
Accept: application/json
Content-Type: application/json
{
"global": false,
"roleUids": [
"ZiHQJq5nk",
"GzNQ1357k"
]
}
```
#### JSON body schema
| Field Name | Date Type | Required | Description |
| ------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| global | boolean | No | A flag indicating if the assignment is global or not. If set to `false`, the default org ID of the authenticated user will be used from the request. |
| roleUids | list | Yes | List of role UIDs. |
| includeHidden | boolean | No | Specify whether the hidden role assignments should be updated. |
#### Example response
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
{
"message": "User roles have been updated."
}
```
#### Status codes
| Code | Description |
| ---- | -------------------------------------------------------------------- |
| 200 | Roles have been assigned. |
| 403 | Access denied. |
| 404 | Role not found. |
| 500 | Unexpected error. Refer to body and/or server logs for more details. |
## Create and remove team role assignments
### List roles assigned to a team

@ -24,9 +24,9 @@ title: 'Service account HTTP API'
See note in the [introduction]({{< ref "#service-account-api" >}}) for an explanation.
| Action | Scope |
| -------------------- | ------------------------- |
| serviceaccounts:read | global:serviceaccounts:\* |
| Action | Scope |
| -------------------- | ----- |
| serviceaccounts:read | n/a |
**Example Request**:
@ -91,9 +91,9 @@ Content-Type: application/json
See note in the [introduction]({{< ref "#service-account-api" >}}) for an explanation.
| Action | Scope |
| --------------------- | ------------------ |
| serviceaccounts:write | serviceaccounts:\* |
| Action | Scope |
| ---------------------- | ----- |
| serviceaccounts:create | n/a |
**Example Request**:

@ -1,31 +0,0 @@
---
aliases:
- /docs/grafana/latest/linking/
title: Linking
weight: 120
---
# Linking overview
You can use links to navigate between commonly-used dashboards or to connect others to your visualizations. Links let you create shortcuts to other dashboards, panels, and even external websites.
Grafana supports dashboard links, panel links, and data links. Dashboard links are displayed at the top of the dashboard. Panel links are accessible by clicking an icon on the top left corner of the panel.
## Which link should you use?
Start by figuring out how you're currently navigating between dashboards. If you're often jumping between a set of dashboards and struggling to find the same context in each, links can help optimize your workflow.
The next step is to figure out which link type is right for your workflow. Even though all the link types in Grafana are used to create shortcuts to other dashboards or external websites, they work in different contexts.
- If the link relates to most if not all of the panels in the dashboard, use [dashboard links]({{< relref "dashboard-links/" >}}).
- If you want to drill down into specific panels, use [panel links]({{< relref "panel-links/" >}}).
- If you want to link to an external site, you can use either a dashboard link or a panel link.
- If you want to drill down into a specific series, or even a single measurement, use [data links]({{< relref "data-links/" >}}).
## Controlling time range using the URL
You can control the time range of a panel or dashboard by providing following query parameters in dashboard URL:
- `from` - defines lower limit of the time range, specified in ms epoch
- `to` - defines upper limit of the time range, specified in ms epoch
- `time` and `time.window` - defines a time range from `time-time.window/2` to `time+time.window/2`. Both params should be specified in ms. For example `?time=1500000000000&time.window=10000` will result in 10s time range from 1499999995000 to 1500000005000

@ -1,62 +0,0 @@
---
aliases:
- /docs/grafana/latest/linking/data-link-variables/
- /docs/grafana/latest/variables/url-variables/
- /docs/grafana/latest/variables/variable-types/url-variables/
keywords:
- grafana
- url variables
- documentation
- variables
- data link
title: URL variables
weight: 400
---
# Data link variables
You can use variables in data links to refer to series fields, labels, and values. For more information about data links, refer to [Data links]({{< relref "data-links/" >}}).
To see a list of available variables, type `$` in the data link **URL** field to see a list of variables that you can use.
> **Note:** These variables changed in 6.4 so if you have an older version of Grafana, then use the version picker to select docs for an older version of Grafana.
You can also use template variables in your data links URLs, refer to [Templates and variables]({{< relref "../variables/" >}}) for more information on template variables.
## Time range panel variables
These variables allow you to include the current time range in the data link URL.
- `__url_time_range` - current dashboard's time range (i.e. `?from=now-6h&to=now`)
- `$__from and $__to` - For more information, refer to [Global variables]({{< relref "../variables/variable-types/global-variables/#__from-and-__to" >}}).
## Series variables
Series specific variables are available under `__series` namespace:
- `__series.name` - series name to the URL
## Field variables
Field-specific variables are available under `__field` namespace:
- `__field.name` - the name of the field
- `__field.labels.<LABEL>` - label's value to the URL. If your label contains dots, then use `__field.labels["<LABEL>"]` syntax.
## Value variables
Value-specific variables are available under `__value` namespace:
- `__value.time` - value's timestamp (Unix ms epoch) to the URL (i.e. `?time=1560268814105`)
- `__value.raw` - raw value
- `__value.numeric` - numeric representation of a value
- `__value.text` - text representation of a value
- `__value.calc` - calculation name if the value is result of calculation
## Template variables
When linking to another dashboard that uses template variables, select variable values for whoever clicks the link.
`${myvar:queryparam}` - where `myvar` is a name of the template variable that matches one in the current dashboard that you want to use.
If you want to add all of the current dashboard's variables to the URL, then use `__all_variables`.

@ -1,54 +0,0 @@
---
aliases:
- /docs/grafana/latest/linking/data-links/
- /docs/grafana/latest/reference/datalinks/
keywords:
- grafana
- data links
- documentation
- playlist
title: Data links
---
# Data links
Data links allow you to provide more granular context to your links. You can create links that include the series name or even the value under the cursor. For example, if your visualization showed four servers, you could add a data link to one or two of them.
The link itself is accessible in different ways depending on the visualization. For the Graph you need to click on a data point or line, for a panel like
Stat, Gauge, or Bar Gauge you can click anywhere on the visualization to open the context menu.
You can use variables in data links to send people to a detailed dashboard with preserved data filters. For example, you could use variables to specify a time range, series, and variable selection. For more information, refer to [Data link variables]({{< relref "data-link-variables/" >}}).
## Typeahead suggestions
When creating or updating a data link, press Cmd+Space or Ctrl+Space on your keyboard to open the typeahead suggestions to more easily add variables to your URL.
{{< figure src="/static/img/docs/data_link_typeahead.png" max-width= "800px" >}}
## Add a data link
1. Hover your cursor over the panel that you want to add a link to and then press `e`. Or click the dropdown arrow next to the panel title and then click **Edit**.
1. On the Field tab, scroll down to the Data links section.
1. Expand Data links and then click **Add link**.
1. Enter a **Title**. **Title** is a human-readable label for the link that will be displayed in the UI.
1. Enter the **URL** you want to link to.
You can even add one of the template variables defined in the dashboard. Click in the **URL** field and then type `$` or press Ctrl+Space or Cmd+Space to see a list of available variables. By adding template variables to your panel link, the link sends the user to the right context, with the relevant variables already set. For more information, refer to [Data link variables]({{< relref "data-link-variables/" >}}).
1. If you want the link to open in a new tab, then select **Open in a new tab**.
1. Click **Save** to save changes and close the window.
1. Click **Save** in the upper right to save your changes to the dashboard.
## Update a data link
1. On the Field tab, find the link that you want to make changes to.
1. Click the Edit (pencil) icon to open the Edit link window.
1. Make any necessary changes.
1. Click **Save** to save changes and close the window.
1. Click **Save** in the upper right to save your changes to the dashboard.
## Delete a data link
1. On the Field tab, find the link that you want to delete.
1. Click the **X** icon next to the link you want to delete.
1. Click **Save** in the upper right to save your changes to the dashboard.

@ -1,39 +0,0 @@
---
aliases:
- /docs/grafana/latest/features/navigation-links/
- /docs/grafana/latest/linking/linking-overview/
keywords:
- grafana
- linking
- create links
- link panels
- link dashboards
- navigate
title: Linking overview
weight: 100
---
# Linking overview
You can use links to navigate between commonly-used dashboards or to connect others to your visualizations. Links let you create shortcuts to other dashboards, panels, and even external websites.
Grafana supports dashboard links, panel links, and data links. Dashboard links are displayed at the top of the dashboard. Panel links are accessible by clicking an icon on the top left corner of the panel.
## Which link should you use?
Start by figuring out how you're currently navigating between dashboards. If you're often jumping between a set of dashboards and struggling to find the same context in each, links can help optimize your workflow.
The next step is to figure out which link type is right for your workflow. Even though all the link types in Grafana are used to create shortcuts to other dashboards or external websites, they work in different contexts.
- If the link relates to most if not all of the panels in the dashboard, use [dashboard links]({{< relref "dashboard-links/" >}}).
- If you want to drill down into specific panels, use [panel links]({{< relref "panel-links/" >}}).
- If you want to link to an external site, you can use either a dashboard link or a panel link.
- If you want to drill down into a specific series, or even a single measurement, use [data links]({{< relref "data-links/" >}}).
## Controlling time range using the URL
You can control the time range of a panel or dashboard by providing following query parameters in dashboard URL:
- `from` - defines lower limit of the time range, specified in ms epoch
- `to` - defines upper limit of the time range, specified in ms epoch
- `time` and `time.window` - defines a time range from `time-time.window/2` to `time+time.window/2`. Both params should be specified in ms. For example `?time=1500000000000&time.window=10000` will result in 10s time range from 1499999995000 to 1500000005000

@ -1,52 +0,0 @@
---
aliases:
- /docs/grafana/latest/features/navigation-links/
- /docs/grafana/latest/linking/panel-links/
description: ''
keywords:
- grafana
- linking
- create links
- link panels
- link dashboards
- navigate
title: Panel links
weight: 300
---
# Panel links
{{< docs/shared "panels/panel-links-intro.md" >}}
Click the icon on the top left corner of a panel to see available panel links.
<img class="no-shadow" src="/static/img/docs/linking/panel-links.png" width="500px">
## Add a panel link
1. Hover your cursor over the panel that you want to add a link to and then press `e`. Or click the dropdown arrow next to the panel title and then click **Edit**.
1. On the Panel tab, scroll down to the Links section.
1. Expand Links and then click **Add link**.
1. Enter a **Title**. **Title** is a human-readable label for the link that will be displayed in the UI.
1. Enter the **URL** you want to link to.
You can even add one of the template variables defined in the dashboard. Press Ctrl+Space or Cmd+Space and click in the **URL** field to see the available variables. By adding template variables to your panel link, the link sends the user to the right context, with the relevant variables already set. You can also use time variables:
- `from` - Defines the lower limit of the time range, specified in ms epoch.
- `to` - Defines the upper limit of the time range, specified in ms epoch.
- `time` and `time.window` - Define a time range from `time-time.window/2` to `time+time.window/2`. Both params should be specified in ms. For example `?time=1500000000000&time.window=10000` will result in 10s time range from 1499999995000 to 1500000005000.
1. If you want the link to open in a new tab, then select **Open in a new tab**.
1. Click **Save** to save changes and close the window.
1. Click **Save** in the upper right to save your changes to the dashboard.
## Update a panel link
1. On the Panel tab, find the link that you want to make changes to.
1. Click the Edit (pencil) icon to open the Edit link window.
1. Make any necessary changes.
1. Click **Save** to save changes and close the window.
1. Click **Save** in the upper right to save your changes to the dashboard.
## Delete a panel link
1. On the Panel tab, find the link that you want to delete.
1. Click the **X** icon next to the link you want to delete.
1. Click **Save** in the upper right to save your changes to the dashboard.

@ -0,0 +1,114 @@
---
aliases:
- /docs/grafana/latest/linking/data-link-variables/
- /docs/grafana/latest/variables/url-variables/
- /docs/grafana/latest/variables/variable-types/url-variables/
- /docs/grafana/latest/linking/data-links/
- /docs/grafana/latest/reference/datalinks/
- /docs/grafana/latest/panels/configure-data-links/
keywords:
- grafana
- url variables
- variables
- data link
- documentation
- playlist
title: Configure data links
menuTitle: Configure data links
weight: 400
---
# Configure data links
You can use data link variables or data links to create links between panels.
## Data link variables
You can use variables in data links to refer to series fields, labels, and values. For more information about data links, refer to [Data links]({{< relref "#data-links" >}}).
To see a list of available variables, type `$` in the data link **URL** field to see a list of variables that you can use.
> **Note:** These variables changed in 6.4 so if you have an older version of Grafana, then use the version picker to select docs for an older version of Grafana.
You can also use template variables in your data links URLs, refer to [Templates and variables]({{< relref "../../variables/" >}}) for more information on template variables.
## Time range panel variables
These variables allow you to include the current time range in the data link URL.
- `__url_time_range` - current dashboard's time range (i.e. `?from=now-6h&to=now`)
- `$__from and $__to` - For more information, refer to [Global variables]({{< relref "../../variables/variable-types/global-variables/#__from-and-__to" >}}).
## Series variables
Series specific variables are available under `__series` namespace:
- `__series.name` - series name to the URL
## Field variables
Field-specific variables are available under `__field` namespace:
- `__field.name` - the name of the field
- `__field.labels.<LABEL>` - label's value to the URL. If your label contains dots, then use `__field.labels["<LABEL>"]` syntax.
## Value variables
Value-specific variables are available under `__value` namespace:
- `__value.time` - value's timestamp (Unix ms epoch) to the URL (i.e. `?time=1560268814105`)
- `__value.raw` - raw value
- `__value.numeric` - numeric representation of a value
- `__value.text` - text representation of a value
- `__value.calc` - calculation name if the value is result of calculation
## Template variables
When linking to another dashboard that uses template variables, select variable values for whoever clicks the link.
`${myvar:queryparam}` - where `myvar` is a name of the template variable that matches one in the current dashboard that you want to use.
If you want to add all of the current dashboard's variables to the URL, then use `__all_variables`.
## Data links
Data links allow you to provide more granular context to your links. You can create links that include the series name or even the value under the cursor. For example, if your visualization showed four servers, you could add a data link to one or two of them.
The link itself is accessible in different ways depending on the visualization. For the Graph you need to click on a data point or line, for a panel like
Stat, Gauge, or Bar Gauge you can click anywhere on the visualization to open the context menu.
You can use variables in data links to send people to a detailed dashboard with preserved data filters. For example, you could use variables to specify a time range, series, and variable selection. For more information, refer to [Data link variables]({{< relref "#data-link-variables" >}}).
### Typeahead suggestions
When creating or updating a data link, press Cmd+Space or Ctrl+Space on your keyboard to open the typeahead suggestions to more easily add variables to your URL.
{{< figure src="/static/img/docs/data_link_typeahead.png" max-width= "800px" >}}
### Add a data link
1. Hover your cursor over the panel that you want to add a link to and then press `e`. Or click the dropdown arrow next to the panel title and then click **Edit**.
1. On the Field tab, scroll down to the Data links section.
1. Expand Data links and then click **Add link**.
1. Enter a **Title**. **Title** is a human-readable label for the link that will be displayed in the UI.
1. Enter the **URL** you want to link to.
You can even add one of the template variables defined in the dashboard. Click in the **URL** field and then type `$` or press Ctrl+Space or Cmd+Space to see a list of available variables. By adding template variables to your panel link, the link sends the user to the right context, with the relevant variables already set. For more information, refer to [Data link variables]({{< relref "#data-link-variables" >}}).
1. If you want the link to open in a new tab, then select **Open in a new tab**.
1. Click **Save** to save changes and close the window.
1. Click **Save** in the upper right to save your changes to the dashboard.
### Update a data link
1. On the Field tab, find the link that you want to make changes to.
1. Click the Edit (pencil) icon to open the Edit link window.
1. Make any necessary changes.
1. Click **Save** to save changes and close the window.
1. Click **Save** in the upper right to save your changes to the dashboard.
### Delete a data link
1. On the Field tab, find the link that you want to delete.
1. Click the **X** icon next to the link you want to delete.
1. Click **Save** in the upper right to save your changes to the dashboard.

@ -27,7 +27,7 @@ To change this setting, type a number in the field and then click outside the fi
Lets you control the URL to which a value or visualization link.
For more information and instructions, refer to [Data links]({{< relref "../linking/data-links/" >}}).
For more information and instructions, refer to [Data links]({{< relref "./configure-data-links/#data-links" >}}).
## Display name

@ -1,13 +0,0 @@
---
aliases:
- /docs/grafana/latest/panels/working-with-panels/add-link-to-panel/
- /docs/sources/panels/working-with-panels/add-link-to-panel/
title: Add a link to a panel
weight: 60
---
# Add a link to a panel
{{< docs/shared "panels/panel-links-intro.md" >}}
For more information, refer to [Panel links]({{< relref "../../linking/panel-links/" >}}).

@ -1305,6 +1305,18 @@ Uploads screenshots to the local Grafana server or remote storage such as Azure,
<hr>
## [unified_alerting.reserved_labels]
For more information about Grafana Reserved Labels, refer to [Labels in Grafana Alerting]({{< relref "../../alerting/fundamentals/annotation-label/how-to-use-labels/#grafana-reserved-labels" >}}).
### disabled_labels
Comma-separated list of reserved labels added by the Grafana Alerting engine that should be disabled.
For example: `disabled_labels=grafana_folder`
<hr>
## [alerting]
For more information about the legacy dashboard alerting feature in Grafana, refer to [Alerts overview]({{< relref "../../alerting/" >}}).

@ -194,6 +194,27 @@ org_role = "Viewer"
| `org_id` | No | The Grafana organization database id. Setting this allows for multiple group_dn's to be assigned to the same `org_role` provided the `org_id` differs | `1` (default org id) |
| `grafana_admin` | No | When `true` makes user of `group_dn` Grafana server admin. A Grafana server admin has admin access over all organizations and users. Available in Grafana v5.3 and above | `false` |
Note: Commenting out a group mapping requires also commenting out the header of
said group or it will fail validation as an empty mapping. Example:
```bash
[[servers]]
# other settings omitted for clarity
[[servers.group_mappings]]
group_dn = "cn=superadmins,dc=grafana,dc=org"
org_role = "Admin"
grafana_admin = true # Available in Grafana v5.3 and above
# [[servers.group_mappings]]
# group_dn = "cn=admins,dc=grafana,dc=org"
# org_role = "Admin"
[[servers.group_mappings]]
group_dn = "cn=users,dc=grafana,dc=org"
org_role = "Editor"
```
### Nested/recursive group membership
Users with nested/recursive group membership must have an LDAP server that supports `LDAP_MATCHING_RULE_IN_CHAIN`

@ -1,48 +0,0 @@
---
aliases:
- /docs/grafana/latest/features/panels/table_panel/
- /docs/grafana/latest/panels/visualizations/table/filter-table-columns/
- /docs/grafana/latest/reference/table/
- /docs/grafana/latest/visualizations/table/filter-table-columns/
- /docs/grafana/next/panels/visualizations/table/table-field-options/
keywords:
- grafana
- table options
- documentation
- format tables
- table filter
- filter columns
title: Filter table columns
weight: 600
---
# Filter table columns
If you turn on the **Column filter**, then you can filter table options.
## Turn on column filtering
1. In Grafana, navigate to the dashboard with the table with the columns that you want to filter.
1. On the table panel you want to filter, open the panel editor.
1. Click the **Field** tab.
1. In Table options, turn on the **Column filter** option.
A filter icon appears next to each column title.
{{< figure src="/static/img/docs/tables/column-filter-with-icon.png" max-width="500px" caption="Column filtering turned on" class="docs-image--no-shadow" >}}
## Filter column values
To filter column values, click the filter (funnel) icon next to a column title. Grafana displays the filter options for that column.
{{< figure src="/static/img/docs/tables/filter-column-values.png" max-width="500px" caption="Filter column values" class="docs-image--no-shadow" >}}
Click the check box next to the values that you want to display. Enter text in the search field at the top to show those values in the display so that you can select them rather than scroll to find them.
## Clear column filters
Columns with filters applied have a blue funnel displayed next to the title.
{{< figure src="/static/img/docs/tables/filtered-column.png" max-width="500px" caption="Filtered column" class="docs-image--no-shadow" >}}
To remove the filter, click the blue funnel icon and then click **Clear filter**.

@ -4,13 +4,23 @@ aliases:
- /docs/grafana/latest/reference/table/
- /docs/grafana/latest/visualizations/table/
- /docs/grafana/next/panels/visualizations/table/table-field-options/
- /docs/grafana/latest/features/panels/table_panel/
- /docs/grafana/latest/panels/visualizations/table/filter-table-columns/
- /docs/grafana/latest/reference/table/
- /docs/grafana/latest/visualizations/table/filter-table-columns/
- /docs/grafana/latest/visualizations/table/
keywords:
- grafana
- dashboard
- documentation
- panels
- table panel
- table options
- format tables
- table filter
- filter columns
title: Table
menuTitle: Table
description: Learn about table panel visualization features.
weight: 1000
---
@ -113,8 +123,39 @@ Enables value inspection from table cell. The raw value is presented in a modal
## Column filter
You can temporarily change how column data is displayed. For example, you can order values from highest to lowest or hide specific values. For more information, refer to [Filter table columns]({{< relref "filter-table-columns/" >}}).
You can temporarily change how column data is displayed. For example, you can order values from highest to lowest or hide specific values. For more information, refer to [Filter table columns]({{< relref "#filter-table-columns" >}}).
## Pagination
Use this option to enable or disable pagination. It is a front-end option that does not affect queries. When enabled, the page size automatically adjusts to the height of the table.
## Filter table columns
If you turn on the **Column filter**, then you can filter table options.
### Turn on column filtering
1. In Grafana, navigate to the dashboard with the table with the columns that you want to filter.
1. On the table panel you want to filter, open the panel editor.
1. Click the **Field** tab.
1. In Table options, turn on the **Column filter** option.
A filter icon appears next to each column title.
{{< figure src="/static/img/docs/tables/column-filter-with-icon.png" max-width="500px" caption="Column filtering turned on" class="docs-image--no-shadow" >}}
### Filter column values
To filter column values, click the filter (funnel) icon next to a column title. Grafana displays the filter options for that column.
{{< figure src="/static/img/docs/tables/filter-column-values.png" max-width="500px" caption="Filter column values" class="docs-image--no-shadow" >}}
Click the check box next to the values that you want to display. Enter text in the search field at the top to show those values in the display so that you can select them rather than scroll to find them.
### Clear column filters
Columns with filters applied have a blue funnel displayed next to the title.
{{< figure src="/static/img/docs/tables/filtered-column.png" max-width="500px" caption="Filtered column" class="docs-image--no-shadow" >}}
To remove the filter, click the blue funnel icon and then click **Clear filter**.

@ -35,7 +35,7 @@ To learn more about the differences between new alerts and the legacy alerts, re
### Library panels
Library panels allow users to build panels that can be used in multiple dashboards. Any updates made to that shared panel will then automatically be applied to all the dashboards that have that panel. For instructions on how to create, add, unlink and manage library panels, refer to [Library panels]({{< relref "../panels/library-panels/" >}}).
Library panels allow users to build panels that can be used in multiple dashboards. Any updates made to that shared panel will then automatically be applied to all the dashboards that have that panel. For instructions on how to create, add, unlink and manage library panels, refer to [Library panels]({{< relref "../dashboards/manage-library-panels/" >}}).
### Real-time streaming

@ -162,7 +162,7 @@ You can now encrypt the query data cached by Grafana. This improves the security
You can now customize Grafana’s loading logo, which displays while Grafana is loading in a user’s browser. White labeling in Grafana Enterprise allows you to customize the look and feel of Grafana to match your product’s or company’s brand. This makes Grafana a more integrated part of your observability stack and keep Grafana consistent with other visualizations displayed in public.
To find out how you can configure it along with other Grafana UI elements, like the corner logo and application footer, refer to the [White labeling]({{< relref "../setup-grafana/enable-custom-branding/" >}}) topic of the Grafana Enterprise docs.
To find out how you can configure it along with other Grafana UI elements, like the corner logo and application footer, refer to the [White labeling]({{< relref "../setup-grafana/configure-grafana/configure-custom-branding/" >}}) topic of the Grafana Enterprise docs.
### Oauth2 - Team Sync to Group Mapping

@ -256,7 +256,7 @@
"@grafana/e2e-selectors": "workspace:*",
"@grafana/experimental": "^0.0.2-canary.32",
"@grafana/google-sdk": "0.0.3",
"@grafana/lezer-logql": "^0.0.13",
"@grafana/lezer-logql": "^0.0.14",
"@grafana/runtime": "workspace:*",
"@grafana/schema": "workspace:*",
"@grafana/slate-react": "0.22.10-grafana",
@ -285,6 +285,7 @@
"@sentry/browser": "6.19.7",
"@sentry/types": "6.19.7",
"@sentry/utils": "6.19.7",
"@types/rc-tree": "^3.0.0",
"@types/react-resizable": "3.0.0",
"@types/webpack-env": "^1.17.0",
"@visx/event": "2.6.0",
@ -349,6 +350,7 @@
"rc-drawer": "4.4.3",
"rc-slider": "9.7.5",
"rc-time-picker": "3.7.3",
"rc-tree": "^5.6.5",
"re-resizable": "6.9.9",
"react": "17.0.2",
"react-awesome-query-builder": "^5.1.2",

@ -59,6 +59,7 @@ export interface FeatureToggles {
scenes?: boolean;
useLegacyHeatmapPanel?: boolean;
cloudMonitoringExperimentalUI?: boolean;
disableSecretsCompatibility?: boolean;
logRequestsInstrumentedAsUnknown?: boolean;
dataConnectionsConsole?: boolean;
internationalization?: boolean;

@ -56,7 +56,7 @@ export const onUpdateDatasourceJsonDataOption =
export const onUpdateDatasourceSecureJsonDataOption =
<J, S extends {} = KeyValue>(props: DataSourcePluginOptionsEditorProps<J, S>, key: string) =>
(event: React.SyntheticEvent<HTMLInputElement | HTMLSelectElement>) => {
(event: React.SyntheticEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
updateDatasourcePluginSecureJsonDataOption(props, key, event.currentTarget.value);
};

@ -5,7 +5,7 @@ import rimrafCallback from 'rimraf';
import { promisify } from 'util';
import { getPluginId } from '../../config/utils/getPluginId';
import { getPluginJson } from '../../config/utils/pluginValidation';
import { assertRootUrlIsValid, getPluginJson } from '../../config/utils/pluginValidation';
import {
getJobFolder,
writeJobStats,
@ -141,6 +141,7 @@ const packagePluginRunner: TaskRunner<PluginCIOptions> = async ({ signatureType,
manifest.signatureType = signatureType;
}
if (rootUrls) {
rootUrls.forEach(assertRootUrlIsValid);
manifest.rootUrls = rootUrls;
}
const signedManifest = await signManifest(manifest);

@ -1,5 +1,6 @@
import path from 'path';
import { assertRootUrlIsValid } from '../../config/utils/pluginValidation';
import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest';
import { getToolkitVersion } from './plugin.utils';
@ -22,6 +23,7 @@ const pluginSignRunner: TaskRunner<PluginSignOptions> = async ({ signatureType,
manifest.signatureType = signatureType;
}
if (rootUrls) {
rootUrls.forEach(assertRootUrlIsValid);
manifest.rootUrls = rootUrls;
}

@ -36,3 +36,11 @@ export const getPluginJson = (path: string): PluginMeta => {
return pluginJson as PluginMeta;
};
export const assertRootUrlIsValid = (rootUrl: string) => {
try {
new URL(rootUrl);
} catch (err) {
throw new Error(`${rootUrl} is not a valid URL`);
}
};

@ -169,7 +169,9 @@ export function FileDropzone({ options, children, readAs, onLoad, fileListRender
let errors: string[] = [];
rejectedFiles.map((rejectedFile) => {
rejectedFile.errors.map((error) => {
errors.push(error.message);
if (errors.indexOf(error.message) === -1) {
errors.push(error.message);
}
});
});

@ -123,7 +123,9 @@ class UnThemedLogRowMessage extends PureComponent<Props> {
const styles = getStyles(theme);
return (
<td className={style.logsRowMessage}>
// When context is open, the position has to be NOT relative.
// Setting the postion as inline-style to overwrite the more sepecific style definition from `style.logsRowMessage`.
<td style={contextIsOpen ? { position: 'unset' } : undefined} className={style.logsRowMessage}>
<div
className={cx({ [styles.positionRelative]: wrapLogMessage }, { [styles.horizontalScroll]: !wrapLogMessage })}
>

@ -1,4 +1,4 @@
import { cx, css } from '@emotion/css';
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import { LogRowModel, Field, LinkModel } from '@grafana/data';
@ -19,12 +19,11 @@ class UnThemedLogRowMessageDetectedFields extends PureComponent<Props> {
render() {
const { row, showDetectedFields, getFieldLinks, wrapLogMessage } = this.props;
const fields = getAllFields(row, getFieldLinks);
const wrapClassName = cx(
wrapLogMessage &&
css`
white-space: pre-wrap;
`
);
const wrapClassName = wrapLogMessage
? ''
: css`
white-space: nowrap;
`;
const line = showDetectedFields
.map((parsedKey) => {

@ -64,15 +64,13 @@ const getTagStyles = (theme: GrafanaTheme, name: string, colorIndex?: number) =>
font-weight: ${theme.typography.weight.semibold};
font-size: ${theme.typography.size.sm};
line-height: ${theme.typography.lineHeight.xs};
vertical-align: baseline;
background-color: ${colors.color};
color: ${theme.palette.gray98};
white-space: nowrap;
text-shadow: none;
padding: 3px 6px;
border-radius: ${theme.border.radius.md};
display: flex;
align-items: center;
gap: 3px;
`,
hover: css`
&:hover {

@ -1,9 +1,10 @@
import { action } from '@storybook/addon-actions';
import { Story } from '@storybook/react';
import React from 'react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { TagList } from './TagList';
import { TagList, Props as TagListProps } from './TagList';
import mdx from './TagList.mdx';
export default {
@ -14,15 +15,31 @@ export default {
docs: {
page: mdx,
},
controls: {
exclude: ['className', 'onClick', 'getAriaLabel'],
},
},
args: {
displayMax: 3,
tags: ['datasource-test', 'gdev', 'mysql', 'mssql'],
onClick: action('Tag clicked'),
showIcon: false,
},
};
const tags = ['datasource-test', 'gdev', 'mysql', 'mssql'];
interface StoryProps extends TagListProps {
showIcon?: boolean;
}
export const list = () => {
export const List: Story<StoryProps> = (args) => {
return (
<div style={{ width: 300 }}>
<TagList tags={tags} onClick={action('Tag clicked')} />
<TagList
tags={args.tags}
onClick={args.onClick}
displayMax={args.displayMax}
icon={args.showIcon ? args.icon : undefined}
/>
</div>
);
};

@ -9,8 +9,11 @@ import { IconName } from '../../types/icon';
import { OnTagClick, Tag } from './Tag';
export interface Props {
/** Maximum number of the tags to display */
displayMax?: number;
/** Names of the tags to display */
tags: string[];
/** Callback when the tag is clicked */
onClick?: OnTagClick;
/** Custom styles for the wrapper component */
className?: string;
@ -33,8 +36,8 @@ export const TagList = memo(
<Tag name={tag} icon={icon} onClick={onClick} aria-label={getAriaLabel?.(tag, i)} data-tag-id={i} />
</li>
))}
{displayMax && displayMax > 0 && numTags - 1 > 0 && (
<span className={styles.moreTagsLabel}>+ {numTags - 1}</span>
{displayMax && displayMax > 0 && numTags - displayMax > 0 && (
<span className={styles.moreTagsLabel}>+ {numTags - displayMax}</span>
)}
</ul>
);

@ -212,6 +212,8 @@ export { Input, getInputStyles } from './Input/Input';
export { AutoSizeInput } from './Input/AutoSizeInput';
export { FilterInput } from './FilterInput/FilterInput';
export { FormInputSize } from './Forms/types';
export * from './SecretInput';
export * from './SecretTextArea';
export { Switch, InlineSwitch } from './Switch/Switch';
export { Checkbox } from './Forms/Checkbox';

@ -26,13 +26,6 @@ export function getPageStyles(theme: GrafanaTheme2) {
flex: 1 1 0;
}
.page-scrollbar-wrapper {
width: 100%;
flex-grow: 1;
width: 100%;
min-height: 0;
}
.page-scrollbar-content {
display: flex;
min-height: 100%;

@ -33,6 +33,7 @@
"@emotion/css": "11.9.0",
"@grafana/data": "9.1.0-pre",
"@grafana/e2e-selectors": "9.1.0-pre",
"@grafana/runtime": "9.1.0-pre",
"@grafana/ui": "9.1.0-pre",
"chance": "^1.0.10",
"classnames": "^2.2.5",

@ -24,6 +24,13 @@ import TimelineHeaderRow from './TimelineHeaderRow';
import TraceTimelineViewer from './index';
jest.mock('@grafana/runtime', () => {
return {
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
};
});
describe('<TraceTimelineViewer>', () => {
const trace = transformTraceData(traceGenerator.trace({}));
const props = {

@ -16,6 +16,7 @@ import { css } from '@emotion/css';
import React, { RefObject } from 'react';
import { GrafanaTheme2, LinkModel, TimeZone } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { stylesFactory, withTheme2 } from '@grafana/ui';
import { Accessors } from '../ScrollManager';
@ -76,6 +77,7 @@ type TProps = TExtractUiFindFromStateReturn & {
scrollToFirstVisibleSpan: () => void;
traceTimeline: TTraceTimeline;
trace: Trace;
datasourceType: string;
spanBarOptions: SpanBarOptions | undefined;
updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void;
updateViewRangeTime: TUpdateViewRangeTimeFunction;
@ -143,18 +145,34 @@ export class UnthemedTraceTimelineViewer extends React.PureComponent<TProps, Sta
collapseAll = () => {
this.props.collapseAll(this.props.trace.spans);
reportInteraction('grafana_traces_traceID_expand_collapse_clicked', {
datasourceType: this.props.datasourceType,
type: 'collapseAll',
});
};
collapseOne = () => {
this.props.collapseOne(this.props.trace.spans);
reportInteraction('grafana_traces_traceID_expand_collapse_clicked', {
datasourceType: this.props.datasourceType,
type: 'collapseOne',
});
};
expandAll = () => {
this.props.expandAll();
reportInteraction('grafana_traces_traceID_expand_collapse_clicked', {
datasourceType: this.props.datasourceType,
type: 'expandAll',
});
};
expandOne = () => {
this.props.expandOne(this.props.trace.spans);
reportInteraction('grafana_traces_traceID_expand_collapse_clicked', {
datasourceType: this.props.datasourceType,
type: 'expandOne',
});
};
render() {

@ -507,91 +507,107 @@ func (hs *HTTPServer) NotificationTest(c *models.ReqContext) response.Response {
}
// POST /api/alerts/:alertId/pause
func (hs *HTTPServer) PauseAlert(c *models.ReqContext) response.Response {
dto := dtos.PauseAlertCommand{}
if err := web.Bind(c.Req, &dto); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
alertID, err := strconv.ParseInt(web.Params(c.Req)[":alertId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "alertId is invalid", err)
}
result := make(map[string]interface{})
result["alertId"] = alertID
query := models.GetAlertByIdQuery{Id: alertID}
if err := hs.SQLStore.GetAlertById(c.Req.Context(), &query); err != nil {
return response.Error(500, "Get Alert failed", err)
func (hs *HTTPServer) PauseAlert(legacyAlertingEnabled *bool) func(c *models.ReqContext) response.Response {
if legacyAlertingEnabled == nil || !*legacyAlertingEnabled {
return func(_ *models.ReqContext) response.Response {
return response.Error(http.StatusBadRequest, "legacy alerting is disabled, so this call has no effect.", nil)
}
}
guardian := guardian.New(c.Req.Context(), query.Result.DashboardId, c.OrgId, c.SignedInUser)
if canEdit, err := guardian.CanEdit(); err != nil || !canEdit {
return func(c *models.ReqContext) response.Response {
dto := dtos.PauseAlertCommand{}
if err := web.Bind(c.Req, &dto); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
alertID, err := strconv.ParseInt(web.Params(c.Req)[":alertId"], 10, 64)
if err != nil {
return response.Error(500, "Error while checking permissions for Alert", err)
return response.Error(http.StatusBadRequest, "alertId is invalid", err)
}
result := make(map[string]interface{})
result["alertId"] = alertID
return response.Error(403, "Access denied to this dashboard and alert", nil)
}
query := models.GetAlertByIdQuery{Id: alertID}
if err := hs.SQLStore.GetAlertById(c.Req.Context(), &query); err != nil {
return response.Error(500, "Get Alert failed", err)
}
// Alert state validation
if query.Result.State != models.AlertStatePaused && !dto.Paused {
result["state"] = "un-paused"
result["message"] = "Alert is already un-paused"
return response.JSON(http.StatusOK, result)
} else if query.Result.State == models.AlertStatePaused && dto.Paused {
result["state"] = models.AlertStatePaused
result["message"] = "Alert is already paused"
return response.JSON(http.StatusOK, result)
}
guardian := guardian.New(c.Req.Context(), query.Result.DashboardId, c.OrgId, c.SignedInUser)
if canEdit, err := guardian.CanEdit(); err != nil || !canEdit {
if err != nil {
return response.Error(500, "Error while checking permissions for Alert", err)
}
cmd := models.PauseAlertCommand{
OrgId: c.OrgId,
AlertIds: []int64{alertID},
Paused: dto.Paused,
}
return response.Error(403, "Access denied to this dashboard and alert", nil)
}
if err := hs.SQLStore.PauseAlert(c.Req.Context(), &cmd); err != nil {
return response.Error(500, "", err)
}
// Alert state validation
if query.Result.State != models.AlertStatePaused && !dto.Paused {
result["state"] = "un-paused"
result["message"] = "Alert is already un-paused"
return response.JSON(http.StatusOK, result)
} else if query.Result.State == models.AlertStatePaused && dto.Paused {
result["state"] = models.AlertStatePaused
result["message"] = "Alert is already paused"
return response.JSON(http.StatusOK, result)
}
resp := models.AlertStateUnknown
pausedState := "un-paused"
if cmd.Paused {
resp = models.AlertStatePaused
pausedState = "paused"
}
cmd := models.PauseAlertCommand{
OrgId: c.OrgId,
AlertIds: []int64{alertID},
Paused: dto.Paused,
}
result["state"] = resp
result["message"] = "Alert " + pausedState
return response.JSON(http.StatusOK, result)
if err := hs.SQLStore.PauseAlert(c.Req.Context(), &cmd); err != nil {
return response.Error(500, "", err)
}
resp := models.AlertStateUnknown
pausedState := "un-paused"
if cmd.Paused {
resp = models.AlertStatePaused
pausedState = "paused"
}
result["state"] = resp
result["message"] = "Alert " + pausedState
return response.JSON(http.StatusOK, result)
}
}
// POST /api/admin/pause-all-alerts
func (hs *HTTPServer) PauseAllAlerts(c *models.ReqContext) response.Response {
dto := dtos.PauseAllAlertsCommand{}
if err := web.Bind(c.Req, &dto); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
updateCmd := models.PauseAllAlertCommand{
Paused: dto.Paused,
func (hs *HTTPServer) PauseAllAlerts(legacyAlertingEnabled *bool) func(c *models.ReqContext) response.Response {
if legacyAlertingEnabled == nil || !*legacyAlertingEnabled {
return func(_ *models.ReqContext) response.Response {
return response.Error(http.StatusBadRequest, "legacy alerting is disabled, so this call has no effect.", nil)
}
}
if err := hs.SQLStore.PauseAllAlerts(c.Req.Context(), &updateCmd); err != nil {
return response.Error(500, "Failed to pause alerts", err)
}
return func(c *models.ReqContext) response.Response {
dto := dtos.PauseAllAlertsCommand{}
if err := web.Bind(c.Req, &dto); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
updateCmd := models.PauseAllAlertCommand{
Paused: dto.Paused,
}
resp := models.AlertStatePending
pausedState := "un paused"
if updateCmd.Paused {
resp = models.AlertStatePaused
pausedState = "paused"
}
if err := hs.SQLStore.PauseAllAlerts(c.Req.Context(), &updateCmd); err != nil {
return response.Error(500, "Failed to pause alerts", err)
}
result := map[string]interface{}{
"state": resp,
"message": "alerts " + pausedState,
"alertsAffected": updateCmd.ResultCount,
}
resp := models.AlertStatePending
pausedState := "un paused"
if updateCmd.Paused {
resp = models.AlertStatePaused
pausedState = "paused"
}
return response.JSON(http.StatusOK, result)
result := map[string]interface{}{
"state": resp,
"message": "alerts " + pausedState,
"alertsAffected": updateCmd.ResultCount,
}
return response.JSON(http.StatusOK, result)
}
}

@ -145,7 +145,9 @@ func postAlertScenario(t *testing.T, hs *HTTPServer, desc string, url string, ro
sc.context.OrgId = testOrgID
sc.context.OrgRole = role
return hs.PauseAlert(c)
legacyAlertingEnabled := new(bool)
*legacyAlertingEnabled = true
return hs.PauseAlert(legacyAlertingEnabled)(c)
})
sc.m.Post(routePattern, sc.defaultHandler)

@ -16,6 +16,7 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
@ -460,7 +461,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Group("/alerts", func(alertsRoute routing.RouteRegister) {
alertsRoute.Post("/test", routing.Wrap(hs.AlertTest))
alertsRoute.Post("/:alertId/pause", reqEditorRole, routing.Wrap(hs.PauseAlert))
alertsRoute.Post("/:alertId/pause", reqEditorRole, routing.Wrap(hs.PauseAlert(setting.AlertingEnabled)))
alertsRoute.Get("/:alertId", hs.ValidateOrgAlert, routing.Wrap(hs.GetAlert))
alertsRoute.Get("/", routing.Wrap(hs.GetAlerts))
alertsRoute.Get("/states-for-dashboard", routing.Wrap(hs.GetAlertStatesForDashboard))
@ -554,7 +555,7 @@ func (hs *HTTPServer) registerRoutes() {
adminRoute.Get("/settings/features", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)), hs.Features.HandleGetSettings)
}
adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(hs.AdminGetStats))
adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(hs.PauseAllAlerts))
adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(hs.PauseAllAlerts(setting.AlertingEnabled)))
if hs.ThumbService != nil && hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) {
adminRoute.Post("/crawler/start", reqGrafanaAdmin, routing.Wrap(hs.ThumbService.StartCrawler))
@ -566,6 +567,7 @@ func (hs *HTTPServer) registerRoutes() {
adminRoute.Get("/export", reqGrafanaAdmin, routing.Wrap(hs.ExportService.HandleGetStatus))
adminRoute.Post("/export", reqGrafanaAdmin, routing.Wrap(hs.ExportService.HandleRequestExport))
adminRoute.Post("/export/stop", reqGrafanaAdmin, routing.Wrap(hs.ExportService.HandleRequestStop))
adminRoute.Get("/export/options", reqGrafanaAdmin, routing.Wrap(hs.ExportService.HandleGetOptions))
}
adminRoute.Post("/encryption/rotate-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminRotateDataEncryptionKeys))

@ -626,13 +626,9 @@ func (hs *HTTPServer) checkDatasourceHealth(c *models.ReqContext, ds *datasource
return response.JSON(http.StatusOK, payload)
}
func (hs *HTTPServer) decryptSecureJsonDataFn(ctx context.Context) func(ds *datasources.DataSource) map[string]string {
return func(ds *datasources.DataSource) map[string]string {
decryptedJsonData, err := hs.DataSourcesService.DecryptedValues(ctx, ds)
if err != nil {
hs.log.Error("Failed to decrypt secure json data", "error", err)
}
return decryptedJsonData
func (hs *HTTPServer) decryptSecureJsonDataFn(ctx context.Context) func(ds *datasources.DataSource) (map[string]string, error) {
return func(ds *datasources.DataSource) (map[string]string, error) {
return hs.DataSourcesService.DecryptedValues(ctx, ds)
}
}

@ -0,0 +1,177 @@
package definitions
import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/models"
)
// swagger:route GET /playlists playlists searchPlaylists
//
// Get playlists.
//
// Responses:
// 200: searchPlaylistsResponse
// 500: internalServerError
// swagger:route GET /playlists/{uid} playlists getPlaylist
//
// Get playlist by UID.
//
// Responses:
// 200: getPlaylistResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
// swagger:route GET /playlists/{uid}/items playlists getPlaylistItems
//
// Get playlist items.
//
// Responses:
// 200: getPlaylistItemsResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
// swagger:route GET /playlists/{uid}/dashboards playlists getPlaylistDashboards
//
// Get playlist dashboards.
//
// Responses:
// 200: getPlaylistDashboardsResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
// swagger:route DELETE /playlists/{uid} playlists deletePlaylist
//
// Delete pllaylist.
//
// Responses:
// 200: okResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
// swagger:route PUT /playlists/{uid} playlists updatePlaylist
//
// Update playlist.
//
// Responses:
// 200: updatePlaylistResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
// swagger:route POST /playlists playlists createPlaylist
//
// Create playlist.
//
// Responses:
// 200: createPlaylistResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
// swagger:parameters searchPlaylists
type SearchPlaylistsParams struct {
// in:query
// required:false
Query string `json:"query"`
// in:limit
// required:false
Limit int `json:"limit"`
}
// swagger:parameters getPlaylist
type GetPlaylistParams struct {
// in:path
// required:true
UID string `json:"uid"`
}
// swagger:parameters getPlaylistItems
type GetPlaylistItemsParams struct {
// in:path
// required:true
UID string `json:"uid"`
}
// swagger:parameters getPlaylistDashboards
type GetPlaylistDashboardsParams struct {
// in:path
// required:true
UID string `json:"uid"`
}
// swagger:parameters deletePlaylist
type DeletePlaylistParams struct {
// in:path
// required:true
UID string `json:"uid"`
}
// swagger:parameters updatePlaylist
type UpdatePlaylistParams struct {
// in:body
// required:true
Body models.UpdatePlaylistCommand
// in:path
// required:true
UID string `json:"uid"`
}
// swagger:parameters createPlaylist
type CreatePlaylistParams struct {
// in:body
// required:true
Body models.CreatePlaylistCommand
}
// swagger:response searchPlaylistsResponse
type SearchPlaylistsResponse struct {
// The response message
// in: body
Body models.Playlists `json:"body"`
}
// swagger:response getPlaylistResponse
type GetPlaylistResponse struct {
// The response message
// in: body
Body *models.PlaylistDTO `json:"body"`
}
// swagger:response getPlaylistItemsResponse
type GetPlaylistItemsResponse struct {
// The response message
// in: body
Body []models.PlaylistItemDTO `json:"body"`
}
// swagger:response getPlaylistDashboardsResponse
type GetPlaylistDashboardsResponse struct {
// The response message
// in: body
Body dtos.PlaylistDashboardsSlice `json:"body"`
}
// swagger:response updatePlaylistResponse
type UpdatePlaylistResponseResponse struct {
// The response message
// in: body
Body *models.PlaylistDTO `json:"body"`
}
// swagger:response createPlaylistResponse
type CreatePlaylistResponse struct {
// The response message
// in: body
Body *models.Playlist `json:"body"`
}

@ -519,7 +519,7 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
}
m.Use(middleware.Recovery(hs.Cfg))
m.UseMiddleware(hs.Csrf.Middleware(hs.log))
m.UseMiddleware(hs.Csrf.Middleware())
hs.mapStatic(m, hs.Cfg.StaticRootPath, "build", "public/build")
hs.mapStatic(m, hs.Cfg.StaticRootPath, "", "public", "/public/views/swagger.html")

@ -36,17 +36,17 @@ func (hs *HTTPServer) getProfileNode(c *models.ReqContext) *dtos.NavLink {
children := []*dtos.NavLink{
{
Text: "Preferences", Id: "profile-settings", Url: hs.Cfg.AppSubURL + "/profile", Icon: "sliders-v-alt",
Text: "Preferences", Id: "profile/settings", Url: hs.Cfg.AppSubURL + "/profile", Icon: "sliders-v-alt",
},
}
children = append(children, &dtos.NavLink{
Text: "Notification history", Id: "notifications", Url: hs.Cfg.AppSubURL + "/notifications", Icon: "bell",
Text: "Notification history", Id: "profile/notifications", Url: hs.Cfg.AppSubURL + "profile/notifications", Icon: "bell",
})
if setting.AddChangePasswordLink() {
children = append(children, &dtos.NavLink{
Text: "Change password", Id: "change-password", Url: hs.Cfg.AppSubURL + "/profile/password",
Text: "Change password", Id: "profile/password", Url: hs.Cfg.AppSubURL + "/profile/password",
Icon: "lock",
})
}

@ -2,6 +2,7 @@ package api
import (
"errors"
"fmt"
"net/http"
"github.com/grafana/grafana-plugin-sdk-go/backend"
@ -24,7 +25,13 @@ func (hs *HTTPServer) handleQueryMetricsError(err error) *response.NormalRespons
if errors.Is(err, datasources.ErrDataSourceNotFound) {
return response.Error(http.StatusNotFound, "Data source not found", err)
}
var badQuery *query.ErrBadQuery
var secretsPlugin datasources.ErrDatasourceSecretsPluginUserFriendly
if errors.As(err, &secretsPlugin) {
return response.Error(http.StatusInternalServerError, fmt.Sprint("Secrets Plugin error: ", err.Error()), err)
}
var badQuery query.ErrBadQuery
if errors.As(err, &badQuery) {
return response.Error(http.StatusBadRequest, util.Capitalize(badQuery.Message), err)
}

@ -1,7 +1,9 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
@ -40,6 +42,11 @@ type fakePluginRequestValidator struct {
err error
}
type secretsErrorResponseBody struct {
Error string `json:"error"`
Message string `json:"message"`
}
func (rv *fakePluginRequestValidator) Validate(dsURL string, req *http.Request) error {
return rv.err
}
@ -104,3 +111,44 @@ func TestAPIEndpoint_Metrics_QueryMetricsV2(t *testing.T) {
require.Equal(t, http.StatusMultiStatus, resp.StatusCode)
})
}
func TestAPIEndpoint_Metrics_PluginDecryptionFailure(t *testing.T) {
qds := query.ProvideService(
nil,
nil,
nil,
&fakePluginRequestValidator{},
&fakeDatasources.FakeDataSourceService{SimulatePluginFailure: true},
&fakePluginClient{
QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
resp := backend.Responses{
"A": backend.DataResponse{
Error: fmt.Errorf("query failed"),
},
}
return &backend.QueryDataResponse{Responses: resp}, nil
},
},
&fakeOAuthTokenService{},
)
httpServer := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.queryDataService = qds
})
t.Run("Status code is 500 and a secrets plugin error is returned if there is a problem getting secrets from the remote plugin", func(t *testing.T) {
req := httpServer.NewPostRequest("/api/ds/query", strings.NewReader(queryDatasourceInput))
webtest.RequestWithSignedInUser(req, &models.SignedInUser{UserId: 1, OrgId: 1, OrgRole: models.ROLE_VIEWER})
resp, err := httpServer.SendJSON(req)
require.NoError(t, err)
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
var resObj secretsErrorResponseBody
err = json.Unmarshal(buf.Bytes(), &resObj)
require.NoError(t, err)
require.Equal(t, "unknown error", resObj.Error)
require.Contains(t, resObj.Message, "Secrets Plugin error:")
})
}

@ -239,6 +239,22 @@ func Err(err error) *NormalResponse {
return resp
}
// ErrOrFallback uses the information in an errutil.Error if available
// and otherwise falls back to the status and message provided as
// arguments.
//
// The signature is equivalent to that of Error which allows us to
// rename this to Error when we're confident that that would be safe to
// do.
func ErrOrFallback(status int, message string, err error) *NormalResponse {
grafanaErr := &errutil.Error{}
if errors.As(err, grafanaErr) {
return Err(err)
}
return Error(status, message, err)
}
// Empty creates an empty NormalResponse.
func Empty(status int) *NormalResponse {
return Respond(status, nil)

@ -0,0 +1,127 @@
package response
import (
"errors"
"net/http"
"testing"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestErrors(t *testing.T) {
const fakeNotFoundMessage = "I looked, but did not find the thing"
const genericErrorMessage = "Something went wrong in parsing the request"
cases := []struct {
name string
// inputs
err error
statusCode int
message string
// responses
legacyResponse *NormalResponse
newResponse *NormalResponse
fallbackUseNew bool
compareErr bool
}{
{
name: "base case",
legacyResponse: &NormalResponse{},
newResponse: &NormalResponse{
status: http.StatusInternalServerError,
},
},
{
name: "not found error",
err: errors.New("not found"),
statusCode: http.StatusNotFound,
message: fakeNotFoundMessage,
legacyResponse: &NormalResponse{
status: http.StatusNotFound,
errMessage: fakeNotFoundMessage,
},
newResponse: &NormalResponse{
status: http.StatusInternalServerError,
},
},
{
name: "grafana error with fallback to other error",
err: errutil.NewBase(errutil.StatusTimeout, "thing.timeout").Errorf("whoops"),
statusCode: http.StatusBadRequest,
message: genericErrorMessage,
legacyResponse: &NormalResponse{
status: http.StatusBadRequest,
errMessage: genericErrorMessage,
},
newResponse: &NormalResponse{
status: http.StatusGatewayTimeout,
errMessage: errutil.StatusTimeout.String(),
},
fallbackUseNew: true,
},
}
compareResponses := func(expected *NormalResponse, actual *NormalResponse, compareErr bool) func(t *testing.T) {
return func(t *testing.T) {
if expected == nil {
require.Nil(t, actual)
return
}
require.NotNil(t, actual)
assert.Equal(t, expected.status, actual.status)
if expected.body != nil {
assert.Equal(t, expected.body.Bytes(), actual.body.Bytes())
}
if expected.header != nil {
assert.EqualValues(t, expected.header, actual.header)
}
assert.Equal(t, expected.errMessage, actual.errMessage)
if compareErr {
assert.ErrorIs(t, expected.err, actual.err)
}
}
}
for _, tc := range cases {
tc := tc
t.Run(
tc.name+" Error",
compareResponses(tc.legacyResponse, Error(
tc.statusCode,
tc.message,
tc.err,
), tc.compareErr),
)
t.Run(
tc.name+" Err",
compareResponses(tc.newResponse, Err(
tc.err,
), tc.compareErr),
)
fallbackResponse := tc.legacyResponse
if tc.fallbackUseNew {
fallbackResponse = tc.newResponse
}
t.Run(
tc.name+" ErrOrFallback",
compareResponses(fallbackResponse, ErrOrFallback(
tc.statusCode,
tc.message,
tc.err,
), tc.compareErr),
)
}
}

@ -55,6 +55,14 @@ type DataSourceDeleted struct {
OrgID int64 `json:"org_id"`
}
type DataSourceSecretDeleted struct {
Timestamp time.Time `json:"timestamp"`
Name string `json:"name"`
ID int64 `json:"id"`
UID string `json:"uid"`
OrgID int64 `json:"org_id"`
}
type DataSourceCreated struct {
Timestamp time.Time `json:"timestamp"`
Name string `json:"name"`

@ -127,12 +127,8 @@ func hiddenRefIDs(queries []Query) (map[string]struct{}, error) {
return hidden, nil
}
func (s *Service) decryptSecureJsonDataFn(ctx context.Context) func(ds *datasources.DataSource) map[string]string {
return func(ds *datasources.DataSource) map[string]string {
decryptedJsonData, err := s.dataSourceService.DecryptedValues(ctx, ds)
if err != nil {
logger.Error("Failed to decrypt secure json data", "error", err)
}
return decryptedJsonData
func (s *Service) decryptSecureJsonDataFn(ctx context.Context) func(ds *datasources.DataSource) (map[string]string, error) {
return func(ds *datasources.DataSource) (map[string]string, error) {
return s.dataSourceService.DecryptedValues(ctx, ds)
}
}

@ -2,112 +2,62 @@ package csrf
import (
"errors"
"fmt"
"net/http"
"net/url"
"reflect"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
type Service interface {
Middleware(logger log.Logger) func(http.Handler) http.Handler
Middleware() func(http.Handler) http.Handler
TrustOrigin(origin string)
AddOriginHeader(headerName string)
AddAdditionalHeaders(headerName string)
AddSafeEndpoint(endpoint string)
}
type Implementation struct {
type CSRF struct {
cfg *setting.Cfg
trustedOrigins map[string]struct{}
originHeaders map[string]struct{}
headers map[string]struct{}
safeEndpoints map[string]struct{}
}
func ProvideCSRFFilter(cfg *setting.Cfg) Service {
i := &Implementation{
c := &CSRF{
cfg: cfg,
trustedOrigins: map[string]struct{}{},
originHeaders: map[string]struct{}{
"Origin": {},
},
safeEndpoints: map[string]struct{}{},
headers: map[string]struct{}{},
safeEndpoints: map[string]struct{}{},
}
additionalHeaders := cfg.SectionWithEnvOverrides("security").Key("csrf_additional_headers").Strings(" ")
trustedOrigins := cfg.SectionWithEnvOverrides("security").Key("csrf_trusted_origins").Strings(" ")
for _, header := range additionalHeaders {
i.originHeaders[header] = struct{}{}
c.headers[header] = struct{}{}
}
for _, origin := range trustedOrigins {
i.trustedOrigins[origin] = struct{}{}
c.trustedOrigins[origin] = struct{}{}
}
return i
return c
}
func (i *Implementation) Middleware(logger log.Logger) func(http.Handler) http.Handler {
// As per RFC 7231/4.2.2 these methods are idempotent:
// (GET is excluded because it may have side effects in some APIs)
safeMethods := []string{"HEAD", "OPTIONS", "TRACE"}
func (c *CSRF) Middleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// If request has no login cookie - skip CSRF checks
if _, err := r.Cookie(i.cfg.LoginCookieName); errors.Is(err, http.ErrNoCookie) {
next.ServeHTTP(w, r)
return
}
// Skip CSRF checks for "safe" methods
for _, method := range safeMethods {
if r.Method == method {
next.ServeHTTP(w, r)
return
}
}
// Skip CSRF checks for "safe" endpoints
for safeEndpoint := range i.safeEndpoints {
if r.URL.Path == safeEndpoint {
next.ServeHTTP(w, r)
return
}
}
// Otherwise - verify that Origin matches the server origin
netAddr, err := util.SplitHostPortDefault(r.Host, "", "0") // we ignore the port
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
origins := map[string]struct{}{}
for header := range i.originHeaders {
origin, err := url.Parse(r.Header.Get(header))
if err != nil {
logger.Error("error parsing Origin header", "header", header, "err", err)
}
if origin.String() != "" {
origins[origin.Hostname()] = struct{}{}
}
}
// No Origin header sent, skip CSRF check.
if len(origins) == 0 {
next.ServeHTTP(w, r)
return
}
e := &errorWithStatus{}
trustedOrigin := false
for o := range i.trustedOrigins {
if _, ok := origins[o]; ok {
trustedOrigin = true
break
err := c.check(r)
if err != nil {
if !errors.As(err, &e) {
http.Error(w, fmt.Sprintf("internal server error: expected error type errorWithStatus, got %s. Error: %v", reflect.TypeOf(err), err), http.StatusInternalServerError)
}
}
_, hostnameMatches := origins[netAddr.Host]
if netAddr.Host == "" || !trustedOrigin && !hostnameMatches {
http.Error(w, "origin not allowed", http.StatusForbidden)
http.Error(w, err.Error(), e.HTTPStatus)
return
}
@ -116,15 +66,96 @@ func (i *Implementation) Middleware(logger log.Logger) func(http.Handler) http.H
}
}
func (i *Implementation) TrustOrigin(origin string) {
i.trustedOrigins[origin] = struct{}{}
func (c *CSRF) check(r *http.Request) error {
// As per RFC 7231/4.2.2 these methods are idempotent:
// (GET is excluded because it may have side effects in some APIs)
safeMethods := []string{"HEAD", "OPTIONS", "TRACE"}
// If request has no login cookie - skip CSRF checks
if _, err := r.Cookie(c.cfg.LoginCookieName); errors.Is(err, http.ErrNoCookie) {
return nil
}
// Skip CSRF checks for "safe" methods
for _, method := range safeMethods {
if r.Method == method {
return nil
}
}
// Skip CSRF checks for "safe" endpoints
for safeEndpoint := range c.safeEndpoints {
if r.URL.Path == safeEndpoint {
return nil
}
}
// Otherwise - verify that Origin matches the server origin
netAddr, err := util.SplitHostPortDefault(r.Host, "", "0") // we ignore the port
if err != nil {
return &errorWithStatus{Underlying: err, HTTPStatus: http.StatusBadRequest}
}
o := r.Header.Get("Origin")
// No Origin header sent, skip CSRF check.
if o == "" {
return nil
}
originURL, err := url.Parse(o)
if err != nil {
return &errorWithStatus{Underlying: err, HTTPStatus: http.StatusBadRequest}
}
origin := originURL.Hostname()
trustedOrigin := false
for h := range c.headers {
customHost := r.Header.Get(h)
addr, err := util.SplitHostPortDefault(customHost, "", "0") // we ignore the port
if err != nil {
return &errorWithStatus{Underlying: err, HTTPStatus: http.StatusBadRequest}
}
if addr.Host == origin {
trustedOrigin = true
break
}
}
for o := range c.trustedOrigins {
if o == origin {
trustedOrigin = true
break
}
}
hostnameMatches := origin == netAddr.Host
if netAddr.Host == "" || !trustedOrigin && !hostnameMatches {
return &errorWithStatus{Underlying: errors.New("origin not allowed"), HTTPStatus: http.StatusForbidden}
}
return nil
}
func (c *CSRF) TrustOrigin(origin string) {
c.trustedOrigins[origin] = struct{}{}
}
func (i *Implementation) AddOriginHeader(headerName string) {
i.originHeaders[headerName] = struct{}{}
func (c *CSRF) AddAdditionalHeaders(headerName string) {
c.headers[headerName] = struct{}{}
}
// AddSafeEndpoint is used for endpoints requests to skip CSRF check
func (i *Implementation) AddSafeEndpoint(endpoint string) {
i.safeEndpoints[endpoint] = struct{}{}
func (c *CSRF) AddSafeEndpoint(endpoint string) {
c.safeEndpoints[endpoint] = struct{}{}
}
type errorWithStatus struct {
Underlying error
HTTPStatus int
}
func (e errorWithStatus) Error() string {
return e.Underlying.Error()
}
func (e errorWithStatus) Unwrap() error {
return e.Underlying
}

@ -1,13 +1,15 @@
package csrf
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
)
@ -100,6 +102,117 @@ func TestMiddlewareCSRF(t *testing.T) {
}
}
func TestCSRF_Check(t *testing.T) {
tests := []struct {
name string
request *http.Request
addtHeader map[string]struct{}
trustedOrigins map[string]struct{}
safeEndpoints map[string]struct{}
expectedOK bool
expectedStatus int
}{
{
name: "base case",
request: postRequest(t, "", nil),
expectedOK: true,
},
{
name: "base with null origin header",
request: postRequest(t, "", map[string]string{"Origin": "null"}),
expectedStatus: http.StatusForbidden,
},
{
name: "grafana.org",
request: postRequest(t, "grafana.org", map[string]string{"Origin": "https://grafana.org"}),
expectedOK: true,
},
{
name: "grafana.org with X-Forwarded-Host",
request: postRequest(t, "grafana.localhost", map[string]string{"X-Forwarded-Host": "grafana.org", "Origin": "https://grafana.org"}),
expectedStatus: http.StatusForbidden,
},
{
name: "grafana.org with X-Forwarded-Host and header trusted",
request: postRequest(t, "grafana.localhost", map[string]string{"X-Forwarded-Host": "grafana.org", "Origin": "https://grafana.org"}),
addtHeader: map[string]struct{}{"X-Forwarded-Host": {}},
expectedOK: true,
},
{
name: "grafana.org from grafana.com",
request: postRequest(t, "grafana.org", map[string]string{"Origin": "https://grafana.com"}),
expectedStatus: http.StatusForbidden,
},
{
name: "grafana.org from grafana.com explicit trust for grafana.com",
request: postRequest(t, "grafana.org", map[string]string{"Origin": "https://grafana.com"}),
trustedOrigins: map[string]struct{}{"grafana.com": {}},
expectedOK: true,
},
{
name: "grafana.org from grafana.com with X-Forwarded-Host and header trusted",
request: postRequest(t, "grafana.localhost", map[string]string{"X-Forwarded-Host": "grafana.org", "Origin": "https://grafana.com"}),
addtHeader: map[string]struct{}{"X-Forwarded-Host": {}},
trustedOrigins: map[string]struct{}{"grafana.com": {}},
expectedOK: true,
},
{
name: "safe endpoint",
request: postRequest(t, "example.org/foo/bar", map[string]string{"Origin": "null"}),
safeEndpoints: map[string]struct{}{"foo/bar": {}},
expectedOK: true,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
c := CSRF{
cfg: setting.NewCfg(),
trustedOrigins: tc.trustedOrigins,
headers: tc.addtHeader,
safeEndpoints: tc.safeEndpoints,
}
c.cfg.LoginCookieName = "LoginCookie"
err := c.check(tc.request)
if tc.expectedOK {
require.NoError(t, err)
} else {
require.Error(t, err)
var actual *errorWithStatus
require.True(t, errors.As(err, &actual))
assert.EqualValues(t, tc.expectedStatus, actual.HTTPStatus)
}
})
}
}
func postRequest(t testing.TB, hostname string, headers map[string]string) *http.Request {
t.Helper()
urlParts := strings.SplitN(hostname, "/", 2)
path := "/"
if len(urlParts) == 2 {
path = urlParts[1]
}
r, err := http.NewRequest(http.MethodPost, path, nil)
require.NoError(t, err)
r.Host = urlParts[0]
r.AddCookie(&http.Cookie{
Name: "LoginCookie",
Value: "this should not be important",
})
for k, v := range headers {
r.Header.Set(k, v)
}
return r
}
func csrfScenario(t *testing.T, cookieName, method, origin, host string) *httptest.ResponseRecorder {
req, err := http.NewRequest(method, "/", nil)
if err != nil {
@ -123,7 +236,7 @@ func csrfScenario(t *testing.T, cookieName, method, origin, host string) *httpte
cfg := setting.NewCfg()
cfg.LoginCookieName = cookieName
service := ProvideCSRFFilter(cfg)
handler := service.Middleware(log.New())(testHandler)
handler := service.Middleware()(testHandler)
handler.ServeHTTP(rr, req)
return rr
}

@ -10,7 +10,7 @@ func GetUniqueDashboardDatasourceUids(dashboard *simplejson.Json) []string {
for _, panelObj := range dashboard.Get("panels").MustArray() {
panel := simplejson.NewFromAny(panelObj)
uid := panel.Get("datasource").Get("uid").MustString()
uid := GetDataSourceUidFromJson(panel)
// if uid is for a mixed datasource, get the datasource uids from the targets
if uid == "-- Mixed --" {
@ -44,8 +44,11 @@ func GroupQueriesByPanelId(dashboard *simplejson.Json) map[int64][]*simplejson.J
for _, queryObj := range panel.Get("targets").MustArray() {
query := simplejson.NewFromAny(queryObj)
// if query target has no datasource, set it to have the datasource on the panel
if _, ok := query.CheckGet("datasource"); !ok {
query.Set("datasource", panel.Get("datasource"))
uid := GetDataSourceUidFromJson(panel)
datasource := map[string]interface{}{"type": "public-ds", "uid": uid}
query.Set("datasource", datasource)
}
panelQueries = append(panelQueries, query)
@ -61,13 +64,8 @@ func GroupQueriesByDataSource(queries []*simplejson.Json) (result [][]*simplejso
byDataSource := make(map[string][]*simplejson.Json)
for _, query := range queries {
dataSourceUid, err := query.GetPath("datasource", "uid").String()
if err != nil {
continue
}
byDataSource[dataSourceUid] = append(byDataSource[dataSourceUid], query)
uid := GetDataSourceUidFromJson(query)
byDataSource[uid] = append(byDataSource[uid], query)
}
for _, queries := range byDataSource {
@ -76,3 +74,14 @@ func GroupQueriesByDataSource(queries []*simplejson.Json) (result [][]*simplejso
return
}
func GetDataSourceUidFromJson(query *simplejson.Json) string {
uid := query.Get("datasource").Get("uid").MustString()
// before 8.3 special types could be sent as datasource (expr)
if uid == "" {
uid = query.Get("datasource").MustString()
}
return uid
}

@ -20,6 +20,37 @@ const (
"schemaVersion": 35
}`
dashboardWithTargetsWithNoDatasources = `
{
"panels": [
{
"id": 2,
"datasource": {
"type": "postgres",
"uid": "abc123"
},
"targets": [
{
"expr": "go_goroutines{job=\"$job\"}",
"interval": "",
"legendFormat": "",
"refId": "A"
},
{
"exemplar": true,
"expr": "query2",
"interval": "",
"legendFormat": "",
"refId": "B"
}
],
"title": "Panel Title",
"type": "timeseries"
}
],
"schemaVersion": 35
}`
dashboardWithQueries = `
{
"panels": [
@ -256,6 +287,24 @@ func TestGetUniqueDashboardDatasourceUids(t *testing.T) {
}
func TestGroupQueriesByPanelId(t *testing.T) {
t.Run("can extract queries from dashboard with panel datasource string that has no datasource on panel targets", func(t *testing.T) {
json, err := simplejson.NewJson([]byte(oldStyleDashboard))
require.NoError(t, err)
queries := GroupQueriesByPanelId(json)
panelId := int64(2)
queriesByDatasource := GroupQueriesByDataSource(queries[panelId])
require.Len(t, queriesByDatasource[0], 1)
})
t.Run("can extract queries from dashboard with panel json datasource that has no datasource on panel targets", func(t *testing.T) {
json, err := simplejson.NewJson([]byte(dashboardWithTargetsWithNoDatasources))
require.NoError(t, err)
queries := GroupQueriesByPanelId(json)
panelId := int64(2)
queriesByDatasource := GroupQueriesByDataSource(queries[panelId])
require.Len(t, queriesByDatasource[0], 2)
})
t.Run("can extract no queries from empty dashboard", func(t *testing.T) {
json, err := simplejson.NewJson([]byte(`{"panels": {}}`))
require.NoError(t, err)
@ -321,7 +370,10 @@ func TestGroupQueriesByPanelId(t *testing.T) {
query, err := queries[2][0].MarshalJSON()
require.NoError(t, err)
require.JSONEq(t, `{
"datasource": "_yxMP8Ynk",
"datasource": {
"uid": "_yxMP8Ynk",
"type": "public-ds"
},
"exemplar": true,
"expr": "go_goroutines{job=\"$job\"}",
"interval": "",

@ -3,6 +3,7 @@ package adapters
import (
"encoding/json"
"fmt"
"github.com/grafana/grafana-plugin-sdk-go/backend"
@ -11,16 +12,20 @@ import (
)
// ModelToInstanceSettings converts a datasources.DataSource to a backend.DataSourceInstanceSettings.
func ModelToInstanceSettings(ds *datasources.DataSource, decryptFn func(ds *datasources.DataSource) map[string]string,
func ModelToInstanceSettings(ds *datasources.DataSource, decryptFn func(ds *datasources.DataSource) (map[string]string, error),
) (*backend.DataSourceInstanceSettings, error) {
var jsonDataBytes json.RawMessage
if ds.JsonData != nil {
var err error
jsonDataBytes, err = ds.JsonData.MarshalJSON()
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to convert data source to instance settings: %w", err)
}
}
decrypted, err := decryptFn(ds)
if err != nil {
return nil, err
}
return &backend.DataSourceInstanceSettings{
ID: ds.Id,
@ -32,9 +37,9 @@ func ModelToInstanceSettings(ds *datasources.DataSource, decryptFn func(ds *data
BasicAuthEnabled: ds.BasicAuth,
BasicAuthUser: ds.BasicAuthUser,
JSONData: jsonDataBytes,
DecryptedSecureJSONData: decryptFn(ds),
DecryptedSecureJSONData: decrypted,
Updated: ds.Updated,
}, nil
}, err
}
// BackendUserFromSignedInUser converts Grafana's SignedInUser model

@ -127,12 +127,8 @@ func (p *Provider) getCachedPluginSettings(ctx context.Context, pluginID string,
return ps, nil
}
func (p *Provider) decryptSecureJsonDataFn(ctx context.Context) func(ds *datasources.DataSource) map[string]string {
return func(ds *datasources.DataSource) map[string]string {
decryptedJsonData, err := p.dataSourceService.DecryptedValues(ctx, ds)
if err != nil {
p.logger.Error("Failed to decrypt secure json data", "error", err)
}
return decryptedJsonData
func (p *Provider) decryptSecureJsonDataFn(ctx context.Context) func(ds *datasources.DataSource) (map[string]string, error) {
return func(ds *datasources.DataSource) (map[string]string, error) {
return p.dataSourceService.DecryptedValues(ctx, ds)
}
}

@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/provisioning"
secretsMigrations "github.com/grafana/grafana/pkg/services/secrets/kvstore/migrations"
"github.com/grafana/grafana/pkg/setting"
"golang.org/x/sync/errgroup"
@ -43,9 +44,10 @@ type Options struct {
func New(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleRegistry accesscontrol.RoleRegistry,
provisioningService provisioning.ProvisioningService, backgroundServiceProvider registry.BackgroundServiceRegistry,
usageStatsProvidersRegistry registry.UsageStatsProvidersRegistry, statsCollectorService *statscollector.Service,
secretMigrationService secretsMigrations.SecretMigrationService,
) (*Server, error) {
statsCollectorService.RegisterProviders(usageStatsProvidersRegistry.GetServices())
s, err := newServer(opts, cfg, httpServer, roleRegistry, provisioningService, backgroundServiceProvider)
s, err := newServer(opts, cfg, httpServer, roleRegistry, provisioningService, backgroundServiceProvider, secretMigrationService)
if err != nil {
return nil, err
}
@ -59,25 +61,27 @@ func New(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleRegistr
func newServer(opts Options, cfg *setting.Cfg, httpServer *api.HTTPServer, roleRegistry accesscontrol.RoleRegistry,
provisioningService provisioning.ProvisioningService, backgroundServiceProvider registry.BackgroundServiceRegistry,
secretMigrationService secretsMigrations.SecretMigrationService,
) (*Server, error) {
rootCtx, shutdownFn := context.WithCancel(context.Background())
childRoutines, childCtx := errgroup.WithContext(rootCtx)
s := &Server{
context: childCtx,
childRoutines: childRoutines,
HTTPServer: httpServer,
provisioningService: provisioningService,
roleRegistry: roleRegistry,
shutdownFn: shutdownFn,
shutdownFinished: make(chan struct{}),
log: log.New("server"),
cfg: cfg,
pidFile: opts.PidFile,
version: opts.Version,
commit: opts.Commit,
buildBranch: opts.BuildBranch,
backgroundServices: backgroundServiceProvider.GetServices(),
context: childCtx,
childRoutines: childRoutines,
HTTPServer: httpServer,
provisioningService: provisioningService,
roleRegistry: roleRegistry,
shutdownFn: shutdownFn,
shutdownFinished: make(chan struct{}),
log: log.New("server"),
cfg: cfg,
pidFile: opts.PidFile,
version: opts.Version,
commit: opts.Commit,
buildBranch: opts.BuildBranch,
backgroundServices: backgroundServiceProvider.GetServices(),
secretMigrationService: secretMigrationService,
}
return s, nil
@ -101,9 +105,10 @@ type Server struct {
buildBranch string
backgroundServices []registry.BackgroundService
HTTPServer *api.HTTPServer
roleRegistry accesscontrol.RoleRegistry
provisioningService provisioning.ProvisioningService
HTTPServer *api.HTTPServer
roleRegistry accesscontrol.RoleRegistry
provisioningService provisioning.ProvisioningService
secretMigrationService secretsMigrations.SecretMigrationService
}
// init initializes the server and its services.
@ -128,6 +133,10 @@ func (s *Server) init() error {
return err
}
if err := s.secretMigrationService.Migrate(s.context); err != nil {
return err
}
return s.provisioningService.RunInitProvisioners(s.context)
}

@ -7,9 +7,12 @@ import (
"testing"
"time"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/server/backgroundsvcs"
"github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol"
"github.com/grafana/grafana/pkg/services/secrets/kvstore/migrations"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
)
@ -47,7 +50,11 @@ func (s *testService) IsDisabled() bool {
func testServer(t *testing.T, services ...registry.BackgroundService) *Server {
t.Helper()
s, err := newServer(Options{}, setting.NewCfg(), nil, &ossaccesscontrol.OSSAccessControlService{}, nil, backgroundsvcs.NewBackgroundServiceRegistry(services...))
serverLockService := serverlock.ProvideService(sqlstore.InitTestDB(t))
secretMigrationService := &migrations.SecretMigrationServiceImpl{
ServerLockService: serverLockService,
}
s, err := newServer(Options{}, setting.NewCfg(), nil, &ossaccesscontrol.OSSAccessControlService{}, nil, backgroundsvcs.NewBackgroundServiceRegistry(services...), secretMigrationService)
require.NoError(t, err)
// Required to skip configuration initialization that causes
// DI errors in this test.

@ -91,6 +91,7 @@ import (
"github.com/grafana/grafana/pkg/services/secrets"
secretsDatabase "github.com/grafana/grafana/pkg/services/secrets/database"
secretsStore "github.com/grafana/grafana/pkg/services/secrets/kvstore"
secretsMigrations "github.com/grafana/grafana/pkg/services/secrets/kvstore/migrations"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
secretsMigrator "github.com/grafana/grafana/pkg/services/secrets/migrator"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
@ -293,6 +294,9 @@ var wireBasicSet = wire.NewSet(
publicdashboardsApi.ProvideApi,
userimpl.ProvideService,
orgimpl.ProvideService,
datasourceservice.ProvideDataSourceMigrationService,
secretsMigrations.ProvideSecretMigrationService,
wire.Bind(new(secretsMigrations.SecretMigrationService), new(*secretsMigrations.SecretMigrationServiceImpl)),
)
var wireSet = wire.NewSet(

@ -225,7 +225,7 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt
},
}
cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, true, false)
cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, setting.IsLegacyAlertingEnabled(), false)
if err != nil {
return nil, err
}
@ -243,14 +243,17 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt
OrgID: dto.OrgId,
}
alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo)
if err != nil {
return nil, err
}
// extract/save legacy alerts only if legacy alerting is enabled
if setting.IsLegacyAlertingEnabled() {
alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo)
if err != nil {
return nil, err
}
err = dr.dashboardStore.SaveAlerts(ctx, dash.Id, alerts)
if err != nil {
return nil, err
err = dr.dashboardStore.SaveAlerts(ctx, dash.Id, alerts)
if err != nil {
return nil, err
}
}
if dto.Dashboard.Id == 0 {
@ -284,14 +287,17 @@ func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.C
OrgID: dto.OrgId,
}
alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo)
if err != nil {
return nil, err
}
// extract/save legacy alerts only if legacy alerting is enabled
if setting.IsLegacyAlertingEnabled() {
alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo)
if err != nil {
return nil, err
}
err = dr.dashboardStore.SaveAlerts(ctx, dash.Id, alerts)
if err != nil {
return nil, err
err = dr.dashboardStore.SaveAlerts(ctx, dash.Id, alerts)
if err != nil {
return nil, err
}
}
if dto.Dashboard.Id == 0 {
@ -312,7 +318,7 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar
dto.Dashboard.Data.Set("refresh", setting.MinRefreshInterval)
}
cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, true, !allowUiUpdate)
cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, setting.IsLegacyAlertingEnabled(), !allowUiUpdate)
if err != nil {
return nil, err
}
@ -328,14 +334,17 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar
OrgID: dto.OrgId,
}
alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo)
if err != nil {
return nil, err
}
// extract/save legacy alerts only if legacy alerting is enabled
if setting.IsLegacyAlertingEnabled() {
alerts, err := dr.dashAlertExtractor.GetAlerts(ctx, dashAlertInfo)
if err != nil {
return nil, err
}
err = dr.dashboardStore.SaveAlerts(ctx, dash.Id, alerts)
if err != nil {
return nil, err
err = dr.dashboardStore.SaveAlerts(ctx, dash.Id, alerts)
if err != nil {
return nil, err
}
}
// new dashboard created

@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/xorcare/pointer"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
@ -23,7 +24,9 @@ func TestIntegrationDashboardService(t *testing.T) {
t.Run("Dashboard service tests", func(t *testing.T) {
fakeStore := dashboards.FakeDashboardStore{}
defer fakeStore.AssertExpectations(t)
service := &DashboardServiceImpl{
cfg: setting.NewCfg(),
log: log.New("test.logger"),
dashboardStore: &fakeStore,
dashAlertExtractor: &dummyDashAlertExtractor{},
@ -100,7 +103,6 @@ func TestIntegrationDashboardService(t *testing.T) {
t.Run("Should not return validation error if dashboard is provisioned but UI updates allowed", func(t *testing.T) {
fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
fakeStore.On("SaveDashboard", mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once()
fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
dto.Dashboard = models.NewDashboard("Dash")
dto.Dashboard.SetId(3)
@ -110,6 +112,20 @@ func TestIntegrationDashboardService(t *testing.T) {
})
t.Run("Should return validation error if alert data is invalid", func(t *testing.T) {
origAlertingEnabledSet := setting.AlertingEnabled != nil
origAlertingEnabledVal := false
if origAlertingEnabledSet {
origAlertingEnabledVal = *setting.AlertingEnabled
}
setting.AlertingEnabled = pointer.Bool(true)
t.Cleanup(func() {
if !origAlertingEnabledSet {
setting.AlertingEnabled = nil
} else {
setting.AlertingEnabled = &origAlertingEnabledVal
}
})
fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
fakeStore.On("GetProvisionedDataByDashboardID", mock.Anything).Return(nil, nil).Once()
fakeStore.On("SaveDashboard", mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once()
@ -118,6 +134,7 @@ func TestIntegrationDashboardService(t *testing.T) {
dto.Dashboard = models.NewDashboard("Dash")
dto.User = &models.SignedInUser{UserId: 1}
_, err := service.SaveDashboard(context.Background(), dto, false)
require.Error(t, err)
require.Equal(t, err.Error(), "alert validation error")
})
})
@ -128,7 +145,6 @@ func TestIntegrationDashboardService(t *testing.T) {
t.Run("Should not return validation error if dashboard is provisioned", func(t *testing.T) {
fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once()
fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
dto.Dashboard = models.NewDashboard("Dash")
dto.Dashboard.SetId(3)
@ -140,7 +156,6 @@ func TestIntegrationDashboardService(t *testing.T) {
t.Run("Should override invalid refresh interval if dashboard is provisioned", func(t *testing.T) {
fakeStore.On("ValidateDashboardBeforeSave", mock.Anything, mock.Anything).Return(true, nil).Once()
fakeStore.On("SaveProvisionedDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{Data: simplejson.New()}, nil).Once()
fakeStore.On("SaveAlerts", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
oldRefreshInterval := setting.MinRefreshInterval
setting.MinRefreshInterval = "5m"

@ -18,6 +18,9 @@ type DataSourceService interface {
// GetDataSources gets datasources.
GetDataSources(ctx context.Context, query *GetDataSourcesQuery) error
// GetAllDataSources gets all datasources.
GetAllDataSources(ctx context.Context, query *GetAllDataSourcesQuery) error
// GetDataSourcesByType gets datasources by type.
GetDataSourcesByType(ctx context.Context, query *GetDataSourcesByTypeQuery) error

@ -11,8 +11,9 @@ import (
)
type FakeDataSourceService struct {
lastId int64
DataSources []*datasources.DataSource
lastId int64
DataSources []*datasources.DataSource
SimulatePluginFailure bool
}
var _ datasources.DataSourceService = &FakeDataSourceService{}
@ -41,6 +42,11 @@ func (s *FakeDataSourceService) GetDataSources(ctx context.Context, query *datas
return nil
}
func (s *FakeDataSourceService) GetAllDataSources(ctx context.Context, query *datasources.GetAllDataSourcesQuery) error {
query.Result = s.DataSources
return nil
}
func (s *FakeDataSourceService) GetDataSourcesByType(ctx context.Context, query *datasources.GetDataSourcesByTypeQuery) error {
for _, datasource := range s.DataSources {
typeMatch := query.Type != "" && query.Type == datasource.Type
@ -107,6 +113,9 @@ func (s *FakeDataSourceService) GetHTTPTransport(ctx context.Context, ds *dataso
}
func (s *FakeDataSourceService) DecryptedValues(ctx context.Context, ds *datasources.DataSource) (map[string]string, error) {
if s.SimulatePluginFailure {
return nil, datasources.ErrDatasourceSecretsPluginUserFriendly{Err: "unknown error"}
}
values := make(map[string]string)
return values, nil
}

@ -160,6 +160,10 @@ type GetDataSourcesQuery struct {
Result []*DataSource
}
type GetAllDataSourcesQuery struct {
Result []*DataSource
}
type GetDataSourcesByTypeQuery struct {
Type string
Result []*DataSource

@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@ -32,6 +33,7 @@ type Service struct {
features featuremgmt.FeatureToggles
permissionsService accesscontrol.DatasourcePermissionsService
ac accesscontrol.AccessControl
logger log.Logger
ptc proxyTransportCache
}
@ -61,6 +63,7 @@ func ProvideService(
features: features,
permissionsService: datasourcePermissionsService,
ac: ac,
logger: log.New("datasources"),
}
ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store))
@ -136,6 +139,10 @@ func (s *Service) GetDataSources(ctx context.Context, query *datasources.GetData
return s.SQLStore.GetDataSources(ctx, query)
}
func (s *Service) GetAllDataSources(ctx context.Context, query *datasources.GetAllDataSourcesQuery) error {
return s.SQLStore.GetAllDataSources(ctx, query)
}
func (s *Service) GetDataSourcesByType(ctx context.Context, query *datasources.GetDataSourcesByTypeQuery) error {
return s.SQLStore.GetDataSourcesByType(ctx, query)
}
@ -143,18 +150,21 @@ func (s *Service) GetDataSourcesByType(ctx context.Context, query *datasources.G
func (s *Service) AddDataSource(ctx context.Context, cmd *datasources.AddDataSourceCommand) error {
return s.SQLStore.InTransaction(ctx, func(ctx context.Context) error {
var err error
// this is here for backwards compatibility
cmd.EncryptedSecureJsonData, err = s.SecretsService.EncryptJsonData(ctx, cmd.SecureJsonData, secrets.WithoutScope())
if err != nil {
return err
}
secret, err := json.Marshal(cmd.SecureJsonData)
if err != nil {
return err
cmd.EncryptedSecureJsonData = make(map[string][]byte)
if !s.features.IsEnabled(featuremgmt.FlagDisableSecretsCompatibility) {
cmd.EncryptedSecureJsonData, err = s.SecretsService.EncryptJsonData(ctx, cmd.SecureJsonData, secrets.WithoutScope())
if err != nil {
return err
}
}
cmd.UpdateSecretFn = func() error {
secret, err := json.Marshal(cmd.SecureJsonData)
if err != nil {
return err
}
return s.SecretsStore.Set(ctx, cmd.OrgId, cmd.Name, secretType, string(secret))
}
@ -212,21 +222,22 @@ func (s *Service) UpdateDataSource(ctx context.Context, cmd *datasources.UpdateD
return err
}
secret, err := json.Marshal(cmd.SecureJsonData)
if err != nil {
return err
}
if cmd.OrgId > 0 && cmd.Name != "" {
cmd.UpdateSecretFn = func() error {
secret, err := json.Marshal(cmd.SecureJsonData)
if err != nil {
return err
}
cmd.UpdateSecretFn = func() error {
var secretsErr error
if query.Result.Name != cmd.Name {
secretsErr = s.SecretsStore.Rename(ctx, cmd.OrgId, query.Result.Name, secretType, cmd.Name)
}
if secretsErr != nil {
return secretsErr
}
if query.Result.Name != cmd.Name {
err := s.SecretsStore.Rename(ctx, cmd.OrgId, query.Result.Name, secretType, cmd.Name)
if err != nil {
return err
}
}
return s.SecretsStore.Set(ctx, cmd.OrgId, cmd.Name, secretType, string(secret))
return s.SecretsStore.Set(ctx, cmd.OrgId, cmd.Name, secretType, string(secret))
}
}
return s.SQLStore.UpdateDataSource(ctx, cmd)
@ -295,10 +306,13 @@ func (s *Service) DecryptedValues(ctx context.Context, ds *datasources.DataSourc
if exist {
err = json.Unmarshal([]byte(secret), &decryptedValues)
if err != nil {
s.logger.Debug("failed to unmarshal secret value, using legacy secrets", "err", err)
}
}
if (!exist || err != nil) && len(ds.SecureJsonData) > 0 {
decryptedValues, err = s.MigrateSecrets(ctx, ds)
if !exist || err != nil {
decryptedValues, err = s.decryptLegacySecrets(ctx, ds)
if err != nil {
return nil, err
}
@ -307,7 +321,7 @@ func (s *Service) DecryptedValues(ctx context.Context, ds *datasources.DataSourc
return decryptedValues, nil
}
func (s *Service) MigrateSecrets(ctx context.Context, ds *datasources.DataSource) (map[string]string, error) {
func (s *Service) decryptLegacySecrets(ctx context.Context, ds *datasources.DataSource) (map[string]string, error) {
secureJsonData := make(map[string]string)
for k, v := range ds.SecureJsonData {
decrypted, err := s.SecretsService.Decrypt(ctx, v)
@ -316,14 +330,7 @@ func (s *Service) MigrateSecrets(ctx context.Context, ds *datasources.DataSource
}
secureJsonData[k] = string(decrypted)
}
jsonData, err := json.Marshal(secureJsonData)
if err != nil {
return nil, err
}
err = s.SecretsStore.Set(ctx, ds.OrgId, ds.Name, secretType, string(jsonData))
return secureJsonData, err
return secureJsonData, nil
}
func (s *Service) DecryptedValue(ctx context.Context, ds *datasources.DataSource, key string) (string, bool, error) {
@ -564,10 +571,12 @@ func (s *Service) fillWithSecureJSONData(ctx context.Context, cmd *datasources.U
}
}
// this is here for backwards compatibility
cmd.EncryptedSecureJsonData, err = s.SecretsService.EncryptJsonData(ctx, cmd.SecureJsonData, secrets.WithoutScope())
if err != nil {
return err
cmd.EncryptedSecureJsonData = make(map[string][]byte)
if !s.features.IsEnabled(featuremgmt.FlagDisableSecretsCompatibility) {
cmd.EncryptedSecureJsonData, err = s.SecretsService.EncryptJsonData(ctx, cmd.SecureJsonData, secrets.WithoutScope())
if err != nil {
return err
}
}
return nil

@ -0,0 +1,100 @@
package service
import (
"context"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
const (
// Not set means migration has not happened
secretMigrationStatusKey = "secretMigrationStatus"
// Migration happened with disableSecretCompatibility set to false
compatibleSecretMigrationValue = "compatible"
// Migration happened with disableSecretCompatibility set to true
completeSecretMigrationValue = "complete"
)
type DataSourceSecretMigrationService struct {
dataSourcesService datasources.DataSourceService
kvStore *kvstore.NamespacedKVStore
features featuremgmt.FeatureToggles
}
func ProvideDataSourceMigrationService(
dataSourcesService datasources.DataSourceService,
kvStore kvstore.KVStore,
features featuremgmt.FeatureToggles,
) *DataSourceSecretMigrationService {
return &DataSourceSecretMigrationService{
dataSourcesService: dataSourcesService,
kvStore: kvstore.WithNamespace(kvStore, 0, secretType),
features: features,
}
}
func (s *DataSourceSecretMigrationService) Migrate(ctx context.Context) error {
migrationStatus, _, err := s.kvStore.Get(ctx, secretMigrationStatusKey)
if err != nil {
return err
}
// If this flag is true, delete secrets from the legacy secrets store as they are migrated
disableSecretsCompatibility := s.features.IsEnabled(featuremgmt.FlagDisableSecretsCompatibility)
// If migration hasn't happened, migrate to unified secrets and keep copy in legacy
// If a complete migration happened and now backwards compatibility is enabled, copy secrets back to legacy
needCompatibility := migrationStatus != compatibleSecretMigrationValue && !disableSecretsCompatibility
// If migration hasn't happened, migrate to unified secrets and delete from legacy
// If a compatible migration happened and now compatibility is disabled, delete secrets from legacy
needMigration := migrationStatus != completeSecretMigrationValue && disableSecretsCompatibility
if needCompatibility || needMigration {
query := &datasources.GetAllDataSourcesQuery{}
err := s.dataSourcesService.GetAllDataSources(ctx, query)
if err != nil {
return err
}
for _, ds := range query.Result {
secureJsonData, err := s.dataSourcesService.DecryptedValues(ctx, ds)
if err != nil {
return err
}
// Secrets are set by the update data source function if the SecureJsonData is set in the command
// Secrets are deleted by the update data source function if the disableSecretsCompatibility flag is enabled
err = s.dataSourcesService.UpdateDataSource(ctx, &datasources.UpdateDataSourceCommand{
Id: ds.Id,
OrgId: ds.OrgId,
Uid: ds.Uid,
Name: ds.Name,
JsonData: ds.JsonData,
SecureJsonData: secureJsonData,
// These are needed by the SQL function due to UseBool and MustCols
IsDefault: ds.IsDefault,
BasicAuth: ds.BasicAuth,
WithCredentials: ds.WithCredentials,
ReadOnly: ds.ReadOnly,
User: ds.User,
})
if err != nil {
return err
}
}
if disableSecretsCompatibility {
err = s.kvStore.Set(ctx, secretMigrationStatusKey, completeSecretMigrationValue)
} else {
err = s.kvStore.Set(ctx, secretMigrationStatusKey, compatibleSecretMigrationValue)
}
if err != nil {
return err
}
}
return nil
}

@ -0,0 +1,340 @@
package service
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/infra/kvstore"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
secretsStore "github.com/grafana/grafana/pkg/services/secrets/kvstore"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
)
func SetupTestMigrationService(t *testing.T, sqlStore *sqlstore.SQLStore, kvStore kvstore.KVStore, secretsStore secretsStore.SecretsKVStore, compatibility bool) *DataSourceSecretMigrationService {
t.Helper()
cfg := &setting.Cfg{}
features := featuremgmt.WithFeatures()
if !compatibility {
features = featuremgmt.WithFeatures(featuremgmt.FlagDisableSecretsCompatibility, true)
}
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
dsService := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New().WithDisabled(), acmock.NewMockedPermissionsService())
migService := ProvideDataSourceMigrationService(dsService, kvStore, features)
return migService
}
func TestMigrate(t *testing.T) {
t.Run("should migrate from legacy to unified without compatibility", func(t *testing.T) {
sqlStore := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(sqlStore)
secretsStore := secretsStore.SetupTestService(t)
migService := SetupTestMigrationService(t, sqlStore, kvStore, secretsStore, false)
dataSourceName := "Test"
dataSourceOrg := int64(1)
// Add test data source
err := sqlStore.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
OrgId: dataSourceOrg,
Name: dataSourceName,
Type: datasources.DS_MYSQL,
Access: datasources.DS_ACCESS_DIRECT,
Url: "http://test",
EncryptedSecureJsonData: map[string][]byte{
"password": []byte("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"),
},
})
assert.NoError(t, err)
// Check if the secret json data was added
query := &datasources.GetDataSourceQuery{OrgId: dataSourceOrg, Name: dataSourceName}
err = sqlStore.GetDataSource(context.Background(), query)
assert.NoError(t, err)
assert.NotNil(t, query.Result)
assert.NotEmpty(t, query.Result.SecureJsonData)
// Check if the migration status key is empty
value, exist, err := kvStore.Get(context.Background(), 0, secretType, secretMigrationStatusKey)
assert.NoError(t, err)
assert.Empty(t, value)
assert.False(t, exist)
// Check that the secret is not present on the secret store
value, exist, err = secretsStore.Get(context.Background(), dataSourceOrg, dataSourceName, secretType)
assert.NoError(t, err)
assert.Empty(t, value)
assert.False(t, exist)
// Run the migration
err = migService.Migrate(context.Background())
assert.NoError(t, err)
// Check if the secure json data was deleted
query = &datasources.GetDataSourceQuery{OrgId: dataSourceOrg, Name: dataSourceName}
err = sqlStore.GetDataSource(context.Background(), query)
assert.NoError(t, err)
assert.NotNil(t, query.Result)
assert.Empty(t, query.Result.SecureJsonData)
// Check if the secret was added to the secret store
value, exist, err = secretsStore.Get(context.Background(), dataSourceOrg, dataSourceName, secretType)
assert.NoError(t, err)
assert.NotEmpty(t, value)
assert.True(t, exist)
// Check if the migration status key was set
value, exist, err = kvStore.Get(context.Background(), 0, secretType, secretMigrationStatusKey)
assert.NoError(t, err)
assert.Equal(t, completeSecretMigrationValue, value)
assert.True(t, exist)
})
t.Run("should migrate from legacy to unified with compatibility", func(t *testing.T) {
sqlStore := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(sqlStore)
secretsStore := secretsStore.SetupTestService(t)
migService := SetupTestMigrationService(t, sqlStore, kvStore, secretsStore, true)
dataSourceName := "Test"
dataSourceOrg := int64(1)
// Add test data source
err := sqlStore.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
OrgId: dataSourceOrg,
Name: dataSourceName,
Type: datasources.DS_MYSQL,
Access: datasources.DS_ACCESS_DIRECT,
Url: "http://test",
EncryptedSecureJsonData: map[string][]byte{
"password": []byte("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"),
},
})
assert.NoError(t, err)
// Check if the secret json data was added
query := &datasources.GetDataSourceQuery{OrgId: dataSourceOrg, Name: dataSourceName}
err = sqlStore.GetDataSource(context.Background(), query)
assert.NoError(t, err)
assert.NotNil(t, query.Result)
assert.NotEmpty(t, query.Result.SecureJsonData)
// Check if the migration status key is empty
value, exist, err := kvStore.Get(context.Background(), 0, secretType, secretMigrationStatusKey)
assert.NoError(t, err)
assert.Empty(t, value)
assert.False(t, exist)
// Check that the secret is not present on the secret store
value, exist, err = secretsStore.Get(context.Background(), dataSourceOrg, dataSourceName, secretType)
assert.NoError(t, err)
assert.Empty(t, value)
assert.False(t, exist)
// Run the migration
err = migService.Migrate(context.Background())
assert.NoError(t, err)
// Check if the secure json data was maintained for compatibility
query = &datasources.GetDataSourceQuery{OrgId: dataSourceOrg, Name: dataSourceName}
err = sqlStore.GetDataSource(context.Background(), query)
assert.NoError(t, err)
assert.NotNil(t, query.Result)
assert.NotEmpty(t, query.Result.SecureJsonData)
// Check if the secret was added to the secret store
value, exist, err = secretsStore.Get(context.Background(), dataSourceOrg, dataSourceName, secretType)
assert.NoError(t, err)
assert.NotEmpty(t, value)
assert.True(t, exist)
// Check if the migration status key was set
value, exist, err = kvStore.Get(context.Background(), 0, secretType, secretMigrationStatusKey)
assert.NoError(t, err)
assert.Equal(t, compatibleSecretMigrationValue, value)
assert.True(t, exist)
})
t.Run("should replicate from unified to legacy for compatibility", func(t *testing.T) {
sqlStore := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(sqlStore)
secretsStore := secretsStore.SetupTestService(t)
migService := SetupTestMigrationService(t, sqlStore, kvStore, secretsStore, false)
dataSourceName := "Test"
dataSourceOrg := int64(1)
// Add test data source
err := sqlStore.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
OrgId: dataSourceOrg,
Name: dataSourceName,
Type: datasources.DS_MYSQL,
Access: datasources.DS_ACCESS_DIRECT,
Url: "http://test",
EncryptedSecureJsonData: map[string][]byte{
"password": []byte("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"),
},
})
assert.NoError(t, err)
// Check if the secret json data was added
query := &datasources.GetDataSourceQuery{OrgId: dataSourceOrg, Name: dataSourceName}
err = sqlStore.GetDataSource(context.Background(), query)
assert.NoError(t, err)
assert.NotNil(t, query.Result)
assert.NotEmpty(t, query.Result.SecureJsonData)
// Check if the migration status key is empty
value, exist, err := kvStore.Get(context.Background(), 0, secretType, secretMigrationStatusKey)
assert.NoError(t, err)
assert.Empty(t, value)
assert.False(t, exist)
// Check that the secret is not present on the secret store
value, exist, err = secretsStore.Get(context.Background(), dataSourceOrg, dataSourceName, secretType)
assert.NoError(t, err)
assert.Empty(t, value)
assert.False(t, exist)
// Run the migration without compatibility
err = migService.Migrate(context.Background())
assert.NoError(t, err)
// Check if the secure json data was deleted
query = &datasources.GetDataSourceQuery{OrgId: dataSourceOrg, Name: dataSourceName}
err = sqlStore.GetDataSource(context.Background(), query)
assert.NoError(t, err)
assert.NotNil(t, query.Result)
assert.Empty(t, query.Result.SecureJsonData)
// Check if the secret was added to the secret store
value, exist, err = secretsStore.Get(context.Background(), dataSourceOrg, dataSourceName, secretType)
assert.NoError(t, err)
assert.NotEmpty(t, value)
assert.True(t, exist)
// Check if the migration status key was set
value, exist, err = kvStore.Get(context.Background(), 0, secretType, secretMigrationStatusKey)
assert.NoError(t, err)
assert.Equal(t, completeSecretMigrationValue, value)
assert.True(t, exist)
// Run the migration with compatibility
migService = SetupTestMigrationService(t, sqlStore, kvStore, secretsStore, true)
err = migService.Migrate(context.Background())
assert.NoError(t, err)
// Check if the secure json data was re-added for compatibility
query = &datasources.GetDataSourceQuery{OrgId: dataSourceOrg, Name: dataSourceName}
err = sqlStore.GetDataSource(context.Background(), query)
assert.NoError(t, err)
assert.NotNil(t, query.Result)
assert.NotEmpty(t, query.Result.SecureJsonData)
// Check if the secret was added to the secret store
value, exist, err = secretsStore.Get(context.Background(), dataSourceOrg, dataSourceName, secretType)
assert.NoError(t, err)
assert.NotEmpty(t, value)
assert.True(t, exist)
// Check if the migration status key was set
value, exist, err = kvStore.Get(context.Background(), 0, secretType, secretMigrationStatusKey)
assert.NoError(t, err)
assert.Equal(t, compatibleSecretMigrationValue, value)
assert.True(t, exist)
})
t.Run("should delete from legacy to remove compatibility", func(t *testing.T) {
sqlStore := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(sqlStore)
secretsStore := secretsStore.SetupTestService(t)
migService := SetupTestMigrationService(t, sqlStore, kvStore, secretsStore, true)
dataSourceName := "Test"
dataSourceOrg := int64(1)
// Add test data source
err := sqlStore.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
OrgId: dataSourceOrg,
Name: dataSourceName,
Type: datasources.DS_MYSQL,
Access: datasources.DS_ACCESS_DIRECT,
Url: "http://test",
EncryptedSecureJsonData: map[string][]byte{
"password": []byte("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"),
},
})
assert.NoError(t, err)
// Check if the secret json data was added
query := &datasources.GetDataSourceQuery{OrgId: dataSourceOrg, Name: dataSourceName}
err = sqlStore.GetDataSource(context.Background(), query)
assert.NoError(t, err)
assert.NotNil(t, query.Result)
assert.NotEmpty(t, query.Result.SecureJsonData)
// Check if the migration status key is empty
value, exist, err := kvStore.Get(context.Background(), 0, secretType, secretMigrationStatusKey)
assert.NoError(t, err)
assert.Empty(t, value)
assert.False(t, exist)
// Check that the secret is not present on the secret store
value, exist, err = secretsStore.Get(context.Background(), dataSourceOrg, dataSourceName, secretType)
assert.NoError(t, err)
assert.Empty(t, value)
assert.False(t, exist)
// Run the migration with compatibility
err = migService.Migrate(context.Background())
assert.NoError(t, err)
// Check if the secure json data was maintained for compatibility
query = &datasources.GetDataSourceQuery{OrgId: dataSourceOrg, Name: dataSourceName}
err = sqlStore.GetDataSource(context.Background(), query)
assert.NoError(t, err)
assert.NotNil(t, query.Result)
assert.NotEmpty(t, query.Result.SecureJsonData)
// Check if the secret was added to the secret store
value, exist, err = secretsStore.Get(context.Background(), dataSourceOrg, dataSourceName, secretType)
assert.NoError(t, err)
assert.NotEmpty(t, value)
assert.True(t, exist)
// Check if the migration status key was set
value, exist, err = kvStore.Get(context.Background(), 0, secretType, secretMigrationStatusKey)
assert.NoError(t, err)
assert.Equal(t, compatibleSecretMigrationValue, value)
assert.True(t, exist)
// Run the migration without compatibility
migService = SetupTestMigrationService(t, sqlStore, kvStore, secretsStore, false)
err = migService.Migrate(context.Background())
assert.NoError(t, err)
// Check if the secure json data was deleted
query = &datasources.GetDataSourceQuery{OrgId: dataSourceOrg, Name: dataSourceName}
err = sqlStore.GetDataSource(context.Background(), query)
assert.NoError(t, err)
assert.NotNil(t, query.Result)
assert.Empty(t, query.Result.SecureJsonData)
// Check if the secret was added to the secret store
value, exist, err = secretsStore.Get(context.Background(), dataSourceOrg, dataSourceName, secretType)
assert.NoError(t, err)
assert.NotEmpty(t, value)
assert.True(t, exist)
// Check if the migration status key was set
value, exist, err = kvStore.Get(context.Background(), 0, secretType, secretMigrationStatusKey)
assert.NoError(t, err)
assert.Equal(t, completeSecretMigrationValue, value)
assert.True(t, exist)
})
}

@ -26,6 +26,7 @@ type commitHelper struct {
users map[int64]*userInfo
stopRequested bool
broadcast func(path string)
exporter string // key for the current exporter
}
type commitBody struct {

@ -0,0 +1,51 @@
package export
import (
"encoding/json"
"fmt"
"path"
"time"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
func exportAlerts(helper *commitHelper, job *gitExportJob) error {
alertDir := path.Join(helper.orgDir, "alerts")
return job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error {
type ruleResult struct {
Title string `xorm:"title"`
UID string `xorm:"uid"`
NamespaceUID string `xorm:"namespace_uid"`
RuleGroup string `xorm:"rule_group"`
Condition json.RawMessage `xorm:"data"`
DashboardUID string `xorm:"dashboard_uid"`
PanelID int64 `xorm:"panel_id"`
Updated time.Time `xorm:"updated" json:"-"`
}
rows := make([]*ruleResult, 0)
sess.Table("alert_rule").Where("org_id = ?", helper.orgID)
err := sess.Find(&rows)
if err != nil {
return err
}
for _, row := range rows {
err = helper.add(commitOptions{
body: []commitBody{{
body: prettyJSON(row),
fpath: path.Join(alertDir, row.UID) + ".json", // must be JSON files
}},
comment: fmt.Sprintf("Alert: %s", row.Title),
when: row.Updated,
})
if err != nil {
return err
}
}
return err
})
}

@ -105,24 +105,26 @@ func exportAnnotations(helper *commitHelper, job *gitExportJob) error {
}
}
frame := data.NewFrame("", f_ID, f_DashboardID, f_PanelID, f_Epoch, f_EpochEnd, f_Text, f_Tags)
js, err := jsoniter.ConfigCompatibleWithStandardLibrary.MarshalIndent(frame, "", " ")
if err != nil {
return err
}
if f_ID.Len() > 0 {
frame := data.NewFrame("", f_ID, f_DashboardID, f_PanelID, f_Epoch, f_EpochEnd, f_Text, f_Tags)
js, err := jsoniter.ConfigCompatibleWithStandardLibrary.MarshalIndent(frame, "", " ")
if err != nil {
return err
}
err = helper.add(commitOptions{
body: []commitBody{
{
fpath: filepath.Join(helper.orgDir, "annotations", "annotations.json"),
body: js, // TODO, pretty?
err = helper.add(commitOptions{
body: []commitBody{
{
fpath: filepath.Join(helper.orgDir, "annotations", "annotations.json"),
body: js, // TODO, pretty?
},
},
},
when: time.Now(),
comment: "Exported annotations",
})
if err != nil {
return err
when: time.Now(),
comment: "Exported annotations",
})
if err != nil {
return err
}
}
return err
})

@ -1,9 +1,12 @@
package export
import (
"fmt"
"path"
"strconv"
"strings"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
@ -13,58 +16,119 @@ func dumpAuthTables(helper *commitHelper, job *gitExportJob) error {
comment: "auth tables dump",
}
tables := []string{
"user", // joined with "org_user" to get the role
"user_role",
"builtin_role",
"api_key",
"team", "team_group", "team_role", "team_member",
"role",
"temp_user",
"user_auth_token", // no org_id... is it temporary?
"permission",
type statsTables struct {
table string
sql string
converters []sqlutil.Converter
drop []string
}
for _, table := range tables {
switch table {
case "permission":
sess.Table(table).
Join("left", "role", "permission.role_id = role.id").
Cols("permission.*").
Where("org_id = ?", helper.orgID).
Asc("permission.id")
case "user":
sess.Table(table).
Join("inner", "org_user", "user.id = org_user.user_id").
Cols("user.*", "org_user.role").
Where("org_user.org_id = ?", helper.orgID).
Asc("user.id")
case "user_auth_token":
sess.Table(table).
Join("inner", "org_user", "user_auth_token.id = org_user.user_id").
Cols("user_auth_token.*").
Where("org_user.org_id = ?", helper.orgID).
Asc("user_auth_token.id")
default:
sess.Table(table).Where("org_id = ?", helper.orgID).Asc("id")
dump := []statsTables{
{
table: "user",
sql: `
SELECT user.*, org_user.role
FROM user
JOIN org_user ON user.id = org_user.user_id
WHERE org_user.org_id =` + strconv.FormatInt(helper.orgID, 10),
converters: []sqlutil.Converter{{Dynamic: true}},
drop: []string{
"id", "version",
"password", // UMMMMM... for now
"org_id",
},
},
{
table: "user_role",
sql: `
SELECT * FROM user_role
WHERE org_id =` + strconv.FormatInt(helper.orgID, 10),
},
{
table: "builtin_role",
sql: `
SELECT * FROM builtin_role
WHERE org_id =` + strconv.FormatInt(helper.orgID, 10),
},
{
table: "api_key",
sql: `
SELECT * FROM api_key
WHERE org_id =` + strconv.FormatInt(helper.orgID, 10),
},
{
table: "permission",
sql: `
SELECT permission.*
FROM permission
JOIN role ON permission.role_id = role.id
WHERE org_id =` + strconv.FormatInt(helper.orgID, 10),
},
{
table: "user_auth_token",
sql: `
SELECT user_auth_token.*
FROM user_auth_token
JOIN org_user ON user_auth_token.id = org_user.user_id
WHERE org_user.org_id =` + strconv.FormatInt(helper.orgID, 10),
},
{table: "team"},
{table: "team_group"},
{table: "team_role"},
{table: "team_member"},
{table: "temp_user"},
{table: "role"},
}
for _, auth := range dump {
if auth.sql == "" {
auth.sql = `
SELECT * FROM ` + auth.table + `
WHERE org_id =` + strconv.FormatInt(helper.orgID, 10)
}
if auth.converters == nil {
auth.converters = []sqlutil.Converter{{Dynamic: true}}
}
if auth.drop == nil {
auth.drop = []string{
"id",
"org_id",
}
}
raw, err := sess.QueryInterface()
rows, err := sess.DB().QueryContext(helper.ctx, auth.sql)
if err != nil {
return fmt.Errorf("unable to read: %s // %s", table, err.Error())
}
if len(raw) < 1 {
continue // don't write empty files
if strings.HasPrefix(err.Error(), "no such table") {
continue
}
return err
}
frame, err := queryResultToDataFrame(raw, frameOpts{
skip: []string{"org_id", "version", "help_flags1", "theme"},
})
frame, err := sqlutil.FrameFromRows(rows.Rows, -1, auth.converters...)
if err != nil {
return err
}
frame.Name = table
if frame.Fields[0].Len() < 1 {
continue // do not write empty structures
}
if len(auth.drop) > 0 {
lookup := make(map[string]bool, len(auth.drop))
for _, v := range auth.drop {
lookup[v] = true
}
fields := make([]*data.Field, 0, len(frame.Fields))
for _, f := range frame.Fields {
if lookup[f.Name] {
continue
}
fields = append(fields, f)
}
frame.Fields = fields
}
frame.Name = auth.table
commit.body = append(commit.body, commitBody{
fpath: path.Join(helper.orgDir, "auth", "sql.dump", table+".json"),
fpath: path.Join(helper.orgDir, "auth", "sql.dump", auth.table+".json"),
frame: frame,
})
}

@ -151,7 +151,7 @@ func exportDashboards(helper *commitHelper, job *gitExportJob) error {
if job.cfg.KeepHistory {
sess.Table("dashboard_version").
Join("INNER", "dashboard", "dashboard.id = dashboard_version.dashboard_id").
Where("org_id = ?", job.orgID).
Where("org_id = ?", helper.orgID).
Cols("dashboard.id",
"dashboard_version.version",
"dashboard_version.created",
@ -161,7 +161,7 @@ func exportDashboards(helper *commitHelper, job *gitExportJob) error {
Asc("dashboard_version.created")
} else {
sess.Table("dashboard").
Where("org_id = ?", job.orgID).
Where("org_id = ?", helper.orgID).
Cols("id",
"version",
"created",

@ -0,0 +1,80 @@
package export
import (
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"time"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
func exportDashboardThumbnails(helper *commitHelper, job *gitExportJob) error {
alias := make(map[string]string, 100)
aliasLookup, err := ioutil.ReadFile(filepath.Join(helper.orgDir, "root-alias.json"))
if err != nil {
return fmt.Errorf("missing dashboard alias files (must export dashboards first)")
}
err = json.Unmarshal(aliasLookup, &alias)
if err != nil {
return err
}
return job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error {
type dashboardThumb struct {
UID string `xorm:"uid"`
Image []byte `xorm:"image"`
Theme string `xorm:"theme"`
Kind string `xorm:"kind"`
MimeType string `xorm:"mime_type"`
Updated time.Time
}
rows := make([]*dashboardThumb, 0)
// SELECT uid,image,theme,kind,mime_type,dashboard_thumbnail.updated
// FROM dashboard_thumbnail
// JOIN dashboard ON dashboard.id = dashboard_thumbnail.dashboard_id
// WHERE org_id = 2; //dashboard.uid = '2VVbg06nz';
sess.Table("dashboard_thumbnail").
Join("INNER", "dashboard", "dashboard.id = dashboard_thumbnail.dashboard_id").
Cols("uid", "image", "theme", "kind", "mime_type", "dashboard_thumbnail.updated").
Where("dashboard.org_id = ?", helper.orgID)
err := sess.Find(&rows)
if err != nil {
if strings.HasPrefix(err.Error(), "no such table") {
return nil
}
return err
}
// Process all folders
for _, row := range rows {
p, ok := alias[row.UID]
if !ok {
p = "uid/" + row.UID
} else {
p = strings.TrimSuffix(p, "-dash.json")
}
err := helper.add(commitOptions{
body: []commitBody{
{
fpath: filepath.Join(helper.orgDir, "thumbs", fmt.Sprintf("%s.thumb-%s.png", p, row.Theme)),
body: row.Image,
},
},
when: row.Updated,
comment: "Thumbnail",
})
if err != nil {
return err
}
}
return nil
})
}

@ -10,7 +10,7 @@ import (
func exportDataSources(helper *commitHelper, job *gitExportJob) error {
cmd := &datasources.GetDataSourcesQuery{
OrgId: job.orgID,
OrgId: helper.orgID,
}
err := job.sql.GetDataSources(helper.ctx, cmd)
if err != nil {

@ -3,6 +3,7 @@ package export
import (
"fmt"
"path"
"strings"
"time"
"github.com/grafana/grafana/pkg/services/sqlstore"
@ -25,6 +26,9 @@ func exportLive(helper *commitHelper, job *gitExportJob) error {
err := sess.Find(&rows)
if err != nil {
if strings.HasPrefix(err.Error(), "no such table") {
return nil
}
return err
}

@ -0,0 +1,53 @@
package export
import (
"encoding/json"
"fmt"
"path"
"strings"
"time"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
func exportPlugins(helper *commitHelper, job *gitExportJob) error {
return job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error {
type pResult struct {
PluginID string `xorm:"plugin_id" json:"-"`
Enabled string `xorm:"enabled" json:"enabled"`
Pinned string `xorm:"pinned" json:"pinned"`
JSONData json.RawMessage `xorm:"json_data" json:"json_data,omitempty"`
// TODO: secure!!!!
PluginVersion string `xorm:"plugin_version" json:"version"`
Created time.Time `xorm:"created" json:"created"`
Updated time.Time `xorm:"updated" json:"updated"`
}
rows := make([]*pResult, 0)
sess.Table("plugin_setting").Where("org_id = ?", helper.orgID)
err := sess.Find(&rows)
if err != nil {
if strings.HasPrefix(err.Error(), "no such table") {
return nil
}
return err
}
for _, row := range rows {
err = helper.add(commitOptions{
body: []commitBody{{
body: prettyJSON(row),
fpath: path.Join(helper.orgDir, "plugins", row.PluginID, "settings.json"),
}},
comment: fmt.Sprintf("Plugin: %s", row.PluginID),
when: row.Updated,
})
if err != nil {
return err
}
}
return err
})
}

@ -10,7 +10,7 @@ import (
func exportSnapshots(helper *commitHelper, job *gitExportJob) error {
cmd := &dashboardsnapshots.GetDashboardSnapshotsQuery{
OrgId: job.orgID,
OrgId: helper.orgID,
Limit: 500000,
SignedInUser: nil,
}

@ -10,7 +10,7 @@ import (
func exportSystemPlaylists(helper *commitHelper, job *gitExportJob) error {
cmd := &models.GetPlaylistsQuery{
OrgId: job.orgID,
OrgId: helper.orgID,
Limit: 500000,
}
err := job.sql.SearchPlaylists(helper.ctx, cmd)

@ -0,0 +1,72 @@
package export
import (
"fmt"
"path/filepath"
"time"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
func exportSystemShortURL(helper *commitHelper, job *gitExportJob) error {
mostRecent := int64(0)
lastSeen := make(map[string]int64, 50)
dir := filepath.Join(helper.orgDir, "system", "short_url")
err := job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error {
type urlResult struct {
UID string `xorm:"uid" json:"-"`
Path string `xorm:"path" json:"path"`
CreatedBy int64 `xorm:"created_by" json:"-"`
CreatedAt time.Time `xorm:"created_at" json:"-"`
LastSeenAt int64 `xorm:"last_seen_at" json:"-"`
}
rows := make([]*urlResult, 0)
sess.Table("short_url").Where("org_id = ?", helper.orgID)
err := sess.Find(&rows)
if err != nil {
return err
}
for _, row := range rows {
if row.LastSeenAt > 0 {
lastSeen[row.UID] = row.LastSeenAt
if mostRecent < row.LastSeenAt {
mostRecent = row.LastSeenAt
}
}
err := helper.add(commitOptions{
body: []commitBody{
{
fpath: filepath.Join(dir, "uid", fmt.Sprintf("%s.json", row.UID)),
body: prettyJSON(row),
},
},
when: row.CreatedAt,
comment: "short URL",
userID: row.CreatedBy,
})
if err != nil {
return err
}
}
return err
})
if err != nil || len(lastSeen) < 1 {
return err
}
return helper.add(commitOptions{
body: []commitBody{
{
fpath: filepath.Join(dir, "last_seen_at.json"),
body: prettyJSON(lastSeen),
},
},
when: time.UnixMilli(mostRecent),
comment: "short URL",
})
}

@ -0,0 +1,85 @@
package export
import (
"path"
"strconv"
"strings"
"github.com/grafana/grafana-plugin-sdk-go/data/sqlutil"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
func exportUsage(helper *commitHelper, job *gitExportJob) error {
return job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error {
commit := commitOptions{
comment: "usage stats",
}
type statsTables struct {
table string
sql string
converters []sqlutil.Converter
}
dump := []statsTables{
{
table: "data_source_usage_by_day",
sql: `SELECT day,uid,queries,errors,load_duration_ms
FROM data_source_usage_by_day
JOIN data_source ON data_source.id = data_source_usage_by_day.data_source_id
WHERE org_id =` + strconv.FormatInt(helper.orgID, 10),
converters: []sqlutil.Converter{{Dynamic: true}},
},
{
table: "dashboard_usage_by_day",
sql: `SELECT uid,day,views,queries,errors,load_duration
FROM dashboard_usage_by_day
JOIN dashboard ON dashboard_usage_by_day.dashboard_id = dashboard.id
WHERE org_id =` + strconv.FormatInt(helper.orgID, 10),
converters: []sqlutil.Converter{{Dynamic: true}},
},
{
table: "dashboard_usage_sums",
sql: `SELECT uid,
views_last_1_days,
views_last_7_days,
views_last_30_days,
views_total,
queries_last_1_days,
queries_last_7_days,
queries_last_30_days,
queries_total,
errors_last_1_days,
errors_last_7_days,
errors_last_30_days,
errors_total
FROM dashboard_usage_sums
JOIN dashboard ON dashboard_usage_sums.dashboard_id = dashboard.id
WHERE org_id =` + strconv.FormatInt(helper.orgID, 10),
converters: []sqlutil.Converter{{Dynamic: true}},
},
}
for _, usage := range dump {
rows, err := sess.DB().QueryContext(helper.ctx, usage.sql)
if err != nil {
if strings.HasPrefix(err.Error(), "no such table") {
continue
}
return err
}
frame, err := sqlutil.FrameFromRows(rows.Rows, -1, usage.converters...)
if err != nil {
return err
}
frame.Name = usage.table
commit.body = append(commit.body, commitBody{
fpath: path.Join(helper.orgDir, "usage", usage.table+".json"),
frame: frame,
})
}
return helper.add(commit)
})
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save