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
@@ -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
}