feat(repository): implement user repository (#10967)

## Summary

This PR introduces the relational user repository for backend v3,
including schema, repository APIs, reducers/projections, and tests for
both human and machine users.

## What changed

* Added user-focused relational migrations (users, verifications,
metadata, PATs, machine keys, passkeys, identity provider links) and
related FK wiring.
* Implemented/expanded repository domain contracts and storage
implementations for human and machine user flows.
* Added support for machine secret handling and aligned access token
type modeling.
* Extended user domain models (verifications, password/email/phone/TOTP
state, passkeys, metadata, invite handling, identity provider links).
* Updated projection/reduction coverage and integration tests for key
user lifecycle events.
* Regenerated and updated mocks/enums plus supporting database
change/column utilities.

## Notes for reviewers

* This PR is large because it establishes the foundational relational
user storage layer and related test scaffolding. Sorry for that.

## Closes

Closes #10218
Closes #10224
Closes #10225
Closes #10209
Closes #10802

---------

Co-authored-by: Iraq Jaber <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Marco A. <[email protected]>
Co-authored-by: Tim Möhlmann <[email protected]>
This commit is contained in:
Silvan
2026-02-23 16:03:47 +01:00
committed by GitHub
co-authored by Iraq Jaber Copilot Marco A. Tim Möhlmann
parent 40c7b38ca1
commit 5df8a97f9d
73 changed files with 15914 additions and 1661 deletions
@@ -14,7 +14,6 @@ CREATE TABLE zitadel.authorizations
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (instance_id, id),
-- FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users (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, grant_id) REFERENCES zitadel.project_grants (instance_id, id) ON DELETE CASCADE
);
@@ -22,7 +22,6 @@ CREATE TABLE zitadel.sessions (
, PRIMARY KEY (instance_id, id)
, FOREIGN KEY (instance_id) REFERENCES zitadel.instances(id)
-- , FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, user_agent_id) REFERENCES zitadel.session_user_agents(instance_id, fingerprint_id) ON DELETE SET NULL (user_agent_id)
);
@@ -31,8 +31,6 @@ CREATE TABLE zitadel.identity_provider_intents
expires_at TIMESTAMPTZ,
PRIMARY KEY (instance_id, id),
-- TODO(@adlerhurst): easiest would be to create the index in the user migrations so that we can merge this one but the problem is that the migration might fail if there are already intents created.
-- FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users (instance_id, id) ON DELETE CASCADE,
FOREIGN KEY (instance_id, idp_id) REFERENCES zitadel.identity_providers (instance_id, id) ON DELETE CASCADE
);
@@ -0,0 +1,16 @@
package migration
import (
_ "embed"
)
var (
//go:embed 013_user_table/up.sql
up013UserTable string
//go:embed 013_user_table/down.sql
down013UserTable string
)
func init() {
registerSQLMigration(13, up013UserTable, down013UserTable)
}
@@ -0,0 +1,14 @@
DROP TABLE IF EXISTS zitadel.user_identity_provider_links;
DROP TABLE IF EXISTS zitadel.user_passkeys;
DROP TYPE IF EXISTS zitadel.passkey_type;
DROP TABLE IF EXISTS zitadel.machine_keys;
DROP TABLE IF EXISTS zitadel.user_personal_access_tokens;
DROP TABLE IF EXISTS zitadel.user_metadata CASCADE;
ALTER TABLE zitadel.identity_provider_intents DROP CONSTRAINT IF EXISTS fk_idp_intent_user;
ALTER TABLE zitadel.sessions DROP CONSTRAINT IF EXISTS fk_session_user;
ALTER TABLE zitadel.authorizations DROP CONSTRAINT IF EXISTS fk_authorization_user;
DROP TABLE IF EXISTS zitadel.users;
DROP TYPE IF EXISTS zitadel.user_type;
DROP TYPE IF EXISTS zitadel.user_state;
@@ -0,0 +1,228 @@
CREATE TYPE zitadel.user_state AS ENUM (
'initial'
, 'active'
, 'inactive'
, 'locked'
, 'suspended'
);
CREATE TYPE zitadel.user_type AS ENUM (
'human'
, 'machine'
);
CREATE TABLE zitadel.users(
instance_id TEXT NOT NULL
, organization_id TEXT NOT NULL
, id TEXT NOT NULL CHECK (id <> '')
, username TEXT NOT NULL CHECK (username <> '')
, username_org_unique BOOLEAN DEFAULT FALSE NOT NULL -- this field MUST be filled if the username must be unique on organization level
, state zitadel.user_state NOT NULL DEFAULT 'active'
, type zitadel.user_type NOT NULL
, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
, PRIMARY KEY (instance_id, id)
, FOREIGN KEY (instance_id, organization_id) REFERENCES zitadel.organizations(instance_id, id) ON DELETE CASCADE
-- human
, first_name TEXT CHECK ((type = 'machine' AND first_name IS NULL) OR (type = 'human' AND first_name <> ''))
, last_name TEXT CHECK ((type = 'machine' AND last_name IS NULL) OR (type = 'human' AND last_name <> ''))
, nickname TEXT CHECK ((type = 'machine' AND nickname IS NULL) OR (type = 'human'))
, display_name TEXT CHECK ((type = 'machine' AND display_name IS NULL) OR (type = 'human'))
, preferred_language TEXT CHECK ((type = 'machine' AND preferred_language IS NULL) OR (type = 'human'))
, gender SMALLINT CHECK ((type = 'machine' AND gender IS NULL) OR (type = 'human'))
, avatar_key TEXT CHECK ((type = 'machine' AND avatar_key IS NULL) OR (type = 'human'))
, multifactor_initialization_skipped_at TIMESTAMPTZ CHECK ((type = 'machine' AND multifactor_initialization_skipped_at IS NULL) OR (type = 'human'))
, password_hash TEXT CHECK ((type = 'machine' AND password_hash IS NULL) OR (type = 'human'))
, password_change_required BOOLEAN CHECK ((type = 'machine' AND password_change_required IS NULL) OR (type = 'human'))
, password_changed_at TIMESTAMPTZ CHECK ((type = 'machine' AND password_changed_at IS NULL) OR (type = 'human'))
, password_verification_id TEXT CHECK ((type = 'machine' AND password_verification_id IS NULL) OR (type = 'human')) -- used for reset password flow
, password_last_successful_check TIMESTAMPTZ CHECK ((type = 'machine' AND password_last_successful_check IS NULL) OR (type = 'human'))
, password_failed_attempts SMALLINT CHECK ((type = 'machine' AND password_failed_attempts IS NULL) OR (type = 'human') AND (password_failed_attempts >= 0))
, email TEXT CHECK ((type = 'machine' AND email IS NULL) OR (type = 'human'))
, unverified_email TEXT CHECK ((type = 'machine' AND unverified_email IS NULL) OR (type = 'human' AND unverified_email <> '')) -- after successful verification this column is not cleared.
, email_verified_at TIMESTAMPTZ CHECK ((type = 'machine' AND email_verified_at IS NULL) OR (type = 'human'))
, email_verification_id TEXT CHECK ((type = 'machine' AND email_verification_id IS NULL) OR (type = 'human' AND (email IS DISTINCT FROM unverified_email OR email_verification_id IS NULL)))
, email_otp_enabled_at TIMESTAMPTZ CHECK ((type = 'machine' AND email_otp_enabled_at IS NULL) OR (type = 'human'))
, email_otp_last_successful_check TIMESTAMPTZ CHECK ((type = 'machine' AND email_otp_last_successful_check IS NULL) OR (type = 'human'))
, email_otp_failed_attempts SMALLINT CHECK ((type = 'machine' AND email_otp_failed_attempts IS NULL) OR (type = 'human'))
, phone TEXT CHECK ((type = 'machine' AND phone IS NULL) OR (type = 'human'))
, unverified_phone TEXT CHECK ((type = 'machine' AND unverified_phone IS NULL) OR (type = 'human')) -- after successful verification this column is not cleared.
, phone_verified_at TIMESTAMPTZ CHECK ((type = 'machine' AND phone_verified_at IS NULL) OR (type = 'human'))
, phone_verification_id TEXT CHECK ((type = 'machine' AND phone_verification_id IS NULL) OR (type = 'human' AND (phone IS DISTINCT FROM unverified_phone OR phone_verification_id IS NULL)))
, sms_otp_enabled_at TIMESTAMPTZ CHECK ((type = 'machine' AND sms_otp_enabled_at IS NULL) OR (type = 'human'))
, sms_otp_last_successful_check TIMESTAMPTZ CHECK ((type = 'machine' AND sms_otp_last_successful_check IS NULL) OR (type = 'human'))
, sms_otp_failed_attempts SMALLINT CHECK ((type = 'machine' AND sms_otp_failed_attempts IS NULL) OR (type = 'human'))
, totp_secret BYTEA CHECK ((type = 'machine' AND totp_secret IS NULL) OR (type = 'human'))
, totp_verified_at TIMESTAMPTZ CHECK ((type = 'machine' AND totp_verified_at IS NULL) OR (type = 'human'))
, totp_last_successful_check TIMESTAMPTZ CHECK ((type = 'machine' AND totp_last_successful_check IS NULL) OR (type = 'human'))
, totp_failed_attempts SMALLINT CHECK ((type = 'machine' AND totp_failed_attempts IS NULL) OR (type = 'human'))
, invite_verification_id TEXT CHECK ((type = 'machine' AND invite_verification_id IS NULL) OR (type = 'human'))
, invite_accepted_at TIMESTAMPTZ CHECK ((type = 'machine' AND invite_accepted_at IS NULL) OR (type = 'human'))
, invite_failed_attempts SMALLINT CHECK ((type = 'machine' AND invite_failed_attempts IS NULL) OR (type = 'human'))
-- foreign keys for verifications are created in the verification migration
-- machine
, name TEXT CHECK ((type = 'human' AND name IS NULL) OR (type = 'machine'))
, description TEXT CHECK ((type = 'human' AND description IS NULL) OR (type = 'machine'))
, secret TEXT CHECK ((type = 'human' AND secret IS NULL) OR (type = 'machine'))
, access_token_type SMALLINT CHECK ((type = 'human' AND access_token_type IS NULL) OR (type = 'machine'))
);
-- previously created tables
ALTER TABLE zitadel.identity_provider_intents ADD CONSTRAINT fk_idp_intent_user FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users (instance_id, id) ON DELETE CASCADE;
ALTER TABLE zitadel.sessions ADD CONSTRAINT fk_session_user FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE;
ALTER TABLE zitadel.authorizations ADD CONSTRAINT fk_authorization_user FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users (instance_id, id) ON DELETE CASCADE;
-- user
CREATE UNIQUE INDEX ON zitadel.users(instance_id, organization_id, username) WHERE username_org_unique IS TRUE; --TODO(adlerhurst): does that work if a username is already present on a user without org unique?
CREATE UNIQUE INDEX ON zitadel.users(instance_id, username) WHERE username_org_unique IS FALSE;
CREATE INDEX idx_user_username ON zitadel.users (username);
CREATE INDEX idx_user_username_lower ON zitadel.users (lower(username));
CREATE INDEX idx_machine_name ON zitadel.users (name);
CREATE INDEX idx_human_email ON zitadel.users (email);
CREATE INDEX idx_human_email_lower ON zitadel.users (lower(email));
CREATE INDEX idx_human_phone ON zitadel.users (phone);
CREATE INDEX idx_human_phone_lower ON zitadel.users (lower(phone));
-- human
CREATE UNIQUE INDEX ON zitadel.users(password_verification_id) WHERE password_verification_id IS NOT NULL;
CREATE UNIQUE INDEX ON zitadel.users(email_verification_id) WHERE email_verification_id IS NOT NULL;
CREATE UNIQUE INDEX ON zitadel.users(phone_verification_id) WHERE phone_verification_id IS NOT NULL;
CREATE TRIGGER trigger_set_updated_at
BEFORE UPDATE ON zitadel.users
FOR EACH ROW
WHEN (NEW.updated_at IS NULL)
EXECUTE FUNCTION zitadel.set_updated_at();
-- ----------------------------------------------------------------
-- user metadata
-- ----------------------------------------------------------------
CREATE TABLE zitadel.user_metadata (
instance_id TEXT NOT NULL
, user_id TEXT NOT NULL
, key TEXT NOT NULL CHECK (key <> '')
, value BYTEA NOT NULL
, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
, PRIMARY KEY (instance_id, user_id, key)
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users (instance_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_user_metadata_key ON zitadel.user_metadata (key);
CREATE INDEX idx_user_metadata_value ON zitadel.user_metadata (sha256(value));
CREATE TRIGGER trg_set_updated_at_user_metadata
BEFORE INSERT OR UPDATE ON zitadel.user_metadata
FOR EACH ROW
WHEN (NEW.updated_at IS NULL)
EXECUTE FUNCTION zitadel.set_updated_at();
-- ----------------------------------------------------------------
-- personal access tokens
-- ----------------------------------------------------------------
CREATE TABLE zitadel.user_personal_access_tokens(
instance_id TEXT NOT NULL
, user_id TEXT NOT NULL
, id TEXT NOT NULL
, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
, expires_at TIMESTAMPTZ
, scopes TEXT[]
, PRIMARY KEY (instance_id, id)
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
);
-- ----------------------------------------------------------------
-- machine keys
-- ----------------------------------------------------------------
CREATE TABLE zitadel.machine_keys(
instance_id TEXT NOT NULL
, user_id TEXT NOT NULL
, id TEXT NOT NULL
, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
, expires_at TIMESTAMPTZ
, type SMALLINT NOT NULL CHECK (type >= 0)
, public_key BYTEA NOT NULL
, PRIMARY KEY (instance_id, id)
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
);
-- ----------------------------------------------------------------
-- passkeys
-- ----------------------------------------------------------------
CREATE TYPE zitadel.passkey_type AS ENUM (
'passwordless'
, 'u2f'
);
CREATE TABLE zitadel.user_passkeys(
instance_id TEXT NOT NULL
, token_id TEXT NOT NULL
, key_id BYTEA NOT NULL
, user_id TEXT NOT NULL
, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
, verified_at TIMESTAMPTZ
, type zitadel.passkey_type NOT NULL
, name TEXT NOT NULL CHECK (name <> '')
, sign_count INT NOT NULL DEFAULT 0 CHECK (sign_count >= 0)
, challenge BYTEA NOT NULL
, public_key BYTEA NOT NULL
, attestation_type TEXT NOT NULL CHECK (attestation_type <> '')
, authenticator_attestation_guid BYTEA NOT NULL
, relying_party_id TEXT NOT NULL CHECK (relying_party_id <> '')
, PRIMARY KEY (instance_id, token_id)
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_user_passkeys_challenge ON zitadel.user_passkeys (sha256(challenge));
CREATE INDEX idx_user_passkeys_key_id ON zitadel.user_passkeys (key_id);
CREATE INDEX idx_user_passkeys_type ON zitadel.user_passkeys (type);
-- ----------------------------------------------------------------
-- identity provider links
-- ----------------------------------------------------------------
CREATE TABLE zitadel.user_identity_provider_links(
instance_id TEXT NOT NULL
, identity_provider_id TEXT NOT NULL
, user_id TEXT NOT NULL
, provided_user_id TEXT NOT NULL CHECK(provided_user_id <> '')
, provided_username TEXT NOT NULL
, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
, PRIMARY KEY (instance_id, identity_provider_id, provided_user_id)
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, identity_provider_id) REFERENCES zitadel.identity_providers(instance_id, id) ON DELETE CASCADE
);
@@ -0,0 +1,16 @@
package migration
import (
_ "embed"
)
var (
//go:embed 014_verification_table/up.sql
up014VerificationTable string
//go:embed 014_verification_table/down.sql
down014VerificationTable string
)
func init() {
registerSQLMigration(14, up014VerificationTable, down014VerificationTable)
}
@@ -0,0 +1,7 @@
ALTER TABLE zitadel.users DROP CONSTRAINT IF EXISTS fk_unverified_password;
ALTER TABLE zitadel.users DROP CONSTRAINT IF EXISTS fk_unverified_email;
ALTER TABLE zitadel.users DROP CONSTRAINT IF EXISTS fk_unverified_phone;
DROP TRIGGER IF EXISTS user_verification_integrity_trigger ON zitadel.users;
DROP FUNCTION IF EXISTS zitadel.ensure_user_verification_integrity() CASCADE;
DROP TABLE IF EXISTS zitadel.verifications CASCADE;
@@ -0,0 +1,61 @@
CREATE TABLE zitadel.verifications(
instance_id TEXT NOT NULL
, user_id TEXT
, id TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT
, code BYTEA
, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
, expiry INTERVAL
, failed_attempts SMALLINT NOT NULL DEFAULT 0 CHECK (failed_attempts >= 0)
, PRIMARY KEY (instance_id, id)
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
);
ALTER TABLE zitadel.users ADD CONSTRAINT fk_unverified_password FOREIGN KEY (instance_id, password_verification_id) REFERENCES zitadel.verifications(instance_id, id) ON DELETE SET NULL (password_verification_id);
ALTER TABLE zitadel.users ADD CONSTRAINT fk_unverified_email FOREIGN KEY (instance_id, email_verification_id) REFERENCES zitadel.verifications(instance_id, id) ON DELETE SET NULL (email_verification_id);
ALTER TABLE zitadel.users ADD CONSTRAINT fk_unverified_phone FOREIGN KEY (instance_id, phone_verification_id) REFERENCES zitadel.verifications(instance_id, id) ON DELETE SET NULL (phone_verification_id);
CREATE OR REPLACE FUNCTION zitadel.ensure_user_verification_integrity() RETURNS trigger AS $$
BEGIN
IF (NEW.password_verification_id IS DISTINCT FROM OLD.password_verification_id) THEN
DELETE FROM zitadel.verifications
WHERE instance_id = NEW.instance_id
AND id = OLD.password_verification_id;
END IF;
IF (NEW.email_verification_id IS DISTINCT FROM OLD.email_verification_id) THEN
DELETE FROM zitadel.verifications
WHERE instance_id = NEW.instance_id
AND id = OLD.email_verification_id;
END IF;
IF (NEW.phone_verification_id IS DISTINCT FROM OLD.phone_verification_id) THEN
DELETE FROM zitadel.verifications
WHERE instance_id = NEW.instance_id
AND id = OLD.phone_verification_id;
END IF;
IF (NEW.invite_verification_id IS DISTINCT FROM OLD.invite_verification_id) THEN
DELETE FROM zitadel.verifications
WHERE instance_id = NEW.instance_id
AND id = OLD.invite_verification_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_verification_integrity_trigger
AFTER UPDATE ON zitadel.users
FOR EACH ROW
EXECUTE FUNCTION zitadel.ensure_user_verification_integrity();
CREATE TRIGGER trigger_set_updated_at
BEFORE UPDATE ON zitadel.verifications
FOR EACH ROW
WHEN (NEW.updated_at IS NULL)
EXECUTE FUNCTION zitadel.set_updated_at();