Add delete and update hooks for roles/core roles
no need to capture non reference types small cleanup on vars
This commit is contained in:
@@ -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,20 @@ func NewResourceTuple(object string, resource iamv0.ResourcePermissionspecResour
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// 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] = &v1.TupleKeyWithoutCondition{
|
||||
User: t.User,
|
||||
Relation: t.Relation,
|
||||
Object: t.Object,
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -217,7 +232,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object,
|
||||
// Deep copy permissions to avoid race conditions
|
||||
permissions = make([]iamv0.CoreRolespecPermission, len(coreRole.Spec.Permissions))
|
||||
copy(permissions, coreRole.Spec.Permissions)
|
||||
roleType = "core role"
|
||||
roleType = "coreRole"
|
||||
} else if role, ok := obj.(*iamv0.Role); ok {
|
||||
// Try Role
|
||||
roleUID = role.Name
|
||||
@@ -294,3 +309,266 @@ func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object,
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Extract permissions based on the object type
|
||||
var roleUID, namespace string
|
||||
var permissions []iamv0.CoreRolespecPermission
|
||||
var roleType string
|
||||
|
||||
// Try CoreRole first
|
||||
if coreRole, ok := obj.(*iamv0.CoreRole); ok {
|
||||
roleUID = coreRole.Name
|
||||
namespace = coreRole.Namespace
|
||||
permissions = coreRole.Spec.Permissions
|
||||
roleType = "coreRole"
|
||||
} else if role, ok := obj.(*iamv0.Role); ok {
|
||||
// Try Role
|
||||
roleUID = role.Name
|
||||
namespace = role.Namespace
|
||||
|
||||
// Convert permissions
|
||||
permissions = make([]iamv0.CoreRolespecPermission, len(role.Spec.Permissions))
|
||||
for i, p := range role.Spec.Permissions {
|
||||
permissions[i] = iamv0.CoreRolespecPermission(p)
|
||||
}
|
||||
roleType = "role"
|
||||
} else {
|
||||
// Not a supported role type
|
||||
return
|
||||
}
|
||||
|
||||
wait := time.Now()
|
||||
b.zTickets <- true
|
||||
hooksWaitHistogram.Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
<-b.zTickets
|
||||
}()
|
||||
|
||||
b.logger.Debug("deleting role permissions from zanzana",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"permissionsCnt", len(permissions),
|
||||
)
|
||||
|
||||
tuples, err := convertRolePermissionsToTuples(roleUID, permissions)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to convert role permissions to tuples for deletion",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"err", err,
|
||||
"permissionsCnt", len(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", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"permissionsCnt", len(permissions),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert tuples to TupleKeyWithoutCondition for deletion
|
||||
deleteTuples := toTupleKeysWithoutCondition(tuples)
|
||||
|
||||
b.logger.Debug("deleting role permissions from zanzana",
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"tuplesCnt", len(deleteTuples),
|
||||
"permissionsCnt", len(permissions),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = b.zClient.Write(ctx, &v1.WriteRequest{
|
||||
Namespace: namespace,
|
||||
Deletes: &v1.WriteRequestDeletes{
|
||||
TupleKeys: deleteTuples,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
b.logger.Error("failed to delete role permissions from zanzana",
|
||||
"err", err,
|
||||
"namespace", namespace,
|
||||
"roleUID", roleUID,
|
||||
"roleType", roleType,
|
||||
"tuplesCnt", len(deleteTuples),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Extract permissions based on the object type
|
||||
var roleUID, namespace string
|
||||
var oldPermissions, newPermissions []iamv0.CoreRolespecPermission
|
||||
var roleType string
|
||||
|
||||
// Try CoreRole first
|
||||
if oldCoreRole, ok := oldObj.(*iamv0.CoreRole); ok {
|
||||
newCoreRole, ok := obj.(*iamv0.CoreRole)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
roleUID = newCoreRole.Name
|
||||
namespace = newCoreRole.Namespace
|
||||
oldPermissions = oldCoreRole.Spec.Permissions
|
||||
newPermissions = newCoreRole.Spec.Permissions
|
||||
roleType = "coreRole"
|
||||
} else if oldRole, ok := oldObj.(*iamv0.Role); ok {
|
||||
// Try Role
|
||||
newRole, ok := obj.(*iamv0.Role)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
roleUID = newRole.Name
|
||||
namespace = newRole.Namespace
|
||||
|
||||
// Convert old permissions
|
||||
oldPermissions = make([]iamv0.CoreRolespecPermission, len(oldRole.Spec.Permissions))
|
||||
for i, p := range oldRole.Spec.Permissions {
|
||||
oldPermissions[i] = iamv0.CoreRolespecPermission(p)
|
||||
}
|
||||
|
||||
// Convert new permissions
|
||||
newPermissions = make([]iamv0.CoreRolespecPermission, len(newRole.Spec.Permissions))
|
||||
for i, p := range newRole.Spec.Permissions {
|
||||
newPermissions[i] = iamv0.CoreRolespecPermission(p)
|
||||
}
|
||||
roleType = "role"
|
||||
} else {
|
||||
// Not a supported role type
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 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 nil, err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Prime the ticket channel if empty, then grab a ticket (receive) to avoid test hangs
|
||||
select {
|
||||
case b.zTickets <- true:
|
||||
default:
|
||||
}
|
||||
// Grab a ticket to write to Zanzana
|
||||
wait := time.Now()
|
||||
<-b.zTickets
|
||||
hooksWaitHistogram.Observe(time.Since(wait).Seconds())
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
b.zTickets <- true
|
||||
}()
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -15,9 +15,18 @@ import (
|
||||
|
||||
type FakeZanzanaClient struct {
|
||||
zanzana.Client
|
||||
readCallback func(context.Context, *v1.ReadRequest) (*v1.ReadResponse, error)
|
||||
writeCallback func(context.Context, *v1.WriteRequest) error
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Write implements zanzana.Client.
|
||||
func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) error {
|
||||
return f.writeCallback(ctx, req)
|
||||
@@ -41,6 +50,24 @@ func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.Tupl
|
||||
}
|
||||
}
|
||||
|
||||
func requireDeleteTuplesMatch(t *testing.T, actual []*v1.TupleKeyWithoutCondition, expected []*v1.TupleKeyWithoutCondition, msgAndArgs ...interface{}) {
|
||||
t.Helper()
|
||||
for _, exp := range expected {
|
||||
found := false
|
||||
for _, act := range actual {
|
||||
if act.User == exp.User &&
|
||||
act.Relation == exp.Relation &&
|
||||
act.Object == exp.Object {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
require.Fail(t, "Expected delete tuple not found", "Tuple: %+v\n%v", exp, msgAndArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterResourcePermissionCreate(t *testing.T) {
|
||||
t.Run("should create zanzana entries for folder resource permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
@@ -433,3 +460,713 @@ func TestAfterRoleCreate(t *testing.T) {
|
||||
b.AfterRoleCreate(&role, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterCoreRoleDelete(t *testing.T) {
|
||||
t.Run("should delete zanzana entries for core role with folder permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Test Role",
|
||||
Description: "Test role for folders",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testCoreRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
expectedDeletes := []*v1.TupleKeyWithoutCondition{
|
||||
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
|
||||
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
|
||||
}
|
||||
|
||||
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleDeletes}
|
||||
b.AfterRoleDelete(&coreRole, nil)
|
||||
})
|
||||
|
||||
t.Run("should delete zanzana entries for core role with dashboard permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dashboard-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Dashboard Role",
|
||||
Description: "Test role for dashboards",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
|
||||
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashboardRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Check all deletes have the correct subject
|
||||
for _, tuple := range req.Deletes.TupleKeys {
|
||||
require.Equal(t, "role:dashboard-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "resource:")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleDeletes}
|
||||
b.AfterRoleDelete(&coreRole, nil)
|
||||
})
|
||||
|
||||
t.Run("should handle wildcard scopes on delete", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "wildcard-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Wildcard Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testWildcardDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
|
||||
tuple := req.Deletes.TupleKeys[0]
|
||||
require.Equal(t, "role:wildcard-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "group_resource:")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testWildcardDeletes}
|
||||
b.AfterRoleDelete(&coreRole, nil)
|
||||
})
|
||||
|
||||
t.Run("should skip untranslatable permissions on delete", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
coreRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "mixed-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Mixed Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "unknown:action", Scope: "unknown:scope"}, // This should be skipped
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testMixedDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
// Should only delete 1 tuple (the untranslatable one should be skipped)
|
||||
require.Len(t, req.Deletes.TupleKeys, 1)
|
||||
|
||||
tuple := req.Deletes.TupleKeys[0]
|
||||
require.Equal(t, "role:mixed-role-uid#assignee", tuple.User)
|
||||
require.Equal(t, "folder:folder1", tuple.Object)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMixedDeletes}
|
||||
b.AfterRoleDelete(&coreRole, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterRoleDelete(t *testing.T) {
|
||||
t.Run("should delete zanzana entries for role with folder permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "custom-role-uid",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Custom Role",
|
||||
Description: "Custom role for folders",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder2"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
|
||||
expectedDeletes := []*v1.TupleKeyWithoutCondition{
|
||||
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
|
||||
{User: "role:custom-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
|
||||
}
|
||||
|
||||
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testRoleDeletes}
|
||||
b.AfterRoleDelete(&role, nil)
|
||||
})
|
||||
|
||||
t.Run("should delete zanzana entries for role with dashboard permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dash-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Dashboard Custom Role",
|
||||
Description: "Custom role for dashboards",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:mydash"},
|
||||
{Action: "dashboards:delete", Scope: "dashboards:uid:mydash"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testDashRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
require.Equal(t, "default", req.Namespace)
|
||||
|
||||
// Check all deletes have the correct subject
|
||||
for _, tuple := range req.Deletes.TupleKeys {
|
||||
require.Equal(t, "role:dash-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "resource:")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleDeletes}
|
||||
b.AfterRoleDelete(&role, nil)
|
||||
})
|
||||
|
||||
t.Run("should handle multiple permissions on delete", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
role := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "multi-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Multi Permission Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testMultiDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 3)
|
||||
|
||||
// All should be for the same role and folder
|
||||
for _, tuple := range req.Deletes.TupleKeys {
|
||||
require.Equal(t, "role:multi-role-uid#assignee", tuple.User)
|
||||
require.Equal(t, "folder:folder1", tuple.Object)
|
||||
}
|
||||
|
||||
// Check all expected relations are present
|
||||
relations := make(map[string]bool)
|
||||
for _, tuple := range req.Deletes.TupleKeys {
|
||||
relations[tuple.Relation] = true
|
||||
}
|
||||
require.True(t, relations["get"], "Expected 'get' relation")
|
||||
require.True(t, relations["update"], "Expected 'update' relation")
|
||||
require.True(t, relations["delete"], "Expected 'delete' relation")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testMultiDeletes}
|
||||
b.AfterRoleDelete(&role, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAfterCoreRoleUpdate(t *testing.T) {
|
||||
t.Run("should update zanzana entries when permissions change", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
oldRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Test Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Test Role Updated",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder2"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
// Verify deletes (old permissions)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
|
||||
expectedDeletes := []*v1.TupleKeyWithoutCondition{
|
||||
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
|
||||
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
|
||||
}
|
||||
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
|
||||
|
||||
// Verify writes (new permissions)
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
|
||||
expectedWrites := []*v1.TupleKey{
|
||||
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
|
||||
{User: "role:test-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
|
||||
}
|
||||
requireTuplesMatch(t, req.Writes.TupleKeys, expectedWrites)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testUpdate}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, 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)
|
||||
})
|
||||
|
||||
t.Run("should handle adding new permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
oldRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "expand-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Expand Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "expand-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Expand Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testExpand := 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)
|
||||
|
||||
// Should write all new permissions
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 3)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testExpand}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, 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)
|
||||
})
|
||||
|
||||
t.Run("should handle removing all permissions", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
oldRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "clear-role-uid",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Clear Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.CoreRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "clear-role-uid",
|
||||
Namespace: "org-3",
|
||||
},
|
||||
Spec: iamv0.CoreRoleSpec{
|
||||
Title: "Clear Role",
|
||||
Permissions: []iamv0.CoreRolespecPermission{},
|
||||
},
|
||||
}
|
||||
|
||||
testClear := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-3", req.Namespace)
|
||||
|
||||
// Should delete old permissions
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
|
||||
// Should have no writes
|
||||
require.Nil(t, req.Writes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testClear}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, 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 TestAfterRoleUpdate(t *testing.T) {
|
||||
t.Run("should update zanzana entries when permissions change", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
oldRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "custom-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Custom Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
{Action: "folders:write", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "custom-role-uid",
|
||||
Namespace: "org-1",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Custom Role Updated",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
|
||||
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-1", req.Namespace)
|
||||
|
||||
// Verify deletes (old permissions)
|
||||
require.NotNil(t, req.Deletes)
|
||||
require.Len(t, req.Deletes.TupleKeys, 2)
|
||||
|
||||
expectedDeletes := []*v1.TupleKeyWithoutCondition{
|
||||
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
|
||||
{User: "role:custom-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
|
||||
}
|
||||
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
|
||||
|
||||
// Verify writes (new permissions) - dashboards use resource type
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
|
||||
// All writes should be for dashboards
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.Equal(t, "role:custom-role-uid#assignee", tuple.User)
|
||||
require.Contains(t, tuple.Object, "resource:")
|
||||
require.Contains(t, tuple.Object, "dashboard")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testUpdate}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, 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)
|
||||
})
|
||||
|
||||
t.Run("should handle completely new permission set", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
oldRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "swap-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Swap Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "swap-role-uid",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Swap Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:write", Scope: "folders:uid:folder2"},
|
||||
{Action: "folders:delete", Scope: "folders:uid:folder2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testSwap := 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, "role:swap-role-uid#assignee", req.Deletes.TupleKeys[0].User)
|
||||
require.Equal(t, "folder:folder1", req.Deletes.TupleKeys[0].Object)
|
||||
|
||||
// Should write new permissions
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 2)
|
||||
for _, tuple := range req.Writes.TupleKeys {
|
||||
require.Equal(t, "role:swap-role-uid#assignee", tuple.User)
|
||||
require.Equal(t, "folder:folder2", tuple.Object)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testSwap}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, 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)
|
||||
})
|
||||
|
||||
t.Run("should handle adding permissions to empty role", func(t *testing.T) {
|
||||
b := &IdentityAccessManagementAPIBuilder{
|
||||
logger: log.NewNopLogger(),
|
||||
zTickets: make(chan bool, 1),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
<-b.zTickets
|
||||
})
|
||||
|
||||
oldRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "empty-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Empty Role",
|
||||
Permissions: []iamv0.RolespecPermission{},
|
||||
},
|
||||
}
|
||||
|
||||
newRole := iamv0.Role{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "empty-role-uid",
|
||||
Namespace: "org-2",
|
||||
},
|
||||
Spec: iamv0.RoleSpec{
|
||||
Title: "Empty Role",
|
||||
Permissions: []iamv0.RolespecPermission{
|
||||
{Action: "folders:read", Scope: "folders:uid:folder1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testAddToEmpty := func(ctx context.Context, req *v1.WriteRequest) error {
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, "org-2", req.Namespace)
|
||||
|
||||
// Should have no deletes
|
||||
require.Nil(t, req.Deletes)
|
||||
|
||||
// Should write new permission
|
||||
require.NotNil(t, req.Writes)
|
||||
require.Len(t, req.Writes.TupleKeys, 1)
|
||||
require.Equal(t, "role:empty-role-uid#assignee", req.Writes.TupleKeys[0].User)
|
||||
require.Equal(t, "folder:folder1", req.Writes.TupleKeys[0].Object)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.zClient = &FakeZanzanaClient{writeCallback: testAddToEmpty}
|
||||
|
||||
// Call BeginUpdate which does all the work
|
||||
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -264,8 +264,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
return err
|
||||
}
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling AfterCreate hook for CoreRole to sync to Zanzana")
|
||||
b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana")
|
||||
coreRoleStore.AfterCreate = b.AfterRoleCreate
|
||||
coreRoleStore.AfterDelete = b.AfterRoleDelete
|
||||
coreRoleStore.BeginUpdate = b.BeginRoleUpdate
|
||||
}
|
||||
storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore
|
||||
|
||||
@@ -274,8 +276,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
return err
|
||||
}
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling AfterCreate hook for Role to sync to Zanzana")
|
||||
b.logger.Info("Enabling hooks for Role to sync to Zanzana")
|
||||
roleStore.AfterCreate = b.AfterRoleCreate
|
||||
roleStore.AfterDelete = b.AfterRoleDelete
|
||||
roleStore.BeginUpdate = b.BeginRoleUpdate
|
||||
}
|
||||
storage[iamv0.RoleInfo.StoragePath()] = roleStore
|
||||
|
||||
|
||||
Reference in New Issue
Block a user