Datasource Permissions: Allow sending in query param ds_type and add database migration (#121501)

This commit is contained in:
Stephanie Hingtgen 2026-03-31 14:34:11 -06:00 committed by GitHub
parent 84d26bde60
commit 8291111ea1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 231 additions and 57 deletions

View file

@ -23,6 +23,14 @@
# - action: 'users:write'
# scope: 'global.users:*'
# - action: 'users:create'
# # Optional datasource plugin type for data source scoped permissions (`datasourceType`).
# # If omitted and scope is `datasources:uid:<uid>`, the type is resolved from the data source when the role is provisioned.
# # Provisioning fails if that UID if multiple data sources share the same UID, then you must set `datasourceType` explicitly.
# - action: 'datasources:read'
# scope: 'datasources:uid:cfh1oc3kvsw00a'
# - action: 'datasources:read'
# scope: 'datasources:uid:another-uid'
# datasourceType: prometheus
# - name: 'custom:global:users:reader'
# # <bool> overwrite org id and creates a global role.
# global: true

View file

@ -120,6 +120,12 @@ roles:
- action: 'users:write'
scope: 'users:*'
- action: 'users:create'
# Optional `datasourceType` for scopes `datasources:uid:<DATASOURCE_UID>`.
# If you omit it, Grafana resolves the plugin type from the data source when this file is provisioned.
# It is required if there are two datasources with the same uid.
- action: 'datasources:query'
scope: 'datasources:uid:loki-uid-here'
datasourceType: loki
- name: 'custom:global:users:reader'
# <bool> overwrite org id and creates a global role.
global: true

View file

@ -33,12 +33,18 @@ This API can be used to list, add and remove permissions for a data source.
Permissions can be set for a user, team, service account or a basic role (Admin, Editor, Viewer).
### Optional `ds_type` query parameter {#ds-type}
Every endpoint in this API accepts an optional query parameter `ds_type`. Set it to the data source **plugin type** (for example `prometheus` or `loki`). Use `ds_type` when more than one data source in the organization shares the same UID so Grafana can resolve the correct instance. If the UID is unique in the organization, you can omit `ds_type`.
## Get permissions for a data source
`GET /api/access-control/datasources/:uid`
Gets all existing permissions for the data source with the given `uid`.
Append `?ds_type=<TYPE>` when you need to disambiguate the UID; refer to [Optional `ds_type` query parameter](#ds-type).
**Required permissions**
See note in the [introduction](#data-source-permissions-api) for an explanation.
@ -58,6 +64,15 @@ Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example request (with `ds_type` when the UID is not unique):**
```http
GET /api/access-control/datasources/my_datasource?ds_type=prometheus HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example response:**
```http
@ -131,6 +146,8 @@ Status codes:
Sets user permission for the data source with the given `uid`.
Append `?ds_type=<TYPE>` when you need to disambiguate the UID; refer to [Optional `ds_type` query parameter](#ds-type).
To add a permission, set the `permission` field to either `Query`, `Edit`, or `Admin`.
To remove a permission, set the `permission` field to an empty string.
@ -203,6 +220,8 @@ Status codes:
Sets team permission for the data source with the given `uid`.
Append `?ds_type=<TYPE>` when you need to disambiguate the UID; refer to [Optional `ds_type` query parameter](#ds-type).
To add a permission, set the `permission` field to either `Query`, `Edit`, or `Admin`.
To remove a permission, set the `permission` field to an empty string.
@ -275,6 +294,8 @@ Status codes:
Sets permission for the data source with the given `uid` to all users who have the specified basic role.
Append `?ds_type=<TYPE>` when you need to disambiguate the UID; refer to [Optional `ds_type` query parameter](#ds-type).
You can set permissions for the following basic roles: `Admin`, `Editor`, `Viewer`.
To add a permission, set the `permission` field to either `Query`, `Edit`, or `Admin`.

View file

@ -1,6 +1,7 @@
package datasource
import (
"context"
"encoding/json"
"fmt"
@ -11,8 +12,8 @@ import (
dsV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service"
"github.com/grafana/grafana/pkg/setting"
)
@ -30,19 +31,19 @@ type ConnectionClient interface {
// as we cannot guarantee which resource the caller intended to get.
//
// Deprecated: Use /apis/<type>.datasource.grafana.app/v0alpha1/namespaces/{ns}/datasources/{uid} instead.
GetConnectionByUID(c *contextmodel.ReqContext, uid string) (*dsV0.DataSourceConnectionList, error)
GetConnectionByUID(ctx context.Context, orgID int64, uid string) (*dsV0.DataSourceConnectionList, error)
}
var _ ConnectionClient = (*connectionClientImpl)(nil)
// connectionClientImpl implements the ConnectionClient interface.
type connectionClientImpl struct {
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
clientConfigProvider grafanaapiserver.RestConfigProvider
namespaceMapper request.NamespaceMapper
}
// NewConnectionClient creates a new ConnectionClient that queries the connections endpoint in the query api group.
func NewConnectionClient(cfg *setting.Cfg, provider grafanaapiserver.DirectRestConfigProvider) ConnectionClient {
func NewConnectionClient(cfg *setting.Cfg, provider grafanaapiserver.RestConfigProvider) ConnectionClient {
return &connectionClientImpl{
clientConfigProvider: provider,
namespaceMapper: request.GetNamespaceMapper(cfg),
@ -51,19 +52,23 @@ func NewConnectionClient(cfg *setting.Cfg, provider grafanaapiserver.DirectRestC
// GetConnectionByUID queries GET /apis/datasource.grafana.app/v0alpha1/namespaces/{ns}/connections/{uid}
// Deprecated: Use GetConnectionByTypeAndUID when type is known.
func (cl *connectionClientImpl) GetConnectionByUID(c *contextmodel.ReqContext, uid string) (*dsV0.DataSourceConnectionList, error) {
namespace := cl.namespaceMapper(c.OrgID)
func (cl *connectionClientImpl) GetConnectionByUID(ctx context.Context, orgID int64, uid string) (*dsV0.DataSourceConnectionList, error) {
namespace := cl.namespaceMapper(orgID)
cfg := cl.clientConfigProvider.GetDirectRestConfig(c)
cfg = dynamic.ConfigFor(cfg) // This sets NegotiatedSerializer, required for RESTClientFor
restCfg, err := cl.clientConfigProvider.GetRestConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get rest config: %w", err)
}
cfg := dynamic.ConfigFor(restCfg) // This sets NegotiatedSerializer, required for RESTClientFor
cfg.GroupVersion = &dsV0.SchemeGroupVersion
rest, err := rest.RESTClientFor(cfg)
client, err := rest.RESTClientFor(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create rest client: %w", err)
}
var statusCode int
result := rest.Get().AbsPath("apis", dsV0.GROUP, dsV0.VERSION, "namespaces", namespace, "connections").Param("name", uid).Do(c.Req.Context()).StatusCode(&statusCode)
result := client.Get().AbsPath("apis", dsV0.GROUP, dsV0.VERSION, "namespaces", namespace, "connections").Param("name", uid).Do(ctx).StatusCode(&statusCode)
err = result.Error()
if err != nil {
if errors.IsNotFound(err) {
@ -89,25 +94,25 @@ func (cl *connectionClientImpl) GetConnectionByUID(c *contextmodel.ReqContext, u
// datasource service just to get the datasource type, then forwarding the request
// to the new APIs.
type legacyConnectionClientImpl struct {
datasourceService datasources.DataSourceService
datasourceService datasourceservice.DataSourceRetriever
}
var _ ConnectionClient = (*legacyConnectionClientImpl)(nil)
// NewLegacyConnectionClient creates a new ConnectionClient that relies on the legacy datasource service.
func NewLegacyConnectionClient(datasourceService datasources.DataSourceService) ConnectionClient {
func NewLegacyConnectionClient(datasourceService datasourceservice.DataSourceRetriever) ConnectionClient {
return &legacyConnectionClientImpl{
datasourceService: datasourceService,
}
}
func (cl *legacyConnectionClientImpl) GetConnectionByUID(c *contextmodel.ReqContext, uid string) (*dsV0.DataSourceConnectionList, error) {
func (cl *legacyConnectionClientImpl) GetConnectionByUID(ctx context.Context, orgID int64, uid string) (*dsV0.DataSourceConnectionList, error) {
query := datasources.GetDataSourceQuery{
UID: uid,
OrgID: c.OrgID,
OrgID: orgID,
}
conn, err := cl.datasourceService.GetDataSource(c.Req.Context(), &query)
conn, err := cl.datasourceService.GetDataSource(ctx, &query)
if err != nil {
return nil, err
}

View file

@ -7,7 +7,6 @@ import (
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
@ -16,28 +15,22 @@ import (
clientrest "k8s.io/client-go/rest"
dsV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/web"
)
// implements grafanaapiserver.DirectRestConfigProvider
type mockDirectRestConfigProvider struct {
// implements grafanaapiserver.RestConfigProvider
type mockRestConfigProvider struct {
transport http.RoundTripper
host string
}
func (m *mockDirectRestConfigProvider) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config {
func (m *mockRestConfigProvider) GetRestConfig(_ context.Context) (*clientrest.Config, error) {
return &clientrest.Config{
Host: m.host,
Transport: m.transport,
}
}, nil
}
func (m *mockDirectRestConfigProvider) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {}
func (m *mockDirectRestConfigProvider) IsReady() bool { return true }
type mockRoundTripper struct {
statusCode int
responseBody []byte
@ -100,7 +93,7 @@ func TestGetConnectionByUID(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &connectionClientImpl{
clientConfigProvider: &mockDirectRestConfigProvider{
clientConfigProvider: &mockRestConfigProvider{
transport: &mockRoundTripper{
statusCode: tt.statusCode,
responseBody: tt.responseBody,
@ -110,13 +103,7 @@ func TestGetConnectionByUID(t *testing.T) {
namespaceMapper: func(orgID int64) string { return "default" },
}
req := httptest.NewRequest(http.MethodGet, "/test", nil)
reqCtx := &contextmodel.ReqContext{
Context: &web.Context{Req: req},
SignedInUser: &user.SignedInUser{OrgID: 1},
}
result, err := client.GetConnectionByUID(reqCtx, tt.uid)
result, err := client.GetConnectionByUID(context.Background(), 1, tt.uid)
if tt.expectedError != "" {
require.Error(t, err)
@ -187,13 +174,7 @@ func TestGetConnectionByUIDLegacy(t *testing.T) {
},
}
req := httptest.NewRequest(http.MethodGet, "/test", nil)
reqCtx := &contextmodel.ReqContext{
Context: &web.Context{Req: req},
SignedInUser: &user.SignedInUser{OrgID: 1},
}
conn, err := client.GetConnectionByUID(reqCtx, "uid")
conn, err := client.GetConnectionByUID(context.Background(), 1, "uid")
if tt.expectedError != nil {
require.Error(t, err)
@ -215,6 +196,6 @@ type mockDataSourceService struct {
error error
}
func (m *mockDataSourceService) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
func (m *mockDataSourceService) GetDataSource(_ context.Context, _ *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
return m.response, m.error
}

View file

@ -56,7 +56,7 @@ func (hs *HTTPServer) getK8sDataSourceByUIDHandler() web.Handler {
uid := web.Params(c.Req)[":uid"]
// fetch the datasource type so we know which api group to call
conns, err := hs.dsConnectionClient.GetConnectionByUID(c, uid) // nolint:staticcheck
conns, err := hs.dsConnectionClient.GetConnectionByUID(c.Req.Context(), c.OrgID, uid) // nolint:staticcheck
if err != nil {
if strings.Contains(err.Error(), "not found") {
return response.Error(http.StatusNotFound, "Data source not found", nil)
@ -158,7 +158,7 @@ func (hs *HTTPServer) callK8sDataSourceResourceHandler() web.Handler {
// This uses the deprecated api on purpose because we need to get the connection details for the redirect.
// /api/ we only have the UID so we cannot use the new api until the client updates to /apis/ which will not use this
// redirect.
conns, err := hs.dsConnectionClient.GetConnectionByUID(c, dsUID) //nolint:staticcheck
conns, err := hs.dsConnectionClient.GetConnectionByUID(c.Req.Context(), c.OrgID, dsUID) //nolint:staticcheck
if err != nil {
if strings.Contains(err.Error(), "not found") {
c.JsonApiErr(http.StatusNotFound, "Data source not found", nil)

View file

@ -2,6 +2,7 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
@ -59,7 +60,7 @@ type mockConnectionClient struct {
err error
}
func (m *mockConnectionClient) GetConnectionByUID(_ *contextmodel.ReqContext, _ string) (*queryV0.DataSourceConnectionList, error) {
func (m *mockConnectionClient) GetConnectionByUID(_ context.Context, _ int64, _ string) (*queryV0.DataSourceConnectionList, error) {
return m.result, m.err
}

View file

@ -1,4 +1,4 @@
INSERT INTO {{ .Ident .PermissionTable }} (role_id, action, scope, created, updated, kind, attribute, identifier)
INSERT INTO {{ .Ident .PermissionTable }} (role_id, action, scope, created, updated, kind, attribute, identifier, datasource_type)
VALUES (
{{ .Arg .RoleID }},
{{ .Arg .Permission.Action }},
@ -7,5 +7,6 @@ VALUES (
{{ .Arg .Now }},
{{ .Arg .Permission.Kind }},
{{ .Arg .Permission.Attribute }},
{{ .Arg .Permission.Identifier }}
{{ .Arg .Permission.Identifier }},
{{ .Arg .Permission.DatasourceType }}
)

View file

@ -1,4 +1,4 @@
INSERT INTO `grafana`.`permission` (role_id, action, scope, created, updated, kind, attribute, identifier)
INSERT INTO `grafana`.`permission` (role_id, action, scope, created, updated, kind, attribute, identifier, datasource_type)
VALUES (
23,
'dashboards:view',
@ -7,5 +7,6 @@ VALUES (
'2025-08-27 21:35:00',
'dashboard',
'uid',
'dash1'
'dash1',
''
)

View file

@ -1,4 +1,4 @@
INSERT INTO "grafana"."permission" (role_id, action, scope, created, updated, kind, attribute, identifier)
INSERT INTO "grafana"."permission" (role_id, action, scope, created, updated, kind, attribute, identifier, datasource_type)
VALUES (
23,
'dashboards:view',
@ -7,5 +7,6 @@ VALUES (
'2025-08-27 21:35:00',
'dashboard',
'uid',
'dash1'
'dash1',
''
)

View file

@ -1,4 +1,4 @@
INSERT INTO "grafana"."permission" (role_id, action, scope, created, updated, kind, attribute, identifier)
INSERT INTO "grafana"."permission" (role_id, action, scope, created, updated, kind, attribute, identifier, datasource_type)
VALUES (
23,
'dashboards:view',
@ -7,5 +7,6 @@ VALUES (
'2025-08-27 21:35:00',
'dashboard',
'uid',
'dash1'
'dash1',
''
)

View file

@ -209,9 +209,10 @@ type Permission struct {
Action string `json:"action"`
Scope string `json:"scope"`
Kind string `json:"-"`
Attribute string `json:"-"`
Identifier string `json:"-"`
Kind string `json:"-"`
Attribute string `json:"-"`
Identifier string `json:"-"`
DatasourceType string `json:"-" xorm:"datasource_type"`
Updated time.Time `json:"updated"`
Created time.Time `json:"created"`

View file

@ -207,6 +207,12 @@ func (a *api) getPermissions(c *contextmodel.ReqContext) response.Response {
resourceID := web.Params(c.Req)[":resourceID"]
if a.service.options.RequestValidator != nil {
if _, err := a.service.options.RequestValidator(c.Req, c.GetOrgID(), resourceID); err != nil {
return response.Err(err)
}
}
// Teams-specific redirect: read team permissions from TeamBinding K8s API instead of
// the generic resource permissions API. Falls back to legacy on failure.
//nolint:staticcheck // not yet migrated to OpenFeature
@ -340,6 +346,16 @@ func (a *api) setUserPermission(c *contextmodel.ReqContext) response.Response {
}
resourceID := web.Params(c.Req)[":resourceID"]
if a.service.options.RequestValidator != nil {
enrichedCtx, err := a.service.options.RequestValidator(c.Req, c.GetOrgID(), resourceID)
if err != nil {
return response.Err(err)
}
if enrichedCtx != nil {
c.Req = c.Req.WithContext(enrichedCtx)
}
}
resp := a.validateTeamResource(c, resourceID)
if resp != nil {
return resp
@ -437,6 +453,16 @@ func (a *api) setTeamPermission(c *contextmodel.ReqContext) response.Response {
}
resourceID := web.Params(c.Req)[":resourceID"]
if a.service.options.RequestValidator != nil {
enrichedCtx, err := a.service.options.RequestValidator(c.Req, c.GetOrgID(), resourceID)
if err != nil {
return response.Err(err)
}
if enrichedCtx != nil {
c.Req = c.Req.WithContext(enrichedCtx)
}
}
var cmd setPermissionCommand
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
@ -506,6 +532,16 @@ func (a *api) setBuiltinRolePermission(c *contextmodel.ReqContext) response.Resp
builtInRole := web.Params(c.Req)[":builtInRole"]
resourceID := web.Params(c.Req)[":resourceID"]
if a.service.options.RequestValidator != nil {
enrichedCtx, err := a.service.options.RequestValidator(c.Req, c.GetOrgID(), resourceID)
if err != nil {
return response.Err(err)
}
if enrichedCtx != nil {
c.Req = c.Req.WithContext(enrichedCtx)
}
}
cmd := setPermissionCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
@ -564,9 +600,20 @@ type SetResourcePermissionsParams struct {
func (a *api) setPermissions(c *contextmodel.ReqContext) response.Response {
ctx, span := tracer.Start(c.Req.Context(), "accesscontrol.resourcepermissions.setPermissions")
defer span.End()
c.Req = c.Req.WithContext(ctx)
resourceID := web.Params(c.Req)[":resourceID"]
if a.service.options.RequestValidator != nil {
enrichedCtx, err := a.service.options.RequestValidator(c.Req, c.GetOrgID(), resourceID)
if err != nil {
return response.Err(err)
}
if enrichedCtx != nil {
c.Req = c.Req.WithContext(enrichedCtx)
}
}
cmd := setPermissionsCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "Bad request data: "+err.Error(), err)
@ -585,7 +632,7 @@ func (a *api) setPermissions(c *contextmodel.ReqContext) response.Response {
}
metrics.MAccessResourcePermissionsBackend.WithLabelValues("legacy", "set_bulk", a.service.options.Resource, a.getFallbackStatus()).Inc()
_, err := a.service.SetPermissions(ctx, c.GetOrgID(), resourceID, cmd.Permissions...)
_, err := a.service.SetPermissions(c.Req.Context(), c.GetOrgID(), resourceID, cmd.Permissions...)
if err != nil {
return response.Err(err)
}

View file

@ -11,6 +11,7 @@ type SetResourcePermissionCommand struct {
ResourceID string
ResourceAttribute string
Permission string
DatasourceType string
}
type SetResourcePermissionsCommand struct {

View file

@ -3,6 +3,7 @@ package resourcepermissions
import (
"context"
"fmt"
"net/http"
"strings"
"k8s.io/client-go/dynamic"
@ -65,6 +66,14 @@ type Options struct {
LicenseMW web.Handler
// RestConfigProvider if configured enables K8s API redirect for resource permissions
RestConfigProvider apiserver.DirectRestConfigProvider
// RequestValidator if configured is called before each handler. Return an error to abort the request.
// The returned context, if non-nil, replaces the request context for subsequent processing.
// This allows validators to cache resource metadata in the context so that downstream Set* methods
// can read it without an additional DB lookup. Return (nil, nil) on success if no enrichment needed.
RequestValidator func(r *http.Request, orgID int64, resourceID string) (context.Context, error)
// DatasourceTypeResolver if configured resolves the datasource plugin type for a given resource UID.
// Only set for datasource permission services. Used to populate datasource_type on new permission rows.
DatasourceTypeResolver func(ctx context.Context, orgID int64, resourceID string) (string, error)
}
// GetAction returns the permission action string for a given verb.

View file

@ -225,12 +225,20 @@ func (s *Service) SetUserPermission(ctx context.Context, orgID int64, user acces
return nil, err
}
var datasourceType string
if s.options.DatasourceTypeResolver != nil {
if t, err := s.options.DatasourceTypeResolver(ctx, orgID, resourceID); err == nil {
datasourceType = t
}
}
return s.store.SetUserResourcePermission(ctx, orgID, user, SetResourcePermissionCommand{
Actions: actions,
Permission: permission,
Resource: s.scopeResource(),
ResourceID: resourceID,
ResourceAttribute: s.options.ResourceAttribute,
DatasourceType: datasourceType,
}, s.options.OnSetUser)
}
@ -251,12 +259,20 @@ func (s *Service) SetTeamPermission(ctx context.Context, orgID, teamID int64, re
return nil, err
}
var datasourceType string
if s.options.DatasourceTypeResolver != nil {
if t, err := s.options.DatasourceTypeResolver(ctx, orgID, resourceID); err == nil {
datasourceType = t
}
}
return s.store.SetTeamResourcePermission(ctx, orgID, teamID, SetResourcePermissionCommand{
Actions: actions,
Permission: permission,
Resource: s.scopeResource(),
ResourceID: resourceID,
ResourceAttribute: s.options.ResourceAttribute,
DatasourceType: datasourceType,
}, s.options.OnSetTeam)
}
@ -277,12 +293,20 @@ func (s *Service) SetBuiltInRolePermission(ctx context.Context, orgID int64, bui
return nil, err
}
var datasourceType string
if s.options.DatasourceTypeResolver != nil {
if t, err := s.options.DatasourceTypeResolver(ctx, orgID, resourceID); err == nil {
datasourceType = t
}
}
return s.store.SetBuiltInResourcePermission(ctx, orgID, builtInRole, SetResourcePermissionCommand{
Actions: actions,
Permission: permission,
Resource: s.scopeResource(),
ResourceID: resourceID,
ResourceAttribute: s.options.ResourceAttribute,
DatasourceType: datasourceType,
}, s.options.OnSetBuiltInRole)
}
@ -297,6 +321,13 @@ func (s *Service) SetPermissions(
return nil, err
}
var datasourceType string
if s.options.DatasourceTypeResolver != nil {
if t, err := s.options.DatasourceTypeResolver(ctx, orgID, resourceID); err == nil {
datasourceType = t
}
}
dbCommands := make([]SetResourcePermissionsCommand, 0, len(commands))
for _, cmd := range commands {
if cmd.UserID != 0 {
@ -328,6 +359,7 @@ func (s *Service) SetPermissions(
ResourceID: resourceID,
ResourceAttribute: s.options.ResourceAttribute,
Permission: cmd.Permission,
DatasourceType: datasourceType,
},
})
}

View file

@ -748,6 +748,7 @@ func (s *store) createPermissions(sess *db.Session, roleID int64, cmd SetResourc
p.Created = time.Now()
p.Updated = time.Now()
p.Kind, p.Attribute, p.Identifier = p.SplitScope()
p.DatasourceType = cmd.DatasourceType
permissions = append(permissions, p)
}

View file

@ -274,6 +274,10 @@ type GetDataSourceQuery struct {
// Required
OrgID int64
// Type is the datasource plugin type (e.g. "prometheus", "loki").
// When set alongside UID, it scopes the lookup to that specific type.
Type string
}
type DatasourcesPermissionFilterQuery struct {

View file

@ -78,6 +78,7 @@ func (ss *SqlStore) getDataSource(_ context.Context, query *datasources.GetDataS
UID: query.UID,
Name: query.Name, // nolint:staticcheck
ID: query.ID, // nolint:staticcheck
Type: query.Type,
}
has, err := sess.Get(datasource)

View file

@ -0,0 +1,46 @@
package accesscontrol
import (
"fmt"
"github.com/grafana/grafana/pkg/util/xorm"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
// DatasourceTypeMigrationID is the migration_log id for the datasource_type backfill.
const DatasourceTypeMigrationID = "populate datasource_type in permission table for uid-scoped datasource permissions"
func AddDatasourceTypeMigration(mg *migrator.Migrator) {
mg.AddMigration(DatasourceTypeMigrationID, &datasourceTypeMigrator{})
}
type datasourceTypeMigrator struct {
migrator.MigrationBase
}
var _ migrator.CodeMigration = new(datasourceTypeMigrator)
func (m *datasourceTypeMigrator) SQL(_ migrator.Dialect) string {
return CodeMigrationSQL
}
const backfillDatasourceTypeSQL = `
UPDATE permission AS p
SET datasource_type = (
SELECT ds.type
FROM data_source AS ds
INNER JOIN role AS r ON r.id = p.role_id
WHERE ds.uid = p.identifier AND ds.org_id = r.org_id
LIMIT 1
)
WHERE p.kind = 'datasources' AND p.attribute = 'uid'`
// Exec populates the datasource_type column on permission rows that are scoped
// to a specific datasource UID (kind='datasources', attribute='uid')
func (m *datasourceTypeMigrator) Exec(sess *xorm.Session, _ *migrator.Migrator) error {
if _, err := sess.Exec(backfillDatasourceTypeSQL); err != nil {
return fmt.Errorf("failed to backfill permission.datasource_type: %w", err)
}
return nil
}

View file

@ -226,4 +226,10 @@ func AddMigration(mg *migrator.Migrator) {
mg.AddMigration("alter permission.kind to length 80", migrator.NewRawSQLMigration("").
Postgres("ALTER TABLE permission ALTER COLUMN kind TYPE VARCHAR(80);").
Mysql("ALTER TABLE permission MODIFY kind VARCHAR(80);"))
mg.AddMigration("add datasource_type column to permission table", migrator.NewAddColumnMigration(permissionV1, &migrator.Column{
Name: "datasource_type", Type: migrator.DB_NVarchar, Length: 255, Nullable: true,
}))
AddDatasourceTypeMigration(mg)
}