From f019d58a99a572f7d272c28f2ecbbe7cf5fea09e Mon Sep 17 00:00:00 2001 From: Mihai Turdean <6640685+mihai-turdean@users.noreply.github.com> Date: Fri, 24 Oct 2025 11:45:24 -0600 Subject: [PATCH] [grafana-iam] Add `resourcePermissions` hooks to sync write to Zanzana on UPDATE and DELETE (#112767) --- pkg/registry/apis/iam/hooks.go | 278 +++++++++++++++++++++++++++- pkg/registry/apis/iam/hooks_test.go | 257 +++++++++++++++++++++++++ pkg/registry/apis/iam/metrics.go | 42 ++++- pkg/registry/apis/iam/register.go | 4 +- 4 files changed, 573 insertions(+), 8 deletions(-) diff --git a/pkg/registry/apis/iam/hooks.go b/pkg/registry/apis/iam/hooks.go index aac644dcfc3..42b2ffe604b 100644 --- a/pkg/registry/apis/iam/hooks.go +++ b/pkg/registry/apis/iam/hooks.go @@ -10,6 +10,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" 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" @@ -96,6 +97,26 @@ func NewResourceTuple(object string, resource iamv0.ResourcePermissionspecResour return key, 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 +} + // AfterResourcePermissionCreate is a post-create hook that writes the resource permission to Zanzana (openFGA) func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj runtime.Object, _ *metav1.CreateOptions) { if b.zClient == nil { @@ -104,19 +125,29 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj r rp, ok := obj.(*iamv0.ResourcePermission) if !ok { + b.logger.Error("failed to convert object to resourcePermission type", "object", obj) return } + resourceType := "resourcepermission" + operation := "create" + // Grab a ticket to write to Zanzana - // This limits the amount of concurrent writes to Zanzana + // This limits the amount of concurrent connections to Zanzana wait := time.Now() b.zTickets <- true - hooksWaitHistogram.Observe(time.Since(wait).Seconds()) // Record wait time + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) // Record wait time go func(rp *iamv0.ResourcePermission) { + start := time.Now() + status := "success" + defer func() { // Release the ticket after write is done <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() }() resource := rp.Spec.Resource @@ -142,6 +173,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj r // Avoid writing if there are no valid tuples if len(tuples) == 0 { b.logger.Warn("no valid tuples to write", "namespace", rp.Namespace, "resource", object) + status = "failure" return } @@ -161,12 +193,252 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj r }, }) if err != nil { + status = "failure" b.logger.Error("failed to write resource permission to zanzana", "err", err, "namespace", rp.Namespace, "object", object, "tuplesCnt", len(tuples), ) + } else { + // Record successful tuple writes + hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Add(float64(len(tuples))) + } + }(rp.DeepCopy()) // Pass a copy of the object +} + +// BeginResourcePermissionUpdate 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 +func (b *IdentityAccessManagementAPIBuilder) BeginResourcePermissionUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) { + if b.zClient == nil { + return nil, nil + } + + // Extract permissions from both old and new objects + oldRP, ok := oldObj.(*iamv0.ResourcePermission) + if !ok { + return nil, nil + } + + newRP, ok := obj.(*iamv0.ResourcePermission) + if !ok { + return nil, nil + } + + // Convert old permissions to tuples for deletion + var oldTuples []*v1.TupleKey + if len(oldRP.Spec.Permissions) > 0 { + oldResource := oldRP.Spec.Resource + oldObject := zanzana.NewObjectEntry(toZanzanaType(oldResource.ApiGroup), oldResource.ApiGroup, oldResource.Resource, "", oldResource.Name) + + oldTuples = make([]*v1.TupleKey, 0, len(oldRP.Spec.Permissions)) + for _, p := range oldRP.Spec.Permissions { + tuple, err := NewResourceTuple(oldObject, oldResource, p) + if err != nil { + b.logger.Error("failed to create old resource permission tuple", + "namespace", oldRP.Namespace, + "object", oldObject, + "err", err, + ) + continue + } + oldTuples = append(oldTuples, tuple) + } + } + + // Convert new permissions to tuples for writing + var newTuples []*v1.TupleKey + if len(newRP.Spec.Permissions) > 0 { + newResource := newRP.Spec.Resource + newObject := zanzana.NewObjectEntry(toZanzanaType(newResource.ApiGroup), newResource.ApiGroup, newResource.Resource, "", newResource.Name) + + newTuples = make([]*v1.TupleKey, 0, len(newRP.Spec.Permissions)) + for _, p := range newRP.Spec.Permissions { + tuple, err := NewResourceTuple(newObject, newResource, p) + if err != nil { + b.logger.Error("failed to create new resource permission tuple", + "namespace", newRP.Namespace, + "object", newObject, + "err", err, + ) + continue + } + newTuples = append(newTuples, tuple) + } + } + + // 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 + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues("resourcepermission", "update").Observe(time.Since(wait).Seconds()) + + go func() { + start := time.Now() + status := "success" + + defer func() { + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues("resourcepermission", "update", status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues("resourcepermission", "update", status).Inc() + }() + + b.logger.Debug("updating resource permission in zanzana", + "namespace", newRP.Namespace, + "oldPermissionsCnt", len(oldRP.Spec.Permissions), + "newPermissionsCnt", len(newRP.Spec.Permissions), + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + // Prepare write request + req := &v1.WriteRequest{ + Namespace: newRP.Namespace, + } + + // Add deletes for old tuples + if len(oldTuples) > 0 { + deleteTuples := toTupleKeysWithoutCondition(oldTuples) + req.Deletes = &v1.WriteRequestDeletes{ + TupleKeys: deleteTuples, + } + b.logger.Debug("deleting existing resource permissions from zanzana", + "namespace", newRP.Namespace, + "tuplesCnt", len(deleteTuples), + ) + } + + // Add writes for new tuples + if len(newTuples) > 0 { + req.Writes = &v1.WriteRequestWrites{ + TupleKeys: newTuples, + } + b.logger.Debug("writing new resource permissions to zanzana", + "namespace", newRP.Namespace, + "tuplesCnt", len(newTuples), + ) + } + + // Only make the request if there are deletes or writes + if (req.Deletes != nil && len(req.Deletes.TupleKeys) > 0) || (req.Writes != nil && len(req.Writes.TupleKeys) > 0) { + err := b.zClient.Write(ctx, req) + if err != nil { + status = "failure" + b.logger.Error("failed to update resource permission in zanzana", + "err", err, + "namespace", newRP.Namespace, + ) + } else { + // Record successful tuple operations + if len(oldTuples) > 0 { + hooksTuplesCounter.WithLabelValues("resourcepermission", "update", "delete").Add(float64(len(oldTuples))) + } + if len(newTuples) > 0 { + hooksTuplesCounter.WithLabelValues("resourcepermission", "update", "write").Add(float64(len(newTuples))) + } + } + } else { + b.logger.Debug("no tuples to update in zanzana", "namespace", newRP.Namespace) + } + }() + }, nil +} + +// AfterResourcePermissionDelete is a post-delete hook that removes the resource permission from Zanzana (openFGA) +func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionDelete(obj runtime.Object, _ *metav1.DeleteOptions) { + if b.zClient == nil { + return + } + + rp, ok := obj.(*iamv0.ResourcePermission) + if !ok { + b.logger.Error("failed to convert object to resourcePermission type", "object", obj) + return + } + + resourceType := "resourcepermission" + operation := "delete" + + // Grab a ticket to write to Zanzana + // This limits the amount of concurrent connections to Zanzana + wait := time.Now() + b.zTickets <- true + hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) // Record wait time + + go func(rp *iamv0.ResourcePermission) { + start := time.Now() + status := "success" + + defer func() { + // Release the ticket after write is done + <-b.zTickets + // Record operation duration and count + hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds()) + hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc() + }() + + resource := rp.Spec.Resource + permissions := rp.Spec.Permissions + + object := zanzana.NewObjectEntry(toZanzanaType(resource.ApiGroup), resource.ApiGroup, resource.Resource, "", resource.Name) + + // Generate delete tuples from the permissions + deleteTuples := make([]*v1.TupleKeyWithoutCondition, 0, len(permissions)) + for _, p := range permissions { + tuple, err := NewResourceTuple(object, resource, p) + if err != nil { + b.logger.Error("failed to create resource permission tuple for deletion", + "namespace", rp.Namespace, + "object", object, + "err", err, + ) + continue + } + deleteTuples = append(deleteTuples, tupleToTupleKeyWithoutCondition(tuple)) + } + + // Avoid writing if there are no valid tuples + if len(deleteTuples) == 0 { + b.logger.Warn("no valid tuples to delete", "namespace", rp.Namespace, "resource", object) + status = "failure" + return + } + + b.logger.Debug("deleting resource permission from zanzana", + "namespace", rp.Namespace, + "object", object, + "tuplesCnt", len(deleteTuples), + ) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout) + defer cancel() + + err := b.zClient.Write(ctx, &v1.WriteRequest{ + Namespace: rp.Namespace, + Deletes: &v1.WriteRequestDeletes{ + TupleKeys: deleteTuples, + }, + }) + if err != nil { + status = "failure" + b.logger.Error("failed to delete resource permission from zanzana", + "err", err, + "namespace", rp.Namespace, + "object", object, + "tuplesCnt", len(deleteTuples), + ) + } else { + // Record successful tuple deletions + hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Add(float64(len(deleteTuples))) } }(rp.DeepCopy()) // Pass a copy of the object } @@ -236,7 +508,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, wait := time.Now() b.zTickets <- true - hooksWaitHistogram.Observe(time.Since(wait).Seconds()) + hooksWaitHistogram.WithLabelValues("role", "create").Observe(time.Since(wait).Seconds()) go func() { defer func() { diff --git a/pkg/registry/apis/iam/hooks_test.go b/pkg/registry/apis/iam/hooks_test.go index 9c783171560..c19e45ce0d9 100644 --- a/pkg/registry/apis/iam/hooks_test.go +++ b/pkg/registry/apis/iam/hooks_test.go @@ -16,6 +16,7 @@ import ( type FakeZanzanaClient struct { zanzana.Client writeCallback func(context.Context, *v1.WriteRequest) error + readCallback func(context.Context, *v1.ReadRequest) (*v1.ReadResponse, error) } // Write implements zanzana.Client. @@ -23,6 +24,14 @@ func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) err return f.writeCallback(ctx, req) } +// Read implements zanzana.Client. +func (f *FakeZanzanaClient) Read(ctx context.Context, req *v1.ReadRequest) (*v1.ReadResponse, error) { + if f.readCallback != nil { + return f.readCallback(ctx, req) + } + return &v1.ReadResponse{}, nil +} + func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.TupleKey, msgAndArgs ...interface{}) { t.Helper() for _, exp := range expected { @@ -137,6 +146,254 @@ func TestAfterResourcePermissionCreate(t *testing.T) { }) } +func TestBeginResourcePermissionUpdate(t *testing.T) { + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should update zanzana entries for folder resource permissions", func(t *testing.T) { + oldFolderPerm := iamv0.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-2", + }, + Spec: iamv0.ResourcePermissionSpec{ + Resource: iamv0.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1", + }, + Permissions: []iamv0.ResourcePermissionspecPermission{ + {Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"}, + }, + }, + } + + newFolderPerm := iamv0.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-2", + }, + Spec: iamv0.ResourcePermissionSpec{ + Resource: iamv0.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1", + }, + Permissions: []iamv0.ResourcePermissionspecPermission{ + {Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u2", Verb: "Edit"}, + {Kind: iamv0.ResourcePermissionSpecPermissionKindTeam, Name: "team1", Verb: "View"}, + }, + }, + } + + testFolderWrite := func(ctx context.Context, req *v1.WriteRequest) error { + require.NotNil(t, req) + require.Equal(t, "org-2", req.Namespace) + + // Should delete old permission + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:u1", Relation: "view", Object: "folder:fold1"}, + ) + + // Should write new permissions + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 2) + + expectedWrites := []*v1.TupleKey{ + {User: "user:u2", Relation: "edit", Object: "folder:fold1"}, + {User: "team:team1#member", Relation: "view", Object: "folder:fold1"}, + } + requireTuplesMatch(t, req.Writes.TupleKeys, expectedWrites) + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testFolderWrite} + + // Call BeginUpdate which does all the work + finishFunc, err := b.BeginResourcePermissionUpdate(context.Background(), &newFolderPerm, &oldFolderPerm, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call the finish function with success=true to trigger the zanzana write + finishFunc(context.Background(), true) + }) + + // Wait for the ticket to be released + <-b.zTickets + + t.Run("should update zanzana entries for dashboard resource permissions", func(t *testing.T) { + oldDashPerm := iamv0.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Spec: iamv0.ResourcePermissionSpec{ + Resource: iamv0.ResourcePermissionspecResource{ + ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", + }, + Permissions: []iamv0.ResourcePermissionspecPermission{ + {Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"}, + }, + }, + } + + newDashPerm := iamv0.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Spec: iamv0.ResourcePermissionSpec{ + Resource: iamv0.ResourcePermissionspecResource{ + ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", + }, + Permissions: []iamv0.ResourcePermissionspecPermission{ + {Kind: iamv0.ResourcePermissionSpecPermissionKindServiceAccount, Name: "sa1", Verb: "Edit"}, + }, + }, + } + + object := "resource:dashboard.grafana.app/dashboards/dash1" + + testDashWrite := func(ctx context.Context, req *v1.WriteRequest) error { + require.NotNil(t, req) + require.Equal(t, "default", req.Namespace) + + // Should delete old permission + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 1) + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:u1", Relation: "view", Object: object}, + ) + + // Should write new permission + require.NotNil(t, req.Writes) + require.Len(t, req.Writes.TupleKeys, 1) + + tuple := req.Writes.TupleKeys[0] + require.NotNil(t, tuple.Condition) + require.Equal(t, "group_filter", tuple.Condition.Name) + tuple.Condition = nil + require.Equal( + t, + tuple, + &v1.TupleKey{User: "service-account:sa1", Relation: "edit", Object: object}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testDashWrite} + + // Call BeginUpdate which does all the work + finishFunc, err := b.BeginResourcePermissionUpdate(context.Background(), &newDashPerm, &oldDashPerm, nil) + require.NoError(t, err) + require.NotNil(t, finishFunc) + + // Call the finish function with success=true to trigger the zanzana write + finishFunc(context.Background(), true) + }) +} + +func TestAfterResourcePermissionDelete(t *testing.T) { + b := &IdentityAccessManagementAPIBuilder{ + logger: log.NewNopLogger(), + zTickets: make(chan bool, 1), + } + + t.Run("should delete zanzana entries for folder resource permissions", func(t *testing.T) { + folderPerm := iamv0.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "org-2", + }, + Spec: iamv0.ResourcePermissionSpec{ + Resource: iamv0.ResourcePermissionspecResource{ + ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1", + }, + Permissions: []iamv0.ResourcePermissionspecPermission{ + {Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"}, + {Kind: iamv0.ResourcePermissionSpecPermissionKindBasicRole, Name: "Editor", Verb: "Edit"}, + }, + }, + } + + testFolderDelete := func(ctx context.Context, req *v1.WriteRequest) error { + require.NotNil(t, req) + require.Equal(t, "org-2", req.Namespace) + + // Should have deletes but no writes + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 2) + require.Nil(t, req.Writes) + + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "user:u1", Relation: "view", Object: "folder:fold1"}, + ) + require.Equal( + t, + req.Deletes.TupleKeys[1], + &v1.TupleKeyWithoutCondition{User: "role:basic_editor#assignee", Relation: "edit", Object: "folder:fold1"}, + ) + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testFolderDelete} + b.AfterResourcePermissionDelete(&folderPerm, nil) + }) + + // Wait for the ticket to be released + <-b.zTickets + + t.Run("should delete zanzana entries for dashboard resource permissions", func(t *testing.T) { + dashPerm := iamv0.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Spec: iamv0.ResourcePermissionSpec{ + Resource: iamv0.ResourcePermissionspecResource{ + ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1", + }, + Permissions: []iamv0.ResourcePermissionspecPermission{ + {Kind: iamv0.ResourcePermissionSpecPermissionKindServiceAccount, Name: "sa1", Verb: "View"}, + {Kind: iamv0.ResourcePermissionSpecPermissionKindTeam, Name: "team1", Verb: "Edit"}, + }, + }, + } + + testDashDelete := func(ctx context.Context, req *v1.WriteRequest) error { + object := "resource:dashboard.grafana.app/dashboards/dash1" + + require.NotNil(t, req) + require.Equal(t, "default", req.Namespace) + + // Should have deletes but no writes + require.NotNil(t, req.Deletes) + require.Len(t, req.Deletes.TupleKeys, 2) + require.Nil(t, req.Writes) + + require.Equal( + t, + req.Deletes.TupleKeys[0], + &v1.TupleKeyWithoutCondition{User: "service-account:sa1", Relation: "view", Object: object}, + ) + require.Equal( + t, + req.Deletes.TupleKeys[1], + &v1.TupleKeyWithoutCondition{User: "team:team1#member", Relation: "edit", Object: object}, + ) + + return nil + } + + b.zClient = &FakeZanzanaClient{writeCallback: testDashDelete} + b.AfterResourcePermissionDelete(&dashPerm, nil) + }) + + // Wait for the ticket to be released + <-b.zTickets +} + func TestAfterCoreRoleCreate(t *testing.T) { t.Run("should create zanzana entries for core role with folder permissions", func(t *testing.T) { b := &IdentityAccessManagementAPIBuilder{ diff --git a/pkg/registry/apis/iam/metrics.go b/pkg/registry/apis/iam/metrics.go index 6c25a8746f7..aa918732843 100644 --- a/pkg/registry/apis/iam/metrics.go +++ b/pkg/registry/apis/iam/metrics.go @@ -14,19 +14,53 @@ const ( var ( registerOnce sync.Once - hooksWaitHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{ + hooksWaitHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: metricsNamespace, Subsystem: metricsSubSystem, Name: "hooks_wait_duration_seconds", Help: "Time spent in the hooks waiting for a ticket to start processing", Buckets: prometheus.ExponentialBuckets(0.001, 2, 5), // 1ms to ~16s - }) + }, []string{"resource_type", "operation"}) + + // hooksDurationHistogram tracks the total duration of hook operations + hooksDurationHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Name: "hooks_operation_duration_seconds", + Help: "Time spent executing hook operations (create, update, delete)", + Buckets: prometheus.ExponentialBuckets(0.001, 2, 10), // 1ms to ~1s + }, []string{"resource_type", "operation", "status"}) + + // hooksOperationCounter tracks the number of hook operations + hooksOperationCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Name: "hooks_operations_total", + Help: "Total number of hook operations by resource type, operation, and status", + }, []string{"resource_type", "operation", "status"}) + + // hooksTuplesCounter tracks the number of tuples written/deleted + hooksTuplesCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Name: "hooks_tuples_total", + Help: "Total number of tuples written or deleted by resource type and operation type", + }, []string{"resource_type", "operation", "action"}) ) func registerMetrics(reg prometheus.Registerer) { registerOnce.Do(func() { - if err := reg.Register(hooksWaitHistogram); err != nil { - log.New("iam.apis").Warn("failed to register iam apiserver metrics", "error", err) + metrics := []prometheus.Collector{ + hooksWaitHistogram, + hooksDurationHistogram, + hooksOperationCounter, + hooksTuplesCounter, + } + + for _, metric := range metrics { + if err := reg.Register(metric); err != nil { + log.New("iam.apis").Warn("failed to register iam apiserver metrics", "error", err) + } } }) } diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index c852515f75c..1d1e74730f4 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -305,8 +305,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge return err } if enableZanzanaSync { - b.logger.Info("Enabling AfterCreate hook for ResourcePermission to sync to Zanzana") + b.logger.Info("Enabling AfterCreate, BeginUpdate, and AfterDelete hooks for ResourcePermission to sync to Zanzana") resourcePermissionStore.AfterCreate = b.AfterResourcePermissionCreate + resourcePermissionStore.BeginUpdate = b.BeginResourcePermissionUpdate + resourcePermissionStore.AfterDelete = b.AfterResourcePermissionDelete } storage[iamv0.ResourcePermissionInfo.StoragePath()] = resourcePermissionStore }