organizations 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +7 -0
- data/.simplecov +17 -10
- data/CHANGELOG.md +102 -0
- data/README.md +704 -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 +224 -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
|
@@ -32,6 +32,11 @@ module Organizations
|
|
|
32
32
|
# see Organizations::MetadataFlags.
|
|
33
33
|
extend Organizations::MetadataFlags
|
|
34
34
|
|
|
35
|
+
# grant!, revoke_grant!, grant_for, granted? (scoped resource grants)
|
|
36
|
+
include Organizations::GrantHolder
|
|
37
|
+
# limited_by_grants?, capabilities_for, can?, visible, granted_ids
|
|
38
|
+
include Organizations::GrantAccess
|
|
39
|
+
|
|
35
40
|
# Error raised when trying to demote below current role
|
|
36
41
|
class CannotDemoteOwner < Organizations::Error; end
|
|
37
42
|
class CannotPromoteToOwner < Organizations::Error; end
|
|
@@ -51,6 +56,12 @@ module Organizations
|
|
|
51
56
|
class_name: Organizations.user_class_name,
|
|
52
57
|
optional: true
|
|
53
58
|
|
|
59
|
+
# Scoped resource grants (rails g organizations:grants). Removed with the
|
|
60
|
+
# membership: a member who leaves keeps no access. (No dependent:
|
|
61
|
+
# option, because the table is optional: see GrantHolder.)
|
|
62
|
+
has_many :grants, class_name: "Organizations::Grant", inverse_of: :membership
|
|
63
|
+
before_destroy :delete_grants
|
|
64
|
+
|
|
54
65
|
# === Validations ===
|
|
55
66
|
|
|
56
67
|
validates :role, presence: true
|
|
@@ -81,6 +92,11 @@ module Organizations
|
|
|
81
92
|
# Memberships with viewer role
|
|
82
93
|
scope :viewers, -> { where(role: "viewer") }
|
|
83
94
|
|
|
95
|
+
# Memberships with a grant on a resource
|
|
96
|
+
scope :with_grant_on, lambda { |resource|
|
|
97
|
+
where(id: Organizations::Grant.active.for_resource(resource).select(:membership_id))
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
# Order by role hierarchy (owners first)
|
|
85
101
|
# (the configured hierarchy, custom roles included; unknown roles last)
|
|
86
102
|
scope :by_role_hierarchy, -> {
|
|
@@ -165,25 +181,87 @@ module Organizations
|
|
|
165
181
|
Roles.compare(role_sym, other.role_sym)
|
|
166
182
|
end
|
|
167
183
|
|
|
184
|
+
# Check if this member may give another member the specified role, or
|
|
185
|
+
# take it away from them: only roles that rank at or below this member's
|
|
186
|
+
# own role qualify. Useful to filter the role picker in an admin UI.
|
|
187
|
+
# @param role [Symbol, String] The role to give or take away
|
|
188
|
+
# @return [Boolean]
|
|
189
|
+
#
|
|
190
|
+
# @example
|
|
191
|
+
# admin_membership.can_assign_role?(:admin) # => true
|
|
192
|
+
# member_membership.can_assign_role?(:admin) # => false
|
|
193
|
+
#
|
|
194
|
+
def can_assign_role?(role)
|
|
195
|
+
Roles.can_assign?(role_sym, role)
|
|
196
|
+
end
|
|
197
|
+
|
|
168
198
|
# === Role Change Methods ===
|
|
169
199
|
|
|
200
|
+
# Raise unless changed_by may move this member to new_role. A member who
|
|
201
|
+
# changes another member's role must rank at or above both the role being
|
|
202
|
+
# taken away and the role being given (see Roles.can_assign?), so a custom
|
|
203
|
+
# role that can edit member roles still cannot create or demote an admin.
|
|
204
|
+
# A changed_by who holds no membership in this organization, such as a
|
|
205
|
+
# console session or platform staff, is only recorded for the callbacks,
|
|
206
|
+
# as before; authorizing that account is the host's job. promote_to!,
|
|
207
|
+
# demote_to! and Organization#change_role_of! all call this.
|
|
208
|
+
# @param new_role [Symbol, String] The role being given
|
|
209
|
+
# @param changed_by [User, nil] Who is making the change
|
|
210
|
+
# @raise [Organizations::NotAuthorized] if either role ranks above changed_by's own
|
|
211
|
+
def authorize_role_change!(new_role, changed_by:)
|
|
212
|
+
self.class.authorize_rank!(organization, changed_by, [role_sym, new_role],
|
|
213
|
+
permission: :edit_member_roles,
|
|
214
|
+
message_key: :"errors.role_change_outranks_actor")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# @api private
|
|
218
|
+
# The one check behind every action on a role: inviting to it, changing a
|
|
219
|
+
# member to or from it, removing a member who holds it, and revoking or
|
|
220
|
+
# resending an invitation for it. An actor may only act on roles that
|
|
221
|
+
# rank at or below its own and, below admin, whose permissions it holds
|
|
222
|
+
# (see Roles.assignment_refusal). An actor that is nil or holds no
|
|
223
|
+
# membership in the organization is not checked: the gem records it for
|
|
224
|
+
# the callbacks and the host authorizes it.
|
|
225
|
+
# @param message_key [Symbol] The message for a role that ranks above the actor's own
|
|
226
|
+
# @raise [Organizations::NotAuthorized] naming the first role the actor may not act on
|
|
227
|
+
def self.authorize_rank!(organization, actor, roles, permission:, message_key:)
|
|
228
|
+
actor_membership = actor && organization.memberships.find_by(user_id: actor.id)
|
|
229
|
+
return unless actor_membership
|
|
230
|
+
|
|
231
|
+
roles.map(&:to_sym).each do |role|
|
|
232
|
+
refusal = Roles.assignment_refusal(actor_membership.role_sym, role)
|
|
233
|
+
next unless refusal
|
|
234
|
+
|
|
235
|
+
key = refusal == :outranks ? message_key : :"errors.role_has_permissions_actor_lacks"
|
|
236
|
+
raise Organizations::NotAuthorized.new(
|
|
237
|
+
Organizations.t(key, organization: organization,
|
|
238
|
+
role: Organizations.t(:"roles.#{role}", organization: organization,
|
|
239
|
+
default: role.to_s.humanize)),
|
|
240
|
+
permission: permission,
|
|
241
|
+
organization: organization,
|
|
242
|
+
user: actor
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
170
247
|
# Promote to a higher role
|
|
171
248
|
# @param new_role [Symbol, String] The new role
|
|
172
249
|
# @param changed_by [User, nil] Who is making the change
|
|
173
250
|
# @return [self]
|
|
174
251
|
# @raise [InvalidRoleChange] if new role is not higher
|
|
175
252
|
# @raise [CannotPromoteToOwner] if trying to promote to owner (use transfer_ownership_to!)
|
|
253
|
+
# @raise [Organizations::NotAuthorized] if changed_by is a member whose role ranks below the new role
|
|
176
254
|
def promote_to!(new_role, changed_by: nil)
|
|
177
255
|
new_role_sym = new_role.to_sym
|
|
178
256
|
validate_role!(new_role_sym)
|
|
179
257
|
|
|
180
258
|
# Owner role is only assignable via transfer_ownership_to!
|
|
181
259
|
if new_role_sym == :owner
|
|
182
|
-
raise CannotPromoteToOwner, Organizations.t(:"errors.cannot_promote_to_owner")
|
|
260
|
+
raise CannotPromoteToOwner, Organizations.t(:"errors.cannot_promote_to_owner", organization: organization)
|
|
183
261
|
end
|
|
184
262
|
|
|
185
263
|
unless Roles.at_least?(new_role_sym, role_sym)
|
|
186
|
-
raise InvalidRoleChange, Organizations.t(:"errors.promote_not_higher", new_role: new_role, role: role)
|
|
264
|
+
raise InvalidRoleChange, Organizations.t(:"errors.promote_not_higher", organization: organization, new_role: new_role, role: role)
|
|
187
265
|
end
|
|
188
266
|
|
|
189
267
|
change_role_to!(new_role_sym, changed_by: changed_by)
|
|
@@ -195,16 +273,17 @@ module Organizations
|
|
|
195
273
|
# @return [self]
|
|
196
274
|
# @raise [CannotDemoteOwner] if trying to demote owner
|
|
197
275
|
# @raise [InvalidRoleChange] if new role is not lower
|
|
276
|
+
# @raise [Organizations::NotAuthorized] if changed_by is a member whose role ranks below the current role
|
|
198
277
|
def demote_to!(new_role, changed_by: nil)
|
|
199
278
|
new_role_sym = new_role.to_sym
|
|
200
279
|
validate_role!(new_role_sym)
|
|
201
280
|
|
|
202
281
|
if owner?
|
|
203
|
-
raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner")
|
|
282
|
+
raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner", organization: organization)
|
|
204
283
|
end
|
|
205
284
|
|
|
206
285
|
unless Roles.at_least?(role_sym, new_role_sym)
|
|
207
|
-
raise InvalidRoleChange, Organizations.t(:"errors.demote_not_lower", new_role: new_role, role: role)
|
|
286
|
+
raise InvalidRoleChange, Organizations.t(:"errors.demote_not_lower", organization: organization, new_role: new_role, role: role)
|
|
208
287
|
end
|
|
209
288
|
|
|
210
289
|
change_role_to!(new_role_sym, changed_by: changed_by)
|
|
@@ -220,7 +299,7 @@ module Organizations
|
|
|
220
299
|
|
|
221
300
|
return unless existing_owner.exists?
|
|
222
301
|
|
|
223
|
-
errors.add(:role, Organizations.t(:"attributes.owner_taken"))
|
|
302
|
+
errors.add(:role, Organizations.t(:"attributes.owner_taken", organization: organization))
|
|
224
303
|
end
|
|
225
304
|
|
|
226
305
|
def validate_role!(role)
|
|
@@ -237,6 +316,9 @@ module Organizations
|
|
|
237
316
|
ActiveRecord::Base.transaction do
|
|
238
317
|
# Lock row to prevent concurrent changes
|
|
239
318
|
lock!
|
|
319
|
+
# Checked on the locked, freshly read role, so a concurrent change
|
|
320
|
+
# cannot slip an admin past the check.
|
|
321
|
+
authorize_role_change!(new_role, changed_by: changed_by)
|
|
240
322
|
update!(role: new_role.to_s)
|
|
241
323
|
end
|
|
242
324
|
|
|
@@ -31,6 +31,7 @@ module Organizations
|
|
|
31
31
|
class CannotInviteAsOwner < Organizations::Error; end
|
|
32
32
|
class NoOwnerPresent < Organizations::Error; end
|
|
33
33
|
class MemberAlreadyExists < Organizations::Error; end
|
|
34
|
+
class CannotTransferPersonalOrganization < Organizations::Error; end
|
|
34
35
|
|
|
35
36
|
# === Associations ===
|
|
36
37
|
|
|
@@ -42,6 +43,10 @@ module Organizations
|
|
|
42
43
|
has_many :users,
|
|
43
44
|
through: :memberships
|
|
44
45
|
|
|
46
|
+
# Every grant of every membership (scoped resource grants). Grants held
|
|
47
|
+
# by pending invitations are not included: see invitation.grants.
|
|
48
|
+
has_many :grants, through: :memberships
|
|
49
|
+
|
|
45
50
|
has_many :invitations,
|
|
46
51
|
class_name: "Organizations::Invitation",
|
|
47
52
|
inverse_of: :organization,
|
|
@@ -73,9 +78,19 @@ module Organizations
|
|
|
73
78
|
inverse_of: :organization,
|
|
74
79
|
dependent: :destroy
|
|
75
80
|
|
|
81
|
+
# === Personal organizations (v0.7.0) ===
|
|
82
|
+
|
|
83
|
+
# The user whose personal workspace this is (nil for every other
|
|
84
|
+
# organization). Set by user.create_personal_organization!, never guessed.
|
|
85
|
+
# The column is added by `rails g organizations:upgrade`.
|
|
86
|
+
belongs_to :personal_owner,
|
|
87
|
+
class_name: Organizations.user_class_name,
|
|
88
|
+
optional: true
|
|
89
|
+
|
|
76
90
|
# === Validations ===
|
|
77
91
|
|
|
78
92
|
validates :name, presence: true
|
|
93
|
+
validate :personal_owner_holds_ownership, on: :update, if: :personal_owner_changed_to_someone?
|
|
79
94
|
|
|
80
95
|
# === Scopes ===
|
|
81
96
|
|
|
@@ -87,6 +102,12 @@ module Organizations
|
|
|
87
102
|
joins(:memberships).where(organizations_memberships: { user_id: user.id })
|
|
88
103
|
}
|
|
89
104
|
|
|
105
|
+
# Personal organizations (each one linked to the user it belongs to)
|
|
106
|
+
scope :personal, -> { where.not(personal_owner_id: nil) }
|
|
107
|
+
|
|
108
|
+
# Every organization that is not a personal organization
|
|
109
|
+
scope :not_personal, -> { where(personal_owner_id: nil) }
|
|
110
|
+
|
|
90
111
|
# === Creation ===
|
|
91
112
|
|
|
92
113
|
# Create an organization WITH its owner membership in one transaction —
|
|
@@ -114,6 +135,8 @@ module Organizations
|
|
|
114
135
|
def self.create_with_owner!(owner:, **attributes)
|
|
115
136
|
raise ArgumentError, "owner is required" unless owner
|
|
116
137
|
|
|
138
|
+
ensure_personal_owner_is_owner!(attributes, owner)
|
|
139
|
+
|
|
117
140
|
organization = nil
|
|
118
141
|
ActiveRecord::Base.transaction do
|
|
119
142
|
organization = create!(**attributes)
|
|
@@ -125,6 +148,51 @@ module Organizations
|
|
|
125
148
|
organization
|
|
126
149
|
end
|
|
127
150
|
|
|
151
|
+
# @api private (public only because HasOrganizations#create_organization! calls it)
|
|
152
|
+
# A new organization may be personal only for the user who becomes its
|
|
153
|
+
# owner in the same transaction (user.create_organization!,
|
|
154
|
+
# create_with_owner!). Raw Organization.create! writes stay privileged.
|
|
155
|
+
# @raise [ArgumentError]
|
|
156
|
+
def self.ensure_personal_owner_is_owner!(attributes, owner)
|
|
157
|
+
attributes = attributes.to_h.symbolize_keys
|
|
158
|
+
personal_owner_id =
|
|
159
|
+
attributes.key?(:personal_owner) ? attributes[:personal_owner]&.id : attributes[:personal_owner_id]
|
|
160
|
+
return if personal_owner_id.nil? || personal_owner_id.to_s == owner.id.to_s
|
|
161
|
+
|
|
162
|
+
raise ArgumentError, "personal_owner must be the owner of the new organization"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Link reviewed users to the organizations that are their personal
|
|
166
|
+
# organizations, for installs that created personal workspaces before
|
|
167
|
+
# 0.7.0. Nothing is guessed: pass the mapping you reviewed. A pair
|
|
168
|
+
# conflicts when the organization is missing, the user is not its owner,
|
|
169
|
+
# the organization is linked to another user, the user already has another
|
|
170
|
+
# personal organization, or one organization is mapped twice. When any
|
|
171
|
+
# pair conflicts, nothing is written.
|
|
172
|
+
#
|
|
173
|
+
# @param mapping [Hash{User, id => Organization, id}]
|
|
174
|
+
# @param dry_run [Boolean] report only, write nothing
|
|
175
|
+
# @return [Organizations::PersonalOwnerBackfill::Report]
|
|
176
|
+
def self.backfill_personal_owners!(mapping, dry_run: false)
|
|
177
|
+
PersonalOwnerBackfill.new(mapping).run(dry_run: dry_run)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# === Personal organizations ===
|
|
181
|
+
|
|
182
|
+
# Whether this is a user's personal organization
|
|
183
|
+
# @return [Boolean]
|
|
184
|
+
def personal?
|
|
185
|
+
has_attribute?(:personal_owner_id) && personal_owner_id.present?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Stop treating this organization as the owner's personal organization.
|
|
189
|
+
# Do this before you transfer its ownership. Members, roles and data stay.
|
|
190
|
+
# @return [self]
|
|
191
|
+
def convert_to_shared!
|
|
192
|
+
update!(personal_owner_id: nil)
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
|
|
128
196
|
# === Member Query Methods ===
|
|
129
197
|
|
|
130
198
|
# Get the owner of this organization
|
|
@@ -196,7 +264,7 @@ module Organizations
|
|
|
196
264
|
|
|
197
265
|
# Owner role is only assignable via transfer_ownership_to! or initial creation
|
|
198
266
|
if role_sym == :owner
|
|
199
|
-
raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_add_as_owner")
|
|
267
|
+
raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_add_as_owner", organization: self)
|
|
200
268
|
end
|
|
201
269
|
|
|
202
270
|
# Check if already a member (idempotent operation)
|
|
@@ -260,17 +328,33 @@ module Organizations
|
|
|
260
328
|
# @param user [User] The user to remove
|
|
261
329
|
# @param removed_by [User, nil] Who is removing them (for callbacks)
|
|
262
330
|
# @raise [CannotRemoveOwner] if trying to remove the owner
|
|
331
|
+
# @raise [Organizations::NotAuthorized] if removed_by is a member whose role
|
|
332
|
+
# ranks below the removed member's role
|
|
263
333
|
def remove_member!(user, removed_by: nil)
|
|
264
334
|
membership = memberships.find_by(user_id: user.id)
|
|
265
335
|
return unless membership
|
|
266
336
|
|
|
267
337
|
if membership.role.to_sym == :owner
|
|
268
|
-
raise CannotRemoveOwner, Organizations.t(:"errors.cannot_remove_owner")
|
|
338
|
+
raise CannotRemoveOwner, Organizations.t(:"errors.cannot_remove_owner", organization: self)
|
|
269
339
|
end
|
|
270
340
|
|
|
271
341
|
ActiveRecord::Base.transaction do
|
|
272
342
|
# Lock organization to prevent race conditions
|
|
273
343
|
lock!
|
|
344
|
+
|
|
345
|
+
# Re-read the role under a row lock, without reloading the record (a
|
|
346
|
+
# reload would drop the live counter cache and fail on a row that is
|
|
347
|
+
# already gone). A member promoted, or made owner, since the lookup
|
|
348
|
+
# above is judged by the role they hold now.
|
|
349
|
+
locked_role = Membership.where(id: membership.id).lock.pick(:role)
|
|
350
|
+
if locked_role == "owner"
|
|
351
|
+
raise CannotRemoveOwner, Organizations.t(:"errors.cannot_remove_owner", organization: self)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Only roles removed_by may act on (see Roles.assignment_refusal)
|
|
355
|
+
Membership.authorize_rank!(self, removed_by, [locked_role || membership.role],
|
|
356
|
+
permission: :remove_members,
|
|
357
|
+
message_key: :"errors.member_removal_outranks_actor")
|
|
274
358
|
membership.destroy!
|
|
275
359
|
end
|
|
276
360
|
|
|
@@ -290,6 +374,8 @@ module Organizations
|
|
|
290
374
|
# @return [Membership] The updated membership
|
|
291
375
|
# @raise [CannotHaveMultipleOwners] if promoting to owner when one exists
|
|
292
376
|
# @raise [CannotRemoveLastOwner] if demoting the only owner
|
|
377
|
+
# @raise [Organizations::NotAuthorized] if changed_by is a member whose role
|
|
378
|
+
# ranks below the member's current role or the new role
|
|
293
379
|
def change_role_of!(user, to:, changed_by: nil)
|
|
294
380
|
new_role = to.to_sym
|
|
295
381
|
validate_role!(new_role)
|
|
@@ -310,14 +396,17 @@ module Organizations
|
|
|
310
396
|
if new_role == :owner && old_role != :owner
|
|
311
397
|
# Promoting to owner - this is only allowed via transfer_ownership_to!
|
|
312
398
|
# Direct role change to owner is not permitted
|
|
313
|
-
raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_promote_to_owner")
|
|
399
|
+
raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_promote_to_owner", organization: self)
|
|
314
400
|
end
|
|
315
401
|
|
|
316
402
|
if old_role == :owner && new_role != :owner
|
|
317
403
|
# Demoting owner - not allowed directly
|
|
318
|
-
raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner_directly")
|
|
404
|
+
raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner_directly", organization: self)
|
|
319
405
|
end
|
|
320
406
|
|
|
407
|
+
# Only roles at or below changed_by's own may be given or taken away
|
|
408
|
+
membership.authorize_role_change!(new_role, changed_by: changed_by)
|
|
409
|
+
|
|
321
410
|
membership.update!(role: new_role.to_s)
|
|
322
411
|
end
|
|
323
412
|
|
|
@@ -341,8 +430,10 @@ module Organizations
|
|
|
341
430
|
# @raise [CannotTransferToNonAdmin] if user is not an admin
|
|
342
431
|
def transfer_ownership_to!(new_owner)
|
|
343
432
|
ActiveRecord::Base.transaction do
|
|
344
|
-
|
|
345
|
-
lock!
|
|
433
|
+
lock_new_owner_row!(new_owner)
|
|
434
|
+
lock! # then the organization row, to prevent concurrent operations
|
|
435
|
+
|
|
436
|
+
ensure_not_personal_for_transfer!
|
|
346
437
|
|
|
347
438
|
# Always perform a fresh read in this write path, even if memberships
|
|
348
439
|
# are preloaded on this instance, to avoid stale-owner selection.
|
|
@@ -350,24 +441,23 @@ module Organizations
|
|
|
350
441
|
new_owner_membership = memberships.find_by(user_id: new_owner.id)
|
|
351
442
|
|
|
352
443
|
unless old_owner_membership
|
|
353
|
-
raise NoOwnerPresent, Organizations.t(:"errors.transfer_no_owner")
|
|
444
|
+
raise NoOwnerPresent, Organizations.t(:"errors.transfer_no_owner", organization: self)
|
|
354
445
|
end
|
|
355
446
|
|
|
356
447
|
unless new_owner_membership
|
|
357
|
-
raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member")
|
|
448
|
+
raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member", organization: self)
|
|
358
449
|
end
|
|
359
450
|
|
|
360
451
|
# New owner must be at least an admin (per README: "Ownership can be transferred to any admin")
|
|
361
452
|
unless Roles.at_least?(new_owner_membership.role.to_sym, :admin)
|
|
362
|
-
raise CannotTransferToNonAdmin, Organizations.t(:"errors.transfer_to_non_admin")
|
|
453
|
+
raise CannotTransferToNonAdmin, Organizations.t(:"errors.transfer_to_non_admin", organization: self)
|
|
363
454
|
end
|
|
364
455
|
|
|
365
456
|
# No-op transfer to the current owner.
|
|
366
457
|
return old_owner_membership if old_owner_membership.user_id == new_owner.id
|
|
367
458
|
|
|
368
459
|
# Lock both memberships
|
|
369
|
-
old_owner_membership.lock!
|
|
370
|
-
new_owner_membership.lock!
|
|
460
|
+
[old_owner_membership, new_owner_membership].each(&:lock!)
|
|
371
461
|
|
|
372
462
|
old_owner_user = old_owner_membership.user
|
|
373
463
|
|
|
@@ -393,8 +483,14 @@ module Organizations
|
|
|
393
483
|
# @param invited_by [User, nil] Who is sending the invitation (uses Current.user if not provided)
|
|
394
484
|
# @param role [Symbol] Role for the invitation (default: from config)
|
|
395
485
|
# @return [Invitation] The created or existing invitation
|
|
486
|
+
# @param grants [Hash{resource => Array}, nil] resources the new member
|
|
487
|
+
# gets on acceptance, with their capabilities (scoped resource grants).
|
|
488
|
+
# The inviter needs config.grant_management_permission and, when grants
|
|
489
|
+
# limit it, may give only access it has. On an existing pending
|
|
490
|
+
# invitation they are added to it.
|
|
396
491
|
# @raise [CannotInviteAsOwner] if role is :owner
|
|
397
|
-
|
|
492
|
+
# @raise [Organizations::NotAuthorized] if the role ranks above the inviter's own
|
|
493
|
+
def send_invite_to!(email, invited_by: nil, role: nil, grants: nil)
|
|
398
494
|
inviter = invited_by || current_user_from_context
|
|
399
495
|
unless inviter
|
|
400
496
|
raise ArgumentError, "invited_by is required (or set Current.user)"
|
|
@@ -407,18 +503,22 @@ module Organizations
|
|
|
407
503
|
|
|
408
504
|
# Owner role cannot be assigned via invitation - only via transfer_ownership_to!
|
|
409
505
|
if role_sym == :owner
|
|
410
|
-
raise CannotInviteAsOwner, Organizations.t(:"errors.invite_as_owner")
|
|
506
|
+
raise CannotInviteAsOwner, Organizations.t(:"errors.invite_as_owner", organization: self)
|
|
411
507
|
end
|
|
412
508
|
|
|
509
|
+
# Nobody can invite to a role that ranks above their own
|
|
510
|
+
authorize_invited_role!(inviter, role_sym)
|
|
511
|
+
|
|
413
512
|
normalized_email = email.downcase.strip
|
|
513
|
+
grants = grants.to_a
|
|
414
514
|
|
|
415
515
|
# Check for existing pending invitation (idempotent)
|
|
416
516
|
existing = invitations.pending.for_email(normalized_email).first
|
|
417
|
-
return existing if existing
|
|
517
|
+
return add_invitation_grants(existing, grants, inviter) if existing
|
|
418
518
|
|
|
419
519
|
# Check if already a member (case-insensitive)
|
|
420
520
|
if users.where("LOWER(email) = ?", normalized_email).exists?
|
|
421
|
-
raise Organizations::InvitationError, Organizations.t(:"errors.invitation_already_member")
|
|
521
|
+
raise Organizations::InvitationError, Organizations.t(:"errors.invitation_already_member", organization: self)
|
|
422
522
|
end
|
|
423
523
|
|
|
424
524
|
# Allow callback hooks to veto invitations (e.g., plan seat limits) before write.
|
|
@@ -429,6 +529,9 @@ module Organizations
|
|
|
429
529
|
expires_at: calculate_expiry
|
|
430
530
|
)
|
|
431
531
|
|
|
532
|
+
# Checked before anything is written or sent
|
|
533
|
+
invitation_context.authorize_grants!(grants, inviter)
|
|
534
|
+
|
|
432
535
|
Callbacks.dispatch(
|
|
433
536
|
:member_invited,
|
|
434
537
|
strict: true,
|
|
@@ -445,24 +548,18 @@ module Organizations
|
|
|
445
548
|
ActiveRecord::Base.transaction(requires_new: true) do
|
|
446
549
|
# Check for expired invitation and refresh it instead of creating duplicate
|
|
447
550
|
expired_invitation = invitations.expired.for_email(normalized_email).first
|
|
448
|
-
if expired_invitation
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
invited_by: inviter,
|
|
461
|
-
role: role.to_s,
|
|
462
|
-
token: generate_unique_token,
|
|
463
|
-
expires_at: calculate_expiry
|
|
464
|
-
)
|
|
465
|
-
end
|
|
551
|
+
invitation = if expired_invitation
|
|
552
|
+
reissue_invitation!(expired_invitation, inviter, role)
|
|
553
|
+
else
|
|
554
|
+
invitations.create!(
|
|
555
|
+
email: normalized_email,
|
|
556
|
+
invited_by: inviter,
|
|
557
|
+
role: role.to_s,
|
|
558
|
+
token: generate_unique_token,
|
|
559
|
+
expires_at: calculate_expiry
|
|
560
|
+
)
|
|
561
|
+
end
|
|
562
|
+
apply_invitation_grants(invitation, grants, inviter)
|
|
466
563
|
end
|
|
467
564
|
|
|
468
565
|
# Send invitation email
|
|
@@ -470,8 +567,9 @@ module Organizations
|
|
|
470
567
|
|
|
471
568
|
invitation
|
|
472
569
|
rescue ActiveRecord::RecordNotUnique
|
|
473
|
-
# Race condition: invitation was created by another process
|
|
474
|
-
|
|
570
|
+
# Race condition: invitation was created by another process. Its
|
|
571
|
+
# grants are added the same way as for any existing invitation.
|
|
572
|
+
add_invitation_grants(invitations.pending.for_email(normalized_email).first!, grants.to_a, inviter)
|
|
475
573
|
end
|
|
476
574
|
|
|
477
575
|
# === Verified Joining Methods (v0.5.0) ===
|
|
@@ -479,9 +577,12 @@ module Organizations
|
|
|
479
577
|
# Enroll an email domain for domain-verified joining.
|
|
480
578
|
# @param domain [String] e.g. "inizio.com" (exact match — subdomains must be enrolled separately)
|
|
481
579
|
# @param membership_metadata [Hash] copied onto memberships created through this domain
|
|
580
|
+
# @param join_policy [Symbol, String, nil] :none, :suggest or :auto_join (acts once the domain is verified)
|
|
482
581
|
# @return [Domain]
|
|
483
|
-
def add_domain!(domain, membership_metadata: {})
|
|
484
|
-
|
|
582
|
+
def add_domain!(domain, membership_metadata: {}, join_policy: nil)
|
|
583
|
+
attributes = { domain: domain, membership_metadata: membership_metadata }
|
|
584
|
+
attributes[:join_policy] = join_policy.to_s unless join_policy.nil?
|
|
585
|
+
domains.create!(attributes)
|
|
485
586
|
end
|
|
486
587
|
|
|
487
588
|
# Generate a shareable join code (PIN).
|
|
@@ -495,7 +596,7 @@ module Organizations
|
|
|
495
596
|
# @return [JoinCode]
|
|
496
597
|
# Every parameter is an optional keyword with a safe default — this IS
|
|
497
598
|
# the public API surface, not incidental complexity.
|
|
498
|
-
# rubocop:disable Metrics/ParameterLists
|
|
599
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
499
600
|
def generate_join_code!(label: nil, requires_verified_domain_email: false, auto_approve: true,
|
|
500
601
|
expires_at: nil, max_uses: nil, created_by: nil, membership_metadata: {})
|
|
501
602
|
join_codes.create!(
|
|
@@ -508,7 +609,6 @@ module Organizations
|
|
|
508
609
|
membership_metadata: membership_metadata
|
|
509
610
|
)
|
|
510
611
|
end
|
|
511
|
-
# rubocop:enable Metrics/ParameterLists
|
|
512
612
|
|
|
513
613
|
# Bulk-import roster emails as allowlist entries (idempotent per address:
|
|
514
614
|
# already-enrolled addresses are skipped, not duplicated).
|
|
@@ -549,7 +649,7 @@ module Organizations
|
|
|
549
649
|
# challenge). Drives whether a join screen shows the email form.
|
|
550
650
|
# @return [Boolean]
|
|
551
651
|
def accepts_domain_joining?
|
|
552
|
-
domains.exists? || allowlist_entries.unclaimed.exists?
|
|
652
|
+
domains.joinable.exists? || allowlist_entries.unclaimed.exists?
|
|
553
653
|
end
|
|
554
654
|
|
|
555
655
|
# Whether this organization has at least one code that can actually be
|
|
@@ -599,19 +699,19 @@ module Organizations
|
|
|
599
699
|
# the email is unconfirmed, or the feature is disabled
|
|
600
700
|
def join_with_account_email!(user)
|
|
601
701
|
unless Organizations.configuration.trust_confirmed_account_email
|
|
602
|
-
raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_trust_disabled")
|
|
702
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_trust_disabled", organization: self)
|
|
603
703
|
end
|
|
604
704
|
|
|
605
705
|
email = user.respond_to?(:email) ? user.email.to_s : ""
|
|
606
706
|
confirmed = user.respond_to?(:confirmed_at) && user.confirmed_at.present?
|
|
607
707
|
|
|
608
708
|
unless confirmed
|
|
609
|
-
raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_unconfirmed")
|
|
709
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_unconfirmed", organization: self)
|
|
610
710
|
end
|
|
611
711
|
|
|
612
|
-
matched_domain = domains.matching_email(email).first
|
|
712
|
+
matched_domain = domains.joinable.matching_email(email).first
|
|
613
713
|
unless matched_domain
|
|
614
|
-
raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible")
|
|
714
|
+
raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible", organization: self)
|
|
615
715
|
end
|
|
616
716
|
|
|
617
717
|
# Uniform funnel: every self-serve join goes through a JoinRequest so
|
|
@@ -622,7 +722,7 @@ module Organizations
|
|
|
622
722
|
ActiveRecord::Base.transaction do
|
|
623
723
|
if Membership.where(organization_id: id, verified_email_normalized: normalized).exists?
|
|
624
724
|
raise VerificationEmailAlreadyClaimed,
|
|
625
|
-
Organizations.t(:"errors.verification_email_already_claimed")
|
|
725
|
+
Organizations.t(:"errors.verification_email_already_claimed", organization: self)
|
|
626
726
|
end
|
|
627
727
|
|
|
628
728
|
request.assign_attributes(
|
|
@@ -635,11 +735,72 @@ module Organizations
|
|
|
635
735
|
request.save!
|
|
636
736
|
end
|
|
637
737
|
|
|
638
|
-
request.
|
|
738
|
+
request.send(:auto_approve!)
|
|
639
739
|
end
|
|
640
740
|
|
|
641
741
|
private
|
|
642
742
|
|
|
743
|
+
# Lock the new owner's user row before the organization row: the
|
|
744
|
+
# account-deletion guard locks a user row before organization rows, so
|
|
745
|
+
# both take their locks in the same order and cannot deadlock. A
|
|
746
|
+
# concurrent deletion of that user either finishes first (the membership
|
|
747
|
+
# is gone and the transfer is refused) or waits until the transfer
|
|
748
|
+
# commits (and sees the new ownership).
|
|
749
|
+
def lock_new_owner_row!(new_owner)
|
|
750
|
+
user_class = new_owner.class
|
|
751
|
+
return if user_class.unscoped.where(user_class.primary_key => new_owner.id).lock.pick(user_class.primary_key)
|
|
752
|
+
|
|
753
|
+
raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member", organization: self)
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# A personal organization belongs to its owner. Call convert_to_shared!
|
|
757
|
+
# first to move it to someone else.
|
|
758
|
+
def ensure_not_personal_for_transfer!
|
|
759
|
+
return unless personal?
|
|
760
|
+
|
|
761
|
+
raise CannotTransferPersonalOrganization, Organizations.t(:"errors.transfer_personal_organization", organization: self)
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def personal_owner_changed_to_someone?
|
|
765
|
+
has_attribute?(:personal_owner_id) && will_save_change_to_personal_owner_id? && personal_owner_id.present?
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# The personal owner is the owner of the organization. A second,
|
|
769
|
+
# independent ownership record would be an authorization hole.
|
|
770
|
+
def personal_owner_holds_ownership
|
|
771
|
+
# Lock this organization's row first: transfer_ownership_to! takes the
|
|
772
|
+
# same lock, so the owner read here cannot go stale before the write.
|
|
773
|
+
self.class.unscoped.where(id: id).lock.pick(:id)
|
|
774
|
+
owner_id = memberships.where(role: "owner").pick(:user_id)
|
|
775
|
+
return if owner_id.present? && owner_id == personal_owner_id
|
|
776
|
+
|
|
777
|
+
errors.add(:personal_owner, Organizations.t(:"attributes.personal_owner_not_owner", organization: self))
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# Refresh an expired invitation instead of creating a duplicate. It
|
|
781
|
+
# carries only the grants of the new call: the old ones were authorized
|
|
782
|
+
# for another inviter and role.
|
|
783
|
+
def reissue_invitation!(invitation, inviter, role)
|
|
784
|
+
invitation.lock!
|
|
785
|
+
invitation.grants.delete_all(:delete_all) if Organizations::GrantHolder.grants_table?
|
|
786
|
+
invitation.update!(invited_by: inviter, role: role.to_s, token: generate_unique_token,
|
|
787
|
+
expires_at: calculate_expiry)
|
|
788
|
+
invitation
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
def add_invitation_grants(invitation, grants, inviter)
|
|
792
|
+
return invitation if grants.empty?
|
|
793
|
+
|
|
794
|
+
ActiveRecord::Base.transaction(requires_new: true) { apply_invitation_grants(invitation, grants, inviter) }
|
|
795
|
+
invitation
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def apply_invitation_grants(invitation, grants, inviter)
|
|
799
|
+
grants.each do |resource, capabilities|
|
|
800
|
+
invitation.grant!(resource, capabilities: capabilities, granted_by: inviter)
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
|
|
643
804
|
def ensure_join_request_belongs_here!(join_request)
|
|
644
805
|
return if join_request.organization_id == id
|
|
645
806
|
|
|
@@ -662,7 +823,7 @@ module Organizations
|
|
|
662
823
|
|
|
663
824
|
unless inviter_membership
|
|
664
825
|
raise Organizations::NotAMember.new(
|
|
665
|
-
Organizations.t(:"errors.invite_not_a_member"),
|
|
826
|
+
Organizations.t(:"errors.invite_not_a_member", organization: self),
|
|
666
827
|
organization: self,
|
|
667
828
|
user: inviter
|
|
668
829
|
)
|
|
@@ -671,13 +832,23 @@ module Organizations
|
|
|
671
832
|
return if Roles.has_permission?(inviter_membership.role.to_sym, :invite_members)
|
|
672
833
|
|
|
673
834
|
raise Organizations::NotAuthorized.new(
|
|
674
|
-
Organizations.t(:"errors.invite_not_authorized"),
|
|
835
|
+
Organizations.t(:"errors.invite_not_authorized", organization: self),
|
|
675
836
|
permission: :invite_members,
|
|
676
837
|
organization: self,
|
|
677
838
|
user: inviter
|
|
678
839
|
)
|
|
679
840
|
end
|
|
680
841
|
|
|
842
|
+
# An inviter may only invite to roles that rank at or below their own (see
|
|
843
|
+
# Roles.can_assign?), so a custom role that can invite members still
|
|
844
|
+
# cannot invite an admin. A role that is not configured at all passes
|
|
845
|
+
# here and fails the invitation's own role validation instead.
|
|
846
|
+
def authorize_invited_role!(inviter, role)
|
|
847
|
+
Membership.authorize_rank!(self, inviter, [role],
|
|
848
|
+
permission: :invite_members,
|
|
849
|
+
message_key: :"errors.invite_role_outranks_actor")
|
|
850
|
+
end
|
|
851
|
+
|
|
681
852
|
def validate_role!(role)
|
|
682
853
|
unless Roles.valid_role?(role)
|
|
683
854
|
raise ArgumentError, "Invalid role: #{role}. Must be one of: #{Roles.valid_roles.join(', ')}"
|