organizations 0.5.0 → 0.6.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/Appraisals +2 -0
- data/CHANGELOG.md +19 -0
- data/README.md +28 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +1 -1
- data/lib/organizations/configuration.rb +3 -2
- data/lib/organizations/models/invitation.rb +3 -1
- data/lib/organizations/models/membership.rb +9 -10
- data/lib/organizations/roles.rb +143 -36
- data/lib/organizations/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 883d468209035eb168c343e8ffc8261ee27155f1c16b3879d367fa081ce937e2
|
|
4
|
+
data.tar.gz: d38f2b84422a031248b03682a1485dc43367deef95135e3e04fe7e32190c2e33
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc9d92ca2dac7522fd4911ba60e375fc60f8ef56a172d94b969b643fe2d9e719b1b69247dfa6b07064009e1b3d73fb893d30fdea6356b00d358f54def4a696a9
|
|
7
|
+
data.tar.gz: eca61e80a95a36ebb6e917283770c99c146b5b719a01d3c526b3a89e0444073eac08f678872bf9e151d731cb7f419eddac7a93aa9dd90ea50577a12c791ea423
|
data/Appraisals
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## [0.6.0] - 2026-10-18
|
|
2
|
+
|
|
3
|
+
**Custom role names.** Register a role outside owner > admin > member > viewer (a `client`, an `auditor`, a `guest`) in the existing `config.roles` block. No migration is needed. Installs without custom roles change nothing.
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Custom roles.** `role :client, inherits: :viewer do can :answer_questions end` in `config.roles` adds a role with its own permissions plus its parent's. A custom role sits directly above its parent in `Roles.hierarchy`. A custom role without `inherits:` sits at the bottom. Two custom roles with the same parent stack in the order you define them.
|
|
8
|
+
- Custom roles work in `add_member!`, memberships, invitations, `default_invitation_role`, promotions, demotions, `at_least?`, `by_role_hierarchy` and every permission helper. The generated views and the dummy reference views list them in the role pickers.
|
|
9
|
+
- **Safety rules, checked when the configuration loads.** A custom role inherits from `member`, `viewer` or another custom role, never from `admin` or `owner`, so it ranks below admin. A custom role cannot grant a permission that the owner role has and the admin role lacks (`manage_billing`, `transfer_ownership`, `delete_organization` by default), because admins can assign it. A built-in role cannot inherit a custom role that ranks above it. A role name is lowercase letters, digits and underscores, and a parent must be defined above its child.
|
|
10
|
+
- `Roles.hierarchy_for(definition)` returns the hierarchy of a roles definition.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `RoleBuilder` keeps roles outside the four built-in ones as custom roles. Before, it dropped them without an error. A `config.roles` block with such a role now registers it, or raises `ConfigurationError` when the role breaks a safety rule.
|
|
15
|
+
- A block that only adds custom roles keeps the default permissions of the built-in roles. A block that redefines a built-in role defines only the built-in roles it lists, as before. An empty block still defines no permissions.
|
|
16
|
+
- `Membership` and `Invitation` check the role against the configured roles only when the role is set or changed. A membership of a role that you remove from the configuration stays readable and updatable, and ranks below every configured role. Move such memberships with `org.change_role_of!(user, to: :member)`. A pending invitation for a removed role cannot be accepted.
|
|
17
|
+
- `by_role_hierarchy` follows the configured hierarchy, custom roles included, with unknown roles last.
|
|
18
|
+
- The hierarchy and the permissions are one immutable snapshot for each roles definition, and each comparison reads one snapshot. A reconfiguration, or a rolled-back one, can no longer leave a stale hierarchy in place.
|
|
19
|
+
|
|
1
20
|
## [0.5.0] - 2026-07-16
|
|
2
21
|
|
|
3
22
|
**Verified Joining** — users can now join organizations by proving they belong, closing three roadmap items (domain-based joining, request-to-join, roster/bulk import). Fully additive: existing installs change nothing until they enroll a domain, generate a code, or import a roster. Run `rails g organizations:upgrade` + `rails db:migrate` to get the new tables/columns.
|
data/README.md
CHANGED
|
@@ -804,6 +804,34 @@ Organizations.configure do |config|
|
|
|
804
804
|
end
|
|
805
805
|
```
|
|
806
806
|
|
|
807
|
+
### Add custom roles
|
|
808
|
+
|
|
809
|
+
Need a role that is not a step on the owner > admin > member > viewer ladder — a `client` who sees and answers for their own records, an `auditor`, a `guest`? Define it in the same block. A custom role inherits from one role and sits directly above it in the hierarchy:
|
|
810
|
+
|
|
811
|
+
```ruby
|
|
812
|
+
Organizations.configure do |config|
|
|
813
|
+
config.roles do
|
|
814
|
+
role :client, inherits: :viewer do
|
|
815
|
+
can :view_own_records
|
|
816
|
+
can :answer_questions
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
Organizations::Roles.hierarchy # => [:owner, :admin, :member, :client, :viewer]
|
|
822
|
+
org.add_member!(user, role: :client)
|
|
823
|
+
user.role_in(org) # => :client
|
|
824
|
+
user.has_organization_permission_to?(:answer_questions) # => true
|
|
825
|
+
require_organization_role!(:client) # clients and everyone above them
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
- A custom role gets its own permissions plus its parent's (the parent's defaults when the block does not redefine a built-in parent).
|
|
829
|
+
- A custom role without `inherits:` sits at the bottom of the hierarchy. Two custom roles with the same parent stack in the order you define them, the first one highest.
|
|
830
|
+
- A block that only adds custom roles keeps the built-in roles' default permissions; a block that redefines a built-in role defines the built-in roles it lists, as before.
|
|
831
|
+
- A custom role ranks below admin: it inherits from `member`, `viewer` or another custom role, never from `admin` or `owner`, so it never outranks the people who can assign it. A custom role also cannot grant a permission that the owner role has and the admin role lacks (`manage_billing`, `transfer_ownership`, `delete_organization` by default), because admins can assign it. A role name is lowercase letters, digits and underscores; a parent must be a built-in role or a custom role defined above it. Both are checked when the configuration loads.
|
|
832
|
+
- Memberships, invitations, `default_invitation_role`, `add_member!`, promotions and demotions, `by_role_hierarchy` and every permission helper accept custom roles. The generated views list them in the role pickers.
|
|
833
|
+
- Removing a custom role from the configuration later leaves its memberships readable and updatable (the role is checked only when it is set or changed) and ranks them below every configured role; move them to a configured role with `org.change_role_of!(user, to: :member)`. A pending invitation for a retired role cannot be accepted; revoke it and invite again with a configured role.
|
|
834
|
+
|
|
807
835
|
### Add custom permissions
|
|
808
836
|
|
|
809
837
|
```ruby
|
data/gemfiles/rails_7.2.gemfile
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
<div>
|
|
36
36
|
<%= form.label :role, "Role", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
|
37
37
|
<%= form.select :role,
|
|
38
|
-
Organizations::Roles
|
|
38
|
+
Organizations::Roles.hierarchy.reject { |role| role == :owner }.map { |role| [role.to_s.capitalize, role] },
|
|
39
39
|
{ selected: @invitation.role || :member },
|
|
40
40
|
required: true,
|
|
41
41
|
class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-slate-500 focus:ring-slate-500 sm:text-sm" %>
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
<div class="flex items-center justify-end gap-2">
|
|
64
64
|
<%= form_with(model: membership, url: membership_path(membership), method: :patch, local: true, class: "flex items-center gap-2") do |form| %>
|
|
65
65
|
<%= form.select :role,
|
|
66
|
-
Organizations::Roles
|
|
66
|
+
Organizations::Roles.hierarchy.reject { |r| r == :owner }.map { |role| [role.to_s.capitalize, role] },
|
|
67
67
|
{ selected: membership.role },
|
|
68
68
|
class: "rounded-md border-gray-300 text-sm focus:border-slate-500 focus:ring-slate-500" %>
|
|
69
69
|
<%= form.submit "Update",
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
<div class="flex items-center gap-2">
|
|
127
127
|
<%= form_with(model: membership, url: organizations.membership_path(membership), method: :patch, local: true, class: "flex items-center gap-2") do |form| %>
|
|
128
128
|
<%= form.select :role,
|
|
129
|
-
Organizations::Roles
|
|
129
|
+
Organizations::Roles.hierarchy.reject { |r| r == :owner }.map { |role| [role.to_s.capitalize, role] },
|
|
130
130
|
{ selected: membership.role },
|
|
131
131
|
class: "rounded-md border-gray-300 text-xs focus:border-slate-500 focus:ring-slate-500 py-1" %>
|
|
132
132
|
<%= form.submit "Update",
|
|
@@ -558,8 +558,9 @@ module Organizations
|
|
|
558
558
|
raise ConfigurationError, "invitation_expiry must be a Duration (e.g., 7.days) or nil"
|
|
559
559
|
end
|
|
560
560
|
|
|
561
|
-
|
|
562
|
-
|
|
561
|
+
roles = Roles.hierarchy_for(@custom_roles_definition)
|
|
562
|
+
unless roles.include?(@default_invitation_role.to_sym)
|
|
563
|
+
raise ConfigurationError, "default_invitation_role must be one of: #{roles.join(', ')}"
|
|
563
564
|
end
|
|
564
565
|
end
|
|
565
566
|
|
|
@@ -38,7 +38,9 @@ module Organizations
|
|
|
38
38
|
|
|
39
39
|
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
40
40
|
validates :token, presence: true, uniqueness: true
|
|
41
|
-
validates :role, presence: true
|
|
41
|
+
validates :role, presence: true
|
|
42
|
+
validates :role, inclusion: { in: ->(_) { Roles.valid_roles.map(&:to_s) } },
|
|
43
|
+
if: -> { new_record? || will_save_change_to_role? }
|
|
42
44
|
|
|
43
45
|
# Validate only one non-accepted invitation per email per organization
|
|
44
46
|
# This matches the DB constraint (accepted_at IS NULL), not expiry status
|
|
@@ -53,7 +53,11 @@ module Organizations
|
|
|
53
53
|
|
|
54
54
|
# === Validations ===
|
|
55
55
|
|
|
56
|
-
validates :role, presence: true
|
|
56
|
+
validates :role, presence: true
|
|
57
|
+
# Checked when the role is set or changed, so a role retired from the
|
|
58
|
+
# configuration does not block unrelated updates of existing memberships.
|
|
59
|
+
validates :role, inclusion: { in: ->(_) { Roles.valid_roles.map(&:to_s) } },
|
|
60
|
+
if: -> { new_record? || will_save_change_to_role? }
|
|
57
61
|
# Proc message: resolved at VALIDATION time so it follows I18n.locale —
|
|
58
62
|
# a literal string here would be frozen in whatever locale loaded first.
|
|
59
63
|
validates :user_id, uniqueness: { scope: :organization_id,
|
|
@@ -78,16 +82,11 @@ module Organizations
|
|
|
78
82
|
scope :viewers, -> { where(role: "viewer") }
|
|
79
83
|
|
|
80
84
|
# Order by role hierarchy (owners first)
|
|
85
|
+
# (the configured hierarchy, custom roles included; unknown roles last)
|
|
81
86
|
scope :by_role_hierarchy, -> {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
WHEN 'admin' THEN 1
|
|
86
|
-
WHEN 'member' THEN 2
|
|
87
|
-
WHEN 'viewer' THEN 3
|
|
88
|
-
ELSE 4
|
|
89
|
-
END
|
|
90
|
-
SQL
|
|
87
|
+
roles = Roles.hierarchy # one snapshot for the ranks and the fallback
|
|
88
|
+
ranks = roles.each_with_index.map { |role, index| "WHEN #{connection.quote(role.to_s)} THEN #{index}" }
|
|
89
|
+
order(Arel.sql("CASE role #{ranks.join(' ')} ELSE #{roles.length} END"))
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
# === Verified Joining (v0.5.0) ===
|
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,8 +114,10 @@ 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
|
|
|
@@ -146,19 +152,18 @@ module Organizations
|
|
|
146
152
|
# Get the pre-computed permission hash (for direct access)
|
|
147
153
|
# @return [Hash<Symbol, Array<Symbol>>]
|
|
148
154
|
def permissions
|
|
149
|
-
|
|
155
|
+
snapshot.permissions
|
|
150
156
|
end
|
|
151
157
|
|
|
152
158
|
# Get the pre-computed permission sets (for O(1) lookups)
|
|
153
159
|
# @return [Hash<Symbol, Set<Symbol>>]
|
|
154
160
|
def permission_sets
|
|
155
|
-
|
|
161
|
+
snapshot.permission_sets
|
|
156
162
|
end
|
|
157
163
|
|
|
158
164
|
# Reset computed permissions (used when custom roles are defined)
|
|
159
165
|
def reset!
|
|
160
|
-
@
|
|
161
|
-
@permission_sets = nil
|
|
166
|
+
@snapshot = nil
|
|
162
167
|
end
|
|
163
168
|
|
|
164
169
|
# Get the next role up in hierarchy
|
|
@@ -167,10 +172,11 @@ module Organizations
|
|
|
167
172
|
def higher_role(role)
|
|
168
173
|
return nil if role.nil?
|
|
169
174
|
|
|
170
|
-
|
|
175
|
+
order = hierarchy # one snapshot for the lookup and the result
|
|
176
|
+
idx = order.index(role.to_sym)
|
|
171
177
|
return nil unless idx && idx > 0
|
|
172
178
|
|
|
173
|
-
|
|
179
|
+
order[idx - 1]
|
|
174
180
|
end
|
|
175
181
|
|
|
176
182
|
# Get the next role down in hierarchy
|
|
@@ -179,23 +185,54 @@ module Organizations
|
|
|
179
185
|
def lower_role(role)
|
|
180
186
|
return nil if role.nil?
|
|
181
187
|
|
|
182
|
-
|
|
183
|
-
|
|
188
|
+
order = hierarchy # one snapshot for the lookup and the result
|
|
189
|
+
idx = order.index(role.to_sym)
|
|
190
|
+
return nil unless idx && idx < order.length - 1
|
|
184
191
|
|
|
185
|
-
|
|
192
|
+
order[idx + 1]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The role hierarchy (highest first) of a roles definition: the four
|
|
196
|
+
# built-in roles, and each custom role directly above the role it
|
|
197
|
+
# inherits from (a custom role without `inherits:` sits at the bottom).
|
|
198
|
+
def hierarchy_for(definition)
|
|
199
|
+
return HIERARCHY unless definition
|
|
200
|
+
|
|
201
|
+
builder = RoleBuilder.new
|
|
202
|
+
builder.instance_eval(&definition)
|
|
203
|
+
builder.validate_privileges!
|
|
204
|
+
builder.hierarchy
|
|
186
205
|
end
|
|
187
206
|
|
|
188
207
|
private
|
|
189
208
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
209
|
+
# The hierarchy and permissions of one roles definition, computed
|
|
210
|
+
# together and published as one immutable value. It belongs to the
|
|
211
|
+
# definition object it was built from: after a reconfiguration (or a
|
|
212
|
+
# rolled-back one) the next reader sees the identity change and builds a
|
|
213
|
+
# new snapshot, so a reader that raced a reset can never keep an
|
|
214
|
+
# obsolete hierarchy in place.
|
|
215
|
+
def snapshot
|
|
216
|
+
definition = Organizations.configuration&.custom_roles_definition
|
|
217
|
+
current = @snapshot
|
|
218
|
+
return current if current && current.definition.equal?(definition)
|
|
219
|
+
|
|
220
|
+
@snapshot = build_snapshot(definition)
|
|
221
|
+
end
|
|
194
222
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
223
|
+
def build_snapshot(definition)
|
|
224
|
+
if definition
|
|
225
|
+
builder = RoleBuilder.new
|
|
226
|
+
builder.instance_eval(&definition)
|
|
227
|
+
builder.validate_privileges!
|
|
228
|
+
hierarchy = builder.hierarchy
|
|
229
|
+
permissions = builder.to_permissions
|
|
230
|
+
else
|
|
231
|
+
hierarchy = HIERARCHY
|
|
232
|
+
permissions = DEFAULT_PERMISSIONS
|
|
233
|
+
end
|
|
234
|
+
sets = permissions.transform_values { |perms| Set.new(perms).freeze }.freeze
|
|
235
|
+
Snapshot.new(definition, hierarchy, permissions, sets).freeze
|
|
199
236
|
end
|
|
200
237
|
end
|
|
201
238
|
|
|
@@ -211,6 +248,10 @@ module Organizations
|
|
|
211
248
|
# @param inherits [Symbol, nil] Role to inherit from
|
|
212
249
|
# @yield Block to define permissions
|
|
213
250
|
def role(name, inherits: nil, &block)
|
|
251
|
+
name = name.to_sym
|
|
252
|
+
inherits = inherits&.to_sym
|
|
253
|
+
validate_role_definition!(name, inherits)
|
|
254
|
+
|
|
214
255
|
@roles[name] = {
|
|
215
256
|
inherits: inherits,
|
|
216
257
|
permissions: []
|
|
@@ -228,28 +269,94 @@ module Organizations
|
|
|
228
269
|
@roles[@current_role][:permissions] << permission.to_sym
|
|
229
270
|
end
|
|
230
271
|
|
|
272
|
+
# The hierarchy, highest first: the built-in roles in their fixed order,
|
|
273
|
+
# and each custom role inserted directly above the role it inherits from.
|
|
274
|
+
# @return [Array<Symbol>]
|
|
275
|
+
def hierarchy
|
|
276
|
+
order = Roles::HIERARCHY.dup
|
|
277
|
+
custom_roles.each do |name|
|
|
278
|
+
parent = @roles[name][:inherits]
|
|
279
|
+
parent ? order.insert(order.index(parent), name) : order << name
|
|
280
|
+
end
|
|
281
|
+
order.freeze
|
|
282
|
+
end
|
|
283
|
+
|
|
231
284
|
# Build final permissions hash with inheritance resolved
|
|
232
285
|
# @return [Hash<Symbol, Array<Symbol>>]
|
|
233
286
|
def to_permissions
|
|
234
|
-
result = {}
|
|
235
|
-
|
|
236
287
|
# Process roles in reverse hierarchy order (lowest first)
|
|
237
288
|
# so inheritance works correctly
|
|
238
|
-
|
|
239
|
-
|
|
289
|
+
hierarchy.reverse_each.with_object({}) do |role_name, result|
|
|
290
|
+
perms = @roles.key?(role_name) ? defined_permissions(role_name, result) : omitted_permissions(role_name)
|
|
291
|
+
result[role_name] = perms.freeze if perms
|
|
292
|
+
end.freeze
|
|
293
|
+
end
|
|
240
294
|
|
|
241
|
-
|
|
242
|
-
|
|
295
|
+
# An admin can assign a custom role, so a custom role may not grant a
|
|
296
|
+
# permission that the owner role has and the admin role lacks (for
|
|
297
|
+
# example manage_billing or transfer_ownership). Otherwise an admin
|
|
298
|
+
# could give another account a power that the admin does not have.
|
|
299
|
+
def validate_privileges!
|
|
300
|
+
validate_built_in_parents!
|
|
301
|
+
permissions = to_permissions
|
|
302
|
+
owner_only = (permissions[:owner] || DEFAULT_PERMISSIONS[:owner]) -
|
|
303
|
+
(permissions[:admin] || DEFAULT_PERMISSIONS[:admin])
|
|
304
|
+
custom_roles.each do |name|
|
|
305
|
+
granted = @roles[name][:permissions] & owner_only
|
|
306
|
+
next if granted.empty?
|
|
307
|
+
|
|
308
|
+
raise ConfigurationError, "Custom role #{name} cannot grant #{granted.join(', ')}: " \
|
|
309
|
+
"admins can assign it, and only the owner has that permission"
|
|
310
|
+
end
|
|
311
|
+
end
|
|
243
312
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
313
|
+
private
|
|
314
|
+
|
|
315
|
+
# A built-in role may inherit a custom role only when that custom role
|
|
316
|
+
# ranks below it; otherwise the inheritance would be dropped silently.
|
|
317
|
+
def validate_built_in_parents!
|
|
318
|
+
order = hierarchy
|
|
319
|
+
(@roles.keys & Roles::HIERARCHY).each do |name|
|
|
320
|
+
parent = @roles[name][:inherits]
|
|
321
|
+
next unless parent && custom_roles.include?(parent)
|
|
322
|
+
next if order.index(parent) > order.index(name)
|
|
248
323
|
|
|
249
|
-
|
|
324
|
+
raise ConfigurationError, "Role #{name} cannot inherit from #{parent}: #{parent} ranks above #{name}"
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def custom_roles = @roles.keys - Roles::HIERARCHY
|
|
329
|
+
|
|
330
|
+
# A role's own permissions plus its parent's. A custom role that inherits
|
|
331
|
+
# a built-in role the block does not redefine inherits its defaults.
|
|
332
|
+
def defined_permissions(role_name, result)
|
|
333
|
+
role_def = @roles[role_name]
|
|
334
|
+
parent = role_def[:inherits]
|
|
335
|
+
inherited = parent && (result[parent] || (custom_roles.include?(role_name) && DEFAULT_PERMISSIONS[parent]))
|
|
336
|
+
inherited ? (inherited + role_def[:permissions]).uniq : role_def[:permissions].dup
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# A block that only adds custom roles keeps the built-in roles' defaults.
|
|
340
|
+
# An empty block still defines no permissions, as before custom roles.
|
|
341
|
+
def omitted_permissions(role_name) = custom_only? ? DEFAULT_PERMISSIONS[role_name] : nil
|
|
342
|
+
def custom_only? = custom_roles.any? && !@roles.keys.intersect?(Roles::HIERARCHY)
|
|
343
|
+
|
|
344
|
+
# A custom role needs a plain name, and a parent that already exists (a
|
|
345
|
+
# built-in role below admin or a custom role defined above it). Nothing
|
|
346
|
+
# inherits admin or owner.
|
|
347
|
+
def validate_role_definition!(name, inherits)
|
|
348
|
+
unless name.to_s.match?(/\A[a-z][a-z0-9_]{0,31}\z/)
|
|
349
|
+
raise ConfigurationError, "Role name #{name.inspect} must be lowercase letters, digits and underscores"
|
|
350
|
+
end
|
|
351
|
+
return if Roles::HIERARCHY.include?(name)
|
|
352
|
+
raise ConfigurationError, "Role #{name} is defined twice" if @roles.key?(name)
|
|
353
|
+
# A custom role ranks below admin, so it never outranks the people who can assign it.
|
|
354
|
+
if %i[owner admin].include?(inherits)
|
|
355
|
+
raise ConfigurationError, "Custom role #{name} cannot inherit from #{inherits}: a custom role ranks below admin"
|
|
250
356
|
end
|
|
357
|
+
return if inherits.nil? || %i[member viewer].include?(inherits) || @roles.key?(inherits)
|
|
251
358
|
|
|
252
|
-
|
|
359
|
+
raise ConfigurationError, "Custom role #{name} inherits from #{inherits}, which is not defined above it"
|
|
253
360
|
end
|
|
254
361
|
end
|
|
255
362
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: organizations
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-17 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|