-
Notifications
You must be signed in to change notification settings - Fork 5.7k
/
user.rb
2738 lines (2222 loc) · 94.1 KB
/
user.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# frozen_string_literal: true
require 'carrierwave/orm/activerecord'
class User < MainClusterwide::ApplicationRecord
extend Gitlab::ConfigHelper
include Gitlab::ConfigHelper
include Gitlab::SQL::Pattern
include AfterCommitQueue
include Avatarable
include Referable
include Sortable
include CaseSensitivity
include TokenAuthenticatable
include FeatureGate
include CreatedAtFilterable
include BulkMemberAccessLoad
include BlocksUnsafeSerialization
include WithUploads
include OptionallySearch
include FromUnion
include BatchDestroyDependentAssociations
include BatchNullifyDependentAssociations
include UpdateHighestRole
include HasUserType
include Gitlab::Auth::Otp::Fortinet
include Gitlab::Auth::Otp::DuoAuth
include RestrictedSignup
include StripAttribute
include EachBatch
include CrossDatabaseIgnoredTables
include UseSqlFunctionForPrimaryKeyLookups
# `ensure_namespace_correct` needs to be moved to an after_commit (?)
cross_database_ignore_tables %w[namespaces namespace_settings], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424279'
# `notification_settings_for` is called, and elsewhere `save` is then called.
cross_database_ignore_tables %w[notification_settings], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424284'
# Associations with dependent: option
cross_database_ignore_tables(
%w[namespaces projects project_authorizations issues merge_requests merge_requests issues issues merge_requests events],
url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424285',
on: :destroy
)
DEFAULT_NOTIFICATION_LEVEL = :participating
INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'
COUNT_CACHE_VALIDITY_PERIOD = 24.hours
OTP_SECRET_LENGTH = 32
OTP_SECRET_TTL = 2.minutes
MAX_USERNAME_LENGTH = 255
MIN_USERNAME_LENGTH = 2
MAX_LIMIT_FOR_ASSIGNEED_ISSUES_COUNT = 100
SECONDARY_EMAIL_ATTRIBUTES = [
:commit_email,
:notification_email,
:public_email
].freeze
FORBIDDEN_SEARCH_STATES = %w[blocked banned ldap_blocked].freeze
INCOMING_MAIL_TOKEN_PREFIX = 'glimt-'
FEED_TOKEN_PREFIX = 'glft-'
FIRST_GROUP_PATHS_LIMIT = 200
# lib/tasks/tokens.rake needs to be updated when changing mail and feed tokens
add_authentication_token_field :incoming_email_token, token_generator: -> { self.generate_incoming_mail_token } # rubocop:disable Gitlab/TokenWithoutPrefix -- wontfix: the prefix is in the generator
add_authentication_token_field :feed_token, format_with_prefix: :prefix_for_feed_token
# TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/439294
add_authentication_token_field :static_object_token, encrypted: :optional # rubocop:todo Gitlab/TokenWithoutPrefix -- https://gitlab.com/gitlab-org/gitlab/-/issues/439294
attribute :admin, default: false
attribute :external, default: -> { Gitlab::CurrentSettings.user_default_external }
attribute :can_create_group, default: -> { Gitlab::CurrentSettings.can_create_group }
attribute :private_profile, default: -> { Gitlab::CurrentSettings.user_defaults_to_private_profile }
attribute :can_create_team, default: false
attribute :hide_no_ssh_key, default: false
attribute :hide_no_password, default: false
attribute :project_view, default: :files
attribute :notified_of_own_activity, default: false
attribute :preferred_language, default: -> { Gitlab::CurrentSettings.default_preferred_language }
attribute :theme_id, default: -> { gitlab_config.default_theme }
attribute :color_scheme_id, default: -> { Gitlab::CurrentSettings.default_syntax_highlighting_theme }
attribute :color_mode_id, default: -> { Gitlab::ColorModes::APPLICATION_DEFAULT }
attr_encrypted :otp_secret,
key: Gitlab::Application.secrets.otp_key_base,
mode: :per_attribute_iv_and_salt,
insecure_mode: true,
algorithm: 'aes-256-cbc'
devise :two_factor_authenticatable,
otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base
devise :two_factor_backupable, otp_number_of_backup_codes: 10
devise :two_factor_backupable_pbkdf2
serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiveRecordSerialize
devise :lockable, :recoverable, :rememberable, :trackable,
:validatable, :omniauthable, :confirmable, :registerable
# Must be included after `devise`
include EncryptedUserPassword
include RecoverableByAnyEmail
include AdminChangedPasswordNotifier
# This module adds async behaviour to Devise emails
# and should be added after Devise modules are initialized.
include AsyncDeviseEmail
include ForcedEmailConfirmation
include RequireEmailVerification
MINIMUM_DAYS_CREATED = 7
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
# rubocop: disable CodeReuse/ServiceClass
def update_tracked_fields!(request)
return if Gitlab::Database.read_only?
update_tracked_fields(request)
Gitlab::ExclusiveLease.throttle(id) do
::Ability.forgetting(/admin/) do
Users::UpdateService.new(self, user: self).execute(validate: false)
end
end
end
# rubocop: enable CodeReuse/ServiceClass
attr_accessor :force_random_password
# Virtual attribute for authenticating by either username or email
attr_accessor :login
# Virtual attribute for impersonator
attr_accessor :impersonator
#
# Relations
#
# Namespace for personal projects
has_one :namespace,
-> { where(type: Namespaces::UserNamespace.sti_name) },
required: false,
dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent
foreign_key: :owner_id,
inverse_of: :owner,
autosave: true # rubocop:disable Cop/ActiveRecordDependent
# Profile
has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :expired_today_and_unnotified_keys, -> { expired_today_and_not_notified }, class_name: 'Key'
has_many :expiring_soon_and_unnotified_keys, -> { expiring_soon_and_not_notified }, class_name: 'Key'
has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :group_deploy_keys
has_many :gpg_keys
has_many :emails
has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :expiring_soon_and_unnotified_personal_access_tokens, -> { expiring_and_not_notified_without_impersonation }, class_name: 'PersonalAccessToken'
has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent
has_many :webauthn_registrations
has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :saved_replies, class_name: '::Users::SavedReply'
has_one :user_synced_attributes_metadata, autosave: true
has_one :aws_role, class_name: 'Aws::Role'
# Followers
has_many :followed_users, foreign_key: :follower_id, class_name: 'Users::UserFollowUser'
has_many :followees, through: :followed_users
has_many :following_users, foreign_key: :followee_id, class_name: 'Users::UserFollowUser'
has_many :followers, -> { active }, through: :following_users
# Namespaces
has_many :members
has_many :member_namespaces, through: :members
# Groups
has_many :group_members, -> { where(requested_at: nil).where("access_level >= ?", Gitlab::Access::GUEST) }, class_name: 'GroupMember'
has_many :groups, through: :group_members
has_many :groups_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :group_members, source: :group
has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group
has_many :maintainers_groups, -> { where(members: { access_level: Gitlab::Access::MAINTAINER }) }, through: :group_members, source: :group
has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group
has_many :owned_or_maintainers_groups,
-> { where(members: { access_level: [Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
through: :group_members,
source: :group
alias_attribute :masters_groups, :maintainers_groups
has_many :developer_maintainer_owned_groups,
-> { where(members: { access_level: [Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
through: :group_members,
source: :group
has_many :reporter_developer_maintainer_owned_groups,
-> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
through: :group_members,
source: :group
has_many :minimal_access_group_members, -> { where(access_level: [Gitlab::Access::MINIMAL_ACCESS]) }, class_name: 'GroupMember'
has_many :minimal_access_groups, through: :minimal_access_group_members, source: :group
# Projects
has_many :groups_projects, through: :groups, source: :projects
has_many :personal_projects, through: :namespace, source: :projects
has_many :project_members, -> { where(requested_at: nil) }
has_many :projects, through: :project_members
has_many :created_projects, foreign_key: :creator_id, class_name: 'Project', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :created_namespace_details, foreign_key: :creator_id, class_name: 'Namespace::Detail'
has_many :projects_with_active_memberships, -> { where(members: { state: ::Member::STATE_ACTIVE }) }, through: :project_members, source: :project
has_many :users_star_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :starred_projects, through: :users_star_projects, source: :project
has_many :project_authorizations, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :authorized_projects, through: :project_authorizations, source: :project
has_many :snippets, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :notes, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :issues, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :legacy_assigned_merge_requests, class_name: 'MergeRequest', dependent: :nullify, foreign_key: :assignee_id # rubocop:disable Cop/ActiveRecordDependent
has_many :merged_merge_requests, class_name: 'MergeRequest::Metrics', dependent: :nullify, foreign_key: :merged_by_id # rubocop:disable Cop/ActiveRecordDependent
has_many :closed_merge_requests, class_name: 'MergeRequest::Metrics', dependent: :nullify, foreign_key: :latest_closed_by_id # rubocop:disable Cop/ActiveRecordDependent
has_many :updated_merge_requests, class_name: 'MergeRequest', dependent: :nullify, foreign_key: :updated_by_id # rubocop:disable Cop/ActiveRecordDependent
has_many :updated_issues, class_name: 'Issue', dependent: :nullify, foreign_key: :updated_by_id # rubocop:disable Cop/ActiveRecordDependent
has_many :closed_issues, class_name: 'Issue', dependent: :nullify, foreign_key: :closed_by_id # rubocop:disable Cop/ActiveRecordDependent
has_many :merge_requests, dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :events, dependent: :delete_all, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :releases, dependent: :nullify, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :subscriptions, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :abuse_reports, dependent: :nullify, foreign_key: :user_id, inverse_of: :user # rubocop:disable Cop/ActiveRecordDependent
has_many :admin_abuse_report_assignees, class_name: "Admin::AbuseReportAssignee"
has_many :assigned_abuse_reports, class_name: "AbuseReport", through: :admin_abuse_report_assignees, source: :abuse_report
has_many :reported_abuse_reports, dependent: :nullify, foreign_key: :reporter_id, class_name: "AbuseReport", inverse_of: :reporter # rubocop:disable Cop/ActiveRecordDependent
has_many :resolved_abuse_reports, foreign_key: :resolved_by_id, class_name: "AbuseReport", inverse_of: :resolved_by
has_many :abuse_events, foreign_key: :user_id, class_name: 'AntiAbuse::Event', inverse_of: :user
has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :abuse_trust_scores, class_name: 'AntiAbuse::TrustScore', foreign_key: :user_id
has_many :builds, class_name: 'Ci::Build'
has_many :pipelines, class_name: 'Ci::Pipeline'
has_many :todos, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :authored_todos, class_name: 'Todo', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :notification_settings
has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :triggers, class_name: 'Ci::Trigger', foreign_key: :owner_id
has_many :audit_events, foreign_key: :author_id, inverse_of: :user
has_many :alert_assignees, class_name: '::AlertManagement::AlertAssignee', inverse_of: :assignee
has_many :issue_assignees, inverse_of: :assignee
has_many :merge_request_assignees, inverse_of: :assignee, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :merge_request_reviewers, inverse_of: :reviewer, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request
has_many :created_custom_emoji, class_name: 'CustomEmoji', inverse_of: :creator
has_many :bulk_imports
has_many :custom_attributes, class_name: 'UserCustomAttribute'
has_one :trusted_with_spam_attribute, -> { UserCustomAttribute.trusted_with_spam }, class_name: 'UserCustomAttribute'
has_many :callouts, class_name: 'Users::Callout'
has_many :group_callouts, class_name: 'Users::GroupCallout'
has_many :project_callouts, class_name: 'Users::ProjectCallout'
has_many :term_agreements
belongs_to :accepted_term, class_name: 'ApplicationSetting::Term'
has_many :organization_users, class_name: 'Organizations::OrganizationUser', inverse_of: :user
has_many :organizations, through: :organization_users, class_name: 'Organizations::Organization', inverse_of: :users,
disable_joins: true
has_many :owned_organizations, -> { where(organization_users: { access_level: Gitlab::Access::OWNER }) },
through: :organization_users, source: :organization, class_name: 'Organizations::Organization'
has_one :status, class_name: 'UserStatus'
has_one :user_preference
has_one :user_detail
has_one :user_highest_role
has_one :user_canonical_email
has_one :credit_card_validation, class_name: '::Users::CreditCardValidation'
has_one :phone_number_validation, class_name: '::Users::PhoneNumberValidation'
has_one :atlassian_identity, class_name: 'Atlassian::Identity'
has_one :banned_user, class_name: '::Users::BannedUser'
has_many :reviews, foreign_key: :author_id, inverse_of: :author
has_many :timelogs
has_many :resource_label_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :resource_state_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :issue_assignment_events, class_name: 'ResourceEvents::IssueAssignmentEvent', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :merge_request_assignment_events, class_name: 'ResourceEvents::MergeRequestAssignmentEvent', dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent
has_many :authored_events, class_name: 'Event', dependent: :destroy, foreign_key: :author_id # rubocop:disable Cop/ActiveRecordDependent
has_many :early_access_program_tracking_events, class_name: 'EarlyAccessProgram::TrackingEvent', inverse_of: :user
has_many :namespace_commit_emails, class_name: 'Users::NamespaceCommitEmail'
has_many :user_achievements, class_name: 'Achievements::UserAchievement', inverse_of: :user
has_many :awarded_user_achievements, class_name: 'Achievements::UserAchievement', foreign_key: 'awarded_by_user_id', inverse_of: :awarded_by_user
has_many :revoked_user_achievements, class_name: 'Achievements::UserAchievement', foreign_key: 'revoked_by_user_id', inverse_of: :revoked_by_user
has_many :achievements, through: :user_achievements, class_name: 'Achievements::Achievement', inverse_of: :users
has_many :vscode_settings, class_name: 'VsCode::Settings::VsCodeSetting', inverse_of: :user
has_many :requested_member_approvals, class_name: 'Members::MemberApproval', foreign_key: 'requested_by_id'
has_many :reviewed_member_approvals, class_name: 'Members::MemberApproval', foreign_key: 'reviewed_by_id'
has_many :broadcast_message_dismissals, class_name: 'Users::BroadcastMessageDismissal'
#
# Validations
#
# Note: devise :validatable above adds validations for :email and :password
validates :username,
presence: true,
exclusion: { in: Gitlab::PathRegex::TOP_LEVEL_ROUTES, message: N_('%{value} is a reserved name') }
validates :username, uniqueness: true, unless: :namespace
validates :name, presence: true, length: { maximum: 255 }
validates :first_name, length: { maximum: 127 }
validates :last_name, length: { maximum: 127 }
validates :email, confirmation: true, devise_email: true
validates :notification_email, devise_email: true, allow_blank: true
validates :public_email, uniqueness: true, devise_email: true, allow_blank: true
validates :commit_email, devise_email: true, allow_blank: true, unless: ->(user) { user.commit_email == Gitlab::PrivateCommitEmail::TOKEN }
validates :projects_limit,
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE }
validate :check_password_weakness, if: :encrypted_password_changed?
validates :namespace, presence: true, unless: :optional_namespace?
validate :namespace_move_dir_allowed, if: :username_changed?, unless: :new_record?
validate :unique_email, if: :email_changed?
validate :notification_email_verified, if: :notification_email_changed?
validate :public_email_verified, if: :public_email_changed?
validate :commit_email_verified, if: :commit_email_changed?
validate :email_allowed_by_restrictions?, if: ->(user) { user.new_record? ? !user.created_by_id : user.email_changed? }
validate :check_username_format, if: :username_changed?
validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids,
message: ->(*) { _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } } }
validates :color_mode_id, allow_nil: true, inclusion: { in: Gitlab::ColorModes.valid_ids,
message: ->(*) { _("%{placeholder} is not a valid color mode") % { placeholder: '%{value}' } } }
validates :color_scheme_id, allow_nil: true, inclusion: { in: Gitlab::ColorSchemes.valid_ids,
message: ->(*) { _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } } }
validates :hide_no_ssh_key, allow_nil: false, inclusion: { in: [true, false] }
validates :hide_no_password, allow_nil: false, inclusion: { in: [true, false] }
validates :notified_of_own_activity, allow_nil: false, inclusion: { in: [true, false] }
validates :project_view, presence: true
after_initialize :set_projects_limit
before_validation :sanitize_attrs
before_validation :ensure_namespace_correct
after_validation :set_username_errors
before_save :ensure_incoming_email_token
before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? }
before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) }
before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? }
before_save :ensure_namespace_correct # in case validation is skipped
after_update :username_changed_hook, if: :saved_change_to_username?
after_destroy :post_destroy_hook
after_destroy :remove_key_cache
after_save if: -> { (saved_change_to_email? || saved_change_to_confirmed_at?) && confirmed? } do
email_to_confirm = self.emails.find_by(email: self.email)
if email_to_confirm.present?
if skip_confirmation_period_expiry_check
email_to_confirm.force_confirm
else
email_to_confirm.confirm
end
else
ignore_cross_database_tables_if_factory_bot(%w[emails]) do
add_primary_email_to_emails!
end
end
end
after_commit(on: :update) do
update_invalid_gpg_signatures if previous_changes.key?('email')
end
after_create_commit :create_default_organization_user
after_update_commit :update_default_organization_user, if: -> { saved_change_to_admin }
# User's Layout preference
enum layout: { fixed: 0, fluid: 1 }
# User's Dashboard preference
enum dashboard: { projects: 0, stars: 1, your_activity: 10, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8, followed_user_activity: 9 }
# User's Project preference
enum project_view: { readme: 0, activity: 1, files: 2, wiki: 3 }
# User's role
enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true
delegate :notes_filter_for,
:set_notes_filter,
:first_day_of_week, :first_day_of_week=,
:timezone, :timezone=,
:time_display_relative, :time_display_relative=,
:time_display_format, :time_display_format=,
:show_whitespace_in_diffs, :show_whitespace_in_diffs=,
:view_diffs_file_by_file, :view_diffs_file_by_file=,
:pass_user_identities_to_ci_jwt, :pass_user_identities_to_ci_jwt=,
:tab_width, :tab_width=,
:sourcegraph_enabled, :sourcegraph_enabled=,
:gitpod_enabled, :gitpod_enabled=,
:use_web_ide_extension_marketplace, :use_web_ide_extension_marketplace=,
:extensions_marketplace_opt_in_status, :extensions_marketplace_opt_in_status=,
:organization_groups_projects_sort, :organization_groups_projects_sort=,
:organization_groups_projects_display, :organization_groups_projects_display=,
:extensions_marketplace_enabled, :extensions_marketplace_enabled=,
:setup_for_company, :setup_for_company=,
:project_shortcut_buttons, :project_shortcut_buttons=,
:keyboard_shortcuts_enabled, :keyboard_shortcuts_enabled=,
:render_whitespace_in_code, :render_whitespace_in_code=,
:markdown_surround_selection, :markdown_surround_selection=,
:markdown_automatic_lists, :markdown_automatic_lists=,
:diffs_deletion_color, :diffs_deletion_color=,
:diffs_addition_color, :diffs_addition_color=,
:use_new_navigation, :use_new_navigation=,
:pinned_nav_items, :pinned_nav_items=,
:achievements_enabled, :achievements_enabled=,
:enabled_following, :enabled_following=,
:home_organization, :home_organization_id, :home_organization_id=,
to: :user_preference
delegate :path, to: :namespace, allow_nil: true, prefix: true
delegate :job_title, :job_title=, to: :user_detail, allow_nil: true
delegate :bio, :bio=, to: :user_detail, allow_nil: true
delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true
delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true
delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true
delegate :registration_objective, :registration_objective=, to: :user_detail, allow_nil: true
delegate :mastodon, :mastodon=, to: :user_detail, allow_nil: true
delegate :linkedin, :linkedin=, to: :user_detail, allow_nil: true
delegate :twitter, :twitter=, to: :user_detail, allow_nil: true
delegate :skype, :skype=, to: :user_detail, allow_nil: true
delegate :website_url, :website_url=, to: :user_detail, allow_nil: true
delegate :location, :location=, to: :user_detail, allow_nil: true
delegate :organization, :organization=, to: :user_detail, allow_nil: true
delegate :discord, :discord=, to: :user_detail, allow_nil: true
delegate :email_reset_offered_at, :email_reset_offered_at=, to: :user_detail, allow_nil: true
delegate :project_authorizations_recalculated_at, :project_authorizations_recalculated_at=, to: :user_detail, allow_nil: true
accepts_nested_attributes_for :user_preference, update_only: true
accepts_nested_attributes_for :user_detail, update_only: true
accepts_nested_attributes_for :credit_card_validation, update_only: true, allow_destroy: true
state_machine :state, initial: :active do
# state_machine uses this method at class loading time to fetch the default
# value for the `state` column but in doing so it also evaluates all other
# columns default values which could trigger the recursive generation of
# ApplicationSetting records. We're setting it to `nil` here because we
# don't have a database default for the `state` column.
#
def owner_class_attribute_default; end
event :block do
transition active: :blocked
transition deactivated: :blocked
transition ldap_blocked: :blocked
transition blocked_pending_approval: :blocked
end
event :ldap_block do
transition active: :ldap_blocked
transition deactivated: :ldap_blocked
end
# aliasing system_block to set ldap_blocked statuses
# ldap_blocked is used for LDAP, SAML, and SCIM blocked users
# Issue for improving this naming:
# https://gitlab.com/gitlab-org/gitlab/-/issues/388487
event :system_block do
transition active: :ldap_blocked
transition deactivated: :ldap_blocked
end
event :activate do
transition deactivated: :active
transition blocked: :active
transition ldap_blocked: :active
transition blocked_pending_approval: :active
transition banned: :active
end
event :block_pending_approval do
transition active: :blocked_pending_approval
end
event :ban do
transition active: :banned
end
event :unban do
transition banned: :active
end
event :deactivate do
# Any additional changes to this event should be also
# reflected in app/workers/users/deactivate_dormant_users_worker.rb
transition active: :deactivated
end
state :blocked, :ldap_blocked, :blocked_pending_approval, :banned do
def blocked?
true
end
end
before_transition do
!Gitlab::Database.read_only?
end
# rubocop: disable CodeReuse/ServiceClass
after_transition any => :blocked do |user|
user.run_after_commit do
Ci::DropPipelinesAndDisableSchedulesForUserService.new.execute(
user,
reason: :user_blocked,
include_owned_projects_and_groups: false
)
end
end
after_transition any => :deactivated do |user|
next unless Gitlab::CurrentSettings.user_deactivation_emails_enabled
user.run_after_commit do
NotificationService.new.user_deactivated(user.name, user.notification_email_or_default)
end
end
after_transition active: :banned do |user|
user.create_banned_user
if Gitlab.com? # rubocop:disable Gitlab/AvoidGitlabInstanceChecks -- this is always necessary on GitLab.com
user.run_after_commit do
deep_clean_ci = user.custom_attributes.by_key(UserCustomAttribute::DEEP_CLEAN_CI_USAGE_WHEN_BANNED).exists?
Ci::DropPipelinesAndDisableSchedulesForUserService.new.execute(
user,
reason: :user_banned,
include_owned_projects_and_groups: deep_clean_ci
)
end
end
end
# rubocop: enable CodeReuse/ServiceClass
after_transition banned: :active do |user|
user.banned_user&.destroy
end
after_transition any => :active do |user|
user.class.temporary_ignore_cross_database_tables(
%w[projects], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424278'
) do
user.starred_projects.update_counters(star_count: 1)
end
end
after_transition active: any do |user|
user.class.temporary_ignore_cross_database_tables(
%w[projects], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424278'
) do
user.starred_projects.update_counters(star_count: -1)
end
end
end
# Scopes
scope :admins, -> { where(admin: true) }
scope :instance_access_request_approvers_to_be_notified, -> { admins.active.order_recent_sign_in.limit(INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) }
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :blocked_pending_approval, -> { with_states(:blocked_pending_approval) }
scope :banned, -> { with_states(:banned) }
scope :external, -> { where(external: true) }
scope :non_external, -> { where(external: false) }
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :active, -> { with_state(:active).non_internal }
scope :active_without_ghosts, -> { with_state(:active).without_ghosts }
scope :all_without_ghosts, -> { without_ghosts }
scope :deactivated, -> { with_state(:deactivated).non_internal }
scope :without_projects, -> do
joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id')
.where(project_authorizations: { user_id: nil })
.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045')
end
scope :by_username, ->(usernames) { iwhere(username: Array(usernames).map(&:to_s)) }
scope :by_name, ->(names) { iwhere(name: Array(names)) }
scope :by_login, ->(login) do
return none if login.blank?
login.include?('@') ? iwhere(email: login) : iwhere(username: login)
end
scope :by_user_email, ->(emails) { iwhere(email: Array(emails)) }
scope :by_emails, ->(emails) { joins(:emails).where(emails: { email: Array(emails).map(&:downcase) }) }
scope :for_todos, ->(todos) { where(id: todos.select(:user_id).distinct) }
scope :with_emails, -> { preload(:emails) }
scope :with_dashboard, ->(dashboard) { where(dashboard: dashboard) }
scope :with_public_profile, -> { where(private_profile: false) }
scope :with_personal_access_tokens_expired_today, -> do
where('EXISTS (?)', ::PersonalAccessToken
.select(1)
.where('personal_access_tokens.user_id = users.id')
.without_impersonation
.expired_today_and_not_notified
)
end
scope :with_ssh_key_expiring_soon, -> do
includes(:expiring_soon_and_unnotified_keys)
.where('EXISTS (?)', ::Key
.select(1)
.where('keys.user_id = users.id')
.expiring_soon_and_not_notified)
end
scope :with_personal_access_tokens_expiring_soon, -> do
includes(:expiring_soon_and_unnotified_personal_access_tokens)
end
scope :order_recent_sign_in, -> { reorder(arel_table[:current_sign_in_at].desc.nulls_last) }
scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) }
scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) }
scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) }
scope :ordered_by_id_desc, -> { reorder(arel_table[:id].desc) }
scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', Gitlab::CurrentSettings.deactivate_dormant_users_period.day.ago.to_date) }
scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) }
scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) }
scope :by_ids, ->(ids) { where(id: ids) }
scope :by_ids_or_usernames, ->(ids, usernames) { where(username: usernames).or(where(id: ids)) }
scope :without_forbidden_states, -> { where.not(state: FORBIDDEN_SEARCH_STATES) }
scope :trusted, -> do
where('EXISTS (?)', ::UserCustomAttribute
.select(1)
.where('user_id = users.id')
.trusted_with_spam)
end
# This scope to be used only for bot_users since for
# regular users this may lead to memory allocation issues
scope :with_personal_access_tokens_and_resources, -> do
includes(:personal_access_tokens)
.includes(:groups)
.includes(:projects)
end
scope :left_join_user_detail, -> { left_joins(:user_detail) }
scope :preload_user_detail, -> { preload(:user_detail) }
def self.supported_keyset_orderings
{
id: [:asc, :desc],
name: [:asc, :desc],
username: [:asc, :desc],
created_at: [:asc, :desc],
updated_at: [:asc, :desc]
}
end
strip_attributes! :name
def preferred_language
read_attribute('preferred_language').presence || Gitlab::CurrentSettings.default_preferred_language
end
def active_for_authentication?
return false unless super
check_ldap_if_ldap_blocked!
can?(:log_in)
end
# The messages for these keys are defined in `devise.en.yml`
def inactive_message
if blocked_pending_approval?
:blocked_pending_approval
elsif blocked?
:blocked
elsif internal?
:forbidden
else
super
end
end
def self.with_visible_profile(user)
return with_public_profile if user.nil?
if user.admin?
all
else
with_public_profile.or(where(id: user.id))
end
end
# Limits the users to those that have TODOs, optionally in the given state.
#
# user - The user to get the todos for.
#
# with_todos - If we should limit the result set to users that are the
# authors of todos.
#
# todo_state - An optional state to require the todos to be in.
def self.limit_to_todo_authors(user: nil, with_todos: false, todo_state: nil)
if user && with_todos
where(id: Todo.where(user: user, state: todo_state).select(:author_id))
else
all
end
end
# Returns a relation that optionally includes the given user.
#
# user_id - The ID of the user to include.
def self.union_with_user(user_id = nil)
if user_id.present?
# We use "unscoped" here so that any inner conditions are not repeated for
# the outer query, which would be redundant.
User.unscoped.from_union([all, User.unscoped.where(id: user_id)])
else
all
end
end
def self.with_two_factor
where(otp_required_for_login: true)
.or(where_exists(WebauthnRegistration.where(WebauthnRegistration.arel_table[:user_id].eq(arel_table[:id]))))
end
def self.without_two_factor
where
.missing(:webauthn_registrations)
.where(otp_required_for_login: false)
end
#
# Class methods
#
class << self
# Devise method overridden to allow support for dynamic password lengths
def password_length
Gitlab::CurrentSettings.minimum_password_length..Devise.password_length.max
end
# Generate a random password that conforms to the current password length settings
def random_password
Devise.friendly_token(password_length.max)
end
# Devise method overridden to allow sign in with email or username
def find_for_database_authentication(warden_conditions)
conditions = warden_conditions.dup
if login = conditions.delete(:login)
where(conditions).find_by("lower(username) = :value OR lower(email) = :value", value: login.downcase.strip)
else
find_by(conditions)
end
end
def sort_by_attribute(method)
order_method = method || 'id_desc'
case order_method.to_s
when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then order_oldest_sign_in
when 'last_activity_on_desc' then order_recent_last_activity
when 'last_activity_on_asc' then order_oldest_last_activity
else
order_by(order_method)
end
end
# Find a User by their primary email or any associated confirmed secondary email
def find_by_any_email(email, confirmed: false)
return unless email
by_any_email(email, confirmed: confirmed).take
end
# Returns a relation containing all found users by their primary email
# or any associated confirmed secondary email
#
# @param emails [String, Array<String>] email addresses to check
# @param confirmed [Boolean] Only return users where the primary email is confirmed
def by_any_email(emails, confirmed: false)
from_users = by_user_email(emails)
from_users = from_users.confirmed if confirmed
from_emails = by_emails(emails).merge(Email.confirmed)
from_emails = from_emails.confirmed if confirmed
items = [from_users, from_emails]
# TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/461885
# What about private commit emails with capitalized username, we'd never find them and
# since the private_commit_email derives from the username, it can
# be uppercase in parts. So we'll never find an existing user during the invite
# process by email if that is true as we are case sensitive in this case.
user_ids = Gitlab::PrivateCommitEmail.user_ids_for_emails(Array(emails).map(&:downcase))
items << where(id: user_ids) if user_ids.present?
from_union(items)
end
def find_by_private_commit_email(email)
user_id = Gitlab::PrivateCommitEmail.user_id_for_email(email)
find_by(id: user_id)
end
def filter_items(filter_name)
case filter_name
when 'admins'
admins
when 'blocked'
blocked
when 'blocked_pending_approval'
blocked_pending_approval
when 'banned'
banned
when 'two_factor_disabled'
without_two_factor
when 'two_factor_enabled'
with_two_factor
when 'wop'
without_projects
when 'external'
external
when 'deactivated'
deactivated
when "trusted"
trusted
when "active"
active_without_ghosts
else
all_without_ghosts
end
end
# Searches users matching the given query.
#
# This method uses ILIKE on PostgreSQL.
#
# query - The search query as a String
# with_private_emails - include private emails in search
# partial_email_search - only for admins to preserve email privacy. Only for self-managed instances.
#
# Returns an ActiveRecord::Relation.
def search(query, **options)
return none unless query.is_a?(String)
query = query&.delete_prefix('@')
return none if query.blank?
query = query.downcase
order = <<~SQL
CASE
WHEN LOWER(users.public_email) = :query THEN 0
WHEN LOWER(users.username) = :query THEN 1
WHEN LOWER(users.name) = :query THEN 2
ELSE 3
END
SQL
sanitized_order_sql = Arel.sql(sanitize_sql_array([order, { query: query }]))
use_minimum_char_limit = options[:use_minimum_char_limit]
scope =
if options[:with_private_emails]
with_primary_or_secondary_email(
query, use_minimum_char_limit: use_minimum_char_limit, partial_email_search: options[:partial_email_search]
)
else
with_public_email(query)
end
scope = scope.or(search_by_name_or_username(query, use_minimum_char_limit: use_minimum_char_limit))
order = Gitlab::Pagination::Keyset::Order.build(
[
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'users_match_priority',
order_expression: sanitized_order_sql.asc,
add_to_projections: true
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'users_name',
order_expression: arel_table[:name].asc,
add_to_projections: true,
nullable: :not_nullable
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'users_id',
order_expression: arel_table[:id].asc,
add_to_projections: true,
nullable: :not_nullable
)
])
scope.reorder(order)
end
# This should be kept in sync with the frontend filtering in
# https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L68 and
# https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L1053
def gfm_autocomplete_search(query)
where(
"REPLACE(users.name, ' ', '') ILIKE :pattern OR users.username ILIKE :pattern",
pattern: "%#{sanitize_sql_like(query)}%"
).order(
Arel.sql(sanitize_sql(
[
"CASE WHEN REPLACE(users.name, ' ', '') ILIKE :prefix_pattern OR users.username ILIKE :prefix_pattern THEN 1 ELSE 2 END",
{ prefix_pattern: "#{sanitize_sql_like(query)}%" }
]
)),
:username,
:id
)
end
# Limits the result set to users _not_ in the given query/list of IDs.
#
# users - The list of users to ignore. This can be an
# `ActiveRecord::Relation`, or an Array.
def where_not_in(users = nil)
users ? where.not(id: users) : all
end
def reorder_by_name
reorder(:name)
end
# searches user by given pattern
# it compares name and username fields with given pattern
# This method uses ILIKE on PostgreSQL.
def search_by_name_or_username(query, use_minimum_char_limit: nil)
use_minimum_char_limit = user_search_minimum_char_limit if use_minimum_char_limit.nil?
where(
fuzzy_arel_match(:name, query, use_minimum_char_limit: use_minimum_char_limit)
.or(fuzzy_arel_match(:username, query, use_minimum_char_limit: use_minimum_char_limit))
)
end
def with_public_email(email_address)
where(public_email: email_address)
end
def with_primary_or_secondary_email(query, use_minimum_char_limit: true, partial_email_search: false)
email_table = Email.arel_table
if partial_email_search
email_table_matched_by_email = Email.fuzzy_arel_match(:email, query, use_minimum_char_limit: use_minimum_char_limit)
matched_by_email = User.fuzzy_arel_match(:email, query, use_minimum_char_limit: use_minimum_char_limit)
else
email_table_matched_by_email = email_table[:email].eq(query)
matched_by_email = arel_table[:email].eq(query)
end
matched_by_email_user_id = email_table
.project(email_table[:user_id])
.where(email_table_matched_by_email)
.where(email_table[:confirmed_at].not_eq(nil))
.take(1) # at most 1 record as there is a unique constraint
where(
matched_by_email
.or(arel_table[:id].eq(matched_by_email_user_id))
)
end
# This method is overridden in JiHu.
# https://gitlab.com/gitlab-org/gitlab/-/issues/348509
def user_search_minimum_char_limit
true
end
def find_by_login(login)
by_login(login).take
end
def find_by_username(username)
by_username(username).take
end