feat: add administrator repository (#11803)

Introduces the `administrators` relational table and repository layer
for the v3 backend, unifying instance, organization, project, and
project-grant membership into a single scoped model.

Closes #10220

## Changes

- **Domain**: `Administrator` struct, `AdministratorScope` enum,
`AdministratorRepository` interface with typed CRUD, column, condition,
and change helpers. Generated enumer and GoMock.
- **Migration** (`016`): `zitadel.administrators` table with a generated
`id`, scope-alignment check constraint, cascading foreign keys, and a
child `administrator_roles` table.
- **Repository**: CTE-based role mutations (`AddRole`, `RemoveRole`,
`SetRoles`) for atomicity. Roles aggregated via `ARRAY_AGG`.
- **Projection**: Event reducers for
`member.added/changed/removed/cascade_removed` across all four admin
scopes.
- **Fixes**: `Rows.Close()` error wrapping; `TextArray` nil-safe scan +
full `pgtype` array interface; `authorization` and `projectGrant` scan
helpers fixed for `ARRAY_AGG` under `database/sql`; removed unused `ctx`
parameter from `callReduce`.

---------

Co-authored-by: Marco Ardizzone <[email protected]>
This commit is contained in:
Silvan
2026-03-18 10:24:09 +01:00
committed by GitHub
co-authored by Marco Ardizzone
parent 6d90a120a6
commit f138082b64
21 changed files with 3513 additions and 46 deletions
+93
View File
@@ -0,0 +1,93 @@
package domain
import (
"context"
"time"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
//go:generate enumer -type AdministratorScope -transform snake -trimprefix AdministratorScope -sql
type AdministratorScope uint8
const (
AdministratorScopeInstance AdministratorScope = iota
AdministratorScopeOrganization
AdministratorScopeProject
AdministratorScopeProjectGrant
)
type Administrator struct {
// ID is generated by the storage layer during creation and any value
// provided by the caller is ignored. The domain package must not
// generate this value. The format is storage-specific, for example
// PostgreSQL uses `<user_id>:<scope>:<scope_id>`.
ID string `json:"id,omitempty" db:"id"`
InstanceID string `json:"instanceId,omitempty" db:"instance_id"`
UserID string `json:"userId,omitempty" db:"user_id"`
Scope AdministratorScope `json:"scope,omitempty" db:"scope"`
OrganizationID *string `json:"organizationId,omitempty" db:"organization_id"`
ProjectID *string `json:"projectId,omitempty" db:"project_id"`
ProjectGrantID *string `json:"projectGrantId,omitempty" db:"project_grant_id"`
CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"`
Roles []string `json:"roles,omitempty" db:"roles"`
}
type administratorColumns interface {
PrimaryKeyColumns() []database.Column
IDColumn() database.Column
InstanceIDColumn() database.Column
UserIDColumn() database.Column
ScopeColumn() database.Column
OrganizationIDColumn() database.Column
ProjectIDColumn() database.Column
ProjectGrantIDColumn() database.Column
CreatedAtColumn() database.Column
UpdatedAtColumn() database.Column
}
type administratorConditions interface {
PrimaryKeyCondition(instanceID, administratorID string) database.Condition
IDCondition(administratorID string) database.Condition
InstanceIDCondition(instanceID string) database.Condition
UserIDCondition(userID string) database.Condition
ScopeCondition(scope AdministratorScope) database.Condition
InstanceAdministratorCondition(instanceID, userID string) database.Condition
OrganizationAdministratorCondition(instanceID, organizationID, userID string) database.Condition
ProjectAdministratorCondition(instanceID, projectID, userID string) database.Condition
ProjectGrantAdministratorCondition(instanceID, projectGrantID, userID string) database.Condition
OrganizationIDCondition(organizationID string) database.Condition
ProjectIDCondition(projectID string) database.Condition
ProjectGrantIDCondition(projectGrantID string) database.Condition
RoleCondition(op database.TextOperation, role string) database.Condition
}
type administratorChanges interface {
SetUpdatedAt(updatedAt time.Time) database.Change
// AddRole adds the specified role to the administrator.
// If the administrator already has the role, no change will be made.
AddRole(role string) database.Change
// RemoveRole removes the specified role from the administrator.
// If the administrator does not have the role, no change will be made.
RemoveRole(role string) database.Change
// SetRoles replaces all existing roles with the provided ones.
// If no roles are provided, all existing roles will be removed.
SetRoles(roles []string) database.Change
}
//go:generate mockgen -typed -package domainmock -destination ./mock/administrator.mock.go . AdministratorRepository
type AdministratorRepository interface {
Repository
administratorColumns
administratorConditions
administratorChanges
Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*Administrator, error)
List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*Administrator, error)
Create(ctx context.Context, client database.QueryExecutor, administrator *Administrator) error
Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error)
Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error)
}
@@ -0,0 +1,117 @@
// Code generated by "enumer -type AdministratorScope -transform snake -trimprefix AdministratorScope -sql"; DO NOT EDIT.
package domain
import (
"database/sql/driver"
"fmt"
"strings"
)
const _AdministratorScopeName = "instanceorganizationprojectproject_grant"
var _AdministratorScopeIndex = [...]uint8{0, 8, 20, 27, 40}
const _AdministratorScopeLowerName = "instanceorganizationprojectproject_grant"
func (i AdministratorScope) String() string {
if i >= AdministratorScope(len(_AdministratorScopeIndex)-1) {
return fmt.Sprintf("AdministratorScope(%d)", i)
}
return _AdministratorScopeName[_AdministratorScopeIndex[i]:_AdministratorScopeIndex[i+1]]
}
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
func _AdministratorScopeNoOp() {
var x [1]struct{}
_ = x[AdministratorScopeInstance-(0)]
_ = x[AdministratorScopeOrganization-(1)]
_ = x[AdministratorScopeProject-(2)]
_ = x[AdministratorScopeProjectGrant-(3)]
}
var _AdministratorScopeValues = []AdministratorScope{AdministratorScopeInstance, AdministratorScopeOrganization, AdministratorScopeProject, AdministratorScopeProjectGrant}
var _AdministratorScopeNameToValueMap = map[string]AdministratorScope{
_AdministratorScopeName[0:8]: AdministratorScopeInstance,
_AdministratorScopeLowerName[0:8]: AdministratorScopeInstance,
_AdministratorScopeName[8:20]: AdministratorScopeOrganization,
_AdministratorScopeLowerName[8:20]: AdministratorScopeOrganization,
_AdministratorScopeName[20:27]: AdministratorScopeProject,
_AdministratorScopeLowerName[20:27]: AdministratorScopeProject,
_AdministratorScopeName[27:40]: AdministratorScopeProjectGrant,
_AdministratorScopeLowerName[27:40]: AdministratorScopeProjectGrant,
}
var _AdministratorScopeNames = []string{
_AdministratorScopeName[0:8],
_AdministratorScopeName[8:20],
_AdministratorScopeName[20:27],
_AdministratorScopeName[27:40],
}
// AdministratorScopeString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func AdministratorScopeString(s string) (AdministratorScope, error) {
if val, ok := _AdministratorScopeNameToValueMap[s]; ok {
return val, nil
}
if val, ok := _AdministratorScopeNameToValueMap[strings.ToLower(s)]; ok {
return val, nil
}
return 0, fmt.Errorf("%s does not belong to AdministratorScope values", s)
}
// AdministratorScopeValues returns all values of the enum
func AdministratorScopeValues() []AdministratorScope {
return _AdministratorScopeValues
}
// AdministratorScopeStrings returns a slice of all String values of the enum
func AdministratorScopeStrings() []string {
strs := make([]string, len(_AdministratorScopeNames))
copy(strs, _AdministratorScopeNames)
return strs
}
// IsAAdministratorScope returns "true" if the value is listed in the enum definition. "false" otherwise
func (i AdministratorScope) IsAAdministratorScope() bool {
for _, v := range _AdministratorScopeValues {
if i == v {
return true
}
}
return false
}
func (i AdministratorScope) Value() (driver.Value, error) {
return i.String(), nil
}
func (i *AdministratorScope) Scan(value interface{}) error {
if value == nil {
return nil
}
var str string
switch v := value.(type) {
case []byte:
str = string(v)
case string:
str = v
case fmt.Stringer:
str = v.String()
default:
return fmt.Errorf("invalid value of AdministratorScope: %[1]T(%[1]v)", value)
}
val, err := AdministratorScopeString(str)
if err != nil {
return err
}
*i = val
return nil
}
File diff suppressed because it is too large Load Diff
+39
View File
@@ -12,6 +12,7 @@ package domainmock
import (
context "context"
reflect "reflect"
time "time"
domain "github.com/zitadel/zitadel/backend/v3/domain"
database "github.com/zitadel/zitadel/backend/v3/storage/database"
@@ -777,6 +778,44 @@ func (c *MockOrganizationRepositorySetStateCall) DoAndReturn(f func(domain.OrgSt
return c
}
// SetUpdatedAt mocks base method.
func (m *MockOrganizationRepository) SetUpdatedAt(updatedAt time.Time) database.Change {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetUpdatedAt", updatedAt)
ret0, _ := ret[0].(database.Change)
return ret0
}
// SetUpdatedAt indicates an expected call of SetUpdatedAt.
func (mr *MockOrganizationRepositoryMockRecorder) SetUpdatedAt(updatedAt any) *MockOrganizationRepositorySetUpdatedAtCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUpdatedAt", reflect.TypeOf((*MockOrganizationRepository)(nil).SetUpdatedAt), updatedAt)
return &MockOrganizationRepositorySetUpdatedAtCall{Call: call}
}
// MockOrganizationRepositorySetUpdatedAtCall wrap *gomock.Call
type MockOrganizationRepositorySetUpdatedAtCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockOrganizationRepositorySetUpdatedAtCall) Return(arg0 database.Change) *MockOrganizationRepositorySetUpdatedAtCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockOrganizationRepositorySetUpdatedAtCall) Do(f func(time.Time) database.Change) *MockOrganizationRepositorySetUpdatedAtCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockOrganizationRepositorySetUpdatedAtCall) DoAndReturn(f func(time.Time) database.Change) *MockOrganizationRepositorySetUpdatedAtCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// StateColumn mocks base method.
func (m *MockOrganizationRepository) StateColumn() database.Column {
m.ctrl.T.Helper()
@@ -0,0 +1,16 @@
package migration
import (
_ "embed"
)
var (
//go:embed 016_administrators_table/up.sql
up016AdministratorsTable string
//go:embed 016_administrators_table/down.sql
down016AdministratorsTable string
)
func init() {
registerSQLMigration(16, up016AdministratorsTable, down016AdministratorsTable)
}
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS zitadel.administrator_roles;
DROP TABLE IF EXISTS zitadel.administrators;
DROP TYPE IF EXISTS zitadel.administrator_scope;
@@ -0,0 +1,78 @@
CREATE TYPE zitadel.administrator_scope AS ENUM (
'instance',
'organization',
'project',
'project_grant'
);
CREATE TABLE zitadel.administrators(
instance_id TEXT NOT NULL
, user_id TEXT NOT NULL
, scope zitadel.administrator_scope NOT NULL
, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
, organization_id TEXT
, project_id TEXT
, project_grant_id TEXT
, id TEXT GENERATED ALWAYS AS (
user_id || ':' ||
CASE scope
WHEN 'instance'::zitadel.administrator_scope
THEN 'instance:' || instance_id
WHEN 'organization'::zitadel.administrator_scope
THEN 'organization:' || organization_id
WHEN 'project'::zitadel.administrator_scope
THEN 'project:' || project_id
WHEN 'project_grant'::zitadel.administrator_scope
THEN 'project_grant:' || project_grant_id
END
) STORED
, CONSTRAINT administrators_scope_alignment_chk CHECK (
(scope = 'instance'
AND organization_id IS NULL
AND project_id IS NULL
AND project_grant_id IS NULL)
OR
(scope = 'organization'
AND organization_id IS NOT NULL
AND project_id IS NULL
AND project_grant_id IS NULL)
OR
(scope = 'project'
AND organization_id IS NULL
AND project_id IS NOT NULL
AND project_grant_id IS NULL)
OR
(scope = 'project_grant'
AND organization_id IS NULL
AND project_id IS NULL
AND project_grant_id IS NOT NULL)
)
, PRIMARY KEY (instance_id, id)
, FOREIGN KEY (instance_id) REFERENCES zitadel.instances(id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, organization_id) REFERENCES zitadel.organizations(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, project_id) REFERENCES zitadel.projects(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, project_grant_id) REFERENCES zitadel.project_grants(instance_id, id) ON DELETE CASCADE
);
CREATE TABLE zitadel.administrator_roles(
instance_id TEXT NOT NULL
, administrator_id TEXT NOT NULL
, role_name TEXT NOT NULL CHECK (role_name <> '')
, PRIMARY KEY (instance_id, administrator_id, role_name)
, FOREIGN KEY (instance_id, administrator_id) REFERENCES zitadel.administrators(instance_id, id) ON DELETE CASCADE
);
CREATE TRIGGER trigger_set_updated_at
BEFORE UPDATE
ON zitadel.administrators
FOR EACH ROW
WHEN (NEW.updated_at IS NULL)
EXECUTE FUNCTION zitadel.set_updated_at();
@@ -74,5 +74,5 @@ func (r *Rows) CollectExactlyOneRow(dest any) (err error) {
// Close implements [database.Rows].
// Subtle: this method shadows the method (Rows).Close of Rows.Rows.
func (r *Rows) Close() error {
return r.Rows.Close()
return wrapError(r.Rows.Close())
}
@@ -0,0 +1,378 @@
package repository
import (
"context"
"slices"
"time"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
internaldb "github.com/zitadel/zitadel/internal/database"
)
var _ domain.AdministratorRepository = (*administrator)(nil)
type administrator struct{}
func AdministratorRepository() domain.AdministratorRepository {
return new(administrator)
}
func (a administrator) unqualifiedTableName() string {
return "administrators"
}
func (a administrator) qualifiedTableName() string {
return "zitadel." + a.unqualifiedTableName()
}
func (a administrator) unqualifiedRolesTableName() string {
return "administrator_roles"
}
const queryAdministratorStmt = "SELECT administrators.instance_id " +
", administrators.id " +
", administrators.user_id " +
", administrators.scope " +
", administrators.organization_id " +
", administrators.project_id " +
", administrators.project_grant_id " +
", administrators.created_at " +
", administrators.updated_at " +
", ARRAY_AGG(administrator_roles.role_name ORDER BY administrator_roles.role_name) FILTER (WHERE administrator_roles.administrator_id IS NOT NULL) AS roles " +
"FROM zitadel.administrators " +
"LEFT JOIN zitadel.administrator_roles" +
" ON administrators.instance_id = administrator_roles.instance_id" +
" AND administrators.id = administrator_roles.administrator_id"
func (a administrator) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.Administrator, error) {
builder, err := a.prepareQuery(opts)
if err != nil {
return nil, err
}
return scanAdministrator(ctx, client, builder)
}
func (a administrator) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.Administrator, error) {
builder, err := a.prepareQuery(opts)
if err != nil {
return nil, err
}
return scanAdministrators(ctx, client, builder)
}
func (a administrator) prepareQuery(opts []database.QueryOption) (*database.StatementBuilder, error) {
opts = append(opts, database.WithGroupBy(a.InstanceIDColumn(), a.IDColumn()))
options := new(database.QueryOpts)
for _, opt := range opts {
opt(options)
}
if err := checkRestrictingColumns(options.Condition, a.InstanceIDColumn()); err != nil {
return nil, err
}
builder := database.NewStatementBuilder(queryAdministratorStmt)
options.Write(builder)
return builder, nil
}
func (a administrator) Create(ctx context.Context, client database.QueryExecutor, administrator *domain.Administrator) error {
var (
createdAt any = database.DefaultInstruction
updatedAt any = database.DefaultInstruction
roles = rolesOrEmpty(administrator.Roles)
)
if !administrator.CreatedAt.IsZero() {
createdAt = administrator.CreatedAt
}
if !administrator.UpdatedAt.IsZero() {
updatedAt = administrator.UpdatedAt
}
builder := database.NewStatementBuilder("WITH inserted_admin AS (INSERT INTO zitadel.administrators (instance_id, user_id, scope, organization_id, project_id, project_grant_id, created_at, updated_at) VALUES (")
builder.WriteArgs(
administrator.InstanceID,
administrator.UserID,
administrator.Scope,
administrator.OrganizationID,
administrator.ProjectID,
administrator.ProjectGrantID,
createdAt,
updatedAt,
)
builder.WriteString(") RETURNING id, instance_id, created_at, updated_at), inserted_roles AS (INSERT INTO zitadel.administrator_roles (instance_id, administrator_id, role_name) SELECT inserted_admin.instance_id, inserted_admin.id, unnest(")
builder.WriteArg(roles)
builder.WriteString("::TEXT[]) FROM inserted_admin) SELECT id, created_at, updated_at FROM inserted_admin")
return client.QueryRow(ctx, builder.String(), builder.Args()...).Scan(
&administrator.ID,
&administrator.CreatedAt,
&administrator.UpdatedAt,
)
}
func (a administrator) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) {
if len(changes) == 0 {
return 0, database.ErrNoChanges
}
if err := checkRestrictingColumns(condition, a.InstanceIDColumn(), a.UserIDColumn(), a.ScopeColumn()); err != nil {
return 0, err
}
if !database.Changes(changes).IsOnColumn(a.UpdatedAtColumn()) {
changes = append(changes, a.clearUpdatedAt())
}
var builder database.StatementBuilder
builder.WriteString("WITH existing_administrator AS (SELECT * FROM zitadel.administrators ")
writeCondition(&builder, condition)
builder.WriteString(") ")
for i, change := range changes {
sessionCTE(change, i, 0, &builder)
}
builder.WriteString("UPDATE zitadel.administrators SET ")
if err := database.Changes(changes).Write(&builder); err != nil {
return 0, err
}
writeCondition(&builder, condition)
return client.Exec(ctx, builder.String(), builder.Args()...)
}
func (a administrator) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) {
if err := checkRestrictingColumns(condition, a.InstanceIDColumn(), a.UserIDColumn(), a.ScopeColumn()); err != nil {
return 0, err
}
builder := database.NewStatementBuilder("DELETE FROM ")
builder.WriteString(a.qualifiedTableName())
builder.WriteRune(' ')
writeCondition(builder, condition)
return client.Exec(ctx, builder.String(), builder.Args()...)
}
func (a administrator) SetUpdatedAt(updatedAt time.Time) database.Change {
return database.NewChange(a.UpdatedAtColumn(), updatedAt)
}
func (a administrator) AddRole(role string) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.administrator_roles (instance_id, administrator_id, role_name) SELECT instance_id, id, ")
builder.WriteArg(role)
builder.WriteString(" FROM existing_administrator ON CONFLICT (instance_id, administrator_id, role_name) DO NOTHING")
}, nil,
)
}
func (a administrator) RemoveRole(role string) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM zitadel.administrator_roles ar USING existing_administrator ea WHERE ar.instance_id = ea.instance_id AND ar.administrator_id = ea.id AND ar.role_name = ")
builder.WriteArg(role)
}, nil,
)
}
func (a administrator) SetRoles(roles []string) database.Change {
normalizedRoles := rolesOrEmpty(roles)
return database.NewChanges(
database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.administrator_roles (instance_id, administrator_id, role_name) SELECT instance_id, id, unnest(")
builder.WriteArg(normalizedRoles)
builder.WriteString("::text[]) FROM existing_administrator ON CONFLICT (instance_id, administrator_id, role_name) DO NOTHING")
}, nil,
),
database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM zitadel.administrator_roles ar USING existing_administrator ea WHERE ar.instance_id = ea.instance_id AND ar.administrator_id = ea.id AND NOT ar.role_name = ANY (")
builder.WriteArg(normalizedRoles)
builder.WriteString("::text[])")
}, nil,
),
)
}
func (a administrator) PrimaryKeyCondition(instanceID, administratorID string) database.Condition {
return database.And(
a.InstanceIDCondition(instanceID),
a.IDCondition(administratorID),
)
}
func (a administrator) IDCondition(administratorID string) database.Condition {
return database.NewTextCondition(a.IDColumn(), database.TextOperationEqual, administratorID)
}
func (a administrator) InstanceIDCondition(instanceID string) database.Condition {
return database.NewTextCondition(a.InstanceIDColumn(), database.TextOperationEqual, instanceID)
}
func (a administrator) UserIDCondition(userID string) database.Condition {
return database.NewTextCondition(a.UserIDColumn(), database.TextOperationEqual, userID)
}
func (a administrator) ScopeCondition(scope domain.AdministratorScope) database.Condition {
return database.NewTextCondition(a.ScopeColumn(), database.TextOperationEqual, scope.String())
}
func (a administrator) InstanceAdministratorCondition(instanceID, userID string) database.Condition {
return database.And(
a.InstanceIDCondition(instanceID),
a.ScopeCondition(domain.AdministratorScopeInstance),
a.UserIDCondition(userID),
)
}
func (a administrator) OrganizationAdministratorCondition(instanceID, organizationID, userID string) database.Condition {
return database.And(
a.InstanceIDCondition(instanceID),
a.ScopeCondition(domain.AdministratorScopeOrganization),
a.OrganizationIDCondition(organizationID),
a.UserIDCondition(userID),
)
}
func (a administrator) ProjectAdministratorCondition(instanceID, projectID, userID string) database.Condition {
return database.And(
a.InstanceIDCondition(instanceID),
a.ScopeCondition(domain.AdministratorScopeProject),
a.ProjectIDCondition(projectID),
a.UserIDCondition(userID),
)
}
func (a administrator) ProjectGrantAdministratorCondition(instanceID, projectGrantID, userID string) database.Condition {
return database.And(
a.InstanceIDCondition(instanceID),
a.ScopeCondition(domain.AdministratorScopeProjectGrant),
a.ProjectGrantIDCondition(projectGrantID),
a.UserIDCondition(userID),
)
}
func (a administrator) OrganizationIDCondition(organizationID string) database.Condition {
return database.NewTextCondition(a.OrganizationIDColumn(), database.TextOperationEqual, organizationID)
}
func (a administrator) ProjectIDCondition(projectID string) database.Condition {
return database.NewTextCondition(a.ProjectIDColumn(), database.TextOperationEqual, projectID)
}
func (a administrator) ProjectGrantIDCondition(projectGrantID string) database.Condition {
return database.NewTextCondition(a.ProjectGrantIDColumn(), database.TextOperationEqual, projectGrantID)
}
func (a administrator) RoleCondition(op database.TextOperation, role string) database.Condition {
return database.NewTextCondition(database.NewColumn(a.unqualifiedRolesTableName(), "role_name"), op, role)
}
func (a administrator) PrimaryKeyColumns() []database.Column {
return []database.Column{
a.InstanceIDColumn(),
a.IDColumn(),
}
}
func (a administrator) IDColumn() database.Column {
return database.NewColumn(a.unqualifiedTableName(), "id")
}
func (a administrator) InstanceIDColumn() database.Column {
return database.NewColumn(a.unqualifiedTableName(), "instance_id")
}
func (a administrator) UserIDColumn() database.Column {
return database.NewColumn(a.unqualifiedTableName(), "user_id")
}
func (a administrator) ScopeColumn() database.Column {
return database.NewColumn(a.unqualifiedTableName(), "scope")
}
func (a administrator) OrganizationIDColumn() database.Column {
return database.NewColumn(a.unqualifiedTableName(), "organization_id")
}
func (a administrator) ProjectIDColumn() database.Column {
return database.NewColumn(a.unqualifiedTableName(), "project_id")
}
func (a administrator) ProjectGrantIDColumn() database.Column {
return database.NewColumn(a.unqualifiedTableName(), "project_grant_id")
}
func (a administrator) CreatedAtColumn() database.Column {
return database.NewColumn(a.unqualifiedTableName(), "created_at")
}
func (a administrator) UpdatedAtColumn() database.Column {
return database.NewColumn(a.unqualifiedTableName(), "updated_at")
}
func (a administrator) clearUpdatedAt() database.Change {
return database.NewChange(a.UpdatedAtColumn(), database.NullInstruction)
}
// rawAdministrator is used for query collection because ARRAY_AGG roles cannot be
// scanned directly into the domain model's []string field when the client is backed
// by [database/sql]. The intermediate TextArray field works with both [database/sql] and pgx.
type rawAdministrator struct {
domain.Administrator
Roles internaldb.TextArray[string] `db:"roles"`
}
func (a *rawAdministrator) toDomain() *domain.Administrator {
a.Administrator.Roles = []string(a.Roles)
return &a.Administrator
}
func scanAdministrator(ctx context.Context, client database.QueryExecutor, builder *database.StatementBuilder) (*domain.Administrator, error) {
rows, err := client.Query(ctx, builder.String(), builder.Args()...)
if err != nil {
return nil, err
}
var administrator rawAdministrator
if err = rows.(database.CollectableRows).CollectExactlyOneRow(&administrator); err != nil {
return nil, err
}
return administrator.toDomain(), nil
}
func scanAdministrators(ctx context.Context, client database.QueryExecutor, builder *database.StatementBuilder) ([]*domain.Administrator, error) {
rows, err := client.Query(ctx, builder.String(), builder.Args()...)
if err != nil {
return nil, err
}
var administrators []rawAdministrator
if err = rows.(database.CollectableRows).Collect(&administrators); err != nil {
return nil, err
}
result := make([]*domain.Administrator, len(administrators))
for i := range administrators {
result[i] = administrators[i].toDomain()
}
return result, nil
}
func uniqueStrings(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if slices.Contains(result, value) {
continue
}
result = append(result, value)
}
return result
}
func rolesOrEmpty(roles []string) []string {
if roles == nil {
return []string{}
}
return uniqueStrings(roles)
}
@@ -0,0 +1,665 @@
package repository_test
import (
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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/integration"
)
func TestAdministratorRepository_Create(t *testing.T) {
tx, rollback := transactionForRollback(t)
defer rollback()
instanceID := createInstance(t, tx)
orgID := createOrganization(t, tx, instanceID)
projectID := createProject(t, tx, instanceID, orgID)
grantedOrgID := createOrganization(t, tx, instanceID)
projectGrantID := createProjectGrant(t, tx, instanceID, orgID, grantedOrgID, projectID, nil)
userID := createHumanUser(t, tx, instanceID, orgID)
adminRepo := repository.AdministratorRepository()
// Pre-create an admin for duplicate detection.
duplicateUser := createHumanUser(t, tx, instanceID, orgID)
err := adminRepo.Create(t.Context(), tx, &domain.Administrator{
InstanceID: instanceID,
UserID: duplicateUser,
Scope: domain.AdministratorScopeOrganization,
OrganizationID: gu.Ptr(orgID),
Roles: []string{"ORG_OWNER"},
})
require.NoError(t, err)
tests := []struct {
name string
administrator *domain.Administrator
wantErr error
}{
{
name: "instance scope",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: userID,
Scope: domain.AdministratorScopeInstance,
Roles: []string{"IAM_OWNER"},
},
},
{
name: "organization scope",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: createHumanUser(t, tx, instanceID, orgID),
Scope: domain.AdministratorScopeOrganization,
OrganizationID: gu.Ptr(orgID),
Roles: []string{"ORG_OWNER"},
},
},
{
name: "project scope",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: userID,
Scope: domain.AdministratorScopeProject,
ProjectID: gu.Ptr(projectID),
Roles: []string{"PROJECT_OWNER"},
},
},
{
name: "project grant scope",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: createHumanUser(t, tx, instanceID, orgID),
Scope: domain.AdministratorScopeProjectGrant,
ProjectGrantID: gu.Ptr(projectGrantID),
Roles: []string{"PROJECT_OWNER"},
},
},
{
name: "duplicate admin",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: duplicateUser,
Scope: domain.AdministratorScopeOrganization,
OrganizationID: gu.Ptr(orgID),
Roles: []string{"ORG_OWNER"},
},
wantErr: new(database.UniqueError),
},
{
name: "missing user",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: integration.ID(),
Scope: domain.AdministratorScopeOrganization,
OrganizationID: gu.Ptr(orgID),
Roles: []string{"ORG_OWNER"},
},
wantErr: new(database.ForeignKeyError),
},
{
name: "missing organization",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: createHumanUser(t, tx, instanceID, orgID),
Scope: domain.AdministratorScopeOrganization,
OrganizationID: gu.Ptr(integration.ID()),
Roles: []string{"ORG_OWNER"},
},
wantErr: new(database.ForeignKeyError),
},
{
name: "missing project",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: userID,
Scope: domain.AdministratorScopeProject,
ProjectID: gu.Ptr(integration.ID()),
Roles: []string{"PROJECT_OWNER"},
},
wantErr: new(database.ForeignKeyError),
},
{
name: "missing project grant",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: createHumanUser(t, tx, instanceID, orgID),
Scope: domain.AdministratorScopeProjectGrant,
ProjectGrantID: gu.Ptr(integration.ID()),
Roles: []string{"PROJECT_OWNER"},
},
wantErr: new(database.ForeignKeyError),
},
{
name: "invalid scope alignment",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: createHumanUser(t, tx, instanceID, orgID),
Scope: domain.AdministratorScopeProjectGrant,
ProjectID: gu.Ptr(projectID),
ProjectGrantID: gu.Ptr(projectGrantID),
Roles: []string{"PROJECT_OWNER"},
},
wantErr: new(database.CheckError),
},
{
name: "empty role",
administrator: &domain.Administrator{
InstanceID: instanceID,
UserID: createHumanUser(t, tx, instanceID, orgID),
Scope: domain.AdministratorScopeOrganization,
OrganizationID: gu.Ptr(orgID),
Roles: []string{""},
},
wantErr: new(database.CheckError),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
savepoint, rollback := savepointForRollback(t, tx)
defer rollback()
err := adminRepo.Create(t.Context(), savepoint, tt.administrator)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.NotEmpty(t, tt.administrator.ID)
assert.NotZero(t, tt.administrator.CreatedAt)
assert.NotZero(t, tt.administrator.UpdatedAt)
got, err := adminRepo.Get(t.Context(), savepoint, database.WithCondition(
adminRepo.PrimaryKeyCondition(instanceID, tt.administrator.ID),
))
require.NoError(t, err)
assert.Equal(t, tt.administrator.InstanceID, got.InstanceID)
assert.Equal(t, tt.administrator.UserID, got.UserID)
assert.Equal(t, tt.administrator.Scope, got.Scope)
assert.Equal(t, tt.administrator.OrganizationID, got.OrganizationID)
assert.Equal(t, tt.administrator.ProjectID, got.ProjectID)
assert.Equal(t, tt.administrator.ProjectGrantID, got.ProjectGrantID)
assert.ElementsMatch(t, tt.administrator.Roles, got.Roles)
})
}
}
func TestAdministratorRepository_Get(t *testing.T) {
tx, rollback := transactionForRollback(t)
defer rollback()
instanceID := createInstance(t, tx)
orgID := createOrganization(t, tx, instanceID)
projectID := createProject(t, tx, instanceID, orgID)
grantedOrgID := createOrganization(t, tx, instanceID)
projectGrantID := createProjectGrant(t, tx, instanceID, orgID, grantedOrgID, projectID, nil)
userID := createHumanUser(t, tx, instanceID, orgID)
adminRepo := repository.AdministratorRepository()
instanceAdmin := &domain.Administrator{
InstanceID: instanceID,
UserID: userID,
Scope: domain.AdministratorScopeInstance,
Roles: []string{"IAM_OWNER"},
}
require.NoError(t, adminRepo.Create(t.Context(), tx, instanceAdmin))
orgAdmin := &domain.Administrator{
InstanceID: instanceID,
UserID: userID,
Scope: domain.AdministratorScopeOrganization,
OrganizationID: gu.Ptr(orgID),
Roles: []string{"ORG_OWNER"},
}
require.NoError(t, adminRepo.Create(t.Context(), tx, orgAdmin))
projectAdmin := &domain.Administrator{
InstanceID: instanceID,
UserID: userID,
Scope: domain.AdministratorScopeProject,
ProjectID: gu.Ptr(projectID),
Roles: []string{"PROJECT_OWNER"},
}
require.NoError(t, adminRepo.Create(t.Context(), tx, projectAdmin))
projectGrantAdmin := &domain.Administrator{
InstanceID: instanceID,
UserID: userID,
Scope: domain.AdministratorScopeProjectGrant,
ProjectGrantID: gu.Ptr(projectGrantID),
Roles: []string{"PROJECT_OWNER"},
}
require.NoError(t, adminRepo.Create(t.Context(), tx, projectGrantAdmin))
tests := []struct {
name string
condition database.Condition
wantAdmin *domain.Administrator
wantErr error
}{
{
name: "instance scope",
condition: adminRepo.InstanceAdministratorCondition(instanceID, userID),
wantAdmin: instanceAdmin,
},
{
name: "organization scope",
condition: adminRepo.OrganizationAdministratorCondition(instanceID, orgID, userID),
wantAdmin: orgAdmin,
},
{
name: "project scope",
condition: adminRepo.ProjectAdministratorCondition(instanceID, projectID, userID),
wantAdmin: projectAdmin,
},
{
name: "project grant scope",
condition: adminRepo.ProjectGrantAdministratorCondition(instanceID, projectGrantID, userID),
wantAdmin: projectGrantAdmin,
},
{
name: "by primary key",
condition: adminRepo.PrimaryKeyCondition(instanceID, orgAdmin.ID),
wantAdmin: orgAdmin,
},
{
name: "not found",
condition: adminRepo.InstanceAdministratorCondition(instanceID, integration.ID()),
wantErr: database.NewNoRowFoundError(nil),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
savepoint, rollback := savepointForRollback(t, tx)
defer rollback()
got, err := adminRepo.Get(t.Context(), savepoint, database.WithCondition(tt.condition))
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantAdmin.ID, got.ID)
assert.Equal(t, tt.wantAdmin.Scope, got.Scope)
assert.Equal(t, tt.wantAdmin.UserID, got.UserID)
assert.Equal(t, tt.wantAdmin.OrganizationID, got.OrganizationID)
assert.Equal(t, tt.wantAdmin.ProjectID, got.ProjectID)
assert.Equal(t, tt.wantAdmin.ProjectGrantID, got.ProjectGrantID)
assert.ElementsMatch(t, tt.wantAdmin.Roles, got.Roles)
})
}
}
func TestAdministratorRepository_List(t *testing.T) {
tx, rollback := transactionForRollback(t)
defer rollback()
instanceID := createInstance(t, tx)
orgID := createOrganization(t, tx, instanceID)
otherOrgID := createOrganization(t, tx, instanceID)
projectID := createProject(t, tx, instanceID, orgID)
grantedOrgID := createOrganization(t, tx, instanceID)
projectGrantID := createProjectGrant(t, tx, instanceID, orgID, grantedOrgID, projectID, nil)
user1 := createHumanUser(t, tx, instanceID, orgID)
user2 := createHumanUser(t, tx, instanceID, orgID)
adminRepo := repository.AdministratorRepository()
// instance: user1=IAM_OWNER, user2=IAM_VIEWER
require.NoError(t, adminRepo.Create(t.Context(), tx, &domain.Administrator{
InstanceID: instanceID, UserID: user1,
Scope: domain.AdministratorScopeInstance, Roles: []string{"IAM_OWNER"},
}))
require.NoError(t, adminRepo.Create(t.Context(), tx, &domain.Administrator{
InstanceID: instanceID, UserID: user2,
Scope: domain.AdministratorScopeInstance, Roles: []string{"IAM_VIEWER"},
}))
// organization: user1 in orgID
require.NoError(t, adminRepo.Create(t.Context(), tx, &domain.Administrator{
InstanceID: instanceID, UserID: user1,
Scope: domain.AdministratorScopeOrganization, OrganizationID: gu.Ptr(orgID),
Roles: []string{"ORG_OWNER"},
}))
// project: user1
require.NoError(t, adminRepo.Create(t.Context(), tx, &domain.Administrator{
InstanceID: instanceID, UserID: user1,
Scope: domain.AdministratorScopeProject, ProjectID: gu.Ptr(projectID),
Roles: []string{"PROJECT_OWNER"},
}))
// project grant: user1
require.NoError(t, adminRepo.Create(t.Context(), tx, &domain.Administrator{
InstanceID: instanceID, UserID: user1,
Scope: domain.AdministratorScopeProjectGrant, ProjectGrantID: gu.Ptr(projectGrantID),
Roles: []string{"PROJECT_OWNER"},
}))
tests := []struct {
name string
condition database.Condition
wantLen int
}{
{
name: "all instance admins",
condition: database.And(
adminRepo.InstanceIDCondition(instanceID),
adminRepo.ScopeCondition(domain.AdministratorScopeInstance),
),
wantLen: 2,
},
{
name: "instance admins filtered by role",
condition: database.And(
adminRepo.InstanceIDCondition(instanceID),
adminRepo.ScopeCondition(domain.AdministratorScopeInstance),
adminRepo.RoleCondition(database.TextOperationEqual, "IAM_OWNER"),
),
wantLen: 1,
},
{
name: "org admins in org",
condition: database.And(
adminRepo.InstanceIDCondition(instanceID),
adminRepo.ScopeCondition(domain.AdministratorScopeOrganization),
adminRepo.OrganizationIDCondition(orgID),
),
wantLen: 1,
},
{
name: "org admins in other org returns empty",
condition: database.And(
adminRepo.InstanceIDCondition(instanceID),
adminRepo.ScopeCondition(domain.AdministratorScopeOrganization),
adminRepo.OrganizationIDCondition(otherOrgID),
),
wantLen: 0,
},
{
name: "project admins",
condition: database.And(
adminRepo.InstanceIDCondition(instanceID),
adminRepo.ScopeCondition(domain.AdministratorScopeProject),
adminRepo.ProjectIDCondition(projectID),
),
wantLen: 1,
},
{
name: "project grant admins",
condition: database.And(
adminRepo.InstanceIDCondition(instanceID),
adminRepo.ScopeCondition(domain.AdministratorScopeProjectGrant),
adminRepo.ProjectGrantIDCondition(projectGrantID),
),
wantLen: 1,
},
{
name: "scope isolation: instance condition excludes org admin",
condition: adminRepo.InstanceAdministratorCondition(instanceID, user1),
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
savepoint, rollback := savepointForRollback(t, tx)
defer rollback()
list, err := adminRepo.List(t.Context(), savepoint, database.WithCondition(tt.condition))
require.NoError(t, err)
assert.Len(t, list, tt.wantLen)
})
}
}
func TestAdministratorRepository_Update(t *testing.T) {
tx, rollback := transactionForRollback(t)
defer rollback()
instanceID := createInstance(t, tx)
orgID := createOrganization(t, tx, instanceID)
userID := createHumanUser(t, tx, instanceID, orgID)
adminRepo := repository.AdministratorRepository()
admin := &domain.Administrator{
InstanceID: instanceID,
UserID: userID,
Scope: domain.AdministratorScopeOrganization,
OrganizationID: gu.Ptr(orgID),
Roles: []string{"ORG_OWNER"},
}
require.NoError(t, adminRepo.Create(t.Context(), tx, admin))
originalCreatedAt := admin.CreatedAt
condition := adminRepo.OrganizationAdministratorCondition(instanceID, orgID, userID)
tests := []struct {
name string
changes []database.Change
verify func(t *testing.T, got *domain.Administrator)
}{
{
name: "SetUpdatedAt",
changes: []database.Change{adminRepo.SetUpdatedAt(time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC))},
verify: func(t *testing.T, got *domain.Administrator) {
assert.Equal(t, time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC), got.UpdatedAt.UTC())
assert.Equal(t, []string{"ORG_OWNER"}, got.Roles)
assert.Equal(t, originalCreatedAt, got.CreatedAt, "created_at must not change")
},
},
{
name: "SetRoles replaces all",
changes: []database.Change{adminRepo.SetRoles([]string{"AUDITOR", "VIEWER"})},
verify: func(t *testing.T, got *domain.Administrator) {
assert.ElementsMatch(t, []string{"AUDITOR", "VIEWER"}, got.Roles)
},
},
{
name: "SetRoles to single role",
changes: []database.Change{adminRepo.SetRoles([]string{"AUDITOR"})},
verify: func(t *testing.T, got *domain.Administrator) {
assert.Equal(t, []string{"AUDITOR"}, got.Roles)
},
},
{
name: "SetRoles to empty removes all",
changes: []database.Change{adminRepo.SetRoles([]string{})},
verify: func(t *testing.T, got *domain.Administrator) {
assert.Empty(t, got.Roles)
},
},
{
name: "AddRole new",
changes: []database.Change{adminRepo.AddRole("AUDITOR")},
verify: func(t *testing.T, got *domain.Administrator) {
assert.ElementsMatch(t, []string{"ORG_OWNER", "AUDITOR"}, got.Roles)
},
},
{
name: "AddRole existing is idempotent",
changes: []database.Change{adminRepo.AddRole("ORG_OWNER")},
verify: func(t *testing.T, got *domain.Administrator) {
assert.Equal(t, []string{"ORG_OWNER"}, got.Roles)
},
},
{
name: "RemoveRole existing",
changes: []database.Change{adminRepo.RemoveRole("ORG_OWNER")},
verify: func(t *testing.T, got *domain.Administrator) {
assert.Empty(t, got.Roles)
},
},
{
name: "RemoveRole absent is idempotent",
changes: []database.Change{adminRepo.RemoveRole("NONEXISTENT")},
verify: func(t *testing.T, got *domain.Administrator) {
assert.Equal(t, []string{"ORG_OWNER"}, got.Roles)
},
},
{
name: "SetUpdatedAt and SetRoles combined",
changes: []database.Change{
adminRepo.SetUpdatedAt(time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)),
adminRepo.SetRoles([]string{"AUDITOR"}),
},
verify: func(t *testing.T, got *domain.Administrator) {
assert.Equal(t, time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC), got.UpdatedAt.UTC())
assert.Equal(t, []string{"AUDITOR"}, got.Roles)
},
},
{
name: "AddRole and RemoveRole combined",
changes: []database.Change{
adminRepo.AddRole("AUDITOR"),
adminRepo.RemoveRole("ORG_OWNER"),
},
verify: func(t *testing.T, got *domain.Administrator) {
assert.Equal(t, []string{"AUDITOR"}, got.Roles)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
savepoint, rollback := savepointForRollback(t, tx)
defer rollback()
_, err := adminRepo.Update(t.Context(), savepoint, condition, tt.changes...)
require.NoError(t, err)
got, err := adminRepo.Get(t.Context(), savepoint, database.WithCondition(condition))
require.NoError(t, err)
tt.verify(t, got)
})
}
}
func TestAdministratorRepository_Delete(t *testing.T) {
tx, rollback := transactionForRollback(t)
defer rollback()
instanceID := createInstance(t, tx)
orgID := createOrganization(t, tx, instanceID)
projectID := createProject(t, tx, instanceID, orgID)
grantedOrgID := createOrganization(t, tx, instanceID)
projectGrantID := createProjectGrant(t, tx, instanceID, orgID, grantedOrgID, projectID, nil)
user1 := createHumanUser(t, tx, instanceID, orgID)
user2 := createHumanUser(t, tx, instanceID, orgID)
adminRepo := repository.AdministratorRepository()
instanceAdmin := &domain.Administrator{
InstanceID: instanceID, UserID: user1,
Scope: domain.AdministratorScopeInstance, Roles: []string{"IAM_OWNER"},
}
require.NoError(t, adminRepo.Create(t.Context(), tx, instanceAdmin))
otherInstanceAdmin := &domain.Administrator{
InstanceID: instanceID, UserID: user2,
Scope: domain.AdministratorScopeInstance, Roles: []string{"IAM_VIEWER"},
}
require.NoError(t, adminRepo.Create(t.Context(), tx, otherInstanceAdmin))
orgAdmin := &domain.Administrator{
InstanceID: instanceID, UserID: user1,
Scope: domain.AdministratorScopeOrganization, OrganizationID: gu.Ptr(orgID),
Roles: []string{"ORG_OWNER"},
}
require.NoError(t, adminRepo.Create(t.Context(), tx, orgAdmin))
projectAdmin := &domain.Administrator{
InstanceID: instanceID, UserID: user1,
Scope: domain.AdministratorScopeProject, ProjectID: gu.Ptr(projectID),
Roles: []string{"PROJECT_OWNER"},
}
require.NoError(t, adminRepo.Create(t.Context(), tx, projectAdmin))
projectGrantAdmin := &domain.Administrator{
InstanceID: instanceID, UserID: user1,
Scope: domain.AdministratorScopeProjectGrant, ProjectGrantID: gu.Ptr(projectGrantID),
Roles: []string{"PROJECT_OWNER"},
}
require.NoError(t, adminRepo.Create(t.Context(), tx, projectGrantAdmin))
tests := []struct {
name string
condition database.Condition
verifyRemaining func(t *testing.T, sp database.Transaction)
}{
{
name: "instance admin, other scopes remain",
condition: adminRepo.InstanceAdministratorCondition(instanceID, user1),
verifyRemaining: func(t *testing.T, sp database.Transaction) {
_, err := adminRepo.Get(t.Context(), sp, database.WithCondition(
adminRepo.InstanceAdministratorCondition(instanceID, user2),
))
require.NoError(t, err)
_, err = adminRepo.Get(t.Context(), sp, database.WithCondition(
adminRepo.OrganizationAdministratorCondition(instanceID, orgID, user1),
))
require.NoError(t, err)
},
},
{
name: "organization admin, other scopes remain",
condition: adminRepo.OrganizationAdministratorCondition(instanceID, orgID, user1),
verifyRemaining: func(t *testing.T, sp database.Transaction) {
_, err := adminRepo.Get(t.Context(), sp, database.WithCondition(
adminRepo.InstanceAdministratorCondition(instanceID, user1),
))
require.NoError(t, err)
_, err = adminRepo.Get(t.Context(), sp, database.WithCondition(
adminRepo.ProjectAdministratorCondition(instanceID, projectID, user1),
))
require.NoError(t, err)
},
},
{
name: "project admin, other scopes remain",
condition: adminRepo.ProjectAdministratorCondition(instanceID, projectID, user1),
verifyRemaining: func(t *testing.T, sp database.Transaction) {
_, err := adminRepo.Get(t.Context(), sp, database.WithCondition(
adminRepo.InstanceAdministratorCondition(instanceID, user1),
))
require.NoError(t, err)
_, err = adminRepo.Get(t.Context(), sp, database.WithCondition(
adminRepo.ProjectGrantAdministratorCondition(instanceID, projectGrantID, user1),
))
require.NoError(t, err)
},
},
{
name: "project grant admin, other scopes remain",
condition: adminRepo.ProjectGrantAdministratorCondition(instanceID, projectGrantID, user1),
verifyRemaining: func(t *testing.T, sp database.Transaction) {
_, err := adminRepo.Get(t.Context(), sp, database.WithCondition(
adminRepo.InstanceAdministratorCondition(instanceID, user1),
))
require.NoError(t, err)
_, err = adminRepo.Get(t.Context(), sp, database.WithCondition(
adminRepo.OrganizationAdministratorCondition(instanceID, orgID, user1),
))
require.NoError(t, err)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
savepoint, rollback := savepointForRollback(t, tx)
defer rollback()
_, err := adminRepo.Delete(t.Context(), savepoint, tt.condition)
require.NoError(t, err)
_, err = adminRepo.Get(t.Context(), savepoint, database.WithCondition(tt.condition))
require.ErrorIs(t, err, database.NewNoRowFoundError(nil))
tt.verifyRemaining(t, savepoint)
})
}
}
@@ -6,6 +6,7 @@ import (
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
internaldb "github.com/zitadel/zitadel/internal/database"
)
var _ domain.AuthorizationRepository = (*authorization)(nil)
@@ -93,7 +94,7 @@ func (a authorization) Get(ctx context.Context, client database.QueryExecutor, o
if err != nil {
return nil, err
}
return getOne[domain.Authorization](ctx, client, builder)
return scanAuthorization(ctx, client, builder)
}
// List implements [domain.AuthorizationRepository].
@@ -106,7 +107,7 @@ func (a authorization) List(ctx context.Context, client database.QueryExecutor,
if err != nil {
return nil, err
}
return getMany[domain.Authorization](ctx, client, builder)
return scanAuthorizations(ctx, client, builder)
}
func (a authorization) prepareQuery(opts []database.QueryOption) (*database.StatementBuilder, error) {
@@ -196,6 +197,50 @@ func (a authorization) Delete(ctx context.Context, client database.QueryExecutor
return client.Exec(ctx, builder.String(), builder.Args()...)
}
// rawAuthorization is used for query collection because ARRAY_AGG roles cannot be
// scanned directly into the domain model's []string field when the client is backed
// by [database/sql]. The intermediate TextArray field works with both [database/sql] and pgx.
type rawAuthorization struct {
domain.Authorization
Roles internaldb.TextArray[string] `db:"roles"`
}
func (a *rawAuthorization) toDomain() *domain.Authorization {
a.Authorization.Roles = []string(a.Roles)
return &a.Authorization
}
func scanAuthorization(ctx context.Context, client database.QueryExecutor, builder *database.StatementBuilder) (*domain.Authorization, error) {
rows, err := client.Query(ctx, builder.String(), builder.Args()...)
if err != nil {
return nil, err
}
var authorization rawAuthorization
if err = rows.(database.CollectableRows).CollectExactlyOneRow(&authorization); err != nil {
return nil, err
}
return authorization.toDomain(), nil
}
func scanAuthorizations(ctx context.Context, client database.QueryExecutor, builder *database.StatementBuilder) ([]*domain.Authorization, error) {
rows, err := client.Query(ctx, builder.String(), builder.Args()...)
if err != nil {
return nil, err
}
var authorizations []rawAuthorization
if err = rows.(database.CollectableRows).Collect(&authorizations); err != nil {
return nil, err
}
result := make([]*domain.Authorization, len(authorizations))
for i := range authorizations {
result[i] = authorizations[i].toDomain()
}
return result, nil
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
@@ -6,6 +6,7 @@ import (
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
internaldb "github.com/zitadel/zitadel/internal/database"
)
// -------------------------------------------------------------
@@ -28,7 +29,7 @@ func (p projectGrant) Get(ctx context.Context, client database.QueryExecutor, op
if err != nil {
return nil, err
}
return getOne[domain.ProjectGrant](ctx, client, builder)
return scanProjectGrant(ctx, client, builder)
}
func (p projectGrant) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.ProjectGrant, error) {
@@ -40,7 +41,7 @@ func (p projectGrant) List(ctx context.Context, client database.QueryExecutor, o
if err != nil {
return nil, err
}
return getMany[domain.ProjectGrant](ctx, client, builder)
return scanProjectGrants(ctx, client, builder)
}
const insertProjectGrantRolesStmt = `WITH added_roles AS (
@@ -307,3 +308,47 @@ func (p projectGrant) prepareQuery(opts []database.QueryOption) (*database.State
return builder, nil
}
// rawProjectGrant is used for query collection because ARRAY_AGG role keys cannot be
// scanned directly into the domain model's []string field when the client is backed
// by [database/sql]. The intermediate TextArray field works with both [database/sql] and pgx.
type rawProjectGrant struct {
domain.ProjectGrant
RoleKeys internaldb.TextArray[string] `db:"role_keys"`
}
func (p *rawProjectGrant) toDomain() *domain.ProjectGrant {
p.ProjectGrant.RoleKeys = []string(p.RoleKeys)
return &p.ProjectGrant
}
func scanProjectGrant(ctx context.Context, client database.QueryExecutor, builder *database.StatementBuilder) (*domain.ProjectGrant, error) {
rows, err := client.Query(ctx, builder.String(), builder.Args()...)
if err != nil {
return nil, err
}
var projectGrant rawProjectGrant
if err = rows.(database.CollectableRows).CollectExactlyOneRow(&projectGrant); err != nil {
return nil, err
}
return projectGrant.toDomain(), nil
}
func scanProjectGrants(ctx context.Context, client database.QueryExecutor, builder *database.StatementBuilder) ([]*domain.ProjectGrant, error) {
rows, err := client.Query(ctx, builder.String(), builder.Args()...)
if err != nil {
return nil, err
}
var projectGrants []rawProjectGrant
if err = rows.(database.CollectableRows).Collect(&projectGrants); err != nil {
return nil, err
}
result := make([]*domain.ProjectGrant, len(projectGrants))
for i := range projectGrants {
result[i] = projectGrants[i].toDomain()
}
return result, nil
}