mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
feat: add administrator repository (#11803)
Introduces the `administrators` relational table and repository layer for the v3 backend, unifying instance, organization, project, and project-grant membership into a single scoped model. Closes #10220 ## Changes - **Domain**: `Administrator` struct, `AdministratorScope` enum, `AdministratorRepository` interface with typed CRUD, column, condition, and change helpers. Generated enumer and GoMock. - **Migration** (`016`): `zitadel.administrators` table with a generated `id`, scope-alignment check constraint, cascading foreign keys, and a child `administrator_roles` table. - **Repository**: CTE-based role mutations (`AddRole`, `RemoveRole`, `SetRoles`) for atomicity. Roles aggregated via `ARRAY_AGG`. - **Projection**: Event reducers for `member.added/changed/removed/cascade_removed` across all four admin scopes. - **Fixes**: `Rows.Close()` error wrapping; `TextArray` nil-safe scan + full `pgtype` array interface; `authorization` and `projectGrant` scan helpers fixed for `ARRAY_AGG` under `database/sql`; removed unused `ctx` parameter from `callReduce`. --------- Co-authored-by: Marco Ardizzone <[email protected]>
This commit is contained in:
co-authored by
Marco Ardizzone
parent
6d90a120a6
commit
f138082b64
+10
-7
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS zitadel.administrator_roles;
|
||||||
|
DROP TABLE IF EXISTS zitadel.administrators;
|
||||||
|
DROP TYPE IF EXISTS zitadel.administrator_scope;
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
CREATE TYPE zitadel.administrator_scope AS ENUM (
|
||||||
|
'instance',
|
||||||
|
'organization',
|
||||||
|
'project',
|
||||||
|
'project_grant'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE zitadel.administrators(
|
||||||
|
instance_id TEXT NOT NULL
|
||||||
|
, user_id TEXT NOT NULL
|
||||||
|
, scope zitadel.administrator_scope NOT NULL
|
||||||
|
|
||||||
|
, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
|
||||||
|
, organization_id TEXT
|
||||||
|
, project_id TEXT
|
||||||
|
, project_grant_id TEXT
|
||||||
|
|
||||||
|
, id TEXT GENERATED ALWAYS AS (
|
||||||
|
user_id || ':' ||
|
||||||
|
CASE scope
|
||||||
|
WHEN 'instance'::zitadel.administrator_scope
|
||||||
|
THEN 'instance:' || instance_id
|
||||||
|
WHEN 'organization'::zitadel.administrator_scope
|
||||||
|
THEN 'organization:' || organization_id
|
||||||
|
WHEN 'project'::zitadel.administrator_scope
|
||||||
|
THEN 'project:' || project_id
|
||||||
|
WHEN 'project_grant'::zitadel.administrator_scope
|
||||||
|
THEN 'project_grant:' || project_grant_id
|
||||||
|
END
|
||||||
|
) STORED
|
||||||
|
|
||||||
|
, CONSTRAINT administrators_scope_alignment_chk CHECK (
|
||||||
|
(scope = 'instance'
|
||||||
|
AND organization_id IS NULL
|
||||||
|
AND project_id IS NULL
|
||||||
|
AND project_grant_id IS NULL)
|
||||||
|
OR
|
||||||
|
(scope = 'organization'
|
||||||
|
AND organization_id IS NOT NULL
|
||||||
|
AND project_id IS NULL
|
||||||
|
AND project_grant_id IS NULL)
|
||||||
|
OR
|
||||||
|
(scope = 'project'
|
||||||
|
AND organization_id IS NULL
|
||||||
|
AND project_id IS NOT NULL
|
||||||
|
AND project_grant_id IS NULL)
|
||||||
|
OR
|
||||||
|
(scope = 'project_grant'
|
||||||
|
AND organization_id IS NULL
|
||||||
|
AND project_id IS NULL
|
||||||
|
AND project_grant_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
|
||||||
|
, PRIMARY KEY (instance_id, id)
|
||||||
|
, FOREIGN KEY (instance_id) REFERENCES zitadel.instances(id) ON DELETE CASCADE
|
||||||
|
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
|
||||||
|
, FOREIGN KEY (instance_id, organization_id) REFERENCES zitadel.organizations(instance_id, id) ON DELETE CASCADE
|
||||||
|
, FOREIGN KEY (instance_id, project_id) REFERENCES zitadel.projects(instance_id, id) ON DELETE CASCADE
|
||||||
|
, FOREIGN KEY (instance_id, project_grant_id) REFERENCES zitadel.project_grants(instance_id, id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE zitadel.administrator_roles(
|
||||||
|
instance_id TEXT NOT NULL
|
||||||
|
, administrator_id TEXT NOT NULL
|
||||||
|
, role_name TEXT NOT NULL CHECK (role_name <> '')
|
||||||
|
|
||||||
|
, PRIMARY KEY (instance_id, administrator_id, role_name)
|
||||||
|
, FOREIGN KEY (instance_id, administrator_id) REFERENCES zitadel.administrators(instance_id, id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_set_updated_at
|
||||||
|
BEFORE UPDATE
|
||||||
|
ON zitadel.administrators
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (NEW.updated_at IS NULL)
|
||||||
|
EXECUTE FUNCTION zitadel.set_updated_at();
|
||||||
@@ -74,5 +74,5 @@ func (r *Rows) CollectExactlyOneRow(dest any) (err error) {
|
|||||||
// Close implements [database.Rows].
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user