diff --git a/conf/provisioning/access-control/sample.yaml b/conf/provisioning/access-control/sample.yaml index 9115626697e..d463681da4f 100644 --- a/conf/provisioning/access-control/sample.yaml +++ b/conf/provisioning/access-control/sample.yaml @@ -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:`, 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' # # overwrite org id and creates a global role. # global: true diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md index 8ddaa8b51ee..a9811e508f8 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-grafana-provisioning/index.md @@ -120,6 +120,12 @@ roles: - action: 'users:write' scope: 'users:*' - action: 'users:create' + # Optional `datasourceType` for scopes `datasources: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' # overwrite org id and creates a global role. global: true diff --git a/docs/sources/developer-resources/api-reference/http-api/datasource_permissions.md b/docs/sources/developer-resources/api-reference/http-api/datasource_permissions.md index 772c3c16289..21074b02d44 100644 --- a/docs/sources/developer-resources/api-reference/http-api/datasource_permissions.md +++ b/docs/sources/developer-resources/api-reference/http-api/datasource_permissions.md @@ -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=` 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=` 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=` 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=` 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`. diff --git a/pkg/api/datasource/connections.go b/pkg/api/datasource/connections.go index 20bc4f26b85..8c259c6f5c3 100644 --- a/pkg/api/datasource/connections.go +++ b/pkg/api/datasource/connections.go @@ -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/.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 } diff --git a/pkg/api/datasource/connections_test.go b/pkg/api/datasource/connections_test.go index 9b23275149a..9f48b744b06 100644 --- a/pkg/api/datasource/connections_test.go +++ b/pkg/api/datasource/connections_test.go @@ -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 } diff --git a/pkg/api/datasources_k8s.go b/pkg/api/datasources_k8s.go index 60f994bc880..b10123fba63 100644 --- a/pkg/api/datasources_k8s.go +++ b/pkg/api/datasources_k8s.go @@ -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) diff --git a/pkg/api/datasources_k8s_test.go b/pkg/api/datasources_k8s_test.go index e2a53d85e8f..159ae859938 100644 --- a/pkg/api/datasources_k8s_test.go +++ b/pkg/api/datasources_k8s_test.go @@ -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 } diff --git a/pkg/registry/apis/iam/resourcepermission/queries/permission_insert.sql b/pkg/registry/apis/iam/resourcepermission/queries/permission_insert.sql index 1dc0ac83e3b..06f0412b829 100644 --- a/pkg/registry/apis/iam/resourcepermission/queries/permission_insert.sql +++ b/pkg/registry/apis/iam/resourcepermission/queries/permission_insert.sql @@ -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 }} ) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--permission_insert-insert_permission.sql b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--permission_insert-insert_permission.sql index b8400809f28..b41a3c43c97 100755 --- a/pkg/registry/apis/iam/resourcepermission/testdata/mysql--permission_insert-insert_permission.sql +++ b/pkg/registry/apis/iam/resourcepermission/testdata/mysql--permission_insert-insert_permission.sql @@ -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', + '' ) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--permission_insert-insert_permission.sql b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--permission_insert-insert_permission.sql index 8b5f2c2bae3..2dd414b6d02 100755 --- a/pkg/registry/apis/iam/resourcepermission/testdata/postgres--permission_insert-insert_permission.sql +++ b/pkg/registry/apis/iam/resourcepermission/testdata/postgres--permission_insert-insert_permission.sql @@ -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', + '' ) diff --git a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--permission_insert-insert_permission.sql b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--permission_insert-insert_permission.sql index 8b5f2c2bae3..2dd414b6d02 100755 --- a/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--permission_insert-insert_permission.sql +++ b/pkg/registry/apis/iam/resourcepermission/testdata/sqlite--permission_insert-insert_permission.sql @@ -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', + '' ) diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 507b65d14d6..1de554273c5 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -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"` diff --git a/pkg/services/accesscontrol/resourcepermissions/api.go b/pkg/services/accesscontrol/resourcepermissions/api.go index a4066bf8038..8cc97142643 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api.go +++ b/pkg/services/accesscontrol/resourcepermissions/api.go @@ -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) } diff --git a/pkg/services/accesscontrol/resourcepermissions/models.go b/pkg/services/accesscontrol/resourcepermissions/models.go index c4ea926d843..0e630cb3e84 100644 --- a/pkg/services/accesscontrol/resourcepermissions/models.go +++ b/pkg/services/accesscontrol/resourcepermissions/models.go @@ -11,6 +11,7 @@ type SetResourcePermissionCommand struct { ResourceID string ResourceAttribute string Permission string + DatasourceType string } type SetResourcePermissionsCommand struct { diff --git a/pkg/services/accesscontrol/resourcepermissions/options.go b/pkg/services/accesscontrol/resourcepermissions/options.go index a036cac7053..26894261454 100644 --- a/pkg/services/accesscontrol/resourcepermissions/options.go +++ b/pkg/services/accesscontrol/resourcepermissions/options.go @@ -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. diff --git a/pkg/services/accesscontrol/resourcepermissions/service.go b/pkg/services/accesscontrol/resourcepermissions/service.go index fa8967b9548..e700eb40e22 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service.go +++ b/pkg/services/accesscontrol/resourcepermissions/service.go @@ -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, }, }) } diff --git a/pkg/services/accesscontrol/resourcepermissions/store.go b/pkg/services/accesscontrol/resourcepermissions/store.go index f938a04f39f..c57247fe382 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store.go +++ b/pkg/services/accesscontrol/resourcepermissions/store.go @@ -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) } diff --git a/pkg/services/datasources/models.go b/pkg/services/datasources/models.go index 0fad79ac461..59e83efa9af 100644 --- a/pkg/services/datasources/models.go +++ b/pkg/services/datasources/models.go @@ -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 { diff --git a/pkg/services/datasources/service/store.go b/pkg/services/datasources/service/store.go index e0ef6a88111..16d202b6323 100644 --- a/pkg/services/datasources/service/store.go +++ b/pkg/services/datasources/service/store.go @@ -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) diff --git a/pkg/services/sqlstore/migrations/accesscontrol/datasource_type_migrator.go b/pkg/services/sqlstore/migrations/accesscontrol/datasource_type_migrator.go new file mode 100644 index 00000000000..be4dca21670 --- /dev/null +++ b/pkg/services/sqlstore/migrations/accesscontrol/datasource_type_migrator.go @@ -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 +} diff --git a/pkg/services/sqlstore/migrations/accesscontrol/migrations.go b/pkg/services/sqlstore/migrations/accesscontrol/migrations.go index dc78e5249bb..a0e1704b8ad 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/migrations.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/migrations.go @@ -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) }