diff --git a/apps/login/acceptance/oidcrp/main.go b/apps/login/acceptance/oidcrp/main.go index 72ae5f57e..83efa12a7 100644 --- a/apps/login/acceptance/oidcrp/main.go +++ b/apps/login/acceptance/oidcrp/main.go @@ -20,7 +20,6 @@ import ( "github.com/google/uuid" "github.com/sirupsen/logrus" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/client/rp" httphelper "github.com/zitadel/oidc/v3/pkg/http" diff --git a/backend/v3/domain/project.go b/backend/v3/domain/project.go new file mode 100644 index 000000000..885744b2b --- /dev/null +++ b/backend/v3/domain/project.go @@ -0,0 +1,191 @@ +package domain + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +//go:generate enumer -type ProjectState -transform lower -trimprefix ProjectState -sql +type ProjectState uint8 + +const ( + ProjectStateActive ProjectState = iota + ProjectStateInactive +) + +type Project struct { + InstanceID string `json:"instanceId,omitempty" db:"instance_id"` + OrganizationID string `json:"organizationId,omitempty" db:"organization_id"` + ID string `json:"id,omitempty" db:"id"` + CreatedAt time.Time `json:"createdAt,omitzero" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitzero" db:"updated_at"` + Name string `json:"name,omitempty" db:"name"` + State ProjectState `json:"state,omitempty" db:"state"` + ShouldAssertRole bool `json:"shouldAssertRole,omitempty" db:"should_assert_role"` + IsAuthorizationRequired bool `json:"isAuthorizationRequired,omitempty" db:"is_authorization_required"` + IsProjectAccessRequired bool `json:"isProjectAccessRequired,omitempty" db:"is_project_access_required"` + UsedLabelingSettingOwner int16 `json:"usedLabelingSettingOwner,omitempty" db:"used_labeling_setting_owner"` +} + +type projectColumns interface { + // PrimaryKeyColumns returns the columns for the primary key fields + PrimaryKeyColumns() []database.Column + // InstanceIDColumn returns the column for the instance id field + InstanceIDColumn() database.Column + // OrganizationIDColumn returns the column for the organization id field + OrganizationIDColumn() database.Column + // IDColumn returns the column for the id field. + IDColumn() database.Column + // CreatedAtColumn returns the column for the created at field. + CreatedAtColumn() database.Column + // UpdatedAtColumn returns the column for the updated at field. + UpdatedAtColumn() database.Column + // NameColumn returns the column for the name field. + NameColumn() database.Column + // StateColumn returns the column for the state field. + StateColumn() database.Column + // ShouldAssertRoleColumn returns the column for the should assert role field. + ShouldAssertRoleColumn() database.Column + // IsAuthorizationRequiredColumn returns the column for the is authorization required field. + IsAuthorizationRequiredColumn() database.Column + // IsProjectAccessRequiredColumn returns the column for the is project access required field. + IsProjectAccessRequiredColumn() database.Column + // UsedLabelingSettingOwnerColumn returns the column for the used labeling setting owner field. + UsedLabelingSettingOwnerColumn() database.Column +} + +type projectConditions interface { + // PrimaryKeyCondition returns a filter on the primary key fields. + PrimaryKeyCondition(instanceID, projectID string) database.Condition + // InstanceIDCondition returns a filter on the instance id field. + InstanceIDCondition(instanceID string) database.Condition + // OrganizationIDCondition returns a filter on the organization id field. + OrganizationIDCondition(organizationID string) database.Condition + // IDCondition returns an equal filter on the id field. + IDCondition(projectID string) database.Condition + // NameCondition returns a filter on the name field. + NameCondition(op database.TextOperation, name string) database.Condition + // StateCondition returns a filter on the state field. + StateCondition(state ProjectState) database.Condition +} + +type projectChanges interface { + // SetUpdatedAt sets the updated at column. + // Only use this when reducing events, + // during regular updates the DB sets this column automatically. + SetUpdatedAt(updatedAt time.Time) database.Change + // SetName sets the name column. + SetName(name string) database.Change + // SetState sets the state column. + SetState(state ProjectState) database.Change + // SetShouldAssertRole sets the should assert role column. + SetShouldAssertRole(shouldAssertRole bool) database.Change + // SetIsAuthorizationRequired sets the is authorization required column. + SetIsAuthorizationRequired(isAuthorizationRequired bool) database.Change + // SetIsProjectAccessRequired sets the is project access required column. + SetIsProjectAccessRequired(isProjectAccessRequired bool) database.Change + // SetUsedLabelingSettingOwner sets the used labeling setting owner column. + SetUsedLabelingSettingOwner(usedLabelingSettingOwner int16) database.Change +} + +// ProjectRepository manages projects and project roles. +type ProjectRepository interface { + projectColumns + projectConditions + projectChanges + + // Get a single project. An error is returned if not exactly one project is found. + Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*Project, error) + // List projects. An empty list is returned if no projects are found. + List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*Project, error) + // Create a new project. + Create(ctx context.Context, client database.QueryExecutor, project *Project) error + // Update an existing project. + // The condition must include the instanceID and ID of the project to update. + Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) + // Delete an existing project. + // The condition must include the instanceID and ID of the project to delete. + Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) + + // Role returns the sub-repository for project roles. + Role() ProjectRoleRepository +} + +type ProjectRole struct { + InstanceID string `json:"instanceId,omitempty" db:"instance_id"` + OrganizationID string `json:"organizationId,omitempty" db:"organization_id"` + ProjectID string `json:"projectId,omitempty" db:"project_id"` + CreatedAt time.Time `json:"createdAt,omitzero" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitzero" db:"updated_at"` + Key string `json:"key,omitempty" db:"key"` + DisplayName string `json:"displayName,omitempty" db:"display_name"` + RoleGroup *string `json:"roleGroup,omitempty" db:"role_group"` +} + +type projectRoleColumns interface { + // PrimaryKeyColumns returns the columns for the primary key fields + PrimaryKeyColumns() []database.Column + // InstanceIDColumn returns the column for the instance id field + InstanceIDColumn() database.Column + // OrganizationIDColumn returns the column for the organization id field + OrganizationIDColumn() database.Column + // ProjectIDColumn returns the column for the project id field + ProjectIDColumn() database.Column + // CreatedAtColumn returns the column for the created at field. + CreatedAtColumn() database.Column + // UpdatedAtColumn returns the column for the updated at field. + UpdatedAtColumn() database.Column + // KeyColumn returns the column for the key field. + KeyColumn() database.Column + // DisplayNameColumn returns the column for the display name field. + DisplayNameColumn() database.Column + // RoleGroupColumn returns the column for the role group field. + RoleGroupColumn() database.Column +} + +type projectRoleConditions interface { + // PrimaryKeyCondition returns a filter on the primary key fields. + PrimaryKeyCondition(instanceID, projectID, key string) database.Condition + // InstanceIDCondition returns an equal filter on the instance id field. + InstanceIDCondition(instanceID string) database.Condition + // ProjectIDCondition returns an equal filter on the project id field. + ProjectIDCondition(projectID string) database.Condition + // KeyCondition returns an equal filter on the key field. + KeyCondition(key string) database.Condition + // DisplayNameCondition returns a filter on the display name field. + DisplayNameCondition(op database.TextOperation, displayName string) database.Condition + // RoleGroupCondition returns a filter on the role group field. + RoleGroupCondition(op database.TextOperation, roleGroup string) database.Condition +} + +type projectRoleChanges interface { + // SetUpdatedAt sets the updated at column. + // Only use this when reducing events, + // during regular updates the DB sets this column automatically. + SetUpdatedAt(updatedAt time.Time) database.Change + // SetDisplayName sets the display name column. + SetDisplayName(displayName string) database.Change + // SetRoleGroup sets the role group column. + SetRoleGroup(roleGroup string) database.Change +} + +type ProjectRoleRepository interface { + projectRoleColumns + projectRoleConditions + projectRoleChanges + + // Get a single project role. An error is returned if not exactly one project role is found. + Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*ProjectRole, error) + // List project roles. An empty list is returned if no project roles are found. + List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*ProjectRole, error) + // Create a new project role. + Create(ctx context.Context, client database.QueryExecutor, role *ProjectRole) error + // Update an existing project role. + // The condition must include the instanceID, projectID and key of the project role to update. + Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) + // Delete an existing project role. + // The condition must include the instanceID, projectID and key of the project role to delete. + Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) +} diff --git a/backend/v3/domain/projectstate_enumer.go b/backend/v3/domain/projectstate_enumer.go new file mode 100644 index 000000000..fe336e858 --- /dev/null +++ b/backend/v3/domain/projectstate_enumer.go @@ -0,0 +1,109 @@ +// Code generated by "enumer -type ProjectState -transform lower -trimprefix ProjectState -sql"; DO NOT EDIT. + +package domain + +import ( + "database/sql/driver" + "fmt" + "strings" +) + +const _ProjectStateName = "activeinactive" + +var _ProjectStateIndex = [...]uint8{0, 6, 14} + +const _ProjectStateLowerName = "activeinactive" + +func (i ProjectState) String() string { + if i >= ProjectState(len(_ProjectStateIndex)-1) { + return fmt.Sprintf("ProjectState(%d)", i) + } + return _ProjectStateName[_ProjectStateIndex[i]:_ProjectStateIndex[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 _ProjectStateNoOp() { + var x [1]struct{} + _ = x[ProjectStateActive-(0)] + _ = x[ProjectStateInactive-(1)] +} + +var _ProjectStateValues = []ProjectState{ProjectStateActive, ProjectStateInactive} + +var _ProjectStateNameToValueMap = map[string]ProjectState{ + _ProjectStateName[0:6]: ProjectStateActive, + _ProjectStateLowerName[0:6]: ProjectStateActive, + _ProjectStateName[6:14]: ProjectStateInactive, + _ProjectStateLowerName[6:14]: ProjectStateInactive, +} + +var _ProjectStateNames = []string{ + _ProjectStateName[0:6], + _ProjectStateName[6:14], +} + +// ProjectStateString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func ProjectStateString(s string) (ProjectState, error) { + if val, ok := _ProjectStateNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _ProjectStateNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to ProjectState values", s) +} + +// ProjectStateValues returns all values of the enum +func ProjectStateValues() []ProjectState { + return _ProjectStateValues +} + +// ProjectStateStrings returns a slice of all String values of the enum +func ProjectStateStrings() []string { + strs := make([]string, len(_ProjectStateNames)) + copy(strs, _ProjectStateNames) + return strs +} + +// IsAProjectState returns "true" if the value is listed in the enum definition. "false" otherwise +func (i ProjectState) IsAProjectState() bool { + for _, v := range _ProjectStateValues { + if i == v { + return true + } + } + return false +} + +func (i ProjectState) Value() (driver.Value, error) { + return i.String(), nil +} + +func (i *ProjectState) 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 ProjectState: %[1]T(%[1]v)", value) + } + + val, err := ProjectStateString(str) + if err != nil { + return err + } + + *i = val + return nil +} diff --git a/backend/v3/storage/database/dialect/postgres/migration/006_projects_table.go b/backend/v3/storage/database/dialect/postgres/migration/006_projects_table.go new file mode 100644 index 000000000..2dede985e --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/006_projects_table.go @@ -0,0 +1,16 @@ +package migration + +import ( + _ "embed" +) + +var ( + //go:embed 006_projects_table/up.sql + up006ProjectsTable string + //go:embed 006_projects_table/down.sql + down006ProjectsTable string +) + +func init() { + registerSQLMigration(6, up006ProjectsTable, down006ProjectsTable) +} diff --git a/backend/v3/storage/database/dialect/postgres/migration/006_projects_table/down.sql b/backend/v3/storage/database/dialect/postgres/migration/006_projects_table/down.sql new file mode 100644 index 000000000..b19e61d44 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/006_projects_table/down.sql @@ -0,0 +1,5 @@ +DROP TRIGGER trigger_set_updated_at ON zitadel.project_roles; +DROP TRIGGER trigger_set_updated_at ON zitadel.projects; +DROP TABLE zitadel.project_roles; +DROP TABLE zitadel.projects; +DROP TYPE zitadel.project_state; diff --git a/backend/v3/storage/database/dialect/postgres/migration/006_projects_table/up.sql b/backend/v3/storage/database/dialect/postgres/migration/006_projects_table/up.sql new file mode 100644 index 000000000..e069f7a07 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/006_projects_table/up.sql @@ -0,0 +1,61 @@ +CREATE TYPE zitadel.project_state AS ENUM ( + 'active', + 'inactive' +); + +CREATE TABLE zitadel.projects( + instance_id TEXT NOT NULL + , organization_id TEXT NOT NULL + , id TEXT NOT NULL CHECK (id <> '') + + , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + , updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + + , name TEXT NOT NULL CHECK (name <> '') + , state zitadel.project_state NOT NULL + -- API: project_role_assertion + , should_assert_role BOOLEAN NOT NULL DEFAULT FALSE + -- API: authorization_required + , is_authorization_required BOOLEAN NOT NULL DEFAULT FALSE + -- API: project_access_required + , is_project_access_required BOOLEAN NOT NULL DEFAULT FALSE + --API: private_labeling_setting + , used_labeling_setting_owner SMALLINT + + , PRIMARY KEY (instance_id, id) + , UNIQUE (instance_id, organization_id, id) + , FOREIGN KEY (instance_id, organization_id) REFERENCES zitadel.organizations(instance_id, id) ON DELETE CASCADE +); + +CREATE TABLE zitadel.project_roles( + instance_id TEXT NOT NULL + , organization_id TEXT NOT NULL + , project_id TEXT NOT NULL + + , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + , updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + + -- API: role_key + , key TEXT NOT NULL CHECK (key <> '') + -- API: display_name + , display_name TEXT NOT NULL CHECK (display_name <> '') + -- API: group + -- group is a reserved keyword in PostgreSQL + , role_group TEXT + + , PRIMARY KEY (instance_id, project_id, key) + , UNIQUE (instance_id, organization_id, project_id, key) + , FOREIGN KEY (instance_id, organization_id, project_id) REFERENCES zitadel.projects(instance_id, organization_id, id) ON DELETE CASCADE +); + +CREATE TRIGGER trigger_set_updated_at +BEFORE UPDATE ON zitadel.projects +FOR EACH ROW +WHEN (NEW.updated_at IS NULL) +EXECUTE FUNCTION zitadel.set_updated_at(); + +CREATE TRIGGER trigger_set_updated_at +BEFORE UPDATE ON zitadel.project_roles +FOR EACH ROW +WHEN (NEW.updated_at IS NULL) +EXECUTE FUNCTION zitadel.set_updated_at(); diff --git a/backend/v3/storage/database/events_testing/events_test.go b/backend/v3/storage/database/events_testing/events_test.go index cf279b897..008cef489 100644 --- a/backend/v3/storage/database/events_testing/events_test.go +++ b/backend/v3/storage/database/events_testing/events_test.go @@ -19,20 +19,22 @@ import ( v2beta "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + v2beta_project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" "github.com/zitadel/zitadel/pkg/grpc/system" ) const ConnString = "host=localhost port=5432 user=zitadel password=zitadel dbname=zitadel sslmode=disable" var ( - dbPool *pgxpool.Pool - CTX context.Context - IAMCTX context.Context - Instance *integration.Instance - SystemClient system.SystemServiceClient - OrgClient v2beta_org.OrganizationServiceClient - AdminClient admin.AdminServiceClient - MgmtClient mgmt.ManagementServiceClient + dbPool *pgxpool.Pool + CTX context.Context + IAMCTX context.Context + Instance *integration.Instance + SystemClient system.SystemServiceClient + OrgClient v2beta_org.OrganizationServiceClient + ProjectClient v2beta_project.ProjectServiceClient + AdminClient admin.AdminServiceClient + MgmtClient mgmt.ManagementServiceClient ) var pool database.Pool @@ -48,6 +50,7 @@ func TestMain(m *testing.M) { IAMCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) SystemClient = integration.SystemClient() OrgClient = Instance.Client.OrgV2beta + ProjectClient = Instance.Client.Projectv2Beta AdminClient = Instance.Client.Admin MgmtClient = Instance.Client.Mgmt diff --git a/backend/v3/storage/database/events_testing/project_role_test.go b/backend/v3/storage/database/events_testing/project_role_test.go new file mode 100644 index 000000000..27c1a82ea --- /dev/null +++ b/backend/v3/storage/database/events_testing/project_role_test.go @@ -0,0 +1,94 @@ +//go:build integration + +package events_test + +import ( + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + "github.com/zitadel/zitadel/internal/integration" + v2beta_project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func TestServer_ProjectRoleReduces(t *testing.T) { + instanceID := Instance.ID() + orgID := Instance.DefaultOrg.Id + roleRepo := repository.ProjectRepository().Role() + + projectRes, err := ProjectClient.CreateProject(CTX, &v2beta_project.CreateProjectRequest{ + OrganizationId: orgID, + Name: integration.ProjectName(), + ProjectRoleAssertion: true, + AuthorizationRequired: true, + ProjectAccessRequired: true, + }) + require.NoError(t, err) + projectID := projectRes.GetId() + _, err = ProjectClient.AddProjectRole(CTX, &v2beta_project.AddProjectRoleRequest{ + ProjectId: projectID, + RoleKey: "key", + DisplayName: "display name", + Group: proto.String("group"), + }) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + + t.Run("add project role reduces", func(t *testing.T) { + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbRole, err := roleRepo.Get(CTX, pool, database.WithCondition( + roleRepo.PrimaryKeyCondition(instanceID, projectID, "key"), + )) + require.NoError(collect, err) + require.NotNil(collect, dbRole) + assert.Equal(collect, projectID, dbRole.ProjectID) + assert.Equal(collect, "key", dbRole.Key) + assert.Equal(collect, "display name", dbRole.DisplayName) + assert.Equal(collect, gu.Ptr("group"), dbRole.RoleGroup) + assert.NotNil(collect, dbRole.CreatedAt) + assert.NotNil(collect, dbRole.UpdatedAt) + }, retryDuration, tick, "project role not found within %v: %v", retryDuration, err) + }) + + t.Run("update project role reduces", func(t *testing.T) { + _, err := ProjectClient.UpdateProjectRole(CTX, &v2beta_project.UpdateProjectRoleRequest{ + ProjectId: projectID, + RoleKey: "key", + DisplayName: proto.String("new display name"), + Group: proto.String("new group"), + }) + require.NoError(t, err) + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbRole, err := roleRepo.Get(CTX, pool, database.WithCondition( + roleRepo.PrimaryKeyCondition(instanceID, projectID, "key"), + )) + require.NoError(collect, err) + require.NotNil(collect, dbRole) + assert.Equal(collect, "new display name", dbRole.DisplayName) + assert.Equal(collect, gu.Ptr("new group"), dbRole.RoleGroup) + }, retryDuration, tick, "project role not updated within %v: %v", retryDuration, err) + }) + + t.Run("remove project role reduces", func(t *testing.T) { + _, err := ProjectClient.RemoveProjectRole(CTX, &v2beta_project.RemoveProjectRoleRequest{ + ProjectId: projectID, + RoleKey: "key", + }) + require.NoError(t, err) + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := roleRepo.Get(CTX, pool, database.WithCondition( + roleRepo.PrimaryKeyCondition(instanceID, projectID, "key"), + )) + require.ErrorIs(collect, err, database.NewNoRowFoundError(nil)) + }, retryDuration, tick, "project role not deleted within %v: %v", retryDuration, err) + }) +} diff --git a/backend/v3/storage/database/events_testing/project_test.go b/backend/v3/storage/database/events_testing/project_test.go new file mode 100644 index 000000000..bce6f7dcd --- /dev/null +++ b/backend/v3/storage/database/events_testing/project_test.go @@ -0,0 +1,120 @@ +//go:build integration + +package events_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" + v2beta_project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func TestServer_ProjectReduces(t *testing.T) { + instanceID := Instance.ID() + orgID := Instance.DefaultOrg.Id + projectRepo := repository.ProjectRepository() + + projectName := integration.ProjectName() + createRes, err := ProjectClient.CreateProject(CTX, &v2beta_project.CreateProjectRequest{ + OrganizationId: orgID, + Name: projectName, + ProjectRoleAssertion: true, + AuthorizationRequired: true, + ProjectAccessRequired: true, + }) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + + t.Run("create project reduces", func(t *testing.T) { + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbProject, err := projectRepo.Get(CTX, pool, database.WithCondition( + projectRepo.PrimaryKeyCondition(instanceID, createRes.GetId()), + )) + require.NoError(collect, err) + assert.Equal(collect, createRes.GetId(), dbProject.ID) + assert.Equal(collect, orgID, dbProject.OrganizationID) + assert.Equal(collect, projectName, dbProject.Name) + assert.Equal(collect, domain.ProjectStateActive, dbProject.State) + assert.True(collect, dbProject.ShouldAssertRole) + assert.True(collect, dbProject.IsAuthorizationRequired) + assert.True(collect, dbProject.IsProjectAccessRequired) + assert.NotNil(collect, dbProject.CreatedAt) + assert.NotNil(collect, dbProject.UpdatedAt) + }, retryDuration, tick, "project not found within %v: %v", retryDuration, err) + }) + + t.Run("update project reduces", func(t *testing.T) { + _, err := ProjectClient.UpdateProject(CTX, &v2beta_project.UpdateProjectRequest{ + Id: createRes.GetId(), + Name: gu.Ptr("new name"), + ProjectRoleAssertion: gu.Ptr(false), + ProjectRoleCheck: gu.Ptr(false), + HasProjectCheck: gu.Ptr(false), + PrivateLabelingSetting: gu.Ptr(v2beta_project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_ALLOW_LOGIN_USER_RESOURCE_OWNER_POLICY), + }) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + dbProject, err := projectRepo.Get(CTX, pool, database.WithCondition( + projectRepo.PrimaryKeyCondition(instanceID, createRes.GetId()), + )) + require.NoError(collect, err) + assert.Equal(collect, "new name", dbProject.Name) + assert.False(collect, dbProject.ShouldAssertRole) + assert.False(collect, dbProject.IsAuthorizationRequired) + assert.False(collect, dbProject.IsProjectAccessRequired) + assert.Equal(collect, int16(2), dbProject.UsedLabelingSettingOwner) + }, retryDuration, tick, "project not updated within %v: %v", retryDuration, err) + }) + + t.Run("(de)activate project reduces", func(t *testing.T) { + _, err := ProjectClient.DeactivateProject(CTX, &v2beta_project.DeactivateProjectRequest{ + Id: createRes.GetId(), + }) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + dbProject, err := projectRepo.Get(CTX, pool, database.WithCondition( + projectRepo.PrimaryKeyCondition(instanceID, createRes.GetId()), + )) + require.NoError(collect, err) + assert.Equal(collect, domain.ProjectStateInactive, dbProject.State) + }, retryDuration, tick, "project not deactivated within %v: %v", retryDuration, err) + + _, err = ProjectClient.ActivateProject(CTX, &v2beta_project.ActivateProjectRequest{ + Id: createRes.GetId(), + }) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + dbProject, err := projectRepo.Get(CTX, pool, database.WithCondition( + projectRepo.PrimaryKeyCondition(instanceID, createRes.GetId()), + )) + require.NoError(collect, err) + assert.Equal(collect, domain.ProjectStateActive, dbProject.State) + }, retryDuration, tick, "project not activated within %v: %v", retryDuration, err) + }) + + t.Run("delete project reduces", func(t *testing.T) { + _, err := ProjectClient.DeleteProject(CTX, &v2beta_project.DeleteProjectRequest{ + Id: createRes.GetId(), + }) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := projectRepo.Get(CTX, pool, database.WithCondition( + projectRepo.PrimaryKeyCondition(instanceID, createRes.GetId()), + )) + require.ErrorIs(collect, err, database.NewNoRowFoundError(nil)) + }, retryDuration, tick, "project not deleted within %v: %v", retryDuration, err) + }) +} diff --git a/backend/v3/storage/database/repository/doc.go b/backend/v3/storage/database/repository/doc.go index 675c5df3a..523cc321d 100644 --- a/backend/v3/storage/database/repository/doc.go +++ b/backend/v3/storage/database/repository/doc.go @@ -1,5 +1,13 @@ // Package repository implements the repositories defined in the domain package. // The repositories are used by the domain package to access the database. -// the inheritance.sql file is me over-engineering table inheritance. +// +// Repository methods may require a minimal set of columns in the [database.Condition] to be passed. +// This is to ensure that the repository methods are used correctly and do not affect more rows than intended. +// For example: +// - The instance ID is almost always required to ensure that only rows of the correct instance are affected. +// - Update and Delete methods often require the columns from the primary key to ensure that only one row is affected. +// The required columns are checked during the call and an error is returned if they are not present. + +// the inheritance.sql file is me(silvan) over-engineering table inheritance. // I would create a user table which is inherited by human_user and machine_user and the same for objects like idps. package repository diff --git a/backend/v3/storage/database/repository/project.go b/backend/v3/storage/database/repository/project.go new file mode 100644 index 000000000..ce27faf63 --- /dev/null +++ b/backend/v3/storage/database/repository/project.go @@ -0,0 +1,245 @@ +package repository + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +// ------------------------------------------------------------- +// repository +// ------------------------------------------------------------- + +type project struct{} + +// ProjectRepository manages projects and project roles. +func ProjectRepository() domain.ProjectRepository { + return project{} +} + +func (project) Role() domain.ProjectRoleRepository { + return projectRole{} +} + +func (p project) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.Project, error) { + builder, err := p.prepareQuery(opts) + if err != nil { + return nil, err + } + return getOne[domain.Project](ctx, client, builder) +} + +func (p project) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.Project, error) { + builder, err := p.prepareQuery(opts) + if err != nil { + return nil, err + } + return getMany[domain.Project](ctx, client, builder) +} + +const insertProjectStmt = `INSERT INTO zitadel.projects( + instance_id, organization_id, id, name, state, should_assert_role, is_authorization_required, is_project_access_required, used_labeling_setting_owner +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING created_at, updated_at` + +func (project) Create(ctx context.Context, client database.QueryExecutor, project *domain.Project) error { + builder := database.NewStatementBuilder(insertProjectStmt, + project.InstanceID, + project.OrganizationID, + project.ID, + project.Name, + project.State, + project.ShouldAssertRole, + project.IsAuthorizationRequired, + project.IsProjectAccessRequired, + project.UsedLabelingSettingOwner, + ) + return client.QueryRow(ctx, builder.String(), builder.Args()...). + Scan(&project.CreatedAt, &project.UpdatedAt) +} + +func (p project) 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 := checkPKCondition(p, condition); err != nil { + return 0, err + } + if !database.Changes(changes).IsOnColumn(p.UpdatedAtColumn()) { + changes = append(changes, database.NewChange(p.UpdatedAtColumn(), database.NullInstruction)) + } + builder := database.NewStatementBuilder(`UPDATE zitadel.projects SET `) + database.Changes(changes).Write(builder) + writeCondition(builder, condition) + + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +func (p project) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { + if err := checkPKCondition(p, condition); err != nil { + return 0, err + } + + builder := database.NewStatementBuilder(`DELETE FROM zitadel.projects `) + writeCondition(builder, condition) + + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +// ------------------------------------------------------------- +// changes +// ------------------------------------------------------------- + +func (p project) SetUpdatedAt(updatedAt time.Time) database.Change { + return database.NewChange(p.UpdatedAtColumn(), updatedAt) +} + +func (p project) SetName(name string) database.Change { + return database.NewChange(p.NameColumn(), name) +} + +func (p project) SetState(state domain.ProjectState) database.Change { + return database.NewChange(p.StateColumn(), state) +} + +func (p project) SetShouldAssertRole(shouldAssertRole bool) database.Change { + return database.NewChange(p.ShouldAssertRoleColumn(), shouldAssertRole) +} + +func (p project) SetIsAuthorizationRequired(isAuthorizationRequired bool) database.Change { + return database.NewChange(p.IsAuthorizationRequiredColumn(), isAuthorizationRequired) +} + +func (p project) SetIsProjectAccessRequired(isProjectAccessRequired bool) database.Change { + return database.NewChange(p.IsProjectAccessRequiredColumn(), isProjectAccessRequired) +} + +func (p project) SetUsedLabelingSettingOwner(usedLabelingSettingOwner int16) database.Change { + return database.NewChange(p.UsedLabelingSettingOwnerColumn(), usedLabelingSettingOwner) +} + +// ------------------------------------------------------------- +// conditions +// ------------------------------------------------------------- + +func (p project) PrimaryKeyCondition(instanceID, projectID string) database.Condition { + return database.And( + p.InstanceIDCondition(instanceID), + p.IDCondition(projectID), + ) +} + +func (p project) InstanceIDCondition(instanceID string) database.Condition { + return database.NewTextCondition(p.InstanceIDColumn(), database.TextOperationEqual, instanceID) +} + +func (p project) OrganizationIDCondition(organizationID string) database.Condition { + return database.NewTextCondition(p.OrganizationIDColumn(), database.TextOperationEqual, organizationID) +} + +func (p project) IDCondition(projectID string) database.Condition { + return database.NewTextCondition(p.IDColumn(), database.TextOperationEqual, projectID) +} + +func (p project) NameCondition(op database.TextOperation, name string) database.Condition { + return database.NewTextCondition(p.NameColumn(), op, name) +} + +func (p project) StateCondition(state domain.ProjectState) database.Condition { + return database.NewTextCondition(p.StateColumn(), database.TextOperationEqual, state.String()) +} + +// ------------------------------------------------------------- +// columns +// ------------------------------------------------------------- + +func (project) unqualifiedTableName() string { + return "projects" +} + +// PrimaryKeyColumns implements the [pkRepository] interface +func (p project) PrimaryKeyColumns() []database.Column { + return []database.Column{ + p.InstanceIDColumn(), + p.IDColumn(), + } +} + +func (p project) InstanceIDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "instance_id") +} + +func (p project) OrganizationIDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "organization_id") +} + +func (p project) IDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "id") +} + +func (p project) CreatedAtColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "created_at") +} + +func (p project) UpdatedAtColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "updated_at") +} + +func (p project) NameColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "name") +} + +func (p project) StateColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "state") +} + +func (p project) ShouldAssertRoleColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "should_assert_role") +} + +func (p project) IsAuthorizationRequiredColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "is_authorization_required") +} + +func (p project) IsProjectAccessRequiredColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "is_project_access_required") +} + +func (p project) UsedLabelingSettingOwnerColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "used_labeling_setting_owner") +} + +// ------------------------------------------------------------- +// helpers +// ------------------------------------------------------------- + +const queryProjectStmt = `SELECT + projects.instance_id, + projects.organization_id, + projects.id, + projects.created_at, + projects.updated_at, + projects.name, + projects.state, + projects.should_assert_role, + projects.is_authorization_required, + projects.is_project_access_required, + projects.used_labeling_setting_owner + FROM zitadel.projects` + +func (p project) prepareQuery(opts []database.QueryOption) (*database.StatementBuilder, error) { + options := new(database.QueryOpts) + for _, opt := range opts { + opt(options) + } + if err := checkRestrictingColumns(options.Condition, p.InstanceIDColumn()); err != nil { + return nil, err + } + builder := database.NewStatementBuilder(queryProjectStmt) + options.Write(builder) + + return builder, nil +} diff --git a/backend/v3/storage/database/repository/project_role.go b/backend/v3/storage/database/repository/project_role.go new file mode 100644 index 000000000..98ebd8734 --- /dev/null +++ b/backend/v3/storage/database/repository/project_role.go @@ -0,0 +1,203 @@ +package repository + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +// ------------------------------------------------------------- +// repository +// ------------------------------------------------------------- + +type projectRole struct{} + +func (p projectRole) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.ProjectRole, error) { + builder, err := p.prepareQuery(opts) + if err != nil { + return nil, err + } + return getOne[domain.ProjectRole](ctx, client, builder) +} + +func (p projectRole) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.ProjectRole, error) { + builder, err := p.prepareQuery(opts) + if err != nil { + return nil, err + } + return getMany[domain.ProjectRole](ctx, client, builder) +} + +const insertProjectRoleStmt = `INSERT INTO zitadel.project_roles( + instance_id, organization_id, project_id, key, display_name, role_group +) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING created_at, updated_at` + +func (p projectRole) Create(ctx context.Context, client database.QueryExecutor, role *domain.ProjectRole) error { + builder := database.NewStatementBuilder(insertProjectRoleStmt, + role.InstanceID, + role.OrganizationID, + role.ProjectID, + role.Key, + role.DisplayName, + role.RoleGroup, + ) + return client.QueryRow(ctx, builder.String(), builder.Args()...). + Scan(&role.CreatedAt, &role.UpdatedAt) +} + +func (p projectRole) 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 := checkPKCondition(p, condition); err != nil { + return 0, err + } + if !database.Changes(changes).IsOnColumn(p.UpdatedAtColumn()) { + changes = append(changes, database.NewChange(p.UpdatedAtColumn(), database.NullInstruction)) + } + builder := database.NewStatementBuilder(`UPDATE zitadel.project_roles SET `) + database.Changes(changes).Write(builder) + writeCondition(builder, condition) + + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +func (p projectRole) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { + if err := checkPKCondition(p, condition); err != nil { + return 0, err + } + builder := database.NewStatementBuilder(`DELETE FROM zitadel.project_roles`) + writeCondition(builder, condition) + + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +// ------------------------------------------------------------- +// changes +// ------------------------------------------------------------- + +func (p projectRole) SetUpdatedAt(updatedAt time.Time) database.Change { + return database.NewChange(p.UpdatedAtColumn(), updatedAt) +} + +func (p projectRole) SetDisplayName(displayName string) database.Change { + return database.NewChange(p.DisplayNameColumn(), displayName) +} + +func (p projectRole) SetRoleGroup(roleGroup string) database.Change { + return database.NewChange(p.RoleGroupColumn(), roleGroup) +} + +// ------------------------------------------------------------- +// conditions +// ------------------------------------------------------------- + +func (p projectRole) PrimaryKeyCondition(instanceID, projectID, key string) database.Condition { + return database.And( + p.InstanceIDCondition(instanceID), + p.ProjectIDCondition(projectID), + p.KeyCondition(key), + ) +} + +func (p projectRole) InstanceIDCondition(instanceID string) database.Condition { + return database.NewTextCondition(p.InstanceIDColumn(), database.TextOperationEqual, instanceID) +} + +func (p projectRole) ProjectIDCondition(projectID string) database.Condition { + return database.NewTextCondition(p.ProjectIDColumn(), database.TextOperationEqual, projectID) +} + +func (p projectRole) KeyCondition(key string) database.Condition { + return database.NewTextCondition(p.KeyColumn(), database.TextOperationEqual, key) +} + +func (p projectRole) DisplayNameCondition(op database.TextOperation, displayName string) database.Condition { + return database.NewTextCondition(p.DisplayNameColumn(), op, displayName) +} + +func (p projectRole) RoleGroupCondition(op database.TextOperation, roleGroup string) database.Condition { + return database.NewTextCondition(p.RoleGroupColumn(), op, roleGroup) +} + +// ------------------------------------------------------------- +// columns +// ------------------------------------------------------------- + +func (projectRole) unqualifiedTableName() string { + return "project_roles" +} + +// PrimaryKeyColumns implements the [pkRepository] interface +func (p projectRole) PrimaryKeyColumns() []database.Column { + return []database.Column{ + p.InstanceIDColumn(), + p.ProjectIDColumn(), + p.KeyColumn(), + } +} + +func (p projectRole) InstanceIDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "instance_id") +} + +func (p projectRole) OrganizationIDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "organization_id") +} + +func (p projectRole) ProjectIDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "project_id") +} + +func (p projectRole) CreatedAtColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "created_at") +} + +func (p projectRole) UpdatedAtColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "updated_at") +} + +func (p projectRole) KeyColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "key") +} + +func (p projectRole) DisplayNameColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "display_name") +} + +func (p projectRole) RoleGroupColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "role_group") +} + +// ------------------------------------------------------------- +// helpers +// ------------------------------------------------------------- + +const queryProjectRoleStmt = `SELECT + project_roles.instance_id, + project_roles.organization_id, + project_roles.project_id, + project_roles.created_at, + project_roles.updated_at, + project_roles.key, + project_roles.display_name, + project_roles.role_group + FROM zitadel.project_roles` + +func (p projectRole) prepareQuery(opts []database.QueryOption) (*database.StatementBuilder, error) { + options := new(database.QueryOpts) + for _, opt := range opts { + opt(options) + } + if err := checkRestrictingColumns(options.Condition, p.InstanceIDColumn(), p.ProjectIDColumn()); err != nil { + return nil, err + } + builder := database.NewStatementBuilder(queryProjectRoleStmt) + options.Write(builder) + + return builder, nil +} diff --git a/backend/v3/storage/database/repository/project_role_test.go b/backend/v3/storage/database/repository/project_role_test.go new file mode 100644 index 000000000..6da3a8b27 --- /dev/null +++ b/backend/v3/storage/database/repository/project_role_test.go @@ -0,0 +1,467 @@ +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" +) + +func TestGetProjectRole(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + projectID := createProject(t, tx, instanceID, orgID) + roleRepo := repository.ProjectRepository().Role() + + firstRole := &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: projectID, + OrganizationID: orgID, + Key: "key1", + DisplayName: "Role 1", + RoleGroup: gu.Ptr("group1"), + } + err := roleRepo.Create(t.Context(), tx, firstRole) + require.NoError(t, err) + + secondRole := &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: projectID, + OrganizationID: orgID, + Key: "key2", + DisplayName: "Role 2", + RoleGroup: gu.Ptr("group2"), + } + err = roleRepo.Create(t.Context(), tx, secondRole) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRole *domain.ProjectRole + wantErr error + }{ + { + name: "incomplete condition", + condition: roleRepo.KeyCondition(firstRole.Key), + wantErr: database.NewMissingConditionError(roleRepo.InstanceIDColumn()), + }, + { + name: "not found", + condition: roleRepo.PrimaryKeyCondition(instanceID, projectID, "foo"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: database.And(roleRepo.InstanceIDCondition(instanceID), roleRepo.ProjectIDCondition(projectID)), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok", + condition: roleRepo.PrimaryKeyCondition(instanceID, projectID, firstRole.Key), + wantRole: firstRole, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRole, err := roleRepo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRole, gotRole) + }) + } +} + +func TestListProjectRoles(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + firstProjectID := createProject(t, tx, instanceID, orgID) + secondProjectID := createProject(t, tx, instanceID, orgID) + roleRepo := repository.ProjectRepository().Role() + + roles := [...]*domain.ProjectRole{ + { + InstanceID: instanceID, + ProjectID: firstProjectID, + OrganizationID: orgID, + Key: "key1", + DisplayName: "Role 1", + RoleGroup: gu.Ptr("group1"), + }, + { + InstanceID: instanceID, + ProjectID: firstProjectID, + OrganizationID: orgID, + Key: "key2", + DisplayName: "Role 2", + RoleGroup: gu.Ptr("group2"), + }, + { + InstanceID: instanceID, + ProjectID: secondProjectID, + OrganizationID: orgID, + Key: "key3", + DisplayName: "Role 3", + RoleGroup: gu.Ptr("group3"), + }, + { + InstanceID: instanceID, + ProjectID: secondProjectID, + OrganizationID: orgID, + Key: "key4", + DisplayName: "foobar", + RoleGroup: gu.Ptr("group3"), + }, + { + InstanceID: instanceID, + ProjectID: secondProjectID, + OrganizationID: orgID, + Key: "key5", + DisplayName: "foobaz", + RoleGroup: gu.Ptr("group4"), + }, + } + for _, r := range roles { + err := roleRepo.Create(t.Context(), tx, r) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + wantRoles []*domain.ProjectRole + wantErr error + }{ + { + name: "incomplete condition", + condition: roleRepo.KeyCondition("key1"), + wantErr: database.NewMissingConditionError(roleRepo.InstanceIDColumn()), + }, + { + name: "no results, ok", + condition: roleRepo.PrimaryKeyCondition(instanceID, firstProjectID, "foo"), + }, + { + name: "all from project 1", + condition: database.And( + roleRepo.InstanceIDCondition(instanceID), + roleRepo.ProjectIDCondition(firstProjectID), + ), + wantRoles: roles[0:2], + }, + { + name: "group 3 from project 2", + condition: database.And( + roleRepo.InstanceIDCondition(instanceID), + roleRepo.ProjectIDCondition(secondProjectID), + roleRepo.RoleGroupCondition(database.TextOperationEqual, "group3"), + ), + wantRoles: roles[2:4], + }, + { + name: "name starts with 'foo' from project 2", + condition: database.And( + roleRepo.InstanceIDCondition(instanceID), + roleRepo.ProjectIDCondition(secondProjectID), + roleRepo.DisplayNameCondition(database.TextOperationStartsWith, "foo"), + ), + wantRoles: roles[3:5], + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRoles, err := roleRepo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(roleRepo.PrimaryKeyColumns()...), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRoles, gotRoles) + }) + } +} + +func TestCreateProjectRole(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + projectID := createProject(t, tx, instanceID, orgID) + roleRepo := repository.ProjectRepository().Role() + + existingRole := &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: projectID, + OrganizationID: orgID, + Key: "key1", + DisplayName: "Role 1", + RoleGroup: gu.Ptr("group1"), + } + err := roleRepo.Create(t.Context(), tx, existingRole) + require.NoError(t, err) + + tests := []struct { + name string + role *domain.ProjectRole + wantErr error + }{ + { + name: "add role", + role: &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: projectID, + OrganizationID: orgID, + Key: "key2", + DisplayName: "Role 2", + RoleGroup: gu.Ptr("group2"), + }, + }, + { + name: "add role, no group", + role: &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: projectID, + OrganizationID: orgID, + Key: "key2", + DisplayName: "Role 2", + }, + }, + { + name: "non-existing instance", + role: &domain.ProjectRole{ + InstanceID: "foo", + ProjectID: projectID, + OrganizationID: orgID, + Key: "key2", + DisplayName: "Role 2", + RoleGroup: gu.Ptr("group2"), + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing project", + role: &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: "foo", + OrganizationID: orgID, + Key: "key2", + DisplayName: "Role 2", + RoleGroup: gu.Ptr("group2"), + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing organization", + role: &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: projectID, + OrganizationID: "foo", + Key: "key2", + DisplayName: "Role 2", + RoleGroup: gu.Ptr("group2"), + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "empty key error", + role: &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: projectID, + OrganizationID: orgID, + Key: "", + DisplayName: "Role 2", + RoleGroup: gu.Ptr("group2"), + }, + wantErr: new(database.CheckError), + }, + { + name: "empty display name error", + role: &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: projectID, + OrganizationID: orgID, + Key: "key2", + DisplayName: "", + RoleGroup: gu.Ptr("group2"), + }, + wantErr: new(database.CheckError), + }, + { + name: "duplicate key", + role: existingRole, + wantErr: new(database.UniqueError), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + + err := roleRepo.Create(t.Context(), savepoint, tt.role) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestUpdateProjectRoles(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + projectID := createProject(t, tx, instanceID, orgID) + roleRepo := repository.ProjectRepository().Role() + + existingRole := &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: projectID, + OrganizationID: orgID, + Key: "key1", + DisplayName: "Role 1", + RoleGroup: gu.Ptr("group1"), + } + err := roleRepo.Create(t.Context(), tx, existingRole) + require.NoError(t, err) + lastUpdatedAt := existingRole.UpdatedAt + + tests := []struct { + name string + condition database.Condition + changes []database.Change + wantRowsAffected int64 + wantErr error + assertChanges func(t *testing.T, project *domain.ProjectRole) + }{ + { + name: "no changes", + condition: roleRepo.PrimaryKeyCondition(existingRole.InstanceID, existingRole.ProjectID, existingRole.Key), + wantErr: database.ErrNoChanges, + }, + { + name: "incomplete condition", + condition: roleRepo.KeyCondition(existingRole.Key), + changes: []database.Change{ + roleRepo.SetDisplayName("Role 1 Updated"), + }, + wantErr: database.NewMissingConditionError(roleRepo.InstanceIDColumn()), + }, + { + name: "update display name", + condition: roleRepo.PrimaryKeyCondition(existingRole.InstanceID, existingRole.ProjectID, existingRole.Key), + changes: []database.Change{ + roleRepo.SetDisplayName("Role 1 Updated"), + }, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, role *domain.ProjectRole) { + assert.Equal(t, "Role 1 Updated", role.DisplayName) + }, + }, + { + name: "update role group", + condition: roleRepo.PrimaryKeyCondition(existingRole.InstanceID, existingRole.ProjectID, existingRole.Key), + changes: []database.Change{ + roleRepo.SetRoleGroup("group1 Updated"), + }, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, role *domain.ProjectRole) { + assert.Equal(t, gu.Ptr("group1 Updated"), role.RoleGroup) + }, + }, + { + name: "set empty display name, not allowed", + condition: roleRepo.PrimaryKeyCondition(existingRole.InstanceID, existingRole.ProjectID, existingRole.Key), + changes: []database.Change{ + roleRepo.SetDisplayName(""), + }, + wantRowsAffected: 0, + wantErr: new(database.CheckError), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + gotRowsAffected, err := roleRepo.Update(t.Context(), savepoint, tt.condition, tt.changes...) + assert.Equal(t, tt.wantRowsAffected, gotRowsAffected) + assert.ErrorIs(t, err, tt.wantErr) + + if tt.assertChanges != nil { + updatedRole, err := roleRepo.Get(t.Context(), savepoint, database.WithCondition( + roleRepo.PrimaryKeyCondition(existingRole.InstanceID, existingRole.ProjectID, existingRole.Key), + )) + require.NoError(t, err) + assert.WithinRange(t, updatedRole.CreatedAt, existingRole.CreatedAt, existingRole.CreatedAt) + assert.WithinRange(t, updatedRole.UpdatedAt, lastUpdatedAt, lastUpdatedAt.Add(time.Second)) + lastUpdatedAt = updatedRole.UpdatedAt + tt.assertChanges(t, updatedRole) + } + }) + } +} + +func TestDeleteProjectRole(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + projectID := createProject(t, tx, instanceID, orgID) + roleRepo := repository.ProjectRepository().Role() + + existingRole := &domain.ProjectRole{ + InstanceID: instanceID, + ProjectID: projectID, + OrganizationID: orgID, + Key: "key1", + DisplayName: "Role 1", + RoleGroup: gu.Ptr("group1"), + } + err := roleRepo.Create(t.Context(), tx, existingRole) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: roleRepo.KeyCondition(existingRole.Key), + wantErr: database.NewMissingConditionError(roleRepo.InstanceIDColumn()), + }, + { + name: "not found", + condition: roleRepo.PrimaryKeyCondition(instanceID, projectID, "baz"), + wantRowsAffected: 0, + }, + { + name: "delete role", + condition: roleRepo.PrimaryKeyCondition(existingRole.InstanceID, existingRole.ProjectID, existingRole.Key), + wantRowsAffected: 1, + }, + { + name: "delete role twice", + condition: roleRepo.PrimaryKeyCondition(existingRole.InstanceID, existingRole.ProjectID, existingRole.Key), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRowsAffected, err := roleRepo.Delete(t.Context(), tx, tt.condition) + assert.Equal(t, tt.wantRowsAffected, gotRowsAffected) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/backend/v3/storage/database/repository/project_test.go b/backend/v3/storage/database/repository/project_test.go new file mode 100644 index 000000000..a972f3510 --- /dev/null +++ b/backend/v3/storage/database/repository/project_test.go @@ -0,0 +1,521 @@ +package repository_test + +import ( + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "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" +) + +func TestGetProject(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + projectRepo := repository.ProjectRepository() + + firstProject := &domain.Project{ + InstanceID: instanceID, + OrganizationID: orgID, + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + } + err := projectRepo.Create(t.Context(), tx, firstProject) + require.NoError(t, err) + secondProject := &domain.Project{ + InstanceID: instanceID, + OrganizationID: orgID, + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + } + err = projectRepo.Create(t.Context(), tx, secondProject) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + want *domain.Project + wantErr error + }{ + { + name: "incomplete condition", + condition: projectRepo.IDCondition(firstProject.ID), + wantErr: database.NewMissingConditionError(projectRepo.IDColumn()), + }, + { + name: "not found", + condition: projectRepo.PrimaryKeyCondition(instanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: projectRepo.InstanceIDCondition(instanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok", + condition: projectRepo.PrimaryKeyCondition(instanceID, firstProject.ID), + want: firstProject, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := projectRepo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestListProjects(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + firstOrgID := createOrganization(t, tx, instanceID) + secondOrgID := createOrganization(t, tx, instanceID) + projectRepo := repository.ProjectRepository() + + projects := [...]*domain.Project{ + { + InstanceID: instanceID, + OrganizationID: firstOrgID, + ID: "1", + Name: "spanac", + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, + { + InstanceID: instanceID, + OrganizationID: firstOrgID, + ID: "2", + Name: "foobar", + State: domain.ProjectStateInactive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, + { + InstanceID: instanceID, + OrganizationID: secondOrgID, + ID: "3", + Name: "foobaz", + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, + { + InstanceID: instanceID, + OrganizationID: secondOrgID, + ID: "4", + Name: "bazqux", + State: domain.ProjectStateInactive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, + } + for _, project := range projects { + err := projectRepo.Create(t.Context(), tx, project) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.Project + wantErr error + }{ + { + name: "incomplete condition", + condition: projectRepo.OrganizationIDCondition(firstOrgID), + wantErr: database.NewMissingConditionError(projectRepo.IDColumn()), + }, + { + name: "no results, ok", + condition: projectRepo.PrimaryKeyCondition(instanceID, "nix"), + }, + { + name: "all from instance", + condition: projectRepo.InstanceIDCondition(instanceID), + want: projects[:], + }, + { + name: "all from first org", + condition: database.And( + projectRepo.InstanceIDCondition(instanceID), + projectRepo.OrganizationIDCondition(firstOrgID), + ), + want: projects[0:2], + }, + { + name: "name starts with 'foo'", + condition: database.And( + projectRepo.InstanceIDCondition(instanceID), + projectRepo.NameCondition(database.TextOperationStartsWith, "foo"), + ), + want: projects[1:3], + }, + { + name: "state active", + condition: database.And( + projectRepo.InstanceIDCondition(instanceID), + projectRepo.StateCondition(domain.ProjectStateActive), + ), + want: []*domain.Project{projects[0], projects[2]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := projectRepo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(projectRepo.PrimaryKeyColumns()...), + ) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCreateProject(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + projectRepo := repository.ProjectRepository() + + existingProject := &domain.Project{ + InstanceID: instanceID, + OrganizationID: orgID, + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + } + err := projectRepo.Create(t.Context(), tx, existingProject) + require.NoError(t, err) + + tests := []struct { + name string + project *domain.Project + wantErr error + }{ + { + name: "add project", + project: &domain.Project{ + InstanceID: instanceID, + OrganizationID: orgID, + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, + }, + { + name: "non-existing instance", + project: &domain.Project{ + InstanceID: "foo", + OrganizationID: orgID, + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + project: &domain.Project{ + InstanceID: instanceID, + OrganizationID: "foo", + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "empty id error", + project: &domain.Project{ + InstanceID: instanceID, + OrganizationID: orgID, + ID: "", + Name: gofakeit.Name(), + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, + wantErr: new(database.CheckError), + }, + { + name: "empty name error", + project: &domain.Project{ + InstanceID: instanceID, + OrganizationID: orgID, + ID: gofakeit.UUID(), + Name: "", + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, + wantErr: new(database.CheckError), + }, + { + name: "duplicate project", + project: existingProject, + wantErr: new(database.UniqueError), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := projectRepo.Create(t.Context(), savepoint, tt.project) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestUpdateProject(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + projectRepo := repository.ProjectRepository() + + existingProject := &domain.Project{ + InstanceID: instanceID, + OrganizationID: orgID, + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + } + err := projectRepo.Create(t.Context(), tx, existingProject) + require.NoError(t, err) + lastUpdatedAt := existingProject.UpdatedAt + + tests := []struct { + name string + condition database.Condition + changes []database.Change + wantRowsAffected int64 + wantErr error + assertChanges func(t *testing.T, project *domain.Project) + }{ + { + name: "no changes", + condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + changes: []database.Change{}, + wantRowsAffected: 0, + wantErr: database.ErrNoChanges, + }, + { + name: "incomplete condition", + condition: projectRepo.InstanceIDCondition(instanceID), + changes: []database.Change{ + projectRepo.SetName("new name"), + }, + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(projectRepo.IDColumn()), + }, + { + name: "set name", + condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + changes: []database.Change{ + projectRepo.SetName("new name"), + }, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProject *domain.Project) { + assert.Equal(t, "new name", updatedProject.Name) + }, + }, + { + name: "set state", + condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + changes: []database.Change{ + projectRepo.SetState(domain.ProjectStateInactive), + }, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProject *domain.Project) { + assert.Equal(t, domain.ProjectStateInactive, updatedProject.State) + }, + }, + { + name: "set should_assert_role", + condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + changes: []database.Change{ + projectRepo.SetShouldAssertRole(false), + }, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProject *domain.Project) { + assert.False(t, updatedProject.ShouldAssertRole) + }, + }, + { + name: "set is_authorization_required", + condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + changes: []database.Change{ + projectRepo.SetIsAuthorizationRequired(false), + }, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProject *domain.Project) { + assert.False(t, updatedProject.IsAuthorizationRequired) + }, + }, + { + name: "set is_project_access_required", + condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + changes: []database.Change{ + projectRepo.SetIsProjectAccessRequired(false), + }, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProject *domain.Project) { + assert.False(t, updatedProject.IsProjectAccessRequired) + }, + }, + { + name: "set used_labeling_setting_owner", + condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + changes: []database.Change{ + projectRepo.SetUsedLabelingSettingOwner(2), + }, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProject *domain.Project) { + assert.Equal(t, int16(2), updatedProject.UsedLabelingSettingOwner) + }, + }, + { + name: "set empty name, not allowed", + condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + changes: []database.Change{ + projectRepo.SetName(""), + }, + wantRowsAffected: 0, + wantErr: new(database.CheckError), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + rowsAffected, err := projectRepo.Update(t.Context(), savepoint, tt.condition, tt.changes...) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + + if tt.assertChanges != nil { + updatedProject, err := projectRepo.Get(t.Context(), savepoint, database.WithCondition( + projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + )) + require.NoError(t, err) + assert.WithinRange(t, updatedProject.CreatedAt, existingProject.CreatedAt, existingProject.CreatedAt) + assert.WithinRange(t, updatedProject.UpdatedAt, lastUpdatedAt, lastUpdatedAt.Add(time.Second)) + lastUpdatedAt = updatedProject.UpdatedAt + tt.assertChanges(t, updatedProject) + } + }) + } +} + +func TestDeleteProject(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + orgID := createOrganization(t, tx, instanceID) + projectRepo := repository.ProjectRepository() + + existingProject := &domain.Project{ + InstanceID: instanceID, + OrganizationID: orgID, + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + } + err := projectRepo.Create(t.Context(), tx, existingProject) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: projectRepo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(projectRepo.IDColumn()), + }, + { + name: "not found", + condition: projectRepo.PrimaryKeyCondition(instanceID, "foo"), + wantRowsAffected: 0, + }, + { + name: "delete project", + condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + wantRowsAffected: 1, + }, + { + name: "delete project twice", + condition: projectRepo.PrimaryKeyCondition(instanceID, existingProject.ID), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := projectRepo.Delete(t.Context(), tx, tt.condition) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + }) + } +} diff --git a/backend/v3/storage/database/repository/repository.go b/backend/v3/storage/database/repository/repository.go index af7d3b087..95a27c2b6 100644 --- a/backend/v3/storage/database/repository/repository.go +++ b/backend/v3/storage/database/repository/repository.go @@ -1,6 +1,8 @@ package repository import ( + "context" + "github.com/zitadel/zitadel/backend/v3/storage/database" ) @@ -14,3 +16,55 @@ func writeCondition( builder.WriteString(" WHERE ") condition.Write(builder) } + +func checkRestrictingColumns( + condition database.Condition, + requiredColumns ...database.Column, +) error { + for _, col := range requiredColumns { + if !condition.IsRestrictingColumn(col) { + return database.NewMissingConditionError(col) + } + } + return nil +} + +type pkRepository interface { + PrimaryKeyColumns() []database.Column +} + +// checkPKCondition checks if the Primary Key columns are part of the condition. +// This can ensure only a single row is affected by updates and deletes. +func checkPKCondition( + repo pkRepository, + condition database.Condition, +) error { + return checkRestrictingColumns( + condition, + repo.PrimaryKeyColumns()..., + ) +} + +func getOne[Target any](ctx context.Context, querier database.Querier, builder *database.StatementBuilder) (*Target, error) { + rows, err := querier.Query(ctx, builder.String(), builder.Args()...) + if err != nil { + return nil, err + } + var target Target + if err := rows.(database.CollectableRows).CollectExactlyOneRow(&target); err != nil { + return nil, err + } + return &target, nil +} + +func getMany[Target any](ctx context.Context, querier database.Querier, builder *database.StatementBuilder) ([]*Target, error) { + rows, err := querier.Query(ctx, builder.String(), builder.Args()...) + if err != nil { + return nil, err + } + var targets []*Target + if err := rows.(database.CollectableRows).Collect(&targets); err != nil { + return nil, err + } + return targets, nil +} diff --git a/backend/v3/storage/database/repository/repository_test.go b/backend/v3/storage/database/repository/repository_test.go index 36c28c22e..0ccb0e2e2 100644 --- a/backend/v3/storage/database/repository/repository_test.go +++ b/backend/v3/storage/database/repository/repository_test.go @@ -7,8 +7,13 @@ import ( "os" "testing" + "github.com/brianvoe/gofakeit/v6" + "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/dialect/postgres/embedded" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" ) func TestMain(m *testing.M) { @@ -49,3 +54,72 @@ func newEmbeddedDB(ctx context.Context) (pool database.PoolTest, stop func(), er } return pool, stop, err } + +func transactionForRollback(t *testing.T) (tx database.Transaction, rollback func()) { + t.Helper() + tx, err := pool.Begin(t.Context(), nil) + require.NoError(t, err) + return tx, func() { + err := tx.Rollback(t.Context()) + require.NoError(t, err) + } +} + +func savepointForRollback(t *testing.T, tx database.Transaction) (savepoint database.Transaction, rollback func()) { + t.Helper() + savepoint, err := tx.Begin(t.Context()) + require.NoError(t, err) + return savepoint, func() { + err := savepoint.Rollback(t.Context()) + require.NoError(t, err) + } +} + +func createInstance(t *testing.T, tx database.Transaction) (instanceID string) { + t.Helper() + instance := domain.Instance{ + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + DefaultOrgID: "defaultOrgId", + IAMProjectID: "iamProject", + ConsoleClientID: "consoleClient", + ConsoleAppID: "consoleApp", + DefaultLanguage: "defaultLanguage", + } + instanceRepo := repository.InstanceRepository() + err := instanceRepo.Create(t.Context(), tx, &instance) + require.NoError(t, err) + + return instance.ID +} + +func createOrganization(t *testing.T, tx database.Transaction, instanceID string) (orgID string) { + t.Helper() + org := domain.Organization{ + InstanceID: instanceID, + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + State: domain.OrgStateActive, + } + orgRepo := repository.OrganizationRepository() + err := orgRepo.Create(t.Context(), tx, &org) + require.NoError(t, err) + + return org.ID +} + +func createProject(t *testing.T, tx database.Transaction, instanceID, orgID string) (projectID string) { + t.Helper() + project := domain.Project{ + InstanceID: instanceID, + OrganizationID: orgID, + ID: gofakeit.UUID(), + Name: gofakeit.Name(), + State: domain.ProjectStateActive, + } + projectRepo := repository.ProjectRepository() + err := projectRepo.Create(t.Context(), tx, &project) + require.NoError(t, err) + + return project.ID +} diff --git a/backend/v3/storage/database/statement.go b/backend/v3/storage/database/statement.go index f7e7a131c..4b01f60fe 100644 --- a/backend/v3/storage/database/statement.go +++ b/backend/v3/storage/database/statement.go @@ -23,6 +23,13 @@ type argWriter interface { WriteArg(builder *StatementBuilder) } +func NewStatementBuilder(baseQuery string, baseArgs ...any) *StatementBuilder { + b := &StatementBuilder{} + b.WriteString(baseQuery) + b.AppendArgs(baseArgs...) + return b +} + // WriteArgs adds the argument to the statement and writes the placeholder to the query. func (b *StatementBuilder) WriteArg(arg any) { if writer, ok := arg.(argWriter); ok { diff --git a/internal/api/grpc/management/integration_test/server_test.go b/internal/api/grpc/management/integration_test/server_test.go index ac7ab4ea6..210bf8240 100644 --- a/internal/api/grpc/management/integration_test/server_test.go +++ b/internal/api/grpc/management/integration_test/server_test.go @@ -14,8 +14,8 @@ import ( var ( CTX, IAMOwnerCTX, OrgCTX context.Context - Instance *integration.Instance - Client mgmt_pb.ManagementServiceClient + Instance *integration.Instance + Client mgmt_pb.ManagementServiceClient ) func TestMain(m *testing.M) { diff --git a/internal/query/projection/project_relational.go b/internal/query/projection/project_relational.go new file mode 100644 index 000000000..60ef0068d --- /dev/null +++ b/internal/query/projection/project_relational.go @@ -0,0 +1,180 @@ +package projection + +import ( + "context" + "database/sql" + + repoDomain "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/project" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type projectRelationalProjection struct{} + +func (*projectRelationalProjection) Name() string { + return "zitadel.projects" +} + +func newProjectRelationalProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(projectRelationalProjection)) +} + +func (p *projectRelationalProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: project.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: project.ProjectAddedType, + Reduce: p.reduceProjectAdded, + }, + { + Event: project.ProjectChangedType, + Reduce: p.reduceProjectChanged, + }, + { + Event: project.ProjectDeactivatedType, + Reduce: p.reduceProjectDeactivated, + }, + { + Event: project.ProjectReactivatedType, + Reduce: p.reduceProjectReactivated, + }, + { + Event: project.ProjectRemovedType, + Reduce: p.reduceProjectRemoved, + }, + }, + }, + } +} + +func (p *projectRelationalProjection) reduceProjectAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.ProjectAddedEvent) + if !ok { + return nil, zerrors.ThrowInternalf(nil, "HANDL-Oox5e", "reduce.wrong.event.type %s", project.ProjectAddedType) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-kGokE", "reduce.wrong.db.pool %T", ex) + } + repo := repository.ProjectRepository() + return repo.Create(ctx, v3_sql.SQLTx(tx), &repoDomain.Project{ + InstanceID: e.Aggregate().InstanceID, + OrganizationID: e.Aggregate().ResourceOwner, + ID: e.Aggregate().ID, + CreatedAt: e.CreationDate(), + UpdatedAt: e.CreationDate(), + Name: e.Name, + State: repoDomain.ProjectStateActive, + ShouldAssertRole: e.ProjectRoleAssertion, + IsAuthorizationRequired: e.ProjectRoleCheck, + IsProjectAccessRequired: e.HasProjectCheck, + UsedLabelingSettingOwner: int16(e.PrivateLabelingSetting), + }) + }), nil +} + +func (p *projectRelationalProjection) reduceProjectChanged(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.ProjectChangeEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Oox5e", "reduce.wrong.event.type %s", project.ProjectChangedType) + } + if e.Name == nil && e.HasProjectCheck == nil && e.ProjectRoleAssertion == nil && e.ProjectRoleCheck == nil && e.PrivateLabelingSetting == nil { + return handler.NewNoOpStatement(e), nil + } + + repo := repository.ProjectRepository() + changes := make([]database.Change, 0, 6) + changes = append(changes, repo.SetUpdatedAt(e.CreationDate())) + if e.Name != nil { + changes = append(changes, repo.SetName(*e.Name)) + } + if e.ProjectRoleAssertion != nil { + changes = append(changes, repo.SetShouldAssertRole(*e.ProjectRoleAssertion)) + } + if e.ProjectRoleCheck != nil { + changes = append(changes, repo.SetIsAuthorizationRequired(*e.ProjectRoleCheck)) + } + if e.HasProjectCheck != nil { + changes = append(changes, repo.SetIsProjectAccessRequired(*e.HasProjectCheck)) + } + if e.PrivateLabelingSetting != nil { + changes = append(changes, repo.SetUsedLabelingSettingOwner(int16(*e.PrivateLabelingSetting))) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-kGokE", "reduce.wrong.db.pool %T", ex) + } + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID), + changes..., + ) + return err + }), nil +} + +func (p *projectRelationalProjection) reduceProjectDeactivated(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.ProjectDeactivatedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Oox5e", "reduce.wrong.event.type %s", project.ProjectDeactivatedType) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + repo := repository.ProjectRepository() + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-kGokE", "reduce.wrong.db.pool %T", ex) + } + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID), + repo.SetUpdatedAt(e.CreationDate()), + repo.SetState(repoDomain.ProjectStateInactive), + ) + return err + }), nil +} + +func (p *projectRelationalProjection) reduceProjectReactivated(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.ProjectReactivatedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-oof4U", "reduce.wrong.event.type %s", project.ProjectReactivatedType) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + repo := repository.ProjectRepository() + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-kGokE", "reduce.wrong.db.pool %T", ex) + } + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID), + repo.SetUpdatedAt(e.CreationDate()), + repo.SetState(repoDomain.ProjectStateActive), + ) + return err + }), nil +} + +func (p *projectRelationalProjection) reduceProjectRemoved(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.ProjectRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Xae7w", "reduce.wrong.event.type %s", project.ProjectRemovedType) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + repo := repository.ProjectRepository() + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-kGokE", "reduce.wrong.db.pool %T", ex) + } + _, err := repo.Delete(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID), + ) + return err + }), nil +} diff --git a/internal/query/projection/project_role_relational.go b/internal/query/projection/project_role_relational.go new file mode 100644 index 000000000..4d8b4ce00 --- /dev/null +++ b/internal/query/projection/project_role_relational.go @@ -0,0 +1,137 @@ +package projection + +import ( + "context" + "database/sql" + + repoDomain "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/project" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + ProjectRoleRelationalTable = "zitadel.project_roles" + ProjectRoleRelationalKeyCol = "key" + ProjectRoleRelationalDisplayNameCol = "display_name" + ProjectRoleRelationalRoleGroupCol = "role_group" +) + +type projectRoleRelationalProjection struct{} + +func (*projectRoleRelationalProjection) Name() string { + return ProjectRoleRelationalTable +} + +func newProjectRoleRelationalProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(projectRoleRelationalProjection)) +} + +func (p *projectRoleRelationalProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: project.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: project.RoleAddedType, + Reduce: p.reduceProjectRoleAdded, + }, + { + Event: project.RoleChangedType, + Reduce: p.reduceProjectRoleChanged, + }, + { + Event: project.RoleRemovedType, + Reduce: p.reduceProjectRoleRemoved, + }, + }, + }, + } +} + +func (p *projectRoleRelationalProjection) reduceProjectRoleAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.RoleAddedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-EiPa5", "reduce.wrong.event.type %s", project.RoleAddedType) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-ohR0u", "reduce.wrong.db.pool %T", ex) + } + + // Group is optional and nullable but not a pointer in the event + // so we need to convert the empty string to a nil pointer + var group *string + if e.Group != "" { + group = &e.Group + } + + repo := repository.ProjectRepository().Role() + return repo.Create(ctx, v3_sql.SQLTx(tx), &repoDomain.ProjectRole{ + InstanceID: e.Aggregate().InstanceID, + OrganizationID: e.Aggregate().ResourceOwner, + ProjectID: e.Aggregate().ID, + CreatedAt: e.CreationDate(), + UpdatedAt: e.CreationDate(), + Key: e.Key, + DisplayName: e.DisplayName, + RoleGroup: group, + }) + }), nil +} + +func (p *projectRoleRelationalProjection) reduceProjectRoleChanged(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.RoleChangedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-PieJ9", "reduce.wrong.event.type %s", project.RoleChangedType) + } + if e.DisplayName == nil && e.Group == nil { + return handler.NewNoOpStatement(e), nil + } + + repo := repository.ProjectRepository().Role() + changes := make([]database.Change, 0, 3) + changes = append(changes, + repo.SetUpdatedAt(e.CreationDate()), + ) + if e.DisplayName != nil { + changes = append(changes, repo.SetDisplayName(*e.DisplayName)) + } + if e.Group != nil { + changes = append(changes, repo.SetRoleGroup(*e.Group)) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-ooCo1", "reduce.wrong.db.pool %T", ex) + } + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID, e.Key), + changes..., + ) + return err + }), nil +} + +func (p *projectRoleRelationalProjection) reduceProjectRoleRemoved(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.RoleRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-Ei9po", "reduce.wrong.event.type %s", project.RoleRemovedType) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + repo := repository.ProjectRepository().Role() + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-Voh4a", "reduce.wrong.db.pool %T", ex) + } + _, err := repo.Delete(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID, e.Key), + ) + return err + }), nil +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 0ee8096b1..de84bccda 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -94,6 +94,8 @@ var ( InstanceDomainRelationalProjection *handler.Handler OrganizationDomainRelationalProjection *handler.Handler IDPTemplateRelationalProjection *handler.Handler + ProjectRelationalProjection *handler.Handler + ProjectRoleRelationalProjection *handler.Handler ProjectGrantFields *handler.FieldHandler OrgDomainVerifiedFields *handler.FieldHandler @@ -210,6 +212,8 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, InstanceDomainRelationalProjection = newInstanceDomainRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["instance_domains_relational"])) OrganizationDomainRelationalProjection = newOrgDomainRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["organization_domains_relational"])) IDPTemplateRelationalProjection = newIDPTemplateRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["idp_templates_relational"])) + ProjectRelationalProjection = newProjectRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["projects_relational"])) + ProjectRoleRelationalProjection = newProjectRoleRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["project_roles_relational"])) newProjectionsList() newFieldsList() @@ -398,5 +402,7 @@ func newProjectionsList() { InstanceDomainRelationalProjection, OrganizationDomainRelationalProjection, IDPTemplateRelationalProjection, + ProjectRelationalProjection, + ProjectRoleRelationalProjection, } }