Files
grafana/pkg/registry/apis/iam/role_hooks.go
Alexander Zobnin 48438f49e7 grafana-iam: refactor resource permissions write to zanzana (#113700)
* grafana-iam: refactor resource permissions write to zanzana

* Fix linter

* fix linter
2025-11-12 10:23:15 +01:00

434 lines
13 KiB
Go

package iam
import (
"context"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic/registry"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/services/accesscontrol"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
// convertRolePermissionsToTuples converts role permissions (action/scope) to v1 TupleKey format
// using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys
func convertRolePermissionsToTuples(roleUID string, permissions []iamv0.CoreRolespecPermission) ([]*v1.TupleKey, error) {
// Convert IAM permissions to zanzana.RolePermission format
rolePerms := make([]zanzana.RolePermission, 0, len(permissions))
for _, perm := range permissions {
// Split the scope to get kind, attribute, identifier
kind, _, identifier := accesscontrol.SplitScope(perm.Scope)
rolePerms = append(rolePerms, zanzana.RolePermission{
Action: perm.Action,
Kind: kind,
Identifier: identifier,
})
}
// Translate to Zanzana tuples
openfgaTuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, rolePerms)
if err != nil {
return nil, err
}
// Convert directly to v1 tuples using common utility
v1Tuples := common.ToAuthzExtTupleKeys(openfgaTuples)
return v1Tuples, nil
}
// AfterRoleCreate is a post-create hook that writes the role permissions to Zanzana (openFGA)
// It handles both Role and CoreRole types
func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, _ *metav1.CreateOptions) {
if b.zClient == nil {
return
}
var rType string
var rt *iamv0.CoreRole
if coreRole, ok := obj.(*iamv0.CoreRole); ok {
rt = coreRole.DeepCopy()
rType = "coreRole"
} else if regRole, ok := obj.(*iamv0.Role); ok {
regRolePermissions := make([]iamv0.CoreRolespecPermission, len(regRole.Spec.Permissions))
for i, p := range regRole.Spec.Permissions {
regRolePermissions[i] = iamv0.CoreRolespecPermission(p)
}
rt = &iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: regRole.Name,
Namespace: regRole.Namespace,
},
Spec: iamv0.CoreRoleSpec{
Permissions: regRolePermissions,
},
}
rType = "role"
} else {
// Not a supported role type
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(rType, "create").Observe(time.Since(wait).Seconds())
go func(role *iamv0.CoreRole, roleType string) {
start := time.Now()
status := "success"
defer func() {
<-b.zTickets
hooksDurationHistogram.WithLabelValues(rType, "create", status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues(rType, "create", status).Inc()
}()
tuples, err := convertRolePermissionsToTuples(role.Name, role.Spec.Permissions)
if err != nil {
b.logger.Error("failed to convert role permissions to tuples",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"err", err,
"permissionsCnt", len(role.Spec.Permissions),
)
status = "failure"
return
}
// Avoid writing if there are no valid tuples
if len(tuples) == 0 {
b.logger.Debug("no valid tuples to write for role",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"permissionsCnt", len(role.Spec.Permissions),
)
status = "failure"
return
}
b.logger.Debug("writing role permissions to zanzana",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"tuplesCnt", len(tuples),
"permissionsCnt", len(role.Spec.Permissions),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err = b.zClient.Write(ctx, &v1.WriteRequest{
Namespace: role.Namespace,
Writes: &v1.WriteRequestWrites{
TupleKeys: tuples,
},
})
if err != nil {
b.logger.Error("failed to write role permissions to zanzana",
"err", err,
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"tuplesCnt", len(tuples),
)
status = "failure"
return
}
// Record successful tuple writes
hooksTuplesCounter.WithLabelValues(rType, "create", "write").Add(float64(len(tuples)))
}(rt.DeepCopy(), rType)
}
// AfterRoleDelete is a post-delete hook that removes the role permissions from Zanzana (openFGA)
// It handles both Role and CoreRole types
func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
if b.zClient == nil {
return
}
var rType string
var rt *iamv0.CoreRole
// Try CoreRole first
if coreRole, ok := obj.(*iamv0.CoreRole); ok {
rt = coreRole.DeepCopy()
rType = "coreRole"
} else if regRole, ok := obj.(*iamv0.Role); ok {
regRolePermissions := make([]iamv0.CoreRolespecPermission, len(regRole.Spec.Permissions))
for i, p := range regRole.Spec.Permissions {
regRolePermissions[i] = iamv0.CoreRolespecPermission(p)
}
rt = &iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: regRole.Name,
Namespace: regRole.Namespace,
},
Spec: iamv0.CoreRoleSpec{
Permissions: regRolePermissions,
},
}
rType = "role"
} else {
// Not a supported role type
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues("role", "delete").Observe(time.Since(wait).Seconds()) // Record wait time
go func(role *iamv0.CoreRole, roleType string) {
defer func() {
<-b.zTickets
}()
b.logger.Debug("deleting role permissions from zanzana",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"permissionsCnt", len(role.Spec.Permissions),
)
tuples, err := convertRolePermissionsToTuples(role.Name, role.Spec.Permissions)
if err != nil {
b.logger.Error("failed to convert role permissions to tuples for deletion",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"err", err,
"permissionsCnt", len(role.Spec.Permissions),
)
return
}
// Avoid deleting if there are no valid tuples
if len(tuples) == 0 {
b.logger.Debug("no valid tuples to delete for role",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"permissionsCnt", len(role.Spec.Permissions),
)
return
}
// Convert tuples to TupleKeyWithoutCondition for deletion
deleteTuples := toTupleKeysWithoutCondition(tuples)
b.logger.Debug("deleting role permissions from zanzana",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"tuplesCnt", len(deleteTuples),
"permissionsCnt", len(role.Spec.Permissions),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err = b.zClient.Write(ctx, &v1.WriteRequest{
Namespace: role.Namespace,
Deletes: &v1.WriteRequestDeletes{
TupleKeys: deleteTuples,
},
})
if err != nil {
b.logger.Error("failed to delete role permissions from zanzana",
"err", err,
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"tuplesCnt", len(deleteTuples),
)
}
}(rt.DeepCopy(), rType)
}
// beginRoleUpdate is a pre-update hook that prepares zanzana updates
// It converts old and new permissions to tuples and performs the zanzana write after K8s update succeeds
// It handles both Role and CoreRole types
func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
if b.zClient == nil {
return nil, nil
}
var oldRole, newRole *iamv0.CoreRole
var roleType string
if oldCoreRole, ok := oldObj.(*iamv0.CoreRole); ok { // Try CoreRole first
oldRole = oldCoreRole.DeepCopy()
newCoreRole, ok := obj.(*iamv0.CoreRole)
if !ok {
return nil, nil
}
newRole = newCoreRole.DeepCopy()
roleType = "coreRole"
} else if oldRegRole, ok := oldObj.(*iamv0.Role); ok { // Try Role
oldRegRolePermissions := make([]iamv0.CoreRolespecPermission, len(oldRegRole.Spec.Permissions))
for i, p := range oldRegRole.Spec.Permissions {
oldRegRolePermissions[i] = iamv0.CoreRolespecPermission(p)
}
oldRole = &iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: oldRegRole.Name,
Namespace: oldRegRole.Namespace,
},
Spec: iamv0.CoreRoleSpec{
Permissions: oldRegRolePermissions,
},
}
newRegRole, ok := obj.(*iamv0.Role)
if !ok {
return nil, nil
}
newRegRolePermissions := make([]iamv0.CoreRolespecPermission, len(newRegRole.Spec.Permissions))
for i, p := range newRegRole.Spec.Permissions {
newRegRolePermissions[i] = iamv0.CoreRolespecPermission(p)
}
newRole = &iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: newRegRole.Name,
Namespace: newRegRole.Namespace,
},
Spec: iamv0.CoreRoleSpec{
Permissions: newRegRolePermissions,
},
}
roleType = "role"
} else {
// Not a supported role type
return nil, nil
}
// Return a finish function that performs the zanzana write only on success
return func(ctx context.Context, success bool) {
if !success {
// Update failed, don't write to zanzana
return
}
// Grab a ticket to write to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(roleType, "update").Observe(time.Since(wait).Seconds()) // Record wait time
go func(old *iamv0.CoreRole, new *iamv0.CoreRole) {
defer func() {
<-b.zTickets
}()
roleUID, namespace := old.Name, old.Namespace
oldPermissions, newPermissions := old.Spec.Permissions, new.Spec.Permissions
// Convert old permissions to tuples for deletion
var oldTuples []*v1.TupleKey
if len(oldPermissions) > 0 {
var err error
oldTuples, err = convertRolePermissionsToTuples(roleUID, oldPermissions)
if err != nil {
b.logger.Error("failed to convert old role permissions to tuples",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"err", err,
)
}
}
// Convert new permissions to tuples for writing
newTuples, err := convertRolePermissionsToTuples(roleUID, newPermissions)
if err != nil {
b.logger.Error("failed to convert new role permissions to tuples",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"err", err,
)
return
}
b.logger.Debug("updating role permissions in zanzana",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"oldPermissionsCnt", len(oldPermissions),
"newPermissionsCnt", len(newPermissions),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
// Prepare write request
req := &v1.WriteRequest{
Namespace: namespace,
}
// Add deletes for old tuples
if len(oldTuples) > 0 {
deleteTuples := toTupleKeysWithoutCondition(oldTuples)
req.Deletes = &v1.WriteRequestDeletes{
TupleKeys: deleteTuples,
}
b.logger.Debug("deleting existing role permissions from zanzana",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"tuplesCnt", len(deleteTuples),
)
}
// Add writes for new tuples
if len(newTuples) > 0 {
req.Writes = &v1.WriteRequestWrites{
TupleKeys: newTuples,
}
b.logger.Debug("writing new role permissions to zanzana",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"tuplesCnt", len(newTuples),
)
}
// Only make the request if there are deletes or writes
if req.Deletes != nil || req.Writes != nil {
err = b.zClient.Write(ctx, req)
if err != nil {
b.logger.Error("failed to update role permissions in zanzana",
"err", err,
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
)
}
}
}(oldRole.DeepCopy(), newRole.DeepCopy())
}, nil
}
// tupleToTupleKeyWithoutCondition converts a TupleKey to TupleKeyWithoutCondition
// This is needed for delete operations which don't support conditions
func tupleToTupleKeyWithoutCondition(tuple *v1.TupleKey) *v1.TupleKeyWithoutCondition {
return &v1.TupleKeyWithoutCondition{
User: tuple.User,
Relation: tuple.Relation,
Object: tuple.Object,
}
}
// toTupleKeysWithoutCondition converts v1.TupleKey to v1.TupleKeyWithoutCondition
// by stripping the condition field, which is required for delete operations
func toTupleKeysWithoutCondition(tuples []*v1.TupleKey) []*v1.TupleKeyWithoutCondition {
result := make([]*v1.TupleKeyWithoutCondition, len(tuples))
for i, t := range tuples {
result[i] = tupleToTupleKeyWithoutCondition(t)
}
return result
}