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]>
370 lines
13 KiB
Go
370 lines
13 KiB
Go
package projection
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
repoDomain "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/repository/instance"
|
|
"github.com/zitadel/zitadel/internal/repository/org"
|
|
"github.com/zitadel/zitadel/internal/repository/project"
|
|
)
|
|
|
|
func TestAdministratorRelationalReducers(t *testing.T) {
|
|
handler := new(relationalTablesProjection)
|
|
rawTx, tx := getTransactions(t)
|
|
t.Cleanup(func() {
|
|
require.NoError(t, rawTx.Rollback())
|
|
})
|
|
|
|
instanceID, _, orgID, projectID, grantID := seedAdministratorRelationalState(t, tx)
|
|
adminRepo := repository.AdministratorRepository()
|
|
userRepo := repository.UserRepository()
|
|
|
|
var userSeq int
|
|
createUser := func(t *testing.T) string {
|
|
t.Helper()
|
|
userSeq++
|
|
userID := fmt.Sprintf("admin-test-user-%d", userSeq)
|
|
err := userRepo.Create(t.Context(), tx, &repoDomain.User{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: userID,
|
|
Username: userID,
|
|
State: repoDomain.UserStateActive,
|
|
Machine: &repoDomain.MachineUser{
|
|
Name: "machine",
|
|
},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
})
|
|
require.NoError(t, err)
|
|
return userID
|
|
}
|
|
|
|
t.Run("'instance member added' reducer should create instance administrator", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
|
|
added := instance.NewMemberAddedEvent(t.Context(), &instance.NewAggregate(instanceID).Aggregate, userID, "IAM_OWNER")
|
|
require.True(t, callReduce(t, rawTx, handler, added))
|
|
|
|
admin, err := adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.InstanceAdministratorCondition(instanceID, userID),
|
|
))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"IAM_OWNER"}, admin.Roles)
|
|
})
|
|
|
|
t.Run("'instance member changed' reducer should update instance administrator roles", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
err := adminRepo.Create(t.Context(), tx, &repoDomain.Administrator{
|
|
InstanceID: instanceID,
|
|
UserID: userID,
|
|
Scope: repoDomain.AdministratorScopeInstance,
|
|
Roles: []string{"IAM_OWNER"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
changed := instance.NewMemberChangedEvent(t.Context(), &instance.NewAggregate(instanceID).Aggregate, userID, "IAM_OWNER_VIEWER")
|
|
require.True(t, callReduce(t, rawTx, handler, changed))
|
|
|
|
admin, err := adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.InstanceAdministratorCondition(instanceID, userID),
|
|
))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"IAM_OWNER_VIEWER"}, admin.Roles)
|
|
})
|
|
|
|
t.Run("'instance member removed' reducer should remove instance administrator", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
err := adminRepo.Create(t.Context(), tx, &repoDomain.Administrator{
|
|
InstanceID: instanceID,
|
|
UserID: userID,
|
|
Scope: repoDomain.AdministratorScopeInstance,
|
|
Roles: []string{"IAM_OWNER"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
removed := instance.NewMemberRemovedEvent(t.Context(), &instance.NewAggregate(instanceID).Aggregate, userID)
|
|
require.True(t, callReduce(t, rawTx, handler, removed))
|
|
|
|
_, err = adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.InstanceAdministratorCondition(instanceID, userID),
|
|
))
|
|
require.ErrorIs(t, err, database.NewNoRowFoundError(nil))
|
|
})
|
|
|
|
t.Run("'org member added' reducer should create organization administrator", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
|
|
orgAggregate := org.NewAggregate(orgID)
|
|
orgAggregate.InstanceID = instanceID
|
|
|
|
added := org.NewMemberAddedEvent(t.Context(), &orgAggregate.Aggregate, userID, "ORG_OWNER")
|
|
require.True(t, callReduce(t, rawTx, handler, added))
|
|
|
|
admin, err := adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.OrganizationAdministratorCondition(instanceID, orgID, userID),
|
|
))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"ORG_OWNER"}, admin.Roles)
|
|
})
|
|
|
|
t.Run("'org member changed' reducer should update organization administrator roles", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
err := adminRepo.Create(t.Context(), tx, &repoDomain.Administrator{
|
|
InstanceID: instanceID,
|
|
UserID: userID,
|
|
Scope: repoDomain.AdministratorScopeOrganization,
|
|
OrganizationID: &orgID,
|
|
Roles: []string{"ORG_OWNER"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
orgAggregate := org.NewAggregate(orgID)
|
|
orgAggregate.InstanceID = instanceID
|
|
|
|
changed := org.NewMemberChangedEvent(t.Context(), &orgAggregate.Aggregate, userID, "ORG_OWNER_VIEWER")
|
|
require.True(t, callReduce(t, rawTx, handler, changed))
|
|
|
|
admin, err := adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.OrganizationAdministratorCondition(instanceID, orgID, userID),
|
|
))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"ORG_OWNER_VIEWER"}, admin.Roles)
|
|
})
|
|
|
|
t.Run("'org member removed' reducer should remove organization administrator", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
err := adminRepo.Create(t.Context(), tx, &repoDomain.Administrator{
|
|
InstanceID: instanceID,
|
|
UserID: userID,
|
|
Scope: repoDomain.AdministratorScopeOrganization,
|
|
OrganizationID: &orgID,
|
|
Roles: []string{"ORG_OWNER"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
orgAggregate := org.NewAggregate(orgID)
|
|
orgAggregate.InstanceID = instanceID
|
|
|
|
removed := org.NewMemberRemovedEvent(t.Context(), &orgAggregate.Aggregate, userID)
|
|
require.True(t, callReduce(t, rawTx, handler, removed))
|
|
|
|
_, err = adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.OrganizationAdministratorCondition(instanceID, orgID, userID),
|
|
))
|
|
require.ErrorIs(t, err, database.NewNoRowFoundError(nil))
|
|
})
|
|
|
|
t.Run("'project member added' reducer should create project administrator", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
|
|
projectAggregate := project.NewAggregate(projectID, orgID)
|
|
projectAggregate.InstanceID = instanceID
|
|
|
|
added := project.NewProjectMemberAddedEvent(t.Context(), &projectAggregate.Aggregate, userID, "PROJECT_OWNER")
|
|
require.True(t, callReduce(t, rawTx, handler, added))
|
|
|
|
admin, err := adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.ProjectAdministratorCondition(instanceID, projectID, userID),
|
|
))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"PROJECT_OWNER"}, admin.Roles)
|
|
})
|
|
|
|
t.Run("'project member changed' reducer should update project administrator roles", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
err := adminRepo.Create(t.Context(), tx, &repoDomain.Administrator{
|
|
InstanceID: instanceID,
|
|
UserID: userID,
|
|
Scope: repoDomain.AdministratorScopeProject,
|
|
ProjectID: &projectID,
|
|
Roles: []string{"PROJECT_OWNER"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
projectAggregate := project.NewAggregate(projectID, orgID)
|
|
projectAggregate.InstanceID = instanceID
|
|
|
|
changed := project.NewProjectMemberChangedEvent(t.Context(), &projectAggregate.Aggregate, userID, "PROJECT_OWNER_VIEWER")
|
|
require.True(t, callReduce(t, rawTx, handler, changed))
|
|
|
|
admin, err := adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.ProjectAdministratorCondition(instanceID, projectID, userID),
|
|
))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"PROJECT_OWNER_VIEWER"}, admin.Roles)
|
|
})
|
|
|
|
t.Run("'project member removed' reducer should remove project administrator", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
err := adminRepo.Create(t.Context(), tx, &repoDomain.Administrator{
|
|
InstanceID: instanceID,
|
|
UserID: userID,
|
|
Scope: repoDomain.AdministratorScopeProject,
|
|
ProjectID: &projectID,
|
|
Roles: []string{"PROJECT_OWNER"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
projectAggregate := project.NewAggregate(projectID, orgID)
|
|
projectAggregate.InstanceID = instanceID
|
|
|
|
removed := project.NewProjectMemberRemovedEvent(t.Context(), &projectAggregate.Aggregate, userID)
|
|
require.True(t, callReduce(t, rawTx, handler, removed))
|
|
|
|
_, err = adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.ProjectAdministratorCondition(instanceID, projectID, userID),
|
|
))
|
|
require.ErrorIs(t, err, database.NewNoRowFoundError(nil))
|
|
})
|
|
|
|
t.Run("'project grant member added' reducer should create project grant administrator", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
|
|
projectAggregate := project.NewAggregate(projectID, orgID)
|
|
projectAggregate.InstanceID = instanceID
|
|
|
|
added := project.NewProjectGrantMemberAddedEvent(t.Context(), &projectAggregate.Aggregate, userID, grantID, "PROJECT_OWNER")
|
|
require.True(t, callReduce(t, rawTx, handler, added))
|
|
|
|
admin, err := adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.ProjectGrantAdministratorCondition(instanceID, grantID, userID),
|
|
))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"PROJECT_OWNER"}, admin.Roles)
|
|
})
|
|
|
|
t.Run("'project grant member changed' reducer should update project grant administrator roles", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
err := adminRepo.Create(t.Context(), tx, &repoDomain.Administrator{
|
|
InstanceID: instanceID,
|
|
UserID: userID,
|
|
Scope: repoDomain.AdministratorScopeProjectGrant,
|
|
ProjectGrantID: &grantID,
|
|
Roles: []string{"PROJECT_OWNER"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
projectAggregate := project.NewAggregate(projectID, orgID)
|
|
projectAggregate.InstanceID = instanceID
|
|
|
|
changed := project.NewProjectGrantMemberChangedEvent(t.Context(), &projectAggregate.Aggregate, userID, grantID, "PROJECT_OWNER_VIEWER")
|
|
require.True(t, callReduce(t, rawTx, handler, changed))
|
|
|
|
admin, err := adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.ProjectGrantAdministratorCondition(instanceID, grantID, userID),
|
|
))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"PROJECT_OWNER_VIEWER"}, admin.Roles)
|
|
})
|
|
|
|
t.Run("'project grant member cascade removed' reducer should remove project grant administrator", func(t *testing.T) {
|
|
userID := createUser(t)
|
|
err := adminRepo.Create(t.Context(), tx, &repoDomain.Administrator{
|
|
InstanceID: instanceID,
|
|
UserID: userID,
|
|
Scope: repoDomain.AdministratorScopeProjectGrant,
|
|
ProjectGrantID: &grantID,
|
|
Roles: []string{"PROJECT_OWNER"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
projectAggregate := project.NewAggregate(projectID, orgID)
|
|
projectAggregate.InstanceID = instanceID
|
|
|
|
removed := project.NewProjectGrantMemberCascadeRemovedEvent(t.Context(), &projectAggregate.Aggregate, userID, grantID)
|
|
require.True(t, callReduce(t, rawTx, handler, removed))
|
|
|
|
_, err = adminRepo.Get(t.Context(), tx, database.WithCondition(
|
|
adminRepo.ProjectGrantAdministratorCondition(instanceID, grantID, userID),
|
|
))
|
|
require.ErrorIs(t, err, database.NewNoRowFoundError(nil))
|
|
})
|
|
}
|
|
|
|
func seedAdministratorRelationalState(t *testing.T, tx database.QueryExecutor) (instanceID, userID, orgID, projectID, projectGrantID string) {
|
|
t.Helper()
|
|
|
|
instanceRepo := repository.InstanceRepository()
|
|
orgRepo := repository.OrganizationRepository()
|
|
projectRepo := repository.ProjectRepository()
|
|
projectGrantRepo := repository.ProjectGrantRepository()
|
|
userRepo := repository.UserRepository()
|
|
|
|
now := time.Now().UnixNano()
|
|
instanceID = fmt.Sprintf("instance-%d", now)
|
|
err := instanceRepo.Create(t.Context(), tx, &repoDomain.Instance{
|
|
ID: instanceID,
|
|
Name: "instance",
|
|
DefaultOrgID: "default-org",
|
|
IAMProjectID: "iam-project",
|
|
ConsoleClientID: "console-client",
|
|
ConsoleAppID: "console-app",
|
|
DefaultLanguage: "en",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
orgID = fmt.Sprintf("org-%d", now+1)
|
|
err = orgRepo.Create(t.Context(), tx, &repoDomain.Organization{
|
|
InstanceID: instanceID,
|
|
ID: orgID,
|
|
Name: "org",
|
|
State: repoDomain.OrgStateActive,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
projectID = fmt.Sprintf("project-%d", now+2)
|
|
err = projectRepo.Create(t.Context(), tx, &repoDomain.Project{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: projectID,
|
|
Name: "project",
|
|
State: repoDomain.ProjectStateActive,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
projectGrantID = fmt.Sprintf("grant-%d", now+3)
|
|
err = projectGrantRepo.Create(t.Context(), tx, &repoDomain.ProjectGrant{
|
|
InstanceID: instanceID,
|
|
ID: projectGrantID,
|
|
ProjectID: projectID,
|
|
GrantingOrganizationID: orgID,
|
|
GrantedOrganizationID: orgID,
|
|
State: repoDomain.ProjectGrantStateActive,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
userID = fmt.Sprintf("user-%d", now+4)
|
|
err = userRepo.Create(t.Context(), tx, &repoDomain.User{
|
|
InstanceID: instanceID,
|
|
OrganizationID: orgID,
|
|
ID: userID,
|
|
Username: userID,
|
|
State: repoDomain.UserStateActive,
|
|
Machine: &repoDomain.MachineUser{
|
|
Name: "machine",
|
|
},
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return instanceID, userID, orgID, projectID, projectGrantID
|
|
}
|