feat(rt): project repository (#10789)

# Which Problems Are Solved

Add projects to the relational tables

# How the Problems Are Solved

- Define table migrations
- Define and implement Project and Project Role repositories.
- Provide projection handlers to populate the relational tables.

# Additional Changes

- Statement Builder now has a constructor which allows setting of a base
query with arguments.
- Certain operations, like Get, Update and Delete require the Primary
Key to be set as conditions. However, this requires knowledge of the
implementation and table definition. This PR proposes an additional
condition for repositories: `PrimaryKeyCondition`. This gives clarity on
the required IDs for these operations.
- Added couple of helpers to the repository package, to allow more DRY
code.
- getOne / getMany: generic functions for query execution and scanning.
- checkRestrictingColumns, checkPkCondition: simplify condition
checking, instead of using ladders of conditionals.
- Added a couple of helpers to the repository test package:
  - Transaction, savepoint and rollback helpers.
- Create instance and organization helpers for objects that depend on
them (like projects).

# Additional Context

- after https://github.com/zitadel/zitadel/pull/10809
- closes #10765
This commit is contained in:
Tim Möhlmann
2025-10-01 12:47:04 +03:00
committed by GitHub
parent 28db24fa67
commit a45908b364
21 changed files with 2512 additions and 12 deletions

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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

View File

@@ -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)
})
}

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -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)
})
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
}
}