RBAC: Add a function to save external service roles (#66299)

* AuthN: Save external services RBAC roles

* Add missing test

* Placing roles in the same group

* Split function to gen role and assignment

* add test case and comments

* Ensure we check external service roles are assigned once only

* Update pkg/services/accesscontrol/models_test.go

Co-authored-by: Misi <mgyongyosi@users.noreply.github.com>

---------

Co-authored-by: Misi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
Gabriel MABILLE
2023-05-09 13:19:38 +02:00
committed by GitHub
co-authored by Misi
parent 04df92ab47
commit 8c6b5a4319
11 changed files with 657 additions and 20 deletions
@@ -35,9 +35,12 @@ func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query acces
INNER JOIN role ON role.id = permission.role_id
` + filter
if query.RolePrefix != "" {
q += " WHERE role.name LIKE ?"
params = append(params, query.RolePrefix+"%")
if len(query.RolePrefixes) > 0 {
q += " WHERE ( " + strings.Repeat("role.name LIKE ? OR ", len(query.RolePrefixes))
q = q[:len(q)-4] + " )" // remove last " OR "
for i := range query.RolePrefixes {
params = append(params, query.RolePrefixes[i]+"%")
}
}
if err := sess.SQL(q, params...).Find(&result); err != nil {
@@ -0,0 +1,198 @@
package database
import (
"context"
"errors"
"fmt"
"time"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
func (s *AccessControlStore) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error {
role := genExternalServiceRole(cmd)
assignment := genExternalServiceAssignment(cmd)
return s.sql.WithDbSession(ctx, func(sess *db.Session) error {
// Create or update the role
existingRole, errSaveRole := s.saveRole(ctx, sess, &role)
if errSaveRole != nil {
return errSaveRole
}
// Assign role to service account
// We update the assignment before the permissions to avoid an edge case.
// If the role is assigned to another service account (which can only happen if the services have the same ID)
// and permissions are updated before the assignment, this would result in the other service account acquiring
// a different set of permissions.
assignment.RoleID = existingRole.ID
errSaveAssign := s.saveUserAssignment(ctx, sess, assignment)
if errSaveAssign != nil {
return errSaveAssign
}
// Update permissions
return s.savePermissions(ctx, sess, existingRole.ID, cmd.Permissions)
})
}
func genExternalServiceRole(cmd accesscontrol.SaveExternalServiceRoleCommand) accesscontrol.Role {
role := accesscontrol.Role{
OrgID: cmd.OrgID,
Version: 1,
Name: fmt.Sprintf("%s%s:permissions", accesscontrol.ExternalServiceRolePrefix, cmd.ExternalServiceID),
UID: fmt.Sprintf("%s%s_permissions", accesscontrol.ExternalServiceRoleUIDPrefix, cmd.ExternalServiceID),
DisplayName: fmt.Sprintf("External Service %s Permissions", cmd.ExternalServiceID),
Description: fmt.Sprintf("External Service %s permissions", cmd.ExternalServiceID),
Group: "External Service",
Hidden: true,
Created: time.Now(),
Updated: time.Now(),
}
if cmd.Global {
role.OrgID = accesscontrol.GlobalOrgID
}
return role
}
func genExternalServiceAssignment(cmd accesscontrol.SaveExternalServiceRoleCommand) accesscontrol.UserRole {
assignment := accesscontrol.UserRole{
OrgID: cmd.OrgID,
UserID: cmd.ServiceAccountID,
Created: time.Now(),
}
if cmd.Global {
assignment.OrgID = accesscontrol.GlobalOrgID
}
return assignment
}
func getRoleByUID(ctx context.Context, sess *db.Session, uid string) (*accesscontrol.Role, error) {
var role accesscontrol.Role
has, err := sess.Where("uid = ?", uid).Get(&role)
if err != nil {
return nil, err
}
if !has {
return nil, accesscontrol.ErrRoleNotFound
}
return &role, nil
}
func getRoleAssignments(ctx context.Context, sess *db.Session, roleID int64) ([]accesscontrol.UserRole, error) {
var assignements []accesscontrol.UserRole
if err := sess.Where("role_id = ?", roleID).Find(&assignements); err != nil {
return nil, err
}
return assignements, nil
}
func getRolePermissions(ctx context.Context, sess *db.Session, id int64) ([]accesscontrol.Permission, error) {
var permissions []accesscontrol.Permission
if err := sess.Where("role_id = ?", id).Find(&permissions); err != nil {
return nil, err
}
return permissions, nil
}
func permissionDiff(previous, new []accesscontrol.Permission) (added, removed []accesscontrol.Permission) {
type key struct{ Action, Scope string }
prevMap := map[key]int64{}
for i := range previous {
prevMap[key{previous[i].Action, previous[i].Scope}] = previous[i].ID
}
newMap := map[key]int64{}
for i := range new {
newMap[key{new[i].Action, new[i].Scope}] = 0
}
for i := range new {
key := key{new[i].Action, new[i].Scope}
if _, already := prevMap[key]; !already {
added = append(added, new[i])
} else {
delete(prevMap, key)
}
}
for p, id := range prevMap {
removed = append(removed, accesscontrol.Permission{ID: id, Action: p.Action, Scope: p.Scope})
}
return added, removed
}
func (*AccessControlStore) saveRole(ctx context.Context, sess *db.Session, role *accesscontrol.Role) (*accesscontrol.Role, error) {
existingRole, err := getRoleByUID(ctx, sess, role.UID)
if err != nil && !errors.Is(err, accesscontrol.ErrRoleNotFound) {
return nil, err
}
if existingRole == nil {
if _, err := sess.Insert(role); err != nil {
return nil, err
}
} else {
role.ID = existingRole.ID
role.Created = existingRole.Created
if _, err := sess.Where("id = ?", existingRole.ID).MustCols("org_id").Update(role); err != nil {
return nil, err
}
}
return getRoleByUID(ctx, sess, role.UID)
}
func (*AccessControlStore) savePermissions(ctx context.Context, sess *db.Session, roleID int64, permissions []accesscontrol.Permission) error {
now := time.Now()
storedPermissions, err := getRolePermissions(ctx, sess, roleID)
if err != nil {
return err
}
added, removed := permissionDiff(storedPermissions, permissions)
if len(added) > 0 {
for i := range added {
added[i].RoleID = roleID
added[i].Created = now
added[i].Updated = now
}
if _, err := sess.Insert(&added); err != nil {
return err
}
}
if len(removed) > 0 {
ids := make([]int64, len(removed))
for i := range removed {
ids[i] = removed[i].ID
}
count, err := sess.In("id", ids).Delete(&accesscontrol.Permission{})
if err != nil {
return err
}
if count != int64(len(removed)) {
return errors.New("failed to delete permissions that have been removed from role")
}
}
return nil
}
func (*AccessControlStore) saveUserAssignment(ctx context.Context, sess *db.Session, assignment accesscontrol.UserRole) error {
// alreadyAssigned checks if the assignment already exists without accounting for the organization
assignments, errGetAssigns := getRoleAssignments(ctx, sess, assignment.RoleID)
if errGetAssigns != nil {
return errGetAssigns
}
if len(assignments) == 0 {
if _, errInsert := sess.Insert(&assignment); errInsert != nil {
return errInsert
}
return nil
}
// Ensure the role was assigned only to this service account
if len(assignments) > 1 || assignments[0].UserID != assignment.UserID {
return errors.New("external service role assigned to another user or service account")
}
// Ensure the assignment is in the correct organization
_, errUpdate := sess.Where("role_id = ? AND user_id = ?", assignment.RoleID, assignment.UserID).MustCols("org_id").Update(&assignment)
return errUpdate
}
@@ -0,0 +1,191 @@
package database
import (
"context"
"fmt"
"testing"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/stretchr/testify/require"
)
func TestAccessControlStore_SaveExternalServiceRole(t *testing.T) {
type run struct {
cmd accesscontrol.SaveExternalServiceRoleCommand
wantErr bool
}
tests := []struct {
name string
runs []run
}{
{
name: "create app role",
runs: []run{
{
cmd: accesscontrol.SaveExternalServiceRoleCommand{
ExternalServiceID: "app1",
Global: true,
ServiceAccountID: 1,
Permissions: []accesscontrol.Permission{
{Action: "users:read", Scope: "users:id:1"},
{Action: "users:read", Scope: "users:id:2"},
},
},
wantErr: false,
},
},
},
{
name: "update app role",
runs: []run{
{
cmd: accesscontrol.SaveExternalServiceRoleCommand{
ExternalServiceID: "app1",
Global: true,
ServiceAccountID: 1,
Permissions: []accesscontrol.Permission{
{Action: "users:read", Scope: "users:id:1"},
{Action: "users:read", Scope: "users:id:2"},
},
},
},
{
cmd: accesscontrol.SaveExternalServiceRoleCommand{
ExternalServiceID: "app1",
Global: true,
ServiceAccountID: 1,
Permissions: []accesscontrol.Permission{
{Action: "users:write", Scope: "users:id:1"},
{Action: "users:write", Scope: "users:id:2"},
},
},
},
},
},
{
name: "allow switching role from local to global and back",
runs: []run{
{
cmd: accesscontrol.SaveExternalServiceRoleCommand{
ExternalServiceID: "app1",
OrgID: 1,
ServiceAccountID: 1,
Permissions: []accesscontrol.Permission{
{Action: "users:read", Scope: "users:id:1"},
{Action: "users:read", Scope: "users:id:2"},
},
},
},
{
cmd: accesscontrol.SaveExternalServiceRoleCommand{
ExternalServiceID: "app1",
Global: true,
ServiceAccountID: 1,
Permissions: []accesscontrol.Permission{
{Action: "users:read", Scope: "users:id:1"},
{Action: "users:read", Scope: "users:id:2"},
},
},
},
{
cmd: accesscontrol.SaveExternalServiceRoleCommand{
ExternalServiceID: "app1",
OrgID: 1,
ServiceAccountID: 1,
Permissions: []accesscontrol.Permission{
{Action: "users:read", Scope: "users:id:1"},
{Action: "users:read", Scope: "users:id:2"},
},
},
},
},
},
{
name: "edge case - remove all permissions",
runs: []run{
{
cmd: accesscontrol.SaveExternalServiceRoleCommand{
ExternalServiceID: "app1",
Global: true,
ServiceAccountID: 1,
Permissions: []accesscontrol.Permission{
{Action: "users:read", Scope: "users:id:1"},
{Action: "users:read", Scope: "users:id:2"},
},
},
},
{
cmd: accesscontrol.SaveExternalServiceRoleCommand{
ExternalServiceID: "app1",
Global: true,
ServiceAccountID: 1,
Permissions: []accesscontrol.Permission{},
},
},
},
},
{
name: "edge case - reassign to another service account",
runs: []run{
{
cmd: accesscontrol.SaveExternalServiceRoleCommand{
ExternalServiceID: "app1",
Global: true,
ServiceAccountID: 1,
},
},
{
cmd: accesscontrol.SaveExternalServiceRoleCommand{
ExternalServiceID: "app1",
Global: true,
ServiceAccountID: 2,
},
wantErr: true,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
s := &AccessControlStore{
sql: db.InitTestDB(t),
}
for i := range tt.runs {
err := s.SaveExternalServiceRole(ctx, tt.runs[i].cmd)
if tt.runs[i].wantErr {
require.Error(t, err)
continue
}
require.NoError(t, err)
errDBSession := s.sql.WithDbSession(ctx, func(sess *db.Session) error {
storedRole, err := getRoleByUID(ctx, sess, fmt.Sprintf("externalservice_%s_permissions", tt.runs[i].cmd.ExternalServiceID))
require.NoError(t, err)
require.NotNil(t, storedRole)
require.Equal(t, tt.runs[i].cmd.Global, storedRole.Global(), "Incorrect global state of the role")
require.Equal(t, tt.runs[i].cmd.OrgID, storedRole.OrgID, "Incorrect OrgID of the role")
storedPerm, err := getRolePermissions(ctx, sess, storedRole.ID)
require.NoError(t, err)
for i := range storedPerm {
storedPerm[i] = accesscontrol.Permission{Action: storedPerm[i].Action, Scope: storedPerm[i].Scope}
}
require.ElementsMatch(t, tt.runs[i].cmd.Permissions, storedPerm)
var assignment accesscontrol.UserRole
has, err := sess.Where("role_id = ? AND user_id = ?", storedRole.ID, tt.runs[i].cmd.ServiceAccountID).Get(&assignment)
require.NoError(t, err)
require.True(t, has)
require.Equal(t, tt.runs[i].cmd.Global, assignment.OrgID == accesscontrol.GlobalOrgID, "Incorrect global state of the assignment")
require.Equal(t, tt.runs[i].cmd.OrgID, assignment.OrgID, "Incorrect OrgID for the role assignment")
return nil
})
require.NoError(t, errDBSession)
}
})
}
}