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
+79 -35
View File
@@ -5,12 +5,15 @@ import (
"slices"
"go.uber.org/mock/gomock"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
)
// Change represents a change to a column in a database table.
// Its written in the SET clause of an UPDATE statement.
type Change interface {
gomock.Matcher
argWriter
// Write writes the change to the given statement builder.
Write(builder *StatementBuilder) error
// IsOnColumn checks if the change is on the given column.
@@ -44,6 +47,25 @@ func (c *change[V]) String() string {
return "database.change"
}
// WriteArg implements [Change].
func (c change[V]) WriteArg(builder *StatementBuilder) {
err := c.Write(builder)
logging.New(logging.StreamRuntime).Debug("write arg failed", "error", err)
}
// Write implements [Change].
func (c change[V]) Write(builder *StatementBuilder) error {
c.column.WriteUnqualified(builder)
builder.WriteString(" = ")
builder.WriteArg(c.value)
return nil
}
// IsOnColumn implements [Change].
func (c change[V]) IsOnColumn(col Column) bool {
return c.column.Equals(col)
}
var (
_ Change = (*change[string])(nil)
_ gomock.Matcher = (*change[string])(nil)
@@ -67,22 +89,9 @@ func NewChangePtr[V Value](col Column, value *V) Change {
return NewChange(col, *value)
}
// Write implements [Change].
func (c change[V]) Write(builder *StatementBuilder) error {
c.column.WriteUnqualified(builder)
builder.WriteString(" = ")
builder.WriteArg(c.value)
return nil
}
// IsOnColumn implements [Change].
func (c change[V]) IsOnColumn(col Column) bool {
return c.column.Equals(col)
}
type Changes []Change
func NewChanges(cols ...Change) Change {
func NewChanges(cols ...Change) Changes {
return Changes(cols)
}
@@ -152,6 +161,12 @@ func (c Changes) NoChange() bool {
return true
}
// WriteArg implements [Change].
func (c Changes) WriteArg(builder *StatementBuilder) {
err := c.Write(builder)
logging.New(logging.StreamRuntime).Debug("write arg failed", "error", err)
}
var _ Change = Changes(nil)
func NewChangeToNull(col Column) Change {
@@ -194,26 +209,34 @@ func (c *changeToColumn) Write(builder *StatementBuilder) error {
return nil
}
var _ Change = (*changeToColumn)(nil)
type incrementColumnChange struct {
column Column
// WriteArg implements [Change].
func (c *changeToColumn) WriteArg(builder *StatementBuilder) {
err := c.Write(builder)
logging.New(logging.StreamRuntime).Debug("write arg failed", "error", err)
}
func NewIncrementColumnChange(col Column) Change {
return &incrementColumnChange{
var _ Change = (*changeToColumn)(nil)
type incrementColumnChange[V Value] struct {
column Column
value V
}
func NewIncrementColumnChange[V Value](col Column, value V) *incrementColumnChange[V] {
return &incrementColumnChange[V]{
column: col,
value: value,
}
}
// IsOnColumn implements [Change].
func (i *incrementColumnChange) IsOnColumn(col Column) bool {
func (i incrementColumnChange[V]) IsOnColumn(col Column) bool {
return i.column.Equals(col)
}
// Matches implements [Change].
func (i *incrementColumnChange) Matches(x any) bool {
toMatch, ok := x.(*incrementColumnChange)
func (i *incrementColumnChange[V]) Matches(x any) bool {
toMatch, ok := x.(*incrementColumnChange[V])
if !ok {
return false
}
@@ -221,20 +244,26 @@ func (i *incrementColumnChange) Matches(x any) bool {
}
// String implements [Change].
func (i *incrementColumnChange) String() string {
func (i incrementColumnChange[V]) String() string {
return "database.incrementColumnChange"
}
// Write implements [Change].
func (i *incrementColumnChange) Write(builder *StatementBuilder) error {
func (i incrementColumnChange[V]) Write(builder *StatementBuilder) error {
i.column.WriteUnqualified(builder)
builder.WriteString(" = ")
i.column.WriteUnqualified(builder)
builder.WriteArg(i.value)
builder.WriteString(" + 1")
return nil
}
var _ Change = (*incrementColumnChange)(nil)
// WriteArg implements [Change].
func (i incrementColumnChange[V]) WriteArg(builder *StatementBuilder) {
err := i.Write(builder)
logging.New(logging.StreamRuntime).Debug("write arg failed", "error", err)
}
var _ Change = (*incrementColumnChange[any])(nil)
func NewChangeToStatement(col Column, stmt func(builder *StatementBuilder)) Change {
return &changeToStatement{
@@ -293,6 +322,12 @@ func (c *changeToStatement) Write(builder *StatementBuilder) error {
return nil
}
// WriteArg implements [Change].
func (c *changeToStatement) WriteArg(builder *StatementBuilder) {
err := c.Write(builder)
logging.New(logging.StreamRuntime).Debug("write arg failed", "error", err)
}
var _ Change = (*changeToStatement)(nil)
// CTEChange represents a change that uses a Common Table Expression (CTE).
@@ -322,7 +357,7 @@ type cteChange struct {
}
// IsOnColumn implements [CTEChange].
func (c *cteChange) IsOnColumn(col Column) bool {
func (c cteChange) IsOnColumn(col Column) bool {
return false
}
@@ -335,11 +370,15 @@ func (c *cteChange) Matches(x any) bool {
var expectedCTEBuilder, actualCTEBuilder StatementBuilder
c.cte(&expectedCTEBuilder)
if c.change != nil {
c.change(c.name).Write(&expectedCTEBuilder)
if err := c.change(c.name).Write(&expectedCTEBuilder); err != nil {
return false
}
}
toMatch.cte(&actualCTEBuilder)
if toMatch.change != nil {
toMatch.change(toMatch.name).Write(&actualCTEBuilder)
if err := toMatch.change(toMatch.name).Write(&actualCTEBuilder); err != nil {
return false
}
}
if expectedCTEBuilder.String() != actualCTEBuilder.String() {
@@ -359,21 +398,26 @@ func (c *cteChange) String() string {
}
// Write implements [CTEChange].
func (c *cteChange) Write(builder *StatementBuilder) error {
func (c cteChange) Write(builder *StatementBuilder) error {
if c.change == nil {
return nil
}
c.change(c.name).Write(builder)
return nil
return c.change(c.name).Write(builder)
}
// WriteCTE implements [CTEChange].
func (c *cteChange) WriteCTE(builder *StatementBuilder) {
func (c cteChange) WriteCTE(builder *StatementBuilder) {
c.cte(builder)
}
// NoChange implements [NoChange].
// It returns true if there is no change function defined.
func (c *cteChange) NoChange() bool {
func (c cteChange) NoChange() bool {
return c.change == nil
}
// WriteArg implements [Change].
func (c cteChange) WriteArg(builder *StatementBuilder) {
err := c.Write(builder)
logging.New(logging.StreamRuntime).Debug("write arg failed", "error", err)
}
+16 -7
View File
@@ -42,23 +42,32 @@ func TestChangeWrite(t *testing.T) {
args: []any{42},
},
},
{
name: "change to NULL",
change: NewChangeToNull(NewColumn("table", "column")),
want: want{
stmt: "column = NULL",
args: nil,
},
},
{
name: "multiple changes",
change: NewChanges(
NewChange(NewColumn("table", "column1"), "value1"),
NewChangePtr[int](NewColumn("table", "column2"), nil),
NewChange(NewColumn("table", "column3"), 123),
NewChangeToNull(NewColumn("table", "column4")),
),
want: want{
stmt: "column1 = $1, column2 = NULL, column3 = $2",
stmt: "column1 = $1, column2 = NULL, column3 = $2, column4 = NULL",
args: []any{"value1", 123},
},
},
{
name: "increment change",
change: NewIncrementColumnChange(NewColumn("table", "counter")),
change: NewIncrementColumnChange(NewColumn("table", "counter"), NewColumn("table", "counter")),
want: want{
stmt: "counter = counter + 1",
stmt: "counter = table.counter + 1",
},
},
} {
@@ -111,11 +120,11 @@ func TestChangeToStatement(t *testing.T) {
name: "change to statement with existing builder args",
prefillBuilder: func(builder *StatementBuilder) {
builder.WriteString("UPDATE table SET ")
NewChanges(
assert.NoError(t, NewChanges(
NewChange(NewColumn("table", "field1"), "asdf"),
NewChangeToNull(NewColumn("table", "field2")),
NewChangeToColumn(NewColumn("table", "field3"), NewColumn("table", "field4")),
).Write(builder)
).Write(builder))
builder.WriteString(", ")
},
change: NewChangeToStatement(NewColumn("table", "column"), func(builder *StatementBuilder) {
@@ -135,7 +144,7 @@ func TestChangeToStatement(t *testing.T) {
if test.prefillBuilder != nil {
test.prefillBuilder(&builder)
}
test.change.Write(&builder)
assert.NoError(t, test.change.Write(&builder))
assert.Equal(t, test.want.stmt, builder.String())
assert.Equal(t, builder.Args(), test.want.args)
})
@@ -250,7 +259,7 @@ func TestCTEChange(t *testing.T) {
if test.afterCTE != nil {
test.afterCTE(&builder)
}
test.change.Write(&builder)
assert.NoError(t, test.change.Write(&builder))
assert.Equal(t, test.want.stmt, builder.String())
assert.Equal(t, builder.Args(), test.want.args)
+46 -28
View File
@@ -2,6 +2,18 @@ package database
import "go.uber.org/mock/gomock"
// Column represents a column in a database table.
type Column interface {
gomock.Matcher
argWriter
// WriteQualified writes the column with the table name as prefix.
WriteQualified(builder *StatementBuilder)
// WriteUnqualified writes the column without the table name as prefix.
WriteUnqualified(builder *StatementBuilder)
// Equals checks if two columns are equal.
Equals(col Column) bool
}
type Columns []Column
// Matches implements [Column].
@@ -59,24 +71,22 @@ func (c Columns) Equals(col Column) bool {
return true
}
var _ Column = (Columns)(nil)
// Column represents a column in a database table.
type Column interface {
gomock.Matcher
// WriteQualified writes the column with the table name as prefix.
WriteQualified(builder *StatementBuilder)
// WriteUnqualified writes the column without the table name as prefix.
WriteUnqualified(builder *StatementBuilder)
// Equals checks if two columns are equal.
Equals(col Column) bool
// WriteArg implements [Column].
func (c Columns) WriteArg(builder *StatementBuilder) {
c.WriteQualified(builder)
}
var _ Column = (Columns)(nil)
type column struct {
table string
name string
}
func NewColumn(table, name string) Column {
return &column{table: table, name: name}
}
// Matches implements [Column].
func (c column) Matches(x any) bool {
toMatch, ok := x.(*column)
@@ -91,10 +101,6 @@ func (c column) String() string {
return "database.column"
}
func NewColumn(table, name string) Column {
return &column{table: table, name: name}
}
// WriteQualified implements [Column].
func (c column) WriteQualified(builder *StatementBuilder) {
builder.Grow(len(c.table) + len(c.name) + 1)
@@ -120,6 +126,26 @@ func (c *column) Equals(col Column) bool {
return c.table == toMatch.table && c.name == toMatch.name
}
// WriteArg implements [Column].
func (c column) WriteArg(builder *StatementBuilder) {
c.WriteQualified(builder)
}
var _ Column = (*column)(nil)
type functionColumn struct {
fn function
col Column
}
type function string
const (
_ function = ""
functionLower function = "LOWER"
functionSHA256 function = "SHA256"
)
// LowerColumn returns a column that represents LOWER(col).
func LowerColumn(col Column) Column {
return &functionColumn{fn: functionLower, col: col}
@@ -130,11 +156,6 @@ func SHA256Column(col Column) Column {
return &functionColumn{fn: functionSHA256, col: col}
}
type functionColumn struct {
fn function
col Column
}
// Matches implements [Column].
func (c *functionColumn) Matches(x any) bool {
toMatch, ok := x.(*functionColumn)
@@ -155,14 +176,6 @@ func (c *functionColumn) String() string {
return "database.functionColumn"
}
type function string
const (
_ function = ""
functionLower function = "LOWER"
functionSHA256 function = "SHA256"
)
// WriteQualified implements [Column].
func (c functionColumn) WriteQualified(builder *StatementBuilder) {
builder.Grow(len(c.fn) + 2)
@@ -193,4 +206,9 @@ func (c *functionColumn) Equals(col Column) bool {
return c.col.Equals(toMatch.col)
}
// WriteArg implements [Column].
func (c functionColumn) WriteArg(builder *StatementBuilder) {
c.WriteQualified(builder)
}
var _ Column = (*functionColumn)(nil)
@@ -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();
@@ -435,6 +435,8 @@ func TestServer_AuthorizationReduces(t *testing.T) {
}
func createAndEnsureAuthorization(t *testing.T, instanceID, orgID, userID, projectID string, roles []string, retryDuration time.Duration, tick time.Duration) *authorization_v2.CreateAuthorizationResponse {
t.Helper()
// create authorization
createdAuthorization, err := AuthorizationClient.CreateAuthorization(CTX, &authorization_v2.CreateAuthorizationRequest{
UserId: userID,
@@ -454,11 +456,13 @@ func createAndEnsureAuthorization(t *testing.T, instanceID, orgID, userID, proje
assert.Equal(collect, domain.AuthorizationStateActive, az.State)
assert.Equal(collect, createdAuthorization.GetCreationDate().AsTime().UTC(), az.CreatedAt.UTC())
assert.Equal(collect, createdAuthorization.GetCreationDate().AsTime().UTC(), az.UpdatedAt.UTC())
}, retryDuration, tick, "authorization not found within %v: %v", retryDuration, err)
}, retryDuration, tick, "authorization %q not found within %v: %v", createdAuthorization.Id, retryDuration, err)
return createdAuthorization
}
func prepareProjectAndProjectRoles(t *testing.T, orgID string, roles []string) string {
t.Helper()
project, err := ProjectClient.CreateProject(CTX, &project_v2beta.CreateProjectRequest{
OrganizationId: orgID,
Name: integration.ProjectName(),
@@ -477,7 +481,7 @@ func prepareProjectAndProjectRoles(t *testing.T, orgID string, roles []string) s
projectRepo.PrimaryKeyCondition(Instance.ID(), project.Id),
))
require.NoError(collect, err)
}, retryDuration, tick, "project not found")
}, retryDuration, tick, "project %q not found within %v: %v", project.Id, retryDuration, err)
for _, role := range roles {
_, err = ProjectClient.AddProjectRole(CTX, &project_v2beta.AddProjectRoleRequest{
@@ -2,7 +2,7 @@
package events_test
// TODO(adlerhurst): Uncomment once users table is created and FK can be enforced
// The tests of this file fail because we would need to mock an ldap, OIDC and Oauth server.
// import (
// "net"
@@ -325,7 +325,7 @@ func TestServer_SessionReduces(t *testing.T) {
Metadata: metadata,
UserAgent: userAgent,
}, dbSession)
}, retryDuration, tick, "session not found within %v: %v", retryDuration, err)
}, retryDuration, tick, "session %q not found within %v: %v", createdSession.GetSessionId(), retryDuration, err)
})
t.Run("checks with challenges set reduces", func(t *testing.T) {
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
package database
type coalesce struct {
values []any
}
func Coalesce(values ...any) *coalesce {
return &coalesce{values: values}
}
// WriteArg implements [Change].
func (c *coalesce) WriteArg(builder *StatementBuilder) {
c.WriteQualified(builder)
}
// IsOnColumn implements [Change].
func (c coalesce) IsOnColumn(col Column) bool {
for _, value := range c.values {
if change, ok := value.(Change); ok {
if change.IsOnColumn(col) {
return true
}
}
}
return false
}
// Write implements [Change].
func (c coalesce) Write(builder *StatementBuilder) error {
builder.WriteString("COALESCE(")
for i, value := range c.values {
if i > 0 {
builder.WriteString(", ")
}
builder.WriteArg(value)
}
builder.WriteString(")")
return nil
}
// Equals implements [Column].
func (c *coalesce) Equals(col Column) bool {
if col == nil {
return c == nil
}
other, ok := col.(*coalesce)
if !ok || len(other.values) != len(c.values) {
return false
}
for i, value := range c.values {
if value != other.values[i] {
return false
}
}
return true
}
// Matches implements [Column].
func (c *coalesce) Matches(x any) bool {
toMatch, ok := x.(*coalesce)
if !ok || len(toMatch.values) != len(c.values) {
return false
}
for i, value := range c.values {
if value != toMatch.values[i] {
return false
}
}
return true
}
// String implements [Column].
func (c *coalesce) String() string {
return "database.coalesce"
}
// WriteQualified implements [Column].
func (c *coalesce) WriteQualified(builder *StatementBuilder) {
builder.WriteString("COALESCE(")
for i, value := range c.values {
if i > 0 {
builder.WriteString(", ")
}
if col, ok := value.(Column); ok {
col.WriteQualified(builder)
continue
}
builder.WriteArg(value)
}
builder.WriteString(")")
}
// WriteUnqualified implements [Column].
func (c *coalesce) WriteUnqualified(builder *StatementBuilder) {
builder.WriteString("COALESCE(")
for i, value := range c.values {
if i > 0 {
builder.WriteString(", ")
}
if col, ok := value.(Column); ok {
col.WriteUnqualified(builder)
continue
}
builder.WriteArg(value)
}
builder.WriteString(")")
}
var (
_ Column = (*coalesce)(nil)
_ Change = (*coalesce)(nil)
)
+9
View File
@@ -176,6 +176,7 @@ func NewJsonChanges(col database.Column, changes ...JsonUpdate) database.Change
}
}
// WriteArg implements [gomock.Matcher].
func (c *jsonChanges) Matches(x any) bool {
toMatch, ok := x.(*jsonChanges)
if !ok {
@@ -205,10 +206,12 @@ func (c *jsonChanges) Matches(x any) bool {
return true
}
// WriteArg implements [database.Change].
func (c *jsonChanges) String() string {
return "database.json.jsonChange"
}
// WriteArg implements [database.Change].
func (c jsonChanges) Write(builder *database.StatementBuilder) error {
c.column.WriteUnqualified(builder)
builder.WriteString(" = ")
@@ -220,6 +223,12 @@ func (c jsonChanges) Write(builder *database.StatementBuilder) error {
return c.changes[len(c.changes)-1].writeUpdate(builder, c, len(c.changes)-2)
}
// WriteArg implements [database.Change].
func (c jsonChanges) IsOnColumn(col database.Column) bool {
return c.column.Equals(col)
}
// WriteArg implements [database.Change].
func (c jsonChanges) WriteArg(builder *database.StatementBuilder) {
c.column.WriteArg(builder)
}
+1 -1
View File
@@ -31,7 +31,7 @@ func (b wrappedValue[V]) WriteArg(builder *StatementBuilder) {
var _ argWriter = (*wrappedValue[string])(nil)
type Value interface {
Boolean | Number | Text | Instruction | Bytes
Boolean | Number | Text | Instruction | Bytes | any
}
//go:generate enumer -type NumberOperation,TextOperation,BytesOperation -linecomment -output ./operators_enumer.go
@@ -36,17 +36,6 @@ func (a authorization) qualifiedAuthorizationRolesTableName() string {
// repository
// -------------------------------------------------------------
const insertAuthorizationStmt = `INSERT INTO zitadel.authorizations (
instance_id, id, user_id, grant_id, project_id, state, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING created_at, updated_at`
const insertAuthorizationWithRolesStmt = `WITH roles AS (
INSERT INTO zitadel.authorization_roles
(instance_id, authorization_id, grant_id, project_id, role_key)
VALUES ($1, $2, $4, $5, unnest($9::text[])))` + insertAuthorizationStmt
// Create implements [domain.AuthorizationRepository].
func (a authorization) Create(ctx context.Context, client database.QueryExecutor, authorization *domain.Authorization) error {
var (
@@ -59,17 +48,12 @@ func (a authorization) Create(ctx context.Context, client database.QueryExecutor
updatedAt = authorization.UpdatedAt
}
builder := database.NewStatementBuilder(insertAuthorizationWithRolesStmt,
authorization.InstanceID,
authorization.ID,
authorization.UserID,
authorization.GrantID,
authorization.ProjectID,
authorization.State,
createdAt,
updatedAt,
authorization.Roles,
builder := database.NewStatementBuilder("WITH roles AS (INSERT INTO zitadel.authorization_roles (instance_id, authorization_id, grant_id, project_id, role_key)"+
" VALUES ($1, $2, $3, $4, unnest($5::text[]))) INSERT INTO zitadel.authorizations (instance_id, id, grant_id, project_id, user_id, state, created_at, updated_at) VALUES ($1, $2, $3, $4, $6, $7, ",
authorization.InstanceID, authorization.ID, authorization.GrantID, authorization.ProjectID, authorization.Roles, authorization.UserID, authorization.State,
)
builder.WriteArgs(createdAt, updatedAt)
builder.WriteString(") RETURNING created_at, updated_at")
if err := client.QueryRow(
ctx,
@@ -191,10 +175,10 @@ func (a authorization) setRoles(ctx context.Context, client database.QueryExecut
// set the roles
builder.WriteString(updateAuthorizationRoleStmt)
err := database.Changes(changes).Write(builder)
if err != nil {
if err := database.Changes(changes).Write(builder); err != nil {
return 0, err
}
builder.WriteString(updateAuthorizationRoleStmtWhere)
return client.Exec(ctx, builder.String(), builder.Args()...)
@@ -29,10 +29,8 @@ func TestCreateAuthorization(t *testing.T) {
// create project roles
role1 := createProjectRole(t, tx, instanceID, organizationID, projectID, "role1")
role2 := createProjectRole(t, tx, instanceID, organizationID, projectID, "role2")
// TODO: uncomment when user table is available
// userID := createUser(t, tx, instanceID, organizationID)
// require.NotNil(t, userID)
userID := integration.ID()
userID := createHumanUser(t, tx, instanceID, organizationID)
require.NotNil(t, userID)
authorizationRepo := repository.AuthorizationRepository()
@@ -116,19 +114,18 @@ func TestCreateAuthorization(t *testing.T) {
},
wantErr: new(database.ForeignKeyError),
},
// TODO: uncomment when user table is available
// {
// name: "non-existent user",
// authorization: &domain.Authorization{
// ID: integration.ID(),
// UserID: "random-id",
// ProjectID: projectID,
// InstanceID: instanceID,
// State: domain.AuthorizationStateActive,
// Roles: nil,
// },
// wantErr: new(database.ForeignKeyError),
// },
{
name: "non-existent user",
authorization: &domain.Authorization{
ID: integration.ID(),
UserID: "random-id",
ProjectID: projectID,
InstanceID: instanceID,
State: domain.AuthorizationStateActive,
Roles: nil,
},
wantErr: new(database.ForeignKeyError),
},
{
name: "duplicate authorization",
authorization: &domain.Authorization{
@@ -265,10 +262,8 @@ func TestGetAuthorization(t *testing.T) {
role1 := createProjectRole(t, tx, instanceID, organizationID, projectID, "role1")
role2 := createProjectRole(t, tx, instanceID, organizationID, projectID, "role2")
// TODO: uncomment when user table is available
// userID := createUser(t, tx, instanceID, organizationID)
// require.NotNil(t, userID)
userID := integration.ID()
userID := createHumanUser(t, tx, instanceID, organizationID)
require.NotNil(t, userID)
// create authorization with roles
authorizationRepo := repository.AuthorizationRepository()
@@ -458,13 +453,10 @@ func TestListAuthorization(t *testing.T) {
project2Role1 := createProjectRole(t, tx, instanceID, organizationID, project2ID, "project2Role1")
project2Role2 := createProjectRole(t, tx, instanceID, organizationID, project2ID, "project2Role2")
// TODO: uncomment when user table is available
// user1ID := createUser(t, tx, instanceID, organizationID)
// require.NotNil(t, user1ID)
user1ID := integration.ID()
// user2ID := createUser(t, tx, instanceID, organizationID)
// require.NotNil(t, user2ID)
user2ID := integration.ID()
user1ID := createHumanUser(t, tx, instanceID, organizationID)
require.NotNil(t, user1ID)
user2ID := createHumanUser(t, tx, instanceID, organizationID)
require.NotNil(t, user2ID)
// create authorization with roles for user1 for project1
authorizationRepo := repository.AuthorizationRepository()
@@ -890,11 +882,12 @@ func TestUpdateAuthorization(t *testing.T) {
instanceID := createInstance(t, tx)
organizationID := createOrganization(t, tx, instanceID)
projectID := createProject(t, tx, instanceID, organizationID)
userID := createHumanUser(t, tx, instanceID, organizationID)
authorizationRepo := repository.AuthorizationRepository()
activeAuthorization := &domain.Authorization{
ID: integration.ID(),
UserID: integration.ID(),
UserID: userID,
ProjectID: projectID,
InstanceID: instanceID,
State: domain.AuthorizationStateActive,
@@ -907,7 +900,7 @@ func TestUpdateAuthorization(t *testing.T) {
inactiveAuthorization := &domain.Authorization{
ID: integration.ID(),
UserID: integration.ID(),
UserID: userID,
ProjectID: projectID,
InstanceID: instanceID,
State: domain.AuthorizationStateInactive,
@@ -1012,10 +1005,12 @@ func TestAuthorizationUpdate_setRoles(t *testing.T) {
role5 := createProjectRole(t, tx, instanceID, organizationID, project2ID, "role5")
role6 := createProjectRole(t, tx, instanceID, organizationID, project2ID, "role6")
userID := createHumanUser(t, tx, instanceID, organizationID)
authorizationRepo := repository.AuthorizationRepository()
authorization1 := &domain.Authorization{
ID: integration.ID(),
UserID: integration.ID(),
UserID: userID,
ProjectID: projectID,
InstanceID: instanceID,
State: domain.AuthorizationStateActive,
@@ -1028,7 +1023,7 @@ func TestAuthorizationUpdate_setRoles(t *testing.T) {
authorization2 := &domain.Authorization{
ID: integration.ID(),
UserID: integration.ID(),
UserID: userID,
ProjectID: project2ID,
InstanceID: instanceID,
State: domain.AuthorizationStateActive,
@@ -1132,11 +1127,12 @@ func TestDeleteAuthorization(t *testing.T) {
projectID := createProject(t, tx, instanceID, organizationID)
role1 := createProjectRole(t, tx, instanceID, organizationID, projectID, "role1")
role2 := createProjectRole(t, tx, instanceID, organizationID, projectID, "role2")
userID := createHumanUser(t, tx, instanceID, organizationID)
authorizationRepo := repository.AuthorizationRepository()
authorizationWithRoles := &domain.Authorization{
ID: integration.ID(),
UserID: integration.ID(),
UserID: userID,
ProjectID: projectID,
InstanceID: instanceID,
State: domain.AuthorizationStateActive,
@@ -1149,7 +1145,7 @@ func TestDeleteAuthorization(t *testing.T) {
authorizationWithoutRoles := &domain.Authorization{
ID: integration.ID(),
UserID: integration.ID(),
UserID: userID,
ProjectID: projectID,
InstanceID: instanceID,
State: domain.AuthorizationStateActive,
@@ -1162,7 +1158,7 @@ func TestDeleteAuthorization(t *testing.T) {
deletedAuthorization := &domain.Authorization{
ID: integration.ID(),
UserID: integration.ID(),
UserID: userID,
ProjectID: projectID,
InstanceID: instanceID,
State: domain.AuthorizationStateActive,
@@ -1182,7 +1178,7 @@ func TestDeleteAuthorization(t *testing.T) {
grantID := createProjectGrant(t, tx, instanceID, organizationID, grantedOrganizationID, projectID, []string{role1})
authorizationProjectGrant := &domain.Authorization{
ID: integration.ID(),
UserID: integration.ID(),
UserID: userID,
ProjectID: projectID,
GrantID: gu.Ptr(grantID),
InstanceID: instanceID,
@@ -2,7 +2,6 @@ package repository
import (
"context"
"fmt"
"net/url"
"time"
@@ -95,7 +94,6 @@ func (i idpIntentRepository) Get(ctx context.Context, client database.QueryExecu
var builder database.StatementBuilder
builder.WriteString(queryIDPIntentStmt)
options.Write(&builder)
fmt.Println(builder.String())
return scanIDPIntent(ctx, client, &builder)
}
@@ -315,6 +315,8 @@ func TestUpdateIDPIntent(t *testing.T) {
err = idpIntentRepo.Create(t.Context(), tx, &intent2)
require.NoError(t, err)
userID := createHumanUser(t, tx, instanceID, orgID)
now := time.Now()
tomorrow := now.AddDate(0, 0, 1)
tt := []struct {
@@ -344,7 +346,7 @@ func TestUpdateIDPIntent(t *testing.T) {
idpIntentRepo.SetIDPUser([]byte("update user")),
idpIntentRepo.SetIDPUserID("idp user id updated"),
idpIntentRepo.SetIDPUsername("idp username updated"),
idpIntentRepo.SetUserID("some-user-id"),
idpIntentRepo.SetUserID(userID),
idpIntentRepo.SetIDPAccessToken([]byte(`{"secret": "g1g4 s3cr3t 4cc3ss t0k3n"}`)),
idpIntentRepo.SetIDPIDToken("idp id token"),
idpIntentRepo.SetSucceededAt(now),
@@ -355,7 +357,7 @@ func TestUpdateIDPIntent(t *testing.T) {
toReturn.IDPUser = []byte("update user")
toReturn.IDPUserID = "idp user id updated"
toReturn.IDPUsername = "idp username updated"
toReturn.UserID = "some-user-id"
toReturn.UserID = userID
toReturn.IDPAccessToken = []byte(`{"secret": "g1g4 s3cr3t 4cc3ss t0k3n"}`)
toReturn.IDPIDToken = "idp id token"
toReturn.SucceededAt = &now
@@ -373,7 +375,7 @@ func TestUpdateIDPIntent(t *testing.T) {
idpIntentRepo.SetIDPUser([]byte("update user")),
idpIntentRepo.SetIDPUserID("idp user id updated"),
idpIntentRepo.SetIDPUsername("idp username updated"),
idpIntentRepo.SetUserID("some-user-id"),
idpIntentRepo.SetUserID(userID),
idpIntentRepo.SetAssertion([]byte(`{"assertion": "val1"}`)),
idpIntentRepo.SetSucceededAt(now),
idpIntentRepo.SetExpiresAt(tomorrow),
@@ -383,7 +385,7 @@ func TestUpdateIDPIntent(t *testing.T) {
toReturn.IDPUser = []byte("update user")
toReturn.IDPUserID = "idp user id updated"
toReturn.IDPUsername = "idp username updated"
toReturn.UserID = "some-user-id"
toReturn.UserID = userID
toReturn.Assertion = []byte(`{"assertion": "val1"}`)
toReturn.SucceededAt = &now
toReturn.ExpiresAt = &tomorrow
@@ -400,7 +402,7 @@ func TestUpdateIDPIntent(t *testing.T) {
idpIntentRepo.SetIDPUser([]byte("update user")),
idpIntentRepo.SetIDPUserID("idp user id updated"),
idpIntentRepo.SetIDPUsername("idp username updated"),
idpIntentRepo.SetUserID("some-user-id"),
idpIntentRepo.SetUserID(userID),
idpIntentRepo.SetIDPEntryAttributes(marshalledAttrs),
idpIntentRepo.SetSucceededAt(now),
idpIntentRepo.SetExpiresAt(tomorrow),
@@ -410,7 +412,7 @@ func TestUpdateIDPIntent(t *testing.T) {
toReturn.IDPUser = []byte("update user")
toReturn.IDPUserID = "idp user id updated"
toReturn.IDPUsername = "idp username updated"
toReturn.UserID = "some-user-id"
toReturn.UserID = userID
toReturn.EntryAttributes = entryAttrs
toReturn.SucceededAt = &now
toReturn.ExpiresAt = &tomorrow
@@ -0,0 +1,33 @@
package repository_test
import (
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/backend/v3/domain"
)
func assertMetadata(t *testing.T, expected, gotten []*domain.Metadata) {
t.Helper()
assert.Len(t, gotten, len(expected), "metadata length mismatch")
for _, exp := range expected {
var actual *domain.Metadata
gotten = slices.DeleteFunc(gotten, func(m *domain.Metadata) bool {
if exp.Key != m.Key || exp.InstanceID != m.InstanceID {
return false
}
actual = m
return true
})
require.NotNil(t, actual, "metadata with key %s and instance %s not found", exp.Key, exp.InstanceID)
assert.Equal(t, exp.Value, actual.Value, "metadata value mismatch for key %s and instance %s", exp.Key, exp.InstanceID)
assert.NotZero(t, actual.CreatedAt, "metadata created at is zero for key %s and instance %s", exp.Key, exp.InstanceID)
assert.NotZero(t, actual.UpdatedAt, "metadata updated at is zero for key %s and instance %s", exp.Key, exp.InstanceID)
}
assert.Empty(t, gotten, "unmatched metadata found")
}
@@ -123,7 +123,9 @@ func (p projectGrant) Update(ctx context.Context, client database.QueryExecutor,
// now we add the logic to do the required changes based on the given project grant
builder.WriteString(updateProjectGrantRoleStmt)
database.Changes(changes).Write(builder)
if err := database.Changes(changes).Write(builder); err != nil {
return 0, err
}
builder.WriteString(updateProjectGrantRoleStmtWhere)
return client.Exec(ctx, builder.String(), builder.Args()...)
@@ -85,7 +85,9 @@ func updateOne[Target updatable](ctx context.Context, client database.QueryExecu
builder := database.NewStatementBuilder("UPDATE ")
builder.WriteString(target.qualifiedTableName())
builder.WriteString(" SET ")
database.Changes(changes).Write(builder)
if err := database.Changes(changes).Write(builder); err != nil {
return 0, err
}
writeCondition(builder, condition)
return client.Exec(ctx, builder.String(), builder.Args()...)
@@ -15,6 +15,7 @@ import (
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/backend/v3/storage/database/dialect/postgres"
"github.com/zitadel/zitadel/backend/v3/storage/database/dialect/postgres/embedded"
"github.com/zitadel/zitadel/backend/v3/storage/database/repository"
"github.com/zitadel/zitadel/internal/integration"
@@ -31,30 +32,48 @@ func runTests(m *testing.M) int {
var err error
ctx := context.Background()
pool, stop, err = newEmbeddedDB(ctx)
defer stop()
if err != nil {
log.Printf("error with embedded postgres database: %v", err)
return 1
}
defer stop()
defer func() {
r := recover()
pool.Close(ctx)
stop()
if r != nil {
panic(r)
}
}()
return m.Run()
}
func newEmbeddedDB(ctx context.Context) (pool database.PoolTest, stop func(), err error) {
connector, stop, err := embedded.StartEmbedded()
if err != nil {
return nil, nil, fmt.Errorf("unable to start embedded postgres: %w", err)
var connector database.Connector
if url := os.Getenv("ZITADEL_TEST_POSTGRES_URL"); url != "" {
log.Println("using database provided by env")
connector, err = postgres.DecodeConfig(url)
if err != nil {
return nil, nil, fmt.Errorf("unable to connect to provided postgres: %w", err)
}
stop = func() {}
} else {
connector, stop, err = embedded.StartEmbedded()
if err != nil {
return nil, nil, fmt.Errorf("unable to start embedded postgres: %w", err)
}
}
pool_, err := connector.Connect(ctx)
if err != nil {
return nil, nil, fmt.Errorf("unable to connect to embedded postgres: %w", err)
return nil, stop, fmt.Errorf("unable to connect to embedded postgres: %w", err)
}
pool = pool_.(database.PoolTest)
err = pool.MigrateTest(ctx)
if err != nil {
return nil, nil, fmt.Errorf("unable to migrate database: %w", err)
return nil, stop, fmt.Errorf("unable to migrate database: %w", err)
}
return pool, stop, err
}
@@ -64,7 +83,8 @@ func transactionForRollback(t *testing.T) (tx database.Transaction, rollback fun
tx, err := pool.Begin(t.Context(), nil)
require.NoError(t, err)
return tx, func() {
err := tx.Rollback(t.Context())
// context.Background to ensure rollback does not return an error if test is already done
err := tx.Rollback(context.Background())
require.NoError(t, err)
}
}
@@ -74,12 +94,13 @@ func savepointForRollback(t *testing.T, tx database.Transaction) (savepoint data
savepoint, err := tx.Begin(t.Context())
require.NoError(t, err)
return savepoint, func() {
err := savepoint.Rollback(t.Context())
// context.Background to ensure rollback does not return an error if test is already done
err := savepoint.Rollback(context.Background())
require.NoError(t, err)
}
}
func createInstance(t *testing.T, tx database.Transaction) (instanceID string) {
func createInstance(t *testing.T, tx database.QueryExecutor) (instanceID string) {
t.Helper()
instance := domain.Instance{
ID: gofakeit.UUID(),
@@ -97,7 +118,7 @@ func createInstance(t *testing.T, tx database.Transaction) (instanceID string) {
return instance.ID
}
func createOrganization(t *testing.T, tx database.Transaction, instanceID string) (orgID string) {
func createOrganization(t *testing.T, tx database.QueryExecutor, instanceID string) (orgID string) {
t.Helper()
org := domain.Organization{
InstanceID: instanceID,
@@ -112,7 +133,7 @@ func createOrganization(t *testing.T, tx database.Transaction, instanceID string
return org.ID
}
func createProject(t *testing.T, tx database.Transaction, instanceID, orgID string) (projectID string) {
func createProject(t *testing.T, tx database.QueryExecutor, instanceID, orgID string) (projectID string) {
t.Helper()
project := domain.Project{
InstanceID: instanceID,
@@ -128,7 +149,7 @@ func createProject(t *testing.T, tx database.Transaction, instanceID, orgID stri
return project.ID
}
func createProjectRole(t *testing.T, tx database.Transaction, instanceID, orgID, projectID, key string) string {
func createProjectRole(t *testing.T, tx database.QueryExecutor, instanceID, orgID, projectID, key string) string {
t.Helper()
if key == "" {
key = integration.RoleKey()
@@ -210,3 +231,46 @@ func createIDPIntent(t *testing.T, tx database.Transaction, instanceID, idpID st
return intent.ID
}
func createMachineUser(t *testing.T, tx database.QueryExecutor, instanceID, orgID string) (userID string) {
t.Helper()
user := domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: gofakeit.UUID(),
Username: gofakeit.Username(),
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: gofakeit.Name(),
},
}
userRepo := repository.UserRepository()
err := userRepo.Create(t.Context(), tx, &user)
require.NoError(t, err)
return user.ID
}
func createHumanUser(t *testing.T, tx database.QueryExecutor, instanceID, orgID string) string {
t.Helper()
user := &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: gofakeit.UUID(),
Username: gofakeit.Username(),
State: domain.UserStateActive,
Human: &domain.HumanUser{
FirstName: gofakeit.FirstName(),
LastName: gofakeit.LastName(),
Email: domain.HumanEmail{
Address: gofakeit.Email(),
},
Password: domain.HumanPassword{
Hash: "hashed-password",
},
},
}
userRepo := repository.UserRepository()
err := userRepo.Create(t.Context(), tx, user)
require.NoError(t, err)
return user.ID
}
@@ -176,7 +176,9 @@ func (s session) Create(ctx context.Context, client database.QueryExecutor, sess
)
fingerprintID = session.UserAgent.FingerprintID
}
builder.WriteString(`INSERT INTO ` + s.qualifiedTableName() + ` (instance_id, id, lifetime, creator_id, user_agent_id, created_at, updated_at) VALUES ( `)
builder.WriteString(`INSERT INTO `)
builder.WriteString(s.qualifiedTableName())
builder.WriteString(` (instance_id, id, lifetime, creator_id, user_agent_id, created_at, updated_at) VALUES ( `)
builder.WriteArgs(session.InstanceID, session.ID, session.Lifetime, session.CreatorID, fingerprintID, createdAt, updatedAt)
builder.WriteString(` ) RETURNING created_at, updated_at`)
return client.QueryRow(ctx, builder.String(), builder.Args()...).Scan(&session.CreatedAt, &session.UpdatedAt)
@@ -232,6 +232,9 @@ func TestSession_Update(t *testing.T) {
err = instanceRepo.Create(t.Context(), tx, &instance)
require.NoError(t, err)
orgID := createOrganization(t, tx, instanceId)
userID := createHumanUser(t, tx, instanceId, orgID)
testNow := time.Now()
tests := []struct {
name string
@@ -474,7 +477,7 @@ func TestSession_Update(t *testing.T) {
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorUser{
UserID: "user-id",
UserID: userID,
LastVerifiedAt: testNow,
},
}
@@ -482,7 +485,7 @@ func TestSession_Update(t *testing.T) {
},
update: []database.Change{
sessionRepo.SetFactor(&domain.SessionFactorUser{
UserID: "user-id",
UserID: userID,
LastVerifiedAt: testNow,
}),
},
@@ -748,7 +751,7 @@ func TestSession_Update(t *testing.T) {
session.TokenID = "new-token"
session.Factors = []domain.SessionFactor{
&domain.SessionFactorUser{
UserID: "user-id",
UserID: userID,
LastVerifiedAt: testNow,
},
&domain.SessionFactorPassword{
@@ -794,7 +797,7 @@ func TestSession_Update(t *testing.T) {
sessionRepo.SetUpdatedAt(testNow),
sessionRepo.SetLifetime(time.Hour * 48),
sessionRepo.SetFactor(&domain.SessionFactorUser{
UserID: "user-id",
UserID: userID,
LastVerifiedAt: testNow,
}),
sessionRepo.SetFactor(&domain.SessionFactorPassword{
@@ -846,7 +849,7 @@ func TestSession_Update(t *testing.T) {
updatedRows, err := sessionRepo.Update(t.Context(), tx,
sessionRepo.PrimaryKeyCondition(session.InstanceID, session.ID),
sessionRepo.SetFactor(&domain.SessionFactorUser{
UserID: "user-id",
UserID: userID,
LastVerifiedAt: testNow,
}),
sessionRepo.SetFactor(&domain.SessionFactorPassword{
@@ -859,7 +862,7 @@ func TestSession_Update(t *testing.T) {
// update with updated values
session.Factors = []domain.SessionFactor{
&domain.SessionFactorUser{
UserID: "user-id",
UserID: userID,
LastVerifiedAt: testNow,
},
}
@@ -1101,9 +1104,12 @@ func TestSession_List(t *testing.T) {
err = instanceRepo.Create(t.Context(), tx, &instance)
require.NoError(t, err)
orgID := createOrganization(t, tx, instanceId)
userIDs := []string{createHumanUser(t, tx, instanceId, orgID), createHumanUser(t, tx, instanceId, orgID)}
// create sessions
sessions := make([]*domain.Session, 5)
for i := 0; i < 5; i++ {
for i := range 5 {
session := &domain.Session{
InstanceID: instanceId,
ID: strconv.Itoa(i),
@@ -1138,7 +1144,7 @@ func TestSession_List(t *testing.T) {
session.Metadata = metadata
changes = append(changes, sessionRepo.SetMetadata(metadata))
userFactor := &domain.SessionFactorUser{
UserID: "user-" + strconv.Itoa(i%2), // two users: user-0 and user-1
UserID: userIDs[i%2],
LastVerifiedAt: time.Now(),
}
session.Factors.AppendTo(userFactor)
@@ -0,0 +1,479 @@
package repository
import (
"context"
"encoding/json"
"time"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/internal/zerrors"
)
// -------------------------------------------------------------
// repository
// -------------------------------------------------------------
func UserRepository() domain.UserRepository {
return new(user)
}
func MachineUserRepository() domain.MachineUserRepository {
return &userMachine{user: user{}}
}
type user struct {
verification verification
tableName string
machineKey
userPersonalAccessToken
userMetadata
userPasskey
userIdentityProviderLink
}
func (u user) HumanRepository() domain.HumanUserRepository {
return userHuman{user: u}
}
// existingUser is used to get the columns and conditions for the CTE that selects the existing user in update and delete operations.
var existingUser = user{tableName: "existing_user"}
// Create implements [domain.UserRepository].
func (u user) Create(ctx context.Context, client database.QueryExecutor, user *domain.User) error {
var create func(context.Context, *database.StatementBuilder, database.QueryExecutor, *domain.User) error
switch {
case user.Human != nil:
create = userHuman{user: u}.create
case user.Machine != nil:
create = userMachine{user: u}.create
default:
return zerrors.ThrowInvalidArgument(nil, "REPOS-KxMnG", "no user type defined")
}
// we need to cheat a bit here to be able to use CTEChanges
// we pretend we have an existing user to be able to use the existing change mechanisms
builder := database.NewStatementBuilder("WITH existing_user AS (SELECT $1 AS instance_id, $2 AS organization_id, $3 AS id)", user.InstanceID, user.OrganizationID, user.ID)
return create(ctx, builder, client, user)
}
// Delete implements [domain.UserRepository].
func (u user) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) {
if err := checkPKCondition(u, condition); err != nil {
return 0, err
}
builder := database.NewStatementBuilder("DELETE FROM zitadel.users")
writeCondition(builder, condition)
return client.Exec(ctx, builder.String(), builder.Args()...)
}
var queryUserStmt = "SELECT users.instance_id, users.organization_id, users.id, users.username" +
", users.state, users.created_at, users.updated_at" +
// metadata
`, jsonb_agg(DISTINCT jsonb_build_object('instanceId', user_metadata.instance_id, 'key', user_metadata.key, 'value', encode(user_metadata.value, 'base64'), 'createdAt', user_metadata.created_at, 'updatedAt', user_metadata.updated_at)) FILTER (WHERE user_metadata.user_id IS NOT NULL) AS metadata` +
// machine
`, CASE WHEN users.type = 'machine' THEN jsonb_build_object('name', users.name` +
`, 'description', users.description, 'secret', users.secret` +
`, 'accessTokenType', users.access_token_type` +
`, 'keys', jsonb_agg(DISTINCT jsonb_build_object('id', machine_keys.id, 'publicKey', encode(machine_keys.public_key, 'base64'), 'createdAt', machine_keys.created_at, 'expiresAt', machine_keys.expires_at, 'type', machine_keys.type)) FILTER (WHERE machine_keys.user_id IS NOT NULL)` +
`, 'pats', jsonb_agg(DISTINCT jsonb_build_object('id', user_personal_access_tokens.id, 'createdAt', user_personal_access_tokens.created_at, 'expiresAt', user_personal_access_tokens.expires_at, 'scopes', user_personal_access_tokens.scopes)) FILTER (WHERE user_personal_access_tokens.user_id IS NOT NULL)` +
`) END AS machine` +
// human
`, CASE WHEN users.type = 'human' THEN jsonb_build_object('firstName', users.first_name, 'lastName', users.last_name` +
`, 'nickname', users.nickname, 'displayName', users.display_name` +
`, 'preferredLanguage', users.preferred_language, 'gender', users.gender` +
`, 'avatarKey', users.avatar_key` +
`, 'multifactorInitializationSkippedAt', users.multifactor_initialization_skipped_at` +
`, 'password', jsonb_build_object('hash', users.password_hash, 'isChangeRequired', users.password_change_required, 'changedAt', users.password_changed_at, 'failedAttempts', users.password_failed_attempts, 'lastSuccessfullyCheckedAt', users.password_last_successful_check, 'pendingVerification', ` + verificationQuery(userHuman{}.passwordVerificationIDColumn()) + `)` +
`, 'email', jsonb_build_object('address', users.email, 'unverifiedAddress', users.unverified_email, 'verifiedAt', users.email_verified_at, 'otp', jsonb_build_object('enabledAt', users.email_otp_enabled_at, 'lastSuccessfullyCheckedAt', users.email_otp_last_successful_check, 'failedAttempts', users.email_otp_failed_attempts), 'pendingVerification', ` + verificationQuery(userHuman{}.emailVerificationIDColumn()) + `)` +
`, 'phone', CASE WHEN users.phone IS NOT NULL OR users.phone_verification_id IS NOT NULL THEN jsonb_build_object('number', users.phone, 'unverifiedNumber', users.unverified_phone, 'verifiedAt', users.phone_verified_at, 'otp', jsonb_build_object('enabledAt', users.sms_otp_enabled_at, 'lastSuccessfullyCheckedAt', users.sms_otp_last_successful_check, 'failedAttempts', users.sms_otp_failed_attempts), 'pendingVerification', ` + verificationQuery(userHuman{}.phoneVerificationIDColumn()) + `) ELSE NULL END` +
`, 'totp', CASE WHEN users.totp_secret IS NOT NULL THEN jsonb_build_object('secret', encode(users.totp_secret, 'escape')::JSONB, 'verifiedAt', users.totp_verified_at, 'lastSuccessfullyCheckedAt', users.totp_last_successful_check, 'failedAttempts', users.totp_failed_attempts) ELSE NULL END` +
`, 'passkeys', jsonb_agg(DISTINCT jsonb_build_object('id', user_passkeys.token_id, 'keyId', encode(user_passkeys.key_id, 'base64'), 'type', user_passkeys.type, 'name', user_passkeys.name, 'signCount', user_passkeys.sign_count, 'challenge', encode(user_passkeys.challenge, 'base64'), 'publicKey', encode(user_passkeys.public_key, 'base64'), 'attestationType', user_passkeys.attestation_type, 'aaGuid', encode(user_passkeys.authenticator_attestation_guid, 'base64'), 'rpId', user_passkeys.relying_party_id, 'createdAt', user_passkeys.created_at, 'updatedAt', user_passkeys.updated_at, 'verifiedAt', user_passkeys.verified_at)) FILTER (WHERE user_passkeys.user_id IS NOT NULL)` +
`, 'verifications', (SELECT jsonb_agg(jsonb_build_object('id', verifications.id, 'code', encode(verifications.code, 'escape')::JSONB, 'createdAt', verifications.created_at, 'updatedAt', verifications.updated_at, 'expiresAt', verifications.created_at+verifications.expiry, 'failedAttempts', verifications.failed_attempts)) FROM zitadel.verifications WHERE verifications.instance_id = users.instance_id AND verifications.user_id = users.id AND verifications.id NOT IN (COALESCE(users.password_verification_id, ''), COALESCE(users.email_verification_id, ''), COALESCE(users.phone_verification_id, ''), COALESCE(users.invite_verification_id, '')))` +
`, 'identityProviderLinks', jsonb_agg(DISTINCT jsonb_build_object('providerId', user_identity_provider_links.identity_provider_id, 'providedUserId', user_identity_provider_links.provided_user_id, 'providedUsername', user_identity_provider_links.provided_username, 'createdAt', user_identity_provider_links.created_at, 'updatedAt', user_identity_provider_links.updated_at)) FILTER (WHERE user_identity_provider_links.user_id IS NOT NULL)` +
`, 'invite', CASE WHEN users.invite_verification_id IS NOT NULL OR users.invite_accepted_at IS NOT NULL THEN jsonb_build_object('acceptedAt', users.invite_accepted_at, 'failedAttempts', users.invite_failed_attempts, 'pendingVerification', ` + verificationQuery(userHuman{}.inviteVerificationIDColumn()) + `) ELSE NULL END` +
`) END AS human FROM zitadel.users`
func verificationQuery(column database.Column) string {
var builder database.StatementBuilder
builder.WriteString("CASE WHEN ")
column.WriteQualified(&builder)
builder.WriteString(` IS NOT NULL THEN (SELECT row_to_json(res.*) FROM (SELECT verifications.id, encode(verifications.code, 'escape')::JSONB AS code, verifications.created_at as "createdAt", verifications.updated_at as "updatedAt", verifications.created_at+verifications.expiry AS "expiresAt", verifications.failed_attempts as "failedAttempts" FROM zitadel.verifications WHERE verifications.instance_id = users.instance_id AND verifications.id = `)
column.WriteQualified(&builder)
builder.WriteString(`) AS res) ELSE NULL END`)
return builder.String()
}
// Get implements [domain.UserRepository].
func (u user) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.User, error) {
options := new(database.QueryOpts)
for _, opt := range u.appendQueryOpts(opts) {
opt(options)
}
if !options.Condition.IsRestrictingColumn(u.InstanceIDColumn()) {
return nil, database.NewMissingConditionError(u.InstanceIDColumn())
}
builder := database.NewStatementBuilder(queryUserStmt)
options.Write(builder)
return scanUser(ctx, client, builder)
}
// List implements [domain.UserRepository].
func (u user) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.User, error) {
options := new(database.QueryOpts)
for _, opt := range u.appendQueryOpts(opts) {
opt(options)
}
if !options.Condition.IsRestrictingColumn(u.InstanceIDColumn()) {
return nil, database.NewMissingConditionError(u.InstanceIDColumn())
}
builder := database.NewStatementBuilder(queryUserStmt)
options.Write(builder)
return scanUsers(ctx, client, builder)
}
func (u user) appendQueryOpts(opts []database.QueryOption) []database.QueryOption {
return append(opts,
u.joinMachineKeys(),
u.joinPATs(),
u.joinMetadata(),
u.joinVerifications(),
u.joinPasskeys(),
u.joinIdentityProviderLinks(),
database.WithGroupBy(u.PrimaryKeyColumns()...),
)
}
// Update implements [domain.UserRepository].
func (u user) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) {
if err := checkPKCondition(u, condition); err != nil {
return 0, err
}
if len(changes) == 0 {
return 0, database.ErrNoChanges
}
if !database.Changes(changes).IsOnColumn(u.updatedAtColumn()) {
changes = append(changes, u.clearUpdatedAt())
}
builder := database.NewStatementBuilder("WITH existing_user AS (SELECT * FROM zitadel.users")
writeCondition(builder, condition)
builder.WriteString(") ")
for i, change := range changes {
sessionCTE(change, i, 0, builder)
}
builder.WriteString("UPDATE zitadel.users SET ")
if err := database.Changes(changes).Write(builder); err != nil {
return 0, err
}
builder.WriteString(" FROM existing_user")
writeCondition(builder, database.And(
database.NewColumnCondition(u.IDColumn(), existingUser.IDColumn()),
database.NewColumnCondition(u.InstanceIDColumn(), existingUser.InstanceIDColumn()),
))
return client.Exec(ctx, builder.String(), builder.Args()...)
}
func (u user) unqualifiedTableName() string {
if u.tableName != "" {
return u.tableName
}
return "users"
}
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
// SetState implements [domain.UserRepository].
func (u user) SetState(state domain.UserState) database.Change {
return database.NewChange(u.StateColumn(), state)
}
// SetUpdatedAt implements [domain.UserRepository].
func (u user) SetUpdatedAt(updatedAt time.Time) database.Change {
return database.NewChange(u.updatedAtColumn(), updatedAt)
}
// SetUsername implements [domain.UserRepository].
func (u user) SetUsername(username string) database.Change {
return database.NewChange(u.UsernameColumn(), username)
}
func (u user) clearUpdatedAt() database.Change {
return database.NewChange(u.updatedAtColumn(), database.NullInstruction)
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
// IDCondition implements [domain.UserRepository].
func (u user) IDCondition(userID string) database.Condition {
return database.NewTextCondition(u.IDColumn(), database.TextOperationEqual, userID)
}
// InstanceIDCondition implements [domain.UserRepository].
func (u user) InstanceIDCondition(instanceID string) database.Condition {
return database.NewTextCondition(u.InstanceIDColumn(), database.TextOperationEqual, instanceID)
}
// LoginNameCondition implements [domain.UserRepository].
func (u user) LoginNameCondition(op database.TextOperation, loginName string) database.Condition {
panic("unimplemented")
}
// OrganizationIDCondition implements [domain.UserRepository].
func (u user) OrganizationIDCondition(orgID string) database.Condition {
return database.NewTextCondition(u.organizationIDColumn(), database.TextOperationEqual, orgID)
}
// PrimaryKeyCondition implements [domain.UserRepository].
func (u user) PrimaryKeyCondition(instanceID string, userID string) database.Condition {
return database.And(
u.InstanceIDCondition(instanceID),
u.IDCondition(userID),
)
}
// StateCondition implements [domain.UserRepository].
func (u user) StateCondition(state domain.UserState) database.Condition {
return database.NewNumberCondition(u.StateColumn(), database.NumberOperationEqual, state)
}
// TypeCondition implements [domain.UserRepository].
func (u user) TypeCondition(userType domain.UserType) database.Condition {
return database.NewNumberCondition(u.TypeColumn(), database.NumberOperationEqual, userType)
}
// UsernameCondition implements [domain.UserRepository].
func (u user) UsernameCondition(op database.TextOperation, username string) database.Condition {
return database.NewTextCondition(u.UsernameColumn(), op, username)
}
// ExistsMetadata implements [domain.UserRepository].
func (u user) ExistsMetadata(condition database.Condition) database.Condition {
return database.Exists(
u.userMetadata.qualifiedTableName(),
database.And(
database.NewColumnCondition(u.InstanceIDColumn(), u.userMetadata.instanceIDColumn()),
database.NewColumnCondition(u.IDColumn(), u.userMetadata.userIDColumn()),
condition,
),
)
}
// ExistsPasskey implements [domain.HumanUserRepository].
func (u user) ExistsPasskey(condition database.Condition) database.Condition {
return database.Exists(
u.userPasskey.qualifiedTableName(),
database.And(
database.NewColumnCondition(u.InstanceIDColumn(), u.userPasskey.instanceIDColumn()),
database.NewColumnCondition(u.IDColumn(), u.userPasskey.userIDColumn()),
condition,
),
)
}
// ExistsIdentityProviderLink implements [domain.HumanUserRepository].
func (u user) ExistsIdentityProviderLink(condition database.Condition) database.Condition {
return database.Exists(
u.userIdentityProviderLink.qualifiedTableName(),
database.And(
database.NewColumnCondition(u.InstanceIDColumn(), u.userIdentityProviderLink.instanceIDColumn()),
database.NewColumnCondition(u.IDColumn(), u.userIdentityProviderLink.userIDColumn()),
condition,
),
)
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
// PrimaryKeyColumns implements [domain.UserRepository].
func (u user) PrimaryKeyColumns() []database.Column {
return database.Columns{
u.InstanceIDColumn(),
u.IDColumn(),
}
}
// CreatedAtColumn implements [domain.UserRepository].
func (u user) CreatedAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "created_at")
}
// StateColumn implements [domain.UserRepository].
func (u user) StateColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "state")
}
// TypeColumn implements [domain.UserRepository].
func (u user) TypeColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "type")
}
// UsernameColumn implements [domain.UserRepository].
func (u user) UsernameColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "username")
}
// IDColumn implements [domain.UserRepository].
func (u user) IDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "id")
}
func (u user) InstanceIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "instance_id")
}
func (u user) updatedAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "updated_at")
}
func (u user) organizationIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "organization_id")
}
// -------------------------------------------------------------
// scanners
// -------------------------------------------------------------
type rawUser struct {
domain.User
Machine *json.RawMessage `json:"machine,omitempty" db:"machine"`
Human *json.RawMessage `json:"human,omitempty" db:"human"`
}
func (u *rawUser) toDomain() (*domain.User, error) {
if u.Machine != nil {
err := json.Unmarshal(*u.Machine, &u.User.Machine)
if err != nil {
return nil, err
}
} else if u.Human != nil {
err := json.Unmarshal(*u.Human, &u.User.Human)
if err != nil {
return nil, err
}
}
return &u.User, nil
}
func scanUser(ctx context.Context, client database.QueryExecutor, builder *database.StatementBuilder) (*domain.User, error) {
rows, err := client.Query(ctx, builder.String(), builder.Args()...)
if err != nil {
return nil, err
}
var user rawUser
if err = rows.(database.CollectableRows).CollectExactlyOneRow(&user); err != nil {
return nil, err
}
return user.toDomain()
}
func scanUsers(ctx context.Context, client database.QueryExecutor, builder *database.StatementBuilder) ([]*domain.User, error) {
rows, err := client.Query(ctx, builder.String(), builder.Args()...)
if err != nil {
return nil, err
}
var users []rawUser
if err = rows.(database.CollectableRows).Collect(&users); err != nil {
return nil, err
}
result := make([]*domain.User, len(users))
for i, user := range users {
domainUser, err := user.toDomain()
if err != nil {
return nil, err
}
result[i] = domainUser
}
return result, nil
}
// -------------------------------------------------------------
// sub repositories
// -------------------------------------------------------------
// Human implements [domain.UserRepository].
func (u user) Human() domain.HumanUserRepository {
return userHuman{user: u}
}
// Machine implements [domain.UserRepository].
func (u user) Machine() domain.MachineUserRepository {
return userMachine{user: u}
}
func (u user) joinMachineKeys() database.QueryOption {
return database.WithLeftJoin(
u.machineKey.qualifiedTableName(),
database.And(
database.NewColumnCondition(u.InstanceIDColumn(), u.machineKey.instanceIDColumn()),
database.NewColumnCondition(u.IDColumn(), u.machineKey.userIDColumn()),
),
)
}
func (u user) joinPATs() database.QueryOption {
return database.WithLeftJoin(
u.userPersonalAccessToken.qualifiedTableName(),
database.And(
database.NewColumnCondition(u.InstanceIDColumn(), u.userPersonalAccessToken.instanceIDColumn()),
database.NewColumnCondition(u.IDColumn(), u.userPersonalAccessToken.userIDColumn()),
),
)
}
func (u user) joinMetadata() database.QueryOption {
return database.WithLeftJoin(
u.userMetadata.qualifiedTableName(),
database.And(
database.NewColumnCondition(u.InstanceIDColumn(), u.userMetadata.instanceIDColumn()),
database.NewColumnCondition(u.IDColumn(), u.userMetadata.userIDColumn()),
),
)
}
func (u user) joinPasskeys() database.QueryOption {
return database.WithLeftJoin(
u.userPasskey.qualifiedTableName(),
database.And(
database.NewColumnCondition(u.InstanceIDColumn(), u.userPasskey.instanceIDColumn()),
database.NewColumnCondition(u.IDColumn(), u.userPasskey.userIDColumn()),
),
)
}
func (u user) joinVerifications() database.QueryOption {
return database.WithLeftJoin(
u.verification.qualifiedTableName(),
database.And(
database.NewColumnCondition(u.InstanceIDColumn(), u.verification.instanceIDColumn()),
database.NewColumnCondition(u.IDColumn(), u.verification.userIDColumn()),
),
)
}
func (u user) joinIdentityProviderLinks() database.QueryOption {
return database.WithLeftJoin(
u.userIdentityProviderLink.qualifiedTableName(),
database.And(
database.NewColumnCondition(u.InstanceIDColumn(), u.userIdentityProviderLink.instanceIDColumn()),
database.NewColumnCondition(u.IDColumn(), u.userIdentityProviderLink.userIDColumn()),
),
)
}
@@ -0,0 +1,486 @@
package repository
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"time"
"github.com/muhlemmer/gu"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
// -------------------------------------------------------------
// repository
// -------------------------------------------------------------
type userHuman struct {
user
}
var existingHumanUser = userHuman{
user: user{
tableName: "existing_user",
},
}
func HumanUserRepository() domain.HumanUserRepository {
return &userHuman{
user: user{},
}
}
func (u userHuman) create(ctx context.Context, builder *database.StatementBuilder, client database.QueryExecutor, user *domain.User) error {
var createdAt any = database.NowInstruction
if !user.CreatedAt.IsZero() {
createdAt = user.CreatedAt
}
columnValues := map[string]any{
"instance_id": user.InstanceID,
"organization_id": user.OrganizationID,
"id": user.ID,
"username": user.Username,
"state": user.State,
"type": "human",
"first_name": user.Human.FirstName,
"last_name": user.Human.LastName,
"created_at": createdAt,
"updated_at": createdAt,
}
if !user.Human.MultifactorInitializationSkippedAt.IsZero() {
columnValues["multifactor_initialization_skipped_at"] = user.Human.MultifactorInitializationSkippedAt
}
columnValues = u.setOptionalProfileColumns(columnValues, user.Human)
ctes := make(map[string]database.CTEChange)
columnValues, ctes = u.setPasswordColumns(columnValues, ctes, user, createdAt)
columnValues, ctes = u.setEmailColumns(columnValues, ctes, user, createdAt)
columnValues, ctes = u.setPhoneColumns(columnValues, ctes, user, createdAt)
columnValues, ctes = u.setInviteColumns(columnValues, ctes, user, createdAt)
for i, passkey := range user.Human.Passkeys {
ctes[fmt.Sprintf("passkey_%d", i)] = u.AddPasskey(passkey).(database.CTEChange)
}
if user.Human.TOTP != nil {
columnValues["totp_secret"] = user.Human.TOTP.Secret
if !user.Human.TOTP.VerifiedAt.IsZero() {
columnValues["totp_verified_at"] = user.Human.TOTP.VerifiedAt
}
}
for i, link := range user.Human.IdentityProviderLinks {
name := fmt.Sprintf("idp_link_%d", i)
ctes[name] = u.AddIdentityProviderLink(link).(database.CTEChange)
}
for i, verification := range user.Human.Verifications {
ctes[fmt.Sprintf("verification_%d", i)] = u.SetVerification(&domain.VerificationTypeInit{
ID: gu.Ptr(verification.ID),
CreatedAt: user.CreatedAt,
Code: verification.Code,
}).(database.CTEChange)
}
for i, metadata := range user.Metadata {
ctes[fmt.Sprintf("metadata_%d", i)] = u.SetMetadata(metadata).(database.CTEChange)
}
// write CTE changes
for name, cte := range ctes {
cte.SetName(name)
builder.WriteString(", ")
builder.WriteString(name)
builder.WriteString(" AS (")
cte.WriteCTE(builder)
builder.WriteString(")")
}
columns := slices.Sorted(maps.Keys(columnValues))
// write final insert
builder.WriteString(" INSERT INTO zitadel.users (")
builder.WriteString(strings.Join(columns, ", "))
builder.WriteString(") VALUES (")
for i, column := range columns {
if i > 0 {
builder.WriteString(", ")
}
value := columnValues[column]
if fn, ok := value.(func(builder *database.StatementBuilder)); ok {
fn(builder)
continue
}
builder.WriteArg(value)
}
builder.WriteString(") RETURNING created_at, updated_at")
return client.QueryRow(ctx, builder.String(), builder.Args()...).Scan(&user.CreatedAt, &user.UpdatedAt)
}
func (u userHuman) setPasswordColumns(columnValues map[string]any, ctes map[string]database.CTEChange, user *domain.User, createdAt any) (map[string]any, map[string]database.CTEChange) {
if user.Human.Password.PendingVerification != nil {
verification := &domain.VerificationTypeInit{
CreatedAt: user.CreatedAt,
Code: user.Human.Password.PendingVerification.Code,
}
if user.Human.Password.PendingVerification.ID != "" {
verification.ID = &user.Human.Password.PendingVerification.ID
}
if user.Human.Password.PendingVerification.ExpiresAt != nil && !user.Human.Password.PendingVerification.ExpiresAt.IsZero() {
verification.Expiry = gu.Ptr(time.Since(*user.Human.Password.PendingVerification.ExpiresAt))
}
ctes["password_verification"] = u.SetResetPasswordVerification(verification).(database.CTEChange)
columnValues["password_verification_id"] = func(builder *database.StatementBuilder) {
builder.WriteString(`(SELECT id FROM password_verification)`)
}
} else if user.Human.Password.Hash != "" {
columnValues["password_hash"] = user.Human.Password.Hash
columnValues["password_changed_at"] = createdAt
if !user.Human.Password.ChangedAt.IsZero() {
columnValues["password_changed_at"] = user.Human.Password.ChangedAt
}
}
return columnValues, ctes
}
func (u userHuman) setEmailColumns(columnValues map[string]any, ctes map[string]database.CTEChange, user *domain.User, createdAt any) (map[string]any, map[string]database.CTEChange) {
if user.Human.Email.PendingVerification != nil {
verification := &domain.VerificationTypeInit{
CreatedAt: user.CreatedAt,
Code: user.Human.Email.PendingVerification.Code,
}
if user.Human.Email.PendingVerification.ID != "" {
verification.ID = &user.Human.Email.PendingVerification.ID
}
if user.Human.Email.PendingVerification.ExpiresAt != nil && !user.Human.Email.PendingVerification.ExpiresAt.IsZero() {
verification.Expiry = gu.Ptr(time.Since(*user.Human.Email.PendingVerification.ExpiresAt))
}
ctes["email_verification"] = u.SetEmailVerification(verification).(database.CTEChange)
columnValues["email_verification_id"] = func(builder *database.StatementBuilder) {
builder.WriteString(`(SELECT id FROM email_verification)`)
}
}
if user.Human.Email.Address != "" {
columnValues["email"] = user.Human.Email.Address
columnValues["unverified_email"] = user.Human.Email.Address
columnValues["email_verified_at"] = createdAt
if !user.Human.Email.VerifiedAt.IsZero() {
columnValues["email_verified_at"] = user.Human.Email.VerifiedAt
}
}
if user.Human.Email.UnverifiedAddress != "" {
columnValues["unverified_email"] = user.Human.Email.UnverifiedAddress
}
return columnValues, ctes
}
func (u userHuman) setPhoneColumns(columnValues map[string]any, ctes map[string]database.CTEChange, user *domain.User, createdAt any) (map[string]any, map[string]database.CTEChange) {
if user.Human.Phone == nil {
return columnValues, ctes
}
if user.Human.Phone.PendingVerification != nil {
verification := &domain.VerificationTypeInit{
CreatedAt: user.CreatedAt,
Code: user.Human.Phone.PendingVerification.Code,
}
if user.Human.Phone.PendingVerification.ID != "" {
verification.ID = &user.Human.Phone.PendingVerification.ID
}
if user.Human.Phone.PendingVerification.ExpiresAt != nil && !user.Human.Phone.PendingVerification.ExpiresAt.IsZero() {
verification.Expiry = gu.Ptr(time.Since(*user.Human.Phone.PendingVerification.ExpiresAt))
}
ctes["phone_verification"] = u.SetPhoneVerification(verification).(database.CTEChange)
columnValues["phone_verification_id"] = func(builder *database.StatementBuilder) {
builder.WriteString(`(SELECT id FROM phone_verification)`)
}
}
if user.Human.Phone.Number != "" {
columnValues["phone"] = user.Human.Phone.Number
columnValues["unverified_phone"] = user.Human.Phone.Number
columnValues["phone_verified_at"] = createdAt
if !user.Human.Phone.VerifiedAt.IsZero() {
columnValues["phone_verified_at"] = user.Human.Phone.VerifiedAt
}
}
if user.Human.Phone.UnverifiedNumber != "" {
columnValues["unverified_phone"] = user.Human.Phone.UnverifiedNumber
}
return columnValues, ctes
}
func (u userHuman) setInviteColumns(columnValues map[string]any, ctes map[string]database.CTEChange, user *domain.User, createdAt any) (map[string]any, map[string]database.CTEChange) {
if user.Human.Invite == nil {
return columnValues, ctes
}
if user.Human.Invite.PendingVerification != nil {
verification := &domain.VerificationTypeInit{
CreatedAt: user.CreatedAt,
Code: user.Human.Invite.PendingVerification.Code,
}
if user.Human.Invite.PendingVerification.ID != "" {
verification.ID = &user.Human.Invite.PendingVerification.ID
}
if user.Human.Invite.PendingVerification.ExpiresAt != nil && !user.Human.Invite.PendingVerification.ExpiresAt.IsZero() {
verification.Expiry = gu.Ptr(time.Since(*user.Human.Invite.PendingVerification.ExpiresAt))
}
ctes["invite_verification"] = u.SetInviteVerification(verification).(database.CTEChange)
}
if !user.Human.Invite.AcceptedAt.IsZero() {
columnValues["invite_accepted_at"] = createdAt
}
return columnValues, ctes
}
func (u userHuman) setOptionalProfileColumns(columnValues map[string]any, human *domain.HumanUser) map[string]any {
if !human.PreferredLanguage.IsRoot() {
columnValues["preferred_language"] = human.PreferredLanguage
}
if human.DisplayName != "" {
columnValues["display_name"] = human.DisplayName
}
if human.Nickname != "" {
columnValues["nickname"] = human.Nickname
}
if human.Gender != domain.HumanGenderUnspecified {
columnValues["gender"] = human.Gender
}
if human.AvatarKey != "" {
columnValues["avatar_key"] = human.AvatarKey
}
if human.Password.IsChangeRequired {
columnValues["password_change_required"] = true
}
return columnValues
}
func (u userHuman) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) {
if !condition.IsRestrictingColumn(u.TypeColumn()) {
condition = database.And(condition, u.TypeCondition(domain.UserTypeHuman))
}
return u.user.Update(ctx, client, condition, changes...)
}
var _ domain.HumanUserRepository = (*userHuman)(nil)
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
// SetAvatarKey implements [domain.HumanUserRepository].
func (u userHuman) SetAvatarKey(avatarKey *string) database.Change {
return database.NewChangePtr(u.avatarKeyColumn(), avatarKey)
}
// SetDisplayName implements [domain.HumanUserRepository].
func (u userHuman) SetDisplayName(displayName string) database.Change {
return database.NewChange(u.DisplayNameColumn(), displayName)
}
// SetFirstName implements [domain.HumanUserRepository].
func (u userHuman) SetFirstName(firstName string) database.Change {
return database.NewChange(u.FirstNameColumn(), firstName)
}
// SetGender implements [domain.HumanUserRepository].
func (u userHuman) SetGender(gender domain.HumanGender) database.Change {
return database.NewChange(u.genderColumn(), gender)
}
// SetLastName implements [domain.HumanUserRepository].
func (u userHuman) SetLastName(lastName string) database.Change {
return database.NewChange(u.LastNameColumn(), lastName)
}
// SkipMultifactorInitializationAt implements [domain.HumanUserRepository].
func (u userHuman) SkipMultifactorInitializationAt(skippedAt time.Time) database.Change {
if skippedAt.IsZero() {
return u.SkipMultifactorInitialization()
}
return database.NewChange(u.multifactorInitializationSkippedAtColumn(), skippedAt)
}
// SkipMultifactorInitialization implements [domain.HumanUserRepository].
func (u userHuman) SkipMultifactorInitialization() database.Change {
return database.NewChange(u.multifactorInitializationSkippedAtColumn(), database.NowInstruction)
}
// SetNickname implements [domain.HumanUserRepository].
func (u userHuman) SetNickname(nickname string) database.Change {
return database.NewChange(u.NicknameColumn(), nickname)
}
// SetPreferredLanguage implements [domain.HumanUserRepository].
func (u userHuman) SetPreferredLanguage(preferredLanguage language.Tag) database.Change {
if preferredLanguage == language.Und {
return database.NewChangeToNull(u.preferredLanguageColumn())
}
return database.NewChange(u.preferredLanguageColumn(), preferredLanguage.String())
}
// SetVerification implements [domain.HumanUserRepository].
func (u userHuman) SetVerification(verification domain.VerificationType) database.Change {
switch typ := verification.(type) {
case *domain.VerificationTypeInit:
return database.NewCTEChange(func(builder *database.StatementBuilder) {
var (
createdAt any = database.NowInstruction
expiry any = database.NullInstruction
id any = database.DefaultInstruction
)
if !typ.CreatedAt.IsZero() {
createdAt = typ.CreatedAt
}
if typ.Expiry != nil {
expiry = *typ.Expiry
}
if typ.ID != nil {
id = *typ.ID
}
builder.WriteString("INSERT INTO zitadel.verifications(instance_id, user_id, id, code, created_at, expiry) SELECT instance_id, id, ")
builder.WriteArgs(id, typ.Code, createdAt, expiry)
builder.WriteString(" FROM ")
builder.WriteString(existingHumanUser.unqualifiedTableName())
builder.WriteString(" RETURNING id")
}, nil)
case *domain.VerificationTypeSucceeded:
return database.NewCTEChange(func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM zitadel.verifications USING existing_user")
writeCondition(builder, database.And(
database.NewColumnCondition(u.verification.instanceIDColumn(), existingHumanUser.InstanceIDColumn()),
database.NewColumnCondition(u.verification.userIDColumn(), existingHumanUser.IDColumn()),
database.NewTextCondition(u.verification.idColumn(), database.TextOperationEqual, *typ.ID),
))
}, nil)
case *domain.VerificationTypeUpdate:
changes := make(database.Changes, 0, 3)
if typ.Code != nil {
changes = append(changes, database.NewChange(u.verification.codeColumn(), typ.Code))
}
if typ.Expiry != nil {
changes = append(changes, database.NewChange(u.verification.expiryColumn(), *typ.Expiry))
}
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("UPDATE zitadel.verifications SET ")
err := changes.Write(builder)
logging.New(logging.StreamRuntime).Debug("write changes in cte failed", "error", err)
builder.WriteString(" FROM existing_user")
writeCondition(builder, database.And(
database.NewColumnCondition(u.verification.instanceIDColumn(), existingHumanUser.InstanceIDColumn()),
database.NewColumnCondition(u.verification.userIDColumn(), existingHumanUser.IDColumn()),
database.NewTextCondition(u.verification.idColumn(), database.TextOperationEqual, *typ.ID),
))
},
nil,
)
case *domain.VerificationTypeSkipped:
panic("skip verification is not supported for verifications")
case *domain.VerificationTypeFailed:
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("UPDATE ")
builder.WriteString(u.verification.qualifiedTableName())
builder.WriteString(" SET ")
err := database.NewIncrementColumnChange(u.verification.failedAttemptsColumn(), database.Coalesce(u.verification.failedAttemptsColumn(), 0)).Write(builder)
logging.New(logging.StreamRuntime).Debug("write cte failed", "error", err)
builder.WriteString(" FROM existing_user")
writeCondition(builder, database.And(
database.NewColumnCondition(u.verification.instanceIDColumn(), existingHumanUser.InstanceIDColumn()),
database.NewColumnCondition(u.verification.userIDColumn(), existingHumanUser.IDColumn()),
database.NewTextCondition(u.verification.idColumn(), database.TextOperationEqual, *typ.ID),
))
},
nil,
)
}
panic(fmt.Sprintf("undefined verification type %T", verification))
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
// DisplayNameCondition implements [domain.HumanUserRepository].
func (u userHuman) DisplayNameCondition(op database.TextOperation, displayName string) database.Condition {
return database.NewTextCondition(u.DisplayNameColumn(), op, displayName)
}
// FirstNameCondition implements [domain.HumanUserRepository].
func (u userHuman) FirstNameCondition(op database.TextOperation, firstName string) database.Condition {
return database.NewTextCondition(u.FirstNameColumn(), op, firstName)
}
// LastNameCondition implements [domain.HumanUserRepository].
func (u userHuman) LastNameCondition(op database.TextOperation, lastName string) database.Condition {
return database.NewTextCondition(u.LastNameColumn(), op, lastName)
}
// NicknameCondition implements [domain.HumanUserRepository].
func (u userHuman) NicknameCondition(op database.TextOperation, nickname string) database.Condition {
return database.NewTextCondition(u.NicknameColumn(), op, nickname)
}
// PreferredLanguageCondition implements [domain.HumanUserRepository].
func (u userHuman) PreferredLanguageCondition(lang language.Tag) database.Condition {
return database.NewTextCondition(u.preferredLanguageColumn(), database.TextOperationEqual, lang.String())
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
// DisplayNameColumn implements [domain.HumanUserRepository].
func (u userHuman) DisplayNameColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "display_name")
}
func (u userHuman) genderColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "gender")
}
func (u userHuman) avatarKeyColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "avatar_key")
}
// FirstNameColumn implements [domain.HumanUserRepository].
func (u userHuman) FirstNameColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "first_name")
}
// LastNameColumn implements [domain.HumanUserRepository].
func (u userHuman) LastNameColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "last_name")
}
// NicknameColumn implements [domain.HumanUserRepository].
func (u userHuman) NicknameColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "nickname")
}
func (u userHuman) multifactorInitializationSkippedAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "multifactor_initialization_skipped_at")
}
func (u userHuman) preferredLanguageColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "preferred_language")
}
// -------------------------------------------------------------
// scanners
// -------------------------------------------------------------
// -------------------------------------------------------------
// sub repositories
// -------------------------------------------------------------
@@ -0,0 +1,132 @@
package repository
import (
"fmt"
"time"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
func (u userHuman) SetEmail(email string) database.Change {
return database.NewChange(u.EmailColumn(), email)
}
func (u userHuman) SetUnverifiedEmail(email string) database.Change {
return database.NewChange(u.unverifiedEmailColumn(), email)
}
// SetEmailVerification implements [domain.HumanUserRepository].
func (u userHuman) SetEmailVerification(verification domain.VerificationType) database.Change {
switch typ := verification.(type) {
case *domain.VerificationTypeInit:
return u.verification.init(typ, existingHumanUser.unqualifiedTableName(), existingHumanUser.emailVerificationIDColumn())
case *domain.VerificationTypeUpdate:
return u.verification.update(typ, existingHumanUser.unqualifiedTableName(),
existingHumanUser.InstanceIDColumn(), existingHumanUser.emailVerificationIDColumn())
case *domain.VerificationTypeSucceeded:
return database.NewChanges(
u.verification.verified(typ, existingUser.unqualifiedTableName(), existingUser.InstanceIDColumn(),
existingHumanUser.emailVerificationIDColumn(), u.emailVerifiedAtColumn(), u.failedEmailOTPAttemptsColumn(),
),
database.NewChangeToColumn(u.EmailColumn(), u.unverifiedEmailColumn()),
)
case *domain.VerificationTypeFailed:
return u.verification.failed(existingHumanUser.unqualifiedTableName(), existingHumanUser.InstanceIDColumn(), existingHumanUser.emailVerificationIDColumn())
case *domain.VerificationTypeSkipped:
return u.verification.skipped(typ, u.emailVerifiedAtColumn(), u.emailVerificationIDColumn(), u.failedEmailOTPAttemptsColumn())
}
panic(fmt.Sprintf("type not allowed for email verification change %T", verification))
}
// EnableEmailOTPAt implements [domain.HumanUserRepository].
func (u userHuman) EnableEmailOTPAt(enabledAt time.Time) database.Change {
return database.NewChange(u.emailOTPEnabledAtColumn(), enabledAt)
}
// EnableEmailOTP implements [domain.HumanUserRepository].
func (u userHuman) EnableEmailOTP() database.Change {
return database.NewChange(u.emailOTPEnabledAtColumn(), database.NowInstruction)
}
// DisableEmailOTP implements [domain.HumanUserRepository].
func (u userHuman) DisableEmailOTP() database.Change {
return database.NewChangeToNull(u.emailOTPEnabledAtColumn())
}
func (u userHuman) SetLastSuccessfulEmailOTPCheck(checkedAt time.Time) database.Change {
if checkedAt.IsZero() {
return database.NewChanges(
database.NewChange(u.lastSuccessfulEmailOTPCheckColumn(), database.NowInstruction),
u.ResetEmailOTPFailedAttempts(),
)
}
return database.NewChanges(
database.NewChange(u.lastSuccessfulEmailOTPCheckColumn(), checkedAt),
u.ResetEmailOTPFailedAttempts(),
)
}
func (u userHuman) IncrementEmailOTPFailedAttempts() database.Change {
return database.NewIncrementColumnChange(u.failedEmailOTPAttemptsColumn(), database.Coalesce(u.failedEmailOTPAttemptsColumn(), 0))
}
func (u userHuman) ResetEmailOTPFailedAttempts() database.Change {
return database.NewChange(u.failedEmailOTPAttemptsColumn(), 0)
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
// EmailCondition implements [domain.HumanUserRepository].
func (u userHuman) EmailCondition(op database.TextOperation, email string) database.Condition {
return database.Or(u.VerifiedEmailCondition(op, email), u.UnverifiedEmailCondition(op, email))
}
// UnverifiedEmailCondition implements [domain.HumanUserRepository].
func (u userHuman) UnverifiedEmailCondition(op database.TextOperation, email string) database.Condition {
return database.NewTextCondition(u.unverifiedEmailColumn(), op, email)
}
// VerifiedEmailCondition implements [domain.HumanUserRepository].
func (u userHuman) VerifiedEmailCondition(op database.TextOperation, email string) database.Condition {
return database.NewTextCondition(u.EmailColumn(), op, email)
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
// EmailColumn implements [domain.HumanUserRepository].
func (u userHuman) EmailColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "email")
}
func (u userHuman) unverifiedEmailColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "unverified_email")
}
func (u userHuman) emailOTPEnabledAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "email_otp_enabled_at")
}
func (u userHuman) emailVerifiedAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "email_verified_at")
}
func (u userHuman) emailVerificationIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "email_verification_id")
}
func (u userHuman) failedEmailOTPAttemptsColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "email_otp_failed_attempts")
}
func (u userHuman) lastSuccessfulEmailOTPCheckColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "email_otp_last_successful_check")
}
@@ -0,0 +1,92 @@
package repository
import (
"fmt"
"time"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
func (u userHuman) SetPassword(password string) database.Change {
return database.NewChange(u.passwordHashColumn(), password)
}
// SetResetPasswordVerification implements [domain.HumanUserRepository].
func (u userHuman) SetResetPasswordVerification(verification domain.VerificationType) database.Change {
switch typ := verification.(type) {
case *domain.VerificationTypeInit:
return u.verification.init(typ, existingUser.unqualifiedTableName(), existingHumanUser.passwordVerificationIDColumn())
case *domain.VerificationTypeUpdate:
return u.verification.update(typ, existingHumanUser.unqualifiedTableName(),
existingHumanUser.InstanceIDColumn(), existingHumanUser.passwordVerificationIDColumn(),
)
case *domain.VerificationTypeSucceeded:
return u.verification.verified(typ, existingHumanUser.unqualifiedTableName(), existingHumanUser.InstanceIDColumn(),
existingHumanUser.passwordVerificationIDColumn(), u.passwordVerifiedAtColumn(), u.failedPasswordAttemptsColumn(),
)
case *domain.VerificationTypeSkipped:
return u.verification.skipped(typ, u.passwordVerifiedAtColumn(), u.passwordVerificationIDColumn(), u.failedPasswordAttemptsColumn())
case *domain.VerificationTypeFailed:
return u.verification.failed(existingHumanUser.unqualifiedTableName(), existingHumanUser.InstanceIDColumn(), existingHumanUser.passwordVerificationIDColumn())
}
panic(fmt.Sprintf("undefined verification type %T", verification))
}
// SetPasswordChangeRequired implements [domain.HumanUserRepository].
func (u userHuman) SetPasswordChangeRequired(required bool) database.Change {
return database.NewChange(u.passwordChangeRequiredColumn(), required)
}
func (u userHuman) SetLastSuccessfulPasswordCheck(checkedAt time.Time) database.Change {
if checkedAt.IsZero() {
return database.NewChanges(
database.NewChange(u.lastSuccessfulPasswordCheckColumn(), database.NowInstruction),
u.ResetPasswordFailedAttempts(),
)
}
return database.NewChanges(
database.NewChange(u.lastSuccessfulPasswordCheckColumn(), checkedAt),
u.ResetPasswordFailedAttempts(),
)
}
func (u userHuman) IncrementPasswordFailedAttempts() database.Change {
return database.NewIncrementColumnChange(u.failedPasswordAttemptsColumn(), database.Coalesce(u.failedPasswordAttemptsColumn(), 0))
}
func (u userHuman) ResetPasswordFailedAttempts() database.Change {
return database.NewChange(u.failedPasswordAttemptsColumn(), 0)
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (u userHuman) passwordHashColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "password_hash")
}
func (u userHuman) passwordChangeRequiredColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "password_change_required")
}
func (u userHuman) passwordVerifiedAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "password_changed_at")
}
func (u userHuman) passwordVerificationIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "password_verification_id")
}
func (u userHuman) lastSuccessfulPasswordCheckColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "password_last_successful_check")
}
func (u userHuman) failedPasswordAttemptsColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "password_failed_attempts")
}
@@ -0,0 +1,148 @@
package repository
import (
"fmt"
"time"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
// SetPhone implements [domain.HumanUserRepository].
func (u userHuman) SetPhone(phone string) database.Change {
return database.NewChange(u.phoneColumn(), phone)
}
// SetUnverifiedPhone implements [domain.HumanUserRepository].
func (u userHuman) SetUnverifiedPhone(phone string) database.Change {
return database.NewChange(u.unverifiedPhoneColumn(), phone)
}
// SetPhoneVerification implements [domain.HumanUserRepository].
func (u userHuman) SetPhoneVerification(verification domain.VerificationType) database.Change {
switch typ := verification.(type) {
case *domain.VerificationTypeInit:
return u.verification.init(typ, existingHumanUser.unqualifiedTableName(), existingHumanUser.phoneVerificationIDColumn())
case *domain.VerificationTypeUpdate:
return u.verification.update(typ, existingHumanUser.unqualifiedTableName(),
existingHumanUser.InstanceIDColumn(), existingHumanUser.phoneVerificationIDColumn(),
)
case *domain.VerificationTypeSucceeded:
return database.NewChanges(
u.verification.verified(typ, existingHumanUser.unqualifiedTableName(), existingHumanUser.InstanceIDColumn(),
existingHumanUser.phoneVerificationIDColumn(), u.phoneVerifiedAtColumn(), u.failedSMSOTPAttemptsColumn(),
),
database.NewChangeToColumn(u.phoneColumn(), u.unverifiedPhoneColumn()),
)
case *domain.VerificationTypeFailed:
return u.verification.failed(existingHumanUser.unqualifiedTableName(), existingHumanUser.InstanceIDColumn(), existingHumanUser.phoneVerificationIDColumn())
case *domain.VerificationTypeSkipped:
return u.verification.skipped(typ, u.phoneVerifiedAtColumn(), u.phoneVerificationIDColumn(), u.failedSMSOTPAttemptsColumn())
}
panic(fmt.Sprintf("type not allowed for phone verification change %T", verification))
}
// RemovePhone implements [domain.HumanUserRepository].
func (u userHuman) RemovePhone() database.Change {
return database.NewChanges(
database.NewChangeToNull(u.phoneColumn()),
database.NewChangeToNull(u.phoneVerifiedAtColumn()),
database.NewChangeToNull(u.phoneVerificationIDColumn()),
database.NewChangeToNull(u.smsOTPEnabledAtColumn()),
database.NewChangeToNull(u.lastSuccessfulSMSOTPCheckColumn()),
database.NewChangeToNull(u.failedSMSOTPAttemptsColumn()),
)
}
// EnableSMSOTPAt implements [domain.HumanUserRepository].
func (u userHuman) EnableSMSOTPAt(enabledAt time.Time) database.Change {
return database.NewChange(u.smsOTPEnabledAtColumn(), enabledAt)
}
// EnableSMSOTP implements [domain.HumanUserRepository].
func (u userHuman) EnableSMSOTP() database.Change {
return database.NewChange(u.smsOTPEnabledAtColumn(), database.NowInstruction)
}
// DisableSMSOTP implements [domain.HumanUserRepository].
func (u userHuman) DisableSMSOTP() database.Change {
return database.NewChangeToNull(u.smsOTPEnabledAtColumn())
}
func (u userHuman) SetLastSuccessfulSMSOTPCheck(checkedAt time.Time) database.Change {
if checkedAt.IsZero() {
return database.NewChanges(
database.NewChange(u.lastSuccessfulSMSOTPCheckColumn(), database.NowInstruction),
u.ResetSMSOTPFailedAttempts(),
)
}
return database.NewChanges(
database.NewChange(u.lastSuccessfulSMSOTPCheckColumn(), checkedAt),
u.ResetSMSOTPFailedAttempts(),
)
}
// IncrementSMSOTPFailedAttempts implements [domain.HumanUserRepository].
func (u userHuman) IncrementSMSOTPFailedAttempts() database.Change {
return database.NewIncrementColumnChange(u.failedSMSOTPAttemptsColumn(), database.Coalesce(u.failedSMSOTPAttemptsColumn(), 0))
}
// ResetSMSOTPFailedAttempts implements [domain.HumanUserRepository].
func (u userHuman) ResetSMSOTPFailedAttempts() database.Change {
return database.NewChange(u.failedSMSOTPAttemptsColumn(), 0)
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
// PhoneCondition implements [domain.HumanUserRepository].
func (u userHuman) PhoneCondition(op database.TextOperation, phone string) database.Condition {
return database.Or(u.UnverifiedPhoneCondition(op, phone), u.VerifiedPhoneCondition(op, phone))
}
// UnverifiedPhoneCondition implements [domain.HumanUserRepository].
func (u userHuman) UnverifiedPhoneCondition(op database.TextOperation, phone string) database.Condition {
return database.NewTextCondition(u.unverifiedPhoneColumn(), op, phone)
}
// VerifiedPhoneCondition implements [domain.HumanUserRepository].
func (u userHuman) VerifiedPhoneCondition(op database.TextOperation, phone string) database.Condition {
return database.NewTextCondition(u.phoneColumn(), op, phone)
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (u userHuman) phoneColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "phone")
}
func (u userHuman) unverifiedPhoneColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "unverified_phone")
}
func (u userHuman) phoneVerifiedAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "phone_verified_at")
}
func (u userHuman) phoneVerificationIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "phone_verification_id")
}
func (u userHuman) smsOTPEnabledAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "sms_otp_enabled_at")
}
func (u userHuman) lastSuccessfulSMSOTPCheckColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "sms_otp_last_successful_check")
}
func (u userHuman) failedSMSOTPAttemptsColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "sms_otp_failed_attempts")
}
@@ -0,0 +1,598 @@
package repository_test
import (
"context"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/backend/v3/storage/database/repository"
"github.com/zitadel/zitadel/internal/crypto"
)
func Test_humanUser_create(t *testing.T) {
tx, rollback := transactionForRollback(t)
t.Cleanup(rollback)
// tx := pool
userRepo := repository.UserRepository()
instanceID := createInstance(t, tx)
orgID := createOrganization(t, tx, instanceID)
createdAt := time.Now().Round(time.Millisecond).UTC()
existingUserID := createMachineUser(t, tx, instanceID, orgID)
password := gofakeit.Password(true, true, true, true, false, 16)
type args struct {
user *domain.User
}
type want struct {
err error
user *domain.User
}
type test struct {
name string
args args
want want
}
tests := []test{
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
firstName := gofakeit.FirstName()
lastName := gofakeit.LastName()
email := gofakeit.Email()
return test{
name: "minimal representation",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Email: domain.HumanEmail{
UnverifiedAddress: email,
},
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Email: domain.HumanEmail{
UnverifiedAddress: email,
},
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
firstName := gofakeit.FirstName()
lastName := gofakeit.LastName()
email := gofakeit.Email()
return test{
name: "including optional profile fields",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Nickname: "Nick",
DisplayName: firstName + " " + lastName,
PreferredLanguage: language.Georgian,
Gender: domain.HumanGenderFemale,
AvatarKey: "http://localhost:8080/my/profile/picture",
MultifactorInitializationSkippedAt: createdAt,
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
},
Email: domain.HumanEmail{
Address: email,
},
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Nickname: "Nick",
DisplayName: firstName + " " + lastName,
PreferredLanguage: language.Georgian,
Gender: domain.HumanGenderFemale,
AvatarKey: "http://localhost:8080/my/profile/picture",
MultifactorInitializationSkippedAt: createdAt,
Email: domain.HumanEmail{
Address: email,
UnverifiedAddress: email,
VerifiedAt: createdAt,
},
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
ChangedAt: createdAt,
},
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
firstName := gofakeit.FirstName()
lastName := gofakeit.LastName()
email := gofakeit.Email()
code := &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "aes256",
KeyID: "key-id",
Crypted: []byte("crypted"),
}
return test{
name: "with unverified email without expiry",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
},
Email: domain.HumanEmail{
UnverifiedAddress: email,
PendingVerification: &domain.Verification{
ID: "test",
Code: code,
},
},
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Email: domain.HumanEmail{
UnverifiedAddress: email,
PendingVerification: &domain.Verification{
ID: "test",
Code: code,
CreatedAt: createdAt,
UpdatedAt: createdAt,
},
},
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
ChangedAt: createdAt,
},
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
firstName := gofakeit.FirstName()
lastName := gofakeit.LastName()
email := gofakeit.Email()
phone := gofakeit.Phone()
code := &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "aes256",
KeyID: "key-id",
Crypted: []byte("crypted"),
}
return test{
name: "with unverified phone without expiry",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
},
Email: domain.HumanEmail{
Address: email,
UnverifiedAddress: email,
VerifiedAt: createdAt,
},
Phone: &domain.HumanPhone{
UnverifiedNumber: phone,
PendingVerification: &domain.Verification{
ID: "id",
Code: code,
},
},
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Email: domain.HumanEmail{
Address: email,
UnverifiedAddress: email,
VerifiedAt: createdAt,
},
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
ChangedAt: createdAt,
},
Phone: &domain.HumanPhone{
UnverifiedNumber: phone,
PendingVerification: &domain.Verification{
CreatedAt: createdAt,
UpdatedAt: createdAt,
ID: "id",
Code: code,
},
},
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
firstName := gofakeit.FirstName()
lastName := gofakeit.LastName()
email := gofakeit.Email()
passkeys := []*domain.Passkey{
{
ID: gofakeit.UUID(),
KeyID: []byte(gofakeit.UUID()),
Type: domain.PasskeyTypePasswordless,
Name: "passwordless",
PublicKey: []byte("public key"),
AttestationType: "don't know",
AuthenticatorAttestationGUID: []byte("aaguid"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
Challenge: []byte("challenge"),
VerifiedAt: createdAt,
RelyingPartyID: "rpid",
},
{
ID: gofakeit.UUID(),
KeyID: []byte(gofakeit.UUID()),
Type: domain.PasskeyTypeU2F,
Name: "u2f",
PublicKey: []byte("public key"),
AttestationType: "don't know",
AuthenticatorAttestationGUID: []byte("aaguid"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
Challenge: []byte("challenge"),
VerifiedAt: createdAt,
RelyingPartyID: "rpid",
},
}
return test{
name: "with verified passkeys",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
},
Email: domain.HumanEmail{
Address: email,
},
Passkeys: passkeys,
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Email: domain.HumanEmail{
Address: email,
UnverifiedAddress: email,
VerifiedAt: createdAt,
},
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
ChangedAt: createdAt,
},
Passkeys: passkeys,
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
firstName := gofakeit.FirstName()
lastName := gofakeit.LastName()
email := gofakeit.Email()
code := &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "aes256",
KeyID: "key-id",
Crypted: []byte("crypted"),
}
verifications := []*domain.Verification{
{
ID: gofakeit.UUID(),
Code: code,
},
{
ID: gofakeit.UUID(),
Code: code,
},
}
return test{
name: "with verifications",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
},
Email: domain.HumanEmail{
Address: email,
},
Verifications: verifications,
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Email: domain.HumanEmail{
Address: email,
UnverifiedAddress: email,
VerifiedAt: createdAt,
},
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
ChangedAt: createdAt,
},
Verifications: verifications,
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
firstName := gofakeit.FirstName()
lastName := gofakeit.LastName()
email := gofakeit.Email()
return test{
name: "with totp",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
},
Email: domain.HumanEmail{
Address: email,
},
TOTP: &domain.HumanTOTP{
VerifiedAt: createdAt,
Secret: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "aes-256",
KeyID: "id",
Crypted: []byte("secret"),
},
},
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Human: &domain.HumanUser{
FirstName: firstName,
LastName: lastName,
Email: domain.HumanEmail{
Address: email,
UnverifiedAddress: email,
VerifiedAt: createdAt,
},
Password: domain.HumanPassword{
Hash: password,
IsChangeRequired: true,
ChangedAt: createdAt,
},
TOTP: &domain.HumanTOTP{
VerifiedAt: createdAt,
Secret: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "aes-256",
KeyID: "id",
Crypted: []byte("secret"),
},
},
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
return test{
name: "already exists",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: existingUserID,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
},
},
},
want: want{
err: new(database.UniqueError),
},
}
}(),
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
savepoint, err := tx.Begin(t.Context())
require.NoError(t, err)
t.Cleanup(func() {
err := savepoint.Rollback(context.Background())
if err != nil {
t.Log("rollback savepoint failed", err)
}
})
err = userRepo.Create(t.Context(), savepoint, tt.args.user)
require.ErrorIs(t, err, tt.want.err)
if tt.want.err != nil {
return
}
got, err := userRepo.Get(t.Context(), savepoint, database.WithCondition(
userRepo.PrimaryKeyCondition(tt.want.user.InstanceID, tt.want.user.ID),
))
require.NoError(t, err)
assertUser(t, tt.want.user, got)
})
}
}
@@ -0,0 +1,82 @@
package repository
import (
"time"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/internal/crypto"
)
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
// SetTOTPSecret implements [domain.HumanUserRepository].
func (u userHuman) SetTOTPSecret(secret *crypto.CryptoValue) database.Change {
return database.NewChange(u.totpSecretColumn(), secret)
}
// SetTOTPVerifiedAt implements [domain.HumanUserRepository].
func (u userHuman) SetTOTPVerifiedAt(verifiedAt time.Time) database.Change {
if verifiedAt.IsZero() {
return database.NewChange(u.totpVerifiedAtColumn(), database.NowInstruction)
}
return database.NewChange(u.totpVerifiedAtColumn(), verifiedAt)
}
// RemoveTOTP implements [domain.HumanUserRepository].
func (u userHuman) RemoveTOTP() database.Change {
return database.NewChanges(
database.NewChangeToNull(u.totpSecretColumn()),
database.NewChangeToNull(u.totpVerifiedAtColumn()),
database.NewChangeToNull(u.lastSuccessfulTOTPCheckColumn()),
)
}
// SetLastSuccessfulTOTPCheck implements [domain.HumanUserRepository].
func (u userHuman) SetLastSuccessfulTOTPCheck(checkedAt time.Time) database.Change {
if checkedAt.IsZero() {
return database.NewChanges(
database.NewChange(u.lastSuccessfulTOTPCheckColumn(), database.NowInstruction),
u.ResetTOTPFailedAttempts(),
)
}
return database.NewChanges(
database.NewChange(u.lastSuccessfulTOTPCheckColumn(), checkedAt),
u.ResetTOTPFailedAttempts(),
)
}
// IncrementTOTPFailedAttempts implements [domain.HumanUserRepository].
func (u userHuman) IncrementTOTPFailedAttempts() database.Change {
return database.NewIncrementColumnChange(u.totpFailedAttemptsColumn(), database.Coalesce(u.totpFailedAttemptsColumn(), 0))
}
// ResetTOTPFailedAttempts implements [domain.HumanUserRepository].
func (u userHuman) ResetTOTPFailedAttempts() database.Change {
return database.NewChange(u.totpFailedAttemptsColumn(), 0)
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (u userHuman) totpSecretColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "totp_secret")
}
func (u userHuman) totpVerifiedAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "totp_verified_at")
}
func (u userHuman) lastSuccessfulTOTPCheckColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "totp_last_successful_check")
}
func (u userHuman) totpFailedAttemptsColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "totp_failed_attempts")
}
@@ -0,0 +1,161 @@
package repository
import (
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type userIdentityProviderLink struct{}
func (u userIdentityProviderLink) unqualifiedTableName() string {
return "user_identity_provider_links"
}
func (u userIdentityProviderLink) qualifiedTableName() string {
return "zitadel.user_identity_provider_links"
}
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
// AddIdentityProviderLink implements [domain.HumanUserRepository].
func (u userIdentityProviderLink) AddIdentityProviderLink(link *domain.IdentityProviderLink) database.Change {
var createdAt, updatedAt any = database.NowInstruction, database.NowInstruction
if !link.CreatedAt.IsZero() {
createdAt = link.CreatedAt
}
if !link.UpdatedAt.IsZero() {
updatedAt = link.UpdatedAt
}
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO ")
builder.WriteString(u.qualifiedTableName())
builder.WriteString("(instance_id, user_id, provided_user_id, provided_username, identity_provider_id, created_at, updated_at) SELECT ")
database.Columns{
existingHumanUser.InstanceIDColumn(),
existingHumanUser.IDColumn(),
}.WriteQualified(builder)
builder.WriteString(", ")
builder.WriteArgs(
link.ProvidedUserID,
link.ProvidedUsername,
link.ProviderID,
createdAt,
updatedAt,
)
builder.WriteString(" FROM ")
builder.WriteString(existingHumanUser.unqualifiedTableName())
builder.WriteString(" RETURNING *")
}, nil,
)
}
// UpdateIdentityProviderLink implements [domain.HumanUserRepository].
func (u userIdentityProviderLink) UpdateIdentityProviderLink(condition database.Condition, changes ...database.Change) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("UPDATE zitadel.user_identity_provider_links SET ")
err := database.Changes(changes).Write(builder)
logging.New(logging.StreamRuntime).Debug("write changes in cte failed", "error", err)
builder.WriteString(" FROM ")
builder.WriteString(existingHumanUser.unqualifiedTableName())
writeCondition(builder, database.And(
database.NewColumnCondition(existingHumanUser.InstanceIDColumn(), u.instanceIDColumn()),
database.NewColumnCondition(existingHumanUser.IDColumn(), u.userIDColumn()),
condition,
))
}, nil,
)
}
// RemoveIdentityProviderLink implements [domain.HumanUserRepository].
func (u userIdentityProviderLink) RemoveIdentityProviderLink(providerID string, providedUserID string) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM ")
builder.WriteString(u.qualifiedTableName())
builder.WriteString(" USING ")
builder.WriteString(existingHumanUser.unqualifiedTableName())
writeCondition(builder, database.And(
database.NewColumnCondition(
existingHumanUser.InstanceIDColumn(),
u.instanceIDColumn(),
),
database.NewColumnCondition(
existingHumanUser.IDColumn(),
u.userIDColumn(),
),
database.NewTextCondition(
u.providerIDColumn(),
database.TextOperationEqual,
providerID,
),
database.NewTextCondition(
u.providedUserIDColumn(),
database.TextOperationEqual,
providedUserID,
),
))
}, nil,
)
}
// SetIdentityProviderLinkUsername implements [domain.HumanUserRepository].
func (u userIdentityProviderLink) SetIdentityProviderLinkUsername(username string) database.Change {
return database.NewChange(u.providedUsernameColumn(), username)
}
// SetIdentityProviderLinkProvidedID implements [domain.HumanUserRepository].
func (u userIdentityProviderLink) SetIdentityProviderLinkProvidedID(providedUserID string) database.Change {
return database.NewChange(u.providedUserIDColumn(), providedUserID)
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
// IdentityProviderLinkConditions implements [domain.HumanUserRepository].
func (u userIdentityProviderLink) IdentityProviderLinkConditions() domain.HumanIdentityProviderLinkConditions {
return u
}
// ProviderIDCondition implements [domain.HumanUserRepository].
func (u userIdentityProviderLink) ProviderIDCondition(idpID string) database.Condition {
return database.NewTextCondition(u.providerIDColumn(), database.TextOperationEqual, idpID)
}
// ProvidedUserIDCondition implements [domain.HumanUserRepository].
func (u userIdentityProviderLink) ProvidedUserIDCondition(providedUserID string) database.Condition {
return database.NewTextCondition(u.providedUserIDColumn(), database.TextOperationEqual, providedUserID)
}
// ProvidedUsernameCondition implements [domain.HumanUserRepository].
func (u userIdentityProviderLink) ProvidedUsernameCondition(op database.TextOperation, username string) database.Condition {
return database.NewTextCondition(u.providedUsernameColumn(), op, username)
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (u userIdentityProviderLink) instanceIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "instance_id")
}
func (u userIdentityProviderLink) userIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "user_id")
}
func (u userIdentityProviderLink) providerIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "identity_provider_id")
}
func (u userIdentityProviderLink) providedUserIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "provided_user_id")
}
func (u userIdentityProviderLink) providedUsernameColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "provided_username")
}
@@ -0,0 +1,49 @@
package repository
import (
"fmt"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
// SetInviteVerification implements [domain.HumanUserRepository].
func (u userHuman) SetInviteVerification(verification domain.VerificationType) database.Change {
switch typ := verification.(type) {
case *domain.VerificationTypeInit:
return u.verification.init(typ, existingUser.unqualifiedTableName(), existingHumanUser.inviteVerificationIDColumn())
case *domain.VerificationTypeUpdate:
return u.verification.update(typ, existingHumanUser.unqualifiedTableName(),
existingHumanUser.InstanceIDColumn(), existingHumanUser.inviteVerificationIDColumn(),
)
case *domain.VerificationTypeSucceeded:
return u.verification.verified(typ, existingHumanUser.unqualifiedTableName(), existingHumanUser.InstanceIDColumn(),
existingHumanUser.inviteVerificationIDColumn(), u.inviteAcceptedAtColumn(), u.failedInviteAttemptsColumn(),
)
case *domain.VerificationTypeSkipped:
return u.verification.skipped(typ, u.inviteAcceptedAtColumn(), u.inviteVerificationIDColumn(), u.failedInviteAttemptsColumn())
case *domain.VerificationTypeFailed:
return u.verification.failed(existingHumanUser.unqualifiedTableName(), existingHumanUser.InstanceIDColumn(), existingHumanUser.inviteVerificationIDColumn())
}
panic(fmt.Sprintf("undefined verification type %T", verification))
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (u userHuman) inviteVerificationIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "invite_verification_id")
}
func (u userHuman) inviteAcceptedAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "invite_accepted_at")
}
func (u userHuman) failedInviteAttemptsColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "invite_failed_attempts")
}
@@ -0,0 +1,103 @@
package repository
import (
"context"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type userMachine struct {
user
machineKey
userPersonalAccessToken
}
func (u userMachine) create(ctx context.Context, builder *database.StatementBuilder, client database.QueryExecutor, user *domain.User) error {
changes := make(database.Changes, 0, len(user.Machine.Keys)+len(user.Machine.PATs))
for _, key := range user.Machine.Keys {
changes = append(changes, u.AddKey(key))
}
for _, pat := range user.Machine.PATs {
changes = append(changes, u.AddPersonalAccessToken(pat))
}
for _, metadata := range user.Metadata {
changes = append(changes, u.SetMetadata(metadata))
}
for i, change := range changes {
sessionCTE(change, i, 0, builder)
}
builder.WriteString("INSERT INTO zitadel.users (" +
"instance_id, organization_id, id, username, state, type" +
", name, description, secret, access_token_type, created_at, updated_at) VALUES (",
)
var createdAt any = database.NowInstruction
if !user.CreatedAt.IsZero() {
createdAt = user.CreatedAt
}
builder.WriteArgs(
user.InstanceID, user.OrganizationID, user.ID, user.Username, user.State, "machine",
user.Machine.Name, user.Machine.Description, user.Machine.Secret, uint8(user.Machine.AccessTokenType),
createdAt, createdAt,
)
builder.WriteString(") RETURNING created_at, updated_at")
return client.QueryRow(ctx, builder.String(), builder.Args()...).Scan(&user.CreatedAt, &user.UpdatedAt)
}
// Update implements [domain.MachineUserRepository].
func (u userMachine) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) {
if !condition.IsRestrictingColumn(u.TypeColumn()) {
condition = database.And(condition, u.TypeCondition(domain.UserTypeMachine))
}
return u.user.Update(ctx, client, condition, changes...)
}
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
// SetAccessTokenType implements [domain.MachineUserRepository].
func (u userMachine) SetAccessTokenType(tokenType domain.AccessTokenType) database.Change {
return database.NewChange(u.accessTokenTypeColumn(), tokenType)
}
// SetDescription implements [domain.MachineUserRepository].
func (u userMachine) SetDescription(description string) database.Change {
return database.NewChange(u.descriptionColumn(), description)
}
// SetName implements [domain.MachineUserRepository].
func (u userMachine) SetName(name string) database.Change {
return database.NewChange(u.nameColumn(), name)
}
// SetSecret implements [domain.MachineUserRepository].
func (u userMachine) SetSecret(secret *string) database.Change {
return database.NewChangePtr(u.secretColumn(), secret)
}
var _ domain.MachineUserRepository = (*userMachine)(nil)
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (u userMachine) nameColumn() database.Column {
return database.NewColumn(u.user.unqualifiedTableName(), "name")
}
func (u userMachine) descriptionColumn() database.Column {
return database.NewColumn(u.user.unqualifiedTableName(), "description")
}
func (u userMachine) accessTokenTypeColumn() database.Column {
return database.NewColumn(u.user.unqualifiedTableName(), "access_token_type")
}
func (u userMachine) secretColumn() database.Column {
return database.NewColumn(u.user.unqualifiedTableName(), "secret")
}
@@ -0,0 +1,64 @@
package repository
import (
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type machineKey struct{}
// AddKey implements [domain.MachineUserRepository].
func (u machineKey) AddKey(key *domain.MachineKey) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.machine_keys (" +
"instance_id, user_id, id, created_at, expires_at, type, public_key" +
") SELECT instance_id, id, ",
)
var createdAt any = database.NowInstruction
if !key.CreatedAt.IsZero() {
createdAt = key.CreatedAt
}
var expiresAt any = database.NullInstruction
if !key.ExpiresAt.IsZero() {
expiresAt = key.ExpiresAt
}
builder.WriteArgs(key.ID, createdAt, expiresAt, key.Type, key.PublicKey)
builder.WriteString(" FROM existing_user")
},
nil,
)
}
// RemoveKey implements [domain.MachineUserRepository].
func (u machineKey) RemoveKey(id string) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM zitadel.machine_keys WHERE " +
"(instance_id, user_id, id) = (SELECT instance_id, id, ")
builder.WriteArg(id)
builder.WriteString(" FROM existing_user)")
},
nil,
)
}
func (u machineKey) qualifiedTableName() string {
return "zitadel.machine_keys"
}
func (u machineKey) unqualifiedTableName() string {
return "machine_keys"
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (u machineKey) instanceIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "instance_id")
}
func (u machineKey) userIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "user_id")
}
@@ -0,0 +1,394 @@
package repository_test
import (
"context"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/backend/v3/storage/database/repository"
)
func Test_machineUser_create(t *testing.T) {
tx, rollback := transactionForRollback(t)
t.Cleanup(rollback)
userRepo := repository.UserRepository()
instanceID := createInstance(t, tx)
orgID := createOrganization(t, tx, instanceID)
createdAt := time.Now().Round(time.Second)
existingUserID := createMachineUser(t, tx, instanceID, orgID)
type args struct {
user *domain.User
}
type want struct {
err error
user *domain.User
}
type test struct {
name string
args args
want want
}
tests := []test{
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
return test{
name: "minimal representation",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
return test{
name: "with secret",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
Secret: "secret",
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
Secret: "secret",
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
return test{
name: "with access token type",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
AccessTokenType: domain.AccessTokenTypeBearer,
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
AccessTokenType: domain.AccessTokenTypeBearer,
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
keys := []*domain.MachineKey{
{
ID: gofakeit.UUID(),
PublicKey: []byte("public key"),
CreatedAt: createdAt,
ExpiresAt: createdAt.Add(24 * time.Hour),
Type: domain.MachineKeyTypeJSON,
},
{
ID: gofakeit.UUID(),
PublicKey: []byte("public key"),
CreatedAt: createdAt,
ExpiresAt: createdAt.Add(24 * time.Hour),
Type: domain.MachineKeyTypeJSON,
},
}
return test{
name: "with keys",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
Keys: keys,
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
Keys: keys,
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
pats := []*domain.PersonalAccessToken{
{
ID: gofakeit.UUID(),
CreatedAt: createdAt,
ExpiresAt: createdAt.Add(24 * time.Hour),
},
{
ID: gofakeit.UUID(),
CreatedAt: createdAt,
ExpiresAt: createdAt.Add(24 * time.Hour),
Scopes: []string{"some", "scopes"},
},
}
return test{
name: "with keys",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
PATs: pats,
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
PATs: pats,
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
id := gofakeit.UUID()
pats := []*domain.PersonalAccessToken{
{
ID: gofakeit.UUID(),
CreatedAt: createdAt,
ExpiresAt: createdAt.Add(24 * time.Hour),
},
}
keys := []*domain.MachineKey{
{
ID: gofakeit.UUID(),
PublicKey: []byte("public key"),
CreatedAt: createdAt,
ExpiresAt: createdAt.Add(24 * time.Hour),
Type: domain.MachineKeyTypeJSON,
},
}
return test{
name: "all fields set",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Metadata: []*domain.Metadata{
{
Key: "meta1",
Value: []byte("value1"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
},
{
Key: "meta2",
Value: []byte("123"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
},
},
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
PATs: pats,
Keys: keys,
Secret: "secret",
AccessTokenType: domain.AccessTokenTypeJWT,
},
},
},
want: want{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
UpdatedAt: createdAt,
Metadata: []*domain.Metadata{
{
InstanceID: instanceID,
Key: "meta1",
Value: []byte("value1"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
},
{
InstanceID: instanceID,
Key: "meta2",
Value: []byte("123"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
},
},
Machine: &domain.MachineUser{
Name: "machine",
Description: "description",
PATs: pats,
Keys: keys,
Secret: "secret",
AccessTokenType: domain.AccessTokenTypeJWT,
},
},
},
}
}(),
func() test {
username := gofakeit.Username()
return test{
name: "already exists",
args: args{
user: &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: existingUserID,
Username: username,
State: domain.UserStateActive,
CreatedAt: createdAt,
Machine: &domain.MachineUser{
Name: "machine",
},
},
},
want: want{
err: new(database.UniqueError),
},
}
}(),
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
savepoint, err := tx.Begin(t.Context())
require.NoError(t, err)
t.Cleanup(func() {
err := savepoint.Rollback(context.Background())
if err != nil {
t.Log("rollback savepoint failed", err)
}
})
err = userRepo.Create(t.Context(), savepoint, tt.args.user)
require.ErrorIs(t, err, tt.want.err)
if tt.want.err != nil {
return
}
got, err := userRepo.Get(t.Context(), savepoint, database.WithCondition(
userRepo.PrimaryKeyCondition(tt.want.user.InstanceID, tt.want.user.ID),
))
require.NoError(t, err)
assertUser(t, tt.want.user, got)
})
}
}
@@ -0,0 +1,121 @@
package repository
import (
"strings"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type userMetadata struct{}
func (userMetadata) qualifiedTableName() string {
return "zitadel.user_metadata"
}
func (userMetadata) unqualifiedTableName() string {
return "user_metadata"
}
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
// SetMetadata implements [domain.UserRepository].
func (u userMetadata) SetMetadata(metadata ...*domain.Metadata) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.user_metadata(instance_id, user_id, key, value, created_at, updated_at)")
builder.WriteString(" SELECT existing_user.instance_id, existing_user.id, md.key::TEXT, md.value::BYTEA, md.created_at::TIMESTAMPTZ, md.updated_at::TIMESTAMPTZ")
builder.WriteString(" FROM existing_user CROSS JOIN (VALUES ")
for i, md := range metadata {
if i > 0 {
builder.WriteString(", ")
}
builder.WriteRune('(')
// The complex placeholder handling is needed to append the type casts
// if we use WriteArgs directly, pgx resolves all args as TEXT instead of the correct type
placeholders := make([]string, 4)
placeholders[0] = builder.AppendArg(md.Key)
placeholders[1] = builder.AppendArg(md.Value) + "::BYTEA"
var createdAt, updatedAt any = database.NowInstruction, database.NullInstruction
if !md.CreatedAt.IsZero() {
createdAt = md.CreatedAt
}
if !md.UpdatedAt.IsZero() {
updatedAt = md.UpdatedAt
}
placeholders[2] = builder.AppendArg(createdAt) + "::TIMESTAMPTZ"
placeholders[3] = builder.AppendArg(updatedAt) + "::TIMESTAMPTZ"
builder.WriteString(strings.Join(placeholders, ", "))
builder.WriteRune(')')
}
builder.WriteString(") AS md(key, value, created_at, updated_at) ON CONFLICT (")
database.Columns{
u.instanceIDColumn(),
u.userIDColumn(),
u.keyColumn(),
}.WriteUnqualified(builder)
builder.WriteString(") DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at WHERE ")
u.valueColumn().WriteQualified(builder)
builder.WriteString(" IS DISTINCT FROM EXCLUDED.value")
},
nil,
)
}
// RemoveMetadata implements [domain.UserRepository].
func (u userMetadata) RemoveMetadata(condition database.Condition) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM zitadel.user_metadata USING ")
builder.WriteString(existingUser.unqualifiedTableName())
writeCondition(builder, database.And(
database.NewColumnCondition(existingUser.InstanceIDColumn(), u.instanceIDColumn()),
database.NewColumnCondition(existingUser.IDColumn(), u.userIDColumn()),
condition,
))
},
nil,
)
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
func (u userMetadata) MetadataConditions() domain.UserMetadataConditions {
return u
}
// MetadataKeyCondition implements [domain.UserMetadataConditions].
func (u userMetadata) MetadataKeyCondition(op database.TextOperation, key string) database.Condition {
return database.NewTextCondition(u.keyColumn(), op, key)
}
// MetadataValueCondition implements [domain.UserMetadataConditions].
func (u userMetadata) MetadataValueCondition(op database.BytesOperation, value []byte) database.Condition {
return database.NewBytesCondition[[]byte](database.SHA256Column(u.valueColumn()), op, database.SHA256Value(value))
}
var _ domain.UserMetadataConditions = (*userMetadata)(nil)
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (u userMetadata) instanceIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "instance_id")
}
func (u userMetadata) userIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "user_id")
}
func (u userMetadata) keyColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "key")
}
func (u userMetadata) valueColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "value")
}
@@ -0,0 +1,264 @@
package repository
import (
"time"
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type userPasskey struct{}
func (u userPasskey) unqualifiedTableName() string {
return "user_passkeys"
}
func (u userPasskey) qualifiedTableName() string {
return "zitadel.user_passkeys"
}
// -------------------------------------------------------------
// changes
// -------------------------------------------------------------
// AddPasskey implements [domain.HumanUserRepository].
func (u userPasskey) AddPasskey(passkey *domain.Passkey) database.Change {
var (
createdAt any = database.NowInstruction
updatedAt any = database.NowInstruction
verifiedAt any = database.NowInstruction
)
if !passkey.CreatedAt.IsZero() {
createdAt = passkey.CreatedAt
}
if !passkey.UpdatedAt.IsZero() {
updatedAt = passkey.UpdatedAt
}
if !passkey.VerifiedAt.IsZero() {
verifiedAt = passkey.VerifiedAt
}
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO ")
builder.WriteString(u.qualifiedTableName())
builder.WriteString(" (")
database.Columns{
u.instanceIDColumn(),
u.userIDColumn(),
u.tokenIDColumn(),
u.keyIDColumn(),
u.createdAtColumn(),
u.updatedAtColumn(),
u.verifiedAtColumn(),
u.typeColumn(),
u.nameColumn(),
u.signCountColumn(),
u.challengeColumn(),
u.publicKeyColumn(),
u.attestationTypeColumn(),
u.authenticatorAttestationGUIDColumn(),
u.relyingPartyIDColumn(),
}.WriteUnqualified(builder)
builder.WriteString(") SELECT ")
database.Columns{
existingHumanUser.InstanceIDColumn(),
existingHumanUser.IDColumn(),
}.WriteQualified(builder)
builder.WriteString(", ")
builder.WriteArgs(
passkey.ID,
passkey.KeyID,
createdAt,
updatedAt,
verifiedAt,
passkey.Type,
passkey.Name,
passkey.SignCount,
passkey.Challenge,
passkey.PublicKey,
passkey.AttestationType,
passkey.AuthenticatorAttestationGUID,
passkey.RelyingPartyID,
)
builder.WriteString(" FROM ")
builder.WriteString(existingHumanUser.unqualifiedTableName())
}, nil,
)
}
// RemovePasskey implements [domain.HumanUserRepository].
func (u userPasskey) RemovePasskey(condition database.Condition) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM ")
builder.WriteString(u.qualifiedTableName())
builder.WriteString(" USING ")
builder.WriteString(existingHumanUser.unqualifiedTableName())
writeCondition(builder, database.And(
database.NewColumnCondition(existingHumanUser.InstanceIDColumn(), u.instanceIDColumn()),
database.NewColumnCondition(existingHumanUser.IDColumn(), u.userIDColumn()),
condition,
))
},
nil,
)
}
// UpdatePasskey implements [domain.HumanUserRepository].
func (u userPasskey) UpdatePasskey(condition database.Condition, changes ...database.Change) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("UPDATE ")
builder.WriteString(u.qualifiedTableName())
builder.WriteString(" SET ")
err := database.Changes(changes).Write(builder)
logging.New(logging.StreamRuntime).Debug("write changes in cte failed", "error", err)
builder.WriteString(" FROM ")
builder.WriteString(existingHumanUser.unqualifiedTableName())
writeCondition(builder, database.And(
database.NewColumnCondition(existingHumanUser.InstanceIDColumn(), u.instanceIDColumn()),
database.NewColumnCondition(existingHumanUser.IDColumn(), u.userIDColumn()),
condition,
))
},
nil,
)
}
// SetPasskeyAttestationType implements [domain.HumanUserRepository].
func (u userPasskey) SetPasskeyAttestationType(attestationType string) database.Change {
return database.NewChange(u.attestationTypeColumn(), attestationType)
}
// SetPasskeyAuthenticatorAttestationGUID implements [domain.HumanUserRepository].
func (u userPasskey) SetPasskeyAuthenticatorAttestationGUID(aaguid []byte) database.Change {
return database.NewChange(u.authenticatorAttestationGUIDColumn(), aaguid)
}
// SetPasskeyKeyID implements [domain.HumanUserRepository].
func (u userPasskey) SetPasskeyKeyID(keyID []byte) database.Change {
return database.NewChange(u.keyIDColumn(), keyID)
}
// SetPasskeyName implements [domain.HumanUserRepository].
func (u userPasskey) SetPasskeyName(name string) database.Change {
return database.NewChange(u.nameColumn(), name)
}
// SetPasskeyPublicKey implements [domain.HumanUserRepository].
func (u userPasskey) SetPasskeyPublicKey(publicKey []byte) database.Change {
return database.NewChange(u.publicKeyColumn(), publicKey)
}
// SetPasskeySignCount implements [domain.HumanUserRepository].
func (u userPasskey) SetPasskeySignCount(signCount uint32) database.Change {
return database.NewChange(u.signCountColumn(), signCount)
}
// SetPasskeyUpdatedAt implements [domain.HumanUserRepository].
func (u userPasskey) SetPasskeyUpdatedAt(updatedAt time.Time) database.Change {
return database.NewChange(u.updatedAtColumn(), updatedAt)
}
// SetPasskeyVerifiedAt implements [domain.HumanUserRepository].
func (u userPasskey) SetPasskeyVerifiedAt(verifiedAt time.Time) database.Change {
verifiedAtChange := database.NewChange(u.verifiedAtColumn(), database.NowInstruction)
if !verifiedAt.IsZero() {
verifiedAtChange = database.NewChange(u.verifiedAtColumn(), verifiedAt)
}
return verifiedAtChange
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
// PasskeyConditions implements [domain.HumanUserRepository].
func (u userPasskey) PasskeyConditions() domain.HumanPasskeyConditions {
return u
}
// ChallengeCondition implements [domain.HumanPasskeyConditions].
func (u userPasskey) ChallengeCondition(challenge []byte) database.Condition {
return database.NewBytesCondition[[]byte](database.SHA256Column(u.challengeColumn()), database.BytesOperationEqual, database.SHA256Value(challenge))
}
// IDCondition implements [domain.HumanPasskeyConditions].
func (u userPasskey) IDCondition(passkeyID string) database.Condition {
return database.NewTextCondition(u.tokenIDColumn(), database.TextOperationEqual, passkeyID)
}
// KeyIDCondition implements [domain.HumanPasskeyConditions].
func (u userPasskey) KeyIDCondition(keyID string) database.Condition {
return database.NewTextCondition(u.keyIDColumn(), database.TextOperationEqual, keyID)
}
// TypeCondition implements [domain.HumanPasskeyConditions].
func (u userPasskey) TypeCondition(passkeyType domain.PasskeyType) database.Condition {
return database.NewTextCondition(u.typeColumn(), database.TextOperationEqual, passkeyType.String())
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (u userPasskey) tokenIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "token_id")
}
func (u userPasskey) keyIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "key_id")
}
func (u userPasskey) instanceIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "instance_id")
}
func (u userPasskey) userIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "user_id")
}
func (u userPasskey) relyingPartyIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "relying_party_id")
}
func (u userPasskey) authenticatorAttestationGUIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "authenticator_attestation_guid")
}
func (u userPasskey) attestationTypeColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "attestation_type")
}
func (u userPasskey) publicKeyColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "public_key")
}
func (u userPasskey) challengeColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "challenge")
}
func (u userPasskey) signCountColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "sign_count")
}
func (u userPasskey) nameColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "name")
}
func (u userPasskey) typeColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "type")
}
func (u userPasskey) verifiedAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "verified_at")
}
func (u userPasskey) updatedAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "updated_at")
}
func (u userPasskey) createdAtColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "created_at")
}
@@ -0,0 +1,60 @@
package repository
import (
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type userPersonalAccessToken struct{}
// AddPersonalAccessToken implements [domain.MachineUserRepository].
func (userPersonalAccessToken) AddPersonalAccessToken(pat *domain.PersonalAccessToken) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("INSERT INTO zitadel.user_personal_access_tokens (" +
"instance_id, user_id, id, created_at, expires_at, scopes" +
") SELECT instance_id, id, ",
)
var createdAt any = database.NowInstruction
if !pat.CreatedAt.IsZero() {
createdAt = pat.CreatedAt
}
var expiresAt any = database.NullInstruction
if !pat.ExpiresAt.IsZero() {
expiresAt = pat.ExpiresAt
}
builder.WriteArgs(pat.ID, createdAt, expiresAt, pat.Scopes)
builder.WriteString(" FROM existing_user")
},
nil,
)
}
// RemovePersonalAccessToken implements [domain.MachineUserRepository].
func (userPersonalAccessToken) RemovePersonalAccessToken(id string) database.Change {
return database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM zitadel.user_personal_access_tokens WHERE " +
"(instance_id, user_id, id) = (SELECT instance_id, id, ")
builder.WriteArg(id)
builder.WriteString(" FROM existing_user)")
},
nil,
)
}
func (userPersonalAccessToken) qualifiedTableName() string {
return "zitadel.user_personal_access_tokens"
}
func (userPersonalAccessToken) unqualifiedTableName() string {
return "user_personal_access_tokens"
}
func (u userPersonalAccessToken) instanceIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "instance_id")
}
func (u userPersonalAccessToken) userIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "user_id")
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
package repository
import (
"github.com/zitadel/zitadel/backend/v3/domain"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
"github.com/zitadel/zitadel/backend/v3/storage/database"
)
type verification struct{}
func (v verification) unqualifiedTableName() string {
return "verifications"
}
func (v verification) qualifiedTableName() string {
return "zitadel." + v.unqualifiedTableName()
}
// init creates a new verification
func (v verification) init(init *domain.VerificationTypeInit, existingTableName string, verificationID database.Column) database.CTEChange {
return database.NewCTEChange(func(builder *database.StatementBuilder) {
var (
createdAt any = database.NowInstruction
expiry any = database.NullInstruction
id any = database.GenRandomUUIDInstruction
)
if !init.CreatedAt.IsZero() {
createdAt = init.CreatedAt
}
if init.Expiry != nil {
expiry = *init.Expiry
}
if init.ID != nil {
id = *init.ID
}
builder.WriteString("INSERT INTO zitadel.verifications(instance_id, user_id, id, code, created_at, updated_at, expiry) SELECT instance_id, id, ")
builder.WriteArgs(id, init.Code, createdAt, createdAt, expiry)
builder.WriteString(" FROM ")
builder.WriteString(existingTableName)
builder.WriteString(" RETURNING id")
}, func(name string) database.Change {
return database.NewChangeToStatement(verificationID, func(builder *database.StatementBuilder) {
builder.WriteString("SELECT id FROM ")
builder.WriteString(name)
})
})
}
// updates the given fields of the verification with the given ID. Only non-nil fields get updated.
func (v verification) update(update *domain.VerificationTypeUpdate, existingTableName string, instanceID, verificationID database.Column) database.CTEChange {
changes := make(database.Changes, 0, 3)
if update.Code != nil {
changes = append(changes, database.NewChange(v.codeColumn(), update.Code))
}
if update.Expiry != nil {
changes = append(changes, database.NewChange(v.expiryColumn(), *update.Expiry))
}
if update.UpdatedAt.IsZero() {
changes = append(changes, database.NewChange(v.updatedAtColumn(), database.NowInstruction))
} else {
changes = append(changes, database.NewChange(v.updatedAtColumn(), update.UpdatedAt))
}
return database.NewCTEChange(func(builder *database.StatementBuilder) {
builder.WriteString("UPDATE ")
builder.WriteString(v.qualifiedTableName())
builder.WriteString(" SET ")
err := changes.Write(builder)
logging.New(logging.StreamRuntime).Debug("write changes in cte failed", "error", err)
builder.WriteString(" FROM ")
builder.WriteString(existingTableName)
writeCondition(builder, database.And(
database.NewColumnCondition(v.instanceIDColumn(), instanceID),
database.NewColumnCondition(v.idColumn(), verificationID),
))
}, nil)
}
// verified constructs the following changes:
// - sets the verifiedAt column to the current time or the provided time
// - clears the pending verification ID
// - deletes the verification
// - resets the failed attempts counter
func (v verification) verified(verified *domain.VerificationTypeSucceeded, existingTableName string, instanceID, pendingVerificationID, verifiedAt, failedAttempts database.Column) database.Changes {
verifiedAtChange := database.NewChange(verifiedAt, database.NowInstruction)
if !verified.VerifiedAt.IsZero() {
verifiedAtChange = database.NewChange(verifiedAt, verified.VerifiedAt)
}
return database.NewChanges(
verifiedAtChange,
database.NewChangeToNull(pendingVerificationID),
database.NewChange(failedAttempts, 0),
database.NewCTEChange(
func(builder *database.StatementBuilder) {
builder.WriteString("DELETE FROM ")
builder.WriteString(v.qualifiedTableName())
builder.WriteString(" USING ")
builder.WriteString(existingTableName)
writeCondition(builder, database.And(
database.NewColumnCondition(v.instanceIDColumn(), instanceID),
database.NewColumnCondition(v.idColumn(), pendingVerificationID),
))
}, nil,
),
)
}
// skipped constructs the following changes:
// - sets the verifiedAt column to the current time or the provided time
// - clears the pending verification ID
// - resets the failed attempts counter
func (v verification) skipped(skipped *domain.VerificationTypeSkipped, verifiedAt, pendingVerificationID, failedAttempts database.Column) database.Changes {
verifiedAtChange := database.NewChange(verifiedAt, database.NowInstruction)
if !skipped.SkippedAt.IsZero() {
verifiedAtChange = database.NewChange(verifiedAt, skipped.SkippedAt)
}
return database.NewChanges(
verifiedAtChange,
database.NewChange(failedAttempts, 0),
database.NewChangeToNull(pendingVerificationID),
)
}
// failed increments the failed attempts counter.
func (v verification) failed(existingTableName string, instanceID, verificationID database.Column) database.CTEChange {
return database.NewCTEChange(func(builder *database.StatementBuilder) {
builder.WriteString("UPDATE ")
builder.WriteString(v.qualifiedTableName())
builder.WriteString(" SET ")
err := database.NewIncrementColumnChange(v.failedAttemptsColumn(), database.Coalesce(v.failedAttemptsColumn(), 0)).Write(builder)
logging.New(logging.StreamRuntime).Debug("write cte failed", "error", err)
builder.WriteString(" FROM ")
builder.WriteString(existingTableName)
writeCondition(builder, database.And(
database.NewColumnCondition(v.instanceIDColumn(), instanceID),
database.NewColumnCondition(v.idColumn(), verificationID),
))
}, nil)
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (v verification) instanceIDColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "instance_id")
}
func (v verification) idColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "id")
}
func (v verification) codeColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "code")
}
func (v verification) expiryColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "expiry")
}
func (v verification) failedAttemptsColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "failed_attempts")
}
func (v verification) userIDColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "user_id")
}
func (v verification) updatedAtColumn() database.Column {
return database.NewColumn(v.unqualifiedTableName(), "updated_at")
}
+4 -3
View File
@@ -8,9 +8,10 @@ import (
type Instruction string
const (
DefaultInstruction Instruction = "DEFAULT"
NowInstruction Instruction = "NOW()"
NullInstruction Instruction = "NULL"
DefaultInstruction Instruction = "DEFAULT"
NowInstruction Instruction = "NOW()"
NullInstruction Instruction = "NULL"
GenRandomUUIDInstruction Instruction = "gen_random_uuid()"
)
// StatementBuilder is a helper to build SQL statement.