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,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Common free email providers, for config.blocked_email_domains. Opt-in:
|
|
5
|
+
#
|
|
6
|
+
# config.blocked_email_domains = Organizations::PUBLIC_EMAIL_DOMAINS
|
|
7
|
+
# config.blocked_email_domains = Organizations::PUBLIC_EMAIL_DOMAINS + ["example-isp.net"]
|
|
8
|
+
#
|
|
9
|
+
# Defence in depth, not proof of authority and not a complete list: the
|
|
10
|
+
# proof is the DNS challenge. A blocked domain cannot be enrolled and never
|
|
11
|
+
# admits through a domain. People with these addresses can still join by
|
|
12
|
+
# invitation, code or allowlist.
|
|
13
|
+
PUBLIC_EMAIL_DOMAINS = %w[
|
|
14
|
+
aol.com
|
|
15
|
+
fastmail.com
|
|
16
|
+
gmail.com
|
|
17
|
+
gmx.com
|
|
18
|
+
gmx.de
|
|
19
|
+
gmx.net
|
|
20
|
+
googlemail.com
|
|
21
|
+
hey.com
|
|
22
|
+
hotmail.co.uk
|
|
23
|
+
hotmail.com
|
|
24
|
+
hotmail.es
|
|
25
|
+
hotmail.fr
|
|
26
|
+
icloud.com
|
|
27
|
+
live.com
|
|
28
|
+
mac.com
|
|
29
|
+
mail.com
|
|
30
|
+
mail.ru
|
|
31
|
+
me.com
|
|
32
|
+
msn.com
|
|
33
|
+
outlook.com
|
|
34
|
+
outlook.es
|
|
35
|
+
pm.me
|
|
36
|
+
proton.me
|
|
37
|
+
protonmail.com
|
|
38
|
+
qq.com
|
|
39
|
+
tutanota.com
|
|
40
|
+
web.de
|
|
41
|
+
yahoo.co.uk
|
|
42
|
+
yahoo.com
|
|
43
|
+
yahoo.es
|
|
44
|
+
yahoo.fr
|
|
45
|
+
yandex.com
|
|
46
|
+
yandex.ru
|
|
47
|
+
zoho.com
|
|
48
|
+
].freeze
|
|
49
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# A resource of the host app named by type and id, for grants on records
|
|
5
|
+
# that are not ActiveRecord models (or that you do not want to load):
|
|
6
|
+
#
|
|
7
|
+
# entity = Organizations::Resource.new("Ledger::Entity", entity_id)
|
|
8
|
+
# membership.grant!(entity, capabilities: %w[read])
|
|
9
|
+
# membership.can?(:read, entity)
|
|
10
|
+
Resource = Data.define(:type, :id)
|
|
11
|
+
end
|
data/lib/organizations/roles.rb
CHANGED
|
@@ -56,6 +56,9 @@ module Organizations
|
|
|
56
56
|
].freeze
|
|
57
57
|
}.freeze
|
|
58
58
|
|
|
59
|
+
# One published roles configuration (see Roles.snapshot).
|
|
60
|
+
Snapshot = Struct.new(:definition, :hierarchy, :permissions, :permission_sets)
|
|
61
|
+
|
|
59
62
|
class << self
|
|
60
63
|
# Get the default permission structure
|
|
61
64
|
# @return [Hash<Symbol, Array<Symbol>>]
|
|
@@ -66,13 +69,13 @@ module Organizations
|
|
|
66
69
|
# Get the role hierarchy
|
|
67
70
|
# @return [Array<Symbol>]
|
|
68
71
|
def hierarchy
|
|
69
|
-
|
|
72
|
+
snapshot.hierarchy
|
|
70
73
|
end
|
|
71
74
|
|
|
72
75
|
# Get all valid role names
|
|
73
76
|
# @return [Array<Symbol>]
|
|
74
77
|
def valid_roles
|
|
75
|
-
|
|
78
|
+
hierarchy
|
|
76
79
|
end
|
|
77
80
|
|
|
78
81
|
# Check if a role is valid
|
|
@@ -81,7 +84,7 @@ module Organizations
|
|
|
81
84
|
def valid_role?(role)
|
|
82
85
|
return false if role.nil?
|
|
83
86
|
|
|
84
|
-
|
|
87
|
+
hierarchy.include?(role.to_sym)
|
|
85
88
|
end
|
|
86
89
|
|
|
87
90
|
# Check if role_a is at least as high as role_b in hierarchy
|
|
@@ -96,8 +99,9 @@ module Organizations
|
|
|
96
99
|
def at_least?(role_a, role_b)
|
|
97
100
|
return false if role_a.nil? || role_b.nil?
|
|
98
101
|
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
order = hierarchy # one snapshot for both lookups
|
|
103
|
+
idx_a = order.index(role_a.to_sym)
|
|
104
|
+
idx_b = order.index(role_b.to_sym)
|
|
101
105
|
|
|
102
106
|
return false unless idx_a && idx_b
|
|
103
107
|
|
|
@@ -110,14 +114,62 @@ module Organizations
|
|
|
110
114
|
# @param role_b [Symbol, String] Second role
|
|
111
115
|
# @return [Integer] -1 if a > b, 0 if equal, 1 if a < b
|
|
112
116
|
def compare(role_a, role_b)
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
# A role that is no longer configured ranks below every configured role.
|
|
118
|
+
order = hierarchy # one snapshot for both lookups
|
|
119
|
+
idx_a = order.index(role_a.to_sym) || order.length
|
|
120
|
+
idx_b = order.index(role_b.to_sym) || order.length
|
|
115
121
|
|
|
116
122
|
return 0 if idx_a == idx_b
|
|
117
123
|
|
|
118
124
|
idx_a < idx_b ? -1 : 1
|
|
119
125
|
end
|
|
120
126
|
|
|
127
|
+
# Check if an account holding actor_role may act on role: give it to a
|
|
128
|
+
# member, take it away, remove a member who holds it, or invite to it.
|
|
129
|
+
# See assignment_refusal for the rule.
|
|
130
|
+
# @param actor_role [Symbol, String] The role of the account making the change
|
|
131
|
+
# @param role [Symbol, String] The role being acted on
|
|
132
|
+
# @return [Boolean]
|
|
133
|
+
#
|
|
134
|
+
# @example
|
|
135
|
+
# Roles.can_assign?(:admin, :admin) # => true (same rank)
|
|
136
|
+
# Roles.can_assign?(:admin, :member) # => true
|
|
137
|
+
# Roles.can_assign?(:member, :admin) # => false (admin ranks above member)
|
|
138
|
+
#
|
|
139
|
+
def can_assign?(actor_role, role) = assignment_refusal(actor_role, role).nil?
|
|
140
|
+
|
|
141
|
+
# Why an account holding actor_role may not act on role, or nil when it
|
|
142
|
+
# may. A role acts only on roles at or below its own rank, so a custom
|
|
143
|
+
# role that can edit member roles still cannot create or demote an
|
|
144
|
+
# admin. Rank alone is not enough below admin: two custom roles with the
|
|
145
|
+
# same parent are ranked by definition order, so a role below admin may
|
|
146
|
+
# also act only on roles whose permissions it holds itself. Admins and
|
|
147
|
+
# the owner keep the rank rule alone, as before. A role that is no
|
|
148
|
+
# longer configured ranks below every configured role and has no
|
|
149
|
+
# permissions; an actor whose own role is no longer configured may act
|
|
150
|
+
# on nothing. The owner role keeps its own rules on top of this: it
|
|
151
|
+
# moves only through transfer_ownership_to!.
|
|
152
|
+
# @param actor_role [Symbol, String] The role of the account making the change
|
|
153
|
+
# @param role [Symbol, String] The role being acted on
|
|
154
|
+
# @return [nil, :outranks, :lacks_permissions]
|
|
155
|
+
#
|
|
156
|
+
# @example
|
|
157
|
+
# Roles.assignment_refusal(:member, :admin) # => :outranks
|
|
158
|
+
# Roles.assignment_refusal(:lead, :finance) # => :lacks_permissions (a sibling with other powers)
|
|
159
|
+
# Roles.assignment_refusal(:lead, :member) # => nil
|
|
160
|
+
#
|
|
161
|
+
def assignment_refusal(actor_role, role)
|
|
162
|
+
return :outranks if actor_role.nil? || role.nil?
|
|
163
|
+
|
|
164
|
+
current = snapshot # one snapshot for the ranks and the permissions
|
|
165
|
+
actor_role = actor_role.to_sym
|
|
166
|
+
role = role.to_sym
|
|
167
|
+
return :outranks unless ranks_at_or_above?(current.hierarchy, actor_role, role)
|
|
168
|
+
return nil if ranks_at_or_above?(current.hierarchy, actor_role, :admin)
|
|
169
|
+
|
|
170
|
+
holds_permissions_of?(current.permission_sets, actor_role, role) ? nil : :lacks_permissions
|
|
171
|
+
end
|
|
172
|
+
|
|
121
173
|
# Get all permissions for a role (pre-computed, O(1) lookup)
|
|
122
174
|
# @param role [Symbol, String] The role
|
|
123
175
|
# @return [Array<Symbol>] All permissions for the role
|
|
@@ -146,19 +198,18 @@ module Organizations
|
|
|
146
198
|
# Get the pre-computed permission hash (for direct access)
|
|
147
199
|
# @return [Hash<Symbol, Array<Symbol>>]
|
|
148
200
|
def permissions
|
|
149
|
-
|
|
201
|
+
snapshot.permissions
|
|
150
202
|
end
|
|
151
203
|
|
|
152
204
|
# Get the pre-computed permission sets (for O(1) lookups)
|
|
153
205
|
# @return [Hash<Symbol, Set<Symbol>>]
|
|
154
206
|
def permission_sets
|
|
155
|
-
|
|
207
|
+
snapshot.permission_sets
|
|
156
208
|
end
|
|
157
209
|
|
|
158
210
|
# Reset computed permissions (used when custom roles are defined)
|
|
159
211
|
def reset!
|
|
160
|
-
@
|
|
161
|
-
@permission_sets = nil
|
|
212
|
+
@snapshot = nil
|
|
162
213
|
end
|
|
163
214
|
|
|
164
215
|
# Get the next role up in hierarchy
|
|
@@ -167,10 +218,11 @@ module Organizations
|
|
|
167
218
|
def higher_role(role)
|
|
168
219
|
return nil if role.nil?
|
|
169
220
|
|
|
170
|
-
|
|
221
|
+
order = hierarchy # one snapshot for the lookup and the result
|
|
222
|
+
idx = order.index(role.to_sym)
|
|
171
223
|
return nil unless idx && idx > 0
|
|
172
224
|
|
|
173
|
-
|
|
225
|
+
order[idx - 1]
|
|
174
226
|
end
|
|
175
227
|
|
|
176
228
|
# Get the next role down in hierarchy
|
|
@@ -179,23 +231,63 @@ module Organizations
|
|
|
179
231
|
def lower_role(role)
|
|
180
232
|
return nil if role.nil?
|
|
181
233
|
|
|
182
|
-
|
|
183
|
-
|
|
234
|
+
order = hierarchy # one snapshot for the lookup and the result
|
|
235
|
+
idx = order.index(role.to_sym)
|
|
236
|
+
return nil unless idx && idx < order.length - 1
|
|
237
|
+
|
|
238
|
+
order[idx + 1]
|
|
239
|
+
end
|
|
184
240
|
|
|
185
|
-
|
|
241
|
+
# The role hierarchy (highest first) of a roles definition: the four
|
|
242
|
+
# built-in roles, and each custom role directly above the role it
|
|
243
|
+
# inherits from (a custom role without `inherits:` sits at the bottom).
|
|
244
|
+
def hierarchy_for(definition)
|
|
245
|
+
return HIERARCHY unless definition
|
|
246
|
+
|
|
247
|
+
builder = RoleBuilder.new
|
|
248
|
+
builder.instance_eval(&definition)
|
|
249
|
+
builder.validate_privileges!
|
|
250
|
+
builder.hierarchy
|
|
186
251
|
end
|
|
187
252
|
|
|
188
253
|
private
|
|
189
254
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
255
|
+
# A role missing from order ranks below every configured role; an actor
|
|
256
|
+
# missing from it ranks at or above nothing.
|
|
257
|
+
def ranks_at_or_above?(order, actor_role, role)
|
|
258
|
+
actor_rank = order.index(actor_role)
|
|
259
|
+
!actor_rank.nil? && actor_rank <= (order.index(role) || order.length)
|
|
260
|
+
end
|
|
194
261
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
262
|
+
def holds_permissions_of?(sets, actor_role, role) = (sets[role] || Set.new).subset?(sets[actor_role] || Set.new)
|
|
263
|
+
|
|
264
|
+
# The hierarchy and permissions of one roles definition, computed
|
|
265
|
+
# together and published as one immutable value. It belongs to the
|
|
266
|
+
# definition object it was built from: after a reconfiguration (or a
|
|
267
|
+
# rolled-back one) the next reader sees the identity change and builds a
|
|
268
|
+
# new snapshot, so a reader that raced a reset can never keep an
|
|
269
|
+
# obsolete hierarchy in place.
|
|
270
|
+
def snapshot
|
|
271
|
+
definition = Organizations.configuration&.custom_roles_definition
|
|
272
|
+
current = @snapshot
|
|
273
|
+
return current if current && current.definition.equal?(definition)
|
|
274
|
+
|
|
275
|
+
@snapshot = build_snapshot(definition)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def build_snapshot(definition)
|
|
279
|
+
if definition
|
|
280
|
+
builder = RoleBuilder.new
|
|
281
|
+
builder.instance_eval(&definition)
|
|
282
|
+
builder.validate_privileges!
|
|
283
|
+
hierarchy = builder.hierarchy
|
|
284
|
+
permissions = builder.to_permissions
|
|
285
|
+
else
|
|
286
|
+
hierarchy = HIERARCHY
|
|
287
|
+
permissions = DEFAULT_PERMISSIONS
|
|
288
|
+
end
|
|
289
|
+
sets = permissions.transform_values { |perms| Set.new(perms).freeze }.freeze
|
|
290
|
+
Snapshot.new(definition, hierarchy, permissions, sets).freeze
|
|
199
291
|
end
|
|
200
292
|
end
|
|
201
293
|
|
|
@@ -211,6 +303,10 @@ module Organizations
|
|
|
211
303
|
# @param inherits [Symbol, nil] Role to inherit from
|
|
212
304
|
# @yield Block to define permissions
|
|
213
305
|
def role(name, inherits: nil, &block)
|
|
306
|
+
name = name.to_sym
|
|
307
|
+
inherits = inherits&.to_sym
|
|
308
|
+
validate_role_definition!(name, inherits)
|
|
309
|
+
|
|
214
310
|
@roles[name] = {
|
|
215
311
|
inherits: inherits,
|
|
216
312
|
permissions: []
|
|
@@ -228,28 +324,94 @@ module Organizations
|
|
|
228
324
|
@roles[@current_role][:permissions] << permission.to_sym
|
|
229
325
|
end
|
|
230
326
|
|
|
327
|
+
# The hierarchy, highest first: the built-in roles in their fixed order,
|
|
328
|
+
# and each custom role inserted directly above the role it inherits from.
|
|
329
|
+
# @return [Array<Symbol>]
|
|
330
|
+
def hierarchy
|
|
331
|
+
order = Roles::HIERARCHY.dup
|
|
332
|
+
custom_roles.each do |name|
|
|
333
|
+
parent = @roles[name][:inherits]
|
|
334
|
+
parent ? order.insert(order.index(parent), name) : order << name
|
|
335
|
+
end
|
|
336
|
+
order.freeze
|
|
337
|
+
end
|
|
338
|
+
|
|
231
339
|
# Build final permissions hash with inheritance resolved
|
|
232
340
|
# @return [Hash<Symbol, Array<Symbol>>]
|
|
233
341
|
def to_permissions
|
|
234
|
-
result = {}
|
|
235
|
-
|
|
236
342
|
# Process roles in reverse hierarchy order (lowest first)
|
|
237
343
|
# so inheritance works correctly
|
|
238
|
-
|
|
239
|
-
|
|
344
|
+
hierarchy.reverse_each.with_object({}) do |role_name, result|
|
|
345
|
+
perms = @roles.key?(role_name) ? defined_permissions(role_name, result) : omitted_permissions(role_name)
|
|
346
|
+
result[role_name] = perms.freeze if perms
|
|
347
|
+
end.freeze
|
|
348
|
+
end
|
|
240
349
|
|
|
241
|
-
|
|
242
|
-
|
|
350
|
+
# An admin can assign a custom role, so a custom role may not grant a
|
|
351
|
+
# permission that the owner role has and the admin role lacks (for
|
|
352
|
+
# example manage_billing or transfer_ownership). Otherwise an admin
|
|
353
|
+
# could give another account a power that the admin does not have.
|
|
354
|
+
def validate_privileges!
|
|
355
|
+
validate_built_in_parents!
|
|
356
|
+
permissions = to_permissions
|
|
357
|
+
owner_only = (permissions[:owner] || DEFAULT_PERMISSIONS[:owner]) -
|
|
358
|
+
(permissions[:admin] || DEFAULT_PERMISSIONS[:admin])
|
|
359
|
+
custom_roles.each do |name|
|
|
360
|
+
granted = @roles[name][:permissions] & owner_only
|
|
361
|
+
next if granted.empty?
|
|
362
|
+
|
|
363
|
+
raise ConfigurationError, "Custom role #{name} cannot grant #{granted.join(', ')}: " \
|
|
364
|
+
"admins can assign it, and only the owner has that permission"
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
private
|
|
369
|
+
|
|
370
|
+
# A built-in role may inherit a custom role only when that custom role
|
|
371
|
+
# ranks below it; otherwise the inheritance would be dropped silently.
|
|
372
|
+
def validate_built_in_parents!
|
|
373
|
+
order = hierarchy
|
|
374
|
+
(@roles.keys & Roles::HIERARCHY).each do |name|
|
|
375
|
+
parent = @roles[name][:inherits]
|
|
376
|
+
next unless parent && custom_roles.include?(parent)
|
|
377
|
+
next if order.index(parent) > order.index(name)
|
|
378
|
+
|
|
379
|
+
raise ConfigurationError, "Role #{name} cannot inherit from #{parent}: #{parent} ranks above #{name}"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
243
382
|
|
|
244
|
-
|
|
245
|
-
if role_def[:inherits] && result.key?(role_def[:inherits])
|
|
246
|
-
perms = (result[role_def[:inherits]] + perms).uniq
|
|
247
|
-
end
|
|
383
|
+
def custom_roles = @roles.keys - Roles::HIERARCHY
|
|
248
384
|
|
|
249
|
-
|
|
385
|
+
# A role's own permissions plus its parent's. A custom role that inherits
|
|
386
|
+
# a built-in role the block does not redefine inherits its defaults.
|
|
387
|
+
def defined_permissions(role_name, result)
|
|
388
|
+
role_def = @roles[role_name]
|
|
389
|
+
parent = role_def[:inherits]
|
|
390
|
+
inherited = parent && (result[parent] || (custom_roles.include?(role_name) && DEFAULT_PERMISSIONS[parent]))
|
|
391
|
+
inherited ? (inherited + role_def[:permissions]).uniq : role_def[:permissions].dup
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# A block that only adds custom roles keeps the built-in roles' defaults.
|
|
395
|
+
# An empty block still defines no permissions, as before custom roles.
|
|
396
|
+
def omitted_permissions(role_name) = custom_only? ? DEFAULT_PERMISSIONS[role_name] : nil
|
|
397
|
+
def custom_only? = custom_roles.any? && !@roles.keys.intersect?(Roles::HIERARCHY)
|
|
398
|
+
|
|
399
|
+
# A custom role needs a plain name, and a parent that already exists (a
|
|
400
|
+
# built-in role below admin or a custom role defined above it). Nothing
|
|
401
|
+
# inherits admin or owner.
|
|
402
|
+
def validate_role_definition!(name, inherits)
|
|
403
|
+
unless name.to_s.match?(/\A[a-z][a-z0-9_]{0,31}\z/)
|
|
404
|
+
raise ConfigurationError, "Role name #{name.inspect} must be lowercase letters, digits and underscores"
|
|
405
|
+
end
|
|
406
|
+
return if Roles::HIERARCHY.include?(name)
|
|
407
|
+
raise ConfigurationError, "Role #{name} is defined twice" if @roles.key?(name)
|
|
408
|
+
# A custom role ranks below admin, so it never outranks the people who can assign it.
|
|
409
|
+
if %i[owner admin].include?(inherits)
|
|
410
|
+
raise ConfigurationError, "Custom role #{name} cannot inherit from #{inherits}: a custom role ranks below admin"
|
|
250
411
|
end
|
|
412
|
+
return if inherits.nil? || %i[member viewer].include?(inherits) || @roles.key?(inherits)
|
|
251
413
|
|
|
252
|
-
|
|
414
|
+
raise ConfigurationError, "Custom role #{name} inherits from #{inherits}, which is not defined above it"
|
|
253
415
|
end
|
|
254
416
|
end
|
|
255
417
|
end
|
|
@@ -95,32 +95,36 @@ module Organizations
|
|
|
95
95
|
|
|
96
96
|
# Returns a human-readable role label
|
|
97
97
|
# @param role [Symbol, String] The role
|
|
98
|
+
# @param organization [Organizations::Organization, nil] pass it to use
|
|
99
|
+
# that organization's vocabulary (config.vocabulary); never inferred
|
|
98
100
|
# @return [String]
|
|
99
101
|
#
|
|
100
102
|
# @example
|
|
101
103
|
# organization_role_label(:admin) # => "Admin"
|
|
104
|
+
# organization_role_label(:member, organization: school) # => "Student"
|
|
102
105
|
#
|
|
103
|
-
def organization_role_label(role)
|
|
106
|
+
def organization_role_label(role, organization: nil)
|
|
104
107
|
# I18n-backed (organizations.roles.*) so hosts localize/retheme labels
|
|
105
108
|
# by overriding locale keys instead of monkey-patching this helper.
|
|
106
109
|
# Custom roles defined via config.roles fall back to humanize unless
|
|
107
110
|
# the host adds a matching organizations.roles.<name> key.
|
|
108
|
-
Organizations.t(:"roles.#{role}", default: role.to_s.humanize)
|
|
111
|
+
Organizations.t(:"roles.#{role}", organization: organization, default: role.to_s.humanize)
|
|
109
112
|
end
|
|
110
113
|
|
|
111
114
|
# Returns a hash of role information for building badges
|
|
112
115
|
# @param role [Symbol, String] The role
|
|
116
|
+
# @param organization [Organizations::Organization, nil] for its vocabulary
|
|
113
117
|
# @return [Hash] Hash with :role, :label, and :color keys
|
|
114
118
|
#
|
|
115
119
|
# @example
|
|
116
120
|
# organization_role_info(:admin)
|
|
117
121
|
# # => { role: :admin, label: "Admin", color: :blue }
|
|
118
122
|
#
|
|
119
|
-
def organization_role_info(role)
|
|
123
|
+
def organization_role_info(role, organization: nil)
|
|
120
124
|
role_sym = role.to_sym
|
|
121
125
|
{
|
|
122
126
|
role: role_sym,
|
|
123
|
-
label:
|
|
127
|
+
label: role_label_for(role_sym, organization),
|
|
124
128
|
color: role_color(role_sym)
|
|
125
129
|
}
|
|
126
130
|
end
|
|
@@ -172,8 +176,8 @@ module Organizations
|
|
|
172
176
|
name: user.respond_to?(:name) && user.name.present? ? user.name : user.email,
|
|
173
177
|
email: user.email,
|
|
174
178
|
role: membership.role.to_sym,
|
|
175
|
-
role_label:
|
|
176
|
-
role_info: organization_role_info(membership.role),
|
|
179
|
+
role_label: role_label_for(membership.role, organization),
|
|
180
|
+
role_info: organization_role_info(membership.role, organization: organization),
|
|
177
181
|
joined_at: membership.created_at,
|
|
178
182
|
is_owner: membership.owner?
|
|
179
183
|
}
|
|
@@ -190,7 +194,7 @@ module Organizations
|
|
|
190
194
|
id: invitation.id,
|
|
191
195
|
email: invitation.email,
|
|
192
196
|
role: invitation.role.to_sym,
|
|
193
|
-
role_label:
|
|
197
|
+
role_label: role_label_for(invitation.role, organization),
|
|
194
198
|
invited_by: inviter,
|
|
195
199
|
invited_by_name: inviter_display_name(inviter),
|
|
196
200
|
status: organization_invitation_status(invitation),
|
|
@@ -240,6 +244,8 @@ module Organizations
|
|
|
240
244
|
end
|
|
241
245
|
|
|
242
246
|
# Check if current user can remove a member
|
|
247
|
+
# Follows the same rule as Organization#remove_member!: the member's role
|
|
248
|
+
# must be one the user may act on (see Roles.assignment_refusal).
|
|
243
249
|
# @param user [User] The user performing the action
|
|
244
250
|
# @param membership [Organizations::Membership] The membership to remove
|
|
245
251
|
# @return [Boolean]
|
|
@@ -247,10 +253,12 @@ module Organizations
|
|
|
247
253
|
return false unless user_has_permission_in_org?(user, membership.organization, :remove_members)
|
|
248
254
|
return false if membership.owner? # Can't remove owner
|
|
249
255
|
|
|
250
|
-
|
|
256
|
+
user_can_act_on_role?(user, membership)
|
|
251
257
|
end
|
|
252
258
|
|
|
253
259
|
# Check if current user can change a member's role
|
|
260
|
+
# Follows the same rule as Organization#change_role_of!: the member's
|
|
261
|
+
# current role must be one the user may act on (see Roles.assignment_refusal).
|
|
254
262
|
# @param user [User] The user performing the action
|
|
255
263
|
# @param membership [Organizations::Membership] The membership to update
|
|
256
264
|
# @return [Boolean]
|
|
@@ -262,7 +270,7 @@ module Organizations
|
|
|
262
270
|
if membership.owner?
|
|
263
271
|
user_has_permission_in_org?(user, membership.organization, :transfer_ownership)
|
|
264
272
|
else
|
|
265
|
-
|
|
273
|
+
user_can_act_on_role?(user, membership)
|
|
266
274
|
end
|
|
267
275
|
end
|
|
268
276
|
|
|
@@ -353,6 +361,24 @@ module Organizations
|
|
|
353
361
|
end
|
|
354
362
|
end
|
|
355
363
|
|
|
364
|
+
# organization_role_label with the organization, unless a host override
|
|
365
|
+
# of it takes only the role (written before 0.7.0).
|
|
366
|
+
def role_label_for(role, organization)
|
|
367
|
+
if role_label_takes_organization?
|
|
368
|
+
organization_role_label(role, organization: organization)
|
|
369
|
+
else
|
|
370
|
+
organization_role_label(role)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def role_label_takes_organization?
|
|
375
|
+
return @_role_label_takes_organization if defined?(@_role_label_takes_organization)
|
|
376
|
+
|
|
377
|
+
@_role_label_takes_organization = method(:organization_role_label).parameters.any? do |kind, name|
|
|
378
|
+
kind == :keyrest || name == :organization
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
356
382
|
def role_color(role)
|
|
357
383
|
case role.to_sym
|
|
358
384
|
when :owner then :purple
|
|
@@ -379,6 +405,11 @@ module Organizations
|
|
|
379
405
|
role && Roles.has_permission?(role, permission)
|
|
380
406
|
end
|
|
381
407
|
|
|
408
|
+
def user_can_act_on_role?(user, membership)
|
|
409
|
+
role = user.role_in(membership.organization)
|
|
410
|
+
role.present? && Roles.can_assign?(role, membership.role)
|
|
411
|
+
end
|
|
412
|
+
|
|
382
413
|
# Returns display name for invitation sender (handles nil inviter)
|
|
383
414
|
def inviter_display_name(inviter)
|
|
384
415
|
return nil unless inviter
|
data/lib/organizations.rb
CHANGED
|
@@ -71,6 +71,20 @@ module Organizations
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# Domain authority: why a DNS verification failed. reason is one of
|
|
75
|
+
# :not_started, :expired, :changed (the claim changed during the check),
|
|
76
|
+
# :record_not_found, :lookup_failed.
|
|
77
|
+
class DomainVerificationFailed < Error
|
|
78
|
+
REASONS = %i[not_started expired changed record_not_found lookup_failed].freeze
|
|
79
|
+
|
|
80
|
+
attr_reader :reason
|
|
81
|
+
|
|
82
|
+
def initialize(reason)
|
|
83
|
+
@reason = reason
|
|
84
|
+
super(Organizations.t(:"errors.domain_verification.#{reason}"))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
74
88
|
# Join code errors (verified joining)
|
|
75
89
|
# JoinCodeInvalid covers unknown, revoked, and expired codes — hosts should
|
|
76
90
|
# show the same generic message for all three (don't leak which codes exist).
|
|
@@ -105,6 +119,13 @@ module Organizations
|
|
|
105
119
|
autoload :JoinState, "organizations/join_state"
|
|
106
120
|
autoload :MetadataFlags, "organizations/metadata_flags"
|
|
107
121
|
autoload :OrganizationScoped, "organizations/organization_scoped"
|
|
122
|
+
autoload :PersonalOwnerBackfill, "organizations/personal_owner_backfill"
|
|
123
|
+
autoload :PUBLIC_EMAIL_DOMAINS, "organizations/public_email_domains"
|
|
124
|
+
autoload :Resource, "organizations/resource"
|
|
125
|
+
autoload :GrantHolder, "organizations/grant_holder"
|
|
126
|
+
autoload :GrantAccess, "organizations/grant_access"
|
|
127
|
+
autoload :Grantable, "organizations/grantable"
|
|
128
|
+
autoload :AccountDeletionPlan, "organizations/account_deletion_plan"
|
|
108
129
|
|
|
109
130
|
# Alias for README compatibility: `include Organizations::Controller`
|
|
110
131
|
Controller = ControllerHelpers
|
|
@@ -142,6 +163,7 @@ module Organizations
|
|
|
142
163
|
autoload :JoinCode, "organizations/models/join_code"
|
|
143
164
|
autoload :AllowlistEntry, "organizations/models/allowlist_entry"
|
|
144
165
|
autoload :JoinRequest, "organizations/models/join_request"
|
|
166
|
+
autoload :Grant, "organizations/models/grant"
|
|
145
167
|
|
|
146
168
|
# Non-Rails contexts (plain ActiveRecord, the gem's own test suite) don't
|
|
147
169
|
# get the Rails engine's automatic `config/locales` pickup, so register
|
|
@@ -281,13 +303,86 @@ module Organizations
|
|
|
281
303
|
# Hosts override any key the standard Rails way — app locale files load
|
|
282
304
|
# after engine locale files, so the host's value wins.
|
|
283
305
|
#
|
|
284
|
-
#
|
|
306
|
+
# VOCABULARY (config.vocabulary): pass the organization record as
|
|
307
|
+
# `organization:`. When the selected vocabulary has the key
|
|
308
|
+
# (`organizations.vocabularies.<vocabulary>.<key>`, in the current locale
|
|
309
|
+
# or one of its I18n fallbacks), that translation wins. Otherwise the call
|
|
310
|
+
# is exactly the ordinary one: same key, scope, defaults and options.
|
|
311
|
+
# `%{organization}` interpolates the record's name. A String
|
|
312
|
+
# `organization:` stays a plain interpolation value, as before. Calls
|
|
313
|
+
# with an Array key or a custom `scope:` skip the vocabulary.
|
|
314
|
+
#
|
|
315
|
+
# Organizations.t("roles.member", organization: school) # => "Student"
|
|
316
|
+
#
|
|
317
|
+
# @param key [String, Symbol, Array] key under the `organizations.` scope,
|
|
285
318
|
# e.g. :"errors.join_code_invalid" or "roles.owner"
|
|
286
|
-
# @param options [Hash] I18n options (interpolations, :locale, :
|
|
319
|
+
# @param options [Hash] I18n options (interpolations, :locale, :count,
|
|
320
|
+
# :default…) and :organization (a record or a String)
|
|
287
321
|
# @return [String]
|
|
288
|
-
def translate(key, **)
|
|
289
|
-
|
|
322
|
+
def translate(key, **options)
|
|
323
|
+
# A nil organization is no organization: the ordinary call stays unchanged.
|
|
324
|
+
options = options.except(:organization) if options.key?(:organization) && options[:organization].nil?
|
|
325
|
+
record = options[:organization].is_a?(Organizations::Organization) ? options[:organization] : nil
|
|
326
|
+
options = options.merge(organization: record.name) if record
|
|
327
|
+
vocabulary_key = vocabulary_key_for(key, record, options)
|
|
328
|
+
# The vocabulary key exists, so the caller's default (meant for the
|
|
329
|
+
# ordinary key) does not apply.
|
|
330
|
+
return I18n.t(vocabulary_key, scope: :organizations, **options.except(:default)) if vocabulary_key
|
|
331
|
+
|
|
332
|
+
I18n.t(key, scope: :organizations, **options)
|
|
290
333
|
end
|
|
334
|
+
|
|
335
|
+
# The I18n lookup chain for a key, most specific first: the vocabulary
|
|
336
|
+
# key (when config.vocabulary selects one for this organization), then
|
|
337
|
+
# the ordinary key. Absolute keys, for the Rails `t` view helper (needed
|
|
338
|
+
# for `_html` keys):
|
|
339
|
+
#
|
|
340
|
+
# primary, *fallbacks = Organizations.i18n_keys(:"mailers.invitation.invited_by_html", organization: org)
|
|
341
|
+
# t(primary, default: fallbacks, inviter: name, organization: org.name)
|
|
342
|
+
#
|
|
343
|
+
# @param key [String, Symbol]
|
|
344
|
+
# @param organization [Organizations::Organization, nil]
|
|
345
|
+
# @return [Array<Symbol>]
|
|
346
|
+
def i18n_keys(key, organization: nil)
|
|
347
|
+
ordinary = :"organizations.#{key}"
|
|
348
|
+
vocabulary = vocabulary_for(organization)
|
|
349
|
+
vocabulary ? [:"organizations.vocabularies.#{vocabulary}.#{key}", ordinary] : [ordinary]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# @api private
|
|
353
|
+
# The vocabulary key to use for this call, or nil for the ordinary call.
|
|
354
|
+
def vocabulary_key_for(key, record, options)
|
|
355
|
+
return nil if key.is_a?(Array) || options.key?(:scope)
|
|
356
|
+
|
|
357
|
+
vocabulary = vocabulary_for(record)
|
|
358
|
+
return nil unless vocabulary
|
|
359
|
+
|
|
360
|
+
candidate = :"vocabularies.#{vocabulary}.#{key}"
|
|
361
|
+
I18n.exists?(candidate, scope: :organizations, locale: options[:locale] || I18n.locale) ? candidate : nil
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# The vocabulary config.vocabulary selects: a Symbol/String for the whole
|
|
365
|
+
# app, or a callable that receives the organization (never called
|
|
366
|
+
# without one). nil selects the ordinary translations.
|
|
367
|
+
# @param organization [Organizations::Organization, nil]
|
|
368
|
+
# @return [String, nil]
|
|
369
|
+
# A resolver that raises, or returns a name that is not one plain label
|
|
370
|
+
# (letters, digits, underscores), selects the ordinary translations and
|
|
371
|
+
# logs a warning: wording must never break a page or an email.
|
|
372
|
+
def vocabulary_for(organization)
|
|
373
|
+
setting = configuration.vocabulary
|
|
374
|
+
setting = organization ? setting.call(organization) : nil if setting.respond_to?(:call)
|
|
375
|
+
name = setting.to_s.presence
|
|
376
|
+
return nil unless name
|
|
377
|
+
return name if name.match?(/\A[a-z0-9_]+\z/i)
|
|
378
|
+
|
|
379
|
+
Callbacks.log_warn("[Organizations] Ignored vocabulary #{name.inspect}: use letters, digits and underscores")
|
|
380
|
+
nil
|
|
381
|
+
rescue StandardError => e
|
|
382
|
+
Callbacks.log_warn("[Organizations] The vocabulary resolver failed: #{e.class}: #{e.message}")
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
291
386
|
alias t translate
|
|
292
387
|
end
|
|
293
388
|
end
|