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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +7 -0
- data/.simplecov +17 -10
- data/Appraisals +2 -0
- data/CHANGELOG.md +112 -0
- data/README.md +714 -32
- 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 +6 -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 +155 -2
- 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 +79 -8
- data/lib/organizations/models/join_request.rb +102 -43
- data/lib/organizations/models/membership.rb +96 -15
- 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 +198 -36
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +40 -9
- data/lib/organizations.rb +99 -4
- metadata +22 -2
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Access to one resource of the host app, with a list of capabilities. A
|
|
5
|
+
# grant belongs to a membership, or to an invitation until it is accepted.
|
|
6
|
+
#
|
|
7
|
+
# Grants narrow a membership: a member whose role ranks below
|
|
8
|
+
# config.full_resource_access_role sees only the resources granted to it,
|
|
9
|
+
# and on each one only the granted capabilities that its role also has.
|
|
10
|
+
# See Organizations::GrantHolder and Membership#can?.
|
|
11
|
+
#
|
|
12
|
+
# The resource is polymorphic (grantable_type, grantable_id). It can be
|
|
13
|
+
# an ActiveRecord record (`grant.grantable` loads it) or any object with an
|
|
14
|
+
# id, such as an Organizations::Resource (then only the type and id are
|
|
15
|
+
# stored, and `grantable` is not used).
|
|
16
|
+
class Grant < ActiveRecord::Base
|
|
17
|
+
self.table_name = "organizations_grants"
|
|
18
|
+
|
|
19
|
+
# === Associations ===
|
|
20
|
+
|
|
21
|
+
belongs_to :membership, class_name: "Organizations::Membership", inverse_of: :grants, optional: true
|
|
22
|
+
belongs_to :invitation, class_name: "Organizations::Invitation", inverse_of: :grants, optional: true
|
|
23
|
+
belongs_to :grantable, polymorphic: true, optional: true
|
|
24
|
+
belongs_to :granted_by, class_name: Organizations.user_class_name, optional: true
|
|
25
|
+
|
|
26
|
+
# === Validations ===
|
|
27
|
+
|
|
28
|
+
validates :grantable_type, :grantable_id, presence: true
|
|
29
|
+
validate :one_holder
|
|
30
|
+
validate :capabilities_listed
|
|
31
|
+
|
|
32
|
+
# === Callbacks ===
|
|
33
|
+
|
|
34
|
+
before_validation :normalize_capabilities
|
|
35
|
+
|
|
36
|
+
# === Scopes ===
|
|
37
|
+
|
|
38
|
+
# Grants on one resource (a record, any object with an id, or a Resource)
|
|
39
|
+
scope :for_resource, lambda { |resource|
|
|
40
|
+
type, id = Grant.resource_key(resource)
|
|
41
|
+
where(grantable_type: type, grantable_id: id)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Grants on resources of one type (a class or a type name)
|
|
45
|
+
scope :of_type, ->(type) { where(grantable_type: Grant.type_name(type)) }
|
|
46
|
+
|
|
47
|
+
# Grants held by memberships (accepted), not by pending invitations
|
|
48
|
+
scope :active, -> { where.not(membership_id: nil) }
|
|
49
|
+
|
|
50
|
+
# [type, id] for a resource. The id must fit the grantable_id column
|
|
51
|
+
# without loss ("42-a" never becomes 42).
|
|
52
|
+
# @param resource [ActiveRecord::Base, Organizations::Resource, #id]
|
|
53
|
+
# @return [Array(String, Object)]
|
|
54
|
+
# @raise [ArgumentError] for an object without an id, or an id that does not fit
|
|
55
|
+
def self.resource_key(resource)
|
|
56
|
+
type, id =
|
|
57
|
+
case resource
|
|
58
|
+
when Organizations::Resource then [resource.type.to_s, resource.id]
|
|
59
|
+
when ActiveRecord::Base then [resource.class.polymorphic_name, resource.id]
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError, "A grant needs a resource with an id, got #{resource.inspect}" unless resource.respond_to?(:id)
|
|
62
|
+
|
|
63
|
+
[resource.class.name, resource.id]
|
|
64
|
+
end
|
|
65
|
+
[type, lossless_id(id)]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @api private
|
|
69
|
+
# The id as the grantable_id column stores it, or ArgumentError when the
|
|
70
|
+
# column would change it.
|
|
71
|
+
def self.lossless_id(id)
|
|
72
|
+
cast = type_for_attribute("grantable_id").cast(id)
|
|
73
|
+
return cast if cast.to_s == id.to_s
|
|
74
|
+
|
|
75
|
+
raise ArgumentError, "The id #{id.inspect} does not fit the grantable_id column (#{columns_hash['grantable_id']&.type})"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @api private
|
|
79
|
+
# The organization a resource belongs to, or nil when it does not say:
|
|
80
|
+
# an object with organization_id, or an Organizations::Resource naming
|
|
81
|
+
# an ActiveRecord model with an organization_id column (one query).
|
|
82
|
+
def self.resource_organization_id(resource)
|
|
83
|
+
if resource.is_a?(Organizations::Resource)
|
|
84
|
+
model = resource.type.to_s.safe_constantize
|
|
85
|
+
return nil unless tenant_model?(model)
|
|
86
|
+
|
|
87
|
+
model.where(model.primary_key => resource.id).pick(:organization_id)
|
|
88
|
+
elsif resource.respond_to?(:organization_id)
|
|
89
|
+
resource.organization_id.presence
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The stored type name for a class or a name.
|
|
94
|
+
# @return [String]
|
|
95
|
+
def self.type_name(type)
|
|
96
|
+
type.respond_to?(:polymorphic_name) ? type.polymorphic_name : type.to_s
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @api private
|
|
100
|
+
def self.tenant_model?(model)
|
|
101
|
+
model.is_a?(Class) && model < ActiveRecord::Base && model.column_names.include?("organization_id")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Array<String>]
|
|
105
|
+
def capabilities
|
|
106
|
+
Array(super).map(&:to_s)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param capability [Symbol, String]
|
|
110
|
+
# @return [Boolean] whether this grant lists the capability
|
|
111
|
+
def allows?(capability)
|
|
112
|
+
capabilities.include?(capability.to_s)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The resource as an Organizations::Resource (no loading).
|
|
116
|
+
# @return [Organizations::Resource]
|
|
117
|
+
def resource
|
|
118
|
+
Organizations::Resource.new(grantable_type, grantable_id)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def normalize_capabilities
|
|
124
|
+
self.capabilities = Array(read_attribute(:capabilities)).map { |c| c.to_s.strip }.compact_blank.uniq
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def one_holder
|
|
128
|
+
return if membership_id.present? ^ invitation_id.present?
|
|
129
|
+
|
|
130
|
+
errors.add(:base, Organizations.t(:"attributes.grant_one_holder"))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def capabilities_listed
|
|
134
|
+
if capabilities.empty?
|
|
135
|
+
errors.add(:capabilities, Organizations.t(:"attributes.grant_capabilities_blank"))
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
allowed = Organizations.configuration.grant_capabilities
|
|
140
|
+
return if allowed.nil?
|
|
141
|
+
|
|
142
|
+
unknown = capabilities - allowed.map(&:to_s)
|
|
143
|
+
return if unknown.empty?
|
|
144
|
+
|
|
145
|
+
errors.add(:capabilities,
|
|
146
|
+
Organizations.t(:"attributes.grant_capabilities_unknown", capabilities: unknown.join(", ")))
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Host extension seam — see the load-hooks note in models/organization.rb.
|
|
152
|
+
ActiveSupport.run_load_hooks(:organizations_grant, Organizations::Grant)
|
|
@@ -20,6 +20,10 @@ module Organizations
|
|
|
20
20
|
class Invitation < ActiveRecord::Base
|
|
21
21
|
self.table_name = "organizations_invitations"
|
|
22
22
|
|
|
23
|
+
# grant!, revoke_grant!, grant_for, granted?: grants the membership gets
|
|
24
|
+
# when this invitation is accepted
|
|
25
|
+
include Organizations::GrantHolder
|
|
26
|
+
|
|
23
27
|
# === Associations ===
|
|
24
28
|
|
|
25
29
|
belongs_to :organization,
|
|
@@ -34,11 +38,18 @@ module Organizations
|
|
|
34
38
|
# Alias for invited_by (semantic convenience as per README)
|
|
35
39
|
alias_method :from, :invited_by
|
|
36
40
|
|
|
41
|
+
# Grants that move to the membership on acceptance (scoped resource
|
|
42
|
+
# grants). No dependent: option, because the table is optional.
|
|
43
|
+
has_many :grants, class_name: "Organizations::Grant", inverse_of: :invitation
|
|
44
|
+
before_destroy :delete_grants
|
|
45
|
+
|
|
37
46
|
# === Validations ===
|
|
38
47
|
|
|
39
48
|
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
40
49
|
validates :token, presence: true, uniqueness: true
|
|
41
|
-
validates :role, presence: true
|
|
50
|
+
validates :role, presence: true
|
|
51
|
+
validates :role, inclusion: { in: ->(_) { Roles.valid_roles.map(&:to_s) } },
|
|
52
|
+
if: -> { new_record? || will_save_change_to_role? }
|
|
42
53
|
|
|
43
54
|
# Validate only one non-accepted invitation per email per organization
|
|
44
55
|
# This matches the DB constraint (accepted_at IS NULL), not expiry status
|
|
@@ -131,7 +142,7 @@ module Organizations
|
|
|
131
142
|
# Validate email matches at model level (security)
|
|
132
143
|
unless skip_email_validation
|
|
133
144
|
if accepting_user.respond_to?(:email) && !for_email?(accepting_user.email)
|
|
134
|
-
raise EmailMismatch, Organizations.t(:"errors.invitation_email_mismatch")
|
|
145
|
+
raise EmailMismatch, Organizations.t(:"errors.invitation_email_mismatch", organization: organization)
|
|
135
146
|
end
|
|
136
147
|
end
|
|
137
148
|
|
|
@@ -148,27 +159,29 @@ module Organizations
|
|
|
148
159
|
existing_membership = organization.memberships.find_by(user_id: accepting_user.id)
|
|
149
160
|
return existing_membership if existing_membership
|
|
150
161
|
|
|
151
|
-
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted")
|
|
162
|
+
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted", organization: organization)
|
|
152
163
|
end
|
|
153
164
|
|
|
154
165
|
if expired?
|
|
155
|
-
raise InvitationExpired, Organizations.t(:"errors.invitation_expired")
|
|
166
|
+
raise InvitationExpired, Organizations.t(:"errors.invitation_expired", organization: organization)
|
|
156
167
|
end
|
|
157
168
|
|
|
158
169
|
# Owner role cannot be assigned via invitation (defense in depth)
|
|
159
170
|
if role.to_sym == :owner
|
|
160
|
-
raise CannotAcceptAsOwner, Organizations.t(:"errors.invitation_accept_as_owner")
|
|
171
|
+
raise CannotAcceptAsOwner, Organizations.t(:"errors.invitation_accept_as_owner", organization: organization)
|
|
161
172
|
end
|
|
162
173
|
|
|
163
174
|
# Check if user is already a member (race condition from another invitation)
|
|
164
175
|
existing_membership = organization.memberships.find_by(user_id: accepting_user.id)
|
|
165
176
|
if existing_membership
|
|
177
|
+
move_grants_to!(existing_membership)
|
|
166
178
|
update!(accepted_at: Time.current)
|
|
167
179
|
return existing_membership
|
|
168
180
|
end
|
|
169
181
|
|
|
170
182
|
# Create the membership (with verified-joining provenance)
|
|
171
183
|
membership = create_membership_for!(accepting_user, skip_email_validation)
|
|
184
|
+
move_grants_to!(membership)
|
|
172
185
|
|
|
173
186
|
# Mark invitation as accepted
|
|
174
187
|
update!(accepted_at: Time.current)
|
|
@@ -186,15 +199,21 @@ module Organizations
|
|
|
186
199
|
|
|
187
200
|
# Resend the invitation email
|
|
188
201
|
# Generates new token and resets expiry
|
|
202
|
+
# @param resent_by [User, nil] Who is resending it. When that account is a
|
|
203
|
+
# member, the invitation's role must rank at or below its own role.
|
|
189
204
|
# @return [self]
|
|
190
|
-
|
|
205
|
+
# @raise [InvitationAlreadyAccepted] if already accepted
|
|
206
|
+
# @raise [Organizations::NotAuthorized] if the role ranks above resent_by's own
|
|
207
|
+
def resend!(resent_by: nil)
|
|
191
208
|
ActiveRecord::Base.transaction do
|
|
192
209
|
lock!
|
|
193
210
|
|
|
194
211
|
if accepted?
|
|
195
|
-
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_cannot_resend_accepted")
|
|
212
|
+
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_cannot_resend_accepted", organization: organization)
|
|
196
213
|
end
|
|
197
214
|
|
|
215
|
+
authorize_management!(resent_by)
|
|
216
|
+
|
|
198
217
|
update!(
|
|
199
218
|
token: generate_unique_token,
|
|
200
219
|
expires_at: calculate_expiry
|
|
@@ -205,6 +224,19 @@ module Organizations
|
|
|
205
224
|
self
|
|
206
225
|
end
|
|
207
226
|
|
|
227
|
+
# Revoke (delete) the invitation, so its link stops working
|
|
228
|
+
# @param revoked_by [User, nil] Who is revoking it. When that account is a
|
|
229
|
+
# member, the invitation's role must rank at or below its own role.
|
|
230
|
+
# @return [self]
|
|
231
|
+
# @raise [Organizations::NotAuthorized] if the role ranks above revoked_by's own
|
|
232
|
+
def revoke!(revoked_by: nil)
|
|
233
|
+
ActiveRecord::Base.transaction do
|
|
234
|
+
lock!
|
|
235
|
+
authorize_management!(revoked_by)
|
|
236
|
+
destroy!
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
208
240
|
# Get the acceptance URL
|
|
209
241
|
# @param base_url [String, nil] Base URL for the link
|
|
210
242
|
# @return [String]
|
|
@@ -225,6 +257,36 @@ module Organizations
|
|
|
225
257
|
|
|
226
258
|
private
|
|
227
259
|
|
|
260
|
+
# The invitation's grants become the membership's (a grant on the same
|
|
261
|
+
# resource takes the invitation's capabilities). The inviter was
|
|
262
|
+
# checked when the invitation was sent.
|
|
263
|
+
# Grants are written only while the invitation is pending, under its
|
|
264
|
+
# row lock (accept! takes the same lock).
|
|
265
|
+
def lock_for_grant_write!
|
|
266
|
+
return unless persisted?
|
|
267
|
+
|
|
268
|
+
lock!
|
|
269
|
+
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted", organization: organization) if accepted?
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Each grant is checked again, against the inviter as it is now and the
|
|
273
|
+
# membership that receives it (an existing member may hold a higher
|
|
274
|
+
# role than the invitation): a grant the inviter may no longer give, or
|
|
275
|
+
# whose capabilities config.grant_capabilities no longer allows, is
|
|
276
|
+
# dropped and logged. (Deleting the inviter deletes the grants of the
|
|
277
|
+
# invitations they sent, so a nil inviter here is a trusted actor.)
|
|
278
|
+
def move_grants_to!(membership)
|
|
279
|
+
return unless Organizations::GrantHolder.grants_table?
|
|
280
|
+
|
|
281
|
+
grants.each do |grant|
|
|
282
|
+
membership.authorize_grants!([[grant.resource, grant.capabilities]], invited_by)
|
|
283
|
+
membership.send(:upsert_grant!, grant.grantable_type, grant.grantable_id, grant.capabilities, invited_by)
|
|
284
|
+
rescue Organizations::NotAuthorized, ArgumentError, ActiveRecord::RecordInvalid => e
|
|
285
|
+
Callbacks.log_warn("[Organizations] Invitation grant on #{grant.grantable_type}##{grant.grantable_id} dropped: #{e.message}")
|
|
286
|
+
end
|
|
287
|
+
grants.delete_all(:delete_all)
|
|
288
|
+
end
|
|
289
|
+
|
|
228
290
|
# Create the membership for an acceptance.
|
|
229
291
|
# Uses invited_by_id instead of invited_by to avoid Rails class reloading
|
|
230
292
|
# issues (AssociationTypeMismatch when User is reloaded in development).
|
|
@@ -313,6 +375,15 @@ module Organizations
|
|
|
313
375
|
}
|
|
314
376
|
end
|
|
315
377
|
|
|
378
|
+
# A member may only revoke or resend invitations for roles that rank at or
|
|
379
|
+
# below its own, so a custom role that can invite members still cannot
|
|
380
|
+
# manage an admin's invitation (see Membership.authorize_rank!).
|
|
381
|
+
def authorize_management!(actor)
|
|
382
|
+
Membership.authorize_rank!(organization, actor, [role],
|
|
383
|
+
permission: :invite_members,
|
|
384
|
+
message_key: :"errors.invitation_management_outranks_actor")
|
|
385
|
+
end
|
|
386
|
+
|
|
316
387
|
def normalize_email
|
|
317
388
|
self.email = email.to_s.downcase.strip if email.present?
|
|
318
389
|
end
|
|
@@ -350,7 +421,7 @@ module Organizations
|
|
|
350
421
|
.exists?
|
|
351
422
|
|
|
352
423
|
if existing
|
|
353
|
-
errors.add(:email, Organizations.t(:"attributes.invitation_taken"))
|
|
424
|
+
errors.add(:email, Organizations.t(:"attributes.invitation_taken", organization: organization))
|
|
354
425
|
end
|
|
355
426
|
end
|
|
356
427
|
|
|
@@ -31,7 +31,7 @@ module Organizations
|
|
|
31
31
|
# The class carries the request's FULL lifecycle (statuses, challenge,
|
|
32
32
|
# decisions) exactly like its mirror Invitation does — splitting it would
|
|
33
33
|
# scatter one cohesive state machine across files.
|
|
34
|
-
# rubocop:disable Metrics/ClassLength
|
|
34
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
35
35
|
class JoinRequest < ActiveRecord::Base
|
|
36
36
|
self.table_name = "organizations_join_requests"
|
|
37
37
|
|
|
@@ -146,7 +146,7 @@ module Organizations
|
|
|
146
146
|
address = email.to_s.strip
|
|
147
147
|
|
|
148
148
|
unless address.match?(URI::MailTo::EMAIL_REGEXP)
|
|
149
|
-
raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_invalid")
|
|
149
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_invalid", organization: organization)
|
|
150
150
|
end
|
|
151
151
|
|
|
152
152
|
code = nil
|
|
@@ -183,6 +183,9 @@ module Organizations
|
|
|
183
183
|
lock!
|
|
184
184
|
ensure_open!
|
|
185
185
|
ensure_active_challenge!
|
|
186
|
+
# The domain or roster entry may have been removed, revoked or
|
|
187
|
+
# blocked since the code was sent: the code then proves nothing.
|
|
188
|
+
ensure_instruments_still_eligible!
|
|
186
189
|
|
|
187
190
|
if correct_code?(code)
|
|
188
191
|
# Burn the code: single-use by construction.
|
|
@@ -192,13 +195,15 @@ module Organizations
|
|
|
192
195
|
# raising here would roll the increment back and give attackers
|
|
193
196
|
# unlimited tries.
|
|
194
197
|
update!(verification_attempts: verification_attempts + 1)
|
|
195
|
-
failure = VerificationCodeInvalid.new(
|
|
198
|
+
failure = VerificationCodeInvalid.new(
|
|
199
|
+
Organizations.t(:"errors.verification_code_invalid", organization: organization)
|
|
200
|
+
)
|
|
196
201
|
end
|
|
197
202
|
end
|
|
198
203
|
|
|
199
204
|
raise failure if failure
|
|
200
205
|
|
|
201
|
-
return
|
|
206
|
+
return send(:auto_approve!) if auto_approvable_after_verification?
|
|
202
207
|
|
|
203
208
|
self
|
|
204
209
|
end
|
|
@@ -218,32 +223,22 @@ module Organizations
|
|
|
218
223
|
# @return [Membership]
|
|
219
224
|
# @raise [JoinRequestExpired, JoinRequestAlreadyDecided]
|
|
220
225
|
def approve!(decided_by: nil)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
ActiveRecord::Base.transaction do
|
|
225
|
-
lock!
|
|
226
|
-
|
|
227
|
-
return approved_membership! if approved? # idempotent re-approval
|
|
228
|
-
|
|
229
|
-
ensure_open!
|
|
230
|
-
|
|
231
|
-
existing = existing_membership
|
|
232
|
-
|
|
233
|
-
if existing
|
|
234
|
-
membership = existing
|
|
235
|
-
reused_existing = true
|
|
236
|
-
else
|
|
237
|
-
membership = create_membership!
|
|
238
|
-
claim_matched_allowlist_entry!
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
update!(status: "approved", decided_by_id: decided_by&.id, decided_at: Time.current)
|
|
242
|
-
end
|
|
226
|
+
perform_approval!(decided_by: decided_by, recheck_instruments: false)
|
|
227
|
+
end
|
|
243
228
|
|
|
244
|
-
|
|
245
|
-
|
|
229
|
+
# The automatic approval behind a verified email (verify_email_code! and
|
|
230
|
+
# Organization#join_with_account_email!). Like approve!, but it needs a
|
|
231
|
+
# verified email and a matched domain or roster entry, and checks again,
|
|
232
|
+
# under row locks, that the instrument still admits members. A manual
|
|
233
|
+
# approve! by an admin does not need that check: the admin decides.
|
|
234
|
+
# @return [Membership]
|
|
235
|
+
# @raise [VerificationEmailNotEligible] when the instrument is gone, revoked or blocked
|
|
236
|
+
def auto_approve!
|
|
237
|
+
perform_approval!(decided_by: nil, recheck_instruments: true)
|
|
246
238
|
end
|
|
239
|
+
# Callable from Organization#join_with_account_email! only. Hosts call
|
|
240
|
+
# approve! (a decision) or verify_email_code! (a proof).
|
|
241
|
+
private :auto_approve!
|
|
247
242
|
|
|
248
243
|
# Reject this request.
|
|
249
244
|
# @param rejected_by [User, nil]
|
|
@@ -324,9 +319,72 @@ module Organizations
|
|
|
324
319
|
|
|
325
320
|
private
|
|
326
321
|
|
|
322
|
+
def perform_approval!(decided_by:, recheck_instruments:)
|
|
323
|
+
membership = nil
|
|
324
|
+
reused_existing = false
|
|
325
|
+
|
|
326
|
+
ActiveRecord::Base.transaction do
|
|
327
|
+
lock!
|
|
328
|
+
|
|
329
|
+
return approved_membership! if approved? # idempotent re-approval
|
|
330
|
+
|
|
331
|
+
ensure_open!
|
|
332
|
+
ensure_instruments_still_eligible!(require_proof: true) if recheck_instruments
|
|
333
|
+
|
|
334
|
+
existing = existing_membership
|
|
335
|
+
|
|
336
|
+
if existing
|
|
337
|
+
membership = existing
|
|
338
|
+
reused_existing = true
|
|
339
|
+
else
|
|
340
|
+
membership = create_membership!
|
|
341
|
+
claim_matched_allowlist_entry!
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
update!(status: "approved", decided_by_id: decided_by&.id, decided_at: Time.current)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
dispatch_approval_callbacks(membership, decided_by, reused_existing)
|
|
348
|
+
membership
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The domain (or allowlist entry) that made the verification email
|
|
352
|
+
# eligible must still admit: present, matching the email, joinable
|
|
353
|
+
# (Domain.joinable), and for an entry, unclaimed by anyone else. The row
|
|
354
|
+
# is locked until the admission commits, so a revocation or removal
|
|
355
|
+
# either finishes first (and refuses) or waits.
|
|
356
|
+
#
|
|
357
|
+
# Called at the challenge check (verify_email_code!) before the proof
|
|
358
|
+
# exists, and again by auto_approve! after it, which also needs it.
|
|
359
|
+
def ensure_instruments_still_eligible!(require_proof: false)
|
|
360
|
+
return if verification_email.blank? && !require_proof
|
|
361
|
+
return if instruments_still_eligible?(require_proof)
|
|
362
|
+
|
|
363
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible", organization: organization)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def instruments_still_eligible?(require_proof)
|
|
367
|
+
return false if require_proof && (!email_verified? || verification_email.blank?)
|
|
368
|
+
|
|
369
|
+
domain_id = (metadata || {})["matched_domain_id"]
|
|
370
|
+
entry_id = (metadata || {})["matched_allowlist_entry_id"]
|
|
371
|
+
return !require_proof unless domain_id || entry_id
|
|
372
|
+
|
|
373
|
+
matched_instrument_scope(domain_id, entry_id).lock.pick(:id).present?
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def matched_instrument_scope(domain_id, entry_id)
|
|
377
|
+
if domain_id
|
|
378
|
+
organization.domains.joinable.matching_email(verification_email).where(id: domain_id)
|
|
379
|
+
else
|
|
380
|
+
organization.allowlist_entries.for_email(verification_email).where(id: entry_id)
|
|
381
|
+
.where("claimed_at IS NULL OR claimed_by_id = ?", user_id)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
327
385
|
# Raise unless this request is still open (pending and not expired)
|
|
328
386
|
def ensure_open!
|
|
329
|
-
raise JoinRequestExpired, Organizations.t(:"errors.join_request_expired") if expired?
|
|
387
|
+
raise JoinRequestExpired, Organizations.t(:"errors.join_request_expired", organization: organization) if expired?
|
|
330
388
|
raise JoinRequestAlreadyDecided, already_decided_message if decided?
|
|
331
389
|
end
|
|
332
390
|
|
|
@@ -340,8 +398,10 @@ module Organizations
|
|
|
340
398
|
# (organizations.join_request_status.*, lowercase for mid-sentence use)
|
|
341
399
|
# so the whole sentence localizes as one unit.
|
|
342
400
|
def already_decided_message
|
|
343
|
-
Organizations.t(:"errors.join_request_already_decided",
|
|
344
|
-
|
|
401
|
+
Organizations.t(:"errors.join_request_already_decided", organization: organization,
|
|
402
|
+
status: Organizations.t(:"join_request_status.#{status}",
|
|
403
|
+
organization: organization,
|
|
404
|
+
default: status))
|
|
345
405
|
end
|
|
346
406
|
|
|
347
407
|
def auto_approvable_after_verification?
|
|
@@ -358,7 +418,7 @@ module Organizations
|
|
|
358
418
|
# Raises if it was removed after approval — the request can't be reused.
|
|
359
419
|
def approved_membership!
|
|
360
420
|
membership = existing_membership
|
|
361
|
-
raise JoinRequestAlreadyDecided, Organizations.t(:"errors.join_request_membership_gone") unless membership
|
|
421
|
+
raise JoinRequestAlreadyDecided, Organizations.t(:"errors.join_request_membership_gone", organization: organization) unless membership
|
|
362
422
|
|
|
363
423
|
membership
|
|
364
424
|
end
|
|
@@ -392,11 +452,11 @@ module Organizations
|
|
|
392
452
|
# Domains win over allowlist entries (an org can have both).
|
|
393
453
|
# @return [Array(Domain|nil, AllowlistEntry|nil)]
|
|
394
454
|
def eligible_instruments_for!(address)
|
|
395
|
-
matched_domain = organization.domains.matching_email(address).first
|
|
455
|
+
matched_domain = organization.domains.joinable.matching_email(address).first
|
|
396
456
|
matched_entry = matched_domain ? nil : organization.allowlist_entries.unclaimed.for_email(address).first
|
|
397
457
|
|
|
398
458
|
unless matched_domain || matched_entry
|
|
399
|
-
raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible")
|
|
459
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible", organization: organization)
|
|
400
460
|
end
|
|
401
461
|
|
|
402
462
|
[matched_domain, matched_entry]
|
|
@@ -409,34 +469,34 @@ module Organizations
|
|
|
409
469
|
return unless Membership.where(organization_id: organization_id, verified_email_normalized: normalized).exists?
|
|
410
470
|
|
|
411
471
|
raise VerificationEmailAlreadyClaimed,
|
|
412
|
-
Organizations.t(:"errors.verification_email_already_claimed")
|
|
472
|
+
Organizations.t(:"errors.verification_email_already_claimed", organization: organization)
|
|
413
473
|
end
|
|
414
474
|
|
|
415
475
|
def ensure_send_allowed!
|
|
416
476
|
config = Organizations.configuration
|
|
417
477
|
|
|
418
478
|
if verification_sends_count >= config.verification_max_sends
|
|
419
|
-
raise VerificationThrottled, Organizations.t(:"errors.verification_sends_exceeded")
|
|
479
|
+
raise VerificationThrottled, Organizations.t(:"errors.verification_sends_exceeded", organization: organization)
|
|
420
480
|
end
|
|
421
481
|
|
|
422
482
|
resend_floor = interval_ago(config.verification_resend_interval)
|
|
423
483
|
return unless verification_sent_at.present? && verification_sent_at > resend_floor
|
|
424
484
|
|
|
425
|
-
raise VerificationThrottled, Organizations.t(:"errors.verification_resend_throttled")
|
|
485
|
+
raise VerificationThrottled, Organizations.t(:"errors.verification_resend_throttled", organization: organization)
|
|
426
486
|
end
|
|
427
487
|
|
|
428
488
|
def ensure_active_challenge!
|
|
429
489
|
config = Organizations.configuration
|
|
430
490
|
|
|
431
|
-
raise VerificationCodeInvalid, Organizations.t(:"errors.verification_code_missing") if verification_code_digest.blank?
|
|
491
|
+
raise VerificationCodeInvalid, Organizations.t(:"errors.verification_code_missing", organization: organization) if verification_code_digest.blank?
|
|
432
492
|
|
|
433
493
|
if verification_attempts >= config.verification_max_attempts
|
|
434
|
-
raise VerificationAttemptsExceeded, Organizations.t(:"errors.verification_attempts_exceeded")
|
|
494
|
+
raise VerificationAttemptsExceeded, Organizations.t(:"errors.verification_attempts_exceeded", organization: organization)
|
|
435
495
|
end
|
|
436
496
|
|
|
437
497
|
return unless verification_expires_at.blank? || verification_expires_at <= Time.current
|
|
438
498
|
|
|
439
|
-
raise VerificationCodeExpired, Organizations.t(:"errors.verification_code_expired")
|
|
499
|
+
raise VerificationCodeExpired, Organizations.t(:"errors.verification_code_expired", organization: organization)
|
|
440
500
|
end
|
|
441
501
|
|
|
442
502
|
def correct_code?(code)
|
|
@@ -487,7 +547,7 @@ module Organizations
|
|
|
487
547
|
return existing if existing
|
|
488
548
|
|
|
489
549
|
raise VerificationEmailAlreadyClaimed,
|
|
490
|
-
Organizations.t(:"errors.verification_email_already_claimed")
|
|
550
|
+
Organizations.t(:"errors.verification_email_already_claimed", organization: organization)
|
|
491
551
|
end
|
|
492
552
|
|
|
493
553
|
def insert_membership_row!(effective_joined_via)
|
|
@@ -617,10 +677,9 @@ module Organizations
|
|
|
617
677
|
.where.not(id: id)
|
|
618
678
|
.exists?
|
|
619
679
|
|
|
620
|
-
errors.add(:user_id, Organizations.t(:"attributes.pending_request_taken")) if existing
|
|
680
|
+
errors.add(:user_id, Organizations.t(:"attributes.pending_request_taken", organization: organization)) if existing
|
|
621
681
|
end
|
|
622
682
|
end
|
|
623
|
-
# rubocop:enable Metrics/ClassLength
|
|
624
683
|
end
|
|
625
684
|
|
|
626
685
|
# Host extension seam — see the load-hooks note in models/organization.rb.
|