From 9274e008fe1b632b75dd2c359b052c97160721b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 14 Nov 2025 12:04:16 +0100 Subject: [PATCH] feat(rt): project grants (#10951) This pull request introduces a new feature that allows adding, updating, and querying project grants The changes are primarily in the backend and include new database tables, repositories, and domain logic to support project grants. ## Changes * New `project_grants` table: A new table `zitadel.project_grants` is introduced to store project grants It includes columns for `instance_id`, `id`, `state`, `project_id`, `granted_org_id` and `granting_org_id`. * New `ProjectGrantRepository`: A new repository `ProjectGrantRepository` is created to handle all database operations for project grants. It provides methods to `Get`, `List`, `Create`, `Update`, and `Delete`. * New `project_grant_roles` table: A new table `zitadel.project_grant_roles` is introduced to store project grant roles It includes columns for `instance_id`, `grant_id`, `key`, `project_id` and `project_org_id`. * New `ProjectGrantRoleRepository`: A new repository `ProjectGrantRoleRepository` is created to handle all database operations for project grant roles. It provides methods to `Get`, `List`, `Add`, and `Delete`. * New `project_grant_relational_projection`: A new projection `project_grant_relational_projection` is added to update the `zitadel.project_grants` and `zitadel.project_grant_roles` tables based on events. It handles all project grant events. * Updated `ProjectRepository`: The `ProjectRepository` is updated to list granted projects. # Additional Context Closes https://github.com/zitadel/zitadel/issues/10766 --------- Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> Co-authored-by: Marco A. Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- backend/v3/domain/project.go | 9 + backend/v3/domain/project_grant.go | 102 +++ backend/v3/domain/projectgrantstate_enumer.go | 109 +++ .../migration/008_project_grants_table.go | 16 + .../008_project_grants_table/down.sql | 3 + .../migration/008_project_grants_table/up.sql | 45 ++ .../events_testing/project_grant_test.go | 154 +++++ .../v3/storage/database/repository/project.go | 105 ++- .../database/repository/project_grant.go | 305 +++++++++ .../database/repository/project_grant_test.go | 639 ++++++++++++++++++ .../database/repository/project_test.go | 76 ++- .../storage/database/repository/repository.go | 43 ++ .../database/repository/repository_test.go | 20 + .../projection/project_grant_relational.go | 183 +++++ internal/query/projection/projection.go | 3 + 15 files changed, 1782 insertions(+), 30 deletions(-) create mode 100644 backend/v3/domain/project_grant.go create mode 100644 backend/v3/domain/projectgrantstate_enumer.go create mode 100644 backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table.go create mode 100644 backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/down.sql create mode 100644 backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/up.sql create mode 100644 backend/v3/storage/database/events_testing/project_grant_test.go create mode 100644 backend/v3/storage/database/repository/project_grant.go create mode 100644 backend/v3/storage/database/repository/project_grant_test.go create mode 100644 internal/query/projection/project_grant_relational.go diff --git a/backend/v3/domain/project.go b/backend/v3/domain/project.go index dad502423..2e91962c6 100644 --- a/backend/v3/domain/project.go +++ b/backend/v3/domain/project.go @@ -27,6 +27,11 @@ type Project struct { 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"` + + // Granted Project only filled in List + GrantID *string `json:"grantId,omitempty" db:"grant_id"` + GrantedOrganizationID *string `json:"grantedOrganizationId,omitempty" db:"granted_organization_id"` + GrantState *ProjectGrantState `json:"grantState,omitempty" db:"grant_state"` } type projectColumns interface { @@ -34,6 +39,8 @@ type projectColumns interface { InstanceIDColumn() database.Column // OrganizationIDColumn returns the column for the organization id field OrganizationIDColumn() database.Column + // GrantedOrganizationIDColumn returns the column for the granted organization id field + GrantedOrganizationIDColumn() database.Column // IDColumn returns the column for the id field. IDColumn() database.Column // CreatedAtColumn returns the column for the created at field. @@ -61,6 +68,8 @@ type projectConditions interface { InstanceIDCondition(instanceID string) database.Condition // OrganizationIDCondition returns a filter on the organization id field. OrganizationIDCondition(organizationID string) database.Condition + // GrantedOrganizationIDCondition returns a filter on the granted organization id field. + GrantedOrganizationIDCondition(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. diff --git a/backend/v3/domain/project_grant.go b/backend/v3/domain/project_grant.go new file mode 100644 index 000000000..41ef36419 --- /dev/null +++ b/backend/v3/domain/project_grant.go @@ -0,0 +1,102 @@ +package domain + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +//go:generate enumer -type ProjectGrantState -transform lower -trimprefix ProjectGrantState -sql +type ProjectGrantState uint8 + +const ( + ProjectGrantStateActive ProjectGrantState = iota + ProjectGrantStateInactive +) + +type ProjectGrant struct { + InstanceID string `json:"instanceId,omitempty" db:"instance_id"` + ID string `json:"id,omitempty" db:"id"` + ProjectID string `json:"projectId,omitempty" db:"project_id"` + GrantedOrganizationID string `json:"grantedOrganizationId,omitempty" db:"granted_organization_id"` + GrantingOrganizationID string `json:"grantingOrganizationId,omitempty" db:"granting_organization_id"` + CreatedAt time.Time `json:"createdAt,omitzero" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitzero" db:"updated_at"` + State ProjectGrantState `json:"state,omitempty" db:"state"` + RoleKeys []string `json:"roleKeys,omitempty" db:"role_keys"` +} + +type projectGrantColumns 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 + // IDColumn returns the column for the id field + IDColumn() database.Column + // ProjectIDColumn returns the column for the project id field + ProjectIDColumn() database.Column + // GrantingOrganizationIDColumn returns the column for the granting organization id field + GrantingOrganizationIDColumn() database.Column + // GrantedOrganizationIDColumn returns the column for the granted organization id field + GrantedOrganizationIDColumn() 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 + // StateColumn returns the column for the state field. + StateColumn() database.Column +} + +type projectGrantConditions interface { + // PrimaryKeyCondition returns a filter on the primary key fields. + PrimaryKeyCondition(instanceID, id string) database.Condition + // InstanceIDCondition returns a filter on the instance id field. + InstanceIDCondition(instanceID string) database.Condition + // IDCondition returns a filter on the id field. + IDCondition(id string) database.Condition + // ProjectIDCondition returns a filter on the project id field. + ProjectIDCondition(projectID string) database.Condition + // GrantingOrganizationIDCondition returns a filter on the granting organization id field. + GrantingOrganizationIDCondition(grantingOrgID string) database.Condition + // GrantedOrganizationIDCondition returns a filter on the granted organization id field. + GrantedOrganizationIDCondition(grantedOrgID string) database.Condition + // StateCondition returns a filter on the state field. + StateCondition(state ProjectGrantState) database.Condition + // RoleKeyCondition returns a filter on the role field. + RoleKeyCondition(op database.TextOperation, role string) database.Condition + ExistsRoleKey(cond database.Condition) database.Condition +} + +type projectGrantChanges 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 + // SetState sets the state column. + SetState(state ProjectGrantState) database.Change +} + +// ProjectGrantRepository manages project grants. +// +//go:generate mockgen -typed -package domainmock -destination ./mock/project_grant.mock.go . ProjectGrantRepository +type ProjectGrantRepository interface { + Repository + + projectGrantColumns + projectGrantConditions + projectGrantChanges + + // Get a single project grant. An error is returned if not exactly one project is found. + Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*ProjectGrant, error) + // List projects grant. An empty list is returned if no project grants are found. + List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*ProjectGrant, error) + // Create a new project grant. + Create(ctx context.Context, client database.QueryExecutor, project *ProjectGrant) error + // Update an existing project grant. + // The condition must include the instanceID and ID of the project grant to update. + Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, roleKeys []string, changes ...database.Change) (int64, error) + // Delete an existing project grant. + // The condition must include the instanceID and ID of the project grant to delete. + Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) +} diff --git a/backend/v3/domain/projectgrantstate_enumer.go b/backend/v3/domain/projectgrantstate_enumer.go new file mode 100644 index 000000000..f13b70ddc --- /dev/null +++ b/backend/v3/domain/projectgrantstate_enumer.go @@ -0,0 +1,109 @@ +// Code generated by "enumer -type ProjectGrantState -transform lower -trimprefix ProjectGrantState -sql"; DO NOT EDIT. + +package domain + +import ( + "database/sql/driver" + "fmt" + "strings" +) + +const _ProjectGrantStateName = "activeinactive" + +var _ProjectGrantStateIndex = [...]uint8{0, 6, 14} + +const _ProjectGrantStateLowerName = "activeinactive" + +func (i ProjectGrantState) String() string { + if i >= ProjectGrantState(len(_ProjectGrantStateIndex)-1) { + return fmt.Sprintf("ProjectGrantState(%d)", i) + } + return _ProjectGrantStateName[_ProjectGrantStateIndex[i]:_ProjectGrantStateIndex[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 _ProjectGrantStateNoOp() { + var x [1]struct{} + _ = x[ProjectGrantStateActive-(0)] + _ = x[ProjectGrantStateInactive-(1)] +} + +var _ProjectGrantStateValues = []ProjectGrantState{ProjectGrantStateActive, ProjectGrantStateInactive} + +var _ProjectGrantStateNameToValueMap = map[string]ProjectGrantState{ + _ProjectGrantStateName[0:6]: ProjectGrantStateActive, + _ProjectGrantStateLowerName[0:6]: ProjectGrantStateActive, + _ProjectGrantStateName[6:14]: ProjectGrantStateInactive, + _ProjectGrantStateLowerName[6:14]: ProjectGrantStateInactive, +} + +var _ProjectGrantStateNames = []string{ + _ProjectGrantStateName[0:6], + _ProjectGrantStateName[6:14], +} + +// ProjectGrantStateString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func ProjectGrantStateString(s string) (ProjectGrantState, error) { + if val, ok := _ProjectGrantStateNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _ProjectGrantStateNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to ProjectGrantState values", s) +} + +// ProjectGrantStateValues returns all values of the enum +func ProjectGrantStateValues() []ProjectGrantState { + return _ProjectGrantStateValues +} + +// ProjectGrantStateStrings returns a slice of all String values of the enum +func ProjectGrantStateStrings() []string { + strs := make([]string, len(_ProjectGrantStateNames)) + copy(strs, _ProjectGrantStateNames) + return strs +} + +// IsAProjectGrantState returns "true" if the value is listed in the enum definition. "false" otherwise +func (i ProjectGrantState) IsAProjectGrantState() bool { + for _, v := range _ProjectGrantStateValues { + if i == v { + return true + } + } + return false +} + +func (i ProjectGrantState) Value() (driver.Value, error) { + return i.String(), nil +} + +func (i *ProjectGrantState) 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 ProjectGrantState: %[1]T(%[1]v)", value) + } + + val, err := ProjectGrantStateString(str) + if err != nil { + return err + } + + *i = val + return nil +} diff --git a/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table.go b/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table.go new file mode 100644 index 000000000..a6e750d46 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table.go @@ -0,0 +1,16 @@ +package migration + +import ( + _ "embed" +) + +var ( + //go:embed 008_project_grants_table/up.sql + up008ProjectGrantsTable string + //go:embed 008_project_grants_table/down.sql + down008ProjectGrantsTable string +) + +func init() { + registerSQLMigration(8, up008ProjectGrantsTable, down008ProjectGrantsTable) +} diff --git a/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/down.sql b/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/down.sql new file mode 100644 index 000000000..1527a84a8 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/down.sql @@ -0,0 +1,3 @@ +DROP TYPE zitadel.project_grant_state; +DROP TABLE IF EXISTS zitadel.project_grant_roles; +DROP TABLE IF EXISTS zitadel.project_grants; diff --git a/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/up.sql b/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/up.sql new file mode 100644 index 000000000..cfa3c85f7 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/008_project_grants_table/up.sql @@ -0,0 +1,45 @@ +CREATE TYPE zitadel.project_grant_state AS ENUM ( + 'active', + 'inactive' +); + +CREATE TABLE zitadel.project_grants( + instance_id TEXT NOT NULL + , id TEXT NOT NULL CHECK (id <> '') + + , granting_organization_id TEXT NOT NULL + , project_id TEXT NOT NULL + , granted_organization_id TEXT NOT NULL + + , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + , updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + + , state zitadel.project_grant_state NOT NULL + + , PRIMARY KEY (instance_id, id) + + , FOREIGN KEY (instance_id, granting_organization_id) REFERENCES zitadel.organizations(instance_id, id) ON DELETE CASCADE + , FOREIGN KEY (instance_id, granted_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 + + , UNIQUE (instance_id, project_id, granted_organization_id) +); + +CREATE TRIGGER trg_set_updated_at_project_grants + BEFORE UPDATE ON zitadel.project_grants + FOR EACH ROW + WHEN (NEW.updated_at IS NULL) + EXECUTE FUNCTION zitadel.set_updated_at(); + +CREATE TABLE zitadel.project_grant_roles( + instance_id TEXT NOT NULL + , grant_id TEXT NOT NULL + , key TEXT NOT NULL CHECK (key <> '') + + , project_id TEXT NOT NULL + + , PRIMARY KEY (instance_id, grant_id, key) + + , FOREIGN KEY (instance_id, grant_id) REFERENCES zitadel.project_grants(instance_id, id) ON DELETE CASCADE + , FOREIGN KEY (instance_id, project_id, key) REFERENCES zitadel.project_roles(instance_id, project_id, key) ON DELETE CASCADE +); diff --git a/backend/v3/storage/database/events_testing/project_grant_test.go b/backend/v3/storage/database/events_testing/project_grant_test.go new file mode 100644 index 000000000..17b5f10c7 --- /dev/null +++ b/backend/v3/storage/database/events_testing/project_grant_test.go @@ -0,0 +1,154 @@ +//go:build integration + +package events_test + +import ( + "testing" + "time" + + "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" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + v2beta_project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func TestServer_ProjectGrantReduces(t *testing.T) { + instanceID := Instance.ID() + orgID := Instance.DefaultOrg.Id + projectGrantRepo := repository.ProjectGrantRepository() + + projectName := integration.ProjectName() + createProjectRes, err := ProjectClient.CreateProject(CTX, &v2beta_project.CreateProjectRequest{ + OrganizationId: orgID, + Name: projectName, + ProjectRoleAssertion: true, + AuthorizationRequired: true, + ProjectAccessRequired: true, + }) + require.NoError(t, err) + keyName := "key" + _, err = ProjectClient.AddProjectRole(CTX, &v2beta_project.AddProjectRoleRequest{ + ProjectId: createProjectRes.GetId(), + RoleKey: keyName, + DisplayName: "display", + Group: nil, + }) + require.NoError(t, err) + + grantedOrgRes, err := OrgClient.CreateOrganization(CTX, &org.CreateOrganizationRequest{ + Name: integration.OrganizationName(), + }) + require.NoError(t, err) + _, err = ProjectClient.CreateProjectGrant(CTX, &v2beta_project.CreateProjectGrantRequest{ + ProjectId: createProjectRes.GetId(), + GrantedOrganizationId: grantedOrgRes.GetId(), + }) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + + t.Run("create project grant reduces", func(t *testing.T) { + require.EventuallyWithT(t, func(collect *assert.CollectT) { + dbProjectGrant, err := projectGrantRepo.Get(CTX, pool, database.WithCondition( + database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.ProjectIDCondition(createProjectRes.GetId()), + projectGrantRepo.GrantedOrganizationIDCondition(grantedOrgRes.GetId()), + ), + )) + require.NoError(collect, err) + assert.Equal(collect, createProjectRes.GetId(), dbProjectGrant.ProjectID) + assert.Equal(collect, orgID, dbProjectGrant.GrantingOrganizationID) + assert.Equal(collect, grantedOrgRes.GetId(), dbProjectGrant.GrantedOrganizationID) + assert.Equal(collect, domain.ProjectGrantStateActive, dbProjectGrant.State) + assert.NotNil(collect, dbProjectGrant.CreatedAt) + assert.NotNil(collect, dbProjectGrant.UpdatedAt) + }, retryDuration, tick, "project grant not found within %v: %v", retryDuration, err) + }) + + t.Run("update project grant reduces", func(t *testing.T) { + _, err := ProjectClient.UpdateProjectGrant(CTX, &v2beta_project.UpdateProjectGrantRequest{ + ProjectId: createProjectRes.GetId(), + GrantedOrganizationId: grantedOrgRes.GetId(), + RoleKeys: []string{keyName}, + }) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + dbProjectGrant, err := projectGrantRepo.Get(CTX, pool, database.WithCondition( + database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.ProjectIDCondition(createProjectRes.GetId()), + projectGrantRepo.GrantedOrganizationIDCondition(grantedOrgRes.GetId()), + ), + )) + require.NoError(collect, err) + if !assert.Len(collect, dbProjectGrant.RoleKeys, 1) { + return + } + assert.Equal(collect, dbProjectGrant.RoleKeys[0], keyName) + }, retryDuration, tick, "project grant not updated within %v: %v", retryDuration, err) + }) + + t.Run("(de)activate project grant reduces", func(t *testing.T) { + _, err := ProjectClient.DeactivateProjectGrant(CTX, &v2beta_project.DeactivateProjectGrantRequest{ + ProjectId: createProjectRes.GetId(), + GrantedOrganizationId: grantedOrgRes.GetId(), + }) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + dbProject, err := projectGrantRepo.Get(CTX, pool, database.WithCondition( + database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.ProjectIDCondition(createProjectRes.GetId()), + projectGrantRepo.GrantedOrganizationIDCondition(grantedOrgRes.GetId()), + ), + )) + require.NoError(collect, err) + assert.Equal(collect, domain.ProjectGrantStateInactive, dbProject.State) + }, retryDuration, tick, "project grant not deactivated within %v: %v", retryDuration, err) + + _, err = ProjectClient.ActivateProjectGrant(CTX, &v2beta_project.ActivateProjectGrantRequest{ + ProjectId: createProjectRes.GetId(), + GrantedOrganizationId: grantedOrgRes.GetId(), + }) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + dbProject, err := projectGrantRepo.Get(CTX, pool, database.WithCondition( + database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.ProjectIDCondition(createProjectRes.GetId()), + projectGrantRepo.GrantedOrganizationIDCondition(grantedOrgRes.GetId()), + ), + )) + require.NoError(collect, err) + assert.Equal(collect, domain.ProjectGrantStateActive, dbProject.State) + }, retryDuration, tick, "project grant not activated within %v: %v", retryDuration, err) + }) + + t.Run("delete project grant reduces", func(t *testing.T) { + _, err := ProjectClient.DeleteProjectGrant(CTX, &v2beta_project.DeleteProjectGrantRequest{ + ProjectId: createProjectRes.GetId(), + GrantedOrganizationId: grantedOrgRes.GetId(), + }) + require.NoError(t, err) + + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + _, err := projectGrantRepo.Get(CTX, pool, database.WithCondition( + database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.ProjectIDCondition(createProjectRes.GetId()), + projectGrantRepo.GrantedOrganizationIDCondition(grantedOrgRes.GetId()), + ), + )) + require.ErrorIs(collect, err, database.NewNoRowFoundError(nil)) + }, retryDuration, tick, "project grant not deleted within %v: %v", retryDuration, err) + }) +} diff --git a/backend/v3/storage/database/repository/project.go b/backend/v3/storage/database/repository/project.go index ce27faf63..c3a38fb1e 100644 --- a/backend/v3/storage/database/repository/project.go +++ b/backend/v3/storage/database/repository/project.go @@ -24,7 +24,7 @@ func (project) Role() domain.ProjectRoleRepository { } func (p project) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.Project, error) { - builder, err := p.prepareQuery(opts) + builder, err := p.prepareGetQuery(opts) if err != nil { return nil, err } @@ -32,7 +32,7 @@ func (p project) Get(ctx context.Context, client database.QueryExecutor, opts .. } func (p project) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.Project, error) { - builder, err := p.prepareQuery(opts) + builder, err := p.prepareListQuery(opts) if err != nil { return nil, err } @@ -62,31 +62,11 @@ func (project) Create(ctx context.Context, client database.QueryExecutor, projec } 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()...) + return updateOne(ctx, client, p, condition, changes...) } 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()...) + return deleteOne(ctx, client, p, condition) } // ------------------------------------------------------------- @@ -140,6 +120,10 @@ func (p project) OrganizationIDCondition(organizationID string) database.Conditi return database.NewTextCondition(p.OrganizationIDColumn(), database.TextOperationEqual, organizationID) } +func (p project) GrantedOrganizationIDCondition(organizationID string) database.Condition { + return database.NewTextCondition(p.GrantedOrganizationIDColumn(), database.TextOperationEqual, organizationID) +} + func (p project) IDCondition(projectID string) database.Condition { return database.NewTextCondition(p.IDColumn(), database.TextOperationEqual, projectID) } @@ -156,6 +140,10 @@ func (p project) StateCondition(state domain.ProjectState) database.Condition { // columns // ------------------------------------------------------------- +func (p project) qualifiedTableName() string { + return "zitadel." + p.unqualifiedTableName() +} + func (project) unqualifiedTableName() string { return "projects" } @@ -176,6 +164,10 @@ func (p project) OrganizationIDColumn() database.Column { return database.NewColumn(p.unqualifiedTableName(), "organization_id") } +func (p project) GrantedOrganizationIDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "granted_organization_id") +} + func (p project) IDColumn() database.Column { return database.NewColumn(p.unqualifiedTableName(), "id") } @@ -227,10 +219,13 @@ const queryProjectStmt = `SELECT projects.should_assert_role, projects.is_authorization_required, projects.is_project_access_required, - projects.used_labeling_setting_owner + projects.used_labeling_setting_owner, + NULL::TEXT AS grant_id, + NULL::TEXT AS granted_organization_id, + NULL::zitadel.project_grant_state AS grant_state FROM zitadel.projects` -func (p project) prepareQuery(opts []database.QueryOption) (*database.StatementBuilder, error) { +func (p project) prepareGetQuery(opts []database.QueryOption) (*database.StatementBuilder, error) { options := new(database.QueryOpts) for _, opt := range opts { opt(options) @@ -243,3 +238,61 @@ func (p project) prepareQuery(opts []database.QueryOption) (*database.StatementB return builder, nil } + +const queryGrantedProjectStmt = `SELECT + projects.instance_id, + projects.organization_id, + projects.id as id, + grants.created_at, + grants.updated_at, + projects.name, + projects.state, + projects.should_assert_role, + projects.is_authorization_required, + projects.is_project_access_required, + projects.used_labeling_setting_owner, + grants.id AS grant_id, + grants.granted_organization_id, + grants.state as grant_state + FROM zitadel.projects + INNER JOIN zitadel.project_grants as grants + ON grants.instance_id = projects.instance_id + AND grants.granting_organization_id = projects.organization_id + AND grants.project_id = projects.id` + +const queryProjectsUnionStmt = `SELECT + projects.instance_id, + projects.organization_id, + projects.id as 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, + projects.grant_id, + projects.granted_organization_id, + projects.grant_state + FROM (` + + queryProjectStmt + + ` UNION ALL ` + + queryGrantedProjectStmt + + `) AS projects` + +func (p project) prepareListQuery(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( + queryProjectsUnionStmt, + ) + options.Write(builder) + + return builder, nil +} diff --git a/backend/v3/storage/database/repository/project_grant.go b/backend/v3/storage/database/repository/project_grant.go new file mode 100644 index 000000000..0e1106d43 --- /dev/null +++ b/backend/v3/storage/database/repository/project_grant.go @@ -0,0 +1,305 @@ +package repository + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +// ------------------------------------------------------------- +// repository +// ------------------------------------------------------------- + +type projectGrant struct{} + +// ProjectGrantRepository manages project grants. +func ProjectGrantRepository() domain.ProjectGrantRepository { + return new(projectGrant) +} + +func (p projectGrant) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.ProjectGrant, error) { + opts = append(opts, + database.WithGroupBy(p.InstanceIDColumn(), p.IDColumn()), + ) + + builder, err := p.prepareQuery(opts) + if err != nil { + return nil, err + } + return getOne[domain.ProjectGrant](ctx, client, builder) +} + +func (p projectGrant) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.ProjectGrant, error) { + opts = append(opts, + database.WithGroupBy(p.InstanceIDColumn(), p.IDColumn()), + ) + + builder, err := p.prepareQuery(opts) + if err != nil { + return nil, err + } + return getMany[domain.ProjectGrant](ctx, client, builder) +} + +const insertProjectGrantRolesStmt = `WITH added_roles AS ( + INSERT INTO zitadel.project_grant_roles ( + instance_id, grant_id, project_id, key + ) + VALUES ($2, $3, $4, unnest($1::text[])) +) ` + +func (p projectGrant) Create(ctx context.Context, client database.QueryExecutor, projectGrant *domain.ProjectGrant) error { + var ( + createdAt, updatedAt any = database.DefaultInstruction, database.DefaultInstruction + ) + if !projectGrant.CreatedAt.IsZero() { + createdAt = projectGrant.CreatedAt + } + if !projectGrant.UpdatedAt.IsZero() { + updatedAt = projectGrant.UpdatedAt + } + + // separate statement to add roles to project grant + builder := database.NewStatementBuilder(insertProjectGrantRolesStmt, projectGrant.RoleKeys) + + builder.WriteString(`INSERT INTO ` + p.qualifiedTableName() + ` (instance_id, id, project_id, granting_organization_id, granted_organization_id, state, created_at, updated_at) VALUES ( `) + builder.WriteArgs( + projectGrant.InstanceID, + projectGrant.ID, + projectGrant.ProjectID, + projectGrant.GrantingOrganizationID, + projectGrant.GrantedOrganizationID, + projectGrant.State, + createdAt, + updatedAt, + ) + builder.WriteString(` ) RETURNING created_at, updated_at`) + + return client.QueryRow(ctx, builder.String(), builder.Args()...). + Scan(&projectGrant.CreatedAt, &projectGrant.UpdatedAt) +} + +const queryUpdateProjectGrantRoleStmt = `SELECT instance_id, id, project_id, $1::text[] as keys from zitadel.project_grants` + +const updateProjectGrantRoleStmt = `removed_roles AS ( + DELETE FROM zitadel.project_grant_roles as pgr + USING pg + WHERE pg.instance_id = pgr.instance_id AND pg.id = pgr.grant_id AND NOT(pgr.key = ANY(pg.keys)) +), added_roles AS ( + INSERT INTO zitadel.project_grant_roles ( + instance_id, grant_id, project_id, key + ) + SELECT instance_id, id, project_id, unnest(pg.keys) FROM pg + ON CONFLICT (instance_id, grant_id, key) DO NOTHING +) +UPDATE zitadel.project_grants SET ` + +const updateProjectGrantRoleStmtWhere = ` FROM pg +WHERE pg.instance_id = project_grants.instance_id + AND pg.id = project_grants.id` + +func (p projectGrant) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, roleKeys []string, changes ...database.Change) (int64, error) { + // if no role keys set we only have to update the project grant table + if roleKeys == nil { + return updateOne(ctx, client, p, condition, changes...) + } + + // if you want to update the roles you have to have the primary key, otherwise multiple project grants get updated + 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)) + } + + // the statement begins with getting the project grant we want to update + builder := database.NewStatementBuilder("WITH pg AS (") + builder.WriteString(queryUpdateProjectGrantRoleStmt) + builder.AppendArg(roleKeys) + writeCondition(builder, condition) + builder.WriteString(" ), ") + + // now we add the logic to do the required changes based on the given project grant + builder.WriteString(updateProjectGrantRoleStmt) + database.Changes(changes).Write(builder) + builder.WriteString(updateProjectGrantRoleStmtWhere) + + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +func (p projectGrant) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { + return deleteOne(ctx, client, p, condition) +} + +// ------------------------------------------------------------- +// changes +// ------------------------------------------------------------- + +func (p projectGrant) SetUpdatedAt(updatedAt time.Time) database.Change { + return database.NewChange(p.UpdatedAtColumn(), updatedAt) +} + +func (p projectGrant) SetState(state domain.ProjectGrantState) database.Change { + return database.NewChange(p.StateColumn(), state) +} + +// ------------------------------------------------------------- +// conditions +// ------------------------------------------------------------- + +func (p projectGrant) PrimaryKeyCondition(instanceID, projectGrantID string) database.Condition { + return database.And( + p.InstanceIDCondition(instanceID), + p.IDCondition(projectGrantID), + ) +} + +func (p projectGrant) InstanceIDCondition(instanceID string) database.Condition { + return database.NewTextCondition(p.InstanceIDColumn(), database.TextOperationEqual, instanceID) +} + +func (p projectGrant) OrganizationCondition(organizationID string) database.Condition { + return database.Or( + p.GrantingOrganizationIDCondition(organizationID), + p.GrantedOrganizationIDCondition(organizationID), + ) +} + +func (p projectGrant) IDCondition(projectGrantID string) database.Condition { + return database.NewTextCondition(p.IDColumn(), database.TextOperationEqual, projectGrantID) +} + +func (p projectGrant) ProjectIDCondition(projectID string) database.Condition { + return database.NewTextCondition(p.ProjectIDColumn(), database.TextOperationEqual, projectID) +} + +func (p projectGrant) GrantingOrganizationIDCondition(organizationID string) database.Condition { + return database.NewTextCondition(p.GrantingOrganizationIDColumn(), database.TextOperationEqual, organizationID) +} + +func (p projectGrant) GrantedOrganizationIDCondition(organizationID string) database.Condition { + return database.NewTextCondition(p.GrantedOrganizationIDColumn(), database.TextOperationEqual, organizationID) +} + +func (p projectGrant) StateCondition(state domain.ProjectGrantState) database.Condition { + return database.NewTextCondition(p.StateColumn(), database.TextOperationEqual, state.String()) +} + +func (p projectGrant) RoleKeyCondition(op database.TextOperation, role string) database.Condition { + return database.NewTextCondition(database.NewColumn(p.unqualifiedRolesTableName(), "key"), op, role) +} + +// ExistsRoleKey creates a correlated [database.Exists] condition on project_grant_roles. +// Use this filter to make sure the project grant returned contains a specific project grant role. +// Example usage: +// +// projectGrant, _ := projectGrantRepo.Get(ctx, +// database.WithCondition( +// database.And( +// projectGrantRepo.InstanceIDCondition(instanceID), +// projectGrantRepo.ExistsRoleKey(projectGrantRepo.RoleKeyCondition(database.TextOperationEqual, "admin")), +// ), +// ), +// ) +func (p projectGrant) ExistsRoleKey(cond database.Condition) database.Condition { + return database.Exists( + p.qualifiedRolesTableName(), + database.And( + database.NewColumnCondition(p.InstanceIDColumn(), database.NewColumn(p.unqualifiedRolesTableName(), "instance_id")), + database.NewColumnCondition(p.IDColumn(), database.NewColumn(p.unqualifiedRolesTableName(), "grant_id")), + cond, + ), + ) +} + +// ------------------------------------------------------------- +// columns +// ------------------------------------------------------------- + +func (p projectGrant) qualifiedTableName() string { + return "zitadel." + p.unqualifiedTableName() +} + +func (projectGrant) unqualifiedTableName() string { + return "project_grants" +} + +func (p projectGrant) qualifiedRolesTableName() string { + return "zitadel." + p.unqualifiedRolesTableName() +} + +func (projectGrant) unqualifiedRolesTableName() string { + return "project_grant_roles" +} + +func (p projectGrant) PrimaryKeyColumns() []database.Column { + return []database.Column{ + p.InstanceIDColumn(), + p.IDColumn(), + } +} + +func (p projectGrant) InstanceIDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "instance_id") +} + +func (p projectGrant) IDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "id") +} + +func (p projectGrant) ProjectIDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "project_id") +} + +func (p projectGrant) GrantingOrganizationIDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "granting_organization_id") +} + +func (p projectGrant) GrantedOrganizationIDColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "granted_organization_id") +} + +func (p projectGrant) CreatedAtColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "created_at") +} + +func (p projectGrant) UpdatedAtColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "updated_at") +} + +func (p projectGrant) StateColumn() database.Column { + return database.NewColumn(p.unqualifiedTableName(), "state") +} + +// ------------------------------------------------------------- +// helpers +// ------------------------------------------------------------- + +const queryProjectGrantStmt = `SELECT + zitadel.project_grants.instance_id, + zitadel.project_grants.id, + zitadel.project_grants.project_id, + zitadel.project_grants.granting_organization_id, + zitadel.project_grants.granted_organization_id, + zitadel.project_grants.created_at, + zitadel.project_grants.updated_at, + zitadel.project_grants.state, + ARRAY_AGG(project_grant_roles.key) FILTER (WHERE project_grant_roles.grant_id IS NOT NULL) AS role_keys + FROM zitadel.project_grants + LEFT JOIN zitadel.project_grant_roles ON zitadel.project_grant_roles.instance_id = zitadel.project_grants.instance_id AND zitadel.project_grant_roles.grant_id = zitadel.project_grants.id` + +func (p projectGrant) 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(queryProjectGrantStmt) + options.Write(builder) + + return builder, nil +} diff --git a/backend/v3/storage/database/repository/project_grant_test.go b/backend/v3/storage/database/repository/project_grant_test.go new file mode 100644 index 000000000..d3fc08b5a --- /dev/null +++ b/backend/v3/storage/database/repository/project_grant_test.go @@ -0,0 +1,639 @@ +package repository_test + +import ( + "testing" + "time" + + "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 TestGetProjectGrant(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + grantingOrgID := createOrganization(t, tx, instanceID) + firstGrantedOrgID := createOrganization(t, tx, instanceID) + secondGrantedOrgID := createOrganization(t, tx, instanceID) + projectID := createProject(t, tx, instanceID, grantingOrgID) + firstRoleKey := createProjectRole(t, tx, instanceID, grantingOrgID, projectID, "") + secondRoleKey := createProjectRole(t, tx, instanceID, grantingOrgID, projectID, "") + thirdRoleKey := createProjectRole(t, tx, instanceID, grantingOrgID, projectID, "") + projectGrantRepo := repository.ProjectGrantRepository() + + firstProjectGrant := &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + GrantingOrganizationID: grantingOrgID, + ProjectID: projectID, + GrantedOrganizationID: firstGrantedOrgID, + State: domain.ProjectGrantStateActive, + RoleKeys: []string{firstRoleKey}, + } + err := projectGrantRepo.Create(t.Context(), tx, firstProjectGrant) + require.NoError(t, err) + secondProjectGrant := &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + GrantingOrganizationID: grantingOrgID, + ProjectID: projectID, + GrantedOrganizationID: secondGrantedOrgID, + State: domain.ProjectGrantStateInactive, + RoleKeys: []string{firstRoleKey, secondRoleKey, thirdRoleKey}, + } + err = projectGrantRepo.Create(t.Context(), tx, secondProjectGrant) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + want *domain.ProjectGrant + wantErr error + }{ + { + name: "incomplete condition", + condition: projectGrantRepo.IDCondition(firstProjectGrant.ID), + wantErr: database.NewMissingConditionError(projectGrantRepo.IDColumn()), + }, + { + name: "not found", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, "nix"), + wantErr: database.NewNoRowFoundError(nil), + }, + { + name: "too many", + condition: projectGrantRepo.InstanceIDCondition(instanceID), + wantErr: database.NewMultipleRowsFoundError(nil), + }, + { + name: "ok first", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, firstProjectGrant.ID), + want: firstProjectGrant, + }, + { + name: "ok second", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, secondProjectGrant.ID), + want: secondProjectGrant, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := projectGrantRepo.Get(t.Context(), tx, database.WithCondition(tt.condition)) + require.ErrorIs(t, err, tt.wantErr) + assertProjectGrant(t, tt.want, got) + }) + } +} + +func TestListProjectGrants(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + firstGrantingOrgID := createOrganization(t, tx, instanceID) + firstProjectID := createProject(t, tx, instanceID, firstGrantingOrgID) + firstRoleKey := createProjectRole(t, tx, instanceID, firstGrantingOrgID, firstProjectID, "") + secondRoleKey := createProjectRole(t, tx, instanceID, firstGrantingOrgID, firstProjectID, "") + thirdRoleKey := createProjectRole(t, tx, instanceID, firstGrantingOrgID, firstProjectID, "") + secondGrantingOrgID := createOrganization(t, tx, instanceID) + secondProjectID := createProject(t, tx, instanceID, secondGrantingOrgID) + + firstGrantedOrgID := createOrganization(t, tx, instanceID) + secondGrantedOrgID := createOrganization(t, tx, instanceID) + projectGrantRepo := repository.ProjectGrantRepository() + + projectGrants := [...]*domain.ProjectGrant{ + { + InstanceID: instanceID, + ID: "1", + ProjectID: firstProjectID, + GrantedOrganizationID: firstGrantedOrgID, + GrantingOrganizationID: firstGrantingOrgID, + State: domain.ProjectGrantStateActive, + RoleKeys: []string{firstRoleKey}, + }, + { + InstanceID: instanceID, + ID: "2", + ProjectID: firstProjectID, + GrantedOrganizationID: secondGrantedOrgID, + GrantingOrganizationID: firstGrantingOrgID, + State: domain.ProjectGrantStateInactive, + RoleKeys: []string{secondRoleKey, thirdRoleKey}, + }, + { + InstanceID: instanceID, + ID: "3", + ProjectID: secondProjectID, + GrantedOrganizationID: firstGrantedOrgID, + GrantingOrganizationID: secondGrantingOrgID, + State: domain.ProjectGrantStateInactive, + }, + { + InstanceID: instanceID, + ID: "4", + ProjectID: secondProjectID, + GrantedOrganizationID: secondGrantedOrgID, + GrantingOrganizationID: secondGrantingOrgID, + State: domain.ProjectGrantStateActive, + }, + } + for _, projectGrant := range projectGrants { + err := projectGrantRepo.Create(t.Context(), tx, projectGrant) + require.NoError(t, err) + } + + tests := []struct { + name string + condition database.Condition + want []*domain.ProjectGrant + wantErr error + }{ + { + name: "incomplete condition", + condition: projectGrantRepo.GrantedOrganizationIDCondition(firstGrantedOrgID), + wantErr: database.NewMissingConditionError(projectGrantRepo.IDColumn()), + }, + { + name: "no results, ok", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, "nix"), + }, + { + name: "all from instance", + condition: projectGrantRepo.InstanceIDCondition(instanceID), + want: projectGrants[:], + }, + { + name: "all granting from first org", + condition: database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.GrantingOrganizationIDCondition(firstGrantingOrgID), + ), + want: projectGrants[0:2], + }, + { + name: "all granting from second org", + condition: database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.GrantingOrganizationIDCondition(secondGrantingOrgID), + ), + want: projectGrants[2:4], + }, + { + name: "all granted from first org", + condition: database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.GrantingOrganizationIDCondition(firstGrantingOrgID), + ), + want: []*domain.ProjectGrant{projectGrants[0], projectGrants[1]}, + }, + { + name: "all granted to second org", + condition: database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.GrantedOrganizationIDCondition(secondGrantedOrgID), + ), + want: []*domain.ProjectGrant{projectGrants[1], projectGrants[3]}, + }, + { + name: "state active", + condition: database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.StateCondition(domain.ProjectGrantStateActive), + ), + want: []*domain.ProjectGrant{projectGrants[0], projectGrants[3]}, + }, + { + name: "exists role key", + condition: database.And( + projectGrantRepo.InstanceIDCondition(instanceID), + projectGrantRepo.ExistsRoleKey(projectGrantRepo.RoleKeyCondition(database.TextOperationEqual, firstRoleKey)), + ), + want: []*domain.ProjectGrant{projectGrants[0]}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := projectGrantRepo.List(t.Context(), tx, + database.WithCondition(tt.condition), + database.WithOrderByAscending(projectGrantRepo.PrimaryKeyColumns()...), + ) + require.ErrorIs(t, err, tt.wantErr) + + if !assert.Len(t, got, len(tt.want)) { + return + } + for i, want := range tt.want { + assertProjectGrant(t, want, got[i]) + } + }) + } +} + +func TestCreateProjectGrant(t *testing.T) { + beforeCreate := time.Now() + + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + grantingOrgID := createOrganization(t, tx, instanceID) + firstGrantedOrgID := createOrganization(t, tx, instanceID) + secondGrantedOrgID := createOrganization(t, tx, instanceID) + thirdGrantedOrgID := createOrganization(t, tx, instanceID) + fourthGrantedOrgID := createOrganization(t, tx, instanceID) + projectID := createProject(t, tx, instanceID, grantingOrgID) + firstRoleKey := createProjectRole(t, tx, instanceID, grantingOrgID, projectID, "") + secondRoleKey := createProjectRole(t, tx, instanceID, grantingOrgID, projectID, "") + thirdRoleKey := createProjectRole(t, tx, instanceID, grantingOrgID, projectID, "") + projectGrantRepo := repository.ProjectGrantRepository() + + existingProjectGrant := &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: firstGrantedOrgID, + State: domain.ProjectGrantStateActive, + } + err := projectGrantRepo.Create(t.Context(), tx, existingProjectGrant) + require.NoError(t, err) + + tests := []struct { + name string + projectGrant *domain.ProjectGrant + wantErr error + }{ + { + name: "add project grant", + projectGrant: &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: secondGrantedOrgID, + State: domain.ProjectGrantStateActive, + }, + }, + { + name: "non-existing instance", + projectGrant: &domain.ProjectGrant{ + InstanceID: "foo", + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: secondGrantedOrgID, + State: domain.ProjectGrantStateActive, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "non-existing org", + projectGrant: &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: "foo", + GrantedOrganizationID: secondGrantedOrgID, + State: domain.ProjectGrantStateActive, + }, + wantErr: new(database.ForeignKeyError), + }, + { + name: "empty id error", + projectGrant: &domain.ProjectGrant{ + InstanceID: instanceID, + ID: "", + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: secondGrantedOrgID, + State: domain.ProjectGrantStateActive, + }, + wantErr: new(database.CheckError), + }, + { + name: "duplicate project grant", + projectGrant: existingProjectGrant, + wantErr: new(database.UniqueError), + }, + { + name: "add project grant with role", + projectGrant: &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: thirdGrantedOrgID, + State: domain.ProjectGrantStateActive, + RoleKeys: []string{firstRoleKey}, + }, + }, + { + name: "add project grant with multiple roles", + projectGrant: &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: fourthGrantedOrgID, + State: domain.ProjectGrantStateActive, + RoleKeys: []string{firstRoleKey, secondRoleKey, thirdRoleKey}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + err := projectGrantRepo.Create(t.Context(), savepoint, tt.projectGrant) + require.ErrorIs(t, err, tt.wantErr) + if err != nil { + return + } + afterCreate := time.Now() + + // check instance values + projectGrant, err := projectGrantRepo.Get(t.Context(), tx, + database.WithCondition( + projectGrantRepo.PrimaryKeyCondition(tt.projectGrant.InstanceID, tt.projectGrant.ID), + ), + ) + require.NoError(t, err) + assertProjectGrant(t, tt.projectGrant, projectGrant) + assert.WithinRange(t, projectGrant.CreatedAt, beforeCreate, afterCreate) + assert.WithinRange(t, projectGrant.UpdatedAt, beforeCreate, afterCreate) + }) + } +} + +func assertProjectGrant(t assert.TestingT, expected *domain.ProjectGrant, actual *domain.ProjectGrant) { + if expected == nil { + assert.Nil(t, actual) + return + } + assert.Equal(t, expected.ID, actual.ID) + assert.Equal(t, expected.ProjectID, actual.ProjectID) + assert.Equal(t, expected.GrantingOrganizationID, actual.GrantingOrganizationID) + assert.Equal(t, expected.GrantedOrganizationID, actual.GrantedOrganizationID) + assert.Equal(t, expected.State, actual.State) + assert.ElementsMatch(t, expected.RoleKeys, actual.RoleKeys) +} + +func TestUpdateProjectGrant(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + grantingOrgID := createOrganization(t, tx, instanceID) + firstGrantedOrgID := createOrganization(t, tx, instanceID) + secondGrantedOrgID := createOrganization(t, tx, instanceID) + thirdGrantedOrgID := createOrganization(t, tx, instanceID) + fourthGrantedOrgID := createOrganization(t, tx, instanceID) + fifthGrantedOrgID := createOrganization(t, tx, instanceID) + projectID := createProject(t, tx, instanceID, grantingOrgID) + firstRoleKey := createProjectRole(t, tx, instanceID, grantingOrgID, projectID, "") + secondRoleKey := createProjectRole(t, tx, instanceID, grantingOrgID, projectID, "") + thirdRoleKey := createProjectRole(t, tx, instanceID, grantingOrgID, projectID, "") + projectGrantRepo := repository.ProjectGrantRepository() + + existingProjectGrant := &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: firstGrantedOrgID, + State: domain.ProjectGrantStateActive, + } + err := projectGrantRepo.Create(t.Context(), tx, existingProjectGrant) + require.NoError(t, err) + + existingProjectGrantWithRole := &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: secondGrantedOrgID, + State: domain.ProjectGrantStateActive, + RoleKeys: []string{firstRoleKey}, + } + err = projectGrantRepo.Create(t.Context(), tx, existingProjectGrantWithRole) + require.NoError(t, err) + + existingProjectGrantWithRoles := &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: thirdGrantedOrgID, + State: domain.ProjectGrantStateActive, + RoleKeys: []string{firstRoleKey, secondRoleKey, thirdRoleKey}, + } + err = projectGrantRepo.Create(t.Context(), tx, existingProjectGrantWithRoles) + require.NoError(t, err) + + existingProjectGrantWithAllRoles := &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: fourthGrantedOrgID, + State: domain.ProjectGrantStateActive, + RoleKeys: []string{firstRoleKey, secondRoleKey, thirdRoleKey}, + } + err = projectGrantRepo.Create(t.Context(), tx, existingProjectGrantWithAllRoles) + require.NoError(t, err) + + existingProjectGrantToRemoveRoles := &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: fifthGrantedOrgID, + State: domain.ProjectGrantStateActive, + RoleKeys: []string{firstRoleKey, secondRoleKey, thirdRoleKey}, + } + err = projectGrantRepo.Create(t.Context(), tx, existingProjectGrantToRemoveRoles) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + roleKeys []string + changes []database.Change + wantRowsAffected int64 + wantErr error + assertChanges func(t *testing.T, project *domain.ProjectGrant) + }{ + { + name: "no changes", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, existingProjectGrant.ID), + changes: []database.Change{}, + wantRowsAffected: 0, + wantErr: database.ErrNoChanges, + }, + { + name: "incomplete condition", + condition: projectGrantRepo.InstanceIDCondition(instanceID), + changes: []database.Change{ + projectGrantRepo.SetState(domain.ProjectGrantStateActive), + }, + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(projectGrantRepo.IDColumn()), + }, + { + name: "set state", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, existingProjectGrant.ID), + changes: []database.Change{ + projectGrantRepo.SetState(domain.ProjectGrantStateInactive), + }, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProject *domain.ProjectGrant) { + assert.Equal(t, domain.ProjectGrantStateInactive, updatedProject.State) + }, + }, + { + name: "incomplete condition for set rolekey", + condition: projectGrantRepo.InstanceIDCondition(instanceID), + roleKeys: []string{firstRoleKey}, + changes: []database.Change{ + projectGrantRepo.SetState(domain.ProjectGrantStateActive), + }, + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(projectGrantRepo.IDColumn()), + }, + { + name: "set rolekey", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, existingProjectGrant.ID), + roleKeys: []string{firstRoleKey}, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProjectGrant *domain.ProjectGrant) { + assert.WithinRange(t, updatedProjectGrant.CreatedAt, existingProjectGrant.CreatedAt, existingProjectGrant.CreatedAt) + assert.WithinRange(t, updatedProjectGrant.UpdatedAt, existingProjectGrant.UpdatedAt, existingProjectGrant.UpdatedAt.Add(time.Second)) + assert.ElementsMatch(t, []string{firstRoleKey}, updatedProjectGrant.RoleKeys) + }, + }, + { + name: "add rolekeys", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, existingProjectGrantWithRole.ID), + roleKeys: []string{firstRoleKey, secondRoleKey, thirdRoleKey}, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProjectGrant *domain.ProjectGrant) { + assert.WithinRange(t, updatedProjectGrant.CreatedAt, existingProjectGrantWithRole.CreatedAt, existingProjectGrantWithRole.CreatedAt) + assert.WithinRange(t, updatedProjectGrant.UpdatedAt, existingProjectGrantWithRole.UpdatedAt, existingProjectGrantWithRole.UpdatedAt.Add(time.Second)) + assert.ElementsMatch(t, []string{firstRoleKey, secondRoleKey, thirdRoleKey}, updatedProjectGrant.RoleKeys) + }, + }, + { + name: "remove rolekeys", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, existingProjectGrantWithRoles.ID), + roleKeys: []string{firstRoleKey}, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProjectGrant *domain.ProjectGrant) { + assert.WithinRange(t, updatedProjectGrant.CreatedAt, existingProjectGrantWithRoles.CreatedAt, existingProjectGrantWithRoles.CreatedAt) + assert.WithinRange(t, updatedProjectGrant.UpdatedAt, existingProjectGrantWithRoles.UpdatedAt, existingProjectGrantWithRoles.UpdatedAt.Add(time.Second)) + assert.ElementsMatch(t, []string{firstRoleKey}, updatedProjectGrant.RoleKeys) + }, + }, + { + name: "remove all rolekeys", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, existingProjectGrantToRemoveRoles.ID), + roleKeys: []string{}, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProjectGrant *domain.ProjectGrant) { + assert.WithinRange(t, updatedProjectGrant.CreatedAt, existingProjectGrantToRemoveRoles.CreatedAt, existingProjectGrantToRemoveRoles.CreatedAt) + assert.WithinRange(t, updatedProjectGrant.UpdatedAt, existingProjectGrantToRemoveRoles.UpdatedAt, existingProjectGrantToRemoveRoles.UpdatedAt.Add(time.Second)) + assert.ElementsMatch(t, []string{}, updatedProjectGrant.RoleKeys) + }, + }, + { + name: "no changes role keys", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, existingProjectGrantWithAllRoles.ID), + roleKeys: []string{firstRoleKey, secondRoleKey, thirdRoleKey}, + wantRowsAffected: 1, + assertChanges: func(t *testing.T, updatedProjectGrant *domain.ProjectGrant) { + assert.WithinRange(t, updatedProjectGrant.CreatedAt, existingProjectGrantWithAllRoles.CreatedAt, existingProjectGrantWithAllRoles.CreatedAt) + assert.WithinRange(t, updatedProjectGrant.UpdatedAt, existingProjectGrantWithAllRoles.UpdatedAt, existingProjectGrantWithAllRoles.UpdatedAt.Add(time.Second)) + assert.ElementsMatch(t, []string{firstRoleKey, secondRoleKey, thirdRoleKey}, updatedProjectGrant.RoleKeys) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savepoint, rollback := savepointForRollback(t, tx) + defer rollback() + rowsAffected, err := projectGrantRepo.Update(t.Context(), savepoint, tt.condition, tt.roleKeys, tt.changes...) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantRowsAffected, rowsAffected) + + if tt.assertChanges != nil { + updatedProjectGrant, err := projectGrantRepo.Get(t.Context(), savepoint, database.WithCondition(tt.condition)) + require.NoError(t, err) + tt.assertChanges(t, updatedProjectGrant) + } + }) + } +} + +func TestDeleteProjectGrant(t *testing.T) { + tx, rollback := transactionForRollback(t) + defer rollback() + instanceID := createInstance(t, tx) + grantingOrgID := createOrganization(t, tx, instanceID) + grantedOrgID := createOrganization(t, tx, instanceID) + projectID := createProject(t, tx, instanceID, grantingOrgID) + roleKey := createProjectRole(t, tx, instanceID, grantingOrgID, projectID, "") + projectGrantRepo := repository.ProjectGrantRepository() + + existingProjectGrant := &domain.ProjectGrant{ + InstanceID: instanceID, + ID: integration.ID(), + ProjectID: projectID, + GrantingOrganizationID: grantingOrgID, + GrantedOrganizationID: grantedOrgID, + State: domain.ProjectGrantStateActive, + RoleKeys: []string{roleKey}, + } + err := projectGrantRepo.Create(t.Context(), tx, existingProjectGrant) + require.NoError(t, err) + + tests := []struct { + name string + condition database.Condition + wantRowsAffected int64 + wantErr error + }{ + { + name: "incomplete condition", + condition: projectGrantRepo.InstanceIDCondition(instanceID), + wantRowsAffected: 0, + wantErr: database.NewMissingConditionError(projectGrantRepo.IDColumn()), + }, + { + name: "not found", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, "foo"), + wantRowsAffected: 0, + }, + { + name: "delete project grant", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, existingProjectGrant.ID), + wantRowsAffected: 1, + }, + { + name: "delete project grant twice", + condition: projectGrantRepo.PrimaryKeyCondition(instanceID, existingProjectGrant.ID), + wantRowsAffected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rowsAffected, err := projectGrantRepo.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/project_test.go b/backend/v3/storage/database/repository/project_test.go index a972f3510..9d5b14d89 100644 --- a/backend/v3/storage/database/repository/project_test.go +++ b/backend/v3/storage/database/repository/project_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -89,7 +90,10 @@ func TestListProjects(t *testing.T) { instanceID := createInstance(t, tx) firstOrgID := createOrganization(t, tx, instanceID) secondOrgID := createOrganization(t, tx, instanceID) + thirdOrgID := createOrganization(t, tx, instanceID) + grantedOrgID := createOrganization(t, tx, instanceID) projectRepo := repository.ProjectRepository() + projectGrantRepo := repository.ProjectGrantRepository() projects := [...]*domain.Project{ { @@ -136,10 +140,59 @@ func TestListProjects(t *testing.T) { IsProjectAccessRequired: true, UsedLabelingSettingOwner: 1, }, + { + InstanceID: instanceID, + OrganizationID: thirdOrgID, + ID: "5", + Name: "granted", + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, + { + InstanceID: instanceID, + OrganizationID: thirdOrgID, + ID: "5", + Name: "granted", + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + GrantID: gu.Ptr("1"), + GrantedOrganizationID: gu.Ptr(grantedOrgID), + GrantState: gu.Ptr(domain.ProjectGrantStateActive), + }, + { + InstanceID: instanceID, + OrganizationID: grantedOrgID, + ID: "6", + Name: "owned", + State: domain.ProjectStateActive, + ShouldAssertRole: true, + IsAuthorizationRequired: true, + IsProjectAccessRequired: true, + UsedLabelingSettingOwner: 1, + }, } for _, project := range projects { - err := projectRepo.Create(t.Context(), tx, project) - require.NoError(t, err) + // Granted Projects + if project.GrantedOrganizationID != nil { + err := projectGrantRepo.Create(t.Context(), tx, &domain.ProjectGrant{ + InstanceID: project.InstanceID, + ID: *project.GrantID, + ProjectID: project.ID, + GrantedOrganizationID: *project.GrantedOrganizationID, + GrantingOrganizationID: project.OrganizationID, + State: domain.ProjectGrantStateActive, + }) + require.NoError(t, err) + } else { + err := projectRepo.Create(t.Context(), tx, project) + require.NoError(t, err) + } } tests := []struct { @@ -179,13 +232,28 @@ func TestListProjects(t *testing.T) { want: projects[1:3], }, { - name: "state active", + name: "state active from first or second org", condition: database.And( projectRepo.InstanceIDCondition(instanceID), + database.Or( + projectRepo.OrganizationIDCondition(firstOrgID), + projectRepo.OrganizationIDCondition(secondOrgID), + ), projectRepo.StateCondition(domain.ProjectStateActive), ), want: []*domain.Project{projects[0], projects[2]}, }, + { + name: "all from granted org or granted to granted org", + condition: database.And( + projectRepo.InstanceIDCondition(instanceID), + database.Or( + projectRepo.OrganizationIDCondition(grantedOrgID), + projectRepo.GrantedOrganizationIDCondition(grantedOrgID), + ), + ), + want: []*domain.Project{projects[5], projects[6]}, + }, } for _, tt := range tests { @@ -195,7 +263,7 @@ func TestListProjects(t *testing.T) { database.WithOrderByAscending(projectRepo.PrimaryKeyColumns()...), ) require.ErrorIs(t, err, tt.wantErr) - assert.Equal(t, tt.want, got) + assert.EqualExportedValues(t, tt.want, got) }) } } diff --git a/backend/v3/storage/database/repository/repository.go b/backend/v3/storage/database/repository/repository.go index 065a9fcc8..345f9a002 100644 --- a/backend/v3/storage/database/repository/repository.go +++ b/backend/v3/storage/database/repository/repository.go @@ -65,3 +65,46 @@ func getMany[Target any](ctx context.Context, querier database.Querier, builder } return targets, nil } + +type updatable interface { + PrimaryKeyColumns() []database.Column + UpdatedAtColumn() database.Column + qualifiedTableName() string +} + +func updateOne[Target updatable](ctx context.Context, client database.QueryExecutor, target Target, condition database.Condition, changes ...database.Change) (int64, error) { + if len(changes) == 0 { + return 0, database.ErrNoChanges + } + if err := checkPKCondition(target, condition); err != nil { + return 0, err + } + if !database.Changes(changes).IsOnColumn(target.UpdatedAtColumn()) { + changes = append(changes, database.NewChange(target.UpdatedAtColumn(), database.NullInstruction)) + } + builder := database.NewStatementBuilder("UPDATE ") + builder.WriteString(target.qualifiedTableName()) + builder.WriteString(" SET ") + database.Changes(changes).Write(builder) + writeCondition(builder, condition) + + return client.Exec(ctx, builder.String(), builder.Args()...) +} + +type deletable interface { + PrimaryKeyColumns() []database.Column + qualifiedTableName() string +} + +func deleteOne[Target deletable](ctx context.Context, client database.QueryExecutor, target Target, condition database.Condition) (int64, error) { + if err := checkPKCondition(target, condition); err != nil { + return 0, err + } + + builder := database.NewStatementBuilder("DELETE FROM ") + builder.WriteString(target.qualifiedTableName()) + builder.WriteRune(' ') + writeCondition(builder, condition) + + return client.Exec(ctx, builder.String(), builder.Args()...) +} diff --git a/backend/v3/storage/database/repository/repository_test.go b/backend/v3/storage/database/repository/repository_test.go index 0ccb0e2e2..82d89b4e5 100644 --- a/backend/v3/storage/database/repository/repository_test.go +++ b/backend/v3/storage/database/repository/repository_test.go @@ -14,6 +14,7 @@ import ( "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" + "github.com/zitadel/zitadel/internal/integration" ) func TestMain(m *testing.M) { @@ -123,3 +124,22 @@ func createProject(t *testing.T, tx database.Transaction, instanceID, orgID stri return project.ID } + +func createProjectRole(t *testing.T, tx database.Transaction, instanceID, orgID, projectID, key string) string { + t.Helper() + if key == "" { + key = integration.RoleKey() + } + projectRole := domain.ProjectRole{ + InstanceID: instanceID, + OrganizationID: orgID, + ProjectID: projectID, + Key: key, + DisplayName: integration.RoleDisplayName(), + } + projectRoleRepo := repository.ProjectRepository().Role() + err := projectRoleRepo.Create(t.Context(), tx, &projectRole) + require.NoError(t, err) + + return projectRole.Key +} diff --git a/internal/query/projection/project_grant_relational.go b/internal/query/projection/project_grant_relational.go new file mode 100644 index 000000000..bfc8dc6cd --- /dev/null +++ b/internal/query/projection/project_grant_relational.go @@ -0,0 +1,183 @@ +package projection + +import ( + "context" + "database/sql" + + repoDomain "github.com/zitadel/zitadel/backend/v3/domain" + 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 projectGrantRelationalProjection struct{} + +func (*projectGrantRelationalProjection) Name() string { + return "zitadel.project_grants" +} + +func newProjectGrantRelationalProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(projectGrantRelationalProjection)) +} + +func (p *projectGrantRelationalProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: project.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: project.GrantAddedType, + Reduce: p.reduceProjectGrantAdded, + }, + { + Event: project.GrantChangedType, + Reduce: p.reduceProjectGrantChanged, + }, + { + Event: project.GrantCascadeChangedType, + Reduce: p.reduceProjectGrantCascadeChanged, + }, + { + Event: project.GrantDeactivatedType, + Reduce: p.reduceProjectGrantDeactivated, + }, + { + Event: project.GrantReactivatedType, + Reduce: p.reduceProjectGrantReactivated, + }, + { + Event: project.GrantRemovedType, + Reduce: p.reduceProjectGrantRemoved, + }, + }, + }, + } +} + +func (p *projectGrantRelationalProjection) reduceProjectGrantAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.GrantAddedEvent) + if !ok { + return nil, zerrors.ThrowInternalf(nil, "HANDL-5l2bWQrkKf", "reduce.wrong.event.type %s", project.GrantAddedType) + } + 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-5w96sjaQ16", "reduce.wrong.db.pool %T", ex) + } + repo := repository.ProjectGrantRepository() + return repo.Create(ctx, v3_sql.SQLTx(tx), &repoDomain.ProjectGrant{ + InstanceID: e.Aggregate().InstanceID, + ID: e.GrantID, + ProjectID: e.Aggregate().ID, + GrantedOrganizationID: e.GrantedOrgID, + GrantingOrganizationID: e.Aggregate().ResourceOwner, + CreatedAt: e.CreationDate(), + UpdatedAt: e.CreationDate(), + State: repoDomain.ProjectGrantStateActive, + RoleKeys: e.RoleKeys, + }) + }), nil +} + +func (p *projectGrantRelationalProjection) reduceProjectGrantChanged(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.GrantChangedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-I2ciunHcy7", "reduce.wrong.event.type %s", project.GrantChangedType) + } + 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-ANaKzWKAUc", "reduce.wrong.db.pool %T", ex) + } + repo := repository.ProjectGrantRepository() + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.GrantID), + e.RoleKeys, + repo.SetUpdatedAt(e.CreationDate()), + ) + return err + }), nil +} + +func (p *projectGrantRelationalProjection) reduceProjectGrantCascadeChanged(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.GrantCascadeChangedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-osXtu6jnWa", "reduce.wrong.event.type %s", project.GrantCascadeChangedType) + } + 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-aI2o6NlWpv", "reduce.wrong.db.pool %T", ex) + } + repo := repository.ProjectGrantRepository() + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.GrantID), + e.RoleKeys, + repo.SetUpdatedAt(e.CreationDate()), + ) + return err + }), nil +} + +func (p *projectGrantRelationalProjection) reduceProjectGrantDeactivated(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.GrantDeactivateEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-XraL17MUkr", "reduce.wrong.event.type %s", project.GrantDeactivatedType) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + repo := repository.ProjectGrantRepository() + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-rIjAqCzj67", "reduce.wrong.db.pool %T", ex) + } + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.GrantID), + nil, + repo.SetUpdatedAt(e.CreationDate()), + repo.SetState(repoDomain.ProjectGrantStateInactive), + ) + return err + }), nil +} + +func (p *projectGrantRelationalProjection) reduceProjectGrantReactivated(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.GrantReactivatedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-vGUHp6uHJ7", "reduce.wrong.event.type %s", project.GrantReactivatedType) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + repo := repository.ProjectGrantRepository() + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-8milgIP7BS", "reduce.wrong.db.pool %T", ex) + } + _, err := repo.Update(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.GrantID), + nil, + repo.SetUpdatedAt(e.CreationDate()), + repo.SetState(repoDomain.ProjectGrantStateActive), + ) + return err + }), nil +} + +func (p *projectGrantRelationalProjection) reduceProjectGrantRemoved(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.GrantRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-MVPgtdg1w5", "reduce.wrong.event.type %s", project.GrantRemovedType) + } + return handler.NewStatement(e, func(ctx context.Context, ex handler.Executer, _ string) error { + repo := repository.ProjectGrantRepository() + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-gYyqxBv5d0", "reduce.wrong.db.pool %T", ex) + } + _, err := repo.Delete(ctx, v3_sql.SQLTx(tx), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.GrantID), + ) + return err + }), nil +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 550899e56..b0ea3f48b 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -97,6 +97,7 @@ var ( ProjectRelationalProjection *handler.Handler ProjectRoleRelationalProjection *handler.Handler OrganizationMetadataRelationalProjection *handler.Handler + ProjectGrantRelationalProjection *handler.Handler ProjectGrantFields *handler.FieldHandler OrgDomainVerifiedFields *handler.FieldHandler @@ -222,6 +223,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, ProjectRelationalProjection = newProjectRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["projects_relational"])) ProjectRoleRelationalProjection = newProjectRoleRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["project_roles_relational"])) OrganizationMetadataRelationalProjection = newOrgMetadataRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["organization_metadata_relational"])) + ProjectGrantRelationalProjection = newProjectGrantRelationalProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["project_grant_relational"])) newProjectionsList() newFieldsList() @@ -415,5 +417,6 @@ func newProjectionsList() { ProjectRelationalProjection, ProjectRoleRelationalProjection, OrganizationMetadataRelationalProjection, + ProjectGrantRelationalProjection, } }