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

## Summary

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

## What changed

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

## Notes for reviewers

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

## Closes

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

---------

Co-authored-by: Iraq Jaber <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Marco A. <[email protected]>
Co-authored-by: Tim Möhlmann <[email protected]>
This commit is contained in:
Silvan
2026-02-23 16:03:47 +01:00
committed by GitHub
co-authored by Iraq Jaber Copilot Marco A. Tim Möhlmann
parent 40c7b38ca1
commit 5df8a97f9d
73 changed files with 15914 additions and 1661 deletions
@@ -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")
}