diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index 0c995fc91b5335b303cf2a3400a0e3e5848f7a03..8625fde8d64fce60f33ee056577cb450197052cd 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -32,7 +32,8 @@ 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 + has_many :organization_user_aliases, inverse_of: :organization # deprecated + has_many :organization_user_details, 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_detail.rb b/app/models/organizations/organization_user_detail.rb new file mode 100644 index 0000000000000000000000000000000000000000..96862832a0327469f5e13fcd2392ed1d89650bbe --- /dev/null +++ b/app/models/organizations/organization_user_detail.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Organizations + class OrganizationUserDetail < ApplicationRecord + belongs_to :organization, inverse_of: :organization_user_details, optional: false + belongs_to :user, inverse_of: :organization_user_details, optional: false + + validates :username, presence: true, uniqueness: { scope: :organization_id } + validates :display_name, presence: true + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 50e4c068db7f447784b7bb7e7fe205e767e61292..b6f16e38b4bbc312b101732d679cf51ddbadf42e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -276,7 +276,8 @@ 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 :organization_user_aliases, class_name: 'Organizations::OrganizationUserAlias', inverse_of: :user # deprecated + has_many :organization_user_details, class_name: 'Organizations::OrganizationUserDetail', 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_details.yml b/db/docs/organization_user_details.yml new file mode 100644 index 0000000000000000000000000000000000000000..681181057aa2c5861c96f176c0b1c4c28ea9412d --- /dev/null +++ b/db/docs/organization_user_details.yml @@ -0,0 +1,13 @@ +--- +table_name: organization_user_details +classes: +- Organizations::OrganizationUserDetail +feature_categories: +- cell +description: | + Store organization-specific usernames and other user details. This allows users in organizations to be referenced by their organization-assigned handles (usernames) as opposed to their global, platform-wide usernames. +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/191575 +milestone: '18.1' +gitlab_schema: gitlab_main_cell +sharding_key: + organization_id: organizations diff --git a/db/migrate/20250515190013_add_organization_user_details.rb b/db/migrate/20250515190013_add_organization_user_details.rb new file mode 100644 index 0000000000000000000000000000000000000000..5223701ca6e384e4733a3822c135de405a64db08 --- /dev/null +++ b/db/migrate/20250515190013_add_organization_user_details.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddOrganizationUserDetails < Gitlab::Database::Migration[2.3] + milestone '18.1' + + def change + create_table :organization_user_details do |t| + t.belongs_to :organization, null: false, index: false + t.belongs_to :user, null: false + + t.text :username, null: false, limit: 510 + t.text :display_name, null: false, limit: 510 + + t.timestamps_with_timezone null: false + + t.index [:organization_id, :user_id], unique: true, + name: :unique_organization_user_details_organization_id_user_id + t.index [:organization_id, :username], unique: true, + name: :unique_organization_user_details_organization_id_username + t.index 'lower(username)' + end + end +end diff --git a/db/migrate/20250515190136_add_fk_organization_user_details_organizations.rb b/db/migrate/20250515190136_add_fk_organization_user_details_organizations.rb new file mode 100644 index 0000000000000000000000000000000000000000..f1d6643aca6926f10fcae5de5c8c07e3d99a17b7 --- /dev/null +++ b/db/migrate/20250515190136_add_fk_organization_user_details_organizations.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddFkOrganizationUserDetailsOrganizations < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + milestone '18.1' + + def up + add_concurrent_foreign_key :organization_user_details, :organizations, column: :organization_id, on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key :organization_user_details, column: :organization_id + end + end +end diff --git a/db/migrate/20250515190353_add_fk_organization_user_details_users.rb b/db/migrate/20250515190353_add_fk_organization_user_details_users.rb new file mode 100644 index 0000000000000000000000000000000000000000..53df51093df9caacdab02c6b31fd73ed6fc4478c --- /dev/null +++ b/db/migrate/20250515190353_add_fk_organization_user_details_users.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddFkOrganizationUserDetailsUsers < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + milestone '18.1' + + def up + add_concurrent_foreign_key :organization_user_details, :users, column: :user_id, on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key :organization_user_details, column: :user_id + end + end +end diff --git a/db/schema_migrations/20250515190013 b/db/schema_migrations/20250515190013 new file mode 100644 index 0000000000000000000000000000000000000000..f459cd28cd8b18938d49764b0c2143aeb7b3826a --- /dev/null +++ b/db/schema_migrations/20250515190013 @@ -0,0 +1 @@ +9d67ff936bcd2fda808ec22526afe619d39d1ad41d7a6421a0050e78f5c74457 \ No newline at end of file diff --git a/db/schema_migrations/20250515190136 b/db/schema_migrations/20250515190136 new file mode 100644 index 0000000000000000000000000000000000000000..759ae997dd274838e8dfce44869e4b1572faecce --- /dev/null +++ b/db/schema_migrations/20250515190136 @@ -0,0 +1 @@ +1da7143e770d4b4445f30b661b10aa8d3fb843eaa4766976985041fcdf71700a \ No newline at end of file diff --git a/db/schema_migrations/20250515190353 b/db/schema_migrations/20250515190353 new file mode 100644 index 0000000000000000000000000000000000000000..f6d023ad51a0e78a5b9356f7c204de3f2d6871e1 --- /dev/null +++ b/db/schema_migrations/20250515190353 @@ -0,0 +1 @@ +f81dbd3fb12060afecb19f95ea8cd76f6ec53497e897ad7a39bf3d162aa36f7b \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index cdbc01dc31926bbb1e4636b3b810b7d8a2c80599..16309b5d899ce37285377b07fb3e3b88211363e9 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -18814,6 +18814,27 @@ CREATE SEQUENCE organization_user_aliases_id_seq ALTER SEQUENCE organization_user_aliases_id_seq OWNED BY organization_user_aliases.id; +CREATE TABLE organization_user_details ( + id bigint NOT NULL, + organization_id bigint NOT NULL, + user_id bigint NOT NULL, + username text NOT NULL, + display_name text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT check_470dbccf9b CHECK ((char_length(display_name) <= 510)), + CONSTRAINT check_dc5e9cf6f2 CHECK ((char_length(username) <= 510)) +); + +CREATE SEQUENCE organization_user_details_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE organization_user_details_id_seq OWNED BY organization_user_details.id; + CREATE TABLE organization_users ( id bigint NOT NULL, organization_id bigint NOT NULL, @@ -27743,6 +27764,8 @@ ALTER TABLE ONLY organization_push_rules ALTER COLUMN id SET DEFAULT nextval('or ALTER TABLE ONLY organization_user_aliases ALTER COLUMN id SET DEFAULT nextval('organization_user_aliases_id_seq'::regclass); +ALTER TABLE ONLY organization_user_details ALTER COLUMN id SET DEFAULT nextval('organization_user_details_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); @@ -30466,6 +30489,9 @@ ALTER TABLE ONLY organization_settings ALTER TABLE ONLY organization_user_aliases ADD CONSTRAINT organization_user_aliases_pkey PRIMARY KEY (id); +ALTER TABLE ONLY organization_user_details + ADD CONSTRAINT organization_user_details_pkey PRIMARY KEY (id); + ALTER TABLE ONLY organization_users ADD CONSTRAINT organization_users_pkey PRIMARY KEY (id); @@ -36510,6 +36536,10 @@ CREATE UNIQUE INDEX index_organization_push_rules_on_organization_id ON organiza CREATE INDEX index_organization_user_aliases_on_user_id ON organization_user_aliases USING btree (user_id); +CREATE INDEX index_organization_user_details_on_lower_username ON organization_user_details USING btree (lower(username)); + +CREATE INDEX index_organization_user_details_on_user_id ON organization_user_details 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); @@ -38914,6 +38944,10 @@ CREATE UNIQUE INDEX unique_organization_user_alias_organization_id_user_id ON or CREATE UNIQUE INDEX unique_organization_user_alias_organization_id_username ON organization_user_aliases USING btree (organization_id, username); +CREATE UNIQUE INDEX unique_organization_user_details_organization_id_user_id ON organization_user_details USING btree (organization_id, user_id); + +CREATE UNIQUE INDEX unique_organization_user_details_organization_id_username ON organization_user_details 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)); @@ -42611,6 +42645,9 @@ ALTER TABLE ONLY todos ALTER TABLE ONLY merge_requests_approval_rules_projects ADD CONSTRAINT fk_451a9dfe93 FOREIGN KEY (approval_rule_id) REFERENCES merge_requests_approval_rules(id) ON DELETE CASCADE; +ALTER TABLE ONLY organization_user_details + ADD CONSTRAINT fk_4533918f8e FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY ai_settings ADD CONSTRAINT fk_4571bb0ccc FOREIGN KEY (duo_workflow_oauth_application_id) REFERENCES oauth_applications(id) ON DELETE SET NULL; @@ -42860,6 +42897,9 @@ ALTER TABLE ONLY ci_pipeline_chat_data ALTER TABLE ONLY cluster_agent_tokens ADD CONSTRAINT fk_64f741f626 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY organization_user_details + ADD CONSTRAINT fk_657140ae14 FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ALTER TABLE ONLY container_repository_states ADD CONSTRAINT fk_6591698505 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/spec/factories/organizations/organization_user_details.rb b/spec/factories/organizations/organization_user_details.rb new file mode 100644 index 0000000000000000000000000000000000000000..1e2377684ebe66ad863d9b7f4572cebc29e1f689 --- /dev/null +++ b/spec/factories/organizations/organization_user_details.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :organization_user_detail, class: 'Organizations::OrganizationUserDetail' do + user + organization + + sequence(:username) { |n| "user_alias_#{n}" } + display_name { username.humanize.titleize } + end +end diff --git a/spec/models/organizations/organization_user_detail_spec.rb b/spec/models/organizations/organization_user_detail_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..268f6009db94ec29f1c94c9d5df685e0de0b4c51 --- /dev/null +++ b/spec/models/organizations/organization_user_detail_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Organizations::OrganizationUserDetail, type: :model, feature_category: :cell do + describe 'associations' do + it { is_expected.to belong_to(:organization).inverse_of(:organization_user_details).required } + it { is_expected.to belong_to(:user).inverse_of(:organization_user_details).required } + end + + describe 'validations' do + subject { build(:organization_user_detail) } + + it { is_expected.to validate_presence_of(:username) } + it { is_expected.to validate_presence_of(:display_name) } + it { is_expected.to validate_uniqueness_of(:username).scoped_to(:organization_id) } + end +end