mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-30 02:52:16 +00:00
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:
co-authored by
Iraq Jaber
Copilot
Marco A.
Tim Möhlmann
parent
40c7b38ca1
commit
5df8a97f9d
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
-2
@@ -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
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user