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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/.simplecov +17 -10
  5. data/Appraisals +2 -0
  6. data/CHANGELOG.md +112 -0
  7. data/README.md +714 -32
  8. data/app/controllers/organizations/invitations_controller.rb +27 -22
  9. data/app/controllers/organizations/memberships_controller.rb +37 -30
  10. data/app/controllers/organizations/organizations_controller.rb +8 -8
  11. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  12. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  13. data/app/mailers/organizations/verification_mailer.rb +1 -1
  14. data/app/models/organizations/grant.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  19. data/config/locales/en.yml +22 -0
  20. data/config/locales/es.yml +22 -0
  21. data/config/routes.rb +1 -2
  22. data/gemfiles/rails_7.2.gemfile +6 -1
  23. data/gemfiles/rails_8.1.gemfile +5 -1
  24. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  25. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  26. data/lib/generators/organizations/install/install_generator.rb +1 -0
  27. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  28. data/lib/generators/organizations/join/join_generator.rb +113 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  30. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  33. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  34. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  35. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  36. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  37. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  38. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  39. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  40. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  41. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  42. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  43. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  44. data/lib/generators/organizations/views/views_generator.rb +4 -6
  45. data/lib/organizations/account_deletion_plan.rb +118 -0
  46. data/lib/organizations/configuration.rb +155 -2
  47. data/lib/organizations/controller_helpers.rb +55 -15
  48. data/lib/organizations/grant_access.rb +75 -0
  49. data/lib/organizations/grant_holder.rb +152 -0
  50. data/lib/organizations/grantable.rb +28 -0
  51. data/lib/organizations/join_flow.rb +16 -12
  52. data/lib/organizations/models/concerns/has_organizations.rb +217 -9
  53. data/lib/organizations/models/domain.rb +283 -1
  54. data/lib/organizations/models/grant.rb +152 -0
  55. data/lib/organizations/models/invitation.rb +79 -8
  56. data/lib/organizations/models/join_request.rb +102 -43
  57. data/lib/organizations/models/membership.rb +96 -15
  58. data/lib/organizations/models/organization.rb +219 -48
  59. data/lib/organizations/organization_scoped.rb +16 -0
  60. data/lib/organizations/personal_owner_backfill.rb +122 -0
  61. data/lib/organizations/public_email_domains.rb +49 -0
  62. data/lib/organizations/resource.rb +11 -0
  63. data/lib/organizations/roles.rb +198 -36
  64. data/lib/organizations/version.rb +1 -1
  65. data/lib/organizations/view_helpers.rb +40 -9
  66. data/lib/organizations.rb +99 -4
  67. 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,9 +56,19 @@ 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
- validates :role, presence: true, inclusion: { in: ->(_) { Roles::HIERARCHY.map(&:to_s) } }
67
+ validates :role, presence: true
68
+ # Checked when the role is set or changed, so a role retired from the
69
+ # configuration does not block unrelated updates of existing memberships.
70
+ validates :role, inclusion: { in: ->(_) { Roles.valid_roles.map(&:to_s) } },
71
+ if: -> { new_record? || will_save_change_to_role? }
57
72
  # Proc message: resolved at VALIDATION time so it follows I18n.locale —
58
73
  # a literal string here would be frozen in whatever locale loaded first.
59
74
  validates :user_id, uniqueness: { scope: :organization_id,
@@ -77,17 +92,17 @@ module Organizations
77
92
  # Memberships with viewer role
78
93
  scope :viewers, -> { where(role: "viewer") }
79
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
+
80
100
  # Order by role hierarchy (owners first)
101
+ # (the configured hierarchy, custom roles included; unknown roles last)
81
102
  scope :by_role_hierarchy, -> {
82
- order(Arel.sql(<<~SQL.squish))
83
- CASE role
84
- WHEN 'owner' THEN 0
85
- WHEN 'admin' THEN 1
86
- WHEN 'member' THEN 2
87
- WHEN 'viewer' THEN 3
88
- ELSE 4
89
- END
90
- SQL
103
+ roles = Roles.hierarchy # one snapshot for the ranks and the fallback
104
+ ranks = roles.each_with_index.map { |role, index| "WHEN #{connection.quote(role.to_s)} THEN #{index}" }
105
+ order(Arel.sql("CASE role #{ranks.join(' ')} ELSE #{roles.length} END"))
91
106
  }
92
107
 
93
108
  # === Verified Joining (v0.5.0) ===
@@ -166,25 +181,87 @@ module Organizations
166
181
  Roles.compare(role_sym, other.role_sym)
167
182
  end
168
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
+
169
198
  # === Role Change Methods ===
170
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
+
171
247
  # Promote to a higher role
172
248
  # @param new_role [Symbol, String] The new role
173
249
  # @param changed_by [User, nil] Who is making the change
174
250
  # @return [self]
175
251
  # @raise [InvalidRoleChange] if new role is not higher
176
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
177
254
  def promote_to!(new_role, changed_by: nil)
178
255
  new_role_sym = new_role.to_sym
179
256
  validate_role!(new_role_sym)
180
257
 
181
258
  # Owner role is only assignable via transfer_ownership_to!
182
259
  if new_role_sym == :owner
183
- raise CannotPromoteToOwner, Organizations.t(:"errors.cannot_promote_to_owner")
260
+ raise CannotPromoteToOwner, Organizations.t(:"errors.cannot_promote_to_owner", organization: organization)
184
261
  end
185
262
 
186
263
  unless Roles.at_least?(new_role_sym, role_sym)
187
- 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)
188
265
  end
189
266
 
190
267
  change_role_to!(new_role_sym, changed_by: changed_by)
@@ -196,16 +273,17 @@ module Organizations
196
273
  # @return [self]
197
274
  # @raise [CannotDemoteOwner] if trying to demote owner
198
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
199
277
  def demote_to!(new_role, changed_by: nil)
200
278
  new_role_sym = new_role.to_sym
201
279
  validate_role!(new_role_sym)
202
280
 
203
281
  if owner?
204
- raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner")
282
+ raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner", organization: organization)
205
283
  end
206
284
 
207
285
  unless Roles.at_least?(role_sym, new_role_sym)
208
- 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)
209
287
  end
210
288
 
211
289
  change_role_to!(new_role_sym, changed_by: changed_by)
@@ -221,7 +299,7 @@ module Organizations
221
299
 
222
300
  return unless existing_owner.exists?
223
301
 
224
- errors.add(:role, Organizations.t(:"attributes.owner_taken"))
302
+ errors.add(:role, Organizations.t(:"attributes.owner_taken", organization: organization))
225
303
  end
226
304
 
227
305
  def validate_role!(role)
@@ -238,6 +316,9 @@ module Organizations
238
316
  ActiveRecord::Base.transaction do
239
317
  # Lock row to prevent concurrent changes
240
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)
241
322
  update!(role: new_role.to_s)
242
323
  end
243
324