From 7b9d2b7d177e3dd9e5747ddf5508fa8c08c61106 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:01:52 +0200 Subject: [PATCH] feat: add administrator role permission table (#11817) This update introduces a new table for managing administrator role permissions, along with the necessary repository and projection logic to handle adding and removing permissions. It also includes tests to ensure the correct functionality of the permission management system. closes https://github.com/zitadel/zitadel/issues/10219 --------- Co-authored-by: Marco Ardizzone --- backend/v3/domain/administrator_role.go | 32 ++ .../v3/domain/mock/administrator_role.mock.go | 434 ++++++++++++++++++ ...18_administrator_role_permissions_table.go | 16 + .../down.sql | 1 + .../up.sql | 8 + .../database/repository/administrator_role.go | 97 ++++ .../repository/administrator_role_test.go | 200 ++++++++ .../v2/integration_test/domain_test.go | 3 + ...dministrator_role_permission_relational.go | 45 ++ ...strator_role_permission_relational_test.go | 94 ++++ internal/query/projection/relational_table.go | 14 + 11 files changed, 944 insertions(+) create mode 100644 backend/v3/domain/administrator_role.go create mode 100644 backend/v3/domain/mock/administrator_role.mock.go create mode 100644 backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table.go create mode 100644 backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table/down.sql create mode 100644 backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table/up.sql create mode 100644 backend/v3/storage/database/repository/administrator_role.go create mode 100644 backend/v3/storage/database/repository/administrator_role_test.go create mode 100644 internal/query/projection/administrator_role_permission_relational.go create mode 100644 internal/query/projection/administrator_role_permission_relational_test.go diff --git a/backend/v3/domain/administrator_role.go b/backend/v3/domain/administrator_role.go new file mode 100644 index 000000000..6410dea9c --- /dev/null +++ b/backend/v3/domain/administrator_role.go @@ -0,0 +1,32 @@ +package domain + +import ( + "context" + + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +type administratorRoleColumns interface { + PrimaryKeyColumns() []database.Column + InstanceIDColumn() database.Column + RoleNameColumn() database.Column + PermissionColumn() database.Column +} + +type administratorRoleConditions interface { + PrimaryKeyCondition(instanceID, roleName, permission string) database.Condition + InstanceIDCondition(instanceID string) database.Condition + RoleNameCondition(op database.TextOperation, name string) database.Condition + PermissionCondition(op database.TextOperation, permission string) database.Condition +} + +//go:generate mockgen -typed -package domainmock -destination ./mock/administrator_role.mock.go . AdministratorRoleRepository +type AdministratorRoleRepository interface { + Repository + + administratorRoleColumns + administratorRoleConditions + + AddPermissions(ctx context.Context, client database.QueryExecutor, instanceID, role string, permissions ...string) (int64, error) + RemovePermissions(ctx context.Context, client database.QueryExecutor, instanceID, role string, permissionsToRemove ...string) (int64, error) +} diff --git a/backend/v3/domain/mock/administrator_role.mock.go b/backend/v3/domain/mock/administrator_role.mock.go new file mode 100644 index 000000000..e0abbb0fa --- /dev/null +++ b/backend/v3/domain/mock/administrator_role.mock.go @@ -0,0 +1,434 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/backend/v3/domain (interfaces: AdministratorRoleRepository) +// +// Generated by this command: +// +// mockgen -typed -package domainmock -destination ./mock/administrator_role.mock.go . AdministratorRoleRepository +// + +// Package domainmock is a generated GoMock package. +package domainmock + +import ( + context "context" + reflect "reflect" + + database "github.com/zitadel/zitadel/backend/v3/storage/database" + gomock "go.uber.org/mock/gomock" +) + +// MockAdministratorRoleRepository is a mock of AdministratorRoleRepository interface. +type MockAdministratorRoleRepository struct { + ctrl *gomock.Controller + recorder *MockAdministratorRoleRepositoryMockRecorder + isgomock struct{} +} + +// MockAdministratorRoleRepositoryMockRecorder is the mock recorder for MockAdministratorRoleRepository. +type MockAdministratorRoleRepositoryMockRecorder struct { + mock *MockAdministratorRoleRepository +} + +// NewMockAdministratorRoleRepository creates a new mock instance. +func NewMockAdministratorRoleRepository(ctrl *gomock.Controller) *MockAdministratorRoleRepository { + mock := &MockAdministratorRoleRepository{ctrl: ctrl} + mock.recorder = &MockAdministratorRoleRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAdministratorRoleRepository) EXPECT() *MockAdministratorRoleRepositoryMockRecorder { + return m.recorder +} + +// AddPermissions mocks base method. +func (m *MockAdministratorRoleRepository) AddPermissions(ctx context.Context, client database.QueryExecutor, instanceID, role string, permissions ...string) (int64, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, client, instanceID, role} + for _, a := range permissions { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AddPermissions", varargs...) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddPermissions indicates an expected call of AddPermissions. +func (mr *MockAdministratorRoleRepositoryMockRecorder) AddPermissions(ctx, client, instanceID, role any, permissions ...any) *MockAdministratorRoleRepositoryAddPermissionsCall { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, client, instanceID, role}, permissions...) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPermissions", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).AddPermissions), varargs...) + return &MockAdministratorRoleRepositoryAddPermissionsCall{Call: call} +} + +// MockAdministratorRoleRepositoryAddPermissionsCall wrap *gomock.Call +type MockAdministratorRoleRepositoryAddPermissionsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdministratorRoleRepositoryAddPermissionsCall) Return(arg0 int64, arg1 error) *MockAdministratorRoleRepositoryAddPermissionsCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdministratorRoleRepositoryAddPermissionsCall) Do(f func(context.Context, database.QueryExecutor, string, string, ...string) (int64, error)) *MockAdministratorRoleRepositoryAddPermissionsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdministratorRoleRepositoryAddPermissionsCall) DoAndReturn(f func(context.Context, database.QueryExecutor, string, string, ...string) (int64, error)) *MockAdministratorRoleRepositoryAddPermissionsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// InstanceIDColumn mocks base method. +func (m *MockAdministratorRoleRepository) InstanceIDColumn() database.Column { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InstanceIDColumn") + ret0, _ := ret[0].(database.Column) + return ret0 +} + +// InstanceIDColumn indicates an expected call of InstanceIDColumn. +func (mr *MockAdministratorRoleRepositoryMockRecorder) InstanceIDColumn() *MockAdministratorRoleRepositoryInstanceIDColumnCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceIDColumn", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).InstanceIDColumn)) + return &MockAdministratorRoleRepositoryInstanceIDColumnCall{Call: call} +} + +// MockAdministratorRoleRepositoryInstanceIDColumnCall wrap *gomock.Call +type MockAdministratorRoleRepositoryInstanceIDColumnCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdministratorRoleRepositoryInstanceIDColumnCall) Return(arg0 database.Column) *MockAdministratorRoleRepositoryInstanceIDColumnCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdministratorRoleRepositoryInstanceIDColumnCall) Do(f func() database.Column) *MockAdministratorRoleRepositoryInstanceIDColumnCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdministratorRoleRepositoryInstanceIDColumnCall) DoAndReturn(f func() database.Column) *MockAdministratorRoleRepositoryInstanceIDColumnCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// InstanceIDCondition mocks base method. +func (m *MockAdministratorRoleRepository) InstanceIDCondition(instanceID string) database.Condition { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InstanceIDCondition", instanceID) + ret0, _ := ret[0].(database.Condition) + return ret0 +} + +// InstanceIDCondition indicates an expected call of InstanceIDCondition. +func (mr *MockAdministratorRoleRepositoryMockRecorder) InstanceIDCondition(instanceID any) *MockAdministratorRoleRepositoryInstanceIDConditionCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceIDCondition", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).InstanceIDCondition), instanceID) + return &MockAdministratorRoleRepositoryInstanceIDConditionCall{Call: call} +} + +// MockAdministratorRoleRepositoryInstanceIDConditionCall wrap *gomock.Call +type MockAdministratorRoleRepositoryInstanceIDConditionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdministratorRoleRepositoryInstanceIDConditionCall) Return(arg0 database.Condition) *MockAdministratorRoleRepositoryInstanceIDConditionCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdministratorRoleRepositoryInstanceIDConditionCall) Do(f func(string) database.Condition) *MockAdministratorRoleRepositoryInstanceIDConditionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdministratorRoleRepositoryInstanceIDConditionCall) DoAndReturn(f func(string) database.Condition) *MockAdministratorRoleRepositoryInstanceIDConditionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PermissionColumn mocks base method. +func (m *MockAdministratorRoleRepository) PermissionColumn() database.Column { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermissionColumn") + ret0, _ := ret[0].(database.Column) + return ret0 +} + +// PermissionColumn indicates an expected call of PermissionColumn. +func (mr *MockAdministratorRoleRepositoryMockRecorder) PermissionColumn() *MockAdministratorRoleRepositoryPermissionColumnCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermissionColumn", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).PermissionColumn)) + return &MockAdministratorRoleRepositoryPermissionColumnCall{Call: call} +} + +// MockAdministratorRoleRepositoryPermissionColumnCall wrap *gomock.Call +type MockAdministratorRoleRepositoryPermissionColumnCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdministratorRoleRepositoryPermissionColumnCall) Return(arg0 database.Column) *MockAdministratorRoleRepositoryPermissionColumnCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdministratorRoleRepositoryPermissionColumnCall) Do(f func() database.Column) *MockAdministratorRoleRepositoryPermissionColumnCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdministratorRoleRepositoryPermissionColumnCall) DoAndReturn(f func() database.Column) *MockAdministratorRoleRepositoryPermissionColumnCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PermissionCondition mocks base method. +func (m *MockAdministratorRoleRepository) PermissionCondition(op database.TextOperation, permission string) database.Condition { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermissionCondition", op, permission) + ret0, _ := ret[0].(database.Condition) + return ret0 +} + +// PermissionCondition indicates an expected call of PermissionCondition. +func (mr *MockAdministratorRoleRepositoryMockRecorder) PermissionCondition(op, permission any) *MockAdministratorRoleRepositoryPermissionConditionCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermissionCondition", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).PermissionCondition), op, permission) + return &MockAdministratorRoleRepositoryPermissionConditionCall{Call: call} +} + +// MockAdministratorRoleRepositoryPermissionConditionCall wrap *gomock.Call +type MockAdministratorRoleRepositoryPermissionConditionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdministratorRoleRepositoryPermissionConditionCall) Return(arg0 database.Condition) *MockAdministratorRoleRepositoryPermissionConditionCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdministratorRoleRepositoryPermissionConditionCall) Do(f func(database.TextOperation, string) database.Condition) *MockAdministratorRoleRepositoryPermissionConditionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdministratorRoleRepositoryPermissionConditionCall) DoAndReturn(f func(database.TextOperation, string) database.Condition) *MockAdministratorRoleRepositoryPermissionConditionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PrimaryKeyColumns mocks base method. +func (m *MockAdministratorRoleRepository) PrimaryKeyColumns() []database.Column { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PrimaryKeyColumns") + ret0, _ := ret[0].([]database.Column) + return ret0 +} + +// PrimaryKeyColumns indicates an expected call of PrimaryKeyColumns. +func (mr *MockAdministratorRoleRepositoryMockRecorder) PrimaryKeyColumns() *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrimaryKeyColumns", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).PrimaryKeyColumns)) + return &MockAdministratorRoleRepositoryPrimaryKeyColumnsCall{Call: call} +} + +// MockAdministratorRoleRepositoryPrimaryKeyColumnsCall wrap *gomock.Call +type MockAdministratorRoleRepositoryPrimaryKeyColumnsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall) Return(arg0 []database.Column) *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall) Do(f func() []database.Column) *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall) DoAndReturn(f func() []database.Column) *MockAdministratorRoleRepositoryPrimaryKeyColumnsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PrimaryKeyCondition mocks base method. +func (m *MockAdministratorRoleRepository) PrimaryKeyCondition(instanceID, roleName, permission string) database.Condition { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PrimaryKeyCondition", instanceID, roleName, permission) + ret0, _ := ret[0].(database.Condition) + return ret0 +} + +// PrimaryKeyCondition indicates an expected call of PrimaryKeyCondition. +func (mr *MockAdministratorRoleRepositoryMockRecorder) PrimaryKeyCondition(instanceID, roleName, permission any) *MockAdministratorRoleRepositoryPrimaryKeyConditionCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrimaryKeyCondition", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).PrimaryKeyCondition), instanceID, roleName, permission) + return &MockAdministratorRoleRepositoryPrimaryKeyConditionCall{Call: call} +} + +// MockAdministratorRoleRepositoryPrimaryKeyConditionCall wrap *gomock.Call +type MockAdministratorRoleRepositoryPrimaryKeyConditionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdministratorRoleRepositoryPrimaryKeyConditionCall) Return(arg0 database.Condition) *MockAdministratorRoleRepositoryPrimaryKeyConditionCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdministratorRoleRepositoryPrimaryKeyConditionCall) Do(f func(string, string, string) database.Condition) *MockAdministratorRoleRepositoryPrimaryKeyConditionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdministratorRoleRepositoryPrimaryKeyConditionCall) DoAndReturn(f func(string, string, string) database.Condition) *MockAdministratorRoleRepositoryPrimaryKeyConditionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// RemovePermissions mocks base method. +func (m *MockAdministratorRoleRepository) RemovePermissions(ctx context.Context, client database.QueryExecutor, instanceID, role string, permissionsToRemove ...string) (int64, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, client, instanceID, role} + for _, a := range permissionsToRemove { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "RemovePermissions", varargs...) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemovePermissions indicates an expected call of RemovePermissions. +func (mr *MockAdministratorRoleRepositoryMockRecorder) RemovePermissions(ctx, client, instanceID, role any, permissionsToRemove ...any) *MockAdministratorRoleRepositoryRemovePermissionsCall { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, client, instanceID, role}, permissionsToRemove...) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePermissions", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).RemovePermissions), varargs...) + return &MockAdministratorRoleRepositoryRemovePermissionsCall{Call: call} +} + +// MockAdministratorRoleRepositoryRemovePermissionsCall wrap *gomock.Call +type MockAdministratorRoleRepositoryRemovePermissionsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdministratorRoleRepositoryRemovePermissionsCall) Return(arg0 int64, arg1 error) *MockAdministratorRoleRepositoryRemovePermissionsCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdministratorRoleRepositoryRemovePermissionsCall) Do(f func(context.Context, database.QueryExecutor, string, string, ...string) (int64, error)) *MockAdministratorRoleRepositoryRemovePermissionsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdministratorRoleRepositoryRemovePermissionsCall) DoAndReturn(f func(context.Context, database.QueryExecutor, string, string, ...string) (int64, error)) *MockAdministratorRoleRepositoryRemovePermissionsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// RoleNameColumn mocks base method. +func (m *MockAdministratorRoleRepository) RoleNameColumn() database.Column { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RoleNameColumn") + ret0, _ := ret[0].(database.Column) + return ret0 +} + +// RoleNameColumn indicates an expected call of RoleNameColumn. +func (mr *MockAdministratorRoleRepositoryMockRecorder) RoleNameColumn() *MockAdministratorRoleRepositoryRoleNameColumnCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RoleNameColumn", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).RoleNameColumn)) + return &MockAdministratorRoleRepositoryRoleNameColumnCall{Call: call} +} + +// MockAdministratorRoleRepositoryRoleNameColumnCall wrap *gomock.Call +type MockAdministratorRoleRepositoryRoleNameColumnCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdministratorRoleRepositoryRoleNameColumnCall) Return(arg0 database.Column) *MockAdministratorRoleRepositoryRoleNameColumnCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdministratorRoleRepositoryRoleNameColumnCall) Do(f func() database.Column) *MockAdministratorRoleRepositoryRoleNameColumnCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdministratorRoleRepositoryRoleNameColumnCall) DoAndReturn(f func() database.Column) *MockAdministratorRoleRepositoryRoleNameColumnCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// RoleNameCondition mocks base method. +func (m *MockAdministratorRoleRepository) RoleNameCondition(op database.TextOperation, name string) database.Condition { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RoleNameCondition", op, name) + ret0, _ := ret[0].(database.Condition) + return ret0 +} + +// RoleNameCondition indicates an expected call of RoleNameCondition. +func (mr *MockAdministratorRoleRepositoryMockRecorder) RoleNameCondition(op, name any) *MockAdministratorRoleRepositoryRoleNameConditionCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RoleNameCondition", reflect.TypeOf((*MockAdministratorRoleRepository)(nil).RoleNameCondition), op, name) + return &MockAdministratorRoleRepositoryRoleNameConditionCall{Call: call} +} + +// MockAdministratorRoleRepositoryRoleNameConditionCall wrap *gomock.Call +type MockAdministratorRoleRepositoryRoleNameConditionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdministratorRoleRepositoryRoleNameConditionCall) Return(arg0 database.Condition) *MockAdministratorRoleRepositoryRoleNameConditionCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdministratorRoleRepositoryRoleNameConditionCall) Do(f func(database.TextOperation, string) database.Condition) *MockAdministratorRoleRepositoryRoleNameConditionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdministratorRoleRepositoryRoleNameConditionCall) DoAndReturn(f func(database.TextOperation, string) database.Condition) *MockAdministratorRoleRepositoryRoleNameConditionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table.go b/backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table.go new file mode 100644 index 000000000..ed8612bea --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table.go @@ -0,0 +1,16 @@ +package migration + +import ( + _ "embed" +) + +var ( + //go:embed 018_administrator_role_permissions_table/up.sql + up018AdministratorRolePermissionsTable string + //go:embed 018_administrator_role_permissions_table/down.sql + down018AdministratorRolePermissionsTable string +) + +func init() { + registerSQLMigration(18, up018AdministratorRolePermissionsTable, down018AdministratorRolePermissionsTable) +} diff --git a/backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table/down.sql b/backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table/down.sql new file mode 100644 index 000000000..25ad37b0f --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS zitadel.administrator_role_permissions; diff --git a/backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table/up.sql b/backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table/up.sql new file mode 100644 index 000000000..59aa5f891 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/018_administrator_role_permissions_table/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE zitadel.administrator_role_permissions( + instance_id TEXT NOT NULL + , role_name TEXT NOT NULL CHECK (role_name <> '') + , permission TEXT NOT NULL CHECK (permission <> '') + + , PRIMARY KEY (instance_id, permission, role_name) + , FOREIGN KEY (instance_id) REFERENCES zitadel.instances(id) ON DELETE CASCADE +); diff --git a/backend/v3/storage/database/repository/administrator_role.go b/backend/v3/storage/database/repository/administrator_role.go new file mode 100644 index 000000000..43a053c41 --- /dev/null +++ b/backend/v3/storage/database/repository/administrator_role.go @@ -0,0 +1,97 @@ +package repository + +import ( + "context" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +var _ domain.AdministratorRoleRepository = (*administratorRole)(nil) + +type administratorRole struct{} + +func AdministratorRoleRepository() domain.AdministratorRoleRepository { + return new(administratorRole) +} + +func (administratorRole) unqualifiedTableName() string { + return "administrator_role_permissions" +} + +func (a administratorRole) qualifiedTableName() string { + return "zitadel." + a.unqualifiedTableName() +} + +// AddPermission implements [domain.AdministratorRoleRepository]. +func (a administratorRole) AddPermissions(ctx context.Context, client database.QueryExecutor, instanceID, role string, permissions ...string) (int64, error) { + if len(permissions) == 0 { + return 0, database.ErrNoChanges + } + builder := database.NewStatementBuilder( + "INSERT INTO zitadel.administrator_role_permissions (instance_id, role_name, permission)"+ + " SELECT $1::text, $2::text, unnest($3::text[])"+ + " ON CONFLICT (instance_id, permission, role_name) DO NOTHING", instanceID, role, permissions, + ) + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +// RemovePermissions implements [domain.AdministratorRoleRepository]. +func (a administratorRole) RemovePermissions(ctx context.Context, client database.QueryExecutor, instanceID, role string, permissionsToRemove ...string) (int64, error) { + if len(permissionsToRemove) == 0 { + return 0, database.ErrNoChanges + } + builder := database.NewStatementBuilder( + "DELETE FROM zitadel.administrator_role_permissions"+ + " WHERE instance_id = $1 AND role_name = $2 AND permission = ANY($3::text[])", instanceID, role, permissionsToRemove, + ) + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +// PrimaryKeyCondition implements [domain.AdministratorRoleRepository]. +func (a administratorRole) PrimaryKeyCondition(instanceID, roleName, permission string) database.Condition { + return database.And( + a.InstanceIDCondition(instanceID), + a.RoleNameCondition(database.TextOperationEqual, roleName), + a.PermissionCondition(database.TextOperationEqual, permission), + ) +} + +// InstanceIDCondition implements [domain.AdministratorRoleRepository]. +func (a administratorRole) InstanceIDCondition(instanceID string) database.Condition { + return database.NewTextCondition(a.InstanceIDColumn(), database.TextOperationEqual, instanceID) +} + +// RoleNameCondition implements [domain.AdministratorRoleRepository]. +func (a administratorRole) RoleNameCondition(op database.TextOperation, name string) database.Condition { + return database.NewTextCondition(a.RoleNameColumn(), op, name) +} + +// PermissionCondition implements [domain.AdministratorRoleRepository]. +func (a administratorRole) PermissionCondition(op database.TextOperation, permission string) database.Condition { + return database.NewTextCondition(a.PermissionColumn(), op, permission) +} + +// PrimaryKeyColumns implements [domain.AdministratorRoleRepository]. +func (a administratorRole) PrimaryKeyColumns() []database.Column { + return []database.Column{ + a.InstanceIDColumn(), + a.PermissionColumn(), + a.RoleNameColumn(), + } +} + +// RoleNameColumn implements [domain.AdministratorRoleRepository]. +func (a administratorRole) RoleNameColumn() database.Column { + return database.NewColumn(a.unqualifiedTableName(), "role_name") +} + +// PermissionColumn implements [domain.AdministratorRoleRepository]. +func (a administratorRole) PermissionColumn() database.Column { + return database.NewColumn(a.unqualifiedTableName(), "permission") +} + +// InstanceIDColumn implements [domain.AdministratorRoleRepository]. +func (a administratorRole) InstanceIDColumn() database.Column { + return database.NewColumn(a.unqualifiedTableName(), "instance_id") +} diff --git a/backend/v3/storage/database/repository/administrator_role_test.go b/backend/v3/storage/database/repository/administrator_role_test.go new file mode 100644 index 000000000..48ae369fd --- /dev/null +++ b/backend/v3/storage/database/repository/administrator_role_test.go @@ -0,0 +1,200 @@ +package repository_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" +) + +type administratorRolePermissionRow struct { + InstanceID string `db:"instance_id"` + RoleName string `db:"role_name"` + Permission string `db:"permission"` +} + +func TestAdministratorRoleRepository_AddPermissions(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + repo := repository.AdministratorRoleRepository() + + tests := []struct { + name string + role string + permissions []string + wantRows []administratorRolePermissionRow + wantErr error + wantCount int64 + }{ + { + name: "add deduplicated permissions", + role: "ORG_OWNER", + permissions: []string{"org.read", "org.write", "org.read"}, + wantCount: 2, + wantRows: []administratorRolePermissionRow{ + {InstanceID: instanceID, RoleName: "ORG_OWNER", Permission: "org.read"}, + {InstanceID: instanceID, RoleName: "ORG_OWNER", Permission: "org.write"}, + }, + }, + { + name: "no permissions", + role: "ORG_OWNER", + wantErr: database.ErrNoChanges, + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollbackSavepoint := savepointForRollback(t, tx) + defer rollbackSavepoint() + + count, err := repo.AddPermissions(t.Context(), savepoint, instanceID, tt.role, tt.permissions...) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantCount, count) + + if tt.wantErr == nil { + assert.Equal(t, + tt.wantRows, + listAdministratorRolePermissions(t, savepoint, repo.RoleNameCondition(database.TextOperationEqual, tt.role)), + ) + } + }) + } +} + +func TestAdministratorRoleRepository_RemovePermissions(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + + repo := repository.AdministratorRoleRepository() + count, err := repo.AddPermissions(t.Context(), tx, instanceID, "INSTANCE_OWNER", "instance.read", "instance.write") + require.NoError(t, err) + assert.Equal(t, int64(2), count) + + tests := []struct { + name string + role string + permissions []string + wantRows []administratorRolePermissionRow + wantErr error + wantCount int64 + }{ + { + name: "no permissions", + role: "INSTANCE_OWNER", + wantErr: database.ErrNoChanges, + }, + { + name: "remove permissions", + role: "INSTANCE_OWNER", + permissions: []string{"instance.read"}, + wantCount: 1, + wantRows: []administratorRolePermissionRow{ + {InstanceID: instanceID, RoleName: "INSTANCE_OWNER", Permission: "instance.write"}, + }, + }, + { + name: "remove exact pair and missing values", + role: "INSTANCE_OWNER", + permissions: []string{"instance.write", "missing"}, + wantCount: 1, + wantRows: []administratorRolePermissionRow{{InstanceID: instanceID, RoleName: "INSTANCE_OWNER", Permission: "instance.read"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollbackSavepoint := savepointForRollback(t, tx) + defer rollbackSavepoint() + + count, err = repo.RemovePermissions(t.Context(), savepoint, instanceID, tt.role, tt.permissions...) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantCount, count) + if tt.wantErr == nil { + assert.Equal(t, + tt.wantRows, + listAdministratorRolePermissions(t, savepoint, repo.RoleNameCondition(database.TextOperationEqual, "INSTANCE_OWNER")), + ) + } + }) + } +} + +func TestAdministratorRoleRepository_AddAndRemoveAcrossCalls(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + + repo := repository.AdministratorRoleRepository() + + count, err := repo.AddPermissions(t.Context(), tx, instanceID, "INSTANCE_OWNER", "instance.read", "instance.write") + require.NoError(t, err) + assert.Equal(t, int64(2), count) + + count, err = repo.AddPermissions(t.Context(), tx, instanceID, "INSTANCE_OWNER", "instance.manage", "instance.audit", "instance.write") + require.NoError(t, err) + assert.Equal(t, int64(2), count) + + count, err = repo.RemovePermissions(t.Context(), tx, instanceID, "INSTANCE_OWNER", "instance.read") + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + assert.Equal(t, + []administratorRolePermissionRow{ + {InstanceID: instanceID, RoleName: "INSTANCE_OWNER", Permission: "instance.audit"}, + {InstanceID: instanceID, RoleName: "INSTANCE_OWNER", Permission: "instance.manage"}, + {InstanceID: instanceID, RoleName: "INSTANCE_OWNER", Permission: "instance.write"}, + }, + listAdministratorRolePermissions(t, tx, repo.RoleNameCondition(database.TextOperationEqual, "INSTANCE_OWNER")), + ) +} + +func TestAdministratorRoleRepository_PrimaryKeyCondition(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + + instanceID := createInstance(t, tx) + + repo := repository.AdministratorRoleRepository() + count, err := repo.AddPermissions(t.Context(), tx, instanceID, "PROJECT_OWNER", "project.read", "project.write") + require.NoError(t, err) + assert.Equal(t, int64(2), count) + + rows := listAdministratorRolePermissions(t, tx, repo.PrimaryKeyCondition(instanceID, "PROJECT_OWNER", "project.read")) + assert.Equal(t, + []administratorRolePermissionRow{{InstanceID: instanceID, RoleName: "PROJECT_OWNER", Permission: "project.read"}}, + rows, + ) +} + +func listAdministratorRolePermissions(t *testing.T, tx database.QueryExecutor, condition database.Condition) []administratorRolePermissionRow { + t.Helper() + + builder := database.NewStatementBuilder(`SELECT instance_id, role_name, permission FROM zitadel.administrator_role_permissions`) + if condition != nil { + builder.WriteString(" WHERE ") + condition.Write(builder) + } + builder.WriteString(" ORDER BY instance_id, role_name, permission") + + rows, err := tx.Query(t.Context(), builder.String(), builder.Args()...) + require.NoError(t, err) + + var result []*administratorRolePermissionRow + require.NoError(t, rows.(database.CollectableRows).Collect(&result)) + + out := make([]administratorRolePermissionRow, len(result)) + for i, row := range result { + out[i] = *row + } + return out +} diff --git a/internal/api/grpc/instance/v2/integration_test/domain_test.go b/internal/api/grpc/instance/v2/integration_test/domain_test.go index 93868e6a7..110ef5123 100644 --- a/internal/api/grpc/instance/v2/integration_test/domain_test.go +++ b/internal/api/grpc/instance/v2/integration_test/domain_test.go @@ -43,6 +43,9 @@ func TestAddCustomDomain(t *testing.T) { relationalInst.Client.InstanceV2.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: relationalInst.ID()}) }) + // ugly fix to wait until instances are projected in all read models. + time.Sleep(10 * time.Second) + type instanceAndCtx struct { testType string instance *integration.Instance diff --git a/internal/query/projection/administrator_role_permission_relational.go b/internal/query/projection/administrator_role_permission_relational.go new file mode 100644 index 000000000..3399c0759 --- /dev/null +++ b/internal/query/projection/administrator_role_permission_relational.go @@ -0,0 +1,45 @@ +package projection + +import ( + "context" + "database/sql" + + v3_sql "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/sql" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/permission" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func (p *relationalTablesProjection) reduceAdministratorRolePermissionAdded(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*permission.AddedEvent](event) + if err != nil { + return nil, err + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-v3ARP01", "reduce.wrong.db.pool %T", ex) + } + repo := repository.AdministratorRoleRepository() + _, err := repo.AddPermissions(ctx, v3_sql.SQLTx(tx), e.Aggregate().InstanceID, e.Role, e.Permission) + return err + }), nil +} + +func (p *relationalTablesProjection) reduceAdministratorRolePermissionRemoved(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*permission.RemovedEvent](event) + if err != nil { + return nil, err + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-v3ARP02", "reduce.wrong.db.pool %T", ex) + } + repo := repository.AdministratorRoleRepository() + _, err := repo.RemovePermissions(ctx, v3_sql.SQLTx(tx), e.Aggregate().InstanceID, e.Role, e.Permission) + return err + }), nil +} diff --git a/internal/query/projection/administrator_role_permission_relational_test.go b/internal/query/projection/administrator_role_permission_relational_test.go new file mode 100644 index 000000000..563eaac97 --- /dev/null +++ b/internal/query/projection/administrator_role_permission_relational_test.go @@ -0,0 +1,94 @@ +package projection + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + repoDomain "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + "github.com/zitadel/zitadel/internal/repository/permission" +) + +type administratorRolePermission struct { + RoleName string `db:"role_name"` + Permission string `db:"permission"` +} + +func TestAdministratorRolePermissionReducers(t *testing.T) { + handler := &relationalTablesProjection{} + rawTx, tx := getTransactions(t) + t.Cleanup(func() { + require.NoError(t, rawTx.Rollback()) + }) + + instanceID := seedAdministratorRolePermissions(t, tx) + + repo := repository.AdministratorRoleRepository() + + t.Run("added event inserts a permission row", func(t *testing.T) { + event := permission.NewAddedEvent(t.Context(), permission.NewAggregate(instanceID), "ORG_OWNER", "org.read") + require.True(t, callReduce(t, rawTx, handler, event)) + assert.Equal(t, + []administratorRolePermission{{RoleName: "ORG_OWNER", Permission: "org.read"}}, + listReducedAdministratorRolePermissions(t, tx), + ) + }) + + t.Run("removed event deletes only the matching permission row", func(t *testing.T) { + _, err := repo.AddPermissions(t.Context(), tx, instanceID, "INSTANCE_OWNER", "instance.read", "instance.write") + require.NoError(t, err) + + event := permission.NewRemovedEvent(t.Context(), permission.NewAggregate(instanceID), "INSTANCE_OWNER", "instance.read") + require.True(t, callReduce(t, rawTx, handler, event)) + assert.Equal(t, + []administratorRolePermission{{RoleName: "INSTANCE_OWNER", Permission: "instance.write"}}, + listReducedAdministratorRolePermissions(t, tx, repo.RoleNameCondition(database.TextOperationEqual, "INSTANCE_OWNER")), + ) + }) +} + +func listReducedAdministratorRolePermissions(t *testing.T, tx database.QueryExecutor, conditions ...database.Condition) []administratorRolePermission { + t.Helper() + + builder := database.NewStatementBuilder(`SELECT role_name, permission FROM zitadel.administrator_role_permissions`) + if len(conditions) > 0 { + builder.WriteString(" WHERE ") + database.And(conditions...).Write(builder) + } + builder.WriteString(" ORDER BY role_name, permission") + + rows, err := tx.Query(t.Context(), builder.String(), builder.Args()...) + require.NoError(t, err) + + var result []*administratorRolePermission + require.NoError(t, rows.(database.CollectableRows).Collect(&result)) + + out := make([]administratorRolePermission, len(result)) + for i, row := range result { + out[i] = *row + } + return out +} + +func seedAdministratorRolePermissions(t *testing.T, tx database.QueryExecutor) string { + t.Helper() + + instanceID := fmt.Sprintf("instance-%d", time.Now().UnixNano()) + err := repository.InstanceRepository().Create(t.Context(), tx, &repoDomain.Instance{ + ID: instanceID, + Name: "instance", + DefaultOrgID: "default-org", + IAMProjectID: "iam-project", + ConsoleClientID: "console-client", + ConsoleAppID: "console-app", + DefaultLanguage: "en", + }) + require.NoError(t, err) + + return instanceID +} diff --git a/internal/query/projection/relational_table.go b/internal/query/projection/relational_table.go index 5d4f03748..d71a446e7 100644 --- a/internal/query/projection/relational_table.go +++ b/internal/query/projection/relational_table.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" settings "github.com/zitadel/zitadel/internal/repository/organization_settings" + "github.com/zitadel/zitadel/internal/repository/permission" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" @@ -26,6 +27,19 @@ func (*relationalTablesProjection) Name() string { func (p *relationalTablesProjection) Reducers() []handler.AggregateReducer { return []handler.AggregateReducer{ + { + Aggregate: permission.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: permission.AddedType, + Reduce: p.reduceAdministratorRolePermissionAdded, + }, + { + Event: permission.RemovedType, + Reduce: p.reduceAdministratorRolePermissionRemoved, + }, + }, + }, { Aggregate: instance.AggregateType, EventReducers: []handler.EventReducer{