Files
zitadel/backend/v3/storage/database/repository/administrator.go
T
f138082b64 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]>
2026-03-18 10:24:09 +01:00

379 lines
13 KiB
Go

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