mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
@@ -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)
|
||||
@@ -229,4 +229,95 @@ 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))
|
||||
|
||||
Reference in New Issue
Block a user