mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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]>
379 lines
13 KiB
Go
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)
|
|
}
|