Files
zitadel/backend/v3/storage/database/repository/user_human.go
T
5df8a97f9d 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]>
2026-02-23 16:03:47 +01:00

487 lines
18 KiB
Go

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
// -------------------------------------------------------------