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

@@ -6,8 +6,8 @@ CREATE TABLE IF NOT EXISTS zitadel.instances(
console_client_id TEXT, -- NOT NULL,
console_app_id TEXT, -- NOT NULL,
default_language TEXT, -- NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
deleted_at TIMESTAMPTZ DEFAULT NULL
);

View File

@@ -0,0 +1,16 @@
package migration
import (
_ "embed"
)
var (
//go:embed 002_organization_table/up.sql
up002OrganizationTable string
//go:embed 002_organization_table/down.sql
down002OrganizationTable string
)
func init() {
registerSQLMigration(2, up002OrganizationTable, down002OrganizationTable)
}

View File

@@ -0,0 +1,2 @@
DROP TABLE zitadel.organizations;
DROP Type zitadel.organization_state;

View File

@@ -0,0 +1,33 @@
CREATE TYPE zitadel.organization_state AS ENUM (
'active',
'inactive'
);
CREATE TABLE zitadel.organizations(
id TEXT NOT NULL CHECK (id <> ''),
name TEXT NOT NULL CHECK (name <> ''),
instance_id TEXT NOT NULL REFERENCES zitadel.instances (id),
state zitadel.organization_state NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
deleted_at TIMESTAMPTZ DEFAULT NULL,
PRIMARY KEY (instance_id, id)
);
CREATE UNIQUE INDEX org_unique_instance_id_name_idx
ON zitadel.organizations (instance_id, name)
WHERE deleted_at IS NULL;
-- users are able to set the id for organizations
CREATE INDEX org_id_not_deleted_idx ON zitadel.organizations (id)
WHERE deleted_at IS NULL;
CREATE INDEX org_name_not_deleted_idx ON zitadel.organizations (name)
WHERE deleted_at IS NULL;
CREATE TRIGGER trigger_set_updated_at
BEFORE UPDATE ON zitadel.organizations
FOR EACH ROW
WHEN (OLD.updated_at IS NOT DISTINCT FROM NEW.updated_at)
EXECUTE FUNCTION zitadel.set_updated_at();

View File

@@ -1,15 +1,55 @@
package postgres
import (
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
var _ database.Rows = (*Rows)(nil)
var (
_ database.Rows = (*Rows)(nil)
_ database.CollectableRows = (*Rows)(nil)
)
type Rows struct{ pgx.Rows }
// Collect implements [database.CollectableRows].
// See [this page](https://github.com/georgysavva/scany/blob/master/dbscan/doc.go#L8) for additional details.
func (r *Rows) Collect(dest any) (err error) {
defer func() {
closeErr := r.Close()
if err == nil {
err = closeErr
}
}()
return pgxscan.ScanAll(dest, r.Rows)
}
// CollectFirst implements [database.CollectableRows].
// See [this page](https://github.com/georgysavva/scany/blob/master/dbscan/doc.go#L8) for additional details.
func (r *Rows) CollectFirst(dest any) (err error) {
defer func() {
closeErr := r.Close()
if err == nil {
err = closeErr
}
}()
return pgxscan.ScanRow(dest, r.Rows)
}
// CollectExactlyOneRow implements [database.CollectableRows].
// See [this page](https://github.com/georgysavva/scany/blob/master/dbscan/doc.go#L8) for additional details.
func (r *Rows) CollectExactlyOneRow(dest any) (err error) {
defer func() {
closeErr := r.Close()
if err == nil {
err = closeErr
}
}()
return pgxscan.ScanOne(dest, r.Rows)
}
// Close implements [database.Rows].
// Subtle: this method shadows the method (Rows).Close of Rows.Rows.
func (r *Rows) Close() error {