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
|
@@ -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,6 +38,11 @@ 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 }
|
|
@@ -133,7 +142,7 @@ module Organizations
|
|
|
133
142
|
# Validate email matches at model level (security)
|
|
134
143
|
unless skip_email_validation
|
|
135
144
|
if accepting_user.respond_to?(:email) && !for_email?(accepting_user.email)
|
|
136
|
-
raise EmailMismatch, Organizations.t(:"errors.invitation_email_mismatch")
|
|
145
|
+
raise EmailMismatch, Organizations.t(:"errors.invitation_email_mismatch", organization: organization)
|
|
137
146
|
end
|
|
138
147
|
end
|
|
139
148
|
|
|
@@ -150,27 +159,29 @@ module Organizations
|
|
|
150
159
|
existing_membership = organization.memberships.find_by(user_id: accepting_user.id)
|
|
151
160
|
return existing_membership if existing_membership
|
|
152
161
|
|
|
153
|
-
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted")
|
|
162
|
+
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_already_accepted", organization: organization)
|
|
154
163
|
end
|
|
155
164
|
|
|
156
165
|
if expired?
|
|
157
|
-
raise InvitationExpired, Organizations.t(:"errors.invitation_expired")
|
|
166
|
+
raise InvitationExpired, Organizations.t(:"errors.invitation_expired", organization: organization)
|
|
158
167
|
end
|
|
159
168
|
|
|
160
169
|
# Owner role cannot be assigned via invitation (defense in depth)
|
|
161
170
|
if role.to_sym == :owner
|
|
162
|
-
raise CannotAcceptAsOwner, Organizations.t(:"errors.invitation_accept_as_owner")
|
|
171
|
+
raise CannotAcceptAsOwner, Organizations.t(:"errors.invitation_accept_as_owner", organization: organization)
|
|
163
172
|
end
|
|
164
173
|
|
|
165
174
|
# Check if user is already a member (race condition from another invitation)
|
|
166
175
|
existing_membership = organization.memberships.find_by(user_id: accepting_user.id)
|
|
167
176
|
if existing_membership
|
|
177
|
+
move_grants_to!(existing_membership)
|
|
168
178
|
update!(accepted_at: Time.current)
|
|
169
179
|
return existing_membership
|
|
170
180
|
end
|
|
171
181
|
|
|
172
182
|
# Create the membership (with verified-joining provenance)
|
|
173
183
|
membership = create_membership_for!(accepting_user, skip_email_validation)
|
|
184
|
+
move_grants_to!(membership)
|
|
174
185
|
|
|
175
186
|
# Mark invitation as accepted
|
|
176
187
|
update!(accepted_at: Time.current)
|
|
@@ -188,15 +199,21 @@ module Organizations
|
|
|
188
199
|
|
|
189
200
|
# Resend the invitation email
|
|
190
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.
|
|
191
204
|
# @return [self]
|
|
192
|
-
|
|
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)
|
|
193
208
|
ActiveRecord::Base.transaction do
|
|
194
209
|
lock!
|
|
195
210
|
|
|
196
211
|
if accepted?
|
|
197
|
-
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_cannot_resend_accepted")
|
|
212
|
+
raise InvitationAlreadyAccepted, Organizations.t(:"errors.invitation_cannot_resend_accepted", organization: organization)
|
|
198
213
|
end
|
|
199
214
|
|
|
215
|
+
authorize_management!(resent_by)
|
|
216
|
+
|
|
200
217
|
update!(
|
|
201
218
|
token: generate_unique_token,
|
|
202
219
|
expires_at: calculate_expiry
|
|
@@ -207,6 +224,19 @@ module Organizations
|
|
|
207
224
|
self
|
|
208
225
|
end
|
|
209
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
|
+
|
|
210
240
|
# Get the acceptance URL
|
|
211
241
|
# @param base_url [String, nil] Base URL for the link
|
|
212
242
|
# @return [String]
|
|
@@ -227,6 +257,36 @@ module Organizations
|
|
|
227
257
|
|
|
228
258
|
private
|
|
229
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
|
+
|
|
230
290
|
# Create the membership for an acceptance.
|
|
231
291
|
# Uses invited_by_id instead of invited_by to avoid Rails class reloading
|
|
232
292
|
# issues (AssociationTypeMismatch when User is reloaded in development).
|
|
@@ -315,6 +375,15 @@ module Organizations
|
|
|
315
375
|
}
|
|
316
376
|
end
|
|
317
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
|
+
|
|
318
387
|
def normalize_email
|
|
319
388
|
self.email = email.to_s.downcase.strip if email.present?
|
|
320
389
|
end
|
|
@@ -352,7 +421,7 @@ module Organizations
|
|
|
352
421
|
.exists?
|
|
353
422
|
|
|
354
423
|
if existing
|
|
355
|
-
errors.add(:email, Organizations.t(:"attributes.invitation_taken"))
|
|
424
|
+
errors.add(:email, Organizations.t(:"attributes.invitation_taken", organization: organization))
|
|
356
425
|
end
|
|
357
426
|
end
|
|
358
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.
|