feat(db): adding org table to relational model (#10066)

# Which Problems Are Solved

As an outcome of [this
issue](https://github.com/zitadel/zitadel/issues/9599) we want to
implement relational tables in Zitadel. For that we use new tables as a
successor of the current tables used by Zitadel in `projections`, `auth`
and `admin` schemas. The new logic is based on [this
proposal](https://github.com/zitadel/zitadel/pull/9870). This issue does
not contain the switch from CQRS to the new tables. This is change will
be implemented in a later stage.

We focus on the most critical tables which is user authentication.

We need a table to manage organizations. 

### organization fields

The following fields must be managed in this table:

- `id`
- `instance_id`
- `name`
- `state` enum (active, inactive)
- `created_at`
- `updated_at`
- `deleted_at`

DISCUSS: should we add a `primary_domain` to this table so that we do
not have to join on domains to return a simple org?

We must ensure the unique constraints for this table matches the current
commands.

### organization repository

The repository must provide the following functions:

Manipulations:
- create
  - `instance_id`
  - `name`
- update
  - `name`
- delete

Queries:
- get returns single organization matching the criteria and pagination,
should return error if multiple were found
- list returns list of organizations matching the criteria, pagination

Criteria are the following:
- by id
- by name

pagination:
- by created_at
- by updated_at
- by name

### organization events

The following events must be applied on the table using a projection
(`internal/query/projection`)

- `org.added` results in create
- `org.changed` sets the `name` field
- `org.deactivated` sets the `state` field
- `org.reactivated` sets the `state` field
- `org.removed` sets the `deleted_at` field
- if answer is yes to discussion: `org.domain.primary.set` sets the
`primary_domain` field
- `instance.removed` sets the the `deleted_at` field if not already set

### acceptance criteria

- [x] migration is implemented and gets executed
- [x] domain interfaces are implemented and documented for service layer
- [x] repository is implemented and implements domain interface
- [x] testing
  - [x] the repository methods
  - [x] events get reduced correctly
  - [x] unique constraints
# Additional Context

Replace this example with links to related issues, discussions, discord
threads, or other sources with more context.
Use the Closing #issue syntax for issues that are resolved with this PR.
- Closes #https://github.com/zitadel/zitadel/issues/9936

---------

Co-authored-by: adlerhurst <27845747+adlerhurst@users.noreply.github.com>
This commit is contained in:
Iraq
2025-07-14 21:27:14 +02:00
committed by GitHub
parent 9595a1bcca
commit 8d020e56bb
28 changed files with 2238 additions and 590 deletions

View File

@@ -86,12 +86,12 @@ type InstanceRepository interface {
// Member returns the member repository which is a sub repository of the instance repository.
// Member() MemberRepository
Get(ctx context.Context, opts ...database.Condition) (*Instance, error)
Get(ctx context.Context, id string) (*Instance, error)
List(ctx context.Context, opts ...database.Condition) ([]*Instance, error)
Create(ctx context.Context, instance *Instance) error
Update(ctx context.Context, condition database.Condition, changes ...database.Change) (int64, error)
Delete(ctx context.Context, condition database.Condition) error
Update(ctx context.Context, id string, changes ...database.Change) (int64, error)
Delete(ctx context.Context, id string) (int64, error)
}
type CreateInstance struct {

View File

@@ -1,120 +0,0 @@
package domain
import (
"context"
"time"
"github.com/zitadel/zitadel/backend/v3/storage/cache"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type OrgState uint8
const (
OrgStateActive OrgState = iota + 1
OrgStateInactive
)
// Org is used by all other packages to represent an organization.
type Org struct {
ID string `json:"id"`
Name string `json:"name"`
State OrgState `json:"state"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type orgCacheIndex uint8
const (
orgCacheIndexUndefined orgCacheIndex = iota
orgCacheIndexID
)
// Keys implements [cache.Entry].
func (o *Org) Keys(index orgCacheIndex) (key []string) {
if index == orgCacheIndexID {
return []string{o.ID}
}
return nil
}
var _ cache.Entry[orgCacheIndex, string] = (*Org)(nil)
// orgColumns define all the columns of the org table.
type orgColumns interface {
// InstanceIDColumn returns the column for the instance id field.
InstanceIDColumn() database.Column
// IDColumn returns the column for the id field.
IDColumn() database.Column
// NameColumn returns the column for the name field.
NameColumn() database.Column
// StateColumn returns the column for the state field.
StateColumn() 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
// DeletedAtColumn returns the column for the deleted at field.
DeletedAtColumn() database.Column
}
// orgConditions define all the conditions for the org table.
type orgConditions interface {
// InstanceIDCondition returns an equal filter on the instance id field.
InstanceIDCondition(instanceID string) database.Condition
// IDCondition returns an equal filter on the id field.
IDCondition(orgID 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(op database.NumberOperation, state OrgState) database.Condition
}
// orgChanges define all the changes for the org table.
type orgChanges interface {
// SetName sets the name column.
SetName(name string) database.Change
// SetState sets the state column.
SetState(state OrgState) database.Change
}
// OrgRepository is the interface for the org repository.
// It is used to interact with the org table in the database.
type OrgRepository interface {
orgColumns
orgConditions
orgChanges
// Member returns the member repository.
Member() MemberRepository
// Domain returns the domain repository.
Domain() DomainRepository
// Get returns an org based on the given condition.
Get(ctx context.Context, opts ...database.QueryOption) (*Org, error)
// List returns a list of orgs based on the given condition.
List(ctx context.Context, opts ...database.QueryOption) ([]*Org, error)
// Create creates a new org.
Create(ctx context.Context, org *Org) error
// Delete removes orgs based on the given condition.
Delete(ctx context.Context, condition database.Condition) error
// Update executes the given changes based on the given condition.
Update(ctx context.Context, condition database.Condition, changes ...database.Change) error
}
// MemberRepository is a sub repository of the org repository and maybe the instance repository.
type MemberRepository interface {
AddMember(ctx context.Context, orgID, userID string, roles []string) error
SetMemberRoles(ctx context.Context, orgID, userID string, roles []string) error
RemoveMember(ctx context.Context, orgID, userID string) error
}
// DomainRepository is a sub repository of the org repository and maybe the instance repository.
type DomainRepository interface {
AddDomain(ctx context.Context, domain string) error
SetDomainVerified(ctx context.Context, domain string) error
RemoveDomain(ctx context.Context, domain string) error
}

View File

@@ -0,0 +1,103 @@
package domain
import (
"context"
"time"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
//go:generate enumer -type OrgState -transform lower -trimprefix OrgState
type OrgState uint8
const (
OrgStateActive OrgState = iota
OrgStateInactive
)
type Organization struct {
ID string `json:"id,omitempty" db:"id"`
Name string `json:"name,omitempty" db:"name"`
InstanceID string `json:"instanceId,omitempty" db:"instance_id"`
State string `json:"state,omitempty" db:"state"`
CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"`
DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
}
// OrgIdentifierCondition is used to help specify a single Organization,
// it will either be used as the organization ID or organization name,
// as organizations can be identified either using (instnaceID + ID) OR (instanceID + name)
type OrgIdentifierCondition interface {
database.Condition
}
// organizationColumns define all the columns of the instance table.
type organizationColumns interface {
// IDColumn returns the column for the id field.
IDColumn() database.Column
// NameColumn returns the column for the name field.
NameColumn() database.Column
// InstanceIDColumn returns the column for the default org id field
InstanceIDColumn() database.Column
// StateColumn returns the column for the name field.
StateColumn() 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
// DeletedAtColumn returns the column for the deleted at field.
DeletedAtColumn() database.Column
}
// organizationConditions define all the conditions for the instance table.
type organizationConditions interface {
// IDCondition returns an equal filter on the id field.
IDCondition(instanceID string) OrgIdentifierCondition
// NameCondition returns a filter on the name field.
NameCondition(name string) OrgIdentifierCondition
// InstanceIDCondition returns a filter on the instance id field.
InstanceIDCondition(instanceID string) database.Condition
// StateCondition returns a filter on the name field.
StateCondition(state OrgState) database.Condition
}
// organizationChanges define all the changes for the instance table.
type organizationChanges interface {
// SetName sets the name column.
SetName(name string) database.Change
// SetState sets the name column.
SetState(state OrgState) database.Change
}
// OrganizationRepository is the interface for the instance repository.
type OrganizationRepository interface {
organizationColumns
organizationConditions
organizationChanges
Get(ctx context.Context, id OrgIdentifierCondition, instance_id string, opts ...database.Condition) (*Organization, error)
List(ctx context.Context, opts ...database.Condition) ([]*Organization, error)
Create(ctx context.Context, instance *Organization) error
Update(ctx context.Context, id OrgIdentifierCondition, instance_id string, changes ...database.Change) (int64, error)
Delete(ctx context.Context, id OrgIdentifierCondition, instance_id string) (int64, error)
}
type CreateOrganization struct {
Name string `json:"name"`
}
// MemberRepository is a sub repository of the org repository and maybe the instance repository.
type MemberRepository interface {
AddMember(ctx context.Context, orgID, userID string, roles []string) error
SetMemberRoles(ctx context.Context, orgID, userID string, roles []string) error
RemoveMember(ctx context.Context, orgID, userID string) error
}
// DomainRepository is a sub repository of the org repository and maybe the instance repository.
type DomainRepository interface {
AddDomain(ctx context.Context, domain string) error
SetDomainVerified(ctx context.Context, domain string) error
RemoveDomain(ctx context.Context, domain string) error
}

View File

@@ -0,0 +1,78 @@
// Code generated by "enumer -type OrgState -transform lower -trimprefix OrgState"; DO NOT EDIT.
package domain
import (
"fmt"
"strings"
)
const _OrgStateName = "activeinactive"
var _OrgStateIndex = [...]uint8{0, 6, 14}
const _OrgStateLowerName = "activeinactive"
func (i OrgState) String() string {
if i < 0 || i >= OrgState(len(_OrgStateIndex)-1) {
return fmt.Sprintf("OrgState(%d)", i)
}
return _OrgStateName[_OrgStateIndex[i]:_OrgStateIndex[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 _OrgStateNoOp() {
var x [1]struct{}
_ = x[OrgStateActive-(0)]
_ = x[OrgStateInactive-(1)]
}
var _OrgStateValues = []OrgState{OrgStateActive, OrgStateInactive}
var _OrgStateNameToValueMap = map[string]OrgState{
_OrgStateName[0:6]: OrgStateActive,
_OrgStateLowerName[0:6]: OrgStateActive,
_OrgStateName[6:14]: OrgStateInactive,
_OrgStateLowerName[6:14]: OrgStateInactive,
}
var _OrgStateNames = []string{
_OrgStateName[0:6],
_OrgStateName[6:14],
}
// OrgStateString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func OrgStateString(s string) (OrgState, error) {
if val, ok := _OrgStateNameToValueMap[s]; ok {
return val, nil
}
if val, ok := _OrgStateNameToValueMap[strings.ToLower(s)]; ok {
return val, nil
}
return 0, fmt.Errorf("%s does not belong to OrgState values", s)
}
// OrgStateValues returns all values of the enum
func OrgStateValues() []OrgState {
return _OrgStateValues
}
// OrgStateStrings returns a slice of all String values of the enum
func OrgStateStrings() []string {
strs := make([]string, len(_OrgStateNames))
copy(strs, _OrgStateNames)
return strs
}
// IsAOrgState returns "true" if the value is listed in the enum definition. "false" otherwise
func (i OrgState) IsAOrgState() bool {
for _, v := range _OrgStateValues {
if i == v {
return true
}
}
return false
}