feat: add administrator repository (#11803)

Introduces the `administrators` relational table and repository layer
for the v3 backend, unifying instance, organization, project, and
project-grant membership into a single scoped model.

Closes #10220

## Changes

- **Domain**: `Administrator` struct, `AdministratorScope` enum,
`AdministratorRepository` interface with typed CRUD, column, condition,
and change helpers. Generated enumer and GoMock.
- **Migration** (`016`): `zitadel.administrators` table with a generated
`id`, scope-alignment check constraint, cascading foreign keys, and a
child `administrator_roles` table.
- **Repository**: CTE-based role mutations (`AddRole`, `RemoveRole`,
`SetRoles`) for atomicity. Roles aggregated via `ARRAY_AGG`.
- **Projection**: Event reducers for
`member.added/changed/removed/cascade_removed` across all four admin
scopes.
- **Fixes**: `Rows.Close()` error wrapping; `TextArray` nil-safe scan +
full `pgtype` array interface; `authorization` and `projectGrant` scan
helpers fixed for `ARRAY_AGG` under `database/sql`; removed unused `ctx`
parameter from `callReduce`.

---------

Co-authored-by: Marco Ardizzone <[email protected]>
This commit is contained in:
Silvan
2026-03-18 10:24:09 +01:00
committed by GitHub
co-authored by Marco Ardizzone
parent 6d90a120a6
commit f138082b64
21 changed files with 3513 additions and 46 deletions
@@ -0,0 +1,16 @@
package migration
import (
_ "embed"
)
var (
//go:embed 016_administrators_table/up.sql
up016AdministratorsTable string
//go:embed 016_administrators_table/down.sql
down016AdministratorsTable string
)
func init() {
registerSQLMigration(16, up016AdministratorsTable, down016AdministratorsTable)
}
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS zitadel.administrator_roles;
DROP TABLE IF EXISTS zitadel.administrators;
DROP TYPE IF EXISTS zitadel.administrator_scope;
@@ -0,0 +1,78 @@
CREATE TYPE zitadel.administrator_scope AS ENUM (
'instance',
'organization',
'project',
'project_grant'
);
CREATE TABLE zitadel.administrators(
instance_id TEXT NOT NULL
, user_id TEXT NOT NULL
, scope zitadel.administrator_scope NOT NULL
, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
, organization_id TEXT
, project_id TEXT
, project_grant_id TEXT
, id TEXT GENERATED ALWAYS AS (
user_id || ':' ||
CASE scope
WHEN 'instance'::zitadel.administrator_scope
THEN 'instance:' || instance_id
WHEN 'organization'::zitadel.administrator_scope
THEN 'organization:' || organization_id
WHEN 'project'::zitadel.administrator_scope
THEN 'project:' || project_id
WHEN 'project_grant'::zitadel.administrator_scope
THEN 'project_grant:' || project_grant_id
END
) STORED
, CONSTRAINT administrators_scope_alignment_chk CHECK (
(scope = 'instance'
AND organization_id IS NULL
AND project_id IS NULL
AND project_grant_id IS NULL)
OR
(scope = 'organization'
AND organization_id IS NOT NULL
AND project_id IS NULL
AND project_grant_id IS NULL)
OR
(scope = 'project'
AND organization_id IS NULL
AND project_id IS NOT NULL
AND project_grant_id IS NULL)
OR
(scope = 'project_grant'
AND organization_id IS NULL
AND project_id IS NULL
AND project_grant_id IS NOT NULL)
)
, PRIMARY KEY (instance_id, id)
, FOREIGN KEY (instance_id) REFERENCES zitadel.instances(id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, organization_id) REFERENCES zitadel.organizations(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, project_id) REFERENCES zitadel.projects(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, project_grant_id) REFERENCES zitadel.project_grants(instance_id, id) ON DELETE CASCADE
);
CREATE TABLE zitadel.administrator_roles(
instance_id TEXT NOT NULL
, administrator_id TEXT NOT NULL
, role_name TEXT NOT NULL CHECK (role_name <> '')
, PRIMARY KEY (instance_id, administrator_id, role_name)
, FOREIGN KEY (instance_id, administrator_id) REFERENCES zitadel.administrators(instance_id, id) ON DELETE CASCADE
);
CREATE TRIGGER trigger_set_updated_at
BEFORE UPDATE
ON zitadel.administrators
FOR EACH ROW
WHEN (NEW.updated_at IS NULL)
EXECUTE FUNCTION zitadel.set_updated_at();