organizations 0.6.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +7 -0
- data/.simplecov +17 -10
- data/CHANGELOG.md +93 -0
- data/README.md +687 -33
- data/app/controllers/organizations/invitations_controller.rb +27 -22
- data/app/controllers/organizations/memberships_controller.rb +37 -30
- data/app/controllers/organizations/organizations_controller.rb +8 -8
- data/app/controllers/organizations/public_invitations_controller.rb +1 -1
- data/app/mailers/organizations/invitation_mailer.rb +2 -2
- data/app/mailers/organizations/verification_mailer.rb +1 -1
- data/app/models/organizations/grant.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
- data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
- data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
- data/config/locales/en.yml +22 -0
- data/config/locales/es.yml +22 -0
- data/config/routes.rb +1 -2
- data/gemfiles/rails_7.2.gemfile +5 -1
- data/gemfiles/rails_8.1.gemfile +5 -1
- data/lib/generators/organizations/grants/grants_generator.rb +47 -0
- data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
- data/lib/generators/organizations/install/install_generator.rb +1 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
- data/lib/generators/organizations/join/join_generator.rb +113 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
- data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
- data/lib/generators/organizations/join/templates/routes.rb +19 -0
- data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
- data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
- data/lib/generators/organizations/views/views_generator.rb +4 -6
- data/lib/organizations/account_deletion_plan.rb +118 -0
- data/lib/organizations/configuration.rb +152 -0
- data/lib/organizations/controller_helpers.rb +55 -15
- data/lib/organizations/grant_access.rb +75 -0
- data/lib/organizations/grant_holder.rb +152 -0
- data/lib/organizations/grantable.rb +28 -0
- data/lib/organizations/join_flow.rb +16 -12
- data/lib/organizations/models/concerns/has_organizations.rb +217 -9
- data/lib/organizations/models/domain.rb +283 -1
- data/lib/organizations/models/grant.rb +152 -0
- data/lib/organizations/models/invitation.rb +76 -7
- data/lib/organizations/models/join_request.rb +102 -43
- data/lib/organizations/models/membership.rb +87 -5
- data/lib/organizations/models/organization.rb +219 -48
- data/lib/organizations/organization_scoped.rb +16 -0
- data/lib/organizations/personal_owner_backfill.rb +122 -0
- data/lib/organizations/public_email_domains.rb +49 -0
- data/lib/organizations/resource.rb +11 -0
- data/lib/organizations/roles.rb +55 -0
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +40 -9
- data/lib/organizations.rb +99 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|