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:
Silvan
2026-03-18 10:24:09 +01:00
committed by GitHub
co-authored by Marco Ardizzone
parent 6d90a120a6
commit f138082b64
21 changed files with 3513 additions and 46 deletions
+10 -7
View File
@@ -1,9 +1,7 @@
version: "2" version: "2"
run: run:
concurrency: 4 concurrency: 4
go: "1.24" go: "1.25"
skip-dirs:
- apps/docs
linters: linters:
enable: enable:
- asciicheck - asciicheck
@@ -94,6 +92,15 @@ linters:
- common-false-positives - common-false-positives
- legacy - legacy
- std-error-handling - std-error-handling
rules:
- path: apps/docs/.*
linters:
- typecheck
# contextcheck requires a context to be passed to the function, but in tests we use `testing.T.Context()` which is not recognized as a context by the linter, so we disable it for the function.
- path: internal/query/projection/.*_relational_test\.go
linters:
- contextcheck
source: callReduce\(
paths: paths:
- .artifacts - .artifacts
- .backups - .backups
@@ -123,10 +130,6 @@ issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
new-from-rev: origin/main new-from-rev: origin/main
exclude-rules:
- path: apps/docs/.*
linters:
- typecheck
formatters: formatters:
enable: enable:
- gci - gci
+1 -1
View File
@@ -124,7 +124,7 @@
"lint-install": { "lint-install": {
"description": "Installs golangci-lint binary for linting. Using go install is not recommended in the official docs, because this can produce non-deterministic results.", "description": "Installs golangci-lint binary for linting. Using go install is not recommended in the official docs, because this can produce non-deterministic results.",
"cache": true, "cache": true,
"command": "curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b .artifacts/bin/$(go env GOOS)/$(go env GOARCH) v2.5.0", "command": "curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b .artifacts/bin/$(go env GOOS)/$(go env GOARCH) v2.11.3",
"inputs": [ "inputs": [
{ {
"runtime": "go env GOOS" "runtime": "go env GOOS"
+93
View File
@@ -0,0 +1,93 @@
package domain
import (
"context"
"time"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
//go:generate enumer -type AdministratorScope -transform snake -trimprefix AdministratorScope -sql
type AdministratorScope uint8
const (
AdministratorScopeInstance AdministratorScope = iota
AdministratorScopeOrganization
AdministratorScopeProject
AdministratorScopeProjectGrant
)
type Administrator struct {
// ID is generated by the storage layer during creation and any value
// provided by the caller is ignored. The domain package must not
// generate this value. The format is storage-specific, for example
// PostgreSQL uses `<user_id>:<scope>:<scope_id>`.
ID string `json:"id,omitempty" db:"id"`
InstanceID string `json:"instanceId,omitempty" db:"instance_id"`
UserID string `json:"userId,omitempty" db:"user_id"`
Scope AdministratorScope `json:"scope,omitempty" db:"scope"`
OrganizationID *string `json:"organizationId,omitempty" db:"organization_id"`
ProjectID *string `json:"projectId,omitempty" db:"project_id"`
ProjectGrantID *string `json:"projectGrantId,omitempty" db:"project_grant_id"`
CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"`
Roles []string `json:"roles,omitempty" db:"roles"`
}
type administratorColumns interface {
PrimaryKeyColumns() []database.Column
IDColumn() database.Column
InstanceIDColumn() database.Column
UserIDColumn() database.Column
ScopeColumn() database.Column
OrganizationIDColumn() database.Column
ProjectIDColumn() database.Column
ProjectGrantIDColumn() database.Column
CreatedAtColumn() database.Column
UpdatedAtColumn() database.Column
}
type administratorConditions interface {
PrimaryKeyCondition(instanceID, administratorID string) database.Condition
IDCondition(administratorID string) database.Condition
InstanceIDCondition(instanceID string) database.Condition
UserIDCondition(userID string) database.Condition
ScopeCondition(scope AdministratorScope) database.Condition
InstanceAdministratorCondition(instanceID, userID string) database.Condition
OrganizationAdministratorCondition(instanceID, organizationID, userID string) database.Condition
ProjectAdministratorCondition(instanceID, projectID, userID string) database.Condition
ProjectGrantAdministratorCondition(instanceID, projectGrantID, userID string) database.Condition
OrganizationIDCondition(organizationID string) database.Condition
ProjectIDCondition(projectID string) database.Condition
ProjectGrantIDCondition(projectGrantID string) database.Condition
RoleCondition(op database.TextOperation, role string) database.Condition
}
type administratorChanges interface {
SetUpdatedAt(updatedAt time.Time) database.Change
// AddRole adds the specified role to the administrator.
// If the administrator already has the role, no change will be made.
AddRole(role string) database.Change
// RemoveRole removes the specified role from the administrator.
// If the administrator does not have the role, no change will be made.
RemoveRole(role string) database.Change
// SetRoles replaces all existing roles with the provided ones.
// If no roles are provided, all existing roles will be removed.
SetRoles(roles []string) database.Change
}
//go:generate mockgen -typed -package domainmock -destination ./mock/administrator.mock.go . AdministratorRepository
type AdministratorRepository interface {
Repository
administratorColumns
administratorConditions
administratorChanges
Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*Administrator, error)
List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*Administrator, error)
Create(ctx context.Context, client database.QueryExecutor, administrator *Administrator) error
Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error)
Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error)
}
@@ -0,0 +1,117 @@
// Code generated by "enumer -type AdministratorScope -transform snake -trimprefix AdministratorScope -sql"; DO NOT EDIT.
package domain
import (
"database/sql/driver"
"fmt"
"strings"
)
const _AdministratorScopeName = "instanceorganizationprojectproject_grant"
var _AdministratorScopeIndex = [...]uint8{0, 8, 20, 27, 40}
const _AdministratorScopeLowerName = "instanceorganizationprojectproject_grant"
func (i AdministratorScope) String() string {
if i >= AdministratorScope(len(_AdministratorScopeIndex)-1) {
return fmt.Sprintf("AdministratorScope(%d)", i)
}
return _AdministratorScopeName[_AdministratorScopeIndex[i]:_AdministratorScopeIndex[i+1]]
}
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
func _AdministratorScopeNoOp() {
var x [1]struct{}
_ = x[AdministratorScopeInstance-(0)]
_ = x[AdministratorScopeOrganization-(1)]
_ = x[AdministratorScopeProject-(2)]
_ = x[AdministratorScopeProjectGrant-(3)]
}
var _AdministratorScopeValues = []AdministratorScope{AdministratorScopeInstance, AdministratorScopeOrganization, AdministratorScopeProject, AdministratorScopeProjectGrant}
var _AdministratorScopeNameToValueMap = map[string]AdministratorScope{
_AdministratorScopeName[0:8]: AdministratorScopeInstance,
_AdministratorScopeLowerName[0:8]: AdministratorScopeInstance,
_AdministratorScopeName[8:20]: AdministratorScopeOrganization,
_AdministratorScopeLowerName[8:20]: AdministratorScopeOrganization,
_AdministratorScopeName[20:27]: AdministratorScopeProject,
_AdministratorScopeLowerName[20:27]: AdministratorScopeProject,
_AdministratorScopeName[27:40]: AdministratorScopeProjectGrant,
_AdministratorScopeLowerName[27:40]: AdministratorScopeProjectGrant,
}
var _AdministratorScopeNames = []string{
_AdministratorScopeName[0:8],
_AdministratorScopeName[8:20],
_AdministratorScopeName[20:27],
_AdministratorScopeName[27:40],
}
// AdministratorScopeString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func AdministratorScopeString(s string) (AdministratorScope, error) {
if val, ok := _AdministratorScopeNameToValueMap[s]; ok {
return val, nil
}
if val, ok := _AdministratorScopeNameToValueMap[strings.ToLower(s)]; ok {
return val, nil
}
return 0, fmt.Errorf("%s does not belong to AdministratorScope values", s)
}
// AdministratorScopeValues returns all values of the enum
func AdministratorScopeValues() []AdministratorScope {
return _AdministratorScopeValues
}
// AdministratorScopeStrings returns a slice of all String values of the enum
func AdministratorScopeStrings() []string {
strs := make([]string, len(_AdministratorScopeNames))
copy(strs, _AdministratorScopeNames)
return strs
}
// IsAAdministratorScope returns "true" if the value is listed in the enum definition. "false" otherwise
func (i AdministratorScope) IsAAdministratorScope() bool {
for _, v := range _AdministratorScopeValues {
if i == v {
return true
}
}
return false
}
func (i AdministratorScope) Value() (driver.Value, error) {
return i.String(), nil
}
func (i *AdministratorScope) Scan(value interface{}) error {
if value == nil {
return nil
}
var str string
switch v := value.(type) {
case []byte:
str = string(v)
case string:
str = v
case fmt.Stringer:
str = v.String()
default:
return fmt.Errorf("invalid value of AdministratorScope: %[1]T(%[1]v)", value)
}
val, err := AdministratorScopeString(str)
if err != nil {
return err
}
*i = val
return nil
}
File diff suppressed because it is too large Load Diff
+39
View File
@@ -12,6 +12,7 @@ package domainmock
import ( import (
context "context" context "context"
reflect "reflect" reflect "reflect"
time "time"
domain "github.com/zitadel/zitadel/backend/v3/domain" domain "github.com/zitadel/zitadel/backend/v3/domain"
database "github.com/zitadel/zitadel/backend/v3/storage/database" database "github.com/zitadel/zitadel/backend/v3/storage/database"
@@ -777,6 +778,44 @@ func (c *MockOrganizationRepositorySetStateCall) DoAndReturn(f func(domain.OrgSt
return c return c
} }
// SetUpdatedAt mocks base method.
func (m *MockOrganizationRepository) SetUpdatedAt(updatedAt time.Time) database.Change {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetUpdatedAt", updatedAt)
ret0, _ := ret[0].(database.Change)
return ret0
}
// SetUpdatedAt indicates an expected call of SetUpdatedAt.
func (mr *MockOrganizationRepositoryMockRecorder) SetUpdatedAt(updatedAt any) *MockOrganizationRepositorySetUpdatedAtCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUpdatedAt", reflect.TypeOf((*MockOrganizationRepository)(nil).SetUpdatedAt), updatedAt)
return &MockOrganizationRepositorySetUpdatedAtCall{Call: call}
}
// MockOrganizationRepositorySetUpdatedAtCall wrap *gomock.Call
type MockOrganizationRepositorySetUpdatedAtCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockOrganizationRepositorySetUpdatedAtCall) Return(arg0 database.Change) *MockOrganizationRepositorySetUpdatedAtCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockOrganizationRepositorySetUpdatedAtCall) Do(f func(time.Time) database.Change) *MockOrganizationRepositorySetUpdatedAtCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockOrganizationRepositorySetUpdatedAtCall) DoAndReturn(f func(time.Time) database.Change) *MockOrganizationRepositorySetUpdatedAtCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// StateColumn mocks base method. // StateColumn mocks base method.
func (m *MockOrganizationRepository) StateColumn() database.Column { func (m *MockOrganizationRepository) StateColumn() database.Column {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -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)
}
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS zitadel.administrator_roles;
DROP TABLE IF EXISTS zitadel.administrators;
DROP TYPE IF EXISTS zitadel.administrator_scope;
@@ -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]. // Close implements [database.Rows].
// Subtle: this method shadows the method (Rows).Close of Rows.Rows. // Subtle: this method shadows the method (Rows).Close of Rows.Rows.
func (r *Rows) Close() error { 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/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database" "github.com/zitadel/zitadel/backend/v3/storage/database"
internaldb "github.com/zitadel/zitadel/internal/database"
) )
var _ domain.AuthorizationRepository = (*authorization)(nil) var _ domain.AuthorizationRepository = (*authorization)(nil)
@@ -93,7 +94,7 @@ func (a authorization) Get(ctx context.Context, client database.QueryExecutor, o
if err != nil { if err != nil {
return nil, err return nil, err
} }
return getOne[domain.Authorization](ctx, client, builder) return scanAuthorization(ctx, client, builder)
} }
// List implements [domain.AuthorizationRepository]. // List implements [domain.AuthorizationRepository].
@@ -106,7 +107,7 @@ func (a authorization) List(ctx context.Context, client database.QueryExecutor,
if err != nil { if err != nil {
return nil, err 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) { 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()...) 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 // columns
// ------------------------------------------------------------- // -------------------------------------------------------------
@@ -6,6 +6,7 @@ import (
"github.com/zitadel/zitadel/backend/v3/domain" "github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database" "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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err return nil, err
} }
return getMany[domain.ProjectGrant](ctx, client, builder) return scanProjectGrants(ctx, client, builder)
} }
const insertProjectGrantRolesStmt = `WITH added_roles AS ( const insertProjectGrantRolesStmt = `WITH added_roles AS (
@@ -307,3 +308,47 @@ func (p projectGrant) prepareQuery(opts []database.QueryOption) (*database.State
return builder, nil 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
}
+45
View File
@@ -15,6 +15,11 @@ type TextArray[T ~string] pgtype.FlatArray[T]
// Scan implements the [database/sql.Scanner] interface. // Scan implements the [database/sql.Scanner] interface.
func (s *TextArray[T]) Scan(src any) error { func (s *TextArray[T]) Scan(src any) error {
if src == nil {
*s = nil
return nil
}
var typedArray []string var typedArray []string
err := pgtype.NewMap().SQLScanner(&typedArray).Scan(src) err := pgtype.NewMap().SQLScanner(&typedArray).Scan(src)
if err != nil { if err != nil {
@@ -29,6 +34,46 @@ func (s *TextArray[T]) Scan(src any) error {
return nil return nil
} }
func (s TextArray[T]) Dimensions() []pgtype.ArrayDimension {
if s == nil {
return nil
}
return []pgtype.ArrayDimension{{Length: int32(len(s)), LowerBound: 1}}
}
func (s TextArray[T]) Index(i int) any {
return s[i]
}
func (s TextArray[T]) IndexType() any {
var el T
return el
}
func (s *TextArray[T]) SetDimensions(dimensions []pgtype.ArrayDimension) error {
if dimensions == nil {
*s = nil
return nil
}
elementCount := 1
for _, dimension := range dimensions {
elementCount *= int(dimension.Length)
}
*s = make(TextArray[T], elementCount)
return nil
}
func (s TextArray[T]) ScanIndex(i int) any {
return &s[i]
}
func (s TextArray[T]) ScanIndexType() any {
return new(T)
}
// Value implements the [database/sql/driver.Valuer] interface. // Value implements the [database/sql/driver.Valuer] interface.
func (s TextArray[T]) Value() (driver.Value, error) { func (s TextArray[T]) Value() (driver.Value, error) {
if len(s) == 0 { if len(s) == 0 {
@@ -0,0 +1,226 @@
package projection
import (
"context"
"database/sql"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
v3_sql "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/sql"
"github.com/zitadel/zitadel/backend/v3/storage/database/repository"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/zerrors"
)
func (p *relationalTablesProjection) reduceInstanceAdminAdded(event eventstore.Event) (*handler.Statement, error) {
e, err := assertEvent[*instance.MemberAddedEvent](event)
if err != nil {
return nil, err
}
return p.createAdministratorStatement(e, &domain.Administrator{
InstanceID: e.Aggregate().InstanceID,
UserID: e.UserID,
Scope: domain.AdministratorScopeInstance,
Roles: e.Roles,
CreatedAt: e.CreationDate(),
UpdatedAt: e.CreationDate(),
})
}
func (p *relationalTablesProjection) reduceInstanceAdminChanged(event eventstore.Event) (*handler.Statement, error) {
e, err := assertEvent[*instance.MemberChangedEvent](event)
if err != nil {
return nil, err
}
return p.updateAdministratorStatement(e,
repository.AdministratorRepository().InstanceAdministratorCondition(e.Aggregate().InstanceID, e.UserID),
repository.AdministratorRepository().SetUpdatedAt(e.CreationDate()),
repository.AdministratorRepository().SetRoles(e.Roles),
)
}
func (p *relationalTablesProjection) reduceInstanceAdminRemoved(event eventstore.Event) (*handler.Statement, error) {
switch e := event.(type) {
case *instance.MemberRemovedEvent:
return p.removeAdministratorStatement(e,
repository.AdministratorRepository().InstanceAdministratorCondition(e.Aggregate().InstanceID, e.UserID),
)
case *instance.MemberCascadeRemovedEvent:
return p.removeAdministratorStatement(e,
repository.AdministratorRepository().InstanceAdministratorCondition(e.Aggregate().InstanceID, e.UserID),
)
default:
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-v3ADM03", "reduce.wrong.event.type %v", []eventstore.EventType{instance.MemberRemovedEventType, instance.MemberCascadeRemovedEventType})
}
}
func (p *relationalTablesProjection) reduceOrganizationAdminAdded(event eventstore.Event) (*handler.Statement, error) {
e, err := assertEvent[*org.MemberAddedEvent](event)
if err != nil {
return nil, err
}
return p.createAdministratorStatement(e, &domain.Administrator{
InstanceID: e.Aggregate().InstanceID,
UserID: e.UserID,
Scope: domain.AdministratorScopeOrganization,
OrganizationID: stringPtr(e.Aggregate().ID),
Roles: e.Roles,
CreatedAt: e.CreationDate(),
UpdatedAt: e.CreationDate(),
})
}
func (p *relationalTablesProjection) reduceOrganizationAdminChanged(event eventstore.Event) (*handler.Statement, error) {
e, err := assertEvent[*org.MemberChangedEvent](event)
if err != nil {
return nil, err
}
return p.updateAdministratorStatement(e,
repository.AdministratorRepository().OrganizationAdministratorCondition(e.Aggregate().InstanceID, e.Aggregate().ID, e.UserID),
repository.AdministratorRepository().SetUpdatedAt(e.CreationDate()),
repository.AdministratorRepository().SetRoles(e.Roles),
)
}
func (p *relationalTablesProjection) reduceOrganizationAdminRemoved(event eventstore.Event) (*handler.Statement, error) {
switch e := event.(type) {
case *org.MemberRemovedEvent:
return p.removeAdministratorStatement(e,
repository.AdministratorRepository().OrganizationAdministratorCondition(e.Aggregate().InstanceID, e.Aggregate().ID, e.UserID),
)
case *org.MemberCascadeRemovedEvent:
return p.removeAdministratorStatement(e,
repository.AdministratorRepository().OrganizationAdministratorCondition(e.Aggregate().InstanceID, e.Aggregate().ID, e.UserID),
)
default:
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-v3ADM06", "reduce.wrong.event.type %v", []eventstore.EventType{org.MemberRemovedEventType, org.MemberCascadeRemovedEventType})
}
}
func (p *relationalTablesProjection) reduceProjectAdminAdded(event eventstore.Event) (*handler.Statement, error) {
e, err := assertEvent[*project.MemberAddedEvent](event)
if err != nil {
return nil, err
}
return p.createAdministratorStatement(e, &domain.Administrator{
InstanceID: e.Aggregate().InstanceID,
UserID: e.UserID,
Scope: domain.AdministratorScopeProject,
ProjectID: stringPtr(e.Aggregate().ID),
Roles: e.Roles,
CreatedAt: e.CreationDate(),
UpdatedAt: e.CreationDate(),
})
}
func (p *relationalTablesProjection) reduceProjectAdminChanged(event eventstore.Event) (*handler.Statement, error) {
e, err := assertEvent[*project.MemberChangedEvent](event)
if err != nil {
return nil, err
}
return p.updateAdministratorStatement(e,
repository.AdministratorRepository().ProjectAdministratorCondition(e.Aggregate().InstanceID, e.Aggregate().ID, e.UserID),
repository.AdministratorRepository().SetUpdatedAt(e.CreationDate()),
repository.AdministratorRepository().SetRoles(e.Roles),
)
}
func (p *relationalTablesProjection) reduceProjectAdminRemoved(event eventstore.Event) (*handler.Statement, error) {
switch e := event.(type) {
case *project.MemberRemovedEvent:
return p.removeAdministratorStatement(e,
repository.AdministratorRepository().ProjectAdministratorCondition(e.Aggregate().InstanceID, e.Aggregate().ID, e.UserID),
)
case *project.MemberCascadeRemovedEvent:
return p.removeAdministratorStatement(e,
repository.AdministratorRepository().ProjectAdministratorCondition(e.Aggregate().InstanceID, e.Aggregate().ID, e.UserID),
)
default:
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-v3ADM09", "reduce.wrong.event.type %v", []eventstore.EventType{project.MemberRemovedEventType, project.MemberCascadeRemovedEventType})
}
}
func (p *relationalTablesProjection) reduceProjectGrantAdminAdded(event eventstore.Event) (*handler.Statement, error) {
e, err := assertEvent[*project.GrantMemberAddedEvent](event)
if err != nil {
return nil, err
}
return p.createAdministratorStatement(e, &domain.Administrator{
InstanceID: e.Aggregate().InstanceID,
UserID: e.UserID,
Scope: domain.AdministratorScopeProjectGrant,
ProjectGrantID: stringPtr(e.GrantID),
Roles: e.Roles,
CreatedAt: e.CreationDate(),
UpdatedAt: e.CreationDate(),
})
}
func (p *relationalTablesProjection) reduceProjectGrantAdminChanged(event eventstore.Event) (*handler.Statement, error) {
e, err := assertEvent[*project.GrantMemberChangedEvent](event)
if err != nil {
return nil, err
}
return p.updateAdministratorStatement(e,
repository.AdministratorRepository().ProjectGrantAdministratorCondition(e.Aggregate().InstanceID, e.GrantID, e.UserID),
repository.AdministratorRepository().SetUpdatedAt(e.CreationDate()),
repository.AdministratorRepository().SetRoles(e.Roles),
)
}
func (p *relationalTablesProjection) reduceProjectGrantAdminRemoved(event eventstore.Event) (*handler.Statement, error) {
switch e := event.(type) {
case *project.GrantMemberRemovedEvent:
return p.removeAdministratorStatement(e,
repository.AdministratorRepository().ProjectGrantAdministratorCondition(e.Aggregate().InstanceID, e.GrantID, e.UserID),
)
case *project.GrantMemberCascadeRemovedEvent:
return p.removeAdministratorStatement(e,
repository.AdministratorRepository().ProjectGrantAdministratorCondition(e.Aggregate().InstanceID, e.GrantID, e.UserID),
)
default:
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-v3ADM12", "reduce.wrong.event.type %v", []eventstore.EventType{project.GrantMemberRemovedType, project.GrantMemberCascadeRemovedType})
}
}
func (p *relationalTablesProjection) createAdministratorStatement(event eventstore.Event, administrator *domain.Administrator) (*handler.Statement, error) {
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, _ string) error {
tx, ok := ex.(*sql.Tx)
if !ok {
return zerrors.ThrowInvalidArgumentf(nil, "HANDL-v3ADM13", "reduce.wrong.db.pool %T", ex)
}
return repository.AdministratorRepository().Create(ctx, v3_sql.SQLTx(tx), administrator)
}), nil
}
func (p *relationalTablesProjection) updateAdministratorStatement(event eventstore.Event, condition database.Condition, changes ...database.Change) (*handler.Statement, error) {
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, _ string) error {
tx, ok := ex.(*sql.Tx)
if !ok {
return zerrors.ThrowInvalidArgumentf(nil, "HANDL-v3ADM14", "reduce.wrong.db.pool %T", ex)
}
repo := repository.AdministratorRepository()
_, err := repo.Update(ctx, v3_sql.SQLTx(tx), condition, changes...)
return err
}), nil
}
func (p *relationalTablesProjection) removeAdministratorStatement(event eventstore.Event, condition database.Condition) (*handler.Statement, error) {
return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, _ string) error {
tx, ok := ex.(*sql.Tx)
if !ok {
return zerrors.ThrowInvalidArgumentf(nil, "HANDL-v3ADM15", "reduce.wrong.db.pool %T", ex)
}
repo := repository.AdministratorRepository()
_, err := repo.Delete(ctx, v3_sql.SQLTx(tx), condition)
return err
}), nil
}
func stringPtr(value string) *string {
return &value
}
@@ -0,0 +1,369 @@
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
}
@@ -85,7 +85,7 @@ func getTransactions(t *testing.T) (rawTx *sql.Tx, v3SQLTx *v3_sql.Transaction)
// 4. the reducer function for the input event executes without errors // 4. the reducer function for the input event executes without errors
// //
// Returns false if any of the above checks fails // Returns false if any of the above checks fails
func callReduce(t *testing.T, ctx context.Context, tx *sql.Tx, projection handler.Projection, event eventstore.Event) bool { func callReduce(t *testing.T, tx *sql.Tx, projection handler.Projection, event eventstore.Event) bool {
reducers := projection.Reducers() reducers := projection.Reducers()
aggregateReducerIdx := slices.IndexFunc(reducers, func(r handler.AggregateReducer) bool { aggregateReducerIdx := slices.IndexFunc(reducers, func(r handler.AggregateReducer) bool {
return r.Aggregate == event.Aggregate().Type return r.Aggregate == event.Aggregate().Type
@@ -111,6 +111,6 @@ func callReduce(t *testing.T, ctx context.Context, tx *sql.Tx, projection handle
return false return false
} }
err = stmt.Execute(ctx, tx, "") err = stmt.Execute(t.Context(), tx, "")
return assert.NoError(t, err) return assert.NoError(t, err)
} }
@@ -44,7 +44,7 @@ func TestInstanceDomainReducers(t *testing.T) {
domainAddedEvt := instance.NewDomainAddedEvent(ctx, &instance.NewAggregate("123").Aggregate, domainName, false) domainAddedEvt := instance.NewDomainAddedEvent(ctx, &instance.NewAggregate("123").Aggregate, domainName, false)
// Test // Test
res := callReduce(t, ctx, rawTx, handler, domainAddedEvt) res := callReduce(t, rawTx, handler, domainAddedEvt)
require.True(t, res) require.True(t, res)
// Verify // Verify
@@ -85,7 +85,7 @@ func TestInstanceDomainReducers(t *testing.T) {
domainPrimarySetEvt := instance.NewDomainPrimarySetEvent(ctx, &instance.NewAggregate("123").Aggregate, domainName) domainPrimarySetEvt := instance.NewDomainPrimarySetEvent(ctx, &instance.NewAggregate("123").Aggregate, domainName)
// Test // Test
res := callReduce(t, ctx, rawTx, handler, domainPrimarySetEvt) res := callReduce(t, rawTx, handler, domainPrimarySetEvt)
require.True(t, res) require.True(t, res)
// Verify // Verify
@@ -127,7 +127,7 @@ func TestInstanceDomainReducers(t *testing.T) {
domainRemovedEvt := instance.NewDomainRemovedEvent(ctx, &instance.NewAggregate("123").Aggregate, domainName) domainRemovedEvt := instance.NewDomainRemovedEvent(ctx, &instance.NewAggregate("123").Aggregate, domainName)
// Test // Test
res := callReduce(t, ctx, rawTx, handler, domainRemovedEvt) res := callReduce(t, rawTx, handler, domainRemovedEvt)
require.True(t, res) require.True(t, res)
// Verify // Verify
@@ -150,7 +150,7 @@ func TestInstanceDomainReducers(t *testing.T) {
trustedDomainAddedEvt := instance.NewTrustedDomainAddedEvent(ctx, &instance.NewAggregate("123").Aggregate, domainName) trustedDomainAddedEvt := instance.NewTrustedDomainAddedEvent(ctx, &instance.NewAggregate("123").Aggregate, domainName)
// Test // Test
res := callReduce(t, ctx, rawTx, handler, trustedDomainAddedEvt) res := callReduce(t, rawTx, handler, trustedDomainAddedEvt)
require.True(t, res) require.True(t, res)
// Verify // Verify
@@ -185,7 +185,7 @@ func TestInstanceDomainReducers(t *testing.T) {
trustedDomainRemovedEvt := instance.NewTrustedDomainRemovedEvent(ctx, &instance.NewAggregate("123").Aggregate, domainName) trustedDomainRemovedEvt := instance.NewTrustedDomainRemovedEvent(ctx, &instance.NewAggregate("123").Aggregate, domainName)
// Test // Test
res := callReduce(t, ctx, rawTx, handler, trustedDomainRemovedEvt) res := callReduce(t, rawTx, handler, trustedDomainRemovedEvt)
require.True(t, res) require.True(t, res)
// Verify // Verify
@@ -377,6 +377,24 @@ func (p *relationalTablesProjection) Reducers() []handler.AggregateReducer {
Event: instance.IDPRemovedEventType, Event: instance.IDPRemovedEventType,
Reduce: p.reduceIDPRemoved, Reduce: p.reduceIDPRemoved,
}, },
// Administrators
{
Event: instance.MemberAddedEventType,
Reduce: p.reduceInstanceAdminAdded,
},
{
Event: instance.MemberChangedEventType,
Reduce: p.reduceInstanceAdminChanged,
},
{
Event: instance.MemberRemovedEventType,
Reduce: p.reduceInstanceAdminRemoved,
},
{
Event: instance.MemberCascadeRemovedEventType,
Reduce: p.reduceInstanceAdminRemoved,
},
}, },
}, },
{ {
@@ -750,6 +768,24 @@ func (p *relationalTablesProjection) Reducers() []handler.AggregateReducer {
Event: org.IDPRemovedEventType, Event: org.IDPRemovedEventType,
Reduce: p.reduceIDPRemoved, Reduce: p.reduceIDPRemoved,
}, },
// Administrators
{
Event: org.MemberAddedEventType,
Reduce: p.reduceOrganizationAdminAdded,
},
{
Event: org.MemberChangedEventType,
Reduce: p.reduceOrganizationAdminChanged,
},
{
Event: org.MemberRemovedEventType,
Reduce: p.reduceOrganizationAdminRemoved,
},
{
Event: org.MemberCascadeRemovedEventType,
Reduce: p.reduceOrganizationAdminRemoved,
},
}, },
}, },
{ {
@@ -828,6 +864,40 @@ func (p *relationalTablesProjection) Reducers() []handler.AggregateReducer {
Event: project.RoleRemovedType, Event: project.RoleRemovedType,
Reduce: p.reduceProjectRoleRemoved, Reduce: p.reduceProjectRoleRemoved,
}, },
// Administrators
{
Event: project.MemberAddedEventType,
Reduce: p.reduceProjectAdminAdded,
},
{
Event: project.MemberChangedEventType,
Reduce: p.reduceProjectAdminChanged,
},
{
Event: project.MemberRemovedEventType,
Reduce: p.reduceProjectAdminRemoved,
},
{
Event: project.MemberCascadeRemovedEventType,
Reduce: p.reduceProjectAdminRemoved,
},
{
Event: project.GrantMemberAddedType,
Reduce: p.reduceProjectGrantAdminAdded,
},
{
Event: project.GrantMemberChangedType,
Reduce: p.reduceProjectGrantAdminChanged,
},
{
Event: project.GrantMemberRemovedType,
Reduce: p.reduceProjectGrantAdminRemoved,
},
{
Event: project.GrantMemberCascadeRemovedType,
Reduce: p.reduceProjectGrantAdminRemoved,
},
}, },
}, },
{ {
@@ -1,7 +1,6 @@
package projection package projection
import ( import (
"context"
"testing" "testing"
"time" "time"
@@ -23,13 +22,11 @@ func TestUserRelationalProjection_Reducers(t *testing.T) {
require.NoError(t, rawTx.Rollback()) require.NoError(t, rawTx.Rollback())
}) })
ctx := t.Context()
// create instance // create instance
instanceRepo := repository.InstanceRepository() instanceRepo := repository.InstanceRepository()
instanceID := gofakeit.UUID() instanceID := gofakeit.UUID()
orgID := gofakeit.UUID() orgID := gofakeit.UUID()
err := instanceRepo.Create(ctx, tx, &domain.Instance{ err := instanceRepo.Create(t.Context(), tx, &domain.Instance{
ID: instanceID, ID: instanceID,
Name: "test-instance", Name: "test-instance",
DefaultOrgID: orgID, DefaultOrgID: orgID,
@@ -38,7 +35,7 @@ func TestUserRelationalProjection_Reducers(t *testing.T) {
// create org // create org
orgRepo := repository.OrganizationRepository() orgRepo := repository.OrganizationRepository()
err = orgRepo.Create(ctx, tx, &domain.Organization{ err = orgRepo.Create(t.Context(), tx, &domain.Organization{
InstanceID: instanceID, InstanceID: instanceID,
ID: orgID, ID: orgID,
Name: "test-org", Name: "test-org",
@@ -54,22 +51,22 @@ func TestUserRelationalProjection_Reducers(t *testing.T) {
t.Run("reduce user.human.mfa.recoverycode.added event", func(t *testing.T) { t.Run("reduce user.human.mfa.recoverycode.added event", func(t *testing.T) {
// create user // create user
existingUserID := gofakeit.UUID() existingUserID := gofakeit.UUID()
existingUserAgg := createUser(t, ctx, tx, userRepo, instanceID, orgID, existingUserID) existingUserAgg := createUser(t, tx, userRepo, instanceID, orgID, existingUserID)
// set `user.human.mfa.recoverycode.added` event // set `user.human.mfa.recoverycode.added` event
recoveryCodes := []string{"code1", "code2", "code3"} recoveryCodes := []string{"code1", "code2", "code3"}
recoveryCodesAddedEvent := user.NewHumanRecoveryCodesAddedEvent( recoveryCodesAddedEvent := user.NewHumanRecoveryCodesAddedEvent(
ctx, t.Context(),
&existingUserAgg.Aggregate, &existingUserAgg.Aggregate,
recoveryCodes, recoveryCodes,
nil) nil)
// reduce the event // reduce the event
eventReduced := callReduce(t, ctx, rawTx, handler, recoveryCodesAddedEvent) eventReduced := callReduce(t, rawTx, handler, recoveryCodesAddedEvent)
require.True(t, eventReduced) require.True(t, eventReduced)
// assert that the recovery codes are stored // assert that the recovery codes are stored
gotUser, err := userRepo.Get(ctx, tx, database.WithCondition( gotUser, err := userRepo.Get(t.Context(), tx, database.WithCondition(
database.And( database.And(
userRepo.IDCondition(existingUserID), userRepo.IDCondition(existingUserID),
userRepo.InstanceIDCondition(instanceID), userRepo.InstanceIDCondition(instanceID),
@@ -84,26 +81,26 @@ func TestUserRelationalProjection_Reducers(t *testing.T) {
t.Run("reduce user.human.mfa.recoverycode.removed event", func(t *testing.T) { t.Run("reduce user.human.mfa.recoverycode.removed event", func(t *testing.T) {
// create user // create user
existingUserID := gofakeit.UUID() existingUserID := gofakeit.UUID()
existingUserAgg := createUser(t, ctx, tx, userRepo, instanceID, orgID, existingUserID) existingUserAgg := createUser(t, tx, userRepo, instanceID, orgID, existingUserID)
// add recovery codes to the user // add recovery codes to the user
_, err = userRepo.Update(ctx, tx, _, err = userRepo.Update(t.Context(), tx,
userRepo.PrimaryKeyCondition(instanceID, existingUserID), userRepo.PrimaryKeyCondition(instanceID, existingUserID),
userRepo.Human().AddRecoveryCodes([]string{"code1", "code2", "code3"})) userRepo.Human().AddRecoveryCodes([]string{"code1", "code2", "code3"}))
require.NoError(t, err) require.NoError(t, err)
// set `user.human.mfa.recoverycode.removed` event // set `user.human.mfa.recoverycode.removed` event
recoveryCodesRemovedEvent := user.NewHumanRecoveryCodeRemovedEvent( recoveryCodesRemovedEvent := user.NewHumanRecoveryCodeRemovedEvent(
ctx, t.Context(),
&existingUserAgg.Aggregate, &existingUserAgg.Aggregate,
nil) nil)
// reduce the event // reduce the event
eventReduced := callReduce(t, ctx, rawTx, handler, recoveryCodesRemovedEvent) eventReduced := callReduce(t, rawTx, handler, recoveryCodesRemovedEvent)
require.True(t, eventReduced) require.True(t, eventReduced)
// assert that the recovery codes are removed // assert that the recovery codes are removed
gotUser, err := userRepo.Get(ctx, tx, database.WithCondition( gotUser, err := userRepo.Get(t.Context(), tx, database.WithCondition(
database.And( database.And(
userRepo.IDCondition(existingUserID), userRepo.IDCondition(existingUserID),
userRepo.InstanceIDCondition(instanceID), userRepo.InstanceIDCondition(instanceID),
@@ -118,27 +115,27 @@ func TestUserRelationalProjection_Reducers(t *testing.T) {
t.Run("reduce user.human.mfa.recoverycode.check.succeeded event", func(t *testing.T) { t.Run("reduce user.human.mfa.recoverycode.check.succeeded event", func(t *testing.T) {
// create user // create user
existingUserID := gofakeit.UUID() existingUserID := gofakeit.UUID()
existingUserAgg := createUser(t, ctx, tx, userRepo, instanceID, orgID, existingUserID) existingUserAgg := createUser(t, tx, userRepo, instanceID, orgID, existingUserID)
// add recovery codes to the user // add recovery codes to the user
_, err = userRepo.Update(ctx, tx, _, err = userRepo.Update(t.Context(), tx,
userRepo.PrimaryKeyCondition(instanceID, existingUserID), userRepo.PrimaryKeyCondition(instanceID, existingUserID),
userRepo.Human().AddRecoveryCodes([]string{"code1", "code2", "code3"})) userRepo.Human().AddRecoveryCodes([]string{"code1", "code2", "code3"}))
require.NoError(t, err) require.NoError(t, err)
// set `user.human.mfa.recoverycode.check.succeeded` event // set `user.human.mfa.recoverycode.check.succeeded` event
recoveryCodeCheckSucceededEvent := user.NewHumanRecoveryCodeCheckSucceededEvent( recoveryCodeCheckSucceededEvent := user.NewHumanRecoveryCodeCheckSucceededEvent(
ctx, t.Context(),
&existingUserAgg.Aggregate, &existingUserAgg.Aggregate,
"code1", "code1",
nil) nil)
// reduce the event // reduce the event
eventReduced := callReduce(t, ctx, rawTx, handler, recoveryCodeCheckSucceededEvent) eventReduced := callReduce(t, rawTx, handler, recoveryCodeCheckSucceededEvent)
require.True(t, eventReduced) require.True(t, eventReduced)
// assert that the recovery code is removed and the last_successfully_checked_at timestamp is set // assert that the recovery code is removed and the last_successfully_checked_at timestamp is set
gotUser, err := userRepo.Get(ctx, tx, database.WithCondition( gotUser, err := userRepo.Get(t.Context(), tx, database.WithCondition(
database.And( database.And(
userRepo.IDCondition(existingUserID), userRepo.IDCondition(existingUserID),
userRepo.InstanceIDCondition(instanceID), userRepo.InstanceIDCondition(instanceID),
@@ -153,27 +150,27 @@ func TestUserRelationalProjection_Reducers(t *testing.T) {
t.Run("reduce user.human.mfa.recoverycode.check.failed event", func(t *testing.T) { t.Run("reduce user.human.mfa.recoverycode.check.failed event", func(t *testing.T) {
// create user // create user
existingUserID := gofakeit.UUID() existingUserID := gofakeit.UUID()
existingUserAgg := createUser(t, ctx, tx, userRepo, instanceID, orgID, existingUserID) existingUserAgg := createUser(t, tx, userRepo, instanceID, orgID, existingUserID)
// add recovery codes to the user // add recovery codes to the user
_, err = userRepo.Update(ctx, tx, _, err = userRepo.Update(t.Context(), tx,
userRepo.PrimaryKeyCondition(instanceID, existingUserID), userRepo.PrimaryKeyCondition(instanceID, existingUserID),
userRepo.Human().AddRecoveryCodes([]string{"code1", "code2", "code3"})) userRepo.Human().AddRecoveryCodes([]string{"code1", "code2", "code3"}))
require.NoError(t, err) require.NoError(t, err)
// set `user.human.mfa.recoverycode.check.failed` event // set `user.human.mfa.recoverycode.check.failed` event
recoveryCodeCheckFailedEvent := user.NewHumanRecoveryCodeCheckFailedEvent( recoveryCodeCheckFailedEvent := user.NewHumanRecoveryCodeCheckFailedEvent(
ctx, t.Context(),
&existingUserAgg.Aggregate, &existingUserAgg.Aggregate,
nil, nil,
) )
// reduce the event // reduce the event
eventReduced := callReduce(t, ctx, rawTx, handler, recoveryCodeCheckFailedEvent) eventReduced := callReduce(t, rawTx, handler, recoveryCodeCheckFailedEvent)
require.True(t, eventReduced) require.True(t, eventReduced)
// assert that the recovery code failed_attempts is incremented // assert that the recovery code failed_attempts is incremented
gotUser, err := userRepo.Get(ctx, tx, database.WithCondition( gotUser, err := userRepo.Get(t.Context(), tx, database.WithCondition(
database.And( database.And(
userRepo.IDCondition(existingUserID), userRepo.IDCondition(existingUserID),
userRepo.InstanceIDCondition(instanceID), userRepo.InstanceIDCondition(instanceID),
@@ -187,7 +184,6 @@ func TestUserRelationalProjection_Reducers(t *testing.T) {
} }
func createUser(t *testing.T, func createUser(t *testing.T,
ctx context.Context,
tx *sql.Transaction, tx *sql.Transaction,
userRepo domain.UserRepository, userRepo domain.UserRepository,
instanceID, instanceID,
@@ -197,7 +193,7 @@ func createUser(t *testing.T,
userAgg := user.NewAggregate(userID, orgID) userAgg := user.NewAggregate(userID, orgID)
userAgg.InstanceID = instanceID userAgg.InstanceID = instanceID
err := userRepo.Create(ctx, tx, &domain.User{ err := userRepo.Create(t.Context(), tx, &domain.User{
InstanceID: instanceID, InstanceID: instanceID,
OrganizationID: orgID, OrganizationID: orgID,
ID: userID, ID: userID,