diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9307e736605..aa3c66ae1eb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 18407b67f76..5f21ab276ee 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -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}}" diff --git a/.github/workflows/create-next-release-branch.yml b/.github/workflows/create-next-release-branch.yml new file mode 100644 index 00000000000..e1cc71c02a0 --- /dev/null +++ b/.github/workflows/create-next-release-branch.yml @@ -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 }} diff --git a/.github/workflows/migrate-prs.yml b/.github/workflows/migrate-prs.yml new file mode 100644 index 00000000000..9a41ee6ee8a --- /dev/null +++ b/.github/workflows/migrate-prs.yml @@ -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' diff --git a/.github/workflows/publish-technical-documentation-release.yml b/.github/workflows/publish-technical-documentation-release.yml index b36eb48885a..bd77c286018 100644 --- a/.github/workflows/publish-technical-documentation-release.yml +++ b/.github/workflows/publish-technical-documentation-release.yml @@ -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: "" diff --git a/.github/workflows/release-comms.yml b/.github/workflows/release-comms.yml index 02d53057184..fbc7d94ec13 100644 --- a/.github/workflows/release-comms.yml +++ b/.github/workflows/release-comms.yml @@ -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 diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index d6bcd7d5446..56a5e50f332 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -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: diff --git a/apps/advisor/pkg/app/checkregistry/checkregistry.go b/apps/advisor/pkg/app/checkregistry/checkregistry.go index e1bd27387d0..7bbc138f5c6 100644 --- a/apps/advisor/pkg/app/checkregistry/checkregistry.go +++ b/apps/advisor/pkg/app/checkregistry/checkregistry.go @@ -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, diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check.go b/apps/advisor/pkg/app/checks/datasourcecheck/check.go index 5c5d7d8bdcc..df05bf40688 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check.go @@ -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) +} diff --git a/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go b/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go index f51fdb77fbb..5c3dfb913e3 100644 --- a/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go +++ b/apps/advisor/pkg/app/checks/datasourcecheck/check_test.go @@ -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 } diff --git a/conf/defaults.ini b/conf/defaults.ini index 66782391629..e4df4b5ea0a 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -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 diff --git a/conf/sample.ini b/conf/sample.ini index dba3e061ce0..b6130dd4230 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -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 diff --git a/docs/sources/dashboards/create-reports/_index.md b/docs/sources/dashboards/create-reports/_index.md index 12218600293..0b1de37d290 100644 --- a/docs/sources/dashboards/create-reports/_index.md +++ b/docs/sources/dashboards/create-reports/_index.md @@ -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//administration/organization-preferences/#change-grafana-ui-theme - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/organization-preferences/#change-grafana-ui-theme grafana-enterprise: - pattern: /docs/grafana/ destination: /docs/grafana//introduction/grafana-enterprise/ diff --git a/docs/sources/dashboards/create-reports/report-settings/index.md b/docs/sources/dashboards/create-reports/report-settings/index.md index e0c606a8f65..b878953b71a 100644 --- a/docs/sources/dashboards/create-reports/report-settings/index.md +++ b/docs/sources/dashboards/create-reports/report-settings/index.md @@ -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//administration/organization-preferences/#change-grafana-ui-theme + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/organization-preferences/#change-grafana-ui-theme --- # Reporting settings diff --git a/docs/sources/developers/http_api/dashboard.md b/docs/sources/developers/http_api/dashboard.md index d37b0e080c9..fe08dc99f15 100644 --- a/docs/sources/developers/http_api/dashboard.md +++ b/docs/sources/developers/http_api/dashboard.md @@ -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 ``, `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` diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 1d50227704a..4805b42a1a7 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -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`. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md index 1c6b0ce9256..3e20606bb1e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md @@ -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 | diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md new file mode 100644 index 00000000000..06120e6c7da --- /dev/null +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md @@ -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 +``` diff --git a/docs/sources/setup-grafana/set-up-https.md b/docs/sources/setup-grafana/set-up-https.md index 4c9d39d8085..7cf65699203 100644 --- a/docs/sources/setup-grafana/set-up-https.md +++ b/docs/sources/setup-grafana/set-up-https.md @@ -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 diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index b2266899861..e71ec51d376 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -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, diff --git a/pkg/apis/dashboard/migration/schemaversion/migrations.go b/pkg/apis/dashboard/migration/schemaversion/migrations.go index 1b8925e2885..f689894cf34 100644 --- a/pkg/apis/dashboard/migration/schemaversion/migrations.go +++ b/pkg/apis/dashboard/migration/schemaversion/migrations.go @@ -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, } diff --git a/pkg/apis/dashboard/migration/schemaversion/migrations_test.go b/pkg/apis/dashboard/migration/schemaversion/migrations_test.go index 79be968d23b..b20f7e4f561 100644 --- a/pkg/apis/dashboard/migration/schemaversion/migrations_test.go +++ b/pkg/apis/dashboard/migration/schemaversion/migrations_test.go @@ -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) + }) + } +} diff --git a/pkg/apis/dashboard/migration/schemaversion/v39.go b/pkg/apis/dashboard/migration/schemaversion/v39.go new file mode 100644 index 00000000000..b2881c2ee44 --- /dev/null +++ b/pkg/apis/dashboard/migration/schemaversion/v39.go @@ -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 +} diff --git a/pkg/apis/dashboard/migration/schemaversion/v39_test.go b/pkg/apis/dashboard/migration/schemaversion/v39_test.go new file mode 100644 index 00000000000..d204d31f0cf --- /dev/null +++ b/pkg/apis/dashboard/migration/schemaversion/v39_test.go @@ -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) +} diff --git a/pkg/apis/dashboard/migration/schemaversion/v40_test.go b/pkg/apis/dashboard/migration/schemaversion/v40_test.go index 19e6c90aa79..179a2fbf655 100644 --- a/pkg/apis/dashboard/migration/schemaversion/v40_test.go +++ b/pkg/apis/dashboard/migration/schemaversion/v40_test.go @@ -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) - }) - } -} diff --git a/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json b/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json new file mode 100644 index 00000000000..e5f08f17c69 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/input/38.transform_timeseries_table.json @@ -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": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json new file mode 100644 index 00000000000..136a2fb9d40 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.39.json @@ -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": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json new file mode 100644 index 00000000000..4217f847e8c --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/38.transform_timeseries_table.40.json @@ -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": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.39.json b/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.39.json new file mode 100644 index 00000000000..6c0cb4d1974 --- /dev/null +++ b/pkg/apis/dashboard/migration/testdata/output/39.refresh_true.39.json @@ -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": "" +} \ No newline at end of file diff --git a/pkg/apis/dashboard/v1alpha1/conversion.go b/pkg/apis/dashboard/v1alpha1/conversion.go index 27bb5313336..358c482c4e3 100644 --- a/pkg/apis/dashboard/v1alpha1/conversion.go +++ b/pkg/apis/dashboard/v1alpha1/conversion.go @@ -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 } diff --git a/pkg/apis/dashboard/v2alpha1/conversion.go b/pkg/apis/dashboard/v2alpha1/conversion.go index 7dcb7487b59..280cac67b14 100644 --- a/pkg/apis/dashboard/v2alpha1/conversion.go +++ b/pkg/apis/dashboard/v2alpha1/conversion.go @@ -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 } diff --git a/pkg/apiserver/endpoints/filters/requester.go b/pkg/apiserver/endpoints/filters/requester.go index ccdb2c5d2c2..ec14b6aa382 100644 --- a/pkg/apiserver/endpoints/filters/requester.go +++ b/pkg/apiserver/endpoints/filters/requester.go @@ -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) diff --git a/pkg/apiserver/rest/dualwriter_syncer.go b/pkg/apiserver/rest/dualwriter_syncer.go index 816de7ba50d..9c36fdd22b7 100644 --- a/pkg/apiserver/rest/dualwriter_syncer.go +++ b/pkg/apiserver/rest/dualwriter_syncer.go @@ -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 } diff --git a/pkg/services/accesscontrol/dualwrite/collectors.go b/pkg/services/accesscontrol/dualwrite/collectors.go index 1e8f77f6875..871df2edf3f 100644 --- a/pkg/services/accesscontrol/dualwrite/collectors.go +++ b/pkg/services/accesscontrol/dualwrite/collectors.go @@ -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, diff --git a/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go b/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go index 14fa5eca892..a16e77c1717 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go @@ -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 diff --git a/pkg/services/accesscontrol/ossaccesscontrol/folder.go b/pkg/services/accesscontrol/ossaccesscontrol/folder.go index 050570868f6..8d1dc7b0d59 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/folder.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/folder.go @@ -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{ diff --git a/pkg/services/authn/clients/password.go b/pkg/services/authn/clients/password.go index a44f8615086..76e82d4fc59 100644 --- a/pkg/services/authn/clients/password.go +++ b/pkg/services/authn/clients/password.go @@ -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) diff --git a/pkg/services/authn/clients/password_test.go b/pkg/services/authn/clients/password_test.go index c981cfd4b01..6f7619ce1b7 100644 --- a/pkg/services/authn/clients/password_test.go +++ b/pkg/services/authn/clients/password_test.go @@ -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) diff --git a/pkg/services/authn/clients/passwordless.go b/pkg/services/authn/clients/passwordless.go index cda17035e81..3ee20ff055f 100644 --- a/pkg/services/authn/clients/passwordless.go +++ b/pkg/services/authn/clients/passwordless.go @@ -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 diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index d85fb8ed692..d4abb3627fc 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -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 diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 1ae6c4f5b90..58cc555bfd3 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -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) { diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index b97ca614cc5..9ac7c8a2e1e 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -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) } diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go index 6d4ae13da06..203aa4f37ab 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -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) } diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index 8b23f58cf5e..306352fce84 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -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 diff --git a/pkg/services/loginattempt/login_attempt.go b/pkg/services/loginattempt/login_attempt.go index ce3233f0f3b..7758e6716d4 100644 --- a/pkg/services/loginattempt/login_attempt.go +++ b/pkg/services/loginattempt/login_attempt.go @@ -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 } diff --git a/pkg/services/loginattempt/loginattemptimpl/login_attempt.go b/pkg/services/loginattempt/loginattemptimpl/login_attempt.go index f55b8fe34b1..3c901415333 100644 --- a/pkg/services/loginattempt/loginattemptimpl/login_attempt.go +++ b/pkg/services/loginattempt/loginattemptimpl/login_attempt.go @@ -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{ diff --git a/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go b/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go index 7015dff01e9..96e19039f28 100644 --- a/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go +++ b/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go @@ -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 } diff --git a/pkg/services/loginattempt/loginattemptimpl/models.go b/pkg/services/loginattempt/loginattemptimpl/models.go index f235cd3d308..4957daa8414 100644 --- a/pkg/services/loginattempt/loginattemptimpl/models.go +++ b/pkg/services/loginattempt/loginattemptimpl/models.go @@ -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 } diff --git a/pkg/services/loginattempt/loginattemptimpl/store.go b/pkg/services/loginattempt/loginattemptimpl/store.go index f273e6f5ef0..0afa93fbaeb 100644 --- a/pkg/services/loginattempt/loginattemptimpl/store.go +++ b/pkg/services/loginattempt/loginattemptimpl/store.go @@ -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 } diff --git a/pkg/services/loginattempt/loginattemptimpl/store_test.go b/pkg/services/loginattempt/loginattemptimpl/store_test.go index 194e64ff572..c8041bbd6da 100644 --- a/pkg/services/loginattempt/loginattemptimpl/store_test.go +++ b/pkg/services/loginattempt/loginattemptimpl/store_test.go @@ -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) diff --git a/pkg/services/loginattempt/loginattempttest/fake.go b/pkg/services/loginattempt/loginattempttest/fake.go index 581aeae1742..4e6cb56b52b 100644 --- a/pkg/services/loginattempt/loginattempttest/fake.go +++ b/pkg/services/loginattempt/loginattempttest/fake.go @@ -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 +} diff --git a/pkg/services/loginattempt/loginattempttest/mock.go b/pkg/services/loginattempt/loginattempttest/mock.go index c775323a9a3..33cff397161 100644 --- a/pkg/services/loginattempt/loginattempttest/mock.go +++ b/pkg/services/loginattempt/loginattempttest/mock.go @@ -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 +} diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 215048e448c..718922ff8e3 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -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 } } diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index dce8f9a801c..73cf2bb6300 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -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) { diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 24d1d9d81c4..25f7b2ff8c3 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -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" }, diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index d97b99512bf..60fbd2518dd 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -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"` diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 0e02ed6f68a..e8c4c20d008 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -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" }, diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 960fb35a5fe..abe3364c501 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -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" }, diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 07a5eb86280..b7da813141f 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -47,6 +47,11 @@ var ( ErrNoPanel = errors.New("no panel") ) +var ( + FileProvisioningUserUID = UserUID("__provisioning__") + AlertingUserUID = UserUID("__alerting__") +) + // swagger:enum NoDataState type NoDataState string diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 9b8cd429653..c31413b3bdc 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -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 +} diff --git a/pkg/services/ngalert/schedule/registry.go b/pkg/services/ngalert/schedule/registry.go index 2b162ca87be..11f72229f51 100644 --- a/pkg/services/ngalert/schedule/registry.go +++ b/pkg/services/ngalert/schedule/registry.go @@ -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) diff --git a/pkg/services/ngalert/schedule/registry_test.go b/pkg/services/ngalert/schedule/registry_test.go index 6ee2517a706..fb779c346ea 100644 --- a/pkg/services/ngalert/schedule/registry_test.go +++ b/pkg/services/ngalert/schedule/registry_test.go @@ -262,6 +262,8 @@ func TestRuleWithFolderFingerprint(t *testing.T) { "UpdatedBy": {}, "IntervalSeconds": {}, "Annotations": {}, + "ID": {}, + "OrgID": {}, } tp := reflect.TypeOf(rule).Elem() diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index adcfe8450fa..fbc9e061385 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -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 { diff --git a/pkg/services/notifications/email.go b/pkg/services/notifications/email.go index e50646b9662..c60adf605d5 100644 --- a/pkg/services/notifications/email.go +++ b/pkg/services/notifications/email.go @@ -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) { diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 7382454751f..eceaef3caf4 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -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 } diff --git a/pkg/services/notifications/models.go b/pkg/services/notifications/models.go index 364c6e6d262..6f5b1419ea5 100644 --- a/pkg/services/notifications/models.go +++ b/pkg/services/notifications/models.go @@ -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 diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index 2be85628090..dc453874d14 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -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 diff --git a/pkg/services/notifications/notifications_test.go b/pkg/services/notifications/notifications_test.go index 47c1f56ccb8..b1b3bdfdcaa 100644 --- a/pkg/services/notifications/notifications_test.go +++ b/pkg/services/notifications/notifications_test.go @@ -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 diff --git a/pkg/services/notifications/smtp.go b/pkg/services/notifications/smtp.go index 9ac0ecff570..c5eea8a958c 100644 --- a/pkg/services/notifications/smtp.go +++ b/pkg/services/notifications/smtp.go @@ -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 { diff --git a/pkg/services/provisioning/alerting/rules_provisioner.go b/pkg/services/provisioning/alerting/rules_provisioner.go index f41e48a94da..7366de856b3 100644 --- a/pkg/services/provisioning/alerting/rules_provisioner.go +++ b/pkg/services/provisioning/alerting/rules_provisioner.go @@ -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, diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index b7c54ddcdb0..8db4fbc3c80 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -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) diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index 52e9ac19e42..bdc8aebc26a 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -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) }) diff --git a/pkg/services/provisioning/dashboards/validator_test.go b/pkg/services/provisioning/dashboards/validator_test.go index 788eb7e017a..0b13f3092fa 100644 --- a/pkg/services/provisioning/dashboards/validator_test.go +++ b/pkg/services/provisioning/dashboards/validator_test.go @@ -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) diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index b5459992780..d3239e2857d 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -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) } diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index bfb3938b65c..2bb64c3646a 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -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") diff --git a/pkg/services/serviceaccounts/manager/stats.go b/pkg/services/serviceaccounts/manager/stats.go index 521688ca66f..a882ad2db41 100644 --- a/pkg/services/serviceaccounts/manager/stats.go +++ b/pkg/services/serviceaccounts/manager/stats.go @@ -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)) +} diff --git a/pkg/services/stats/statsimpl/stats.go b/pkg/services/stats/statsimpl/stats.go index 6cebff6ee19..ab6847d0b61 100644 --- a/pkg/services/stats/statsimpl/stats.go +++ b/pkg/services/stats/statsimpl/stats.go @@ -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, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index a377e720ae0..e05e5b32315 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -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 { diff --git a/pkg/storage/unified/resource/client_test.go b/pkg/storage/unified/resource/client_test.go index e8c0981b57d..b723e3430bc 100644 --- a/pkg/storage/unified/resource/client_test.go +++ b/pkg/storage/unified/resource/client_test.go @@ -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) diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index 6d03f97e6f2..f0d18960dc5 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -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) diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index 6da7f57c963..4a5ae0cc6fa 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -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":[ diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-get.json b/pkg/tests/api/alerting/test-data/rulegroup-1-get.json index cb2b3df8345..d9b66b010a5 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-get.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-get.json @@ -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": [ diff --git a/pkg/tests/api/alerting/test-data/rulegroup-2-get.json b/pkg/tests/api/alerting/test-data/rulegroup-2-get.json index 572454ad1e7..90adbac6265 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-2-get.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-2-get.json @@ -12,8 +12,6 @@ "annotation": "test-annotation" }, "grafana_alert": { - "id": 3, - "orgId": 1, "title": "Rule3", "condition": "A", "data": [ diff --git a/pkg/tests/api/alerting/test-data/rulegroup-3-get.json b/pkg/tests/api/alerting/test-data/rulegroup-3-get.json index 5e454967b5d..efcf5822ea5 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-3-get.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-3-get.json @@ -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": [ diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index 5cc098dff93..0713009cb2c 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -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) }) } diff --git a/public/api-merged.json b/public/api-merged.json index e6dd277974a..e063c8c959c 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -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" }, diff --git a/public/app/core/components/NestedFolderPicker/LazyFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/LazyFolderPicker.tsx new file mode 100644 index 00000000000..988b2c9d558 --- /dev/null +++ b/public/app/core/components/NestedFolderPicker/LazyFolderPicker.tsx @@ -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[0]) => { + return ( + }> + + + ); +}; diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx index caea1db92d3..f5c90e5f403 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx @@ -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(); + render(); 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(); + render(); // 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(); + render(); const button = await screen.findByRole('button', { name: 'Select folder' }); await userEvent.click(button); diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 2db0129eeff..69ebf79cce3 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -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(undefined); // TODO: error not populated anymore const lastSearchTimestamp = useRef(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 = [ diff --git a/public/app/core/components/NestedFolderPicker/Skeleton.tsx b/public/app/core/components/NestedFolderPicker/Skeleton.tsx new file mode 100644 index 00000000000..98faf98b6a4 --- /dev/null +++ b/public/app/core/components/NestedFolderPicker/Skeleton.tsx @@ -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 ( +
+
+ +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + const baseStyles = getInputStyles({ theme }); + + return { + wrapper: baseStyles.wrapper, + inputWrapper: baseStyles.inputWrapper, + fakeInput: css([ + baseStyles.input, + { + textAlign: 'left', + }, + ]), + }; +}; diff --git a/public/app/core/components/NestedFolderPicker/Trigger.tsx b/public/app/core/components/NestedFolderPicker/Trigger.tsx index 942beab6e96..fe352b806d3 100644 --- a/public/app/core/components/NestedFolderPicker/Trigger.tsx +++ b/public/app/core/components/NestedFolderPicker/Trigger.tsx @@ -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 { isLoading: boolean; handleClearSelection?: (event: React.MouseEvent | React.KeyboardEvent) => void; @@ -28,6 +29,10 @@ function Trigger( } }; + if (isLoading) { + return ; + } + return (
@@ -43,9 +48,7 @@ function Trigger( {...rest} ref={ref} > - {isLoading ? ( - - ) : label ? ( + {label ? ( {label} ) : ( diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 86ed2e16772..35f2ce7123b 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -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 <>
{openOverlay && ( - + )} @@ -185,7 +186,7 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla return (
- +
); } diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx index f3814c2a7e1..4699e8c1c3f 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx @@ -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 ; }, @@ -35,7 +37,7 @@ export class DashboardEditableElement implements EditableDashboardElement { ) .addItem( new OptionsPaneItemDescriptor({ - title: 'Description', + title: t('dashboard.options.description', 'Description'), render: function renderTitle() { return ; }, @@ -47,10 +49,6 @@ export class DashboardEditableElement implements EditableDashboardElement { return [dashboardOptions, layoutCategory]; } - - public getTypeName(): string { - return 'Dashboard'; - } } export function DashboardTitleInput({ dashboard }: { dashboard: DashboardScene }) { diff --git a/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx index b60b48fd549..16adfe8004a 100644 --- a/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx @@ -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 && ( diff --git a/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts b/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts index 29f107a2d84..7a5a9ee61af 100644 --- a/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts +++ b/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts @@ -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'; diff --git a/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx index 574de4164c1..79724e4474e 100644 --- a/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx @@ -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 ( diff --git a/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx index bf9de853158..38db6544d3a 100644 --- a/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx @@ -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 ( diff --git a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx index 36a2928a309..732ac9df825 100644 --- a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx @@ -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 ; @@ -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 ; }, @@ -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 ( <> ), }); @@ -208,7 +207,7 @@ export function ToolbarActions({ dashboard }: Props) { }} data-testid={selectors.components.PageToolbar.itemButton('add_row')} > - Row + Row ), }); @@ -228,7 +227,7 @@ export function ToolbarActions({ dashboard }: Props) { DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' }); }} > - Import + Import ), }); @@ -380,7 +379,7 @@ export function ToolbarActions({ dashboard }: Props) { icon="arrow-left" data-testid={selectors.components.NavToolbar.editDashboard.backToDashboardButton} > - Back to dashboard + Back to dashboard ), }); @@ -401,7 +400,7 @@ export function ToolbarActions({ dashboard }: Props) { icon="arrow-left" data-testid={selectors.components.NavToolbar.editDashboard.backToDashboardButton} > - Back to dashboard + Back to dashboard ), }); @@ -413,7 +412,7 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -436,14 +435,14 @@ export function ToolbarActions({ dashboard }: Props) { onClick={() => { dashboard.onEnterEditMode(); }} - tooltip="Enter edit mode" + tooltip={t('dashboard.toolbar.edit.tooltip', 'Enter edit mode')} key="edit" className={styles.buttonWithExtraMargin} variant={config.featureToggles.newDashboardSharingComponent ? 'secondary' : 'primary'} size="sm" data-testid={selectors.components.NavToolbar.editDashboard.editButton} > - Edit + Edit ), }); @@ -457,14 +456,14 @@ export function ToolbarActions({ dashboard }: Props) { dashboard.onEnterEditMode(); dashboard.setState({ editable: true, meta: { ...meta, canEdit: true } }); }} - tooltip="This dashboard was marked as read only" + tooltip={t('dashboard.toolbar.enter-edit-mode.tooltip', 'This dashboard was marked as read only')} key="edit" className={styles.buttonWithExtraMargin} variant="secondary" size="sm" data-testid={selectors.components.NavToolbar.editDashboard.editButton} > - Make editable + Make editable ), }); @@ -489,14 +488,14 @@ export function ToolbarActions({ dashboard }: Props) { onClick={() => { dashboard.onOpenSettings(); }} - tooltip="Dashboard settings" + tooltip={t('dashboard.toolbar.dashboard-settings.tooltip', 'Dashboard settings')} fill="text" size="sm" key="settings" variant="secondary" data-testid={selectors.components.NavToolbar.editDashboard.settingsButton} > - Settings + Settings ), }); @@ -507,14 +506,14 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -525,7 +524,11 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -544,14 +551,14 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -562,14 +569,14 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -580,14 +587,14 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -604,13 +611,13 @@ export function ToolbarActions({ dashboard }: Props) { dashboard.openSaveDrawer({ saveProvisioned: isProvisionedNG }); }} className={styles.buttonWithExtraMargin} - tooltip="Save changes" + tooltip={t('dashboard.toolbar.save-dashboard.tooltip', 'Save changes')} key="save" size="sm" - variant={'primary'} + variant="primary" data-testid={selectors.components.NavToolbar.editDashboard.saveButton} > - Save dashboard + Save dashboard ); } @@ -623,12 +630,12 @@ export function ToolbarActions({ dashboard }: Props) { dashboard.openSaveDrawer({ saveAsCopy: true }); }} className={styles.buttonWithExtraMargin} - tooltip="Save as copy" + tooltip={t('dashboard.toolbar.save-dashboard-copy.tooltip', 'Save as copy')} key="save" size="sm" variant={isDirty ? 'primary' : 'secondary'} > - Save as copy + Save as copy ); } @@ -637,14 +644,14 @@ export function ToolbarActions({ dashboard }: Props) { const menu = ( { dashboard.openSaveDrawer({}); }} /> { dashboard.openSaveDrawer({ saveAsCopy: true }); @@ -659,16 +666,16 @@ export function ToolbarActions({ dashboard }: Props) { onClick={() => { dashboard.openSaveDrawer({}); }} - tooltip="Save changes" + tooltip={t('dashboard.toolbar.save-dashboard.tooltip', 'Save changes')} size="sm" data-testid={selectors.components.NavToolbar.editDashboard.saveButton} variant={isDirty ? 'primary' : 'secondary'} > - Save dashboard + Save dashboard