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]>
361 lines
13 KiB
Go
361 lines
13 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"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.AuthorizationRepository = (*authorization)(nil)
|
|
|
|
type authorization struct{}
|
|
|
|
func AuthorizationRepository() domain.AuthorizationRepository {
|
|
return new(authorization)
|
|
}
|
|
|
|
func (a authorization) unqualifiedTableName() string {
|
|
return "authorizations"
|
|
}
|
|
|
|
func (a authorization) qualifiedTableName() string {
|
|
return "zitadel." + a.unqualifiedTableName()
|
|
}
|
|
|
|
func (a authorization) unqualifiedAuthorizationRolesTableName() string {
|
|
return "authorization_roles"
|
|
}
|
|
|
|
func (a authorization) qualifiedAuthorizationRolesTableName() string {
|
|
return "zitadel." + a.unqualifiedAuthorizationRolesTableName()
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// repository
|
|
// -------------------------------------------------------------
|
|
|
|
// Create implements [domain.AuthorizationRepository].
|
|
func (a authorization) Create(ctx context.Context, client database.QueryExecutor, authorization *domain.Authorization) error {
|
|
var (
|
|
createdAt, updatedAt any = database.DefaultInstruction, database.DefaultInstruction
|
|
)
|
|
if !authorization.CreatedAt.IsZero() {
|
|
createdAt = authorization.CreatedAt
|
|
}
|
|
if !authorization.UpdatedAt.IsZero() {
|
|
updatedAt = authorization.UpdatedAt
|
|
}
|
|
|
|
builder := database.NewStatementBuilder("WITH roles AS (INSERT INTO zitadel.authorization_roles (instance_id, authorization_id, grant_id, project_id, role_key)"+
|
|
" VALUES ($1, $2, $3, $4, unnest($5::text[]))) INSERT INTO zitadel.authorizations (instance_id, id, grant_id, project_id, user_id, state, created_at, updated_at) VALUES ($1, $2, $3, $4, $6, $7, ",
|
|
authorization.InstanceID, authorization.ID, authorization.GrantID, authorization.ProjectID, authorization.Roles, authorization.UserID, authorization.State,
|
|
)
|
|
builder.WriteArgs(createdAt, updatedAt)
|
|
builder.WriteString(") RETURNING created_at, updated_at")
|
|
|
|
if err := client.QueryRow(
|
|
ctx,
|
|
builder.String(),
|
|
builder.Args()...).
|
|
Scan(
|
|
&authorization.CreatedAt,
|
|
&authorization.UpdatedAt,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const queryAuthorizationStmt = `SELECT zitadel.authorizations.instance_id,
|
|
zitadel.authorizations.id,
|
|
zitadel.authorizations.user_id,
|
|
zitadel.authorizations.grant_id,
|
|
zitadel.authorizations.project_id,
|
|
zitadel.authorizations.state,
|
|
zitadel.authorizations.created_at,
|
|
zitadel.authorizations.updated_at,
|
|
ARRAY_AGG(authorization_roles.role_key)
|
|
FILTER (WHERE authorization_roles.authorization_id IS NOT NULL) AS roles
|
|
FROM zitadel.authorizations
|
|
LEFT JOIN zitadel.authorization_roles
|
|
ON zitadel.authorizations.instance_id = zitadel.authorization_roles.instance_id
|
|
AND zitadel.authorizations.id = zitadel.authorization_roles.authorization_id`
|
|
|
|
// Get implements [domain.AuthorizationRepository].
|
|
func (a authorization) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.Authorization, error) {
|
|
opts = append(opts,
|
|
database.WithGroupBy(a.InstanceIDColumn(), a.IDColumn()),
|
|
)
|
|
|
|
builder, err := a.prepareQuery(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanAuthorization(ctx, client, builder)
|
|
}
|
|
|
|
// List implements [domain.AuthorizationRepository].
|
|
func (a authorization) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.Authorization, error) {
|
|
opts = append(opts,
|
|
database.WithGroupBy(a.InstanceIDColumn(), a.IDColumn()),
|
|
)
|
|
|
|
builder, err := a.prepareQuery(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanAuthorizations(ctx, client, builder)
|
|
}
|
|
|
|
func (a authorization) prepareQuery(opts []database.QueryOption) (*database.StatementBuilder, error) {
|
|
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(queryAuthorizationStmt)
|
|
options.Write(builder)
|
|
|
|
return builder, nil
|
|
}
|
|
|
|
const queryUpdateAuthorizationRoleStmt = `SELECT instance_id, id, project_id, grant_id, $1::text[] as roles from zitadel.authorizations`
|
|
|
|
const updateAuthorizationRoleStmt = `deleted_roles AS (
|
|
DELETE FROM zitadel.authorization_roles as azr
|
|
USING az
|
|
WHERE azr.instance_id = az.instance_id
|
|
AND azr.authorization_id = az.id
|
|
AND NOT azr.role_key = ANY ($1::text[])
|
|
RETURNING *
|
|
), inserted_roles AS (
|
|
INSERT INTO zitadel.authorization_roles (instance_id, authorization_id, project_id, grant_id, role_key)
|
|
SELECT instance_id,
|
|
id,
|
|
project_id,
|
|
grant_id,
|
|
UNNEST(az.roles) AS role_key
|
|
FROM az
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING *
|
|
)
|
|
UPDATE zitadel.authorizations SET `
|
|
|
|
const updateAuthorizationRoleStmtWhere = ` FROM az
|
|
WHERE az.instance_id = authorizations.instance_id
|
|
AND az.id = authorizations.id`
|
|
|
|
// Update implements [domain.AuthorizationRepository].
|
|
func (a authorization) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, roles []string, changes ...database.Change) (int64, error) {
|
|
if roles != nil {
|
|
return a.setRoles(ctx, client, condition, roles, changes...)
|
|
}
|
|
return updateOne(ctx, client, a, condition, changes...)
|
|
}
|
|
|
|
// setRoles sets the roles of an authorization.
|
|
func (a authorization) setRoles(ctx context.Context, client database.QueryExecutor, condition database.Condition, roles []string, changes ...database.Change) (int64, error) {
|
|
if err := checkPKCondition(a, condition); err != nil {
|
|
return 0, err
|
|
}
|
|
if !database.Changes(changes).IsOnColumn(a.UpdatedAtColumn()) {
|
|
changes = append(changes, database.NewChange(a.UpdatedAtColumn(), database.NullInstruction))
|
|
}
|
|
|
|
// get the authorization to be updated
|
|
builder := database.NewStatementBuilder("WITH az AS (")
|
|
builder.WriteString(queryUpdateAuthorizationRoleStmt)
|
|
builder.AppendArg(roles)
|
|
writeCondition(builder, condition)
|
|
builder.WriteString(" ), ")
|
|
|
|
// set the roles
|
|
builder.WriteString(updateAuthorizationRoleStmt)
|
|
if err := database.Changes(changes).Write(builder); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
builder.WriteString(updateAuthorizationRoleStmtWhere)
|
|
|
|
return client.Exec(ctx, builder.String(), builder.Args()...)
|
|
}
|
|
|
|
// Delete implements [domain.AuthorizationRepository].
|
|
func (a authorization) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) {
|
|
if !condition.IsRestrictingColumn(a.InstanceIDColumn()) {
|
|
return 0, database.NewMissingConditionError(a.InstanceIDColumn())
|
|
}
|
|
|
|
builder := database.NewStatementBuilder("DELETE FROM zitadel.authorizations")
|
|
writeCondition(builder, condition)
|
|
|
|
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
|
|
// -------------------------------------------------------------
|
|
|
|
// PrimaryKeyColumns implements [domain.Repository]
|
|
func (a authorization) PrimaryKeyColumns() []database.Column {
|
|
return []database.Column{
|
|
a.InstanceIDColumn(),
|
|
a.IDColumn(),
|
|
}
|
|
}
|
|
|
|
func (a authorization) IDColumn() database.Column {
|
|
return database.NewColumn(a.unqualifiedTableName(), "id")
|
|
}
|
|
|
|
func (a authorization) UserIDColumn() database.Column {
|
|
return database.NewColumn(a.unqualifiedTableName(), "user_id")
|
|
}
|
|
|
|
func (a authorization) GrantIDColumn() database.Column {
|
|
return database.NewColumn(a.unqualifiedTableName(), "grant_id")
|
|
}
|
|
|
|
func (a authorization) ProjectIDColumn() database.Column {
|
|
return database.NewColumn(a.unqualifiedTableName(), "project_id")
|
|
}
|
|
|
|
func (a authorization) InstanceIDColumn() database.Column {
|
|
return database.NewColumn(a.unqualifiedTableName(), "instance_id")
|
|
}
|
|
|
|
func (a authorization) StateColumn() database.Column {
|
|
return database.NewColumn(a.unqualifiedTableName(), "state")
|
|
}
|
|
|
|
func (a authorization) CreatedAtColumn() database.Column {
|
|
return database.NewColumn(a.unqualifiedTableName(), "created_at")
|
|
}
|
|
|
|
func (a authorization) UpdatedAtColumn() database.Column {
|
|
return database.NewColumn(a.unqualifiedTableName(), "updated_at")
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// conditions
|
|
// -------------------------------------------------------------
|
|
|
|
// PrimaryKeyCondition implements [domain.authorizationConditions]
|
|
func (a authorization) PrimaryKeyCondition(instanceID, id string) database.Condition {
|
|
return database.And(
|
|
a.InstanceIDCondition(instanceID),
|
|
a.IDCondition(id),
|
|
)
|
|
}
|
|
|
|
// IDCondition implements [domain.authorizationConditions]
|
|
func (a authorization) IDCondition(id string) database.Condition {
|
|
return database.NewTextCondition(a.IDColumn(), database.TextOperationEqual, id)
|
|
}
|
|
|
|
// InstanceIDCondition implements [domain.authorizationConditions]
|
|
func (a authorization) InstanceIDCondition(instanceID string) database.Condition {
|
|
return database.NewTextCondition(a.InstanceIDColumn(), database.TextOperationEqual, instanceID)
|
|
}
|
|
|
|
// ProjectIDCondition implements [domain.authorizationConditions]
|
|
func (a authorization) ProjectIDCondition(projectID string) database.Condition {
|
|
return database.NewTextCondition(a.ProjectIDColumn(), database.TextOperationEqual, projectID)
|
|
}
|
|
|
|
// GrantIDCondition implements [domain.authorizationConditions]
|
|
func (a authorization) GrantIDCondition(grantID string) database.Condition {
|
|
return database.NewTextCondition(a.GrantIDColumn(), database.TextOperationEqual, grantID)
|
|
}
|
|
|
|
// UserIDCondition implements [domain.authorizationConditions]
|
|
func (a authorization) UserIDCondition(userID string) database.Condition {
|
|
return database.NewTextCondition(a.UserIDColumn(), database.TextOperationEqual, userID)
|
|
}
|
|
|
|
// RoleCondition implements [domain.authorizationConditions]
|
|
func (a authorization) RoleCondition(op database.TextOperation, role string) database.Condition {
|
|
return database.NewTextCondition(database.NewColumn(a.unqualifiedAuthorizationRolesTableName(), "role_key"), op, role)
|
|
}
|
|
|
|
// ExistsRole implements [domain.authorizationConditions]
|
|
// ExistsRole creates a correlated [database.Exists] condition on the authorization_roles table.
|
|
// Use this filter to make sure the authorization returned contains a specific role.
|
|
func (a authorization) ExistsRole(cond database.Condition) database.Condition {
|
|
return database.Exists(
|
|
a.qualifiedAuthorizationRolesTableName(),
|
|
database.And(
|
|
database.NewColumnCondition(a.InstanceIDColumn(), database.NewColumn(a.unqualifiedAuthorizationRolesTableName(), "instance_id")),
|
|
database.NewColumnCondition(a.IDColumn(), database.NewColumn(a.unqualifiedAuthorizationRolesTableName(), "authorization_id")),
|
|
cond,
|
|
),
|
|
)
|
|
}
|
|
|
|
// StateCondition implements [domain.authorizationConditions]
|
|
func (a authorization) StateCondition(state domain.AuthorizationState) database.Condition {
|
|
return database.NewTextCondition(a.StateColumn(), database.TextOperationEqual, state.String())
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// changes
|
|
// -------------------------------------------------------------
|
|
|
|
// SetState implements [domain.authorizationChanges]
|
|
func (a authorization) SetState(state domain.AuthorizationState) database.Change {
|
|
return database.NewChange(a.StateColumn(), state)
|
|
}
|
|
|
|
func (a authorization) SetUpdatedAt(updatedAt time.Time) database.Change {
|
|
return database.NewChange(a.UpdatedAtColumn(), updatedAt)
|
|
}
|