diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index bc338c305328d0fc5d46bf3eb8f38255d59e9c61..ecc884f5456ab619288bced257af789f5cf73df4 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -30,6 +30,7 @@ class Organization < ApplicationRecord has_one :organization_detail, inverse_of: :organization, autosave: true has_many :organization_users, inverse_of: :organization + has_many :organization_user_aliases, inverse_of: :organization # if considering disable_joins on the below see: # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/140343#note_1705047949 has_many :users, through: :organization_users, inverse_of: :organizations diff --git a/app/models/organizations/organization_user_alias.rb b/app/models/organizations/organization_user_alias.rb new file mode 100644 index 0000000000000000000000000000000000000000..17af066494e7d2ec92010871f5be27f0b7572c05 --- /dev/null +++ b/app/models/organizations/organization_user_alias.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Organizations + class OrganizationUserAlias < ApplicationRecord + belongs_to :organization, inverse_of: :organization_user_aliases, optional: false + belongs_to :user, inverse_of: :organization_user_aliases, optional: false + + validates :username, presence: true, uniqueness: { scope: :organization_id } + end +end diff --git a/app/models/user.rb b/app/models/user.rb index a2946b4e9161ad5cadb81254c381939540f98b36..5ef20fb464d329878e41f01bb6b2b5994d65d308 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -290,6 +290,7 @@ def update_tracked_fields!(request) belongs_to :created_by, class_name: 'User', optional: true has_many :organization_users, class_name: 'Organizations::OrganizationUser', inverse_of: :user + has_many :organization_user_aliases, class_name: 'Organizations::OrganizationUserAlias', inverse_of: :user has_many :organizations, through: :organization_users, class_name: 'Organizations::Organization', inverse_of: :users, disable_joins: true diff --git a/db/docs/organization_user_aliases.yml b/db/docs/organization_user_aliases.yml new file mode 100644 index 0000000000000000000000000000000000000000..1cb14c9052b9a5309f3ba9824f62900febc8cac9 --- /dev/null +++ b/db/docs/organization_user_aliases.yml @@ -0,0 +1,13 @@ +--- +table_name: organization_user_aliases +classes: +- Organizations::OrganizationUserAlias +feature_categories: +- cell +description: | + Store aliases so that internally-managed bot users can be referenced inside an organization by a convenient alias rather than unique username +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/189052 +milestone: '18.0' +gitlab_schema: gitlab_main_cell +sharding_key: + organization_id: organizations diff --git a/db/migrate/20250423153813_create_organization_user_aliases.rb b/db/migrate/20250423153813_create_organization_user_aliases.rb new file mode 100644 index 0000000000000000000000000000000000000000..96b859a537e402cc840550a230f29498d4a99f48 --- /dev/null +++ b/db/migrate/20250423153813_create_organization_user_aliases.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class CreateOrganizationUserAliases < Gitlab::Database::Migration[2.2] + milestone '18.0' + + def change + create_table :organization_user_aliases do |t| # rubocop:disable Lint/RedundantCopDisableDirective, Migration/EnsureFactoryForTable -- factory file sometimes incorrectly detected by rubocop as organizations_organization_user_aliases + t.belongs_to :organization, null: false, index: false + t.belongs_to :user, null: false + + # rubocop:disable Migration/PreventStrings -- these columns' type should be the same as User#username and User#name + t.string :username, null: false + t.string :display_name + # rubocop:enable Migration/PreventStrings + + t.timestamps_with_timezone null: false + + t.index [:organization_id, :user_id], unique: true, + name: :unique_organization_user_alias_organization_id_user_id + t.index [:organization_id, :username], unique: true, + name: :unique_organization_user_alias_organization_id_username + end + end +end diff --git a/db/migrate/20250423215038_add_fk_organization_user_aliases_organizations.rb b/db/migrate/20250423215038_add_fk_organization_user_aliases_organizations.rb new file mode 100644 index 0000000000000000000000000000000000000000..f56f9502b69d484075fc28f3835c3ef934041acb --- /dev/null +++ b/db/migrate/20250423215038_add_fk_organization_user_aliases_organizations.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddFkOrganizationUserAliasesOrganizations < Gitlab::Database::Migration[2.2] + disable_ddl_transaction! + milestone '18.0' + + def up + add_concurrent_foreign_key :organization_user_aliases, :organizations, column: :organization_id, on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key :organization_user_aliases, column: :organization_id + end + end +end diff --git a/db/migrate/20250423215233_add_fk_organization_user_aliases_users.rb b/db/migrate/20250423215233_add_fk_organization_user_aliases_users.rb new file mode 100644 index 0000000000000000000000000000000000000000..8fed6dce847e4812501956e2485ed677e397cc44 --- /dev/null +++ b/db/migrate/20250423215233_add_fk_organization_user_aliases_users.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddFkOrganizationUserAliasesUsers < Gitlab::Database::Migration[2.2] + disable_ddl_transaction! + milestone '18.0' + + def up + add_concurrent_foreign_key :organization_user_aliases, :users, column: :user_id, on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key :organization_user_aliases, column: :user_id + end + end +end diff --git a/db/schema_migrations/20250423153813 b/db/schema_migrations/20250423153813 new file mode 100644 index 0000000000000000000000000000000000000000..eed77107849c42a62a08615316c393930a87542d --- /dev/null +++ b/db/schema_migrations/20250423153813 @@ -0,0 +1 @@ +bc8cab73c5dd80d0436f019c4ef2cd586281ae4363dcd4eebf4c8ec6ec973499 \ No newline at end of file diff --git a/db/schema_migrations/20250423215038 b/db/schema_migrations/20250423215038 new file mode 100644 index 0000000000000000000000000000000000000000..ae81ad87876a11a27695115616a4a53644724fa8 --- /dev/null +++ b/db/schema_migrations/20250423215038 @@ -0,0 +1 @@ +8102e65b833f63d0d6e05856bbbc7990ce9c075a8d8987140a2b1a0f46586bbf \ No newline at end of file diff --git a/db/schema_migrations/20250423215233 b/db/schema_migrations/20250423215233 new file mode 100644 index 0000000000000000000000000000000000000000..41a9480e0a97d4d27f9922ed5b137473f89e2b8b --- /dev/null +++ b/db/schema_migrations/20250423215233 @@ -0,0 +1 @@ +2be27e4ece5f7985a586862dbbf5ac5fd2fad57077fc5a4164ef4f43b538eb83 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index f6ad4b9e71cbdaff192477c807e09b22f76c9c9c..5ff55dd40f8d46429f087fe27c4c3a8b8366d230 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -18941,6 +18941,25 @@ CREATE TABLE organization_settings ( settings jsonb DEFAULT '{}'::jsonb NOT NULL ); +CREATE TABLE organization_user_aliases ( + id bigint NOT NULL, + organization_id bigint NOT NULL, + user_id bigint NOT NULL, + username character varying NOT NULL, + display_name character varying, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE organization_user_aliases_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE organization_user_aliases_id_seq OWNED BY organization_user_aliases.id; + CREATE TABLE organization_users ( id bigint NOT NULL, organization_id bigint NOT NULL, @@ -27735,6 +27754,8 @@ ALTER TABLE ONLY organization_cluster_agent_mappings ALTER COLUMN id SET DEFAULT ALTER TABLE ONLY organization_push_rules ALTER COLUMN id SET DEFAULT nextval('organization_push_rules_id_seq'::regclass); +ALTER TABLE ONLY organization_user_aliases ALTER COLUMN id SET DEFAULT nextval('organization_user_aliases_id_seq'::regclass); + ALTER TABLE ONLY organization_users ALTER COLUMN id SET DEFAULT nextval('organization_users_id_seq'::regclass); ALTER TABLE ONLY organizations ALTER COLUMN id SET DEFAULT nextval('organizations_id_seq'::regclass); @@ -30470,6 +30491,9 @@ ALTER TABLE ONLY organization_push_rules ALTER TABLE ONLY organization_settings ADD CONSTRAINT organization_settings_pkey PRIMARY KEY (organization_id); +ALTER TABLE ONLY organization_user_aliases + ADD CONSTRAINT organization_user_aliases_pkey PRIMARY KEY (id); + ALTER TABLE ONLY organization_users ADD CONSTRAINT organization_users_pkey PRIMARY KEY (id); @@ -36558,6 +36582,8 @@ CREATE UNIQUE INDEX index_ops_strategies_user_lists_on_strategy_id_and_user_list CREATE UNIQUE INDEX index_organization_push_rules_on_organization_id ON organization_push_rules USING btree (organization_id); +CREATE INDEX index_organization_user_aliases_on_user_id ON organization_user_aliases USING btree (user_id); + CREATE INDEX index_organization_users_on_org_id_access_level_user_id ON organization_users USING btree (organization_id, access_level, user_id); CREATE INDEX index_organization_users_on_organization_id_and_id ON organization_users USING btree (organization_id, id); @@ -38900,6 +38926,10 @@ CREATE INDEX unique_ml_model_versions_on_model_id_and_id ON ml_model_versions US CREATE UNIQUE INDEX unique_namespace_cluster_agent_mappings_for_agent_association ON namespace_cluster_agent_mappings USING btree (namespace_id, cluster_agent_id); +CREATE UNIQUE INDEX unique_organization_user_alias_organization_id_user_id ON organization_user_aliases USING btree (organization_id, user_id); + +CREATE UNIQUE INDEX unique_organization_user_alias_organization_id_username ON organization_user_aliases USING btree (organization_id, username); + CREATE UNIQUE INDEX unique_organizations_on_path_case_insensitive ON organizations USING btree (lower(path)); CREATE UNIQUE INDEX unique_packages_project_id_and_name_and_version_when_debian ON packages_packages USING btree (project_id, name, version) WHERE ((package_type = 9) AND (status <> 4)); @@ -42454,6 +42484,9 @@ ALTER TABLE ONLY packages_debian_file_metadata ALTER TABLE ONLY namespaces ADD CONSTRAINT fk_319256d87a FOREIGN KEY (file_template_project_id) REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE ONLY organization_user_aliases + ADD CONSTRAINT fk_31b4eb5ec5 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY snippet_repository_storage_moves ADD CONSTRAINT fk_321e6c6235 FOREIGN KEY (snippet_organization_id) REFERENCES organizations(id) ON DELETE CASCADE; @@ -44101,6 +44134,9 @@ ALTER TABLE ONLY user_project_callouts ALTER TABLE ONLY ml_model_metadata ADD CONSTRAINT fk_f68c7e109c FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY organization_user_aliases + ADD CONSTRAINT fk_f709137eb7 FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ALTER TABLE ONLY workspaces ADD CONSTRAINT fk_f78aeddc77 FOREIGN KEY (cluster_agent_id) REFERENCES cluster_agents(id) ON DELETE CASCADE; diff --git a/spec/factories/organizations/organization_user_aliases.rb b/spec/factories/organizations/organization_user_aliases.rb new file mode 100644 index 0000000000000000000000000000000000000000..9a9f46621f47055372c8f596403e43c1ba40f433 --- /dev/null +++ b/spec/factories/organizations/organization_user_aliases.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :organization_user_alias, class: 'Organizations::OrganizationUserAlias' do + user + organization + + sequence(:username) { |n| "user_alias_#{n}" } + display_name { username.humanize.titleize } + end +end diff --git a/spec/models/organizations/organization_user_alias_spec.rb b/spec/models/organizations/organization_user_alias_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0fdf905d76314b8b82c49815678692f9b3503467 --- /dev/null +++ b/spec/models/organizations/organization_user_alias_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Organizations::OrganizationUserAlias, type: :model, feature_category: :cell do + describe 'associations' do + it { is_expected.to belong_to(:organization).inverse_of(:organization_user_aliases).required } + it { is_expected.to belong_to(:user).inverse_of(:organization_user_aliases).required } + end + + describe 'validations' do + subject { build(:organization_user_alias) } + + it { is_expected.to validate_presence_of(:username) } + it { is_expected.to validate_uniqueness_of(:username).scoped_to(:organization_id) } + end +end