mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-22 07:24:26 +00:00
## 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]>
487 lines
18 KiB
Go
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
|
|
// -------------------------------------------------------------
|