feat: add login names to relational users (#11667)

Introduce a new SQL migration that creates the `login_names` table and
associated triggers/functions for managing login names based on domain
policies and user updates. Manual tests confirm the functionality works
as intended.
This commit is contained in:
Silvan
2026-03-03 09:36:43 +00:00
committed by GitHub
parent 4aacfa842f
commit 41b8aa3f18
12 changed files with 1412 additions and 187 deletions
@@ -0,0 +1,16 @@
package migration
import (
_ "embed"
)
var (
//go:embed 015_login_names/up.sql
up015LoginNames string
//go:embed 015_login_names/down.sql
down015LoginNames string
)
func init() {
registerSQLMigration(15, up015LoginNames, down015LoginNames)
}
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS zitadel.login_names CASCADE;
DROP FUNCTION IF EXISTS zitadel.apply_domain_policy_manipulation_to_login_names();
DROP FUNCTION IF EXISTS zitadel.apply_user_insert_to_login_names();
DROP FUNCTION IF EXISTS zitadel.apply_user_update_to_login_names();
DROP FUNCTION IF EXISTS zitadel.apply_domain_manipulation_to_login_names();
@@ -0,0 +1,300 @@
CREATE TABLE zitadel.login_names(
instance_id TEXT NOT NULL
, organization_id TEXT -- is set if the setting for loginNameIncludesDomain is set on organization level, otherwise null
, user_id TEXT NOT NULL
, username TEXT NOT NULL CHECK (username <> '')
, domain TEXT
, login_name TEXT GENERATED ALWAYS AS (CASE WHEN domain IS NOT NULL THEN username || '@' || domain ELSE username END) STORED
, is_preferred BOOLEAN NOT NULL DEFAULT FALSE
, used_setting TEXT NOT NULL
, PRIMARY KEY (instance_id, user_id, login_name)
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, organization_id, domain) REFERENCES zitadel.org_domains(instance_id, org_id, domain) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_login_names_instance_login_name ON zitadel.login_names(instance_id, lower(login_name));
CREATE INDEX idx_login_names_instance_user ON zitadel.login_names(instance_id, user_id);
CREATE INDEX idx_login_names_setting ON zitadel.login_names(instance_id, used_setting);
CREATE INDEX idx_login_names_domain ON zitadel.login_names(instance_id, domain); -- used for cleanup of login names when a domain is deleted
CREATE OR REPLACE FUNCTION zitadel.apply_domain_manipulation_to_login_names() RETURNS TRIGGER AS $$
DECLARE
setting zitadel.settings%ROWTYPE;
BEGIN
IF (NOT NEW.is_verified) THEN
IF TG_OP = 'UPDATE' AND OLD.is_verified THEN
-- this case can currently not happen but is added for completeness and future-proofing.
RAISE NOTICE 'Domain changed from verified to unverified, removing associated login names';
DELETE FROM zitadel.login_names
WHERE
login_names.instance_id = NEW.instance_id
AND login_names.organization_id = NEW.org_id
AND login_names.domain = NEW.domain;
ELSE
RAISE NOTICE 'Domain is not verified, skipping login name manipulation';
END IF;
RETURN NULL;
END IF;
SELECT settings.*
INTO setting
FROM zitadel.settings
WHERE
settings.instance_id = NEW.instance_id
AND settings.type = 'domain'
AND (settings.organization_id IS NULL OR settings.organization_id = NEW.org_id)
ORDER BY settings.organization_id NULLS LAST
LIMIT 1;
IF NOT (setting.settings->'loginNameIncludesDomain')::BOOLEAN THEN
RAISE NOTICE 'skip adding domain because loginNameIncludesDomain is false for setting';
RETURN NULL;
END IF;
-- Lock based on the setting scope (organization-specific or instance-level/global).
-- The lock is released at transaction end.
PERFORM pg_advisory_xact_lock(
hashtext('zitadel.login_names')
, hashtext(setting.instance_id || ':' || COALESCE(setting.organization_id, 'global'))
);
IF NEW.is_primary IS DISTINCT FROM OLD.is_primary THEN
RAISE NOTICE 'primary domain changed, updating preferred login name';
UPDATE zitadel.login_names
SET is_preferred = NEW.is_primary
WHERE
login_names.instance_id = NEW.instance_id
AND login_names.domain = NEW.domain;
END IF;
IF NEW.domain IS DISTINCT FROM OLD.domain THEN
RAISE NOTICE 'domain changed, updating login names';
UPDATE zitadel.login_names
SET domain = NEW.domain
WHERE
login_names.instance_id = NEW.instance_id
AND login_names.organization_id = NEW.org_id
AND login_names.domain = OLD.domain;
END IF;
IF NEW.is_verified IS DISTINCT FROM OLD.is_verified THEN
RAISE NOTICE 'Domain verification changed, inserting login names for verified domain';
INSERT INTO zitadel.login_names(instance_id, organization_id, user_id, username, domain, is_preferred, used_setting)
SELECT
NEW.instance_id
, NEW.org_id
, users.id
, users.username
, NEW.domain
, NEW.is_primary
, setting.id
FROM
zitadel.users
WHERE
users.instance_id = NEW.instance_id
AND users.organization_id = NEW.org_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER trg_apply_domain_manipulation_to_login_names
AFTER INSERT OR UPDATE ON zitadel.org_domains
FOR EACH ROW
EXECUTE FUNCTION zitadel.apply_domain_manipulation_to_login_names();
CREATE OR REPLACE FUNCTION zitadel.apply_user_update_to_login_names() RETURNS TRIGGER AS $$
BEGIN
-- Lock global scope and organization scope to serialize with instance-level
-- and org-level setting/domain manipulations.
-- The lock is released at transaction end.
PERFORM pg_advisory_xact_lock(hashtext('zitadel.login_names'), hashtext(NEW.instance_id || ':global'));
PERFORM pg_advisory_xact_lock(hashtext('zitadel.login_names'), hashtext(NEW.instance_id || ':' || NEW.organization_id));
UPDATE
zitadel.login_names
SET
username = NEW.username
WHERE
login_names.instance_id = NEW.instance_id
AND login_names.user_id = NEW.id;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER trg_apply_user_update_to_login_names
AFTER UPDATE ON zitadel.users
FOR EACH ROW
WHEN (NEW.username IS DISTINCT FROM OLD.username)
EXECUTE FUNCTION zitadel.apply_user_update_to_login_names();
CREATE OR REPLACE FUNCTION zitadel.apply_user_insert_to_login_names() RETURNS TRIGGER AS $$
DECLARE
setting zitadel.settings%ROWTYPE;
BEGIN
SELECT settings.*
INTO setting
FROM
zitadel.settings
WHERE
settings.instance_id = NEW.instance_id
AND settings.type = 'domain'
AND (
settings.organization_id IS NULL
OR settings.organization_id = NEW.organization_id
)
ORDER BY settings.organization_id NULLS LAST
LIMIT 1;
-- Lock based on the setting scope (organization-specific or instance-level/global).
-- The lock is released at transaction end.
PERFORM pg_advisory_xact_lock(
hashtext('zitadel.login_names')
, hashtext(setting.instance_id || ':' || COALESCE(setting.organization_id, 'global'))
);
IF NOT (setting.settings->'loginNameIncludesDomain')::BOOLEAN THEN
RAISE NOTICE 'inserting username as login name';
INSERT INTO zitadel.login_names(instance_id, organization_id, user_id, username, is_preferred, used_setting)
VALUES (NEW.instance_id, NEW.organization_id, NEW.id, NEW.username, TRUE, setting.id);
RETURN NULL;
END IF;
RAISE NOTICE 'inserting login names';
INSERT INTO zitadel.login_names(instance_id, organization_id, user_id, username, domain, is_preferred, used_setting)
SELECT
NEW.instance_id
, NEW.organization_id
, NEW.id
, NEW.username
, org_domains.domain
, org_domains.is_primary IS NULL OR org_domains.is_primary
, setting.id
FROM
zitadel.org_domains
WHERE
org_domains.instance_id = NEW.instance_id
AND org_domains.org_id = NEW.organization_id
AND org_domains.is_verified = TRUE;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER trg_apply_user_insert_to_login_names
AFTER INSERT ON zitadel.users
FOR EACH ROW
EXECUTE FUNCTION zitadel.apply_user_insert_to_login_names();
CREATE OR REPLACE FUNCTION zitadel.apply_domain_policy_manipulation_to_login_names() RETURNS TRIGGER AS $$
DECLARE
old_lock_key INT4;
new_lock_key INT4;
BEGIN
CASE TG_OP
-- insert and delete can only happen on organization level therefore we can load the instance level setting as OLD or NEW.
WHEN 'DELETE' THEN
SELECT
settings.*
INTO
NEW
FROM
zitadel.settings
WHERE
settings.instance_id = OLD.instance_id
AND settings.type = 'domain'
AND settings.organization_id IS NULL;
WHEN 'INSERT' THEN
SELECT
settings.*
INTO
OLD
FROM
zitadel.settings
WHERE
settings.instance_id = NEW.instance_id
AND settings.type = 'domain'
AND settings.organization_id IS NULL;
ELSE
-- OLD and NEW are already populated, do nothing
END CASE;
-- Lock both OLD and NEW setting scopes to serialize transitions across settings.
-- Acquire in deterministic order to avoid deadlocks.
old_lock_key := hashtext(OLD.instance_id || ':' || COALESCE(OLD.organization_id, 'global'));
new_lock_key := hashtext(NEW.instance_id || ':' || COALESCE(NEW.organization_id, 'global'));
IF old_lock_key = new_lock_key THEN
PERFORM pg_advisory_xact_lock(hashtext('zitadel.login_names'), old_lock_key);
ELSE
PERFORM pg_advisory_xact_lock(hashtext('zitadel.login_names'), new_lock_key);
PERFORM pg_advisory_xact_lock(hashtext('zitadel.login_names'), old_lock_key);
END IF;
IF (OLD.settings->'loginNameIncludesDomain')::BOOLEAN IS NOT DISTINCT FROM (NEW.settings->'loginNameIncludesDomain')::BOOLEAN THEN
-- field not changed but the setting id did change.
IF TG_OP = 'DELETE' OR TG_OP = 'INSERT' THEN
UPDATE
zitadel.login_names
SET
used_setting = NEW.id
WHERE
login_names.instance_id = OLD.instance_id
AND login_names.used_setting = OLD.id
AND (NEW.organization_id IS NULL OR login_names.organization_id = NEW.organization_id);
END IF;
-- no change in the setting, so we can skip the update
RETURN NULL;
END IF;
RAISE NOTICE 'recompute login names';
WITH affected_login_names AS (
DELETE FROM zitadel.login_names
WHERE
login_names.instance_id = NEW.instance_id
AND login_names.used_setting = OLD.id
AND (NEW.organization_id IS NULL OR login_names.organization_id = NEW.organization_id)
RETURNING instance_id, organization_id, user_id
)
INSERT INTO zitadel.login_names(instance_id, organization_id, user_id, username, domain, is_preferred, used_setting)
SELECT DISTINCT ON (affected_login_names.instance_id, affected_login_names.user_id, users.username, org_domains.domain)
affected_login_names.instance_id
, affected_login_names.organization_id
, affected_login_names.user_id
, users.username
, org_domains.domain
, org_domains.is_primary IS NULL OR org_domains.is_primary
, NEW.id
FROM
affected_login_names
JOIN zitadel.users ON
users.instance_id = affected_login_names.instance_id
AND users.id = affected_login_names.user_id
LEFT JOIN zitadel.org_domains ON
org_domains.instance_id = affected_login_names.instance_id
AND org_domains.org_id = affected_login_names.organization_id
AND org_domains.is_verified
AND (NEW.settings->'loginNameIncludesDomain')::BOOLEAN;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER trg_apply_domain_policy_manipulation_to_login_names
AFTER INSERT OR UPDATE ON zitadel.settings
FOR EACH ROW
WHEN (NEW.type = 'domain')
EXECUTE FUNCTION zitadel.apply_domain_policy_manipulation_to_login_names();
CREATE OR REPLACE TRIGGER trg_apply_domain_policy_removed_to_login_names
AFTER DELETE ON zitadel.settings
FOR EACH ROW
WHEN (OLD.type = 'domain')
EXECUTE FUNCTION zitadel.apply_domain_policy_manipulation_to_login_names();
@@ -186,7 +186,7 @@ func createProjectGrant(t *testing.T, tx database.Transaction, instanceID, grant
return projectGrant.ID
}
func createIdentityProvider(t *testing.T, tx database.Transaction, instanceID, orgID string) string {
func createIdentityProvider(t *testing.T, tx database.QueryExecutor, instanceID, orgID string) string {
t.Helper()
idp := domain.IdentityProvider{
InstanceID: instanceID,
+14 -5
View File
@@ -30,6 +30,7 @@ type user struct {
userMetadata
userPasskey
userIdentityProviderLink
userLoginName
}
func (u user) HumanRepository() domain.HumanUserRepository {
@@ -71,6 +72,8 @@ var queryUserStmt = "SELECT users.instance_id, users.organization_id, users.id,
", 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` +
// login names
`, jsonb_agg(DISTINCT jsonb_build_object('loginName', login_names.login_name, 'isPreferred', login_names.is_preferred)) FILTER (WHERE login_names.user_id IS NOT NULL) AS login_names` +
// machine
`, CASE WHEN users.type = 'machine' THEN jsonb_build_object('name', users.name` +
`, 'description', users.description, 'secret', users.secret` +
@@ -147,6 +150,7 @@ func (u user) appendQueryOpts(opts []database.QueryOption) []database.QueryOptio
u.joinVerifications(),
u.joinPasskeys(),
u.joinIdentityProviderLinks(),
u.joinLoginNames(),
database.WithGroupBy(u.PrimaryKeyColumns()...),
)
}
@@ -228,11 +232,6 @@ 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)
@@ -478,3 +477,13 @@ func (u user) joinIdentityProviderLinks() database.QueryOption {
),
)
}
func (u user) joinLoginNames() database.QueryOption {
return database.WithLeftJoin(
u.userLoginName.qualifiedTableName(),
database.And(
database.NewColumnCondition(u.InstanceIDColumn(), u.userLoginName.instanceIDColumn()),
database.NewColumnCondition(u.IDColumn(), u.userLoginName.userIDColumn()),
),
)
}
@@ -0,0 +1,38 @@
package repository
import "github.com/zitadel/zitadel/backend/v3/storage/database"
type userLoginName struct{}
func (u userLoginName) qualifiedTableName() string {
return "zitadel.login_names"
}
func (u userLoginName) unqualifiedTableName() string {
return "login_names"
}
// -------------------------------------------------------------
// conditions
// -------------------------------------------------------------
// LoginNameCondition implements [domain.UserRepository].
func (u userLoginName) LoginNameCondition(op database.TextOperation, loginName string) database.Condition {
return database.NewTextCondition(u.loginNameColumn(), op, loginName)
}
// -------------------------------------------------------------
// columns
// -------------------------------------------------------------
func (u userLoginName) loginNameColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "login_name")
}
func (u userLoginName) instanceIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "instance_id")
}
func (u userLoginName) userIDColumn() database.Column {
return database.NewColumn(u.unqualifiedTableName(), "user_id")
}
@@ -0,0 +1,378 @@
package repository_test
import (
"context"
"fmt"
"maps"
"strconv"
"testing"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"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 TestUserLoginNameManipulations(t *testing.T) {
tx, err := pool.Begin(t.Context(), nil)
require.NoError(t, err)
t.Cleanup(func() {
err := tx.Rollback(context.Background())
if err != nil {
t.Log("failed to rollback transaction:", err)
}
})
orgDomainRepo := repository.OrganizationDomainRepository()
userRepo := repository.UserRepository()
settingsRepo := repository.DomainSettingsRepository()
instanceID := createInstance(t, tx)
err = settingsRepo.Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
Type: domain.SettingTypeDomain,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
LoginNameIncludesDomain: gu.Ptr(false),
},
})
require.NoError(t, err)
org1 := createOrganization(t, tx, instanceID)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID,
OrgID: org1,
Domain: "verified.example.com",
IsVerified: true,
})
require.NoError(t, err)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID,
OrgID: org1,
Domain: "primary.example.com",
IsVerified: true,
IsPrimary: true,
})
require.NoError(t, err)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID,
OrgID: org1,
Domain: "unverified.example.com",
})
require.NoError(t, err)
err = settingsRepo.Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org1,
Type: domain.SettingTypeDomain,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
LoginNameIncludesDomain: gu.Ptr(true),
},
})
require.NoError(t, err)
org2 := createOrganization(t, tx, instanceID)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID,
OrgID: org2,
Domain: "primary.example2.com",
IsVerified: true,
IsPrimary: true,
})
require.NoError(t, err)
loginNames := setupLoginNameTestUsers(t, tx, instanceID, org1, org2)
for _, tt := range []struct {
name string
opts []database.QueryOption
expectedLoginNames map[string][]domain.LoginName
before func(t *testing.T, tx database.QueryExecutor)
}{
{
name: "list all users",
opts: []database.QueryOption{
database.WithCondition(userRepo.InstanceIDCondition(instanceID)),
},
expectedLoginNames: maps.Clone(loginNames),
},
{
name: "update usernames",
before: func(t *testing.T, tx database.QueryExecutor) {
for i := range 100 {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID, strconv.Itoa(i)),
userRepo.SetUsername("updated-user"+strconv.Itoa(i)),
)
require.NoError(t, err)
}
},
opts: []database.QueryOption{
database.WithCondition(userRepo.InstanceIDCondition(instanceID)),
},
expectedLoginNames: expectedLoginNames(loginNames, func(id string, loginNames []domain.LoginName) []domain.LoginName {
updated := make([]domain.LoginName, len(loginNames))
for i, ln := range loginNames {
updated[i] = ln
updated[i].LoginName = "updated-" + ln.LoginName
}
return updated
}),
},
{
name: "add org setting without impact",
before: func(t *testing.T, tx database.QueryExecutor) {
err := settingsRepo.Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org2,
Type: domain.SettingTypeDomain,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
RequireOrgDomainVerification: gu.Ptr(false),
},
})
require.NoError(t, err)
},
opts: []database.QueryOption{
database.WithCondition(userRepo.InstanceIDCondition(instanceID)),
},
expectedLoginNames: maps.Clone(loginNames),
},
{
name: "add and update org setting without impact",
before: func(t *testing.T, tx database.QueryExecutor) {
err := settingsRepo.Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org2,
Type: domain.SettingTypeDomain,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
RequireOrgDomainVerification: gu.Ptr(false),
},
})
require.NoError(t, err)
err = settingsRepo.Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org2,
Type: domain.SettingTypeDomain,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
RequireOrgDomainVerification: gu.Ptr(true),
},
})
require.NoError(t, err)
},
opts: []database.QueryOption{
database.WithCondition(userRepo.InstanceIDCondition(instanceID)),
},
expectedLoginNames: maps.Clone(loginNames),
},
{
name: "add without impact and update with impact org setting",
before: func(t *testing.T, tx database.QueryExecutor) {
err := settingsRepo.Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org2,
Type: domain.SettingTypeDomain,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
RequireOrgDomainVerification: gu.Ptr(false),
},
})
require.NoError(t, err)
err = settingsRepo.Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org2,
Type: domain.SettingTypeDomain,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
LoginNameIncludesDomain: gu.Ptr(true),
},
})
require.NoError(t, err)
},
opts: []database.QueryOption{
database.WithCondition(userRepo.InstanceIDCondition(instanceID)),
},
expectedLoginNames: expectedLoginNames(loginNames, func(id string, loginNames []domain.LoginName) []domain.LoginName {
if len(loginNames) == 2 {
return loginNames
}
return []domain.LoginName{
{
LoginName: fmt.Sprintf("user%[email protected]", id),
IsPreferred: true,
},
}
}),
},
{
name: "change org setting with impact",
before: func(t *testing.T, tx database.QueryExecutor) {
err := settingsRepo.Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org1,
Type: domain.SettingTypeDomain,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
LoginNameIncludesDomain: gu.Ptr(false),
},
})
require.NoError(t, err)
},
opts: []database.QueryOption{
database.WithCondition(userRepo.InstanceIDCondition(instanceID)),
},
expectedLoginNames: expectedLoginNames(loginNames, func(id string, loginNames []domain.LoginName) []domain.LoginName {
if len(loginNames) == 1 {
return loginNames
}
return []domain.LoginName{
{
LoginName: "user" + id,
IsPreferred: true,
},
}
}),
},
{
name: "reset org setting",
before: func(t *testing.T, tx database.QueryExecutor) {
_, err := settingsRepo.Delete(t.Context(), tx, settingsRepo.UniqueCondition(instanceID, &org1, domain.SettingTypeDomain, domain.SettingStateActive))
require.NoError(t, err)
},
opts: []database.QueryOption{
database.WithCondition(userRepo.InstanceIDCondition(instanceID)),
},
expectedLoginNames: expectedLoginNames(loginNames, func(id string, loginNames []domain.LoginName) []domain.LoginName {
if len(loginNames) == 1 {
return loginNames
}
return []domain.LoginName{
{
LoginName: "user" + id,
IsPreferred: true,
},
}
}),
},
{
name: "change instance setting",
before: func(t *testing.T, tx database.QueryExecutor) {
err := settingsRepo.Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
Type: domain.SettingTypeDomain,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
LoginNameIncludesDomain: gu.Ptr(true),
},
})
require.NoError(t, err)
},
opts: []database.QueryOption{
database.WithCondition(userRepo.InstanceIDCondition(instanceID)),
},
expectedLoginNames: expectedLoginNames(loginNames, func(id string, loginNames []domain.LoginName) []domain.LoginName {
if len(loginNames) == 2 {
return loginNames
}
return []domain.LoginName{
{
LoginName: fmt.Sprintf("user%[email protected]", id),
IsPreferred: true,
},
}
}),
},
} {
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("failed to rollback savepoint:", err)
}
})
if tt.before != nil {
tt.before(t, savepoint)
}
users, err := userRepo.List(t.Context(), savepoint, tt.opts...)
require.NoError(t, err)
for _, user := range users {
expected, ok := tt.expectedLoginNames[user.ID]
require.True(t, ok, "unexpected user ID: %s", user.ID)
assertLoginNames(t, expected, user.LoginNames)
delete(tt.expectedLoginNames, user.ID)
}
assert.Empty(t, tt.expectedLoginNames, "some expected users were not found")
})
}
}
func setupLoginNameTestUsers(t *testing.T, tx database.QueryExecutor, instanceID, org1, org2 string) map[string][]domain.LoginName {
userRepo := repository.UserRepository()
expectedLoginNames := make(map[string][]domain.LoginName, 100)
for i := range 100 {
orgID := org1
id := strconv.Itoa(i)
expectedLoginNames[id] = []domain.LoginName{
{
LoginName: fmt.Sprintf("user%[email protected]", i),
IsPreferred: false,
},
{
LoginName: fmt.Sprintf("user%[email protected]", i),
IsPreferred: true,
},
}
if i%2 == 0 {
orgID = org2
expectedLoginNames[id] = []domain.LoginName{
{
LoginName: "user" + id,
IsPreferred: true,
},
}
}
err := userRepo.Create(t.Context(), tx, &domain.User{
InstanceID: instanceID,
OrganizationID: orgID,
ID: id,
Username: "user" + id,
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + id,
},
})
require.NoError(t, err)
}
return expectedLoginNames
}
func expectedLoginNames(loginNames map[string][]domain.LoginName, replace func(id string, loginNames []domain.LoginName) []domain.LoginName) map[string][]domain.LoginName {
updated := maps.Clone(loginNames)
for id, userLoginNames := range updated {
updated[id] = replace(id, userLoginNames)
}
return updated
}
@@ -26,12 +26,66 @@ func Test_user_Get(t *testing.T) {
t.Cleanup(rollback)
userRepo := repository.UserRepository()
orgDomainRepo := repository.OrganizationDomainRepository()
instanceID := createInstance(t, tx)
err := repository.DomainSettingsRepository().Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
Type: domain.SettingTypeDomain,
State: domain.SettingStateActive,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
LoginNameIncludesDomain: gu.Ptr(false),
RequireOrgDomainVerification: gu.Ptr(false),
SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(false),
},
})
require.NoError(t, err)
orgID1 := createOrganization(t, tx, instanceID)
orgID2 := createOrganization(t, tx, instanceID)
err = repository.DomainSettingsRepository().Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &orgID2,
Type: domain.SettingTypeDomain,
State: domain.SettingStateActive,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
LoginNameIncludesDomain: gu.Ptr(true),
RequireOrgDomainVerification: gu.Ptr(false),
SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(false),
},
})
require.NoError(t, err)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID,
OrgID: orgID2,
Domain: "primary.zitadel.com",
IsVerified: true,
IsPrimary: true,
})
require.NoError(t, err)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID,
OrgID: orgID2,
Domain: "verified.zitadel.com",
IsVerified: true,
IsPrimary: false,
})
require.NoError(t, err)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID,
OrgID: orgID2,
Domain: "unverified.zitadel.com",
IsVerified: false,
IsPrimary: false,
})
require.NoError(t, err)
now := time.Now().Round(time.Millisecond)
human := &domain.User{
@@ -42,6 +96,12 @@ func Test_user_Get(t *testing.T) {
State: domain.UserStateActive,
CreatedAt: now,
UpdatedAt: now,
LoginNames: []domain.LoginName{
{
LoginName: "human-user",
IsPreferred: true,
},
},
Human: &domain.HumanUser{
FirstName: "John",
LastName: "Doe",
@@ -66,20 +126,28 @@ func Test_user_Get(t *testing.T) {
State: domain.UserStateActive,
CreatedAt: now,
UpdatedAt: now,
LoginNames: []domain.LoginName{
{
LoginName: "[email protected]",
IsPreferred: true,
},
{
LoginName: "[email protected]",
},
},
Machine: &domain.MachineUser{
Name: "My Machine",
Description: "This is my machine user",
},
}
err := userRepo.Create(t.Context(), tx, human)
err = userRepo.Create(t.Context(), tx, human)
require.NoError(t, err)
err = userRepo.Create(t.Context(), tx, machine)
require.NoError(t, err)
tests := []struct {
name string
setup func(t *testing.T, tx database.Transaction) error
condition database.Condition
wantErr error
expected *domain.User
@@ -120,10 +188,6 @@ func Test_user_Get(t *testing.T) {
t.Log("rollback savepoint failed", err)
}
})
if tt.setup != nil {
err := tt.setup(t, savepoint)
require.NoError(t, err)
}
user, err := userRepo.Get(t.Context(), savepoint, database.WithCondition(tt.condition))
require.ErrorIs(t, err, tt.wantErr)
@@ -536,10 +600,57 @@ func Test_user_ListConditions(t *testing.T) {
userRepo := repository.UserRepository()
humanRepo := repository.HumanUserRepository()
orgDomainRepo := repository.OrganizationDomainRepository()
instanceID1 := createInstance(t, tx)
err := repository.DomainSettingsRepository().Set(t.Context(), tx, &domain.DomainSettings{
Settings: domain.Settings{
InstanceID: instanceID1,
Type: domain.SettingTypeDomain,
State: domain.SettingStateActive,
},
DomainSettingsAttributes: domain.DomainSettingsAttributes{
LoginNameIncludesDomain: gu.Ptr(true),
RequireOrgDomainVerification: gu.Ptr(false),
SMTPSenderAddressMatchesInstanceDomain: gu.Ptr(false),
},
})
require.NoError(t, err)
instance1OrgID1 := createOrganization(t, tx, instanceID1)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID1,
OrgID: instance1OrgID1,
Domain: "org1.zitadel.com",
IsVerified: true,
IsPrimary: true,
})
require.NoError(t, err)
instance1OrgID2 := createOrganization(t, tx, instanceID1)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID1,
OrgID: instance1OrgID2,
Domain: "org2-primary.zitadel.com",
IsVerified: true,
IsPrimary: true,
})
require.NoError(t, err)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID1,
OrgID: instance1OrgID2,
Domain: "org2-verified.zitadel.com",
IsVerified: true,
})
require.NoError(t, err)
err = orgDomainRepo.Add(t.Context(), tx, &domain.AddOrganizationDomain{
InstanceID: instanceID1,
OrgID: instance1OrgID2,
Domain: "org2-unverified.zitadel.com",
})
require.NoError(t, err)
idpID := createIdentityProvider(t, tx, instanceID1, instance1OrgID1)
instanceID2 := createInstance(t, tx)
@@ -645,7 +756,7 @@ func Test_user_ListConditions(t *testing.T) {
}
tests := []struct {
name string
setup func(t *testing.T, tx database.Transaction)
setup func(t *testing.T, tx database.QueryExecutor)
opts []database.QueryOption
want want
}{
@@ -711,7 +822,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by state",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
userRepo.SetState(domain.UserStateInactive),
)
@@ -739,7 +850,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by type",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
},
opts: []database.QueryOption{
database.WithCondition(database.And(
@@ -752,25 +863,24 @@ func Test_user_ListConditions(t *testing.T) {
ids: []string{"m1", "m2"},
},
},
// {
// name: "by login name",
// setup: func(t *testing.T, tx database.Transaction) {
// },
// opts: []database.QueryOption{
// database.WithCondition(database.And(
// userRepo.InstanceIDCondition(instanceID1),
// )),
// },
// want: want{
// err: nil,
// ids: []string{},
// },
// },
{
name: "by login name found",
opts: []database.QueryOption{
database.WithCondition(database.And(
userRepo.InstanceIDCondition(instanceID1),
userRepo.LoginNameCondition(database.TextOperationEqual, "[email protected]"),
)),
},
want: want{
err: nil,
ids: []string{"h2"},
},
},
// metadata conditions
{
name: "by metadata key",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
userRepo.SetMetadata(
&domain.Metadata{
@@ -812,7 +922,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by metadata value",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
userRepo.SetMetadata(
&domain.Metadata{
@@ -864,7 +974,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by metadata key and value",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
userRepo.SetMetadata(
&domain.Metadata{
@@ -921,7 +1031,7 @@ func Test_user_ListConditions(t *testing.T) {
// human conditions
{
name: "by display name",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
},
opts: []database.QueryOption{
database.WithCondition(database.And(
@@ -936,7 +1046,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by first name",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
},
opts: []database.QueryOption{
database.WithCondition(database.And(
@@ -951,7 +1061,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by last name",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
},
opts: []database.QueryOption{
database.WithCondition(database.And(
@@ -966,7 +1076,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by nickname",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
},
opts: []database.QueryOption{
database.WithCondition(database.And(
@@ -981,7 +1091,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by preferred language",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
},
opts: []database.QueryOption{
database.WithCondition(database.And(
@@ -998,7 +1108,7 @@ func Test_user_ListConditions(t *testing.T) {
// phone conditions
{
name: "by phone",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := humanRepo.Update(t.Context(), tx, humanRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.SetPhone("+1234567890"),
humanRepo.SetPhoneVerification(&domain.VerificationTypeSkipped{}),
@@ -1025,7 +1135,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by unverified phone",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := humanRepo.Update(t.Context(), tx, humanRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.SetPhone("+1234567890"),
humanRepo.SetPhoneVerification(&domain.VerificationTypeSkipped{}),
@@ -1052,7 +1162,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by verified phone",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := humanRepo.Update(t.Context(), tx, humanRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.SetPhone("+1234567890"),
humanRepo.SetPhoneVerification(&domain.VerificationTypeSkipped{}),
@@ -1081,7 +1191,7 @@ func Test_user_ListConditions(t *testing.T) {
// human email conditions
{
name: "by email",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := humanRepo.Update(t.Context(), tx, humanRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.SetEmail("[email protected]"),
humanRepo.SetEmailVerification(&domain.VerificationTypeSkipped{}),
@@ -1108,7 +1218,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by unverified email",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := humanRepo.Update(t.Context(), tx, humanRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.SetEmail("[email protected]"),
humanRepo.SetEmailVerification(&domain.VerificationTypeSkipped{}),
@@ -1135,7 +1245,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by verified email",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := humanRepo.Update(t.Context(), tx, humanRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.SetEmail("[email protected]"),
humanRepo.SetEmailVerification(&domain.VerificationTypeSkipped{}),
@@ -1164,7 +1274,7 @@ func Test_user_ListConditions(t *testing.T) {
// passkey conditions
{
name: "by passkey same challenge",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.AddPasskey(
&domain.Passkey{
@@ -1224,7 +1334,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by passkey challenge",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.AddPasskey(
&domain.Passkey{
@@ -1284,7 +1394,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by passkey id",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.AddPasskey(
&domain.Passkey{
@@ -1344,7 +1454,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by passkey key id",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.AddPasskey(
&domain.Passkey{
@@ -1404,7 +1514,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by passkey type",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.AddPasskey(
&domain.Passkey{
@@ -1466,7 +1576,7 @@ func Test_user_ListConditions(t *testing.T) {
// idp link conditions
{
name: "by idp link provider id",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.AddIdentityProviderLink(&domain.IdentityProviderLink{
ProviderID: idpID,
@@ -1505,7 +1615,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by idp link provided user id",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.AddIdentityProviderLink(&domain.IdentityProviderLink{
ProviderID: idpID,
@@ -1544,7 +1654,7 @@ func Test_user_ListConditions(t *testing.T) {
},
{
name: "by idp link username",
setup: func(t *testing.T, tx database.Transaction) {
setup: func(t *testing.T, tx database.QueryExecutor) {
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID1, "h1"),
humanRepo.AddIdentityProviderLink(&domain.IdentityProviderLink{
ProviderID: idpID,
@@ -4395,6 +4505,7 @@ func assertUser(t *testing.T, expected, actual *domain.User) {
require.Equal(t, expected.Human == nil, actual.Human == nil, "human check")
assertMetadata(t, expected.Metadata, actual.Metadata)
assertLoginNames(t, expected.LoginNames, actual.LoginNames)
if expected.Machine != nil {
assertMachineUser(t, expected.Machine, actual.Machine)
@@ -4409,6 +4520,25 @@ func assertUser(t *testing.T, expected, actual *domain.User) {
t.Fail()
}
func assertLoginNames(t *testing.T, expected, gotten []domain.LoginName) {
t.Helper()
assert.Len(t, gotten, len(expected), "login names length mismatch")
for _, exp := range expected {
var actual domain.LoginName
gotten = slices.DeleteFunc(gotten, func(ln domain.LoginName) bool {
if ln.LoginName != exp.LoginName {
return false
}
actual = ln
return true
})
require.NotEmpty(t, actual, "login name %q not found", exp.LoginName)
assert.Equal(t, exp.IsPreferred, actual.IsPreferred, "login name %q is preferred", exp.LoginName)
}
assert.Empty(t, gotten, "unmatched login names found")
}
func assertMachineUser(t *testing.T, expected, actual *domain.MachineUser) {
t.Helper()