mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
AccessControl: Add accesscontrol metadata to datasources DTOs (#42675)
* AccessControl: Provide scope to frontend * Covering datasources with accesscontrol metadata * Write benchmark tests for GetResourcesMetadata * Add accesscontrol util and interface * Add the hasPermissionInMetadata function in the frontend access control code * Use IsDisabled rather that performing a feature toggle check Co-authored-by: Karl Persson <kalle.persson@grafana.com>
This commit is contained in:
14
packages/grafana-data/src/types/accesscontrol.ts
Normal file
14
packages/grafana-data/src/types/accesscontrol.ts
Normal file
@@ -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<boolean>;
|
||||
}
|
||||
@@ -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<JSONData = DataSourceJsonData, SecureJSONData = {}> {
|
||||
options: DataSourceSettings<JSONData, SecureJSONData>;
|
||||
@@ -540,7 +540,8 @@ export interface DataSourceJsonData {
|
||||
* Data Source instance edit model. This is returned from:
|
||||
* /api/datasources
|
||||
*/
|
||||
export interface DataSourceSettings<T extends DataSourceJsonData = DataSourceJsonData, S = {}> {
|
||||
export interface DataSourceSettings<T extends DataSourceJsonData = DataSourceJsonData, S = {}>
|
||||
extends WithAccessControlMetadata {
|
||||
id: number;
|
||||
uid: string;
|
||||
orgId: number;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
)
|
||||
|
||||
type DataSource struct {
|
||||
@@ -28,6 +29,7 @@ type DataSource struct {
|
||||
SecureJsonFields map[string]bool `json:"secureJsonFields"`
|
||||
Version int `json:"version"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"`
|
||||
}
|
||||
|
||||
type DataSourceListItemDTO struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
65
pkg/services/accesscontrol/accesscontrol_bench_test.go
Normal file
65
pkg/services/accesscontrol/accesscontrol_bench_test.go
Normal file
@@ -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
|
||||
104
pkg/services/accesscontrol/accesscontrol_test.go
Normal file
104
pkg/services/accesscontrol/accesscontrol_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
9
public/app/core/utils/accessControl.ts
Normal file
9
public/app/core/utils/accessControl.ts
Normal file
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Props> = ({ isReadOnly, onDelete, onSubmit, onTest, exploreUrl }) => {
|
||||
const canEditDataSources = !isReadOnly && contextSrv.hasPermission(AccessControlAction.DataSourcesWrite);
|
||||
const canDeleteDataSources = !isReadOnly && contextSrv.hasPermission(AccessControlAction.DataSourcesDelete);
|
||||
const ButtonRow: FC<Props> = ({ canSave, canDelete, onDelete, onSubmit, onTest, exploreUrl }) => {
|
||||
const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore);
|
||||
|
||||
return (
|
||||
@@ -30,24 +29,24 @@ const ButtonRow: FC<Props> = ({ isReadOnly, onDelete, onSubmit, onTest, exploreU
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!canDeleteDataSources}
|
||||
disabled={!canDelete}
|
||||
onClick={onDelete}
|
||||
aria-label={selectors.pages.DataSource.delete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{canEditDataSources && (
|
||||
{canSave && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canEditDataSources}
|
||||
disabled={!canSave}
|
||||
onClick={(event) => onSubmit(event)}
|
||||
aria-label={selectors.pages.DataSource.saveAndTest}
|
||||
>
|
||||
Save & test
|
||||
</Button>
|
||||
)}
|
||||
{!canEditDataSources && (
|
||||
{!canSave && (
|
||||
<Button type="submit" variant="primary" onClick={onTest}>
|
||||
Test
|
||||
</Button>
|
||||
|
||||
@@ -13,6 +13,7 @@ jest.mock('app/core/core', () => {
|
||||
return {
|
||||
contextSrv: {
|
||||
hasPermission: () => true,
|
||||
hasPermissionInMetadata: () => true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -167,8 +167,9 @@ export class DataSourceSettingsPage extends PureComponent<Props> {
|
||||
}
|
||||
|
||||
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<Props> {
|
||||
<Page.Contents isLoading={this.props.loading}>
|
||||
{this.isReadOnly() && this.renderIsReadOnlyMessage()}
|
||||
<div className="gf-form-button-row">
|
||||
{canDeleteDataSources && (
|
||||
{canDeleteDataSource && (
|
||||
<Button type="submit" variant="destructive" onClick={this.onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
@@ -230,11 +231,12 @@ export class DataSourceSettingsPage extends PureComponent<Props> {
|
||||
|
||||
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 (
|
||||
<form onSubmit={this.onSubmit}>
|
||||
{!canEditDataSources && this.renderMissingEditRightsMessage()}
|
||||
{!canWriteDataSource && this.renderMissingEditRightsMessage()}
|
||||
{this.isReadOnly() && this.renderIsReadOnlyMessage()}
|
||||
{dataSourceMeta.state && (
|
||||
<div className="gf-form">
|
||||
@@ -277,7 +279,8 @@ export class DataSourceSettingsPage extends PureComponent<Props> {
|
||||
|
||||
<ButtonRow
|
||||
onSubmit={(event) => 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()}
|
||||
|
||||
@@ -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<D
|
||||
const byUid = await lastValueFrom(
|
||||
getBackendSrv().fetch<DataSourceSettings>({
|
||||
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<D
|
||||
const response = await lastValueFrom(
|
||||
getBackendSrv().fetch<DataSourceSettings>({
|
||||
method: 'GET',
|
||||
url: `/api/datasources/${id}`,
|
||||
url: addAccessControlQueryParam(`/api/datasources/${id}`),
|
||||
showErrorAlert: false,
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user