organizations 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/.simplecov +17 -10
  5. data/Appraisals +2 -0
  6. data/CHANGELOG.md +112 -0
  7. data/README.md +714 -32
  8. data/app/controllers/organizations/invitations_controller.rb +27 -22
  9. data/app/controllers/organizations/memberships_controller.rb +37 -30
  10. data/app/controllers/organizations/organizations_controller.rb +8 -8
  11. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  12. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  13. data/app/mailers/organizations/verification_mailer.rb +1 -1
  14. data/app/models/organizations/grant.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  19. data/config/locales/en.yml +22 -0
  20. data/config/locales/es.yml +22 -0
  21. data/config/routes.rb +1 -2
  22. data/gemfiles/rails_7.2.gemfile +6 -1
  23. data/gemfiles/rails_8.1.gemfile +5 -1
  24. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  25. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  26. data/lib/generators/organizations/install/install_generator.rb +1 -0
  27. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  28. data/lib/generators/organizations/join/join_generator.rb +113 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  30. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  33. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  34. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  35. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  36. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  37. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  38. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  39. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  40. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  41. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  42. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  43. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  44. data/lib/generators/organizations/views/views_generator.rb +4 -6
  45. data/lib/organizations/account_deletion_plan.rb +118 -0
  46. data/lib/organizations/configuration.rb +155 -2
  47. data/lib/organizations/controller_helpers.rb +55 -15
  48. data/lib/organizations/grant_access.rb +75 -0
  49. data/lib/organizations/grant_holder.rb +152 -0
  50. data/lib/organizations/grantable.rb +28 -0
  51. data/lib/organizations/join_flow.rb +16 -12
  52. data/lib/organizations/models/concerns/has_organizations.rb +217 -9
  53. data/lib/organizations/models/domain.rb +283 -1
  54. data/lib/organizations/models/grant.rb +152 -0
  55. data/lib/organizations/models/invitation.rb +79 -8
  56. data/lib/organizations/models/join_request.rb +102 -43
  57. data/lib/organizations/models/membership.rb +96 -15
  58. data/lib/organizations/models/organization.rb +219 -48
  59. data/lib/organizations/organization_scoped.rb +16 -0
  60. data/lib/organizations/personal_owner_backfill.rb +122 -0
  61. data/lib/organizations/public_email_domains.rb +49 -0
  62. data/lib/organizations/resource.rb +11 -0
  63. data/lib/organizations/roles.rb +198 -36
  64. data/lib/organizations/version.rb +1 -1
  65. data/lib/organizations/view_helpers.rb +40 -9
  66. data/lib/organizations.rb +99 -4
  67. metadata +22 -2
@@ -47,6 +47,9 @@ module Organizations
47
47
  def has_organizations(**options, &block)
48
48
  # Include instance methods
49
49
  include InstanceMethods
50
+ # Prepended, so a host override of destroy or of the association
51
+ # reader cannot drop these guards.
52
+ prepend PrependedMethods
50
53
 
51
54
  # Define associations
52
55
  define_organization_associations
@@ -59,6 +62,9 @@ module Organizations
59
62
 
60
63
  # Preserve owner integrity on user deletion
61
64
  setup_owner_deletion_guard
65
+
66
+ # Join auto_join domains once the account email is confirmed
67
+ after_commit :join_organizations_by_domain_after_confirmation, on: %i[create update]
62
68
  end
63
69
 
64
70
  private
@@ -83,6 +89,15 @@ module Organizations
83
89
  source: :organization,
84
90
  class_name: "Organizations::Organization"
85
91
 
92
+ # The user's personal organization (organizations.personal_owner_id).
93
+ # A lookup only: it never creates anything. See
94
+ # create_personal_organization!. Deleting the user is governed by
95
+ # config.account_deletion_policy, not by a dependent: option.
96
+ has_one :personal_organization,
97
+ class_name: "Organizations::Organization",
98
+ foreign_key: :personal_owner_id,
99
+ inverse_of: :personal_owner
100
+
86
101
  # Invitations sent by this user
87
102
  has_many :sent_organization_invitations,
88
103
  class_name: "Organizations::Invitation",
@@ -156,7 +171,13 @@ module Organizations
156
171
  def setup_owner_deletion_guard
157
172
  # `memberships` uses `dependent: :destroy`; this guard must run first
158
173
  # so owner memberships still exist when we verify ownership.
174
+ # The method name is kept from earlier versions, so a host's
175
+ # skip_callback for it keeps working.
159
176
  before_destroy :prevent_deletion_while_owning_organizations, prepend: true
177
+ # Before sent_organization_invitations is nullified: grants that
178
+ # this user attached to pending invitations die with the account,
179
+ # so an orphaned invitation never hands out unchecked access.
180
+ before_destroy :drop_grants_of_sent_invitations, prepend: true
160
181
  end
161
182
  end
162
183
 
@@ -192,6 +213,31 @@ module Organizations
192
213
  end
193
214
  end
194
215
 
216
+ # Prepended to the user model (see has_organizations).
217
+ module PrependedMethods
218
+ # Destroy inside its own SAVEPOINT. The account-deletion guard can
219
+ # delete a personal organization before a later callback aborts the
220
+ # destroy. Inside a host transaction, Rails would only release (not
221
+ # roll back) a joined transaction on abort, and the organization
222
+ # would stay deleted while the user survives. The savepoint makes
223
+ # the abort roll back everything this destroy did.
224
+ def destroy
225
+ result = nil
226
+ self.class.transaction(requires_new: true) do
227
+ result = super
228
+ raise ActiveRecord::Rollback unless result
229
+ end
230
+ result
231
+ end
232
+
233
+ # nil, not an unknown-column error, before the upgrade migration ran.
234
+ def personal_organization
235
+ return nil unless Organizations::Organization.column_names.include?("personal_owner_id")
236
+
237
+ super
238
+ end
239
+ end
240
+
195
241
  # Instance methods included in the user model
196
242
  module InstanceMethods
197
243
  # === Current Organization Context ===
@@ -431,6 +477,7 @@ module Organizations
431
477
  #
432
478
  def create_organization!(name_or_attributes)
433
479
  attributes = name_or_attributes.is_a?(Hash) ? name_or_attributes : { name: name_or_attributes }
480
+ Organizations::Organization.ensure_personal_owner_is_owner!(attributes, self)
434
481
 
435
482
  # Check max organizations limit
436
483
  settings = self.class.organization_settings
@@ -480,14 +527,14 @@ module Organizations
480
527
  if membership.role.to_sym == :owner
481
528
  owner_count = org.memberships.where(role: "owner").count
482
529
  if owner_count == 1
483
- raise CannotLeaveAsLastOwner, Organizations.t(:"errors.cannot_leave_as_last_owner")
530
+ raise CannotLeaveAsLastOwner, Organizations.t(:"errors.cannot_leave_as_last_owner", organization: org)
484
531
  end
485
532
  end
486
533
 
487
534
  # Check require_organization setting
488
535
  settings = self.class.organization_settings
489
536
  if settings[:require_organization] && organizations.count == 1
490
- raise CannotLeaveLastOrganization, Organizations.t(:"errors.cannot_leave_last_organization")
537
+ raise CannotLeaveLastOrganization, Organizations.t(:"errors.cannot_leave_last_organization", organization: org)
491
538
  end
492
539
 
493
540
  membership.destroy!
@@ -519,9 +566,10 @@ module Organizations
519
566
  # @param email [String] Email address to invite
520
567
  # @param organization [Organizations::Organization] (optional, defaults to current)
521
568
  # @param role [Symbol] (optional, defaults to configured default)
569
+ # @param grants [Hash{resource => Array}, nil] see Organization#send_invite_to!
522
570
  # @return [Organizations::Invitation]
523
571
  # @raise [Organizations::NotAuthorized] if user doesn't have permission
524
- def send_organization_invite_to!(email, organization: nil, role: nil)
572
+ def send_organization_invite_to!(email, organization: nil, role: nil, grants: nil)
525
573
  org = organization || current_organization
526
574
 
527
575
  unless org
@@ -533,14 +581,14 @@ module Organizations
533
581
  user_role = role_in(org)
534
582
  unless user_role && Roles.has_permission?(user_role, :invite_members)
535
583
  raise Organizations::NotAuthorized.new(
536
- Organizations.t(:"errors.invite_not_authorized"),
584
+ Organizations.t(:"errors.invite_not_authorized", organization: org),
537
585
  permission: :invite_members,
538
586
  organization: org,
539
587
  user: self
540
588
  )
541
589
  end
542
590
 
543
- org.send_invite_to!(email, invited_by: self, role: role)
591
+ org.send_invite_to!(email, invited_by: self, role: role, grants: grants)
544
592
  end
545
593
 
546
594
  # === Verified Joining (v0.5.0) ===
@@ -565,7 +613,7 @@ module Organizations
565
613
  existing_membership = memberships.find_by(organization_id: organization.id)
566
614
  if existing_membership
567
615
  raise Organizations::JoinRequestAlreadyDecided,
568
- Organizations.t(:"errors.join_request_already_member")
616
+ Organizations.t(:"errors.join_request_already_member", organization: organization)
569
617
  end
570
618
 
571
619
  # SAVEPOINT: the RecordNotUnique rescue below must stay reachable
@@ -596,6 +644,81 @@ module Organizations
596
644
  organization_join_requests.pending.find_by(organization_id: organization.id)
597
645
  end
598
646
 
647
+ # === Personal organization (v0.7.0) ===
648
+
649
+ # Create this user's personal organization, or return it when it
650
+ # already exists. The organization, the owner membership and the
651
+ # personal link are written together. Safe to call again: it is the
652
+ # retry path when sign-up provisioning failed, and concurrent calls
653
+ # converge on one organization (unique index on personal_owner_id).
654
+ #
655
+ # @param name_or_attributes [String, Hash, nil] defaults to
656
+ # config.default_organization_name
657
+ # @return [Organizations::Organization]
658
+ # @raise [OrganizationLimitReached] when the user's limit is reached
659
+ def create_personal_organization!(name_or_attributes = nil)
660
+ ensure_personal_organizations_column!
661
+ existing = Organizations::Organization.find_by(personal_owner_id: id)
662
+ return remember_personal_organization(existing) if existing
663
+
664
+ attributes = name_or_attributes.is_a?(Hash) ? name_or_attributes.symbolize_keys : { name: name_or_attributes }
665
+ attributes[:name] ||= Organizations.configuration.resolve_default_organization_name(self)
666
+
667
+ # SAVEPOINT: the rescues below must stay reachable inside a host
668
+ # transaction on PostgreSQL.
669
+ organization = ActiveRecord::Base.transaction(requires_new: true) do
670
+ create_organization!(attributes.merge(personal_owner_id: id))
671
+ end
672
+ remember_personal_organization(organization)
673
+ rescue ActiveRecord::RecordNotUnique, Organizations::OrganizationLimitReached
674
+ # A concurrent call created it first: the unique index fired, or
675
+ # the winner's organization now counts against the limit. Any
676
+ # other failure re-raises, because then no personal organization
677
+ # exists. (When one exists, the call is idempotent by contract.)
678
+ winner = Organizations::Organization.find_by(personal_owner_id: id)
679
+ raise unless winner
680
+
681
+ remember_personal_organization(winner)
682
+ end
683
+
684
+ # === Domain policies (v0.7.0) ===
685
+
686
+ # Organizations to offer this user: a verified domain of theirs
687
+ # matches the user's confirmed account email, with join_policy
688
+ # "suggest" or "auto_join", and the user is not a member yet. Empty
689
+ # while the email is unconfirmed (it proves nothing yet) or
690
+ # config.trust_confirmed_account_email is off. Joining still goes
691
+ # through Organization#join_with_account_email! or JoinFlow.
692
+ # @return [ActiveRecord::Relation<Organizations::Organization>]
693
+ def suggested_organizations
694
+ organizations_with_domain_policy(%w[suggest auto_join])
695
+ .where.not(id: memberships.select(:organization_id))
696
+ end
697
+
698
+ # Join every organization whose verified domain has join_policy
699
+ # "auto_join" and matches the confirmed account email, through the
700
+ # membership gate (Organization#join_with_account_email!). Runs on
701
+ # its own after sign-up and after the email confirmation. A refusal
702
+ # (a veto, an email already claimed) skips that organization.
703
+ # Idempotent: it returns existing memberships too.
704
+ # @return [Array<Organizations::Membership>]
705
+ def join_organizations_by_domain!
706
+ organizations_with_domain_policy(%w[auto_join]).order(:id).filter_map do |organization|
707
+ memberships.find_by(organization_id: organization.id) || organization.join_with_account_email!(self)
708
+ rescue Organizations::Error, ActiveRecord::RecordInvalid => e
709
+ Callbacks.log_warn("[Organizations] Domain auto-join to organization #{organization.id} skipped: #{e.message}")
710
+ nil
711
+ end
712
+ end
713
+
714
+ # What deleting this user would do to their organizations. Changes
715
+ # nothing. See Organizations::AccountDeletionPlan.
716
+ # @param policy [Symbol] defaults to config.account_deletion_policy
717
+ # @return [Organizations::AccountDeletionPlan]
718
+ def account_deletion_plan(policy: Organizations.configuration.account_deletion_policy)
719
+ Organizations::AccountDeletionPlan.for(self, policy: policy)
720
+ end
721
+
599
722
  # Extension seam for personal organization creation.
600
723
  # Override this method in your User model to implement custom logic.
601
724
  #
@@ -632,18 +755,103 @@ module Organizations
632
755
  return unless should_create_personal_organization?
633
756
 
634
757
  org_name = Organizations.configuration.resolve_default_organization_name(self)
635
- create_organization!(org_name)
758
+ if record_personal_organization?
759
+ create_personal_organization!(org_name)
760
+ else
761
+ create_organization!(org_name)
762
+ end
636
763
  rescue StandardError => e
637
764
  # Log but don't fail user creation
638
765
  Callbacks.log_error("[Organizations] Failed to create personal organization: #{e.message}")
639
766
  end
640
767
 
768
+ # The account-deletion guard (config.account_deletion_policy). Runs
769
+ # inside the destroy transaction, before the dependent memberships
770
+ # are destroyed. It locks this user's row first, so a concurrent
771
+ # join or organization creation for this user waits, then locks the
772
+ # owned organizations, so a concurrent join or transfer in them
773
+ # waits too. It checks the complete owned set before it deletes
774
+ # anything; a blocker, or any failure, aborts the whole destroy and
775
+ # rolls back every organization deleted so far.
641
776
  def prevent_deletion_while_owning_organizations
642
- return unless memberships.where(role: "owner").exists?
777
+ lock_own_row_for_deletion
778
+ plan = Organizations::AccountDeletionPlan.for(self, lock: true)
779
+ unless plan.allowed?
780
+ errors.add(:base, Organizations.t(:"errors.cannot_delete_user_owns_organizations"))
781
+ throw(:abort)
782
+ end
783
+
784
+ plan.organizations_to_destroy.each { |organization| destroy_personal_organization_for_deletion(organization) }
785
+ memberships.reset if plan.organizations_to_destroy.any?
786
+ end
643
787
 
644
- errors.add(:base, Organizations.t(:"errors.cannot_delete_user_owns_organizations"))
788
+ def drop_grants_of_sent_invitations
789
+ return unless Organizations::GrantHolder.grants_table?
790
+
791
+ Organizations::Grant.where(invitation_id: sent_organization_invitations.select(:id)).delete_all
792
+ end
793
+
794
+ def lock_own_row_for_deletion
795
+ self.class.unscoped.where(self.class.primary_key => id).lock.pick(self.class.primary_key)
796
+ end
797
+
798
+ def destroy_personal_organization_for_deletion(organization)
799
+ ActiveRecord::Base.transaction(requires_new: true) { organization.destroy! }
800
+ rescue ActiveRecord::RecordNotDestroyed => e
801
+ reasons = e.record.errors.full_messages.presence || [e.message]
802
+ reasons.each { |reason| errors.add(:base, reason) }
645
803
  throw(:abort)
646
804
  end
805
+
806
+ # The flag is on. Before the upgrade migration ran, keep the old
807
+ # behaviour (an ordinary organization) and warn, so a sign-up never
808
+ # loses its organization.
809
+ def record_personal_organization?
810
+ return false unless Organizations.configuration.record_personal_organizations
811
+ return true if Organizations::Organization.column_names.include?("personal_owner_id")
812
+
813
+ Callbacks.log_warn("[Organizations] record_personal_organizations is on but the personal_owner_id " \
814
+ "column is missing. Run `rails g organizations:upgrade && rails db:migrate`.")
815
+ false
816
+ end
817
+
818
+ # The account email counts as proof of inbox control: it is
819
+ # confirmed, and config.trust_confirmed_account_email is on.
820
+ def proven_account_email?
821
+ Organizations.configuration.trust_confirmed_account_email &&
822
+ respond_to?(:confirmed_at) && confirmed_at.present? &&
823
+ respond_to?(:email) && email.present?
824
+ end
825
+
826
+ def organizations_with_domain_policy(policies)
827
+ return Organizations::Organization.none unless proven_account_email? && Organizations::Domain.authority_columns?
828
+
829
+ domains = Organizations::Domain.verified.joinable.with_join_policy(policies).matching_email(email)
830
+ Organizations::Organization.where(id: domains.select(:organization_id))
831
+ end
832
+
833
+ def join_organizations_by_domain_after_confirmation
834
+ return unless respond_to?(:confirmed_at) && saved_change_to_attribute?(:confirmed_at) && confirmed_at.present?
835
+ return unless Organizations::Domain.authority_columns?
836
+
837
+ join_organizations_by_domain!
838
+ rescue StandardError => e
839
+ # Never let a domain policy break a sign-up or a confirmation.
840
+ Callbacks.log_error("[Organizations] Domain auto-join failed: #{e.message}")
841
+ end
842
+
843
+ def remember_personal_organization(organization)
844
+ association(:personal_organization).target = organization
845
+ organization
846
+ end
847
+
848
+ def ensure_personal_organizations_column!
849
+ return if Organizations::Organization.column_names.include?("personal_owner_id")
850
+
851
+ raise Organizations::ConfigurationError,
852
+ "Personal organizations need the personal_owner_id column. " \
853
+ "Run `rails g organizations:upgrade && rails db:migrate`."
854
+ end
647
855
  end
648
856
  end
649
857
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/security_utils"
4
+
3
5
  module Organizations
4
6
  # An email domain owned/claimed by an organization, used for verified joining.
5
7
  #
@@ -18,6 +20,22 @@ module Organizations
18
20
  # domain (see JoinRequest#approve!) — hosts use it for cohort tags like
19
21
  # { "member_kind" => "student" } without the gem interpreting the contents.
20
22
  #
23
+ # DOMAIN AUTHORITY (v0.7.0). An emailed code proves control of one inbox,
24
+ # never authority over the whole domain. Authority is a separate claim per
25
+ # organization: a DNS TXT record with an unpredictable token bound to this
26
+ # row (one organization, one exact domain), or an approval recorded by a
27
+ # platform operator who is not a member of the organization.
28
+ #
29
+ # domain.start_verification!
30
+ # domain.verification_record_name # => "_organizations-verification.acme.com"
31
+ # domain.verification_record_value # => "organizations-verification=…"
32
+ # domain.verify! # => true, or raises DomainVerificationFailed (#reason)
33
+ #
34
+ # `join_policy` decides what happens when someone with a confirmed account
35
+ # email at this domain signs up: "none" (default), "suggest" (listed in
36
+ # user.suggested_organizations) or "auto_join" (joined through the
37
+ # membership gate). The policy acts only while the domain is verified.
38
+ #
21
39
  # @example
22
40
  # org.add_domain!("inizio.com")
23
41
  # org.add_domain!("alumnos.urjc.es", membership_metadata: { member_kind: "student" })
@@ -26,20 +44,37 @@ module Organizations
26
44
  class Domain < ActiveRecord::Base
27
45
  self.table_name = "organizations_domains"
28
46
 
47
+ JOIN_POLICIES = %w[none suggest auto_join].freeze
48
+ VERIFICATION_METHODS = %w[dns_txt operator].freeze
49
+
29
50
  # === Associations ===
30
51
 
31
52
  belongs_to :organization,
32
53
  class_name: "Organizations::Organization",
33
54
  inverse_of: :domains
34
55
 
56
+ # The platform operator who recorded an approval (verification_method "operator")
57
+ belongs_to :verified_by,
58
+ class_name: Organizations.user_class_name,
59
+ optional: true
60
+
35
61
  # === Validations ===
36
62
 
37
63
  validates :domain, presence: true, uniqueness: { scope: :organization_id }
38
64
  validate :domain_shape
65
+ validate :domain_not_blocked, if: -> { new_record? || will_save_change_to_domain? }
66
+ validates :join_policy,
67
+ inclusion: { in: JOIN_POLICIES, message: ->(*) { Organizations.t(:"attributes.join_policy_invalid") } },
68
+ if: :authority_columns?
39
69
 
40
70
  # === Callbacks ===
41
71
 
42
72
  before_validation :normalize_domain
73
+ before_validation :normalize_join_policy, if: :authority_columns?
74
+ # A proof belongs to one claim: one organization and one exact domain.
75
+ before_save :reset_verification, if: lambda {
76
+ persisted? && authority_columns? && (will_save_change_to_domain? || will_save_change_to_organization_id?)
77
+ }
43
78
 
44
79
  # === Scopes ===
45
80
 
@@ -51,6 +86,49 @@ module Organizations
51
86
  extracted ? where(domain: extracted) : none
52
87
  }
53
88
 
89
+ # Domains whose control was proven and not revoked
90
+ scope :verified, -> { where.not(verified_at: nil).where(verification_revoked_at: nil) }
91
+
92
+ # Domains with one of these sign-up policies
93
+ scope :with_join_policy, ->(*policies) { where(join_policy: policies.flatten.map(&:to_s)) }
94
+
95
+ # Domains that may admit members right now: not revoked, not blocked
96
+ # (config.blocked_email_domains), and verified when
97
+ # config.require_verified_domains is on. Every domain-backed join path
98
+ # reads this scope, at the challenge and again at admission.
99
+ scope :joinable, lambda {
100
+ relation = all
101
+ if authority_columns?
102
+ relation = relation.where(verification_revoked_at: nil)
103
+ relation = relation.verified if Organizations.configuration.require_verified_domains
104
+ elsif Organizations.configuration.require_verified_domains
105
+ relation = none # fail closed before the upgrade migration ran
106
+ end
107
+ blocked = Organizations.configuration.normalized_blocked_email_domains
108
+ blocked.any? ? relation.where.not(domain: blocked) : relation
109
+ }
110
+
111
+ # Whether the domain-authority columns exist (rails g organizations:upgrade)
112
+ # @return [Boolean]
113
+ def self.authority_columns?
114
+ table_exists? && column_names.include?("verified_at")
115
+ end
116
+
117
+ # The built-in TXT lookup: Resolv, with config.domain_verification_timeout.
118
+ # @return [Proc] ->(record_name) { [String] }
119
+ def self.default_txt_resolver
120
+ lambda do |name|
121
+ require "resolv"
122
+ Resolv::DNS.open do |dns|
123
+ dns.timeouts = Organizations.configuration.domain_verification_timeout
124
+ # A trailing dot makes the name absolute: no resolver search list,
125
+ # so a wildcard record under a search domain cannot answer.
126
+ absolute = name.end_with?(".") ? name : "#{name}."
127
+ dns.getresources(absolute, Resolv::DNS::Resource::IN::TXT).map { |record| record.strings.join }
128
+ end
129
+ end
130
+ end
131
+
54
132
  # === Matching ===
55
133
 
56
134
  # Whether an email address belongs to this domain (exact match).
@@ -61,8 +139,212 @@ module Organizations
61
139
  extracted.present? && extracted == domain
62
140
  end
63
141
 
142
+ # === Domain authority ===
143
+
144
+ # @return [Boolean] control was proven and not revoked
145
+ def verified?
146
+ authority_columns? && verified_at.present? && verification_revoked_at.blank?
147
+ end
148
+
149
+ # @return [Boolean] the verification was revoked
150
+ def revoked?
151
+ authority_columns? && verification_revoked_at.present?
152
+ end
153
+
154
+ # @return [Boolean] whether config.blocked_email_domains lists this domain
155
+ def blocked?
156
+ Organizations.configuration.normalized_blocked_email_domains.include?(domain)
157
+ end
158
+
159
+ # @return [Boolean] the instance twin of the joinable scope
160
+ def joinable?
161
+ return false if revoked? || blocked?
162
+ return true unless Organizations.configuration.require_verified_domains
163
+
164
+ verified?
165
+ end
166
+
167
+ # The sign-up policy that acts now: the stored policy while the domain is
168
+ # verified and not blocked, :none otherwise.
169
+ # @return [Symbol] :none, :suggest or :auto_join
170
+ def effective_join_policy
171
+ verified? && !blocked? ? join_policy.to_sym : :none
172
+ end
173
+
174
+ # A copy (dup) of a claim carries no proof.
175
+ def initialize_dup(other)
176
+ super
177
+ reset_verification if authority_columns?
178
+ end
179
+
180
+ # Start (or restart) the DNS challenge: a new unpredictable token, and
181
+ # any earlier verification cleared. A revocation stays until a new proof
182
+ # succeeds. Publish verification_record_value as a TXT record at
183
+ # verification_record_name, then call verify!.
184
+ # @return [self]
185
+ def start_verification!
186
+ update!(
187
+ verification_token: SecureRandom.urlsafe_base64(32),
188
+ verification_started_at: Time.current,
189
+ verified_at: nil, verification_method: nil, verified_by_id: nil
190
+ )
191
+ self
192
+ end
193
+
194
+ # @return [String] where the TXT record goes, e.g. "_organizations-verification.acme.com"
195
+ def verification_record_name
196
+ "#{Organizations.configuration.domain_verification_record_prefix}.#{domain}"
197
+ end
198
+
199
+ # @return [String, nil] the exact TXT value, e.g. "organizations-verification=…"
200
+ def verification_record_value
201
+ return nil if verification_token.blank?
202
+
203
+ "#{Organizations.configuration.domain_verification_record_prefix.delete_prefix('_')}=#{verification_token}"
204
+ end
205
+
206
+ # @return [Boolean] the open challenge is past config.domain_verification_ttl
207
+ def verification_expired?
208
+ verification_started_at.blank? ||
209
+ verification_started_at + Organizations.configuration.domain_verification_ttl <= Time.current
210
+ end
211
+
212
+ # Check the TXT record and mark the domain verified. The DNS lookup runs
213
+ # outside any row lock; rate-limit the endpoint that calls this.
214
+ # @return [self]
215
+ # @raise [DomainVerificationFailed] reason :not_started, :expired,
216
+ # :record_not_found or :lookup_failed
217
+ def verify!
218
+ reload
219
+ raise DomainVerificationFailed, :not_started if verification_token.blank?
220
+ raise DomainVerificationFailed, :expired if verification_expired?
221
+
222
+ snapshot = challenge_snapshot
223
+ raise DomainVerificationFailed, :record_not_found unless txt_record_present?(verification_record_value)
224
+
225
+ with_lock do
226
+ # The claim changed during the lookup (new token, other domain or
227
+ # organization, a revocation) or the challenge expired: the lookup
228
+ # proves nothing about the claim as it is now.
229
+ raise DomainVerificationFailed, :changed if challenge_snapshot != snapshot
230
+ raise DomainVerificationFailed, :expired if verification_expired?
231
+
232
+ update!(verified_at: Time.current, verification_method: "dns_txt",
233
+ verified_by_id: nil, verification_revoked_at: nil)
234
+ end
235
+ self
236
+ end
237
+
238
+ # Record an approval by a trusted platform operator who checked the
239
+ # claim another way. The claiming organization cannot self-attest: an
240
+ # operator who is a member of it is refused. Authorizing the operator as
241
+ # platform staff is the host's job.
242
+ # @param operator [User]
243
+ # @param note [String, nil] audit provenance, kept in metadata["verification_note"]
244
+ # @return [self]
245
+ # @raise [Organizations::NotAuthorized] when the operator is a member of the organization
246
+ def verify_by_operator!(operator:, note: nil)
247
+ raise ArgumentError, "operator is required" unless operator
248
+
249
+ ActiveRecord::Base.transaction do
250
+ # The organization row lock serializes this check with joins that
251
+ # take it (the seat-limit gate pattern) and with ownership changes.
252
+ organization.lock!
253
+ lock!
254
+ if organization.memberships.exists?(user_id: operator.id)
255
+ raise Organizations::NotAuthorized.new(Organizations.t(:"errors.domain_operator_is_member", organization: organization),
256
+ organization: organization, user: operator)
257
+ end
258
+
259
+ update!(verified_at: Time.current, verification_method: "operator", verified_by_id: operator.id,
260
+ verification_revoked_at: nil, metadata: metadata_with("verification_note" => note))
261
+ end
262
+ self
263
+ end
264
+
265
+ # Revoke the verification. New domain-backed admissions stop (also
266
+ # pending ones); existing members stay. The verification stays on record
267
+ # for audit. Idempotent.
268
+ # @param reason [String, nil] kept in metadata["verification_revoked_reason"]
269
+ # @return [self]
270
+ def revoke_verification!(reason: nil)
271
+ return self if revoked?
272
+
273
+ update!(verification_revoked_at: Time.current, metadata: metadata_with("verification_revoked_reason" => reason))
274
+ self
275
+ end
276
+
277
+ # Re-check a DNS verification (run it on a schedule if you like). Revokes
278
+ # when the TXT record is gone; a lookup failure raises and changes
279
+ # nothing. An operator approval is not re-checked. Read verified? after.
280
+ # @return [self]
281
+ # @raise [DomainVerificationFailed] reason :lookup_failed
282
+ def recheck_verification!
283
+ reload
284
+ return self unless verified? && verification_method == "dns_txt"
285
+
286
+ snapshot = proof_snapshot
287
+ return self if txt_record_present?(verification_record_value)
288
+
289
+ with_lock do
290
+ # Revoke only the proof that was checked, never a newer one.
291
+ revoke_verification!(reason: "dns_record_removed") if proof_snapshot == snapshot
292
+ end
293
+ self
294
+ end
295
+
64
296
  private
65
297
 
298
+ def authority_columns?
299
+ self.class.authority_columns?
300
+ end
301
+
302
+ # metadata with these keys set, or removed when the value is nil. Other
303
+ # keys (the host's) stay exactly as they are.
304
+ def metadata_with(values)
305
+ values.each_with_object((metadata || {}).dup) do |(key, value), result|
306
+ value.nil? ? result.delete(key) : result[key] = value
307
+ end
308
+ end
309
+
310
+ def challenge_snapshot
311
+ [verification_token, domain, organization_id, verification_revoked_at]
312
+ end
313
+
314
+ def proof_snapshot
315
+ [verification_token, verified_at, verification_method, domain, organization_id]
316
+ end
317
+
318
+ def txt_record_present?(expected)
319
+ resolver = Organizations.configuration.domain_txt_resolver || self.class.default_txt_resolver
320
+ values = Array(resolver.call(verification_record_name)).map { |value| value.to_s.strip }
321
+ values.any? { |value| ActiveSupport::SecurityUtils.secure_compare(value, expected) }
322
+ rescue StandardError => e
323
+ # Resolv::ResolvError/ResolvTimeout, socket errors, or a host resolver's
324
+ # own error. Logged, so a broken resolver does not look like an outage.
325
+ Callbacks.log_warn("[Organizations] TXT lookup for #{verification_record_name} failed: #{e.class}: #{e.message}")
326
+ raise DomainVerificationFailed, :lookup_failed
327
+ end
328
+
329
+ def normalize_join_policy
330
+ self.join_policy = join_policy.to_s if join_policy.is_a?(Symbol)
331
+ end
332
+
333
+ def reset_verification
334
+ self.verification_token = nil
335
+ self.verification_started_at = nil
336
+ self.verified_at = nil
337
+ self.verification_method = nil
338
+ self.verified_by_id = nil
339
+ self.verification_revoked_at = nil
340
+ end
341
+
342
+ def domain_not_blocked
343
+ return if domain.blank? || !blocked?
344
+
345
+ errors.add(:domain, Organizations.t(:"attributes.domain_blocked", organization: organization))
346
+ end
347
+
66
348
  def normalize_domain
67
349
  return if domain.blank?
68
350
 
@@ -75,7 +357,7 @@ module Organizations
75
357
  return if domain.blank? # presence validation covers this
76
358
 
77
359
  unless domain.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\z/)
78
- errors.add(:domain, Organizations.t(:"attributes.domain_invalid"))
360
+ errors.add(:domain, Organizations.t(:"attributes.domain_invalid", organization: organization))
79
361
  end
80
362
  end
81
363
  end