2e1704b56f
As part of migrating Grafana's authorization system to Zanzana (OpenFGA), we need to ensure that role permissions defined in the IAM API are automatically synced to the authorization backend. Without this sync, roles created through the API would not be enforced by Zanzana, creating an inconsistency between defined permissions and actual authorization decisions.
This is a critical piece of the dual-write pattern during the migration to Zanzana, ensuring that:
Role permissions are immediately available for authorization checks
The legacy RBAC system and new Zanzana system remain in sync
Users experience consistent permission enforcement regardless of which backend is queried
safe to revert
492 lines
14 KiB
Go
492 lines
14 KiB
Go
package dualwrite
|
|
|
|
import (
|
|
"context"
|
|
|
|
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
|
|
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
|
"github.com/grafana/grafana/pkg/infra/db"
|
|
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
|
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
|
"github.com/grafana/grafana/pkg/services/folder"
|
|
"github.com/grafana/grafana/pkg/setting"
|
|
)
|
|
|
|
func teamMembershipCollector(store db.DB) legacyTupleCollector {
|
|
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
|
query := `
|
|
SELECT t.uid as team_uid, u.uid as user_uid, tm.permission
|
|
FROM team_member tm
|
|
INNER JOIN team t ON tm.team_id = t.id
|
|
INNER JOIN ` + store.GetDialect().Quote("user") + ` u ON tm.user_id = u.id
|
|
WHERE t.org_id = ?
|
|
`
|
|
|
|
type membership struct {
|
|
TeamUID string `xorm:"team_uid"`
|
|
UserUID string `xorm:"user_uid"`
|
|
Permission int
|
|
}
|
|
|
|
var memberships []membership
|
|
err := store.WithDbSession(ctx, func(sess *db.Session) error {
|
|
return sess.SQL(query, orgID).Find(&memberships)
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
|
|
|
for _, m := range memberships {
|
|
tuple := &openfgav1.TupleKey{
|
|
User: zanzana.NewTupleEntry(zanzana.TypeUser, m.UserUID, ""),
|
|
Object: zanzana.NewTupleEntry(zanzana.TypeTeam, m.TeamUID, ""),
|
|
}
|
|
|
|
// Admin permission is 4 and member 0
|
|
if m.Permission == 4 {
|
|
tuple.Relation = zanzana.RelationTeamAdmin
|
|
} else {
|
|
tuple.Relation = zanzana.RelationTeamMember
|
|
}
|
|
|
|
if tuples[tuple.Object] == nil {
|
|
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
|
}
|
|
|
|
tuples[tuple.Object][tuple.String()] = tuple
|
|
}
|
|
|
|
return tuples, nil
|
|
}
|
|
}
|
|
|
|
// folderTreeCollector collects folder tree structure and writes it as relation tuples
|
|
func folderTreeCollector(folderService folder.Service) legacyTupleCollector {
|
|
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
|
ctx, span := tracer.Start(ctx, "accesscontrol.migrator.folderTreeCollector")
|
|
defer span.End()
|
|
|
|
ctx, ident := identity.WithServiceIdentity(ctx, orgID)
|
|
|
|
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
|
|
|
const pageSize = 1000
|
|
var page int64 = 1
|
|
|
|
for {
|
|
q := folder.GetFoldersQuery{
|
|
OrgID: orgID,
|
|
SignedInUser: ident,
|
|
Limit: pageSize,
|
|
Page: page,
|
|
}
|
|
|
|
folders, err := folderService.GetFolders(ctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, f := range folders {
|
|
var tuple *openfgav1.TupleKey
|
|
if f.ParentUID == "" {
|
|
continue
|
|
}
|
|
|
|
tuple = &openfgav1.TupleKey{
|
|
Object: zanzana.NewTupleEntry(zanzana.TypeFolder, f.UID, ""),
|
|
Relation: zanzana.RelationParent,
|
|
User: zanzana.NewTupleEntry(zanzana.TypeFolder, f.ParentUID, ""),
|
|
}
|
|
|
|
if tuples[tuple.Object] == nil {
|
|
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
|
}
|
|
|
|
tuples[tuple.Object][tuple.String()] = tuple
|
|
}
|
|
|
|
if int64(len(folders)) < pageSize {
|
|
break
|
|
}
|
|
|
|
page++
|
|
}
|
|
|
|
return tuples, nil
|
|
}
|
|
}
|
|
|
|
// managedPermissionsCollector collects managed permissions.
|
|
// It will only store actions that are supported by our schema. Managed permissions can
|
|
// be directly mapped to user/team/role without having to write an intermediate role.
|
|
func managedPermissionsCollector(store db.DB, kind string) legacyTupleCollector {
|
|
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
|
query := `
|
|
SELECT u.uid as user_uid, u.is_service_account as is_service_account, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id, br.role as basic_role_name
|
|
FROM permission p
|
|
INNER JOIN role r ON p.role_id = r.id
|
|
LEFT JOIN user_role ur ON r.id = ur.role_id
|
|
LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ur.user_id
|
|
LEFT JOIN team_role tr ON r.id = tr.role_id
|
|
LEFT JOIN team t ON tr.team_id = t.id
|
|
LEFT JOIN builtin_role br ON r.id = br.role_id
|
|
WHERE r.name LIKE 'managed:%'
|
|
AND r.org_id = ?
|
|
AND p.kind = ?
|
|
`
|
|
type Permission struct {
|
|
Action string `xorm:"action"`
|
|
Kind string
|
|
Identifier string
|
|
UserUID string `xorm:"user_uid"`
|
|
IsServiceAccount bool `xorm:"is_service_account"`
|
|
TeamUID string `xorm:"team_uid"`
|
|
BasicRoleName string `xorm:"basic_role_name"`
|
|
}
|
|
|
|
var permissions []Permission
|
|
err := store.WithDbSession(ctx, func(sess *db.Session) error {
|
|
return sess.SQL(query, orgID, kind).Find(&permissions)
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
|
|
|
for _, p := range permissions {
|
|
var subject string
|
|
if len(p.UserUID) > 0 && p.IsServiceAccount {
|
|
subject = zanzana.NewTupleEntry(zanzana.TypeServiceAccount, p.UserUID, "")
|
|
} else if len(p.UserUID) > 0 {
|
|
subject = zanzana.NewTupleEntry(zanzana.TypeUser, p.UserUID, "")
|
|
} else if len(p.TeamUID) > 0 {
|
|
subject = zanzana.NewTupleEntry(zanzana.TypeTeam, p.TeamUID, zanzana.RelationTeamMember)
|
|
} else if len(p.BasicRoleName) > 0 {
|
|
subject = zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(p.BasicRoleName), zanzana.RelationAssignee)
|
|
} else {
|
|
reconcilerLogger.Debug("unrecognized permission", "permission", p)
|
|
continue
|
|
}
|
|
|
|
tuple, ok := zanzana.TranslateToResourceTuple(subject, p.Action, p.Kind, p.Identifier)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if tuples[tuple.Object] == nil {
|
|
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
|
}
|
|
|
|
// For resource actions on folders we need to merge the tuples into one with combined subresources.
|
|
if zanzana.IsFolderResourceTuple(tuple) {
|
|
key := zanzana.TupleStringWithoutCondition(tuple)
|
|
if t, ok := tuples[tuple.Object][key]; ok {
|
|
zanzana.MergeFolderResourceTuples(t, tuple)
|
|
} else {
|
|
tuples[tuple.Object][key] = tuple
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
tuples[tuple.Object][tuple.String()] = tuple
|
|
}
|
|
|
|
return tuples, nil
|
|
}
|
|
}
|
|
|
|
// basicRoleBindingsCollector collects role bindings for basic roles
|
|
func basicRoleBindingsCollector(store db.DB) legacyTupleCollector {
|
|
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
|
query := `
|
|
SELECT
|
|
ou.org_id, u.uid as user_uid,
|
|
u.is_service_account as is_service_account,
|
|
ou.role as org_role
|
|
FROM org_user ou
|
|
LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ou.user_id
|
|
WHERE ou.org_id = ?
|
|
`
|
|
// FIXME: handle service admin role
|
|
type Binding struct {
|
|
UserUID string `xorm:"user_uid"`
|
|
IsServiceAccount bool `xorm:"is_service_account"`
|
|
OrgRole string `xorm:"org_role"`
|
|
}
|
|
|
|
var bindings []Binding
|
|
err := store.WithDbSession(ctx, func(sess *db.Session) error {
|
|
return sess.SQL(query, orgID).Find(&bindings)
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
|
|
|
for _, b := range bindings {
|
|
userType := zanzana.TypeUser
|
|
if b.IsServiceAccount {
|
|
userType = zanzana.TypeServiceAccount
|
|
}
|
|
|
|
tuple := &openfgav1.TupleKey{
|
|
User: zanzana.NewTupleEntry(userType, b.UserUID, ""),
|
|
Relation: zanzana.RelationAssignee,
|
|
Object: zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(b.OrgRole), ""),
|
|
}
|
|
|
|
if tuples[tuple.Object] == nil {
|
|
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
|
}
|
|
|
|
tuples[tuple.Object][tuple.String()] = tuple
|
|
}
|
|
|
|
return tuples, nil
|
|
}
|
|
}
|
|
|
|
func roleBindingsCollector(store db.DB) legacyTupleCollector {
|
|
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
|
userQuery := `
|
|
SELECT u.uid AS user_uid, u.is_service_account as is_service_account, r.uid AS role_uid
|
|
FROM user_role ur
|
|
INNER JOIN ` + store.GetDialect().Quote("user") + ` u ON ur.user_id = u.id
|
|
INNER JOIN role r ON ur.role_id = r.id
|
|
WHERE (ur.org_id = 0 OR ur.org_id = ?)
|
|
AND r.name NOT LIKE 'managed:%'
|
|
`
|
|
type Binding struct {
|
|
UserUID string `xorm:"user_uid"`
|
|
IsServiceAccount bool `xorm:"is_service_account"`
|
|
TeamUID string `xorm:"team_uid"`
|
|
RoleUID string `xorm:"role_uid"`
|
|
}
|
|
|
|
bindings := make([]Binding, 0)
|
|
var userBindings []Binding
|
|
err := store.WithDbSession(ctx, func(sess *db.Session) error {
|
|
return sess.SQL(userQuery, orgID).Find(&userBindings)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bindings = append(bindings, userBindings...)
|
|
|
|
teamQuery := `
|
|
SELECT t.uid AS team_uid, r.uid AS role_uid
|
|
FROM team_role tr
|
|
INNER JOIN team t ON tr.team_id = t.id
|
|
INNER JOIN role r ON tr.role_id = r.id
|
|
WHERE t.org_id = ?
|
|
AND r.name NOT LIKE 'managed:%'
|
|
`
|
|
var teamBindings []Binding
|
|
err = store.WithDbSession(ctx, func(sess *db.Session) error {
|
|
return sess.SQL(teamQuery, orgID).Find(&teamBindings)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bindings = append(bindings, teamBindings...)
|
|
|
|
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
|
for _, b := range bindings {
|
|
var tuple *openfgav1.TupleKey
|
|
|
|
if b.UserUID != "" {
|
|
userType := zanzana.TypeUser
|
|
if b.IsServiceAccount {
|
|
userType = zanzana.TypeServiceAccount
|
|
}
|
|
|
|
tuple = &openfgav1.TupleKey{
|
|
User: zanzana.NewTupleEntry(userType, b.UserUID, ""),
|
|
Relation: zanzana.RelationAssignee,
|
|
Object: zanzana.NewTupleEntry(zanzana.TypeRole, b.RoleUID, ""),
|
|
}
|
|
|
|
if tuples[tuple.Object] == nil {
|
|
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
|
}
|
|
} else if b.TeamUID != "" {
|
|
tuple = &openfgav1.TupleKey{
|
|
User: zanzana.NewTupleEntry(zanzana.TypeTeam, b.TeamUID, zanzana.RelationTeamMember),
|
|
Relation: zanzana.RelationAssignee,
|
|
Object: zanzana.NewTupleEntry(zanzana.TypeRole, b.RoleUID, ""),
|
|
}
|
|
|
|
if tuples[tuple.Object] == nil {
|
|
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
|
}
|
|
}
|
|
|
|
tuples[tuple.Object][tuple.String()] = tuple
|
|
}
|
|
|
|
return tuples, nil
|
|
}
|
|
}
|
|
|
|
func rolePermissionsCollector(store db.DB) legacyTupleCollector {
|
|
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
|
var query = `
|
|
SELECT r.uid as role_uid, p.action, p.kind, p.identifier
|
|
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.org_id = 0 OR r.org_id = ?)
|
|
AND r.name NOT LIKE 'managed:%'
|
|
`
|
|
|
|
type Permission struct {
|
|
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, orgID).Find(&permissions)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Group permissions by role UID
|
|
rolePermissionsMap := make(map[string][]zanzana.RolePermission)
|
|
for _, p := range permissions {
|
|
rolePermissionsMap[p.RoleUID] = append(rolePermissionsMap[p.RoleUID], zanzana.RolePermission{
|
|
Action: p.Action,
|
|
Kind: p.Kind,
|
|
Identifier: p.Identifier,
|
|
})
|
|
}
|
|
|
|
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
|
|
|
// Convert permissions for each role using the shared utility
|
|
for roleUID, perms := range rolePermissionsMap {
|
|
roleTuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, perms)
|
|
if err != nil {
|
|
reconcilerLogger.Warn("Failed to convert role permissions to tuples", "roleUID", roleUID, "err", err)
|
|
continue
|
|
}
|
|
|
|
// Add tuples to the result map
|
|
for _, tuple := range roleTuples {
|
|
if tuples[tuple.Object] == nil {
|
|
tuples[tuple.Object] = make(map[string]*openfgav1.TupleKey)
|
|
}
|
|
|
|
// Use the appropriate key based on whether it's a folder resource tuple
|
|
var key string
|
|
if zanzana.IsFolderResourceTuple(tuple) {
|
|
key = zanzana.TupleStringWithoutCondition(tuple)
|
|
} else {
|
|
key = tuple.String()
|
|
}
|
|
tuples[tuple.Object][key] = tuple
|
|
}
|
|
}
|
|
|
|
return tuples, nil
|
|
}
|
|
}
|
|
|
|
// basicRoleBindingsCollector collects role bindings for basic roles
|
|
func anonymousRoleBindingsCollector(cfg *setting.Cfg, store db.DB) legacyTupleCollector {
|
|
return func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) {
|
|
tuples := make(map[string]map[string]*openfgav1.TupleKey)
|
|
object := zanzana.NewTupleEntry(zanzana.TypeRole, zanzana.TranslateBasicRole(cfg.Anonymous.OrgRole), "")
|
|
// Object should be set to delete obsolete permissions
|
|
tuples[object] = make(map[string]*openfgav1.TupleKey)
|
|
|
|
o, err := getOrgByName(ctx, store, cfg.Anonymous.OrgName)
|
|
if err != nil {
|
|
return tuples, nil
|
|
}
|
|
|
|
if o.ID != orgID {
|
|
return tuples, nil
|
|
}
|
|
|
|
tuple := &openfgav1.TupleKey{
|
|
User: zanzana.NewTupleEntry(zanzana.TypeAnonymous, "0", ""),
|
|
Relation: zanzana.RelationAssignee,
|
|
Object: object,
|
|
}
|
|
|
|
tuples[tuple.Object][tuple.String()] = tuple
|
|
|
|
return tuples, nil
|
|
}
|
|
}
|
|
|
|
func zanzanaCollector(relations []string) zanzanaTupleCollector {
|
|
return func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) {
|
|
// list will use continuation token to collect all tuples for object and relation
|
|
list := func(relation string) ([]*openfgav1.Tuple, error) {
|
|
first, err := client.Read(ctx, &authzextv1.ReadRequest{
|
|
Namespace: namespace,
|
|
TupleKey: &authzextv1.ReadRequestTupleKey{
|
|
Object: object,
|
|
Relation: relation,
|
|
},
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c := first.ContinuationToken
|
|
|
|
for c != "" {
|
|
res, err := client.Read(ctx, &authzextv1.ReadRequest{
|
|
ContinuationToken: c,
|
|
Namespace: namespace,
|
|
TupleKey: &authzextv1.ReadRequestTupleKey{
|
|
Object: object,
|
|
Relation: relation,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c = res.ContinuationToken
|
|
first.Tuples = append(first.Tuples, res.Tuples...)
|
|
}
|
|
|
|
return zanzana.ToOpenFGATuples(first.Tuples), nil
|
|
}
|
|
|
|
out := make(map[string]*openfgav1.TupleKey)
|
|
for _, r := range relations {
|
|
tuples, err := list(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, t := range tuples {
|
|
if zanzana.IsFolderResourceTuple(t.Key) {
|
|
out[zanzana.TupleStringWithoutCondition(t.Key)] = t.Key
|
|
} else {
|
|
out[t.Key.String()] = t.Key
|
|
}
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
}
|