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
This commit is contained in:
Alexander Zobnin
2024-08-15 16:13:27 +02:00
committed by GitHub
parent 4b0e8653f2
commit aaf33c7923
5 changed files with 504 additions and 12 deletions
+299 -6
View File
@@ -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
}
}
+110
View File
@@ -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:<org_id>-<folder_uid> parent dashboard:<org_id>-<dashboard_uid>
folder:<org_id>-<folder_uid> parent folder:<org_id>-<folder_uid>
org:<org_id> org folder:<org_id>-<folder_uid>
```
## 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:<user_uid> read folder:<org_id>-<folder_uid>
```
It's also possible to assign permissions for team members using `#member` relation:
```text
team:<team_uid>#member read folder:<org_id>-<folder_uid>
```
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:<org_id>-<role_uid>#assignee folder_read org:<org_uid>
user:<user_uid> assignee role:<role_uid>
```
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
```
@@ -9,7 +9,6 @@ type org
relations
define instance: [instance]
define member: [user]
define viewer: [user]
# team management
define team_create: [role#assignee]
+47 -4
View File
@@ -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",
}
+48 -1
View File
@@ -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]
}