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

@@ -7,6 +7,7 @@ import (
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/backend/v3/domain"
@@ -74,11 +75,61 @@ func TestCreateInstance(t *testing.T) {
}
err := instanceRepo.Create(ctx, &inst)
// change the name to make sure same only the id clashes
inst.Name = gofakeit.Name()
require.NoError(t, err)
return &inst
},
err: errors.New("instance id already exists"),
},
func() struct {
name string
testFunc func(ctx context.Context, t *testing.T) *domain.Instance
instance domain.Instance
err error
} {
instanceId := gofakeit.Name()
instanceName := gofakeit.Name()
return struct {
name string
testFunc func(ctx context.Context, t *testing.T) *domain.Instance
instance domain.Instance
err error
}{
name: "adding instance with same name twice",
testFunc: func(ctx context.Context, t *testing.T) *domain.Instance {
instanceRepo := repository.InstanceRepository(pool)
inst := domain.Instance{
ID: gofakeit.Name(),
Name: instanceName,
DefaultOrgID: "defaultOrgId",
IAMProjectID: "iamProject",
ConsoleClientID: "consoleCLient",
ConsoleAppID: "consoleApp",
DefaultLanguage: "defaultLanguage",
}
err := instanceRepo.Create(ctx, &inst)
require.NoError(t, err)
// change the id
inst.ID = instanceId
return &inst
},
instance: domain.Instance{
ID: instanceId,
Name: instanceName,
DefaultOrgID: "defaultOrgId",
IAMProjectID: "iamProject",
ConsoleClientID: "consoleCLient",
ConsoleAppID: "consoleApp",
DefaultLanguage: "defaultLanguage",
},
// two instances can have the sane name
err: nil,
}
}(),
{
name: "adding instance with no id",
instance: func() domain.Instance {
@@ -113,7 +164,7 @@ func TestCreateInstance(t *testing.T) {
// create instance
beforeCreate := time.Now()
err := instanceRepo.Create(ctx, instance)
require.Equal(t, tt.err, err)
assert.Equal(t, tt.err, err)
if err != nil {
return
}
@@ -121,20 +172,20 @@ func TestCreateInstance(t *testing.T) {
// check instance values
instance, err = instanceRepo.Get(ctx,
instanceRepo.NameCondition(database.TextOperationEqual, instance.Name),
instance.ID,
)
require.NoError(t, err)
require.Equal(t, tt.instance.ID, instance.ID)
require.Equal(t, tt.instance.Name, instance.Name)
require.Equal(t, tt.instance.DefaultOrgID, instance.DefaultOrgID)
require.Equal(t, tt.instance.IAMProjectID, instance.IAMProjectID)
require.Equal(t, tt.instance.ConsoleClientID, instance.ConsoleClientID)
require.Equal(t, tt.instance.ConsoleAppID, instance.ConsoleAppID)
require.Equal(t, tt.instance.DefaultLanguage, instance.DefaultLanguage)
require.WithinRange(t, instance.CreatedAt, beforeCreate, afterCreate)
require.WithinRange(t, instance.UpdatedAt, beforeCreate, afterCreate)
require.Nil(t, instance.DeletedAt)
assert.Equal(t, tt.instance.ID, instance.ID)
assert.Equal(t, tt.instance.Name, instance.Name)
assert.Equal(t, tt.instance.DefaultOrgID, instance.DefaultOrgID)
assert.Equal(t, tt.instance.IAMProjectID, instance.IAMProjectID)
assert.Equal(t, tt.instance.ConsoleClientID, instance.ConsoleClientID)
assert.Equal(t, tt.instance.ConsoleAppID, instance.ConsoleAppID)
assert.Equal(t, tt.instance.DefaultLanguage, instance.DefaultLanguage)
assert.WithinRange(t, instance.CreatedAt, beforeCreate, afterCreate)
assert.WithinRange(t, instance.UpdatedAt, beforeCreate, afterCreate)
assert.Nil(t, instance.DeletedAt)
})
}
}
@@ -144,6 +195,7 @@ func TestUpdateInstance(t *testing.T) {
name string
testFunc func(ctx context.Context, t *testing.T) *domain.Instance
rowsAffected int64
getErr error
}{
{
name: "happy path",
@@ -191,10 +243,11 @@ func TestUpdateInstance(t *testing.T) {
require.NoError(t, err)
// delete instance
err = instanceRepo.Delete(ctx,
instanceRepo.IDCondition(inst.ID),
affectedRows, err := instanceRepo.Delete(ctx,
inst.ID,
)
require.NoError(t, err)
assert.Equal(t, int64(1), affectedRows)
return &inst
},
@@ -211,6 +264,7 @@ func TestUpdateInstance(t *testing.T) {
return &inst
},
rowsAffected: 0,
getErr: repository.ErrResourceDoesNotExist,
},
}
for _, tt := range tests {
@@ -224,13 +278,13 @@ func TestUpdateInstance(t *testing.T) {
// update name
newName := "new_" + instance.Name
rowsAffected, err := instanceRepo.Update(ctx,
instanceRepo.IDCondition(instance.ID),
instance.ID,
instanceRepo.SetName(newName),
)
afterUpdate := time.Now()
require.NoError(t, err)
require.Equal(t, tt.rowsAffected, rowsAffected)
assert.Equal(t, tt.rowsAffected, rowsAffected)
if rowsAffected == 0 {
return
@@ -238,13 +292,13 @@ func TestUpdateInstance(t *testing.T) {
// check instance values
instance, err = instanceRepo.Get(ctx,
instanceRepo.IDCondition(instance.ID),
instance.ID,
)
require.NoError(t, err)
require.Equal(t, tt.getErr, err)
require.Equal(t, newName, instance.Name)
require.WithinRange(t, instance.UpdatedAt, beforeUpdate, afterUpdate)
require.Nil(t, instance.DeletedAt)
assert.Equal(t, newName, instance.Name)
assert.WithinRange(t, instance.UpdatedAt, beforeUpdate, afterUpdate)
assert.Nil(t, instance.DeletedAt)
})
}
}
@@ -252,9 +306,9 @@ func TestUpdateInstance(t *testing.T) {
func TestGetInstance(t *testing.T) {
instanceRepo := repository.InstanceRepository(pool)
type test struct {
name string
testFunc func(ctx context.Context, t *testing.T) *domain.Instance
conditionClauses []database.Condition
name string
testFunc func(ctx context.Context, t *testing.T) *domain.Instance
err error
}
tests := []test{
@@ -280,45 +334,17 @@ func TestGetInstance(t *testing.T) {
require.NoError(t, err)
return &inst
},
conditionClauses: []database.Condition{instanceRepo.IDCondition(instanceId)},
}
}(),
func() test {
instanceName := gofakeit.Name()
return test{
name: "happy path get using name",
testFunc: func(ctx context.Context, t *testing.T) *domain.Instance {
instanceId := gofakeit.Name()
inst := domain.Instance{
ID: instanceId,
Name: instanceName,
DefaultOrgID: "defaultOrgId",
IAMProjectID: "iamProject",
ConsoleClientID: "consoleCLient",
ConsoleAppID: "consoleApp",
DefaultLanguage: "defaultLanguage",
}
// create instance
err := instanceRepo.Create(ctx, &inst)
require.NoError(t, err)
return &inst
},
conditionClauses: []database.Condition{instanceRepo.NameCondition(database.TextOperationEqual, instanceName)},
}
}(),
{
name: "get non existent instance",
testFunc: func(ctx context.Context, t *testing.T) *domain.Instance {
instanceId := gofakeit.Name()
_ = domain.Instance{
ID: instanceId,
inst := domain.Instance{
ID: "get non existent instance",
}
return nil
return &inst
},
conditionClauses: []database.Condition{instanceRepo.NameCondition(database.TextOperationEqual, "non-existent-instance-name")},
err: repository.ErrResourceDoesNotExist,
},
}
for _, tt := range tests {
@@ -333,22 +359,25 @@ func TestGetInstance(t *testing.T) {
// check instance values
returnedInstance, err := instanceRepo.Get(ctx,
tt.conditionClauses...,
instance.ID,
)
require.NoError(t, err)
if instance == nil {
require.Nil(t, instance, returnedInstance)
if tt.err != nil {
require.Equal(t, tt.err, err)
return
}
require.NoError(t, err)
require.Equal(t, returnedInstance.ID, instance.ID)
require.Equal(t, returnedInstance.Name, instance.Name)
require.Equal(t, returnedInstance.DefaultOrgID, instance.DefaultOrgID)
require.Equal(t, returnedInstance.IAMProjectID, instance.IAMProjectID)
require.Equal(t, returnedInstance.ConsoleClientID, instance.ConsoleClientID)
require.Equal(t, returnedInstance.ConsoleAppID, instance.ConsoleAppID)
require.Equal(t, returnedInstance.DefaultLanguage, instance.DefaultLanguage)
if instance.ID == "get non existent instance" {
assert.Nil(t, returnedInstance)
return
}
assert.Equal(t, returnedInstance.ID, instance.ID)
assert.Equal(t, returnedInstance.Name, instance.Name)
assert.Equal(t, returnedInstance.DefaultOrgID, instance.DefaultOrgID)
assert.Equal(t, returnedInstance.IAMProjectID, instance.IAMProjectID)
assert.Equal(t, returnedInstance.ConsoleClientID, instance.ConsoleClientID)
assert.Equal(t, returnedInstance.ConsoleAppID, instance.ConsoleAppID)
assert.Equal(t, returnedInstance.DefaultLanguage, instance.DefaultLanguage)
})
}
}
@@ -504,19 +533,19 @@ func TestListInstance(t *testing.T) {
)
require.NoError(t, err)
if tt.noInstanceReturned {
require.Nil(t, returnedInstances)
assert.Nil(t, returnedInstances)
return
}
require.Equal(t, len(instances), len(returnedInstances))
assert.Equal(t, len(instances), len(returnedInstances))
for i, instance := range instances {
require.Equal(t, returnedInstances[i].ID, instance.ID)
require.Equal(t, returnedInstances[i].Name, instance.Name)
require.Equal(t, returnedInstances[i].DefaultOrgID, instance.DefaultOrgID)
require.Equal(t, returnedInstances[i].IAMProjectID, instance.IAMProjectID)
require.Equal(t, returnedInstances[i].ConsoleClientID, instance.ConsoleClientID)
require.Equal(t, returnedInstances[i].ConsoleAppID, instance.ConsoleAppID)
require.Equal(t, returnedInstances[i].DefaultLanguage, instance.DefaultLanguage)
assert.Equal(t, returnedInstances[i].ID, instance.ID)
assert.Equal(t, returnedInstances[i].Name, instance.Name)
assert.Equal(t, returnedInstances[i].DefaultOrgID, instance.DefaultOrgID)
assert.Equal(t, returnedInstances[i].IAMProjectID, instance.IAMProjectID)
assert.Equal(t, returnedInstances[i].ConsoleClientID, instance.ConsoleClientID)
assert.Equal(t, returnedInstances[i].ConsoleAppID, instance.ConsoleAppID)
assert.Equal(t, returnedInstances[i].DefaultLanguage, instance.DefaultLanguage)
}
})
}
@@ -524,18 +553,19 @@ func TestListInstance(t *testing.T) {
func TestDeleteInstance(t *testing.T) {
type test struct {
name string
testFunc func(ctx context.Context, t *testing.T)
conditionClauses database.Condition
name string
testFunc func(ctx context.Context, t *testing.T)
instanceID string
noOfDeletedRows int64
}
tests := []test{
func() test {
instanceRepo := repository.InstanceRepository(pool)
instanceId := gofakeit.Name()
var noOfInstances int64 = 1
return test{
name: "happy path delete single instance filter id",
testFunc: func(ctx context.Context, t *testing.T) {
noOfInstances := 1
instances := make([]*domain.Instance, noOfInstances)
for i := range noOfInstances {
@@ -556,75 +586,15 @@ func TestDeleteInstance(t *testing.T) {
instances[i] = &inst
}
},
conditionClauses: instanceRepo.IDCondition(instanceId),
instanceID: instanceId,
noOfDeletedRows: noOfInstances,
}
}(),
func() test {
instanceRepo := repository.InstanceRepository(pool)
instanceName := gofakeit.Name()
return test{
name: "happy path delete single instance filter name",
testFunc: func(ctx context.Context, t *testing.T) {
noOfInstances := 1
instances := make([]*domain.Instance, noOfInstances)
for i := range noOfInstances {
inst := domain.Instance{
ID: gofakeit.Name(),
Name: instanceName,
DefaultOrgID: "defaultOrgId",
IAMProjectID: "iamProject",
ConsoleClientID: "consoleCLient",
ConsoleAppID: "consoleApp",
DefaultLanguage: "defaultLanguage",
}
// create instance
err := instanceRepo.Create(ctx, &inst)
require.NoError(t, err)
instances[i] = &inst
}
},
conditionClauses: instanceRepo.NameCondition(database.TextOperationEqual, instanceName),
}
}(),
func() test {
instanceRepo := repository.InstanceRepository(pool)
non_existent_instance_name := gofakeit.Name()
return test{
name: "delete non existent instance",
conditionClauses: instanceRepo.NameCondition(database.TextOperationEqual, non_existent_instance_name),
}
}(),
func() test {
instanceRepo := repository.InstanceRepository(pool)
instanceName := gofakeit.Name()
return test{
name: "multiple instance filter on name",
testFunc: func(ctx context.Context, t *testing.T) {
noOfInstances := 5
instances := make([]*domain.Instance, noOfInstances)
for i := range noOfInstances {
inst := domain.Instance{
ID: gofakeit.Name(),
Name: instanceName,
DefaultOrgID: "defaultOrgId",
IAMProjectID: "iamProject",
ConsoleClientID: "consoleCLient",
ConsoleAppID: "consoleApp",
DefaultLanguage: "defaultLanguage",
}
// create instance
err := instanceRepo.Create(ctx, &inst)
require.NoError(t, err)
instances[i] = &inst
}
},
conditionClauses: instanceRepo.NameCondition(database.TextOperationEqual, instanceName),
name: "delete non existent instance",
instanceID: non_existent_instance_name,
}
}(),
func() test {
@@ -655,12 +625,15 @@ func TestDeleteInstance(t *testing.T) {
}
// delete instance
err := instanceRepo.Delete(ctx,
instanceRepo.NameCondition(database.TextOperationEqual, instanceName),
affectedRows, err := instanceRepo.Delete(ctx,
instances[0].ID,
)
require.NoError(t, err)
assert.Equal(t, int64(1), affectedRows)
},
conditionClauses: instanceRepo.NameCondition(database.TextOperationEqual, instanceName),
instanceID: instanceName,
// this test should return 0 affected rows as the instance was already deleted
noOfDeletedRows: 0,
}
}(),
}
@@ -674,17 +647,18 @@ func TestDeleteInstance(t *testing.T) {
}
// delete instance
err := instanceRepo.Delete(ctx,
tt.conditionClauses,
noOfDeletedRows, err := instanceRepo.Delete(ctx,
tt.instanceID,
)
require.NoError(t, err)
assert.Equal(t, noOfDeletedRows, tt.noOfDeletedRows)
// check instance was deleted
instance, err := instanceRepo.Get(ctx,
tt.conditionClauses,
tt.instanceID,
)
require.NoError(t, err)
require.Nil(t, instance)
require.Equal(t, err, repository.ErrResourceDoesNotExist)
assert.Nil(t, instance)
})
}
}