mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-09 07:35:13 +00:00
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:
co-authored by
Marco Ardizzone
parent
6d90a120a6
commit
f138082b64
@@ -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)
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS zitadel.administrator_roles;
|
||||
DROP TABLE IF EXISTS zitadel.administrators;
|
||||
DROP TYPE IF EXISTS zitadel.administrator_scope;
|
||||
+78
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user