diff --git a/packages/grafana-data/src/types/accesscontrol.ts b/packages/grafana-data/src/types/accesscontrol.ts new file mode 100644 index 00000000000..75fc32d69ca --- /dev/null +++ b/packages/grafana-data/src/types/accesscontrol.ts @@ -0,0 +1,14 @@ +import { KeyValue } from '.'; + +/** + * With FGAC, the backend will return additional access control metadata to objects. + * These metadata will contain user permissions associated to a given resource. + * + * For example: + * { + * accessControl: { "datasources:read": true, "datasources:write": true } + * } + */ +export interface WithAccessControlMetadata { + accessControl?: KeyValue; +} diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 2946e7904fe..94276a9114d 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -12,7 +12,7 @@ import { CoreApp } from './app'; import { CustomVariableSupport, DataSourceVariableSupport, StandardVariableSupport } from './variables'; import { makeClassES5Compatible } from '../utils/makeClassES5Compatible'; import { DataQuery } from './query'; -import { DataSourceRef } from '.'; +import { DataSourceRef, WithAccessControlMetadata } from '.'; export interface DataSourcePluginOptionsEditorProps { options: DataSourceSettings; @@ -540,7 +540,8 @@ export interface DataSourceJsonData { * Data Source instance edit model. This is returned from: * /api/datasources */ -export interface DataSourceSettings { +export interface DataSourceSettings + extends WithAccessControlMetadata { id: number; uid: string; orgId: number; diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index ea9672f26ae..3a8729798b2 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -39,3 +39,4 @@ export { isUnsignedPluginSignature } from './pluginSignature'; export { GrafanaConfig, BuildInfo, FeatureToggles, LicenseInfo, PreloadPlugin } from './config'; export * from './alerts'; export * from './slider'; +export * from './accesscontrol'; diff --git a/pkg/api/api.go b/pkg/api/api.go index 98a2751797c..e6a22a5d3d8 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -271,8 +271,8 @@ func (hs *HTTPServer) registerRoutes() { datasourceRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesDelete, ScopeDatasourceID)), routing.Wrap(hs.DeleteDataSourceById)) datasourceRoute.Delete("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesDelete, ScopeDatasourceUID)), routing.Wrap(hs.DeleteDataSourceByUID)) datasourceRoute.Delete("/name/:name", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesDelete, ScopeDatasourceName)), routing.Wrap(hs.DeleteDataSourceByName)) - datasourceRoute.Get("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead, ScopeDatasourceID)), routing.Wrap(GetDataSourceById)) - datasourceRoute.Get("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead, ScopeDatasourceUID)), routing.Wrap(GetDataSourceByUID)) + datasourceRoute.Get("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead, ScopeDatasourceID)), routing.Wrap(hs.GetDataSourceById)) + datasourceRoute.Get("/uid/:uid", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead, ScopeDatasourceUID)), routing.Wrap(hs.GetDataSourceByUID)) datasourceRoute.Get("/name/:name", authorize(reqOrgAdmin, ac.EvalPermission(ActionDatasourcesRead, ScopeDatasourceName)), routing.Wrap(GetDataSourceByName)) }) diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index cd9f8c5f871..632df3f99e7 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/adapters" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -64,7 +65,28 @@ func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response { return response.JSON(200, &result) } -func GetDataSourceById(c *models.ReqContext) response.Response { +func (hs *HTTPServer) getDataSourceAccessControlMetadata(c *models.ReqContext, dsID int64) (accesscontrol.Metadata, error) { + if hs.AccessControl.IsDisabled() || !c.QueryBool("accesscontrol") { + return nil, nil + } + + userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser) + if err != nil || len(userPermissions) == 0 { + return nil, err + } + + key := fmt.Sprintf("%d", dsID) + dsIDs := map[string]bool{key: true} + + metadata, err := accesscontrol.GetResourcesMetadata(c.Req.Context(), userPermissions, "datasources", dsIDs) + if err != nil { + return nil, err + } + + return metadata[key], err +} + +func (hs *HTTPServer) GetDataSourceById(c *models.ReqContext) response.Response { query := models.GetDataSourceQuery{ Id: c.ParamsInt64(":id"), OrgId: c.OrgId, @@ -83,6 +105,13 @@ func GetDataSourceById(c *models.ReqContext) response.Response { ds := query.Result dtos := convertModelToDtos(ds) + // Add accesscontrol metadata + metadata, err := hs.getDataSourceAccessControlMetadata(c, ds.Id) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to query metadata", err) + } + dtos.AccessControl = metadata + return response.JSON(200, &dtos) } @@ -118,17 +147,25 @@ func (hs *HTTPServer) DeleteDataSourceById(c *models.ReqContext) response.Respon } // GET /api/datasources/uid/:uid -func GetDataSourceByUID(c *models.ReqContext) response.Response { +func (hs *HTTPServer) GetDataSourceByUID(c *models.ReqContext) response.Response { ds, err := getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.OrgId) if err != nil { if errors.Is(err, models.ErrDataSourceNotFound) { - return response.Error(404, "Data source not found", nil) + return response.Error(http.StatusNotFound, "Data source not found", nil) } - return response.Error(500, "Failed to query datasources", err) + return response.Error(http.StatusInternalServerError, "Failed to query datasource", err) } dtos := convertModelToDtos(ds) + + // Add accesscontrol metadata + metadata, err := hs.getDataSourceAccessControlMetadata(c, ds.Id) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to query metadata", err) + } + dtos.AccessControl = metadata + return response.JSON(200, &dtos) } diff --git a/pkg/api/dtos/datasource.go b/pkg/api/dtos/datasource.go index 58a40d8d327..6321d08d7b2 100644 --- a/pkg/api/dtos/datasource.go +++ b/pkg/api/dtos/datasource.go @@ -5,29 +5,31 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" ) type DataSource struct { - Id int64 `json:"id"` - UID string `json:"uid"` - OrgId int64 `json:"orgId"` - Name string `json:"name"` - Type string `json:"type"` - TypeLogoUrl string `json:"typeLogoUrl"` - Access models.DsAccess `json:"access"` - Url string `json:"url"` - Password string `json:"password"` - User string `json:"user"` - Database string `json:"database"` - BasicAuth bool `json:"basicAuth"` - BasicAuthUser string `json:"basicAuthUser"` - BasicAuthPassword string `json:"basicAuthPassword"` - WithCredentials bool `json:"withCredentials"` - IsDefault bool `json:"isDefault"` - JsonData *simplejson.Json `json:"jsonData,omitempty"` - SecureJsonFields map[string]bool `json:"secureJsonFields"` - Version int `json:"version"` - ReadOnly bool `json:"readOnly"` + Id int64 `json:"id"` + UID string `json:"uid"` + OrgId int64 `json:"orgId"` + Name string `json:"name"` + Type string `json:"type"` + TypeLogoUrl string `json:"typeLogoUrl"` + Access models.DsAccess `json:"access"` + Url string `json:"url"` + Password string `json:"password"` + User string `json:"user"` + Database string `json:"database"` + BasicAuth bool `json:"basicAuth"` + BasicAuthUser string `json:"basicAuthUser"` + BasicAuthPassword string `json:"basicAuthPassword"` + WithCredentials bool `json:"withCredentials"` + IsDefault bool `json:"isDefault"` + JsonData *simplejson.Json `json:"jsonData,omitempty"` + SecureJsonFields map[string]bool `json:"secureJsonFields"` + Version int `json:"version"` + ReadOnly bool `json:"readOnly"` + AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"` } type DataSourceListItemDTO struct { diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index 2b733e8f8bd..c8869241cda 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -42,6 +42,10 @@ type ResourceStore interface { GetResourcesPermissions(ctx context.Context, orgID int64, query GetResourcesPermissionsQuery) ([]ResourcePermission, error) } +// Metadata contains user accesses for a given resource +// Ex: map[string]bool{"create":true, "delete": true} +type Metadata map[string]bool + // HasGlobalAccess checks user access with globally assigned permissions only func HasGlobalAccess(ac AccessControl, c *models.ReqContext) func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { return func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { @@ -116,3 +120,43 @@ func ValidateScope(scope string) bool { } return !strings.ContainsAny(prefix, "*?") } + +func addActionToMetadata(allMetadata map[string]Metadata, action, id string) map[string]Metadata { + metadata, initialized := allMetadata[id] + if !initialized { + metadata = Metadata{action: true} + } else { + metadata[action] = true + } + allMetadata[id] = metadata + return allMetadata +} + +// GetResourcesMetadata returns a map of accesscontrol metadata, listing for each resource, users available actions +func GetResourcesMetadata(ctx context.Context, permissions []*Permission, resource string, resourceIDs map[string]bool) (map[string]Metadata, error) { + allScope := GetResourceAllScope(resource) + allIDScope := GetResourceAllIDScope(resource) + + // prefix of ID based scopes (resource:id) + idPrefix := Scope(resource, "id") + // index of the ID in the scope + idIndex := len(idPrefix) + 1 + + // Loop through permissions once + result := map[string]Metadata{} + for _, p := range permissions { + if p.Scope == "*" || p.Scope == allScope || p.Scope == allIDScope { + // Add global action to all resources + for id := range resourceIDs { + result = addActionToMetadata(result, p.Action, id) + } + } else { + if len(p.Scope) > idIndex && strings.HasPrefix(p.Scope, idPrefix) && resourceIDs[p.Scope[idIndex:]] { + // Add action to a specific resource + result = addActionToMetadata(result, p.Action, p.Scope[idIndex:]) + } + } + } + + return result, nil +} diff --git a/pkg/services/accesscontrol/accesscontrol_bench_test.go b/pkg/services/accesscontrol/accesscontrol_bench_test.go new file mode 100644 index 00000000000..a6491253464 --- /dev/null +++ b/pkg/services/accesscontrol/accesscontrol_bench_test.go @@ -0,0 +1,65 @@ +// go:build integration +package accesscontrol + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestEnv(b *testing.B, resourceCount, permissionPerResource int) ([]*Permission, map[string]bool) { + res := make([]*Permission, resourceCount*permissionPerResource) + ids := make(map[string]bool, resourceCount) + + for r := 0; r < resourceCount; r++ { + for p := 0; p < permissionPerResource; p++ { + perm := Permission{Action: fmt.Sprintf("resources:action%v", p), Scope: fmt.Sprintf("resources:id:%v", r)} + id := r*permissionPerResource + p + res[id] = &perm + } + ids[fmt.Sprintf("%d", r)] = true + } + + return res, ids +} + +func benchGetMetadata(b *testing.B, resourceCount, permissionPerResource int) { + permissions, ids := setupTestEnv(b, resourceCount, permissionPerResource) + b.ResetTimer() + + var metadata map[string]Metadata + var err error + for n := 0; n < b.N; n++ { + metadata, err = GetResourcesMetadata(context.Background(), permissions, "resources", ids) + require.NoError(b, err) + assert.Len(b, metadata, resourceCount) + for _, resourceMetadata := range metadata { + assert.Len(b, resourceMetadata, permissionPerResource) + } + } +} + +// Lots of permissions +func BenchmarkGetResourcesMetadata_10_1000(b *testing.B) { benchGetMetadata(b, 10, 1000) } // ~0.0017s/op +func BenchmarkGetResourcesMetadata_10_10000(b *testing.B) { benchGetMetadata(b, 10, 10000) } // ~0.016s/op +func BenchmarkGetResourcesMetadata_10_100000(b *testing.B) { benchGetMetadata(b, 10, 100000) } // ~0.17s/op +func BenchmarkGetResourcesMetadata_10_1000000(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchGetMetadata(b, 10, 1000000) +} // ~3.89s/op + +// Lots of resources (worst case) +func BenchmarkGetResourcesMetadata_1000_10(b *testing.B) { benchGetMetadata(b, 1000, 10) } // ~0,0023s/op +func BenchmarkGetResourcesMetadata_10000_10(b *testing.B) { benchGetMetadata(b, 10000, 10) } // ~0.021s/op +func BenchmarkGetResourcesMetadata_100000_10(b *testing.B) { benchGetMetadata(b, 100000, 10) } // ~0.22s/op +func BenchmarkGetResourcesMetadata_1000000_10(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchGetMetadata(b, 1000000, 10) +} // ~2.8s/op diff --git a/pkg/services/accesscontrol/accesscontrol_test.go b/pkg/services/accesscontrol/accesscontrol_test.go new file mode 100644 index 00000000000..0aa5d7e80f8 --- /dev/null +++ b/pkg/services/accesscontrol/accesscontrol_test.go @@ -0,0 +1,104 @@ +package accesscontrol + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetResourcesMetadata(t *testing.T) { + tests := []struct { + desc string + resource string + resourcesIDs map[string]bool + permissions []*Permission + expected map[string]Metadata + }{ + { + desc: "Should return no permission for resources 1,2,3 given the user has no permission", + resource: "resources", + resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, + expected: map[string]Metadata{}, + }, + { + desc: "Should return no permission for resources 1,2,3 given the user has permissions for 4 only", + resource: "resources", + permissions: []*Permission{ + {Action: "resources:action1", Scope: Scope("resources", "id", "4")}, + {Action: "resources:action2", Scope: Scope("resources", "id", "4")}, + {Action: "resources:action3", Scope: Scope("resources", "id", "4")}, + }, + resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, + expected: map[string]Metadata{}, + }, + { + desc: "Should only return permissions for resources 1 and 2, given the user has no permissions for 3", + resource: "resources", + permissions: []*Permission{ + {Action: "resources:action1", Scope: Scope("resources", "id", "1")}, + {Action: "resources:action2", Scope: Scope("resources", "id", "2")}, + {Action: "resources:action3", Scope: Scope("resources", "id", "2")}, + }, + resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, + expected: map[string]Metadata{ + "1": {"resources:action1": true}, + "2": {"resources:action2": true, "resources:action3": true}, + }, + }, + { + desc: "Should return permissions with global scopes for resources 1,2,3", + resource: "resources", + permissions: []*Permission{ + {Action: "resources:action4", Scope: Scope("resources", "id", "*")}, + {Action: "resources:action5", Scope: Scope("resources", "*")}, + {Action: "resources:action6", Scope: "*"}, + {Action: "resources:action1", Scope: Scope("resources", "id", "1")}, + {Action: "resources:action2", Scope: Scope("resources", "id", "2")}, + {Action: "resources:action3", Scope: Scope("resources", "id", "2")}, + }, + resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, + expected: map[string]Metadata{ + "1": {"resources:action1": true, "resources:action4": true, "resources:action5": true, "resources:action6": true}, + "2": {"resources:action2": true, "resources:action3": true, "resources:action4": true, "resources:action5": true, "resources:action6": true}, + "3": {"resources:action4": true, "resources:action5": true, "resources:action6": true}, + }, + }, + { + desc: "Should correctly filter out irrelevant permissions for resources 1,2,3", + resource: "resources", + permissions: []*Permission{ + {Action: "resources:action1", Scope: Scope("resources", "id", "1")}, + {Action: "otherresources:action1", Scope: Scope("resources", "id", "1")}, + {Action: "resources:action2", Scope: Scope("otherresources", "id", "*")}, + {Action: "otherresources:action1", Scope: Scope("otherresources", "id", "*")}, + }, + resourcesIDs: map[string]bool{"1": true, "2": true, "3": true}, + expected: map[string]Metadata{ + "1": {"resources:action1": true, "otherresources:action1": true}, + }, + }, + { + desc: "Should correctly handle permissions with multilayer scope", + resource: "resources:sub", + permissions: []*Permission{ + {Action: "resources:action1", Scope: Scope("resources", "sub", "id", "1")}, + {Action: "resources:action1", Scope: Scope("resources", "sub", "id", "123")}, + }, + resourcesIDs: map[string]bool{"1": true, "123": true}, + expected: map[string]Metadata{ + "1": {"resources:action1": true}, + "123": {"resources:action1": true}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + metadata, err := GetResourcesMetadata(context.Background(), tt.permissions, tt.resource, tt.resourcesIDs) + require.NoError(t, err) + + assert.EqualValues(t, tt.expected, metadata) + }) + } +} diff --git a/pkg/services/accesscontrol/database/resource.go b/pkg/services/accesscontrol/database/resource.go index 2f054c5436d..05ae41ce6e0 100644 --- a/pkg/services/accesscontrol/database/resource.go +++ b/pkg/services/accesscontrol/database/resource.go @@ -101,7 +101,7 @@ func (s *AccessControlStore) setResourcePermissions( ` var current []accesscontrol.Permission - if err := sess.SQL(rawSQL, role.ID, getResourceScope(cmd.Resource, cmd.ResourceID)).Find(¤t); err != nil { + if err := sess.SQL(rawSQL, role.ID, accesscontrol.GetResourceScope(cmd.Resource, cmd.ResourceID)).Find(¤t); err != nil { return nil, err } @@ -162,7 +162,7 @@ func (s *AccessControlStore) RemoveResourcePermission(ctx context.Context, orgID args := []interface{}{ orgID, cmd.PermissionID, - getResourceScope(cmd.Resource, cmd.ResourceID), + accesscontrol.GetResourceScope(cmd.Resource, cmd.ResourceID), } for _, a := range cmd.Actions { @@ -307,12 +307,12 @@ func getResourcesPermissions(sess *sqlstore.DBSession, orgID int64, query access args := []interface{}{ orgID, - getResourceAllScope(query.Resource), - getResourceAllIDScope(query.Resource), + accesscontrol.GetResourceAllScope(query.Resource), + accesscontrol.GetResourceAllIDScope(query.Resource), } for _, id := range query.ResourceIDs { - args = append(args, getResourceScope(query.Resource, id)) + args = append(args, accesscontrol.GetResourceScope(query.Resource, id)) } for _, a := range query.Actions { @@ -333,14 +333,14 @@ func getResourcesPermissions(sess *sqlstore.DBSession, orgID int64, query access return nil, err } - scopeAll := getResourceAllScope(query.Resource) - scopeAllIDs := getResourceAllIDScope(query.Resource) + scopeAll := accesscontrol.GetResourceAllScope(query.Resource) + scopeAllIDs := accesscontrol.GetResourceAllIDScope(query.Resource) out := make([]accesscontrol.ResourcePermission, 0, len(result)) // Add resourceIds and generate permissions for `*`, `resource:*` and `resource:id:*` // TODO: handle scope with other key prefixes e.g. `resource:name:*` and `resource:name:name` for _, id := range query.ResourceIDs { - scope := getResourceScope(query.Resource, id) + scope := accesscontrol.GetResourceScope(query.Resource, id) for _, p := range result { if p.Scope == scope || p.Scope == scopeAll || p.Scope == scopeAllIDs || p.Scope == "*" { p.ResourceID = id @@ -490,7 +490,7 @@ func getManagedPermissions(sess *sqlstore.DBSession, resourceID string, ids []in func managedPermission(action, resource string, resourceID string) accesscontrol.Permission { return accesscontrol.Permission{ Action: action, - Scope: getResourceScope(resource, resourceID), + Scope: accesscontrol.GetResourceScope(resource, resourceID), } } @@ -505,15 +505,3 @@ func managedTeamRoleName(teamID int64) string { func managedBuiltInRoleName(builtinRole string) string { return fmt.Sprintf("managed:builtins:%s:permissions", strings.ToLower(builtinRole)) } - -func getResourceScope(resource string, resourceID string) string { - return fmt.Sprintf("%s:id:%s", resource, resourceID) -} - -func getResourceAllScope(resource string) string { - return fmt.Sprintf("%s:*", resource) -} - -func getResourceAllIDScope(resource string) string { - return fmt.Sprintf("%s:id:*", resource) -} diff --git a/pkg/services/accesscontrol/database/resource_test.go b/pkg/services/accesscontrol/database/resource_test.go index 125550d361e..9b822bc3509 100644 --- a/pkg/services/accesscontrol/database/resource_test.go +++ b/pkg/services/accesscontrol/database/resource_test.go @@ -82,7 +82,7 @@ func TestAccessControlStore_SetUserResourcePermissions(t *testing.T) { require.NoError(t, err) assert.Len(t, added, len(test.actions)) for _, p := range added { - assert.Equal(t, getResourceScope(test.resource, test.resourceID), p.Scope) + assert.Equal(t, accesscontrol.GetResourceScope(test.resource, test.resourceID), p.Scope) } }) } @@ -158,7 +158,7 @@ func TestAccessControlStore_SetTeamResourcePermissions(t *testing.T) { require.NoError(t, err) assert.Len(t, added, len(test.actions)) for _, p := range added { - assert.Equal(t, getResourceScope(test.resource, test.resourceID), p.Scope) + assert.Equal(t, accesscontrol.GetResourceScope(test.resource, test.resourceID), p.Scope) } }) } @@ -234,7 +234,7 @@ func TestAccessControlStore_SetBuiltinResourcePermissions(t *testing.T) { require.NoError(t, err) assert.Len(t, added, len(test.actions)) for _, p := range added { - assert.Equal(t, getResourceScope(test.resource, test.resourceID), p.Scope) + assert.Equal(t, accesscontrol.GetResourceScope(test.resource, test.resourceID), p.Scope) } }) } diff --git a/pkg/services/accesscontrol/scope.go b/pkg/services/accesscontrol/scope.go index 853313a4d3d..d5f87ee3660 100644 --- a/pkg/services/accesscontrol/scope.go +++ b/pkg/services/accesscontrol/scope.go @@ -7,6 +7,18 @@ import ( "github.com/grafana/grafana/pkg/models" ) +func GetResourceScope(resource string, resourceID string) string { + return Scope(resource, "id", resourceID) +} + +func GetResourceAllScope(resource string) string { + return Scope(resource, "*") +} + +func GetResourceAllIDScope(resource string) string { + return Scope(resource, "id", "*") +} + // Scope builds scope from parts // e.g. Scope("users", "*") return "users:*" func Scope(parts ...string) string { diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index deb0d0dda1c..255cf24b402 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -1,6 +1,6 @@ import config from '../../core/config'; import { extend } from 'lodash'; -import { rangeUtil } from '@grafana/data'; +import { rangeUtil, WithAccessControlMetadata } from '@grafana/data'; import { AccessControlAction, UserPermission } from 'app/types'; export class User { @@ -85,6 +85,16 @@ export class ContextSrv { return config.licenseInfo.hasLicense && config.featureToggles['accesscontrol']; } + // Checks whether user has required permission + hasPermissionInMetadata(action: AccessControlAction | string, object: WithAccessControlMetadata): boolean { + // Fallback if access control disabled + if (!config.featureToggles['accesscontrol']) { + return true; + } + + return !!object.accessControl?.[action]; + } + // Checks whether user has required permission hasPermission(action: AccessControlAction | string): boolean { // Fallback if access control disabled diff --git a/public/app/core/utils/accessControl.ts b/public/app/core/utils/accessControl.ts new file mode 100644 index 00000000000..b2ee9fac281 --- /dev/null +++ b/public/app/core/utils/accessControl.ts @@ -0,0 +1,9 @@ +import config from '../../core/config'; + +// addAccessControlQueryParam appends ?accesscontrol=true to a url when accesscontrol is enabled +export function addAccessControlQueryParam(url: string): string { + if (!config.featureToggles['accesscontrol']) { + return url; + } + return url + '?accesscontrol=true'; +} diff --git a/public/app/features/datasources/settings/ButtonRow.test.tsx b/public/app/features/datasources/settings/ButtonRow.test.tsx index 95887f7b753..aa713dd557d 100644 --- a/public/app/features/datasources/settings/ButtonRow.test.tsx +++ b/public/app/features/datasources/settings/ButtonRow.test.tsx @@ -12,7 +12,8 @@ jest.mock('app/core/core', () => { const setup = (propOverrides?: object) => { const props: Props = { - isReadOnly: true, + canSave: false, + canDelete: false, onSubmit: jest.fn(), onDelete: jest.fn(), onTest: jest.fn(), @@ -33,7 +34,8 @@ describe('Render', () => { it('should render with buttons enabled', () => { const wrapper = setup({ - isReadOnly: false, + canSave: true, + canDelete: true, }); expect(wrapper).toMatchSnapshot(); diff --git a/public/app/features/datasources/settings/ButtonRow.tsx b/public/app/features/datasources/settings/ButtonRow.tsx index cf5ee44fef0..e2907d72889 100644 --- a/public/app/features/datasources/settings/ButtonRow.tsx +++ b/public/app/features/datasources/settings/ButtonRow.tsx @@ -8,15 +8,14 @@ import { contextSrv } from 'app/core/core'; export interface Props { exploreUrl: string; - isReadOnly: boolean; + canSave: boolean; + canDelete: boolean; onDelete: () => void; onSubmit: (event: any) => void; onTest: (event: any) => void; } -const ButtonRow: FC = ({ isReadOnly, onDelete, onSubmit, onTest, exploreUrl }) => { - const canEditDataSources = !isReadOnly && contextSrv.hasPermission(AccessControlAction.DataSourcesWrite); - const canDeleteDataSources = !isReadOnly && contextSrv.hasPermission(AccessControlAction.DataSourcesDelete); +const ButtonRow: FC = ({ canSave, canDelete, onDelete, onSubmit, onTest, exploreUrl }) => { const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore); return ( @@ -30,24 +29,24 @@ const ButtonRow: FC = ({ isReadOnly, onDelete, onSubmit, onTest, exploreU - {canEditDataSources && ( + {canSave && ( )} - {!canEditDataSources && ( + {!canSave && ( diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx index c1ab359fdaf..12f7226151b 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx @@ -13,6 +13,7 @@ jest.mock('app/core/core', () => { return { contextSrv: { hasPermission: () => true, + hasPermissionInMetadata: () => true, }, }; }); diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx index 134a09f7598..f4d12900901 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx @@ -167,8 +167,9 @@ export class DataSourceSettingsPage extends PureComponent { } renderLoadError() { - const { loadError } = this.props; - const canDeleteDataSources = !this.isReadOnly() && contextSrv.hasPermission(AccessControlAction.DataSourcesDelete); + const { loadError, dataSource } = this.props; + const canDeleteDataSource = + !this.isReadOnly() && contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource); const node = { text: loadError!, @@ -185,7 +186,7 @@ export class DataSourceSettingsPage extends PureComponent { {this.isReadOnly() && this.renderIsReadOnlyMessage()}
- {canDeleteDataSources && ( + {canDeleteDataSource && ( @@ -230,11 +231,12 @@ export class DataSourceSettingsPage extends PureComponent { renderSettings() { const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, plugin, testingStatus } = this.props; - const canEditDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesWrite); + const canWriteDataSource = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesWrite, dataSource); + const canDeleteDataSource = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource); return (
- {!canEditDataSources && this.renderMissingEditRightsMessage()} + {!canWriteDataSource && this.renderMissingEditRightsMessage()} {this.isReadOnly() && this.renderIsReadOnlyMessage()} {dataSourceMeta.state && (
@@ -277,7 +279,8 @@ export class DataSourceSettingsPage extends PureComponent { this.onSubmit(event)} - isReadOnly={this.isReadOnly()} + canSave={!this.isReadOnly() && canWriteDataSource} + canDelete={!this.isReadOnly() && canDeleteDataSource} onDelete={this.onDelete} onTest={(event) => this.onTest(event)} exploreUrl={this.onNavigateToExplore()} diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 4f058064196..fdc591630c7 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -25,6 +25,7 @@ import { testDataSourceSucceeded, } from './reducers'; import { getDataSource, getDataSourceMeta } from './selectors'; +import { addAccessControlQueryParam } from 'app/core/utils/accessControl'; export interface DataSourceTypesLoadedPayload { plugins: DataSourcePluginMeta[]; @@ -154,7 +155,7 @@ export async function getDataSourceUsingUidOrId(uid: string | number): Promise({ method: 'GET', - url: `/api/datasources/uid/${uid}`, + url: addAccessControlQueryParam(`/api/datasources/uid/${uid}`), showErrorAlert: false, }) ); @@ -172,7 +173,7 @@ export async function getDataSourceUsingUidOrId(uid: string | number): Promise({ method: 'GET', - url: `/api/datasources/${id}`, + url: addAccessControlQueryParam(`/api/datasources/${id}`), showErrorAlert: false, }) );