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:
Gabriel MABILLE
2021-12-15 12:08:15 +01:00
committed by GitHub
parent 2b1ed43cb2
commit c7cabdfd6f
19 changed files with 364 additions and 71 deletions

View File

@@ -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))
})

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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
}

View 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

View 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)
})
}
}

View File

@@ -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(&current); err != nil {
if err := sess.SQL(rawSQL, role.ID, accesscontrol.GetResourceScope(cmd.Resource, cmd.ResourceID)).Find(&current); 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)
}

View File

@@ -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)
}
})
}

View File

@@ -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 {