organizations 0.6.0 → 0.7.1

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 (66) 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/CHANGELOG.md +102 -0
  6. data/README.md +704 -33
  7. data/app/controllers/organizations/invitations_controller.rb +27 -22
  8. data/app/controllers/organizations/memberships_controller.rb +37 -30
  9. data/app/controllers/organizations/organizations_controller.rb +8 -8
  10. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  11. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  12. data/app/mailers/organizations/verification_mailer.rb +1 -1
  13. data/app/models/organizations/grant.rb +6 -0
  14. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  15. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  16. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  17. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  18. data/config/locales/en.yml +22 -0
  19. data/config/locales/es.yml +22 -0
  20. data/config/routes.rb +1 -2
  21. data/gemfiles/rails_7.2.gemfile +5 -1
  22. data/gemfiles/rails_8.1.gemfile +5 -1
  23. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  24. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  25. data/lib/generators/organizations/install/install_generator.rb +1 -0
  26. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  27. data/lib/generators/organizations/join/join_generator.rb +113 -0
  28. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  30. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  33. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  34. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  35. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  36. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  37. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  38. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  39. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  40. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  41. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  42. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  43. data/lib/generators/organizations/views/views_generator.rb +4 -6
  44. data/lib/organizations/account_deletion_plan.rb +118 -0
  45. data/lib/organizations/configuration.rb +152 -0
  46. data/lib/organizations/controller_helpers.rb +55 -15
  47. data/lib/organizations/grant_access.rb +75 -0
  48. data/lib/organizations/grant_holder.rb +152 -0
  49. data/lib/organizations/grantable.rb +28 -0
  50. data/lib/organizations/join_flow.rb +16 -12
  51. data/lib/organizations/models/concerns/has_organizations.rb +224 -9
  52. data/lib/organizations/models/domain.rb +283 -1
  53. data/lib/organizations/models/grant.rb +152 -0
  54. data/lib/organizations/models/invitation.rb +76 -7
  55. data/lib/organizations/models/join_request.rb +102 -43
  56. data/lib/organizations/models/membership.rb +87 -5
  57. data/lib/organizations/models/organization.rb +219 -48
  58. data/lib/organizations/organization_scoped.rb +16 -0
  59. data/lib/organizations/personal_owner_backfill.rb +122 -0
  60. data/lib/organizations/public_email_domains.rb +49 -0
  61. data/lib/organizations/resource.rb +11 -0
  62. data/lib/organizations/roles.rb +55 -0
  63. data/lib/organizations/version.rb +1 -1
  64. data/lib/organizations/view_helpers.rb +40 -9
  65. data/lib/organizations.rb +99 -4
  66. 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",
@@ -90,6 +105,13 @@ module Organizations
90
105
  inverse_of: :invited_by,
91
106
  dependent: :nullify
92
107
 
108
+ # Memberships those invitations became. Nullified on deletion like
109
+ # the invitations: the install migration gives invited_by_id a
110
+ # foreign key with no ON DELETE rule, so without this the database
111
+ # refuses to delete anyone whose invitation was accepted.
112
+ has_many :invited_organization_memberships, class_name: "Organizations::Membership",
113
+ foreign_key: :invited_by_id, inverse_of: :invited_by, dependent: :nullify
114
+
93
115
  define_verified_joining_associations
94
116
  end
95
117
 
@@ -156,7 +178,13 @@ module Organizations
156
178
  def setup_owner_deletion_guard
157
179
  # `memberships` uses `dependent: :destroy`; this guard must run first
158
180
  # so owner memberships still exist when we verify ownership.
181
+ # The method name is kept from earlier versions, so a host's
182
+ # skip_callback for it keeps working.
159
183
  before_destroy :prevent_deletion_while_owning_organizations, prepend: true
184
+ # Before sent_organization_invitations is nullified: grants that
185
+ # this user attached to pending invitations die with the account,
186
+ # so an orphaned invitation never hands out unchecked access.
187
+ before_destroy :drop_grants_of_sent_invitations, prepend: true
160
188
  end
161
189
  end
162
190
 
@@ -192,6 +220,31 @@ module Organizations
192
220
  end
193
221
  end
194
222
 
223
+ # Prepended to the user model (see has_organizations).
224
+ module PrependedMethods
225
+ # Destroy inside its own SAVEPOINT. The account-deletion guard can
226
+ # delete a personal organization before a later callback aborts the
227
+ # destroy. Inside a host transaction, Rails would only release (not
228
+ # roll back) a joined transaction on abort, and the organization
229
+ # would stay deleted while the user survives. The savepoint makes
230
+ # the abort roll back everything this destroy did.
231
+ def destroy
232
+ result = nil
233
+ self.class.transaction(requires_new: true) do
234
+ result = super
235
+ raise ActiveRecord::Rollback unless result
236
+ end
237
+ result
238
+ end
239
+
240
+ # nil, not an unknown-column error, before the upgrade migration ran.
241
+ def personal_organization
242
+ return nil unless Organizations::Organization.column_names.include?("personal_owner_id")
243
+
244
+ super
245
+ end
246
+ end
247
+
195
248
  # Instance methods included in the user model
196
249
  module InstanceMethods
197
250
  # === Current Organization Context ===
@@ -431,6 +484,7 @@ module Organizations
431
484
  #
432
485
  def create_organization!(name_or_attributes)
433
486
  attributes = name_or_attributes.is_a?(Hash) ? name_or_attributes : { name: name_or_attributes }
487
+ Organizations::Organization.ensure_personal_owner_is_owner!(attributes, self)
434
488
 
435
489
  # Check max organizations limit
436
490
  settings = self.class.organization_settings
@@ -480,14 +534,14 @@ module Organizations
480
534
  if membership.role.to_sym == :owner
481
535
  owner_count = org.memberships.where(role: "owner").count
482
536
  if owner_count == 1
483
- raise CannotLeaveAsLastOwner, Organizations.t(:"errors.cannot_leave_as_last_owner")
537
+ raise CannotLeaveAsLastOwner, Organizations.t(:"errors.cannot_leave_as_last_owner", organization: org)
484
538
  end
485
539
  end
486
540
 
487
541
  # Check require_organization setting
488
542
  settings = self.class.organization_settings
489
543
  if settings[:require_organization] && organizations.count == 1
490
- raise CannotLeaveLastOrganization, Organizations.t(:"errors.cannot_leave_last_organization")
544
+ raise CannotLeaveLastOrganization, Organizations.t(:"errors.cannot_leave_last_organization", organization: org)
491
545
  end
492
546
 
493
547
  membership.destroy!
@@ -519,9 +573,10 @@ module Organizations
519
573
  # @param email [String] Email address to invite
520
574
  # @param organization [Organizations::Organization] (optional, defaults to current)
521
575
  # @param role [Symbol] (optional, defaults to configured default)
576
+ # @param grants [Hash{resource => Array}, nil] see Organization#send_invite_to!
522
577
  # @return [Organizations::Invitation]
523
578
  # @raise [Organizations::NotAuthorized] if user doesn't have permission
524
- def send_organization_invite_to!(email, organization: nil, role: nil)
579
+ def send_organization_invite_to!(email, organization: nil, role: nil, grants: nil)
525
580
  org = organization || current_organization
526
581
 
527
582
  unless org
@@ -533,14 +588,14 @@ module Organizations
533
588
  user_role = role_in(org)
534
589
  unless user_role && Roles.has_permission?(user_role, :invite_members)
535
590
  raise Organizations::NotAuthorized.new(
536
- Organizations.t(:"errors.invite_not_authorized"),
591
+ Organizations.t(:"errors.invite_not_authorized", organization: org),
537
592
  permission: :invite_members,
538
593
  organization: org,
539
594
  user: self
540
595
  )
541
596
  end
542
597
 
543
- org.send_invite_to!(email, invited_by: self, role: role)
598
+ org.send_invite_to!(email, invited_by: self, role: role, grants: grants)
544
599
  end
545
600
 
546
601
  # === Verified Joining (v0.5.0) ===
@@ -565,7 +620,7 @@ module Organizations
565
620
  existing_membership = memberships.find_by(organization_id: organization.id)
566
621
  if existing_membership
567
622
  raise Organizations::JoinRequestAlreadyDecided,
568
- Organizations.t(:"errors.join_request_already_member")
623
+ Organizations.t(:"errors.join_request_already_member", organization: organization)
569
624
  end
570
625
 
571
626
  # SAVEPOINT: the RecordNotUnique rescue below must stay reachable
@@ -596,6 +651,81 @@ module Organizations
596
651
  organization_join_requests.pending.find_by(organization_id: organization.id)
597
652
  end
598
653
 
654
+ # === Personal organization (v0.7.0) ===
655
+
656
+ # Create this user's personal organization, or return it when it
657
+ # already exists. The organization, the owner membership and the
658
+ # personal link are written together. Safe to call again: it is the
659
+ # retry path when sign-up provisioning failed, and concurrent calls
660
+ # converge on one organization (unique index on personal_owner_id).
661
+ #
662
+ # @param name_or_attributes [String, Hash, nil] defaults to
663
+ # config.default_organization_name
664
+ # @return [Organizations::Organization]
665
+ # @raise [OrganizationLimitReached] when the user's limit is reached
666
+ def create_personal_organization!(name_or_attributes = nil)
667
+ ensure_personal_organizations_column!
668
+ existing = Organizations::Organization.find_by(personal_owner_id: id)
669
+ return remember_personal_organization(existing) if existing
670
+
671
+ attributes = name_or_attributes.is_a?(Hash) ? name_or_attributes.symbolize_keys : { name: name_or_attributes }
672
+ attributes[:name] ||= Organizations.configuration.resolve_default_organization_name(self)
673
+
674
+ # SAVEPOINT: the rescues below must stay reachable inside a host
675
+ # transaction on PostgreSQL.
676
+ organization = ActiveRecord::Base.transaction(requires_new: true) do
677
+ create_organization!(attributes.merge(personal_owner_id: id))
678
+ end
679
+ remember_personal_organization(organization)
680
+ rescue ActiveRecord::RecordNotUnique, Organizations::OrganizationLimitReached
681
+ # A concurrent call created it first: the unique index fired, or
682
+ # the winner's organization now counts against the limit. Any
683
+ # other failure re-raises, because then no personal organization
684
+ # exists. (When one exists, the call is idempotent by contract.)
685
+ winner = Organizations::Organization.find_by(personal_owner_id: id)
686
+ raise unless winner
687
+
688
+ remember_personal_organization(winner)
689
+ end
690
+
691
+ # === Domain policies (v0.7.0) ===
692
+
693
+ # Organizations to offer this user: a verified domain of theirs
694
+ # matches the user's confirmed account email, with join_policy
695
+ # "suggest" or "auto_join", and the user is not a member yet. Empty
696
+ # while the email is unconfirmed (it proves nothing yet) or
697
+ # config.trust_confirmed_account_email is off. Joining still goes
698
+ # through Organization#join_with_account_email! or JoinFlow.
699
+ # @return [ActiveRecord::Relation<Organizations::Organization>]
700
+ def suggested_organizations
701
+ organizations_with_domain_policy(%w[suggest auto_join])
702
+ .where.not(id: memberships.select(:organization_id))
703
+ end
704
+
705
+ # Join every organization whose verified domain has join_policy
706
+ # "auto_join" and matches the confirmed account email, through the
707
+ # membership gate (Organization#join_with_account_email!). Runs on
708
+ # its own after sign-up and after the email confirmation. A refusal
709
+ # (a veto, an email already claimed) skips that organization.
710
+ # Idempotent: it returns existing memberships too.
711
+ # @return [Array<Organizations::Membership>]
712
+ def join_organizations_by_domain!
713
+ organizations_with_domain_policy(%w[auto_join]).order(:id).filter_map do |organization|
714
+ memberships.find_by(organization_id: organization.id) || organization.join_with_account_email!(self)
715
+ rescue Organizations::Error, ActiveRecord::RecordInvalid => e
716
+ Callbacks.log_warn("[Organizations] Domain auto-join to organization #{organization.id} skipped: #{e.message}")
717
+ nil
718
+ end
719
+ end
720
+
721
+ # What deleting this user would do to their organizations. Changes
722
+ # nothing. See Organizations::AccountDeletionPlan.
723
+ # @param policy [Symbol] defaults to config.account_deletion_policy
724
+ # @return [Organizations::AccountDeletionPlan]
725
+ def account_deletion_plan(policy: Organizations.configuration.account_deletion_policy)
726
+ Organizations::AccountDeletionPlan.for(self, policy: policy)
727
+ end
728
+
599
729
  # Extension seam for personal organization creation.
600
730
  # Override this method in your User model to implement custom logic.
601
731
  #
@@ -632,18 +762,103 @@ module Organizations
632
762
  return unless should_create_personal_organization?
633
763
 
634
764
  org_name = Organizations.configuration.resolve_default_organization_name(self)
635
- create_organization!(org_name)
765
+ if record_personal_organization?
766
+ create_personal_organization!(org_name)
767
+ else
768
+ create_organization!(org_name)
769
+ end
636
770
  rescue StandardError => e
637
771
  # Log but don't fail user creation
638
772
  Callbacks.log_error("[Organizations] Failed to create personal organization: #{e.message}")
639
773
  end
640
774
 
775
+ # The account-deletion guard (config.account_deletion_policy). Runs
776
+ # inside the destroy transaction, before the dependent memberships
777
+ # are destroyed. It locks this user's row first, so a concurrent
778
+ # join or organization creation for this user waits, then locks the
779
+ # owned organizations, so a concurrent join or transfer in them
780
+ # waits too. It checks the complete owned set before it deletes
781
+ # anything; a blocker, or any failure, aborts the whole destroy and
782
+ # rolls back every organization deleted so far.
641
783
  def prevent_deletion_while_owning_organizations
642
- return unless memberships.where(role: "owner").exists?
784
+ lock_own_row_for_deletion
785
+ plan = Organizations::AccountDeletionPlan.for(self, lock: true)
786
+ unless plan.allowed?
787
+ errors.add(:base, Organizations.t(:"errors.cannot_delete_user_owns_organizations"))
788
+ throw(:abort)
789
+ end
790
+
791
+ plan.organizations_to_destroy.each { |organization| destroy_personal_organization_for_deletion(organization) }
792
+ memberships.reset if plan.organizations_to_destroy.any?
793
+ end
794
+
795
+ def drop_grants_of_sent_invitations
796
+ return unless Organizations::GrantHolder.grants_table?
797
+
798
+ Organizations::Grant.where(invitation_id: sent_organization_invitations.select(:id)).delete_all
799
+ end
800
+
801
+ def lock_own_row_for_deletion
802
+ self.class.unscoped.where(self.class.primary_key => id).lock.pick(self.class.primary_key)
803
+ end
643
804
 
644
- errors.add(:base, Organizations.t(:"errors.cannot_delete_user_owns_organizations"))
805
+ def destroy_personal_organization_for_deletion(organization)
806
+ ActiveRecord::Base.transaction(requires_new: true) { organization.destroy! }
807
+ rescue ActiveRecord::RecordNotDestroyed => e
808
+ reasons = e.record.errors.full_messages.presence || [e.message]
809
+ reasons.each { |reason| errors.add(:base, reason) }
645
810
  throw(:abort)
646
811
  end
812
+
813
+ # The flag is on. Before the upgrade migration ran, keep the old
814
+ # behaviour (an ordinary organization) and warn, so a sign-up never
815
+ # loses its organization.
816
+ def record_personal_organization?
817
+ return false unless Organizations.configuration.record_personal_organizations
818
+ return true if Organizations::Organization.column_names.include?("personal_owner_id")
819
+
820
+ Callbacks.log_warn("[Organizations] record_personal_organizations is on but the personal_owner_id " \
821
+ "column is missing. Run `rails g organizations:upgrade && rails db:migrate`.")
822
+ false
823
+ end
824
+
825
+ # The account email counts as proof of inbox control: it is
826
+ # confirmed, and config.trust_confirmed_account_email is on.
827
+ def proven_account_email?
828
+ Organizations.configuration.trust_confirmed_account_email &&
829
+ respond_to?(:confirmed_at) && confirmed_at.present? &&
830
+ respond_to?(:email) && email.present?
831
+ end
832
+
833
+ def organizations_with_domain_policy(policies)
834
+ return Organizations::Organization.none unless proven_account_email? && Organizations::Domain.authority_columns?
835
+
836
+ domains = Organizations::Domain.verified.joinable.with_join_policy(policies).matching_email(email)
837
+ Organizations::Organization.where(id: domains.select(:organization_id))
838
+ end
839
+
840
+ def join_organizations_by_domain_after_confirmation
841
+ return unless respond_to?(:confirmed_at) && saved_change_to_attribute?(:confirmed_at) && confirmed_at.present?
842
+ return unless Organizations::Domain.authority_columns?
843
+
844
+ join_organizations_by_domain!
845
+ rescue StandardError => e
846
+ # Never let a domain policy break a sign-up or a confirmation.
847
+ Callbacks.log_error("[Organizations] Domain auto-join failed: #{e.message}")
848
+ end
849
+
850
+ def remember_personal_organization(organization)
851
+ association(:personal_organization).target = organization
852
+ organization
853
+ end
854
+
855
+ def ensure_personal_organizations_column!
856
+ return if Organizations::Organization.column_names.include?("personal_owner_id")
857
+
858
+ raise Organizations::ConfigurationError,
859
+ "Personal organizations need the personal_owner_id column. " \
860
+ "Run `rails g organizations:upgrade && rails db:migrate`."
861
+ end
647
862
  end
648
863
  end
649
864
  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