feat: add trigger on relational user table to ensure username uniqueness based on organization setting (#11686)

This update introduces a trigger that enforces username uniqueness based
on organization-specific settings. It ensures that usernames are unique
when the organization setting for scoped usernames is enabled.
Additionally, tests have been added to verify the functionality across
different organizational settings.

The users table got introduced by #10967

---------

Co-authored-by: Marco A. <[email protected]>
This commit is contained in:
Silvan
2026-03-03 05:57:06 +01:00
committed by GitHub
co-authored by Marco A.
parent 3ebeb61b80
commit 9fb0eea125
2 changed files with 395 additions and 4 deletions
@@ -90,7 +90,7 @@ ALTER TABLE zitadel.sessions ADD CONSTRAINT fk_session_user FOREIGN KEY (instanc
ALTER TABLE zitadel.authorizations ADD CONSTRAINT fk_authorization_user FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users (instance_id, id) ON DELETE CASCADE;
-- user
CREATE UNIQUE INDEX ON zitadel.users(instance_id, organization_id, username) WHERE username_org_unique IS TRUE; --TODO(adlerhurst): does that work if a username is already present on a user without org unique?
CREATE UNIQUE INDEX ON zitadel.users(instance_id, organization_id, username) WHERE username_org_unique IS TRUE;
CREATE UNIQUE INDEX ON zitadel.users(instance_id, username) WHERE username_org_unique IS FALSE;
CREATE INDEX idx_user_username ON zitadel.users (username);
CREATE INDEX idx_user_username_lower ON zitadel.users (lower(username));
@@ -105,7 +105,7 @@ CREATE UNIQUE INDEX ON zitadel.users(password_verification_id) WHERE password_ve
CREATE UNIQUE INDEX ON zitadel.users(email_verification_id) WHERE email_verification_id IS NOT NULL;
CREATE UNIQUE INDEX ON zitadel.users(phone_verification_id) WHERE phone_verification_id IS NOT NULL;
CREATE TRIGGER trigger_set_updated_at
CREATE OR REPLACE TRIGGER trigger_set_updated_at
BEFORE UPDATE ON zitadel.users
FOR EACH ROW
WHEN (NEW.updated_at IS NULL)
@@ -131,7 +131,7 @@ CREATE TABLE zitadel.user_metadata (
CREATE INDEX idx_user_metadata_key ON zitadel.user_metadata (key);
CREATE INDEX idx_user_metadata_value ON zitadel.user_metadata (sha256(value));
CREATE TRIGGER trg_set_updated_at_user_metadata
CREATE OR REPLACE TRIGGER trg_set_updated_at_user_metadata
BEFORE INSERT OR UPDATE ON zitadel.user_metadata
FOR EACH ROW
WHEN (NEW.updated_at IS NULL)
@@ -230,3 +230,94 @@ CREATE TABLE zitadel.user_identity_provider_links(
, FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, identity_provider_id) REFERENCES zitadel.identity_providers(instance_id, id) ON DELETE CASCADE
);
-- ----------------------------------------------------------------
-- organization scoped usernames
-- ----------------------------------------------------------------
CREATE OR REPLACE FUNCTION zitadel.set_username_org_unique() RETURNS TRIGGER AS $$
BEGIN
SELECT setting.organization_scoped_usernames
INTO NEW.username_org_unique
FROM (
SELECT
(settings.settings->'organizationScopedUsernames' )::BOOLEAN AS organization_scoped_usernames
FROM zitadel.settings
WHERE
settings.type = 'organization'
AND settings.instance_id = NEW.instance_id
AND (
settings.organization_id IS NULL -- instance level setting
OR settings.organization_id = NEW.organization_id -- organization level setting
)
ORDER BY
settings.organization_id NULLS LAST -- organization level setting overrides instance level setting
LIMIT 1
) AS setting;
NEW.username_org_unique = COALESCE(NEW.username_org_unique, FALSE); -- default to false if no setting is found
return NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER trg_set_username_org_unique
BEFORE INSERT ON zitadel.users
FOR EACH ROW
EXECUTE FUNCTION zitadel.set_username_org_unique();
CREATE OR REPLACE FUNCTION zitadel.ensure_username_org_unique() RETURNS TRIGGER AS $$
DECLARE
_instance_id TEXT;
_organization_id TEXT;
_old_organization_scoped_usernames BOOLEAN;
_new_organization_scoped_usernames BOOLEAN;
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
_instance_id := OLD.instance_id;
_organization_id := OLD.organization_id;
_old_organization_scoped_usernames := COALESCE((OLD.settings->'organizationScopedUsernames')::BOOLEAN, FALSE);
_new_organization_scoped_usernames := COALESCE((SELECT COALESCE((settings.settings->'organizationScopedUsernames')::BOOLEAN, FALSE) FROM zitadel.settings WHERE settings.instance_id = OLD.instance_id AND settings.type = 'organization' AND settings.organization_id IS NULL), FALSE);
WHEN 'INSERT' THEN
_instance_id := NEW.instance_id;
_organization_id := NEW.organization_id;
_old_organization_scoped_usernames := COALESCE((SELECT COALESCE((settings.settings->'organizationScopedUsernames')::BOOLEAN, FALSE) FROM zitadel.settings WHERE settings.instance_id = NEW.instance_id AND settings.type = 'organization' AND settings.organization_id IS NULL), FALSE);
_new_organization_scoped_usernames := COALESCE((NEW.settings->'organizationScopedUsernames')::BOOLEAN, FALSE);
ELSE
_instance_id := NEW.instance_id;
_organization_id := NEW.organization_id;
_old_organization_scoped_usernames := COALESCE((OLD.settings->'organizationScopedUsernames')::BOOLEAN, FALSE);
_new_organization_scoped_usernames := COALESCE((NEW.settings->'organizationScopedUsernames')::BOOLEAN, FALSE);
END CASE;
IF _old_organization_scoped_usernames IS NOT DISTINCT FROM _new_organization_scoped_usernames THEN
RETURN NULL;
END IF;
UPDATE zitadel.users
SET username_org_unique = _new_organization_scoped_usernames
WHERE instance_id = _instance_id
AND (
(_organization_id IS NOT NULL AND organization_id = _organization_id) -- organization level setting changed, update users of that organization
OR (_organization_id IS NULL AND organization_id NOT IN (
SELECT organization_id FROM zitadel.settings WHERE instance_id = _instance_id AND type = 'organization' AND organization_id IS NOT NULL
)) -- instance level setting changed, update users of organizations without an organization level setting
);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER trg_ensure_username_org_unique
AFTER INSERT OR UPDATE ON zitadel.settings
FOR EACH ROW
WHEN (NEW.type = 'organization')
EXECUTE FUNCTION zitadel.ensure_username_org_unique();
CREATE OR REPLACE TRIGGER trg_ensure_username_org_unique_delete
AFTER DELETE ON zitadel.settings
FOR EACH ROW
WHEN (OLD.type = 'organization')
EXECUTE FUNCTION zitadel.ensure_username_org_unique();
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"slices"
"strconv"
"testing"
"time"
@@ -4073,6 +4074,305 @@ func Test_user_Delete(t *testing.T) {
}
}
func Test_user_usernameUniqueness(t *testing.T) {
tx, rollback := transactionForRollback(t)
t.Cleanup(rollback)
userRepo := repository.UserRepository()
settingsRepo := repository.OrganizationSettingsRepository()
instanceID := createInstance(t, tx)
// org 1 has no [domain.OrganizationSettings]
org1ID := createOrganization(t, tx, instanceID)
// org 2 has [domain.OrganizationSettings] and organization scoped usernames not enforced
org2ID := createOrganization(t, tx, instanceID)
err := settingsRepo.Set(t.Context(), tx, &domain.OrganizationSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org2ID,
Type: domain.SettingTypeOrganization,
State: domain.SettingStateActive,
},
OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{
OrganizationScopedUsernames: gu.Ptr(false),
},
})
require.NoError(t, err)
// org 3 has [domain.OrganizationSettings] and organization scoped usernames enforced
org3ID := createOrganization(t, tx, instanceID)
err = settingsRepo.Set(t.Context(), tx, &domain.OrganizationSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org3ID,
Type: domain.SettingTypeOrganization,
State: domain.SettingStateActive,
},
OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{
OrganizationScopedUsernames: gu.Ptr(true),
},
})
require.NoError(t, err)
// org 4 has [domain.OrganizationSettings] and organization scoped usernames enforced
org4ID := createOrganization(t, tx, instanceID)
err = settingsRepo.Set(t.Context(), tx, &domain.OrganizationSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org4ID,
Type: domain.SettingTypeOrganization,
State: domain.SettingStateActive,
},
OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{
OrganizationScopedUsernames: gu.Ptr(true),
},
})
require.NoError(t, err)
for name, test := range map[string]func(t *testing.T, tx database.QueryExecutor){
"different usernames on all organizations": func(t *testing.T, tx database.QueryExecutor) {
for _, orgID := range []string{org1ID, org2ID, org3ID, org4ID} {
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: gofakeit.UUID(),
InstanceID: instanceID,
OrganizationID: orgID,
Username: "user@" + orgID,
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + orgID,
},
})
assert.NoError(t, err)
}
},
"same username on orgs with uniqueness": func(t *testing.T, tx database.QueryExecutor) {
for _, orgID := range []string{org3ID, org4ID} {
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: gofakeit.UUID(),
InstanceID: instanceID,
OrganizationID: orgID,
Username: "user",
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + orgID,
},
})
assert.NoError(t, err)
}
},
"same username on orgs without uniqueness": func(t *testing.T, tx database.QueryExecutor) {
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: gofakeit.UUID(),
InstanceID: instanceID,
OrganizationID: org1ID,
Username: "user",
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + org1ID,
},
})
assert.NoError(t, err)
err = userRepo.Create(t.Context(), tx, &domain.User{
ID: gofakeit.UUID(),
InstanceID: instanceID,
OrganizationID: org2ID,
Username: "user",
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + org2ID,
},
})
assert.Error(t, err)
},
"same username on orgs with uniqueness and 1 org without": func(t *testing.T, tx database.QueryExecutor) {
for _, orgID := range []string{org2ID, org3ID, org4ID} {
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: gofakeit.UUID(),
InstanceID: instanceID,
OrganizationID: orgID,
Username: "user",
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + orgID,
},
})
assert.NoError(t, err)
}
},
"same username on orgs with uniqueness and orgs without": func(t *testing.T, tx database.QueryExecutor) {
for _, orgID := range []string{org2ID, org3ID, org4ID} {
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: gofakeit.UUID(),
InstanceID: instanceID,
OrganizationID: orgID,
Username: "user",
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + orgID,
},
})
assert.NoError(t, err)
}
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: gofakeit.UUID(),
InstanceID: instanceID,
OrganizationID: org1ID,
Username: "user",
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + org1ID,
},
})
assert.Error(t, err)
},
"try update username to clash": func(t *testing.T, tx database.QueryExecutor) {
for i, orgID := range []string{org1ID, org2ID} {
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: strconv.Itoa(i),
InstanceID: instanceID,
OrganizationID: orgID,
Username: "user" + strconv.Itoa(i),
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + orgID,
},
})
assert.NoError(t, err)
}
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID, "0"), userRepo.SetUsername("user1"))
assert.Error(t, err)
},
"try update username successful": func(t *testing.T, tx database.QueryExecutor) {
for i, orgID := range []string{org3ID, org4ID} {
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: strconv.Itoa(i),
InstanceID: instanceID,
OrganizationID: orgID,
Username: "user" + strconv.Itoa(i),
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + orgID,
},
})
assert.NoError(t, err)
}
_, err := userRepo.Update(t.Context(), tx, userRepo.PrimaryKeyCondition(instanceID, "0"), userRepo.SetUsername("user1"))
assert.NoError(t, err)
},
"disable organization scoped usernames setting that causes no username clash": func(t *testing.T, tx database.QueryExecutor) {
for i, orgID := range []string{org1ID, org3ID} {
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: strconv.Itoa(i),
InstanceID: instanceID,
OrganizationID: orgID,
Username: "user" + strconv.Itoa(i),
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + orgID,
},
})
assert.NoError(t, err)
}
err := settingsRepo.Set(t.Context(), tx, &domain.OrganizationSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org3ID,
},
OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{
OrganizationScopedUsernames: gu.Ptr(false),
},
})
assert.NoError(t, err)
},
"disable organization scoped usernames setting that causes username clash": func(t *testing.T, tx database.QueryExecutor) {
for i, orgID := range []string{org1ID, org3ID} {
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: strconv.Itoa(i),
InstanceID: instanceID,
OrganizationID: orgID,
Username: "username",
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + orgID,
},
})
assert.NoError(t, err)
}
err := settingsRepo.Set(t.Context(), tx, &domain.OrganizationSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org3ID,
},
OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{
OrganizationScopedUsernames: gu.Ptr(false),
},
})
assert.Error(t, err)
},
"add organization scoped usernames setting and add new user with same username": func(t *testing.T, tx database.QueryExecutor) {
err := userRepo.Create(t.Context(), tx, &domain.User{
ID: gofakeit.UUID(),
InstanceID: instanceID,
OrganizationID: org1ID,
Username: "username",
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + org1ID,
},
})
assert.NoError(t, err)
sp, err := tx.(database.Transaction).Begin(t.Context())
require.NoError(t, err)
err = userRepo.Create(t.Context(), sp, &domain.User{
ID: gofakeit.UUID(),
InstanceID: instanceID,
OrganizationID: org2ID,
Username: "username",
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + org2ID,
},
})
assert.Error(t, err)
require.NoError(t, sp.Rollback(t.Context()))
err = settingsRepo.Set(t.Context(), tx, &domain.OrganizationSettings{
Settings: domain.Settings{
InstanceID: instanceID,
OrganizationID: &org1ID,
},
OrganizationSettingsAttributes: domain.OrganizationSettingsAttributes{
OrganizationScopedUsernames: gu.Ptr(true),
},
})
assert.NoError(t, err)
err = userRepo.Create(t.Context(), tx, &domain.User{
ID: gofakeit.UUID(),
InstanceID: instanceID,
OrganizationID: org2ID,
Username: "username",
State: domain.UserStateActive,
Machine: &domain.MachineUser{
Name: "machine" + org2ID,
},
})
assert.NoError(t, err)
},
} {
t.Run(name, func(t *testing.T) {
savepoint, rollback := savepointForRollback(t, tx)
t.Cleanup(rollback)
test(t, savepoint)
})
}
}
func assertUsers(t *testing.T, expected, actual []*domain.User) {
t.Helper()
require.Len(t, actual, len(expected))