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/infra/log" "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" ) type RoleHooks struct { zClient zanzana.Client zTickets chan bool logger log.Logger } func NewRoleHooks(zClient zanzana.Client, zTickets chan bool, logger log.Logger) *RoleHooks { return &RoleHooks{zClient: zClient, zTickets: zTickets, logger: logger} } // 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 (h *RoleHooks) AfterRoleCreate(obj runtime.Object, _ *metav1.CreateOptions) { if h.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() h.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() { <-h.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 { h.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 { h.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 } h.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 = h.zClient.Write(ctx, &v1.WriteRequest{ Namespace: role.Namespace, Writes: &v1.WriteRequestWrites{ TupleKeys: tuples, }, }) if err != nil { h.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 (h *RoleHooks) AfterRoleDelete(obj runtime.Object, _ *metav1.DeleteOptions) { if h.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() h.zTickets <- true hooksWaitHistogram.WithLabelValues("role", "delete").Observe(time.Since(wait).Seconds()) // Record wait time go func(role *iamv0.CoreRole, roleType string) { defer func() { <-h.zTickets }() h.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 { h.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 { h.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) h.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 = h.zClient.Write(ctx, &v1.WriteRequest{ Namespace: role.Namespace, Deletes: &v1.WriteRequestDeletes{ TupleKeys: deleteTuples, }, }) if err != nil { h.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 (h *RoleHooks) BeginRoleUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { if h.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() h.zTickets <- true hooksWaitHistogram.WithLabelValues(roleType, "update").Observe(time.Since(wait).Seconds()) // Record wait time go func(old *iamv0.CoreRole, new *iamv0.CoreRole) { defer func() { <-h.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 { h.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 { h.logger.Error("failed to convert new role permissions to tuples", "namespace", namespace, "roleUID", roleUID, "roleType", roleType, "err", err, ) return } h.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, } h.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, } h.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 = h.zClient.Write(ctx, req) if err != nil { h.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 }