Merge branch 'main' into grafana-git-ui-sync

# Conflicts:
#	.betterer.results
#	public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx
This commit is contained in:
Clarity-89 committed 2025-02-06 08:04:13 +02:00
commit 8d041bb8ba
143 files changed
+2872 -926

No files matched your search

+2
View File
@@ -762,6 +762,8 @@ embed.go @grafana/grafana-as-code
/.github/workflows/close-milestone.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/release-pr.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/release-comms.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/migrate-prs.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/create-next-release-branch.yml @grafana/grafana-developer-enablement-squad
/.github/workflows/codeowners-validator.yml @tolzhabayev
/.github/workflows/codeql-analysis.yml @DanCech
/.github/workflows/commands.yml @torkelo
+5 -11
View File
@@ -10,24 +10,18 @@ jobs:
if: github.repository == 'grafana/grafana'
runs-on: ubuntu-latest
steps:
- name: Checkout Actions
- name: Checkout
uses: actions/checkout@v4
with:
repository: "grafana/grafana-github-actions"
path: ./actions
ref: main
- name: Install Actions
run: npm install --production --prefix ./actions
- name: "Generate token"
id: generate_token
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92
with:
app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }}
private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }}
- run: git config --global user.email '132647405+grafana-delivery-bot[bot]@users.noreply.github.com'
- run: git config --global user.name 'grafana-delivery-bot[bot]'
- run: git remote set-url origin "https://grafana-delivery-bot:${{ steps.generate_token.outputs.token }}@github.com/grafana/grafana.git"
- name: Run backport
uses: ./actions/backport
uses: grafana/grafana-github-actions-go/backport@main
with:
metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}}
token: ${{ steps.generate_token.outputs.token }}
labelsToAdd: "backport"
title: "[{{base}}] {{originalTitle}}"
@@ -0,0 +1,43 @@
name: Create next release branch
on:
workflow_call:
inputs:
ownerRepo:
type: string
description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana')
required: true
source:
description: The release branch to increment (eg providing `release-11.2.3` will result in `release-11.2.4` being created)
type: string
required: true
secrets:
token:
required: true
outputs:
branch:
description: The new branch that was created
value: ${{ jobs.main.outputs.branch }}
workflow_dispatch:
inputs:
ownerRepo:
description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana')
source:
description: The release branch to increment (eg providing `release-11.2.3` will result in `release-11.2.4` being created)
type: string
required: true
secrets:
token:
required: true
jobs:
main:
runs-on: ubuntu-latest
outputs:
branch: ${{ steps.branch.outputs.branch }}
steps:
- name: Create release branch
id: branch
uses: grafana/grafana-github-actions-go/bump-release@main
with:
ownerRepo: ${{ inputs.ownerRepo }}
source: ${{ inputs.source }}
token: ${{ secrets.token }}
+50
View File
@@ -0,0 +1,50 @@
name: Migrate open PRs
# Migrate open PRs from a superseded release branch to the current release branch and notify authors
on:
workflow_call:
inputs:
from:
description: 'The base branch to check for open PRs'
required: true
type: string
to:
description: 'The base branch to migrate open PRs to'
required: true
type: string
ownerRepo:
description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana')
required: true
type: string
secrets:
token:
required: true
workflow_dispatch:
inputs:
from:
description: 'The base branch to check for open PRs'
required: true
type: string
to:
description: 'The base branch to migrate open PRs to'
required: true
type: string
ownerRepo:
description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana')
required: true
type: string
secrets:
token:
required: true
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Migrate PRs
uses: grafana/grafana-github-actions-go/migrate-open-prs@main
with:
token: ${{ secrets.token }}
ownerRepo: ${{ inputs.ownerRepo }}
from: ${{ inputs.from }}
to: ${{ inputs.to }}
binary_release_tag: 'dev'
@@ -3,7 +3,7 @@ name: publish-technical-documentation-release
on:
push:
branches:
- v[0-9]+.[0-9]+.x
- release-v[0-9]+.[0-9]+.[0-9]+
tags:
- v[0-9]+.[0-9]+.[0-9]+
paths:
@@ -23,7 +23,7 @@ jobs:
- uses: grafana/writers-toolkit/publish-technical-documentation-release@publish-technical-documentation-release/v2
with:
release_tag_regexp: "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
release_branch_regexp: "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.x$"
release_branch_with_patch_regexp: "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
release_branch_regexp: "^release-(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
release_branch_with_patch_regexp: "^release-(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
website_directory: content/docs/grafana
version_suffix: ""
+50
View File
@@ -27,10 +27,18 @@ jobs:
name: Setup and establish latest
outputs:
version: ${{ steps.output.outputs.version }}
release_branch: ${{ steps.output.outputs.release_branch }}
dry_run: ${{ steps.output.outputs.dry_run }}
latest: ${{ steps.output.outputs.latest }}
token: ${{ steps.output.outputs.token }}
runs-on: ubuntu-latest
steps:
- name: "Generate token"
id: generate_token
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92
with:
app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }}
private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }}
# The github-release action expects a `LATEST` value of a string of either '1' or '0'
- if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
@@ -49,9 +57,51 @@ jobs:
echo "latest: $LATEST"
echo "version: $VERSION"
echo "release_branch=$(echo $VERSION | sed -s 's/^v/release-/g')" >> "$GITHUB_OUTPUT"
echo "token=${{ steps.generate_token.outputs.token }}" >> "$GITHUB_OUTPUT"
echo "dry_run=$DRY_RUN" >> "$GITHUB_OUTPUT"
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
create_next_release_branch_grafana:
name: Create next release branch (Grafana)
needs: setup
uses: ./.github/workflows/create-next-release-branch.yml
with:
ownerRepo: 'grafana/grafana'
source: ${{ needs.setup.outputs.release_branch }}
secrets:
token: ${{ needs.setup.outputs.token }}
create_next_release_branch_enterprise:
name: Create next release branch (Grafana Enterprise)
needs: setup
uses: ./.github/workflows/create-next-release-branch.yml
with:
ownerRepo: 'grafana/grafana'
source: ${{ needs.setup.outputs.release_branch }}
secrets:
token: ${{ needs.setup.outputs.token }}
migrate_prs_grafana:
needs:
- setup
- create_next_release_branch_grafana
uses: ./.github/workflows/migrate-prs.yml
with:
ownerRepo: 'grafana/grafana'
from: ${{ needs.setup.outputs.release_branch }}
to: ${{ needs.create_next_release_branch_grafana.outputs.branch }}
secrets:
token: ${{ needs.setup.outputs.token }}
migrate_prs_enterprise:
needs:
- setup
- create_next_release_branch_enterprise
uses: ./.github/workflows/migrate-prs.yml
with:
ownerRepo: 'grafana/grafana-enterprise'
from: ${{ needs.setup.outputs.release_branch }}
to: ${{ needs.create_next_release_branch_enterprise.outputs.branch }}
secrets:
token: ${{ needs.setup.outputs.token }}
post_changelog_on_forum:
needs: setup
uses: ./.github/workflows/community-release.yml
+13 -7
View File
@@ -4,7 +4,7 @@
# Please refrain from including any processes that do not result in code changes in this workflow. Instead, they should
# either be triggered in the release promotion process or in the release comms process (that is triggered by merging
# this PR).
name: Complete a Grafana release
name: Grafana Release PR
on:
workflow_dispatch:
inputs:
@@ -19,7 +19,7 @@ on:
target:
required: true
type: string
description: The base branch that these changes are being merged into
description: The release branch pattern (eg v9.5.x) that these changes are being merged into
backport:
required: false
type: string
@@ -61,10 +61,17 @@ jobs:
with:
app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }}
private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }}
- name: Get release branch
id: branch
uses: grafana/grafana-github-actions-go/latest-release-branch@main
with:
token: ${{ steps.generate_token.outputs.token }}
ownerRepo: 'grafana/grafana'
pattern: ${{ inputs.target }}
- name: Checkout Grafana
uses: actions/checkout@v4
with:
ref: ${{ inputs.target }}
ref: ${{ steps.branch.outputs.branch }}
fetch-depth: 0
fetch-tags: true
- name: Checkout Grafana (main)
@@ -124,7 +131,6 @@ jobs:
rm -f CHANGELOG.part changelog_items.md
git diff CHANGELOG.md
- name: "Prettify CHANGELOG.md"
run: npx prettier --write CHANGELOG.md
- name: Commit CHANGELOG.md changes
@@ -137,7 +143,7 @@ jobs:
- name: Add package.json changes
run: |
git add package.json lerna.json yarn.lock packages public
git add package.json lerna.json yarn.lock packages public e2e/test-plugins
git commit -m "Update version to ${{ inputs.version }}"
- name: Git push
@@ -151,7 +157,7 @@ jobs:
$( [ "x${{ inputs.latest }}" == "xtrue" ] && printf %s '-l "release/latest"') \
-l "no-changelog" \
--dry-run=${{ inputs.dry_run }} \
-B "${{ inputs.target }}" \
-B "${{ steps.branch.outputs.branch }}" \
--title "Release: ${{ inputs.version }}" \
--body "These code changes must be merged after a release is complete"
env:
@@ -165,7 +171,7 @@ jobs:
-l "product-approved" \
-l "no-changelog" \
--dry-run=${{ inputs.dry_run }} \
-B "${{ inputs.target }}" \
-B "${{ steps.branch.outputs.branch }}" \
--title "Release: ${{ inputs.version }}" \
--body "These code changes must be merged after a release is complete"
env:
@@ -6,9 +6,9 @@ import (
"github.com/grafana/grafana/apps/advisor/pkg/app/checks/plugincheck"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
@@ -20,7 +20,7 @@ type CheckService interface {
type Service struct {
datasourceSvc datasources.DataSourceService
pluginStore pluginstore.Store
pluginContextProvider datasource.PluginContextWrapper
pluginContextProvider *plugincontext.Provider
pluginClient plugins.Client
pluginRepo repo.Service
pluginPreinstall plugininstaller.Preinstall
@@ -28,7 +28,7 @@ type Service struct {
}
func ProvideService(datasourceSvc datasources.DataSourceService, pluginStore pluginstore.Store,
pluginContextProvider datasource.PluginContextWrapper, pluginClient plugins.Client,
pluginContextProvider *plugincontext.Provider, pluginClient plugins.Client,
pluginRepo repo.Service, pluginPreinstall plugininstaller.Preinstall, managedPlugins managedplugins.Manager) *Service {
return &Service{
datasourceSvc: datasourceSvc,
@@ -7,8 +7,8 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/util"
@@ -18,7 +18,7 @@ import (
func New(
datasourceSvc datasources.DataSourceService,
pluginStore pluginstore.Store,
pluginContextProvider datasource.PluginContextWrapper,
pluginContextProvider pluginContextProvider,
pluginClient plugins.Client,
) checks.Check {
return &check{
@@ -32,7 +32,7 @@ func New(
type check struct {
DatasourceSvc datasources.DataSourceService
PluginStore pluginstore.Store
PluginContextProvider datasource.PluginContextWrapper
PluginContextProvider pluginContextProvider
PluginClient plugins.Client
}
@@ -62,11 +62,11 @@ func (c *check) Run(ctx context.Context, obj *advisor.CheckSpec) (*advisor.Check
}
// Health check execution
pCtx, err := c.PluginContextProvider.PluginContextForDataSource(ctx, &backend.DataSourceInstanceSettings{
Type: ds.Type,
UID: ds.UID,
APIVersion: ds.APIVersion,
})
requester, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
pCtx, err := c.PluginContextProvider.GetWithDataSource(ctx, ds.Type, requester, ds)
if err != nil {
klog.ErrorS(err, "Error creating plugin context", "datasource", ds.Name)
continue
@@ -96,3 +96,7 @@ func (c *check) Run(ctx context.Context, obj *advisor.CheckSpec) (*advisor.Check
Errors: dsErrs,
}, nil
}
type pluginContextProvider interface {
GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error)
}
@@ -6,9 +6,10 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
advisor "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/registry/apis/datasource"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/user"
"github.com/stretchr/testify/assert"
)
@@ -29,7 +30,8 @@ func TestCheck_Run(t *testing.T) {
PluginClient: mockPluginClient,
}
report, err := check.Run(context.Background(), &advisor.CheckSpec{})
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
report, err := check.Run(ctx, &advisor.CheckSpec{})
assert.NoError(t, err)
assert.Equal(t, int64(2), report.Count)
@@ -51,7 +53,8 @@ func TestCheck_Run(t *testing.T) {
PluginClient: mockPluginClient,
}
report, err := check.Run(context.Background(), &advisor.CheckSpec{})
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
report, err := check.Run(ctx, &advisor.CheckSpec{})
assert.NoError(t, err)
assert.Equal(t, int64(1), report.Count)
@@ -74,7 +77,8 @@ func TestCheck_Run(t *testing.T) {
PluginClient: mockPluginClient,
}
report, err := check.Run(context.Background(), &advisor.CheckSpec{})
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{})
report, err := check.Run(ctx, &advisor.CheckSpec{})
assert.NoError(t, err)
assert.Equal(t, int64(1), report.Count)
@@ -94,12 +98,10 @@ func (m *MockDatasourceSvc) GetAllDataSources(ctx context.Context, query *dataso
}
type MockPluginContextProvider struct {
datasource.PluginContextWrapper
pCtx backend.PluginContext
}
func (m *MockPluginContextProvider) PluginContextForDataSource(ctx context.Context, datasourceSettings *backend.DataSourceInstanceSettings) (backend.PluginContext, error) {
func (m *MockPluginContextProvider) GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error) {
return m.pCtx, nil
}
+3
View File
@@ -361,6 +361,9 @@ disable_brute_force_login_protection = false
# max number of failed login attempts before user gets locked
brute_force_login_protection_max_attempts = 5
# disable protection against brute force login attempts by IP address
disable_ip_address_login_protection = true
# set to true if you host Grafana behind HTTPS. default is false.
cookie_secure = false
+3
View File
@@ -360,6 +360,9 @@
# max number of failed login attempts before user gets locked
;brute_force_login_protection_max_attempts = 5
# disable protection against brute force login attempts by IP address
; disable_ip_address_login_protection = true
# set to true if you host Grafana behind HTTPS. default is false.
;cookie_secure = false
@@ -19,11 +19,6 @@ title: Create and manage reports
description: Generate and share PDF reports from your Grafana dashboards
weight: 600
refs:
change-ui-theme:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/organization-preferences/#change-grafana-ui-theme
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/organization-preferences/#change-grafana-ui-theme
grafana-enterprise:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/
@@ -11,6 +11,12 @@ menuTitle: Settings
title: Reporting settings
description: Manage organizational Reporting settings
weight: 700
refs:
change-ui-theme:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/organization-preferences/#change-grafana-ui-theme
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/organization-preferences/#change-grafana-ui-theme
---
# Reporting settings
@@ -139,7 +139,6 @@ The **412** status code is used for explaining that you cannot create the dashbo
There can be different reasons for this:
- The dashboard has been changed by someone else, `status=version-mismatch`
- A dashboard with the same name in the folder already exists, `status=name-exists`
- A dashboard with the same uid already exists, `status=name-exists`
- The dashboard belongs to plugin `<plugin title>`, `status=plugin-dashboard`
@@ -156,8 +155,6 @@ Content-Length: 97
}
```
In case of title already exists the `status` property will be `name-exists`.
## Get dashboard by uid
`GET /api/dashboards/uid/:uid`
@@ -301,6 +301,10 @@ Path to the certificate file (if `protocol` is set to `https` or `h2`).
Path to the certificate key file (if `protocol` is set to `https` or `h2`).
#### `cert_pass`
Optional. Password to decrypt encrypted certificates.
#### `certs_watch_interval`
Controls whether `cert_key` and `cert_file` are periodically watched for changes.
@@ -696,6 +700,10 @@ An existing user's account is unable to login for five minutes if all login atte
Configure how many login attempts a user can have within a five minute window before their account is locked.
Default is `5`.
#### `disable_ip_address_login_protection`
Set to `true` to disable [brute force login protection by IP address](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#account-lockout). Default is `true`. Anyone from the IP address will be unable to login for 5 minutes if all login attempts are spent within a 5 minute window.
#### `cookie_secure`
Set to `true` if you host Grafana behind HTTPS. Default is `false`.
@@ -25,6 +25,7 @@ The following table shows all supported authentication methods and the features
| [Auth Proxy]({{< relref "./auth-proxy" >}}) | no | yes | yes | no | yes | no | N/A | no | N/A | N/A |
| [Azure AD OAuth]({{< relref "./azuread" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes |
| [Basic auth]({{< relref "./grafana" >}}) | yes | N/A | yes | yes | N/A | N/A | N/A | N/A | N/A | N/A |
| [Passwordless auth]({{< relref "./passwordless" >}}) | yes | N/A | yes | yes | N/A | N/A | N/A | N/A | N/A | N/A |
| [Generic OAuth]({{< relref "./generic-oauth" >}}) | yes | yes | yes | yes | yes | no | N/A | yes | yes | yes |
| [GitHub OAuth]({{< relref "./github" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes |
| [GitLab OAuth]({{< relref "./gitlab" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes |
@@ -0,0 +1,46 @@
---
description: Learn how to configure passwordless authentication with magic links in Grafana
labels:
products:
- enterprise
- oss
menuTitle: Passwordless
title: Configure passwordless authentication with magic links
weight: 200
---
# Configure passwordless authentication with magic links
Passwordless authentication lets Grafana users authenticate with a magic link or one-time password (OTP) sent via email.
## Enable passwordless authentication
{{< docs/experimental product="passwordless authentication" featureFlag="passwordlessMagicLinkAuthentication" >}}
To enable passwordless authentication, use the following configuration:
```bash
[auth.passwordless]
enabled = true
```
## Code expiration
By default, the one-time password (OTP) sent to a user's email is valid for 20 minutes. Use the `code_expiration` option to change the duration that the OTP is valid.
```bash
[auth.passwordless]
enabled = true
code_expiration = 20m
```
## Enable SMTP server
The SMTP server must be enabled so that Grafana can send emails.
The following configuration enables the SMTP server.
For more information on configuring the SMTP server, refer to [SMTP](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#smtp).
```bash
[smtp]
enabled = true
```
@@ -244,6 +244,8 @@ To configure Grafana HTTPS and restart Grafana, complete the following steps.
> **Note**: The standard port for SSL traffic is 443, which you can use instead of Grafana's default port 3000. This change might require additional operating system privileges or configuration to bind to lower-numbered privileged ports.
1. Optional. From Grafana v11.2, edit the `cert_pass` configuration option with the decryption password if you are using encrypted certificates.
1. [Restart the Grafana server]({{< relref "./start-restart-grafana#linux" >}}) using `systemd`, `init.d`, or the binary as appropriate for your environment.
## Troubleshooting
+2 -2
View File
@@ -32,10 +32,10 @@ func checkNilRequester(r Requester) bool {
const serviceName = "service"
// WithServiceIdentitiy sets creates an identity representing the service itself in provided org and store it in context.
// WithServiceIdentity sets creates an identity representing the service itself in provided org and store it in context.
// This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with
// static permissions so it can be used in legacy code paths.
func WithServiceIdentitiy(ctx context.Context, orgID int64) (context.Context, Requester) {
func WithServiceIdentity(ctx context.Context, orgID int64) (context.Context, Requester) {
r := &StaticRequester{
Type: types.TypeAccessPolicy,
Name: serviceName,
@@ -5,11 +5,12 @@ import "strconv"
type SchemaVersionMigrationFunc func(map[string]interface{}) error
const (
MINIUM_VERSION = 39
MINIUM_VERSION = 38
LATEST_VERSION = 40
)
var Migrations = map[int]SchemaVersionMigrationFunc{
39: V39,
40: V40,
}
@@ -55,3 +55,21 @@ func TestGetSchemaVersion(t *testing.T) {
})
}
}
type migrationTestCase struct {
name string
input map[string]interface{}
expected map[string]interface{}
}
func runMigrationTests(t *testing.T, testCases []migrationTestCase, migrationFunc schemaversion.SchemaVersionMigrationFunc) {
t.Helper()
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
err := migrationFunc(tt.input)
require.NoError(t, err)
require.Equal(t, tt.expected, tt.input)
})
}
}
@@ -0,0 +1,60 @@
package schemaversion
// V39 updates the configuration of the Timeseries to table transformation
// to support multiple options per query
func V39(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(39)
panels, ok := dashboard["panels"].([]interface{})
if !ok {
return nil
}
for _, panel := range panels {
p, ok := panel.(map[string]interface{})
if !ok {
continue
}
transformations, ok := p["transformations"].([]interface{})
if !ok {
continue
}
for _, transformation := range transformations {
t, ok := transformation.(map[string]interface{})
if !ok {
continue
}
// If we run into a timeSeriesTable transformation
// and it doesn't have undefined options then we migrate
if t["id"] != "timeSeriesTable" {
continue
}
options, ok := t["options"].(map[string]interface{})
if !ok {
continue
}
refIdStats, ok := options["refIdToStat"].(map[string]interface{})
if !ok {
continue
}
// For each {refIdtoStat} record which maps refId to a statistic
// we add that to the stat property of the new
// RefIdTransformerOptions interface which includes multiple settings
transformationOptions := make(map[string]interface{})
for refId, stat := range refIdStats {
transformationOptions[refId] = map[string]interface{}{"stat": stat}
}
// Update the options
t["options"] = transformationOptions
}
}
return nil
}
@@ -0,0 +1,111 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
)
func TestV39(t *testing.T) {
tests := []migrationTestCase{
{
name: "no transformations",
input: map[string]interface{}{
"schemaVersion": 38,
"title": "Test Dashboard",
"panels": []interface{}{
map[string]interface{}{
"title": "Panel 1",
},
},
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 39,
"panels": []interface{}{
map[string]interface{}{
"title": "Panel 1",
},
},
},
},
{
name: "timeSeriesTable transformation with refIdToStat",
input: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"A": "mean",
"B": "max",
},
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 39,
"panels": []interface{}{
map[string]interface{}{
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"A": map[string]interface{}{
"stat": "mean",
},
"B": map[string]interface{}{
"stat": "max",
},
},
},
},
},
},
},
},
{
name: "non-timeSeriesTable transformation is not modified",
input: map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"transformations": []interface{}{
map[string]interface{}{
"id": "otherTransform",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"A": "mean",
},
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 39,
"panels": []interface{}{
map[string]interface{}{
"transformations": []interface{}{
map[string]interface{}{
"id": "otherTransform",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"A": "mean",
},
},
},
},
},
},
},
},
}
runMigrationTests(t, tests, schemaversion.V39)
}
@@ -4,7 +4,6 @@ import (
"testing"
"github.com/grafana/grafana/pkg/apis/dashboard/migration/schemaversion"
"github.com/stretchr/testify/require"
)
func TestV40(t *testing.T) {
@@ -50,21 +49,3 @@ func TestV40(t *testing.T) {
runMigrationTests(t, tests, schemaversion.V40)
}
type migrationTestCase struct {
name string
input map[string]interface{}
expected map[string]interface{}
}
func runMigrationTests(t *testing.T, testCases []migrationTestCase, migrationFunc schemaversion.SchemaVersionMigrationFunc) {
t.Helper()
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
err := migrationFunc(tt.input)
require.NoError(t, err)
require.Equal(t, tt.expected, tt.input)
})
}
}
@@ -0,0 +1,153 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"transformations": [
{
"id": "timeSeriesTable",
"options": {
"refIdToStat": {
"A": "mean",
"B": "max"
}
}
}
],
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.0-81438",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"queryType": "randomWalk",
"refId": "A"
},
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"queryType": "randomWalk",
"refId": "B"
}
],
"title": "Panel Title",
"type": "timeseries"
}
],
"preload": false,
"refresh": true,
"schemaVersion": 38,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "utc",
"title": "New dashboard",
"version": 0,
"weekStart": ""
}
@@ -0,0 +1,155 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.0-81438",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"queryType": "randomWalk",
"refId": "A"
},
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"queryType": "randomWalk",
"refId": "B"
}
],
"title": "Panel Title",
"transformations": [
{
"id": "timeSeriesTable",
"options": {
"A": {
"stat": "mean"
},
"B": {
"stat": "max"
}
}
}
],
"type": "timeseries"
}
],
"preload": false,
"refresh": true,
"schemaVersion": 39,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "utc",
"title": "New dashboard",
"version": 0,
"weekStart": ""
}
@@ -0,0 +1,155 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.0-81438",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"queryType": "randomWalk",
"refId": "A"
},
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"queryType": "randomWalk",
"refId": "B"
}
],
"title": "Panel Title",
"transformations": [
{
"id": "timeSeriesTable",
"options": {
"A": {
"stat": "mean"
},
"B": {
"stat": "max"
}
}
}
],
"type": "timeseries"
}
],
"preload": false,
"refresh": "",
"schemaVersion": 40,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "utc",
"title": "New dashboard",
"version": 0,
"weekStart": ""
}
@@ -0,0 +1,134 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations \u0026 Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.0-81438",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"queryType": "randomWalk",
"refId": "A"
}
],
"title": "Panel Title",
"type": "timeseries"
}
],
"preload": false,
"refresh": true,
"schemaVersion": 39,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "utc",
"title": "New dashboard",
"version": 0,
"weekStart": ""
}
+3 -1
View File
@@ -34,6 +34,8 @@ func Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(in *common.Unstruct
func Convert_v1alpha1_DashboardSpec_To_v0alpha1_Unstructured(in *DashboardSpec, out *common.Unstructured, s conversion.Scope) error {
*out = in.Unstructured
out.Object["title"] = in.Title
if in.Title != "" {
out.Object["title"] = in.Title
}
return nil
}
+3 -1
View File
@@ -34,6 +34,8 @@ func Convert_v0alpha1_Unstructured_To_v2alpha1_DashboardSpec(in *common.Unstruct
func Convert_v2alpha1_DashboardSpec_To_v0alpha1_Unstructured(in *DashboardSpec, out *common.Unstructured, s conversion.Scope) error {
*out = in.Unstructured
out.Object["title"] = in.Title
if in.Title != "" {
out.Object["title"] = in.Title
}
return nil
}
+1 -1
View File
@@ -39,7 +39,7 @@ func WithRequester(handler http.Handler) http.Handler {
} else if ok && info.GetName() == user.APIServerUser ||
slices.Contains(info.GetGroups(), user.SystemPrivilegedGroup) {
// For system:apiserver we use the identity of the service itself
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
ctx, _ = identity.WithServiceIdentity(ctx, 1)
req = req.WithContext(ctx)
} else {
klog.V(5).Info("unable to map the k8s user to grafana requester", "user", info)
+37 -6
View File
@@ -159,7 +159,7 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, cfg *SyncerConfig) (b
startSync := time.Now()
ctx = klog.NewContext(ctx, log)
ctx, _ = identity.WithServiceIdentitiy(ctx, 0)
ctx, _ = identity.WithServiceIdentity(ctx, 0)
ctx = request.WithNamespace(ctx, cfg.RequestInfo.Namespace)
ctx = request.WithRequestInfo(ctx, cfg.RequestInfo)
@@ -179,7 +179,9 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, cfg *SyncerConfig) (b
log.Info("got items from unified storage", "items", len(storageList))
legacyList, err := getList(ctx, cfg.LegacyStorage, &metainternalversion.ListOptions{})
legacyList, err := getList(ctx, cfg.LegacyStorage, &metainternalversion.ListOptions{
Limit: int64(cfg.DataSyncerRecordsLimit),
})
if err != nil {
log.Error(err, "unable to extract list from legacy storage")
return
@@ -295,10 +297,39 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, cfg *SyncerConfig) (b
}
func getList(ctx context.Context, obj rest.Lister, listOptions *metainternalversion.ListOptions) ([]runtime.Object, error) {
ll, err := obj.List(ctx, listOptions)
if err != nil {
return nil, err
var allItems []runtime.Object
for {
if int64(len(allItems)) >= listOptions.Limit {
return nil, fmt.Errorf("list has more than %d records. Aborting sync", listOptions.Limit)
}
ll, err := obj.List(ctx, listOptions)
if err != nil {
return nil, err
}
items, err := meta.ExtractList(ll)
if err != nil {
return nil, err
}
allItems = append(allItems, items...)
// Get continue token from the list metadata.
listMeta, err := meta.ListAccessor(ll)
if err != nil {
return nil, err
}
// If no continue token, we're done paginating.
if listMeta.GetContinue() == "" {
break
}
// Set continue token for next page.
listOptions.Continue = listMeta.GetContinue()
}
return meta.ExtractList(ll)
return allItems, nil
}
@@ -70,7 +70,7 @@ func folderTreeCollector(folderService folder.Service) legacyTupleCollector {
ctx, span := tracer.Start(ctx, "accesscontrol.migrator.folderTreeCollector")
defer span.End()
ctx, ident := identity.WithServiceIdentitiy(ctx, orgID)
ctx, ident := identity.WithServiceIdentity(ctx, orgID)
q := folder.GetFoldersQuery{
OrgID: orgID,
@@ -118,7 +118,7 @@ func ProvideDashboardPermissions(
ctx, span := tracer.Start(ctx, "accesscontrol.ossaccesscontrol.ProvideDashboardPermissions.ResourceValidator")
defer span.End()
ctx, _ = identity.WithServiceIdentitiy(ctx, orgID)
ctx, _ = identity.WithServiceIdentity(ctx, orgID)
dashboard, err := getDashboard(ctx, orgID, resourceID)
if err != nil {
return err
@@ -131,7 +131,7 @@ func ProvideDashboardPermissions(
return nil
},
InheritedScopesSolver: func(ctx context.Context, orgID int64, resourceID string) ([]string, error) {
ctx, _ = identity.WithServiceIdentitiy(ctx, orgID)
ctx, _ = identity.WithServiceIdentity(ctx, orgID)
dashboard, err := getDashboard(ctx, orgID, resourceID)
if err != nil {
return nil, err
@@ -99,7 +99,7 @@ func ProvideFolderPermissions(
ctx, span := tracer.Start(ctx, "accesscontrol.ossaccesscontrol.ProvideFolderPermissions.ResourceValidator")
defer span.End()
ctx, ident := identity.WithServiceIdentitiy(ctx, orgID)
ctx, ident := identity.WithServiceIdentity(ctx, orgID)
_, err := folderService.Get(ctx, &folder.GetFolderQuery{
UID: &resourceID,
OrgID: orgID,
@@ -124,7 +124,7 @@ func ProvideFolderPermissions(
return nil
},
InheritedScopesSolver: func(ctx context.Context, orgID int64, resourceID string) ([]string, error) {
ctx, _ = identity.WithServiceIdentitiy(ctx, orgID)
ctx, _ = identity.WithServiceIdentity(ctx, orgID)
return dashboards.GetInheritedScopes(ctx, orgID, resourceID, folderService)
},
Assignments: resourcepermissions.Assignments{
+11 -2
View File
@@ -39,6 +39,14 @@ func (c *Password) AuthenticatePassword(ctx context.Context, r *authn.Request, u
return nil, errPasswordAuthFailed.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked")
}
ok, err = c.loginAttempts.ValidateIPAddress(ctx, web.RemoteAddr(r.HTTPRequest))
if err != nil {
return nil, err
}
if !ok {
return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for IP address - login for IP address temporarily blocked")
}
if len(password) == 0 {
return nil, errPasswordAuthFailed.Errorf("no password provided")
}
@@ -56,8 +64,9 @@ func (c *Password) AuthenticatePassword(ctx context.Context, r *authn.Request, u
return identity, nil
}
if errors.Is(clientErrs, errInvalidPassword) {
_ = c.loginAttempts.Add(ctx, username, web.RemoteAddr(r.HTTPRequest))
err = c.loginAttempts.Add(ctx, username, web.RemoteAddr(r.HTTPRequest))
if err != nil {
return nil, err
}
return nil, errPasswordAuthFailed.Errorf("failed to authenticate identity: %w", clientErrs)
+18 -8
View File
@@ -2,6 +2,8 @@ package clients
import (
"context"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
@@ -18,7 +20,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
desc string
username string
password string
req *authn.Request
blockLogin bool
clients []authn.PasswordClient
expectedErr error
@@ -30,7 +31,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
desc: "should success when password client return identity",
username: "test",
password: "test",
req: &authn.Request{},
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "1", Type: claims.TypeUser}}},
expectedIdentity: &authn.Identity{ID: "1", Type: claims.TypeUser},
},
@@ -38,7 +38,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
desc: "should success when found in second client",
username: "test",
password: "test",
req: &authn.Request{},
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}, authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "2", Type: claims.TypeUser}}},
expectedIdentity: &authn.Identity{ID: "2", Type: claims.TypeUser},
},
@@ -46,14 +45,12 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
desc: "should fail for empty password",
username: "test",
password: "",
req: &authn.Request{},
expectedErr: errPasswordAuthFailed,
},
{
desc: "should if login is blocked by to many attempts",
username: "test",
password: "test",
req: &authn.Request{},
blockLogin: true,
expectedErr: errPasswordAuthFailed,
},
@@ -61,7 +58,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
desc: "should fail when not found in any clients",
username: "test",
password: "test",
req: &authn.Request{},
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}, authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}},
expectedErr: errPasswordAuthFailed,
},
@@ -70,8 +66,22 @@ func TestPassword_AuthenticatePassword(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvidePassword(loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin}, tt.clients...)
identity, err := c.AuthenticatePassword(context.Background(), tt.req, tt.username, tt.password)
r := &authn.Request{
OrgID: 12345,
HTTPRequest: &http.Request{
Method: "GET",
URL: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/api/v1/resource",
},
Header: http.Header{
"Content-Type": []string{"application/json"},
"User-Agent": []string{"MyApp/1.0"},
},
},
}
identity, err := c.AuthenticatePassword(context.Background(), r, tt.username, tt.password)
if tt.expectedErr != nil {
assert.ErrorIs(t, err, tt.expectedErr)
assert.Nil(t, identity)
+9 -1
View File
@@ -105,7 +105,6 @@ func (c *Passwordless) RedirectURL(ctx context.Context, r *authn.Request) (*auth
return nil, err
}
// TODO: add IP address validation
ok, err := c.loginAttempts.Validate(ctx, form.Email)
if err != nil {
return nil, err
@@ -115,6 +114,15 @@ func (c *Passwordless) RedirectURL(ctx context.Context, r *authn.Request) (*auth
return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked")
}
ok, err = c.loginAttempts.ValidateIPAddress(ctx, web.RemoteAddr(r.HTTPRequest))
if err != nil {
return nil, err
}
if !ok {
return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for IP address - login for IP address temporarily blocked")
}
err = c.loginAttempts.Add(ctx, form.Email, web.RemoteAddr(r.HTTPRequest))
if err != nil {
return nil, err
@@ -13,6 +13,7 @@ import (
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -45,6 +46,7 @@ import (
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
@@ -154,7 +156,7 @@ func (dr *DashboardServiceImpl) Count(ctx context.Context, scopeParams *quota.Sc
total := int64(0)
for _, org := range orgs {
ctx, _ := identity.WithServiceIdentitiy(ctx, org.ID)
ctx, _ := identity.WithServiceIdentity(ctx, org.ID)
orgDashboards, err := dr.CountDashboardsInOrg(ctx, org.ID)
if err != nil {
return nil, err
@@ -231,7 +233,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardData(ctx context.Context,
for _, org := range orgs {
func(orgID int64) {
g.Go(func() error {
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
ProvisionedRepo: name,
OrgId: orgID,
})
@@ -272,7 +274,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(ctx con
}
for _, org := range orgs {
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
OrgId: org.ID,
DashboardIds: []int64{dashboardID},
})
@@ -299,7 +301,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx co
return nil, nil
}
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{
res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
OrgId: orgID,
DashboardUIDs: []string{dashboardUID},
})
@@ -556,9 +558,9 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.
}
for _, org := range orgs {
ctx, _ := identity.WithServiceIdentitiy(ctx, org.ID)
ctx, _ := identity.WithServiceIdentity(ctx, org.ID)
// find all dashboards in the org that have a file repo set that is not in the given readers list
foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{
foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
ProvisionedReposNotIn: cmd.ReaderNames,
OrgId: org.ID,
})
@@ -657,7 +659,7 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt
dto.Dashboard.Data.Set("refresh", dr.cfg.MinRefreshInterval)
}
ctx, ident := identity.WithServiceIdentitiy(ctx, dto.OrgID)
ctx, ident := identity.WithServiceIdentity(ctx, dto.OrgID)
dto.User = ident
cmd, err := dr.BuildSaveDashboardCommand(ctx, dto, false)
@@ -697,7 +699,7 @@ func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.C
ctx, span := tracer.Start(ctx, "dashboards.service.SaveFolderForProvisionedDashboards")
defer span.End()
ctx, ident := identity.WithServiceIdentitiy(ctx, dto.OrgID)
ctx, ident := identity.WithServiceIdentity(ctx, dto.OrgID)
dto.SignedInUser = ident
f, err := dr.folderService.Create(ctx, dto)
@@ -842,7 +844,7 @@ func (dr *DashboardServiceImpl) GetDashboardByPublicUid(ctx context.Context, das
// DeleteProvisionedDashboard removes dashboard from the DB even if it is provisioned.
func (dr *DashboardServiceImpl) DeleteProvisionedDashboard(ctx context.Context, dashboardId int64, orgId int64) error {
ctx, _ = identity.WithServiceIdentitiy(ctx, orgId)
ctx, _ = identity.WithServiceIdentity(ctx, orgId)
return dr.deleteDashboard(ctx, dashboardId, "", orgId, false)
}
@@ -924,7 +926,7 @@ func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashbo
}
for _, org := range orgs {
ctx, _ = identity.WithServiceIdentitiy(ctx, org.ID)
ctx, _ = identity.WithServiceIdentity(ctx, org.ID)
dash, err := dr.getDashboardThroughK8s(ctx, &dashboards.GetDashboardQuery{OrgID: org.ID, ID: dashboardId})
if err != nil {
// if we can't find it in this org, try the next one
@@ -989,10 +991,8 @@ func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *
}
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
// nolint:staticcheck
inFolder := dash.FolderID > 0
var permissions []accesscontrol.SetResourcePermissionCommand
var permissions []accesscontrol.SetResourcePermissionCommand
if !provisioned && dto.User.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) {
userID, err := dto.User.GetInternalID()
if err != nil {
@@ -1004,7 +1004,7 @@ func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *
}
}
if !inFolder {
if dash.FolderUID == "" {
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
@@ -1025,9 +1025,7 @@ func (dr *DashboardServiceImpl) setDefaultFolderPermissions(ctx context.Context,
return
}
inFolder := f.ParentUID != ""
var permissions []accesscontrol.SetResourcePermissionCommand
if !provisioned && cmd.SignedInUser.IsIdentityType(claims.TypeUser) {
userID, err := cmd.SignedInUser.GetInternalID()
if err != nil {
@@ -1039,7 +1037,7 @@ func (dr *DashboardServiceImpl) setDefaultFolderPermissions(ctx context.Context,
}
}
if !inFolder {
if f.ParentUID == "" {
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
@@ -1439,7 +1437,12 @@ func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64
}
// We need a list of dashboard uids inside the folder to delete related public dashboards
dashes, err := dr.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: u, FolderUIDs: folderUIDs, OrgId: orgID})
dashes, err := dr.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
SignedInUser: u,
FolderUIDs: folderUIDs,
OrgId: orgID,
Type: searchstore.TypeDashboard,
})
if err != nil {
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
}
@@ -1732,8 +1735,12 @@ type dashboardProvisioningWithUID struct {
DashboardUID string
}
func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]*dashboardProvisioningWithUID, error) {
ctx, _ = identity.WithServiceIdentitiy(ctx, query.OrgId)
func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]*dashboardProvisioningWithUID, error) {
if query == nil {
return nil, errors.New("query cannot be nil")
}
ctx, _ = identity.WithServiceIdentity(ctx, query.OrgId)
if query.ProvisionedRepo != "" {
query.ProvisionedRepo = provisionedFileNameWithPrefix(query.ProvisionedRepo)
@@ -1747,7 +1754,9 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex
query.ProvisionedReposNotIn = repos
}
searchResults, err := dr.searchDashboardsThroughK8sRaw(ctx, &query)
query.Type = searchstore.TypeDashboard
searchResults, err := dr.searchDashboardsThroughK8sRaw(ctx, query)
if err != nil {
return nil, err
}
@@ -1810,6 +1819,11 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex
}
func (dr *DashboardServiceImpl) searchDashboardsThroughK8s(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]*dashboards.Dashboard, error) {
if query == nil {
return nil, errors.New("query cannot be nil")
}
query.Type = searchstore.TypeDashboard
response, err := dr.searchDashboardsThroughK8sRaw(ctx, query)
if err != nil {
return nil, err
@@ -1899,6 +1899,134 @@ func TestCountInFolders(t *testing.T) {
})
}
func TestSearchDashboardsThroughK8sRaw(t *testing.T) {
ctx := context.Background()
k8sCliMock := new(client.MockK8sHandler)
service := &DashboardServiceImpl{k8sclient: k8sCliMock}
query := &dashboards.FindPersistedDashboardsQuery{
OrgId: 1,
}
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
{
Name: "title",
Type: resource.ResourceTableColumnDefinition_STRING,
},
{
Name: "folder",
Type: resource.ResourceTableColumnDefinition_STRING,
},
},
Rows: []*resource.ResourceTableRow{
{
Key: &resource.ResourceKey{
Name: "uid",
Resource: "dashboard",
},
Cells: [][]byte{
[]byte("Dashboard 1"),
[]byte("folder1"),
},
},
},
},
TotalHits: 1,
}, nil)
res, err := service.searchDashboardsThroughK8s(ctx, query)
require.NoError(t, err)
assert.Equal(t, []*dashboards.Dashboard{
{
UID: "uid",
OrgID: 1,
FolderUID: "folder1",
Title: "Dashboard 1",
Slug: "dashboard-1", // should be slugified
},
}, res)
assert.Equal(t, "dash-db", query.Type) // query type should be added
}
func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) {
ctx := context.Background()
k8sCliMock := new(client.MockK8sHandler)
service := &DashboardServiceImpl{k8sclient: k8sCliMock}
query := &dashboards.FindPersistedDashboardsQuery{
OrgId: 1,
}
dashboardUnstructuredProvisioned := unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"annotations": map[string]any{
utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test",
utils.AnnoKeyRepoHash: "hash",
utils.AnnoKeyRepoPath: "path/to/file",
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z",
},
},
"spec": map[string]any{},
}}
dashboardUnstructuredNotProvisioned := unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{
"name": "uid2",
},
"spec": map[string]any{},
}}
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
{
Name: "title",
Type: resource.ResourceTableColumnDefinition_STRING,
},
{
Name: "folder",
Type: resource.ResourceTableColumnDefinition_STRING,
},
},
Rows: []*resource.ResourceTableRow{
{
Key: &resource.ResourceKey{
Name: "uid",
Resource: "dashboard",
},
Cells: [][]byte{
[]byte("Dashboard 1"),
[]byte("folder1"),
},
},
{
Key: &resource.ResourceKey{
Name: "uid2",
Resource: "dashboard",
},
Cells: [][]byte{
[]byte("Dashboard 2"),
[]byte("folder2"),
},
},
},
},
TotalHits: 1,
}, nil)
k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructuredProvisioned, nil).Once()
k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructuredNotProvisioned, nil).Once()
res, err := service.searchProvisionedDashboardsThroughK8s(ctx, query)
require.NoError(t, err)
assert.Equal(t, []*dashboardProvisioningWithUID{
{
DashboardUID: "uid",
DashboardProvisioning: dashboards.DashboardProvisioning{
Name: "test",
ExternalID: "path/to/file",
CheckSum: "hash",
Updated: 1735689600,
},
},
}, res) // only should return the one provisioned dashboard
assert.Equal(t, "dash-db", query.Type) // query type should be added as dashboards only
}
func TestLegacySaveCommandToUnstructured(t *testing.T) {
namespace := "test-namespace"
t.Run("successfully converts save command to unstructured", func(t *testing.T) {
+7 -1
View File
@@ -37,6 +37,7 @@ import (
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/services/supportbundles"
"github.com/grafana/grafana/pkg/services/user"
@@ -1028,7 +1029,12 @@ func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderComm
// if dashboard restore is on we don't delete public dashboards, the hard delete will take care of it later
if !s.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) {
// We need a list of dashboard uids inside the folder to delete related public dashboards
dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: cmd.SignedInUser, FolderUIDs: folderUIDs, OrgId: cmd.OrgID})
dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
SignedInUser: cmd.SignedInUser,
FolderUIDs: folderUIDs,
OrgId: cmd.OrgID,
Type: searchstore.TypeDashboard,
})
if err != nil {
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
}
@@ -31,6 +31,7 @@ import (
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@@ -737,7 +738,12 @@ func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFol
}
}
} else {
dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: cmd.SignedInUser, FolderUIDs: folders, OrgId: cmd.OrgID})
dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
SignedInUser: cmd.SignedInUser,
FolderUIDs: folders,
OrgId: cmd.OrgID,
Type: searchstore.TypeDashboard,
})
if err != nil {
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
}
+3 -1
View File
@@ -21,6 +21,7 @@ import (
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -250,8 +251,9 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn
}
// then find the dashboards that were supposed to be connected to this element
_, requester := identity.WithServiceIdentitiy(c, signedInUser.GetOrgID())
_, requester := identity.WithServiceIdentity(c, signedInUser.GetOrgID())
dashs, err := l.dashboardsService.FindDashboards(c, &dashboards.FindPersistedDashboardsQuery{
Type: searchstore.TypeDashboard,
OrgId: signedInUser.GetOrgID(),
DashboardIds: dashboardIDs,
SignedInUser: requester, // a user may be able to delete a library element but not read all dashboards. We still need to run this check, so we don't allow deleting elements if dashboards are connected
+4 -1
View File
@@ -6,10 +6,13 @@ import (
type Service interface {
// Add adds a new login attempt record for provided username
Add(ctx context.Context, username, IPAddress string) error
Add(ctx context.Context, username, ipAddress string) error
// Validate checks if username has to many login attempts inside a window.
// Will return true if provided username do not have too many attempts.
Validate(ctx context.Context, username string) (bool, error)
// Validate checks if IP address has to many login attempts inside a window.
// Will return true if provided IP address do not have too many attempts.
ValidateIPAddress(ctx context.Context, ipAddress string) (bool, error)
// Reset resets all login attempts attached to username
Reset(ctx context.Context, username string) error
}
@@ -53,7 +53,7 @@ func (s *Service) Add(ctx context.Context, username, IPAddress string) error {
_, err := s.store.CreateLoginAttempt(ctx, CreateLoginAttemptCommand{
Username: strings.ToLower(username),
IpAddress: IPAddress,
IPAddress: IPAddress,
})
return err
}
@@ -84,6 +84,28 @@ func (s *Service) Validate(ctx context.Context, username string) (bool, error) {
return true, nil
}
func (s *Service) ValidateIPAddress(ctx context.Context, IPAddress string) (bool, error) {
if s.cfg.DisableIPAddressLoginProtection {
return true, nil
}
loginAttemptCountQuery := GetIPLoginAttemptCountQuery{
IPAddress: IPAddress,
Since: time.Now().Add(-loginAttemptsWindow),
}
count, err := s.store.GetIPLoginAttemptCount(ctx, loginAttemptCountQuery)
if err != nil {
return false, err
}
if count >= s.cfg.BruteForceLoginProtectionMaxAttempts {
return false, nil
}
return true, nil
}
func (s *Service) cleanup(ctx context.Context) {
err := s.lock.LockAndExecute(ctx, "delete old login attempts", time.Minute*10, func(context.Context) {
cmd := DeleteOldLoginAttemptsCommand{
@@ -22,40 +22,40 @@ func TestService_Validate(t *testing.T) {
expectedErr error
}{
{
name: "When brute force protection enabled and user login attempt count is less than max",
name: "Should be valid when brute force protection enabled and user login attempt count is less than max",
loginAttempts: maxInvalidLoginAttempts - 1,
expected: true,
expectedErr: nil,
},
{
name: "When brute force protection enabled and user login attempt count equals max",
name: "Should be invalid when brute force protection enabled and user login attempt count equals max",
loginAttempts: maxInvalidLoginAttempts,
expected: false,
expectedErr: nil,
},
{
name: "When brute force protection enabled and user login attempt count is greater than max",
name: "Should be invalid when brute force protection enabled and user login attempt count is greater than max",
loginAttempts: maxInvalidLoginAttempts + 1,
expected: false,
expectedErr: nil,
},
{
name: "When brute force protection disabled and user login attempt count is less than max",
name: "Should be valid when brute force protection disabled and user login attempt count is less than max",
loginAttempts: maxInvalidLoginAttempts - 1,
disabled: true,
expected: true,
expectedErr: nil,
},
{
name: "When brute force protection disabled and user login attempt count equals max",
name: "Should be valid when brute force protection disabled and user login attempt count equals max",
loginAttempts: maxInvalidLoginAttempts,
disabled: true,
expected: true,
expectedErr: nil,
},
{
name: "When brute force protection disabled and user login attempt count is greater than max",
name: "Should be valid when brute force protection disabled and user login attempt count is greater than max",
loginAttempts: maxInvalidLoginAttempts + 1,
disabled: true,
expected: true,
@@ -83,7 +83,7 @@ func TestService_Validate(t *testing.T) {
}
}
func TestLoginAttempts(t *testing.T) {
func TestUserLoginAttempts(t *testing.T) {
ctx := context.Background()
cfg := setting.NewCfg()
cfg.DisableBruteForceLoginProtection = false
@@ -109,6 +109,102 @@ func TestLoginAttempts(t *testing.T) {
assert.Nil(t, err)
}
func TestService_ValidateIPAddress(t *testing.T) {
const maxInvalidLoginAttempts = 5
testCases := []struct {
name string
loginAttempts int64
disabled bool
expected bool
expectedErr error
}{
{
name: "Should be valid when brute force protection enabled and IP address login attempt count is less than max",
loginAttempts: maxInvalidLoginAttempts - 1,
expected: true,
expectedErr: nil,
},
{
name: "Should be invalid when brute force protection enabled and IP address login attempt count equals max",
loginAttempts: maxInvalidLoginAttempts,
expected: false,
expectedErr: nil,
},
{
name: "Should be invalid when brute force protection enabled and IP address login attempt count is greater than max",
loginAttempts: maxInvalidLoginAttempts + 1,
expected: false,
expectedErr: nil,
},
{
name: "Should be valid when brute force protection disabled and IP address login attempt count is less than max",
loginAttempts: maxInvalidLoginAttempts - 1,
disabled: true,
expected: true,
expectedErr: nil,
},
{
name: "Should be valid when brute force protection disabled and IP address login attempt count equals max",
loginAttempts: maxInvalidLoginAttempts,
disabled: true,
expected: true,
expectedErr: nil,
},
{
name: "Should be valid when brute force protection disabled and IP address login attempt count is greater than max",
loginAttempts: maxInvalidLoginAttempts + 1,
disabled: true,
expected: true,
expectedErr: nil,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
cfg := setting.NewCfg()
cfg.BruteForceLoginProtectionMaxAttempts = maxInvalidLoginAttempts
cfg.DisableIPAddressLoginProtection = tt.disabled
service := &Service{
store: fakeStore{
ExpectedCount: tt.loginAttempts,
ExpectedErr: tt.expectedErr,
},
cfg: cfg,
}
ok, err := service.ValidateIPAddress(context.Background(), "192.168.1.1")
assert.Equal(t, tt.expected, ok)
assert.Equal(t, tt.expectedErr, err)
})
}
}
func TestIPLoginAttempts(t *testing.T) {
ctx := context.Background()
cfg := setting.NewCfg()
cfg.DisableIPAddressLoginProtection = false
cfg.BruteForceLoginProtectionMaxAttempts = 3
db := db.InitTestDB(t)
service := ProvideService(db, cfg, nil)
_ = service.Add(ctx, "user1", "192.168.1.1")
_ = service.Add(ctx, "user2", "10.0.0.123")
_ = service.Add(ctx, "user3", "192.168.1.1")
_ = service.Add(ctx, "user4", "[::1]")
_ = service.Add(ctx, "user5", "192.168.1.1")
_ = service.Add(ctx, "user6", "192.168.1.1")
count, err := service.store.GetIPLoginAttemptCount(ctx, GetIPLoginAttemptCountQuery{IPAddress: "192.168.1.1"})
assert.Nil(t, err)
assert.Equal(t, int64(4), count)
ok, err := service.ValidateIPAddress(ctx, "192.168.1.1")
assert.False(t, ok)
assert.Nil(t, err)
}
var _ store = new(fakeStore)
type fakeStore struct {
@@ -121,6 +217,10 @@ func (f fakeStore) GetUserLoginAttemptCount(ctx context.Context, query GetUserLo
return f.ExpectedCount, f.ExpectedErr
}
func (f fakeStore) GetIPLoginAttemptCount(ctx context.Context, query GetIPLoginAttemptCountQuery) (int64, error) {
return f.ExpectedCount, f.ExpectedErr
}
func (f fakeStore) CreateLoginAttempt(ctx context.Context, command CreateLoginAttemptCommand) (loginattempt.LoginAttempt, error) {
return loginattempt.LoginAttempt{}, f.ExpectedErr
}
@@ -6,7 +6,7 @@ import (
type CreateLoginAttemptCommand struct {
Username string
IpAddress string
IPAddress string
}
type GetUserLoginAttemptCountQuery struct {
@@ -14,6 +14,11 @@ type GetUserLoginAttemptCountQuery struct {
Since time.Time
}
type GetIPLoginAttemptCountQuery struct {
IPAddress string
Since time.Time
}
type DeleteOldLoginAttemptsCommand struct {
OlderThan time.Time
}
@@ -18,13 +18,14 @@ type store interface {
DeleteOldLoginAttempts(ctx context.Context, cmd DeleteOldLoginAttemptsCommand) (int64, error)
DeleteLoginAttempts(ctx context.Context, cmd DeleteLoginAttemptsCommand) error
GetUserLoginAttemptCount(ctx context.Context, query GetUserLoginAttemptCountQuery) (int64, error)
GetIPLoginAttemptCount(ctx context.Context, query GetIPLoginAttemptCountQuery) (int64, error)
}
func (xs *xormStore) CreateLoginAttempt(ctx context.Context, cmd CreateLoginAttemptCommand) (result loginattempt.LoginAttempt, err error) {
err = xs.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
loginAttempt := loginattempt.LoginAttempt{
Username: cmd.Username,
IpAddress: cmd.IpAddress,
IpAddress: cmd.IPAddress,
Created: xs.now().Unix(),
}
@@ -73,6 +74,22 @@ func (xs *xormStore) GetUserLoginAttemptCount(ctx context.Context, query GetUser
And("created >= ?", query.Since.Unix()).
Count(loginAttempt)
return queryErr
})
return total, err
}
func (xs *xormStore) GetIPLoginAttemptCount(ctx context.Context, query GetIPLoginAttemptCountQuery) (int64, error) {
var total int64
err := xs.db.WithDbSession(ctx, func(dbSession *db.Session) error {
var queryErr error
loginAttempt := new(loginattempt.LoginAttempt)
total, queryErr = dbSession.
Where("ip_address = ?", query.IPAddress).
And("created >= ?", query.Since.Unix()).
Count(loginAttempt)
if queryErr != nil {
return queryErr
}
@@ -60,21 +60,21 @@ func TestIntegrationLoginAttemptsQuery(t *testing.T) {
_, err := s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
Username: user,
IpAddress: "192.168.0.1",
IPAddress: "192.168.0.1",
})
require.Nil(t, err)
mockTime = timePlusOneMinute
_, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
Username: user,
IpAddress: "192.168.0.1",
IPAddress: "192.168.0.1",
})
require.Nil(t, err)
mockTime = timePlusTwoMinutes
_, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
Username: user,
IpAddress: "192.168.0.1",
IPAddress: "192.168.0.1",
})
require.Nil(t, err)
@@ -125,21 +125,21 @@ func TestIntegrationLoginAttemptsDelete(t *testing.T) {
_, err := s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
Username: user,
IpAddress: "192.168.0.1",
IPAddress: "192.168.0.1",
})
require.Nil(t, err)
mockTime = timePlusOneMinute
_, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
Username: user,
IpAddress: "192.168.0.1",
IPAddress: "192.168.0.1",
})
require.Nil(t, err)
mockTime = timePlusTwoMinutes
_, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{
Username: user,
IpAddress: "192.168.0.1",
IPAddress: "192.168.0.1",
})
require.Nil(t, err)
@@ -24,3 +24,7 @@ func (f FakeLoginAttemptService) Reset(ctx context.Context, username string) err
func (f FakeLoginAttemptService) Validate(ctx context.Context, username string) (bool, error) {
return f.ExpectedValid, f.ExpectedErr
}
func (f FakeLoginAttemptService) ValidateIPAddress(ctx context.Context, IpAddress string) (bool, error) {
return f.ExpectedValid, f.ExpectedErr
}
@@ -17,7 +17,7 @@ type MockLoginAttemptService struct {
ExpectedErr error
}
func (f *MockLoginAttemptService) Add(ctx context.Context, username, IPAddress string) error {
func (f *MockLoginAttemptService) Add(ctx context.Context, username, ipAddress string) error {
f.AddCalled = true
return f.ExpectedErr
}
@@ -31,3 +31,8 @@ func (f *MockLoginAttemptService) Validate(ctx context.Context, username string)
f.ValidateCalled = true
return f.ExpectedValid, f.ExpectedErr
}
func (f *MockLoginAttemptService) ValidateIPAddress(ctx context.Context, ipAddress string) (bool, error) {
f.ValidateCalled = true
return f.ExpectedValid, f.ExpectedErr
}
+17 -6
View File
@@ -585,8 +585,6 @@ func toGettableExtendedRuleNode(r ngmodels.AlertRule, provenanceRecords map[stri
gettableExtendedRuleNode := apimodels.GettableExtendedRuleNode{
GrafanaManagedAlert: &apimodels.GettableGrafanaRule{
ID: r.ID,
OrgID: r.OrgID,
Title: r.Title,
Condition: r.Condition,
Data: ApiAlertQueriesFromAlertQueries(r.Data),
@@ -757,23 +755,36 @@ type userIDToUserInfoFn func(id *ngmodels.UserUID) *apimodels.UserInfo
// getIdentityName returns name of either user or service account
func (srv RulerSrv) resolveUserIdToNameFn(ctx context.Context) userIDToUserInfoFn {
cache := map[ngmodels.UserUID]*apimodels.UserInfo{
ngmodels.AlertingUserUID: {
UID: string(ngmodels.AlertingUserUID),
},
ngmodels.FileProvisioningUserUID: {
UID: string(ngmodels.FileProvisioningUserUID),
},
}
return func(id *ngmodels.UserUID) *apimodels.UserInfo {
if id == nil {
return nil
}
if val, ok := cache[*id]; ok {
return val
}
u, err := srv.userService.GetByUID(ctx, &user.GetUserByUIDQuery{
UID: string(*id),
})
var result string
var name string
if err != nil {
srv.log.FromContext(ctx).Warn("Failed to get user by uid. Defaulting to an empty name", "uid", id, "error", err)
}
if u != nil {
result = u.NameOrFallback()
name = u.NameOrFallback()
}
return &apimodels.UserInfo{
result := &apimodels.UserInfo{
UID: string(*id),
Name: result,
Name: name,
}
cache[*id] = result
return result
}
}
@@ -404,6 +404,24 @@ func TestRouteGetRuleByUID(t *testing.T) {
Name: "Test",
},
},
{
desc: "recognize system identifier (alerting)",
UpdatedBy: &models.AlertingUserUID,
User: nil,
UserServiceError: nil,
Expected: &apimodels.UserInfo{
UID: string(models.AlertingUserUID),
},
},
{
desc: "recognize system identifier (provisioning)",
UpdatedBy: &models.FileProvisioningUserUID,
User: nil,
UserServiceError: nil,
Expected: &apimodels.UserInfo{
UID: string(models.FileProvisioningUserUID),
},
},
}
for _, tc := range testcases {
t.Run(tc.desc, func(t *testing.T) {
@@ -1593,10 +1593,6 @@
],
"type": "string"
},
"id": {
"format": "int64",
"type": "integer"
},
"intervalSeconds": {
"format": "int64",
"type": "integer"
@@ -1621,10 +1617,6 @@
"notification_settings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"orgId": {
"format": "int64",
"type": "integer"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
@@ -550,8 +550,6 @@ type PostableGrafanaRule struct {
// swagger:model
type GettableGrafanaRule struct {
ID int64 `json:"id" yaml:"id"`
OrgID int64 `json:"orgId" yaml:"orgId"`
Title string `json:"title" yaml:"title"`
Condition string `json:"condition" yaml:"condition"`
Data []AlertQuery `json:"data" yaml:"data"`
@@ -1593,10 +1593,6 @@
],
"type": "string"
},
"id": {
"format": "int64",
"type": "integer"
},
"intervalSeconds": {
"format": "int64",
"type": "integer"
@@ -1621,10 +1617,6 @@
"notification_settings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"orgId": {
"format": "int64",
"type": "integer"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
@@ -5281,10 +5281,6 @@
"Error"
]
},
"id": {
"type": "integer",
"format": "int64"
},
"intervalSeconds": {
"type": "integer",
"format": "int64"
@@ -5309,10 +5305,6 @@
"notification_settings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"orgId": {
"type": "integer",
"format": "int64"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
@@ -47,6 +47,11 @@ var (
ErrNoPanel = errors.New("no panel")
)
var (
FileProvisioningUserUID = UserUID("__provisioning__")
AlertingUserUID = UserUID("__alerting__")
)
// swagger:enum NoDataState
type NoDataState string
@@ -232,7 +232,7 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user ident
}
}
err = service.xact.InTransaction(ctx, func(ctx context.Context) error {
ids, err := service.ruleStore.InsertAlertRules(ctx, models.NewUserUID(user), []models.AlertRule{
ids, err := service.ruleStore.InsertAlertRules(ctx, userUidOrFallback(user), []models.AlertRule{
rule,
})
if err != nil {
@@ -359,7 +359,7 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user ident
}
}
return service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), updateRules)
return service.ruleStore.UpdateAlertRules(ctx, userUidOrFallback(user), updateRules)
})
}
@@ -511,7 +511,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
New: *update.New,
})
}
if err := service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), updates); err != nil {
if err := service.ruleStore.UpdateAlertRules(ctx, userUidOrFallback(user), updates); err != nil {
return fmt.Errorf("failed to update alert rules: %w", err)
}
for _, update := range delta.Update {
@@ -522,7 +522,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
}
if len(delta.New) > 0 {
uids, err := service.ruleStore.InsertAlertRules(ctx, models.NewUserUID(user), withoutNilAlertRules(delta.New))
uids, err := service.ruleStore.InsertAlertRules(ctx, userUidOrFallback(user), withoutNilAlertRules(delta.New))
if err != nil {
return fmt.Errorf("failed to insert alert rules: %w", err)
}
@@ -618,7 +618,7 @@ func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user ident
return models.AlertRule{}, err
}
err = service.xact.InTransaction(ctx, func(ctx context.Context) error {
err := service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), []models.UpdateRule{
err := service.ruleStore.UpdateAlertRules(ctx, userUidOrFallback(user), []models.UpdateRule{
{
Existing: storedRule,
New: rule,
@@ -871,3 +871,11 @@ func (service *AlertRuleService) ensureNamespace(ctx context.Context, user ident
return nil
}
func userUidOrFallback(user identity.Requester) *models.UserUID {
userUID := models.NewUserUID(user)
if user == nil {
return &models.FileProvisioningUserUID
}
return userUID
}
@@ -314,8 +314,6 @@ func (r ruleWithFolder) Fingerprint() fingerprint {
// fields that do not affect the state.
// TODO consider removing fields below from the fingerprint
writeInt(rule.ID)
writeInt(rule.OrgID)
writeInt(int64(rule.For))
if rule.DashboardUID != nil {
writeString(*rule.DashboardUID)
@@ -262,6 +262,8 @@ func TestRuleWithFolderFingerprint(t *testing.T) {
"UpdatedBy": {},
"IntervalSeconds": {},
"Annotations": {},
"ID": {},
"OrgID": {},
}
tp := reflect.TypeOf(rule).Elem()
+2 -2
View File
@@ -988,7 +988,7 @@ func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgI
}
// Provide empty user identifier to ensure it's clear that the rule update was made by the system
// and not by the user who changed the receiver's title.
return result, nil, st.UpdateAlertRules(ctx, nil, updates)
return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates)
}
// RenameTimeIntervalInNotificationSettings renames all rules that use old time interval name to the new name.
@@ -1065,7 +1065,7 @@ func (st DBstore) RenameTimeIntervalInNotificationSettings(
}
// Provide empty user identifier to ensure it's clear that the rule update was made by the system
// and not by the user who changed the receiver's title.
return result, nil, st.UpdateAlertRules(ctx, nil, updates)
return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates)
}
func ruleConstraintViolationToErr(sess *db.Session, rule ngmodels.AlertRule, err error, logger log.Logger) error {
+16 -9
View File
@@ -11,17 +11,24 @@ type AttachedFile struct {
Content []byte
}
// EmbeddedContent struct represents an embedded file.
type EmbeddedContent struct {
Name string
Content []byte
}
// Message is representation of the email message.
type Message struct {
To []string
SingleEmail bool
From string
Subject string
Body map[string]string
Info string
ReplyTo []string
EmbeddedFiles []string
AttachedFiles []*AttachedFile
To []string
SingleEmail bool
From string
Subject string
Body map[string]string
Info string
ReplyTo []string
EmbeddedFiles []string
EmbeddedContents []EmbeddedContent
AttachedFiles []*AttachedFile
}
func setDefaultTemplateData(cfg *setting.Cfg, data map[string]any, u *user.User) {
+9 -8
View File
@@ -112,14 +112,15 @@ func (ns *NotificationService) buildEmailMessage(cmd *SendEmailCommand) (*Messag
addr := mail.Address{Name: ns.Cfg.Smtp.FromName, Address: ns.Cfg.Smtp.FromAddress}
return &Message{
To: cmd.To,
SingleEmail: cmd.SingleEmail,
From: addr.String(),
Subject: subject,
Body: body,
EmbeddedFiles: cmd.EmbeddedFiles,
AttachedFiles: buildAttachedFiles(cmd.AttachedFiles),
ReplyTo: cmd.ReplyTo,
To: cmd.To,
SingleEmail: cmd.SingleEmail,
From: addr.String(),
Subject: subject,
Body: body,
EmbeddedFiles: cmd.EmbeddedFiles,
EmbeddedContents: cmd.EmbeddedContents,
AttachedFiles: buildAttachedFiles(cmd.AttachedFiles),
ReplyTo: cmd.ReplyTo,
}, nil
}
+10 -9
View File
@@ -18,15 +18,16 @@ type SendEmailAttachFile struct {
// SendEmailCommand is the command for sending emails
type SendEmailCommand struct {
To []string
SingleEmail bool
Template string
Subject string
Data map[string]any
Info string
ReplyTo []string
EmbeddedFiles []string
AttachedFiles []*SendEmailAttachFile
To []string
SingleEmail bool
Template string
Subject string
Data map[string]any
Info string
ReplyTo []string
EmbeddedFiles []string
EmbeddedContents []EmbeddedContent
AttachedFiles []*SendEmailAttachFile
}
// SendEmailCommandSync is the command for sending emails synchronously
+10 -9
View File
@@ -201,15 +201,16 @@ func __dangerouslyInjectHTML(s string) template.HTML {
func (ns *NotificationService) SendEmailCommandHandlerSync(ctx context.Context, cmd *SendEmailCommandSync) error {
message, err := ns.buildEmailMessage(&SendEmailCommand{
Data: cmd.Data,
Info: cmd.Info,
Template: cmd.Template,
To: cmd.To,
SingleEmail: cmd.SingleEmail,
EmbeddedFiles: cmd.EmbeddedFiles,
AttachedFiles: cmd.AttachedFiles,
Subject: cmd.Subject,
ReplyTo: cmd.ReplyTo,
Data: cmd.Data,
Info: cmd.Info,
Template: cmd.Template,
To: cmd.To,
SingleEmail: cmd.SingleEmail,
EmbeddedFiles: cmd.EmbeddedFiles,
EmbeddedContents: cmd.EmbeddedContents,
AttachedFiles: cmd.AttachedFiles,
Subject: cmd.Subject,
ReplyTo: cmd.ReplyTo,
})
if err != nil {
return err
@@ -132,6 +132,31 @@ func TestSendEmailSync(t *testing.T) {
require.Equal(t, []byte("text file content"), file.Content)
})
t.Run("When embedding readers to emails", func(t *testing.T) {
ns, mailer := createSut(t, bus)
cmd := &SendEmailCommandSync{
SendEmailCommand: SendEmailCommand{
Subject: "subject",
To: []string{"asdf@grafana.com"},
SingleEmail: true,
Template: "welcome_on_signup",
EmbeddedContents: []EmbeddedContent{
{Name: "embed.jpg", Content: []byte("image content")},
},
},
}
err := ns.SendEmailCommandHandlerSync(context.Background(), cmd)
require.NoError(t, err)
require.NotEmpty(t, mailer.Sent)
sent := mailer.Sent[len(mailer.Sent)-1]
require.Len(t, sent.EmbeddedContents, 1)
f := sent.EmbeddedContents[0]
require.Equal(t, "embed.jpg", f.Name)
require.Equal(t, "image content", string(f.Content))
})
t.Run("When SMTP disabled in configuration", func(t *testing.T) {
cfg := createSmtpConfig()
cfg.Smtp.Enabled = false
+7
View File
@@ -142,6 +142,13 @@ func (sc *SmtpClient) setFiles(
m.Embed(file)
}
for _, file := range msg.EmbeddedContents {
m.Embed(file.Name, gomail.SetCopyFunc(func(writer io.Writer) error {
_, err := writer.Write(file.Content)
return err
}))
}
for _, file := range msg.AttachedFiles {
file := file
m.Attach(file.Name, gomail.SetCopyFunc(func(writer io.Writer) error {
@@ -45,7 +45,7 @@ func (prov *defaultAlertRuleProvisioner) Provision(ctx context.Context,
files []*AlertingFile) error {
for _, file := range files {
for _, group := range file.Groups {
ctx, u := identity.WithServiceIdentitiy(ctx, group.OrgID)
ctx, u := identity.WithServiceIdentity(ctx, group.OrgID)
folderUID, err := prov.getOrCreateFolderFullpath(ctx, group.FolderFullpath, group.OrgID)
if err != nil {
@@ -121,7 +121,7 @@ func (prov *defaultAlertRuleProvisioner) getOrCreateFolderFullpath(
func (prov *defaultAlertRuleProvisioner) getOrCreateFolderByTitle(
ctx context.Context, folderName string, orgID int64, parentUID *string) (string, error) {
ctx, user := identity.WithServiceIdentitiy(ctx, orgID)
ctx, user := identity.WithServiceIdentity(ctx, orgID)
cmd := &folder.GetFolderQuery{
Title: &folderName,
@@ -148,7 +148,7 @@ func (fr *FileReader) isDatabaseAccessRestricted() bool {
// storeDashboardsInFolder saves dashboards from the filesystem on disk to the folder from config
func (fr *FileReader) storeDashboardsInFolder(ctx context.Context, filesFoundOnDisk map[string]os.FileInfo,
dashboardRefs map[string]*dashboards.DashboardProvisioning, usageTracker *usageTracker) error {
ctx, _ = identity.WithServiceIdentitiy(ctx, fr.Cfg.OrgID)
ctx, _ = identity.WithServiceIdentity(ctx, fr.Cfg.OrgID)
folderID, folderUID, err := fr.getOrCreateFolder(ctx, fr.Cfg, fr.dashboardProvisioningService, fr.Cfg.Folder)
if err != nil && !errors.Is(err, ErrFolderNameMissing) {
@@ -180,7 +180,7 @@ func (fr *FileReader) storeDashboardsInFoldersFromFileStructure(ctx context.Cont
folderName = filepath.Base(dashboardsFolder)
}
ctx, _ = identity.WithServiceIdentitiy(ctx, fr.Cfg.OrgID)
ctx, _ = identity.WithServiceIdentity(ctx, fr.Cfg.OrgID)
folderID, folderUID, err := fr.getOrCreateFolder(ctx, fr.Cfg, fr.dashboardProvisioningService, folderName)
if err != nil && !errors.Is(err, ErrFolderNameMissing) {
return fmt.Errorf("%w with name %q from file system structure: %w", ErrGetOrCreateFolder, folderName, err)
@@ -411,7 +411,7 @@ func TestDashboardFileReader(t *testing.T) {
require.NoError(t, err)
ctx := context.Background()
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
ctx, _ = identity.WithServiceIdentity(ctx, 1)
_, _, err = r.getOrCreateFolder(ctx, cfg, fakeService, cfg.Folder)
require.NoError(t, err)
})
@@ -433,7 +433,7 @@ func TestDashboardFileReader(t *testing.T) {
require.NoError(t, err)
ctx := context.Background()
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
ctx, _ = identity.WithServiceIdentity(ctx, 1)
_, _, err = r.getOrCreateFolder(ctx, cfg, fakeService, cfg.Folder)
require.ErrorIs(t, err, dashboards.ErrFolderInvalidUID)
})
@@ -56,7 +56,7 @@ func TestDuplicatesValidator(t *testing.T) {
const folderName = "duplicates-validator-folder"
ctx := context.Background()
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
ctx, _ = identity.WithServiceIdentity(ctx, 1)
fakeStore := &fakeDashboardStore{}
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
@@ -115,7 +115,7 @@ func TestDuplicatesValidator(t *testing.T) {
const folderName = "duplicates-validator-folder"
ctx := context.Background()
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
ctx, _ = identity.WithServiceIdentity(ctx, 1)
fakeStore := &fakeDashboardStore{}
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
@@ -221,7 +221,7 @@ func TestDuplicatesValidator(t *testing.T) {
duplicates := duplicateValidator.getDuplicates()
ctx := context.Background()
ctx, _ = identity.WithServiceIdentitiy(ctx, 1)
ctx, _ = identity.WithServiceIdentity(ctx, 1)
r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc)
require.NoError(t, err)
@@ -25,6 +25,7 @@ import (
"github.com/grafana/grafana/pkg/services/publicdashboards/service/intervalv2"
"github.com/grafana/grafana/pkg/services/publicdashboards/validation"
"github.com/grafana/grafana/pkg/services/query"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@@ -375,7 +376,13 @@ func (pd *PublicDashboardServiceImpl) FindAllWithPagination(ctx context.Context,
dashUIDs[i] = pubdash.DashboardUid
}
dashboardsFound, err := pd.dashboardService.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{OrgId: query.OrgID, DashboardUIDs: dashUIDs, SignedInUser: query.User, Limit: int64(len(dashUIDs))})
dashboardsFound, err := pd.dashboardService.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
OrgId: query.OrgID,
DashboardUIDs: dashUIDs,
SignedInUser: query.User,
Limit: int64(len(dashUIDs)),
Type: searchstore.TypeDashboard,
})
if err != nil {
return nil, ErrInternalServerError.Errorf("FindAllWithPagination: GetDashboards: %w", err)
}
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apikey"
@@ -39,6 +40,8 @@ type ServiceAccountsService struct {
log log.Logger
backgroundLog log.Logger
secretScanService secretscan.Checker
orgService org.Service
serverLock *serverlock.ServerLockService
secretScanEnabled bool
secretScanInterval time.Duration
@@ -54,6 +57,7 @@ func ProvideServiceAccountsService(
orgService org.Service,
acService accesscontrol.Service,
permissions accesscontrol.ServiceAccountPermissionsService,
serverLockService *serverlock.ServerLockService,
) (*ServiceAccountsService, error) {
serviceAccountsStore := database.ProvideServiceAccountsStore(
cfg,
@@ -71,6 +75,8 @@ func ProvideServiceAccountsService(
store: serviceAccountsStore,
log: log.New("serviceaccounts"),
backgroundLog: log.New("serviceaccounts.background"),
orgService: orgService,
serverLock: serverLockService,
}
if err := RegisterRoles(acService); err != nil {
@@ -102,6 +108,17 @@ func (sa *ServiceAccountsService) Run(ctx context.Context) error {
sa.log.Warn("Failed to get usage metrics", "error", err.Error())
}
err := sa.serverLock.LockAndExecute(ctx, "migrate API keys to service accounts", time.Minute*30, func(context.Context) {
err := sa.migrateAPIKeysForAllOrgs(ctx)
if err != nil {
sa.log.Warn("Failed to migrate API keys", "error", err.Error())
}
})
if err != nil {
sa.log.Error("Failed to lock and execute the migration of API keys to service accounts", "error", err)
}
updateStatsTicker := time.NewTicker(metricsCollectionInterval)
defer updateStatsTicker.Stop()
@@ -299,6 +316,51 @@ func (sa *ServiceAccountsService) MigrateApiKeysToServiceAccounts(ctx context.Co
return sa.store.MigrateApiKeysToServiceAccounts(ctx, orgID)
}
func (sa *ServiceAccountsService) migrateAPIKeysForAllOrgs(ctx context.Context) error {
sa.log.Debug("Starting to migrate API keys to service accounts")
total := 0
migrated := 0
failed := 0
errorsTotal := 0
defer func() {
if total > 0 || errorsTotal > 0 {
sa.log.Info("API key migration finished", "total_keys", total, "successful_keys", migrated, "failed_keys", failed, "errors", errorsTotal)
}
setAPIKeyMigrationStats(total, migrated, failed)
}()
orgs, err := sa.orgService.Search(ctx, &org.SearchOrgsQuery{})
if err != nil {
return err
}
for _, o := range orgs {
sa.log.Debug("Migrating API keys for an org", "orgId", o.ID)
result, err := sa.store.MigrateApiKeysToServiceAccounts(ctx, o.ID)
if err != nil {
sa.log.Warn("Failed to migrate API keys", "error", err.Error(), "orgId", o.ID)
errorsTotal += 1
continue
}
if result.Failed > 0 {
sa.log.Warn("Some API keys failed to be migrated", "total_keys", result.Total, "failed_keys", result.Failed, "orgId", o.ID)
} else if result.Total > 0 {
sa.log.Info("API key migration was successful", "orgId", o.ID, "total_keys", result.Total)
} else {
sa.log.Debug("No API keys found to migrate", "orgId", o.ID)
}
total += result.Total
migrated += result.Migrated
failed += result.Failed
}
return nil
}
func validOrgID(orgID int64) error {
if orgID == 0 {
return serviceaccounts.ErrServiceAccountInvalidOrgID.Errorf("invalid org ID 0 has been specified")
@@ -20,6 +20,15 @@ var (
// MStatTotalServiceAccountTokens is a metric gauge for total number of service account tokens
MStatTotalServiceAccountTokens prometheus.Gauge
// MStatTotalMigratedAPIKeysToSATokens is a metric gauge for total number of API keys to be migrated to service account tokens
MStatTotalMigratedAPIKeysToSATokens prometheus.Gauge
// MStatSuccessfullyMigratedAPIKeysToSATokens is a metric gauge for total number of successful migrations of API keys to service account tokens
MStatSuccessfullyMigratedAPIKeysToSATokens prometheus.Gauge
// MStatFailedMigratedAPIKeysToSATokens is a metric gauge for total number of failed migrations of API keys to service account tokens
MStatFailedMigratedAPIKeysToSATokens prometheus.Gauge
Initialised bool = false
)
@@ -42,10 +51,31 @@ func init() {
Namespace: ExporterName,
})
MStatTotalMigratedAPIKeysToSATokens = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "stat_total_migrated_api_keys_to_sa_tokens",
Help: "total number of API keys to be migrated to service account tokens",
Namespace: ExporterName,
})
MStatSuccessfullyMigratedAPIKeysToSATokens = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "stat_successfully_migrated_api_keys_to_sa_tokens",
Help: "total number of successful migrations of API keys to service account tokens",
Namespace: ExporterName,
})
MStatFailedMigratedAPIKeysToSATokens = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "stat_failed_migrated_api_keys_to_sa_tokens",
Help: "total number of failed migrations of API keys to service account tokens",
Namespace: ExporterName,
})
prometheus.MustRegister(
MStatTotalServiceAccounts,
MStatTotalServiceAccountTokens,
MStatTotalServiceAccountsNoRole,
MStatTotalMigratedAPIKeysToSATokens,
MStatSuccessfullyMigratedAPIKeysToSATokens,
MStatFailedMigratedAPIKeysToSATokens,
)
}
@@ -81,3 +111,9 @@ func (sa *ServiceAccountsService) getUsageMetrics(ctx context.Context) (map[stri
return stats, nil
}
func setAPIKeyMigrationStats(total, migrated, failed int) {
MStatTotalMigratedAPIKeysToSATokens.Set(float64(total))
MStatSuccessfullyMigratedAPIKeysToSATokens.Set(float64(migrated))
MStatFailedMigratedAPIKeysToSATokens.Set(float64(failed))
}
+3 -3
View File
@@ -44,7 +44,7 @@ type sqlStatsService struct {
func (ss *sqlStatsService) getDashboardCount(ctx context.Context, orgs []*org.OrgDTO) (int64, error) {
count := int64(0)
for _, org := range orgs {
ctx, _ = identity.WithServiceIdentitiy(ctx, org.ID)
ctx, _ = identity.WithServiceIdentity(ctx, org.ID)
dashsCount, err := ss.dashSvc.CountDashboardsInOrg(ctx, org.ID)
if err != nil {
return 0, err
@@ -58,7 +58,7 @@ func (ss *sqlStatsService) getDashboardCount(ctx context.Context, orgs []*org.Or
func (ss *sqlStatsService) getTagCount(ctx context.Context, orgs []*org.OrgDTO) (int64, error) {
total := 0
for _, org := range orgs {
ctx, _ = identity.WithServiceIdentitiy(ctx, org.ID)
ctx, _ = identity.WithServiceIdentity(ctx, org.ID)
tags, err := ss.dashSvc.GetDashboardTags(ctx, &dashboards.GetDashboardTagsQuery{
OrgID: org.ID,
})
@@ -74,7 +74,7 @@ func (ss *sqlStatsService) getTagCount(ctx context.Context, orgs []*org.OrgDTO)
func (ss *sqlStatsService) getFolderCount(ctx context.Context, orgs []*org.OrgDTO) (int64, error) {
total := 0
for _, org := range orgs {
ctx, ident := identity.WithServiceIdentitiy(ctx, org.ID)
ctx, ident := identity.WithServiceIdentity(ctx, org.ID)
folders, err := ss.folderSvc.GetFolders(ctx, folder.GetFoldersQuery{
OrgID: org.ID,
SignedInUser: ident,
+2
View File
@@ -157,6 +157,7 @@ type Cfg struct {
DisableInitAdminCreation bool
DisableBruteForceLoginProtection bool
BruteForceLoginProtectionMaxAttempts int64
DisableIPAddressLoginProtection bool
CookieSecure bool
CookieSameSiteDisabled bool
CookieSameSiteMode http.SameSite
@@ -1532,6 +1533,7 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error {
cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
cfg.BruteForceLoginProtectionMaxAttempts = security.Key("brute_force_login_protection_max_attempts").MustInt64(5)
cfg.DisableIPAddressLoginProtection = security.Key("disable_ip_address_login_protection").MustBool(true)
// Ensure at least one login attempt can be performed.
if cfg.BruteForceLoginProtectionMaxAttempts <= 0 {
+1 -1
View File
@@ -16,7 +16,7 @@ func TestIDTokenExtractor(t *testing.T) {
assert.Empty(t, token)
})
t.Run("should return an empty token when grafana identity is set", func(t *testing.T) {
ctx, _ := identity.WithServiceIdentitiy(context.Background(), 0)
ctx, _ := identity.WithServiceIdentity(context.Background(), 0)
token, err := idTokenExtractor(ctx)
assert.NoError(t, err)
assert.Empty(t, token)
+3 -3
View File
@@ -460,9 +460,9 @@ func (s *searchSupport) handleEvent(ctx context.Context, evt *WrittenEvent) {
// record latency from when event was created to when it was indexed
latencySeconds := float64(time.Now().UnixMicro()-evt.ResourceVersion) / 1e6
span.AddEvent("index latency", trace.WithAttributes(attribute.Float64("latency_seconds", latencySeconds)))
if latencySeconds > 5 {
s.log.Debug("high index latency object details", "resource", evt.Key.Resource, "latency_seconds", latencySeconds, "name", evt.Key.Name, "namespace", evt.Key.Namespace)
s.log.Warn("high index latency", "latency", latencySeconds)
s.log.Debug("indexed new object", "resource", evt.Key.Resource, "latency_seconds", latencySeconds, "name", evt.Key.Name, "namespace", evt.Key.Namespace, "rv", evt.ResourceVersion)
if latencySeconds > 1 {
s.log.Warn("high index latency object details", "resource", evt.Key.Resource, "latency_seconds", latencySeconds, "name", evt.Key.Name, "namespace", evt.Key.Namespace, "rv", evt.ResourceVersion)
}
if IndexMetrics != nil {
IndexMetrics.IndexLatency.WithLabelValues(evt.Key.Resource).Observe(latencySeconds)
-21
View File
@@ -1143,8 +1143,6 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
"__panelId__": "1"
},
"grafana_alert": {
"id": 1,
"orgId": 1,
"title": "AlwaysFiring",
"condition": "A",
"data": [{
@@ -1186,8 +1184,6 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
"expr": "",
"for":"0s",
"grafana_alert": {
"id": 2,
"orgId": 1,
"title": "AlwaysFiringButSilenced",
"condition": "A",
"data": [{
@@ -1241,8 +1237,6 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
"__panelId__": "1"
},
"grafana_alert": {
"id": 1,
"orgId": 1,
"title": "AlwaysFiring",
"condition": "A",
"data": [{
@@ -1583,7 +1577,6 @@ func TestIntegrationRuleCreate(t *testing.T) {
},
},
GrafanaManagedAlert: &apimodels.GettableGrafanaRule{
OrgID: 1,
Title: "test1 rule1",
Condition: "A",
Data: []apimodels.AlertQuery{
@@ -2526,8 +2519,6 @@ func TestIntegrationQuota(t *testing.T) {
"expr":"",
"for": "2m",
"grafana_alert":{
"id":1,
"orgId":1,
"title":"Updated alert rule",
"condition":"A",
"data":[
@@ -2641,8 +2632,6 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) {
"annotation1": "val1"
},
"grafana_alert": {
"id": 1,
"orgId": 1,
"title": "rule under folder default",
"condition": "A",
"data": [
@@ -3124,8 +3113,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
"label1": "val1"
},
"grafana_alert":{
"id":1,
"orgId":1,
"title":"AlwaysFiring",
"condition":"A",
"data":[
@@ -3170,8 +3157,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
"expr":"",
"for": "0s",
"grafana_alert":{
"id":2,
"orgId":1,
"title":"AlwaysFiringButSilenced",
"condition":"A",
"data":[
@@ -3488,8 +3473,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
"label2": "val2"
},
"grafana_alert":{
"id":1,
"orgId":1,
"title":"AlwaysNormal",
"condition":"A",
"data":[
@@ -3607,8 +3590,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
"expr":"",
"for": "30s",
"grafana_alert":{
"id":1,
"orgId":1,
"title":"AlwaysNormal",
"condition":"A",
"data":[
@@ -3705,8 +3686,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
"expr":"",
"for": "30s",
"grafana_alert":{
"id":1,
"orgId":1,
"title":"AlwaysNormal",
"condition":"A",
"data":[
@@ -12,8 +12,6 @@
"annotation": "test-annotation"
},
"grafana_alert": {
"id": 1,
"orgId": 1,
"title": "Rule1",
"condition": "A",
"data": [
@@ -60,8 +58,6 @@
"annotation": "test-annotation"
},
"grafana_alert": {
"id": 2,
"orgId": 1,
"title": "Rule2",
"condition": "A",
"data": [
@@ -12,8 +12,6 @@
"annotation": "test-annotation"
},
"grafana_alert": {
"id": 3,
"orgId": 1,
"title": "Rule3",
"condition": "A",
"data": [
@@ -12,8 +12,6 @@
"annotation": "test-annotation"
},
"grafana_alert": {
"id": 1,
"orgId": 1,
"title": "Rule1",
"condition": "A",
"data": [
@@ -60,8 +58,6 @@
"annotation": "test-annotation"
},
"grafana_alert": {
"id": 2,
"orgId": 1,
"title": "Rule2",
"condition": "A",
"data": [
+91 -18
View File
@@ -17,17 +17,11 @@ import (
"github.com/grafana/grafana/pkg/tests/testsuite"
)
var gvr = schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Resource: "dashboards",
}
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) {
func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper, gvr schema.GroupVersionResource) {
t.Run("simple crud+list", func(t *testing.T) {
ctx := context.Background()
client := helper.GetResourceClient(apis.ResourceClientArgs{
@@ -64,6 +58,7 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) {
require.NoError(t, err)
require.Equal(t, created, obj.GetName())
require.Equal(t, int64(1), obj.GetGeneration())
require.Equal(t, "Test empty dashboard", obj.Object["spec"].(map[string]any)["title"])
wrap, err := utils.MetaAccessor(obj)
require.NoError(t, err)
@@ -89,6 +84,7 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) {
require.Equal(t, obj.GetName(), updated.GetName())
require.Equal(t, obj.GetUID(), updated.GetUID())
require.Less(t, obj.GetResourceVersion(), updated.GetResourceVersion())
require.Equal(t, "Changed title", updated.Object["spec"].(map[string]any)["title"])
// Delete the object, skipping the provisioned dashboard check
zeroInt64 := int64(0)
@@ -104,12 +100,17 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) {
})
}
func TestIntegrationDashboardsApp(t *testing.T) {
func TestIntegrationDashboardsAppV0Alpha1(t *testing.T) {
gvr := schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Resource: "dashboards",
}
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("with dual writer mode 0", func(t *testing.T) {
t.Run("v0alpha1 with dual writer mode 0", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
@@ -118,10 +119,10 @@ func TestIntegrationDashboardsApp(t *testing.T) {
},
},
})
runDashboardTest(t, helper)
runDashboardTest(t, helper, gvr)
})
t.Run("with dual writer mode 1", func(t *testing.T) {
t.Run("v0alpha1 with dual writer mode 1", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
@@ -130,10 +131,10 @@ func TestIntegrationDashboardsApp(t *testing.T) {
},
},
})
runDashboardTest(t, helper)
runDashboardTest(t, helper, gvr)
})
t.Run("with dual writer mode 2", func(t *testing.T) {
t.Run("v0alpha1 with dual writer mode 2", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
@@ -142,10 +143,10 @@ func TestIntegrationDashboardsApp(t *testing.T) {
},
},
})
runDashboardTest(t, helper)
runDashboardTest(t, helper, gvr)
})
t.Run("with dual writer mode 3", func(t *testing.T) {
t.Run("v0alpha1 with dual writer mode 3", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
@@ -154,10 +155,10 @@ func TestIntegrationDashboardsApp(t *testing.T) {
},
},
})
runDashboardTest(t, helper)
runDashboardTest(t, helper, gvr)
})
t.Run("with dual writer mode 4", func(t *testing.T) {
t.Run("v0alpha1 with dual writer mode 4", func(t *testing.T) {
t.Skip("skipping test because of authorizer issue")
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
@@ -167,6 +168,78 @@ func TestIntegrationDashboardsApp(t *testing.T) {
},
},
})
runDashboardTest(t, helper)
runDashboardTest(t, helper, gvr)
})
}
func TestIntegrationDashboardsAppV1Alpha1(t *testing.T) {
gvr := schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v1alpha1",
Resource: "dashboards",
}
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("v1alpha1 with dual writer mode 0", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: 0,
},
},
})
runDashboardTest(t, helper, gvr)
})
t.Run("v1alpha1 with dual writer mode 1", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: 1,
},
},
})
runDashboardTest(t, helper, gvr)
})
t.Run("v1alpha1 with dual writer mode 2", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: 2,
},
},
})
runDashboardTest(t, helper, gvr)
})
t.Run("v1alpha1 with dual writer mode 3", func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: 3,
},
},
})
runDashboardTest(t, helper, gvr)
})
t.Run("v1alpha1 with dual writer mode 4", func(t *testing.T) {
t.Skip("skipping test because of authorizer issue")
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: 4,
},
},
})
runDashboardTest(t, helper, gvr)
})
}
-8
View File
@@ -16013,10 +16013,6 @@
"Error"
]
},
"id": {
"type": "integer",
"format": "int64"
},
"intervalSeconds": {
"type": "integer",
"format": "int64"
@@ -16041,10 +16037,6 @@
"notification_settings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"orgId": {
"type": "integer",
"format": "int64"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
@@ -0,0 +1,16 @@
import { Suspense, lazy } from 'react';
import { FolderPickerSkeleton } from './Skeleton';
const SuspendingNestedFolderPicker = lazy(() =>
import('./NestedFolderPicker').then((module) => ({ default: module.NestedFolderPicker }))
);
// Lazily load folder picker, is what is exposed to plugins through @grafana/runtime
export const LazyFolderPicker = (props: Parameters<typeof SuspendingNestedFolderPicker>[0]) => {
return (
<Suspense fallback={<FolderPickerSkeleton />}>
<SuspendingNestedFolderPicker {...props} />
</Suspense>
);
};
@@ -6,7 +6,6 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { config } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { PermissionLevelString } from 'app/types';
import {
treeViewersCanEdit,
@@ -187,7 +186,7 @@ describe('NestedFolderPicker', () => {
});
it('shows items the user can view, with the prop', async () => {
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />);
render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' });
await userEvent.click(button);
@@ -209,7 +208,7 @@ describe('NestedFolderPicker', () => {
});
it('can expand and collapse a folder to show its children', async () => {
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />);
render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
// Open the picker and wait for children to load
const button = await screen.findByRole('button', { name: 'Select folder' });
@@ -240,7 +239,7 @@ describe('NestedFolderPicker', () => {
});
it('can expand and collapse a folder to show its children with the keyboard', async () => {
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />);
render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' });
await userEvent.click(button);
@@ -37,7 +37,7 @@ export interface NestedFolderPickerProps {
excludeUIDs?: string[];
/* Show folders matching this permission, mainly used to also show folders user can view. Defaults to showing only folders user has Edit */
permission?: PermissionLevelString.View | PermissionLevelString.Edit;
permission?: 'view' | 'edit';
/* Callback for when the user selects a folder */
onChange?: (folderUID: string | undefined, folderName: string | undefined, repository?: RepositoryView) => void;
@@ -53,7 +53,7 @@ async function getSearchResults(searchQuery: string, permission?: PermissionLeve
query: searchQuery,
kind: ['folder'],
limit: 100,
permission: permission,
permission,
});
const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view));
@@ -66,7 +66,7 @@ export function NestedFolderPicker({
showRootFolder = true,
clearable = false,
excludeUIDs,
permission = PermissionLevelString.Edit,
permission = 'edit',
onChange,
}: NestedFolderPickerProps) {
const styles = useStyles2(getStyles);
@@ -83,12 +83,24 @@ export function NestedFolderPicker({
const overlayId = useId();
const [error] = useState<Error | undefined>(undefined); // TODO: error not populated anymore
const lastSearchTimestamp = useRef<number>(0);
// Map the permission string union to enum value for compatibility
const permissionLevel = useMemo(() => {
if (permission === 'view') {
return PermissionLevelString.View;
} else if (permission === 'edit') {
return PermissionLevelString.Edit;
}
throw new Error('Invalid permission');
}, [permission]);
const isBrowsing = Boolean(overlayOpen && !(search && searchResults));
const {
items: browseFlatTree,
isLoading: isBrowseLoading,
requestNextPage: fetchFolderPage,
} = useFoldersQuery(isBrowsing, foldersOpenState, permission);
} = useFoldersQuery(isBrowsing, foldersOpenState, permissionLevel);
useEffect(() => {
if (!search) {
@@ -99,7 +111,7 @@ export function NestedFolderPicker({
const timestamp = Date.now();
setIsFetchingSearchResults(true);
debouncedSearch(search, permission).then((queryResponse) => {
debouncedSearch(search, permissionLevel).then((queryResponse) => {
// Only keep the results if it's was issued after the most recently resolved search.
// This prevents results showing out of order if first request is slower than later ones.
// We don't need to worry about clearing the isFetching state either - if there's a later
@@ -111,7 +123,7 @@ export function NestedFolderPicker({
lastSearchTimestamp.current = timestamp;
}
});
}, [search, permission]);
}, [search, permissionLevel]);
// the order of middleware is important!
const middleware = [
@@ -0,0 +1,36 @@
import { css } from '@emotion/css';
import Skeleton from 'react-loading-skeleton';
import type { GrafanaTheme2 } from '@grafana/data';
import { getInputStyles, useStyles2 } from '@grafana/ui';
// This component is used as a fallback for codesplitting, so aim to keep
// the bundle size of it as small as possible :)
export function FolderPickerSkeleton() {
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<div className={styles.inputWrapper}>
<button type="button" className={styles.fakeInput} aria-disabled>
<Skeleton width={100} />
</button>
</div>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
const baseStyles = getInputStyles({ theme });
return {
wrapper: baseStyles.wrapper,
inputWrapper: baseStyles.inputWrapper,
fakeInput: css([
baseStyles.input,
{
textAlign: 'left',
},
]),
};
};
@@ -1,13 +1,14 @@
import { css, cx } from '@emotion/css';
import { forwardRef, ReactNode, ButtonHTMLAttributes } from 'react';
import * as React from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, getInputStyles, useTheme2, Text } from '@grafana/ui';
import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins';
import { Trans, t } from 'app/core/internationalization';
import { FolderPickerSkeleton } from './Skeleton';
interface TriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
isLoading: boolean;
handleClearSelection?: (event: React.MouseEvent<SVGElement> | React.KeyboardEvent<SVGElement>) => void;
@@ -28,6 +29,10 @@ function Trigger(
}
};
if (isLoading) {
return <FolderPickerSkeleton />;
}
return (
<div className={styles.wrapper}>
<div className={styles.inputWrapper}>
@@ -43,9 +48,7 @@ function Trigger(
{...rest}
ref={ref}
>
{isLoading ? (
<Skeleton width={100} />
) : label ? (
{label ? (
<Text truncate>{label}</Text>
) : (
<Text truncate color="secondary">
@@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph, useSceneObjectState } from '@grafana/scenes';
import { ElementSelectionContextItem, ElementSelectionContextState, ToolbarButton, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { isInCloneChain } from '../utils/clone';
import { getDashboardSceneFor } from '../utils/utils';
@@ -165,18 +166,18 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
<>
<div className={styles.expandOptionsWrapper}>
<ToolbarButton
tooltip={'Open options pane'}
icon={'arrow-to-right'}
tooltip={t('dashboard.edit-pane.open', 'Open options pane')}
icon="arrow-to-right"
onClick={onToggleCollapse}
variant="canvas"
className={styles.rotate180}
aria-label={'Open options pane'}
aria-label={t('dashboard.edit-pane.open', 'Open options pane')}
/>
</div>
{openOverlay && (
<Resizable className={cx(styles.fixed, styles.container)} defaultSize={{ height: '100%', width: '20vw' }}>
<ElementEditPane element={editableElement} key={editableElement.getTypeName()} />
<ElementEditPane element={editableElement} key={editableElement.typeName} />
</Resizable>
)}
</>
@@ -185,7 +186,7 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
return (
<div className={styles.wrapper} ref={paneRef}>
<ElementEditPane element={editableElement} key={editableElement.getTypeName()} />
<ElementEditPane element={editableElement} key={editableElement.typeName} />
</div>
);
}
@@ -1,15 +1,17 @@
import { useMemo } from 'react';
import { Input, TextArea } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardScene } from '../scene/DashboardScene';
import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
import { EditableDashboardElement } from '../scene/types';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
export class DashboardEditableElement implements EditableDashboardElement {
public isEditableDashboardElement: true = true;
public readonly isEditableDashboardElement = true;
public readonly typeName = 'Dashboard';
public constructor(private dashboard: DashboardScene) {}
@@ -21,13 +23,13 @@ export class DashboardEditableElement implements EditableDashboardElement {
const dashboardOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: 'Dashboard options',
title: t('dashboard.options.title', 'Dashboard options'),
id: 'dashboard-options',
isOpenDefault: true,
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Title',
title: t('dashboard.options.title-option', 'Title'),
render: function renderTitle() {
return <DashboardTitleInput dashboard={dashboard} />;
},
@@ -35,7 +37,7 @@ export class DashboardEditableElement implements EditableDashboardElement {
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Description',
title: t('dashboard.options.description', 'Description'),
render: function renderTitle() {
return <DashboardDescriptionInput dashboard={dashboard} />;
},
@@ -47,10 +49,6 @@ export class DashboardEditableElement implements EditableDashboardElement {
return [dashboardOptions, layoutCategory];
}
public getTypeName(): string {
return 'Dashboard';
}
}
export function DashboardTitleInput({ dashboard }: { dashboard: DashboardScene }) {
@@ -4,7 +4,8 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Stack, useStyles2 } from '@grafana/ui';
import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory';
import { EditableDashboardElement, MultiSelectedEditableDashboardElement } from '../scene/types';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
export interface Props {
element: EditableDashboardElement | MultiSelectedEditableDashboardElement;
@@ -19,7 +20,7 @@ export function ElementEditPane({ element }: Props) {
{element.renderActions && (
<OptionsPaneCategory
id="selected-item"
title={element.getTypeName()}
title={element.typeName}
isOpenDefault={true}
className={styles.noBorderTop}
>
@@ -2,12 +2,9 @@ import { SceneObject, SceneObjectRef, VizPanel } from '@grafana/scenes';
import { ElementSelectionContextItem } from '@grafana/ui';
import { DashboardScene } from '../scene/DashboardScene';
import {
EditableDashboardElement,
isBulkActionElement,
isEditableDashboardElement,
MultiSelectedEditableDashboardElement,
} from '../scene/types';
import { isBulkActionElement } from '../scene/types/BulkActionElement';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
import { DashboardEditableElement } from './DashboardEditableElement';
import { MultiSelectedObjectsEditableElement } from './MultiSelectedObjectsEditableElement';
@@ -3,10 +3,13 @@ import { ReactNode } from 'react';
import { Stack, Text, Button } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { BulkActionElement, MultiSelectedEditableDashboardElement } from '../scene/types';
import { BulkActionElement } from '../scene/types/BulkActionElement';
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
export class MultiSelectedObjectsEditableElement implements MultiSelectedEditableDashboardElement {
public isMultiSelectedEditableDashboardElement: true = true;
public readonly isMultiSelectedEditableDashboardElement = true;
public readonly typeName = 'Objects';
private items?: BulkActionElement[];
constructor(items: BulkActionElement[]) {
@@ -19,10 +22,6 @@ export class MultiSelectedObjectsEditableElement implements MultiSelectedEditabl
}
};
public getTypeName(): string {
return 'Objects';
}
renderActions(): ReactNode {
return (
<Stack direction="column">
@@ -5,11 +5,12 @@ import { Button, Stack, Text } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { MultiSelectedEditableDashboardElement } from '../scene/types';
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEditableDashboardElement {
public isMultiSelectedEditableDashboardElement: true = true;
public readonly isMultiSelectedEditableDashboardElement = true;
public readonly typeName = 'Panels';
private items?: VizPanel[];
@@ -34,10 +35,6 @@ export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEdita
}
};
public getTypeName(): string {
return 'Panels';
}
renderActions(): ReactNode {
return (
<Stack direction="column">
@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { ReactNode, useMemo } from 'react';
import { sceneGraph, VizPanel } from '@grafana/scenes';
import { Button } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { t, Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { getVisualizationOptions2 } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
@@ -12,11 +12,14 @@ import {
PanelDescriptionTextArea,
PanelFrameTitleInput,
} from '../panel-edit/getPanelFrameOptions';
import { BulkActionElement, EditableDashboardElement, isDashboardLayoutItem } from '../scene/types';
import { BulkActionElement } from '../scene/types/BulkActionElement';
import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
export class VizPanelEditableElement implements EditableDashboardElement, BulkActionElement {
public isEditableDashboardElement: true = true;
public readonly isEditableDashboardElement = true;
public readonly typeName = 'Panel';
public constructor(private panel: VizPanel) {}
@@ -26,13 +29,13 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
const panelOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: 'Panel options',
title: t('dashboard.viz-panel.options.title', 'Panel options'),
id: 'panel-options',
isOpenDefault: true,
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Title',
title: t('dashboard.viz-panel.options.title-option', 'Title'),
value: panel.state.title,
popularRank: 1,
render: function renderTitle() {
@@ -42,7 +45,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Description',
title: t('dashboard.viz-panel.options.description', 'Description'),
value: panel.state.description,
render: function renderDescription() {
return <PanelDescriptionTextArea panel={panel} />;
@@ -51,7 +54,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Transparent background',
title: t('dashboard.viz-panel.options.transparent-background', 'Transparent background'),
render: function renderTransparent() {
return <PanelBackgroundSwitch panel={panel} />;
},
@@ -96,16 +99,12 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
return categories;
}
public getTypeName(): string {
return 'Panel';
}
public onDelete = () => {
const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel);
layout.removePanel(this.panel);
};
public renderActions(): React.ReactNode {
public renderActions(): ReactNode {
return (
<>
<Button size="sm" variant="secondary">
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { EditableDashboardElement, MultiSelectedEditableDashboardElement } from '../scene/types';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement';
import { ElementSelection } from './ElementSelection';
@@ -21,7 +21,7 @@ import { saveLibPanel } from 'app/features/library-panels/state/api';
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
import { getPanelChanges } from '../saving/getDashboardChanges';
import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types';
import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import {
activateSceneObjectAndParentTree,
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more