diff --git a/backend/v3/storage/database/dialect/postgres/migration/013_user_table/up.sql b/backend/v3/storage/database/dialect/postgres/migration/013_user_table/up.sql index 068c51023..184fdeb78 100644 --- a/backend/v3/storage/database/dialect/postgres/migration/013_user_table/up.sql +++ b/backend/v3/storage/database/dialect/postgres/migration/013_user_table/up.sql @@ -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 -); \ No newline at end of file +); + +-- ---------------------------------------------------------------- +-- 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(); \ No newline at end of file diff --git a/backend/v3/storage/database/repository/user_test.go b/backend/v3/storage/database/repository/user_test.go index addcf8c67..e3c24cb90 100644 --- a/backend/v3/storage/database/repository/user_test.go +++ b/backend/v3/storage/database/repository/user_test.go @@ -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))