[grafana-iam] Add resourcePermissions hooks to sync write to Zanzana on UPDATE and DELETE (#112767)
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/structpb"
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apiserver/pkg/registry/generic/registry"
|
||||||
|
|
||||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
@@ -96,6 +97,26 @@ func NewResourceTuple(object string, resource iamv0.ResourcePermissionspecResour
|
|||||||
return key, nil
|
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)
|
// AfterResourcePermissionCreate is a post-create hook that writes the resource permission to Zanzana (openFGA)
|
||||||
func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj runtime.Object, _ *metav1.CreateOptions) {
|
func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj runtime.Object, _ *metav1.CreateOptions) {
|
||||||
if b.zClient == nil {
|
if b.zClient == nil {
|
||||||
@@ -104,19 +125,29 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj r
|
|||||||
|
|
||||||
rp, ok := obj.(*iamv0.ResourcePermission)
|
rp, ok := obj.(*iamv0.ResourcePermission)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
b.logger.Error("failed to convert object to resourcePermission type", "object", obj)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resourceType := "resourcepermission"
|
||||||
|
operation := "create"
|
||||||
|
|
||||||
// Grab a ticket to write to Zanzana
|
// 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()
|
wait := time.Now()
|
||||||
b.zTickets <- true
|
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) {
|
go func(rp *iamv0.ResourcePermission) {
|
||||||
|
start := time.Now()
|
||||||
|
status := "success"
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
// Release the ticket after write is done
|
// Release the ticket after write is done
|
||||||
<-b.zTickets
|
<-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
|
resource := rp.Spec.Resource
|
||||||
@@ -142,6 +173,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj r
|
|||||||
// Avoid writing if there are no valid tuples
|
// Avoid writing if there are no valid tuples
|
||||||
if len(tuples) == 0 {
|
if len(tuples) == 0 {
|
||||||
b.logger.Warn("no valid tuples to write", "namespace", rp.Namespace, "resource", object)
|
b.logger.Warn("no valid tuples to write", "namespace", rp.Namespace, "resource", object)
|
||||||
|
status = "failure"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,12 +193,252 @@ func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj r
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
status = "failure"
|
||||||
b.logger.Error("failed to write resource permission to zanzana",
|
b.logger.Error("failed to write resource permission to zanzana",
|
||||||
"err", err,
|
"err", err,
|
||||||
"namespace", rp.Namespace,
|
"namespace", rp.Namespace,
|
||||||
"object", object,
|
"object", object,
|
||||||
"tuplesCnt", len(tuples),
|
"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
|
}(rp.DeepCopy()) // Pass a copy of the object
|
||||||
}
|
}
|
||||||
@@ -236,7 +508,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object,
|
|||||||
|
|
||||||
wait := time.Now()
|
wait := time.Now()
|
||||||
b.zTickets <- true
|
b.zTickets <- true
|
||||||
hooksWaitHistogram.Observe(time.Since(wait).Seconds())
|
hooksWaitHistogram.WithLabelValues("role", "create").Observe(time.Since(wait).Seconds())
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
type FakeZanzanaClient struct {
|
type FakeZanzanaClient struct {
|
||||||
zanzana.Client
|
zanzana.Client
|
||||||
writeCallback func(context.Context, *v1.WriteRequest) error
|
writeCallback func(context.Context, *v1.WriteRequest) error
|
||||||
|
readCallback func(context.Context, *v1.ReadRequest) (*v1.ReadResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements zanzana.Client.
|
// Write implements zanzana.Client.
|
||||||
@@ -23,6 +24,14 @@ func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) err
|
|||||||
return f.writeCallback(ctx, req)
|
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{}) {
|
func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.TupleKey, msgAndArgs ...interface{}) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, exp := range expected {
|
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) {
|
func TestAfterCoreRoleCreate(t *testing.T) {
|
||||||
t.Run("should create zanzana entries for core role with folder permissions", func(t *testing.T) {
|
t.Run("should create zanzana entries for core role with folder permissions", func(t *testing.T) {
|
||||||
b := &IdentityAccessManagementAPIBuilder{
|
b := &IdentityAccessManagementAPIBuilder{
|
||||||
|
|||||||
@@ -14,19 +14,53 @@ const (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
registerOnce sync.Once
|
registerOnce sync.Once
|
||||||
hooksWaitHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
|
hooksWaitHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
Namespace: metricsNamespace,
|
Namespace: metricsNamespace,
|
||||||
Subsystem: metricsSubSystem,
|
Subsystem: metricsSubSystem,
|
||||||
Name: "hooks_wait_duration_seconds",
|
Name: "hooks_wait_duration_seconds",
|
||||||
Help: "Time spent in the hooks waiting for a ticket to start processing",
|
Help: "Time spent in the hooks waiting for a ticket to start processing",
|
||||||
Buckets: prometheus.ExponentialBuckets(0.001, 2, 5), // 1ms to ~16s
|
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) {
|
func registerMetrics(reg prometheus.Registerer) {
|
||||||
registerOnce.Do(func() {
|
registerOnce.Do(func() {
|
||||||
if err := reg.Register(hooksWaitHistogram); err != nil {
|
metrics := []prometheus.Collector{
|
||||||
log.New("iam.apis").Warn("failed to register iam apiserver metrics", "error", err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,8 +305,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if enableZanzanaSync {
|
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.AfterCreate = b.AfterResourcePermissionCreate
|
||||||
|
resourcePermissionStore.BeginUpdate = b.BeginResourcePermissionUpdate
|
||||||
|
resourcePermissionStore.AfterDelete = b.AfterResourcePermissionDelete
|
||||||
}
|
}
|
||||||
storage[iamv0.ResourcePermissionInfo.StoragePath()] = resourcePermissionStore
|
storage[iamv0.ResourcePermissionInfo.StoragePath()] = resourcePermissionStore
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user