From aaf33c792325535733cd7526b068889a1ceb4cd9 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 15 Aug 2024 16:13:27 +0200 Subject: [PATCH] Zanzana: Migrate basic, fixed and custom roles (#91814) * Zanzana: Migrate basic roles permissions * add basic roles assignments * refactor * Sync basic roles permissions in all orgs * migrate fixed roles * map root folders to orgs * fix basic role assignments in orgs * migrate other roles * migrate team roles assignments * add notes about authorization schema * don't migrate fixed roles --- .../accesscontrol/migrator/zanzana.go | 305 +++++++++++++++++- pkg/services/authz/zanzana/schema/README.md | 110 +++++++ pkg/services/authz/zanzana/schema/schema.fga | 1 - pkg/services/authz/zanzana/translations.go | 51 ++- pkg/services/authz/zanzana/zanzana.go | 49 ++- 5 files changed, 504 insertions(+), 12 deletions(-) create mode 100644 pkg/services/authz/zanzana/schema/README.md diff --git a/pkg/services/accesscontrol/migrator/zanzana.go b/pkg/services/accesscontrol/migrator/zanzana.go index 862cb1caf74..437525e6724 100644 --- a/pkg/services/accesscontrol/migrator/zanzana.go +++ b/pkg/services/accesscontrol/migrator/zanzana.go @@ -3,6 +3,7 @@ package migrator import ( "context" "fmt" + "slices" "strconv" "strings" @@ -36,6 +37,11 @@ func NewZanzanaSynchroniser(client zanzana.Client, store db.DB, collectors ...Tu managedPermissionsCollector(store), folderTreeCollector(store), dashboardFolderCollector(store), + basicRolesCollector(store), + customRolesCollector(store), + basicRoleAssignemtCollector(store), + userRoleAssignemtCollector(store), + teamRoleAssignemtCollector(store), ) return &ZanzanaSynchroniser{ @@ -187,7 +193,7 @@ func folderTreeCollector(store db.DB) TupleCollector { return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { const collectorID = "folder" const query = ` - SELECT uid, parent_uid, org_id FROM folder WHERE parent_uid IS NOT NULL + SELECT uid, parent_uid, org_id FROM folder ` type folder struct { OrgID int64 `xorm:"org_id"` @@ -205,12 +211,21 @@ func folderTreeCollector(store db.DB) TupleCollector { } for _, f := range folders { - tuple := &openfgav1.TupleKey{ - User: zanzana.NewScopedTupleEntry(zanzana.TypeFolder, f.ParentUID, "", strconv.FormatInt(f.OrgID, 10)), - Object: zanzana.NewScopedTupleEntry(zanzana.TypeFolder, f.FolderUID, "", strconv.FormatInt(f.OrgID, 10)), - Relation: zanzana.RelationParent, + var tuple *openfgav1.TupleKey + if f.ParentUID != "" { + tuple = &openfgav1.TupleKey{ + Object: zanzana.NewScopedTupleEntry(zanzana.TypeFolder, f.FolderUID, "", strconv.FormatInt(f.OrgID, 10)), + Relation: zanzana.RelationParent, + User: zanzana.NewScopedTupleEntry(zanzana.TypeFolder, f.ParentUID, "", strconv.FormatInt(f.OrgID, 10)), + } + } else { + // Map root folders to org + tuple = &openfgav1.TupleKey{ + Object: zanzana.NewScopedTupleEntry(zanzana.TypeFolder, f.FolderUID, "", strconv.FormatInt(f.OrgID, 10)), + Relation: zanzana.RelationOrg, + User: zanzana.NewTupleEntry(zanzana.TypeOrg, strconv.FormatInt(f.OrgID, 10), ""), + } } - tuples[collectorID] = append(tuples[collectorID], tuple) } @@ -253,3 +268,281 @@ func dashboardFolderCollector(store db.DB) TupleCollector { return nil } } + +// basicRolesCollector migrates basic roles to OpenFGA tuples +func basicRolesCollector(store db.DB) TupleCollector { + return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { + const collectorID = "basic_role" + const query = ` + SELECT r.name, r.uid as role_uid, p.action, p.kind, p.identifier, r.org_id + FROM permission p + INNER JOIN role r ON p.role_id = r.id + LEFT JOIN builtin_role br ON r.id = br.role_id + WHERE r.name LIKE 'basic:%' + ` + type Permission struct { + RoleName string `xorm:"role_name"` + OrgID int64 `xorm:"org_id"` + Action string `xorm:"action"` + Kind string + Identifier string + RoleUID string `xorm:"role_uid"` + } + + var permissions []Permission + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query).Find(&permissions) + }) + if err != nil { + return err + } + + for _, p := range permissions { + type Org struct { + Id int64 + Name string + } + var orgs []Org + orgsQuery := "SELECT id, name FROM org" + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(orgsQuery).Find(&orgs) + }) + if err != nil { + return err + } + + // Populate basic roles permissions for every org + for _, org := range orgs { + var subject string + if p.RoleUID != "" { + subject = zanzana.NewScopedTupleEntry(zanzana.TypeRole, p.RoleUID, "assignee", strconv.FormatInt(org.Id, 10)) + } else { + continue + } + + var tuple *openfgav1.TupleKey + ok := false + if p.Identifier == "" || p.Identifier == "*" { + tuple, ok = zanzana.TranslateToOrgTuple(subject, p.Action, org.Id) + } else { + tuple, ok = zanzana.TranslateToTuple(subject, p.Action, p.Kind, p.Identifier, org.Id) + } + if !ok { + continue + } + + key := fmt.Sprintf("%s-%s", collectorID, p.Action) + if !slices.ContainsFunc(tuples[key], func(e *openfgav1.TupleKey) bool { + // skip duplicated tuples + return e.Object == tuple.Object && e.Relation == tuple.Relation && e.User == tuple.User + }) { + tuples[key] = append(tuples[key], tuple) + } + } + } + + return nil + } +} + +// customRolesCollector migrates custom roles to OpenFGA tuples +func customRolesCollector(store db.DB) TupleCollector { + return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { + const collectorID = "custom_role" + const query = ` + SELECT r.name, r.uid as role_uid, p.action, p.kind, p.identifier, r.org_id + FROM permission p + INNER JOIN role r ON p.role_id = r.id + LEFT JOIN builtin_role br ON r.id = br.role_id + WHERE r.name NOT LIKE 'basic:%' + AND r.name NOT LIKE 'fixed:%' + AND r.name NOT LIKE 'managed:%' + ` + type Permission struct { + RoleName string `xorm:"role_name"` + OrgID int64 `xorm:"org_id"` + Action string `xorm:"action"` + Kind string + Identifier string + RoleUID string `xorm:"role_uid"` + } + + var permissions []Permission + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query).Find(&permissions) + }) + if err != nil { + return err + } + + for _, p := range permissions { + var subject string + if p.RoleUID != "" { + subject = zanzana.NewScopedTupleEntry(zanzana.TypeRole, p.RoleUID, "assignee", strconv.FormatInt(p.OrgID, 10)) + } else { + continue + } + + var tuple *openfgav1.TupleKey + ok := false + if p.Identifier == "" || p.Identifier == "*" { + tuple, ok = zanzana.TranslateToOrgTuple(subject, p.Action, p.OrgID) + } else { + tuple, ok = zanzana.TranslateToTuple(subject, p.Action, p.Kind, p.Identifier, p.OrgID) + } + if !ok { + continue + } + + key := fmt.Sprintf("%s-%s", collectorID, p.Action) + if !slices.ContainsFunc(tuples[key], func(e *openfgav1.TupleKey) bool { + // skip duplicated tuples + return e.Object == tuple.Object && e.Relation == tuple.Relation && e.User == tuple.User + }) { + tuples[key] = append(tuples[key], tuple) + } + } + + return nil + } +} + +func basicRoleAssignemtCollector(store db.DB) TupleCollector { + return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { + const collectorID = "basic_role_assignment" + const query = ` + SELECT ou.org_id, u.uid as user_uid, ou.role as org_role, u.is_admin + FROM org_user ou + LEFT JOIN user u ON u.id = ou.user_id + ` + type Assignment struct { + OrgID int64 `xorm:"org_id"` + UserUID string `xorm:"user_uid"` + OrgRole string `xorm:"org_role"` + IsAdmin bool `xorm:"is_admin"` + } + + var assignments []Assignment + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query).Find(&assignments) + }) + + if err != nil { + return err + } + + for _, a := range assignments { + var subject string + if a.UserUID != "" && a.OrgRole != "" { + subject = zanzana.NewTupleEntry(zanzana.TypeUser, a.UserUID, "") + } else { + continue + } + + roleUID := zanzana.TranslateBasicRole(a.OrgRole) + + tuple := &openfgav1.TupleKey{ + User: subject, + Relation: zanzana.RelationAssignee, + Object: zanzana.NewScopedTupleEntry(zanzana.TypeRole, roleUID, "", strconv.FormatInt(a.OrgID, 10)), + } + + key := fmt.Sprintf("%s-%s", collectorID, zanzana.RelationAssignee) + tuples[key] = append(tuples[key], tuple) + } + + return nil + } +} + +func userRoleAssignemtCollector(store db.DB) TupleCollector { + return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { + const collectorID = "user_role_assignment" + const query = ` + SELECT ur.org_id, u.uid AS user_uid, r.uid AS role_uid + FROM user_role ur + LEFT JOIN role r ON r.id = ur.role_id + LEFT JOIN user u ON u.id = ur.user_id + ` + + type Assignment struct { + OrgID int64 `xorm:"org_id"` + UserUID string `xorm:"user_uid"` + RoleUID string `xorm:"role_uid"` + } + + var assignments []Assignment + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query).Find(&assignments) + }) + if err != nil { + return err + } + + for _, a := range assignments { + var subject string + if a.UserUID != "" && a.RoleUID != "" { + subject = zanzana.NewTupleEntry(zanzana.TypeUser, a.UserUID, "") + } else { + continue + } + + tuple := &openfgav1.TupleKey{ + User: subject, + Relation: zanzana.RelationAssignee, + Object: zanzana.NewScopedTupleEntry(zanzana.TypeRole, a.RoleUID, "", strconv.FormatInt(a.OrgID, 10)), + } + + key := fmt.Sprintf("%s-%s", collectorID, zanzana.RelationAssignee) + tuples[key] = append(tuples[key], tuple) + } + + return nil + } +} + +func teamRoleAssignemtCollector(store db.DB) TupleCollector { + return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { + const collectorID = "team_role_assignment" + const query = ` + SELECT tr.org_id, t.uid AS team_uid, r.uid AS role_uid + FROM team_role tr + LEFT JOIN role r ON r.id = tr.role_id + LEFT JOIN team t ON t.id = tr.team_id + ` + + type Assignment struct { + OrgID int64 `xorm:"org_id"` + TeamUID string `xorm:"team_uid"` + RoleUID string `xorm:"role_uid"` + } + + var assignments []Assignment + err := store.WithDbSession(ctx, func(sess *db.Session) error { + return sess.SQL(query).Find(&assignments) + }) + if err != nil { + return err + } + + for _, a := range assignments { + var subject string + if a.TeamUID != "" && a.RoleUID != "" { + subject = zanzana.NewTupleEntry(zanzana.TypeTeam, a.TeamUID, "member") + } else { + continue + } + + tuple := &openfgav1.TupleKey{ + User: subject, + Relation: zanzana.RelationAssignee, + Object: zanzana.NewScopedTupleEntry(zanzana.TypeRole, a.RoleUID, "", strconv.FormatInt(a.OrgID, 10)), + } + + key := fmt.Sprintf("%s-%s", collectorID, zanzana.RelationAssignee) + tuples[key] = append(tuples[key], tuple) + } + + return nil + } +} diff --git a/pkg/services/authz/zanzana/schema/README.md b/pkg/services/authz/zanzana/schema/README.md new file mode 100644 index 00000000000..63fc4bfa013 --- /dev/null +++ b/pkg/services/authz/zanzana/schema/README.md @@ -0,0 +1,110 @@ +# Authorization schema + +Here's some notes about [OpenFGA authorization model](https://openfga.dev/docs/modeling/getting-started) (schema) using to model access control in Grafana. + +## Org-level permissions + +Most of the permissions are exist in org. Users, teams, dashboards, folders and other objects also related to specific org. + +## Dashboards and folders + +Folder hierarchy is stored directly in OpenFGA database. Each dashboard has parent folder and every folder could have sub-folders. Root-level folders do not have parents, but instead, they related to specific org: + +```text +type org + relations + define instance: [instance] + define member: [user] + +type folder + relations + define parent: [folder] + define org: [org] + +type dashboard + relations + define org: [org] + define parent: [folder] +``` + +Therefore, folders tree is stored as tuples like this: + +```text +folder:- parent dashboard:- +folder:- parent folder:- +org: org folder:- +``` + +## Managed permissions + +In the RBAC model managed permissions stored as a special "managed" role permissions. OpenFGA model allows to assign permissions directly to users, so it produces following tuples: + +```text +user: read folder:- +``` + +It's also possible to assign permissions for team members using `#member` relation: + +```text +team:#member read folder:- +``` + +It's important to understand that folder permissions cannot be directly assigned to teams, because it's restricted by schema: + +```text +type folder + relations + define parent: [folder] + define org: [org] + + define read: [user, team#member, role#assignee] or read from parent or folder_read from org + +type team + relations + define org: [org] + define admin: [user] + define member: [user] or admin +``` + +Therefore, `team#member` can have `read` relation to folder and user will be automatically granted the same permission if it has `member` relation to specific team. + +## Roles and role assignments + +RBAC authorization model grants permissions to users through roles and role assignments. All permissions are linked to roles and then roles granted to users. To model this in OpenFGA, we use org-level permission and `role` type. + +To understand how RBAC permissions linked to roles, let's take a look at the dashboard read permission as example: + +```text +type org + relations + define instance: [instance] + define member: [user] + + define folder_read: [role#assignee] + +type role + relations + define org: [org] + define assignee: [user, team#member, role#assignee] + +type folder + relations + define parent: [folder] + define org: [org] + + define read: [user, team#member, role#assignee] or read from parent or folder_read from org +``` + +According to the schema, user can get `read` access to dashboard if it has `read` relation granted directly to the dashboard ot its parent folders, or by having `folder_read from org`. If we take a look at `folder_read` definition in the org type, we could see that this relation could be granted to `role#assignee`. So in order to allow user to read all dahboards in org, following tuples should be added: + +```text +role:-#assignee folder_read org: +user: assignee role: +``` + +In case of `Admin` basic role, it will be looking like: + +```text +role:1-basic_admin#assignee folder_read org:1 +user:admin assignee role:1-basic_admin +``` diff --git a/pkg/services/authz/zanzana/schema/schema.fga b/pkg/services/authz/zanzana/schema/schema.fga index d4d8a5ba8df..02cc33b4407 100644 --- a/pkg/services/authz/zanzana/schema/schema.fga +++ b/pkg/services/authz/zanzana/schema/schema.fga @@ -9,7 +9,6 @@ type org relations define instance: [instance] define member: [user] - define viewer: [user] # team management define team_create: [role#assignee] diff --git a/pkg/services/authz/zanzana/translations.go b/pkg/services/authz/zanzana/translations.go index 8e52ea2c69c..c851547e973 100644 --- a/pkg/services/authz/zanzana/translations.go +++ b/pkg/services/authz/zanzana/translations.go @@ -46,16 +46,59 @@ var dashboardActions = map[string]string{ "dashboards.permissions:write": "permissions_write", } +var orgActions = map[string]string{ + "folders:create": "folder_create", + "folders:read": "folder_read", + "folders:write": "folder_write", + "folders:delete": "folder_delete", + "folders.permissions:read": "folder_permissions_read", + "folders.permissions:write": "folder_permissions_write", + + "dashboards:create": "dashboard_create", + "dashboards:read": "dashboard_read", + "dashboards:write": "dashboard_write", + "dashboards:delete": "dashboard_delete", + "dashboards.permissions:read": "dashboard_permissions_read", + "dashboards.permissions:write": "dashboard_permissions_write", + + "library.panels:create": "library_panel_create", + "library.panels:read": "library_panel_read", + "library.panels:write": "library_panel_write", + "library.panels:delete": "library_panel_delete", + + "alert.rules:create": "alert_rule_create", + "alert.rules:read": "alert_rule_read", + "alert.rules:write": "alert_rule_write", + "alert.rules:delete": "alert_rule_delete", + + "alert.silences:create": "alert_silence_create", + "alert.silences:read": "alert_silence_read", + "alert.silences:write": "alert_silence_write", +} + // RBAC to OpenFGA translations grouped by kind var actionKindTranslations = map[string]actionKindTranslation{ - "folders": { - objectType: "folder", + KindOrg: { + objectType: TypeOrg, + orgScoped: false, + translations: orgActions, + }, + KindFolders: { + objectType: TypeFolder, orgScoped: true, translations: folderActions, }, - "dashboards": { - objectType: "dashboard", + KindDashboards: { + objectType: TypeDashboard, orgScoped: true, translations: dashboardActions, }, } + +var basicRolesTranslations = map[string]string{ + RoleGrafanaAdmin: "basic_grafana_admin", + RoleAdmin: "basic_admin", + RoleEditor: "basic_editor", + RoleViewer: "basic_viewer", + RoleNone: "basic_none", +} diff --git a/pkg/services/authz/zanzana/zanzana.go b/pkg/services/authz/zanzana/zanzana.go index 940c9c3691f..d59b7616e73 100644 --- a/pkg/services/authz/zanzana/zanzana.go +++ b/pkg/services/authz/zanzana/zanzana.go @@ -10,14 +10,37 @@ import ( const ( TypeUser string = "user" TypeTeam string = "team" + TypeRole string = "role" TypeFolder string = "folder" TypeDashboard string = "dashboard" + TypeOrg string = "org" ) const ( RelationTeamMember string = "member" RelationTeamAdmin string = "admin" RelationParent string = "parent" + RelationAssignee string = "assignee" + RelationOrg string = "org" +) + +const ( + KindOrg string = "org" + KindDashboards string = "dashboards" + KindFolders string = "folders" +) + +const ( + RoleGrafanaAdmin = "Grafana Admin" + RoleAdmin = "Admin" + RoleEditor = "Editor" + RoleViewer = "Viewer" + RoleNone = "None" + + BasicRolePrefix = "basic:" + BasicRoleUIDPrefix = "basic_" + + GlobalOrgID = 0 ) // NewTupleEntry constructs new openfga entry type:id[#relation]. @@ -34,7 +57,7 @@ func NewTupleEntry(objectType, id, relation string) string { // NewScopedTupleEntry constructs new openfga entry type:id[#relation] // with id prefixed by scope (usually org id) func NewScopedTupleEntry(objectType, id, relation, scope string) string { - return NewTupleEntry(objectType, fmt.Sprintf("%s-%s", scope, id), "") + return NewTupleEntry(objectType, fmt.Sprintf("%s-%s", scope, id), relation) } func TranslateToTuple(user string, action, kind, identifier string, orgID int64) (*openfgav1.TupleKey, bool) { @@ -64,3 +87,27 @@ func TranslateToTuple(user string, action, kind, identifier string, orgID int64) return tuple, true } + +func TranslateToOrgTuple(user string, action string, orgID int64) (*openfgav1.TupleKey, bool) { + typeTranslation, ok := actionKindTranslations[KindOrg] + if !ok { + return nil, false + } + + relation, ok := typeTranslation.translations[action] + if !ok { + return nil, false + } + + tuple := &openfgav1.TupleKey{ + Relation: relation, + User: user, + Object: NewTupleEntry(typeTranslation.objectType, strconv.FormatInt(orgID, 10), ""), + } + + return tuple, true +} + +func TranslateBasicRole(role string) string { + return basicRolesTranslations[role] +}