rbacan 0.4.0 → 0.5.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/CHANGELOG.md +25 -0
- data/README.md +97 -15
- data/lib/generators/install/templates/rbacan.rb +25 -25
- data/lib/rbacan/authorizable.rb +15 -0
- data/lib/rbacan/authorization.rb +37 -9
- data/lib/rbacan/host_authorization.rb +165 -0
- data/lib/rbacan/not_authorized.rb +10 -3
- data/lib/rbacan/permittable.rb +39 -28
- data/lib/rbacan/roles_and_permissions.rb +12 -4
- data/lib/rbacan/route_constraint.rb +21 -18
- data/lib/rbacan/version.rb +1 -1
- data/lib/rbacan/view_helpers.rb +13 -8
- data/lib/rbacan.rb +26 -4
- metadata +4 -12
- data/.gitignore +0 -14
- data/.rspec +0 -3
- data/.travis.yml +0 -7
- data/CLAUDE.md +0 -51
- data/Gemfile +0 -4
- data/Gemfile.lock +0 -351
- data/Rakefile +0 -6
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/rbacan.gemspec +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d73e61377cfc8c44c60d79857c8a5c09be75ddd8c7e6bd1547895dc11f03a673
|
|
4
|
+
data.tar.gz: 7027bf24c300f0996fd12cb94875bfb16ab24f0119b4442547da1ea054fb365b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5363d5c569e9b65c754066baac8407b7d4047b9ecb11c5ba81ac66cec9f2fe2096837feb4e1ce3940cf53374fddeaa0feea034553917285e1871c0f6f968f2b7
|
|
7
|
+
data.tar.gz: ac1e65f2064108e82a9686b895b1ae1ea6f7badc9a49b3bcdc5e299f861413b9aaedc0bc3ad17605c0cbd10ea95ca5abf2872abaa23af9067bf7480dd0692a8a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-09-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Host-owned authorization through immutable, value-based `Rbacan::AuthorizationContext` and pluggable authorization/assignment adapters.
|
|
10
|
+
- Association-free `Rbacan::Authorizable` concern for applications that own their RBAC schema and lifecycle.
|
|
11
|
+
- Explicit user and context resolvers for hardened route constraints.
|
|
12
|
+
- Stable denial codes on `Rbacan::NotAuthorized` for host-side localization.
|
|
13
|
+
- Configurable `role_key` and `permission_key` columns.
|
|
14
|
+
- Deeply immutable JSON-like authorization metadata and copied immutable target codes.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Hardened mode is the default. Authorization requires an explicit context and fails closed without a host adapter.
|
|
19
|
+
- Destructive/unscoped `Rbacan::Permittable`, catalog, role-permission, and seed-helper APIs require explicit `legacy_mode = true` opt-in.
|
|
20
|
+
- Authorization contexts require a tenant and snapshot/freeze JSON-like tenant, property, vertical, and subject identifiers plus their timestamp.
|
|
21
|
+
- Controller and view authorization use the host adapter in hardened mode.
|
|
22
|
+
- Hardened redirect denials resolve their stable code through the host I18n backend.
|
|
23
|
+
- Assignment dispatch rejects missing users or blank roles before invoking the host adapter.
|
|
24
|
+
- Gem packaging uses an explicit runtime/documentation allowlist and excludes repository-only files.
|
|
25
|
+
|
|
26
|
+
### Compatibility
|
|
27
|
+
|
|
28
|
+
- Applications using the generated 0.4 schema must consciously enable legacy mode while migrating.
|
|
29
|
+
|
|
5
30
|
## [0.4.0] - 2026-04-13
|
|
6
31
|
|
|
7
32
|
### Added
|
data/README.md
CHANGED
|
@@ -38,6 +38,60 @@ bundle install
|
|
|
38
38
|
|
|
39
39
|
## Setup
|
|
40
40
|
|
|
41
|
+
RBACan 0.5 has two explicit integration modes:
|
|
42
|
+
|
|
43
|
+
- **Host-owned mode (recommended):** the application owns schema, tenancy, scopes, assignment history,
|
|
44
|
+
and record policies. Include `Rbacan::Authorizable` and configure adapters. Do not run the generator.
|
|
45
|
+
- **Legacy generated-schema mode:** preserves the 0.4 Active Record API only after conscious
|
|
46
|
+
`config.legacy_mode = true` opt-in. Its removal methods delete join rows and are unsuitable for
|
|
47
|
+
immutable assignment-history requirements.
|
|
48
|
+
|
|
49
|
+
### Host-owned mode
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
class User < ApplicationRecord
|
|
53
|
+
include Rbacan::Authorizable
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Rbacan.configure do |config|
|
|
57
|
+
config.role_class = 'Role'
|
|
58
|
+
config.role_key = :code
|
|
59
|
+
config.permission_class = 'Permission'
|
|
60
|
+
config.permission_key = :code
|
|
61
|
+
config.authorization_adapter = MyAuthorizationAdapter.new
|
|
62
|
+
config.assignment_adapter = MyAssignmentAdapter.new
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Every check receives an explicit immutable value context. Pass stable identifiers or JSON-like
|
|
67
|
+
references, not live Active Record objects:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
context = Rbacan::AuthorizationContext.new(
|
|
71
|
+
tenant: current_tenant.id,
|
|
72
|
+
property: current_property.id,
|
|
73
|
+
vertical: current_property.property_type.to_s,
|
|
74
|
+
subject: { type: reservation.class.name, id: reservation.id },
|
|
75
|
+
at: Time.current
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
current_user.can?(:hotel_reservations_read, context: context)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The tenant identifier is required and cannot be nil, boolean, or empty. Tenant, property, vertical,
|
|
82
|
+
subject, metadata, and timestamp values are copied
|
|
83
|
+
and frozen when the context is created. Context identifiers must come from records already loaded
|
|
84
|
+
through the host application's authenticated scope; the authorization adapter resolves and validates
|
|
85
|
+
their relationships under the host's tenancy/RLS rules. The request's `user` remains the live host
|
|
86
|
+
principal object.
|
|
87
|
+
|
|
88
|
+
Adapters receive one request object. Authorization adapters respond to `allowed?(request)` or `call`;
|
|
89
|
+
assignment adapters respond to `assign(request)` and `revoke(request)`. RBACan never prescribes deletion
|
|
90
|
+
or caching in host-owned mode. Optional context metadata is deeply copied and frozen and accepts only
|
|
91
|
+
hashes, arrays, strings, finite integers/floats, booleans, nil, and symbols.
|
|
92
|
+
|
|
93
|
+
### Legacy generated-schema mode
|
|
94
|
+
|
|
41
95
|
**1. Run the generator**
|
|
42
96
|
|
|
43
97
|
```bash
|
|
@@ -72,13 +126,21 @@ rails db:seed
|
|
|
72
126
|
|
|
73
127
|
## Configuration
|
|
74
128
|
|
|
75
|
-
The generator creates `config/initializers/rbacan.rb`.
|
|
129
|
+
The generator creates `config/initializers/rbacan.rb`. Generated-schema applications must explicitly
|
|
130
|
+
enable legacy mode; the remaining defaults then work for a standard Devise setup.
|
|
76
131
|
|
|
77
132
|
```ruby
|
|
78
133
|
Rbacan.configure do |config|
|
|
134
|
+
# Conscious opt-in required for the destructive/unscoped 0.4 API while migrating.
|
|
135
|
+
config.legacy_mode = true
|
|
136
|
+
|
|
79
137
|
# Your user model name (default: "User")
|
|
80
138
|
config.permittable_class = "User"
|
|
81
139
|
|
|
140
|
+
# Host key columns default to :name.
|
|
141
|
+
# config.role_key = :code
|
|
142
|
+
# config.permission_key = :code
|
|
143
|
+
|
|
82
144
|
# Override model class names if you have custom implementations
|
|
83
145
|
# config.role_class = "Rbacan::Role"
|
|
84
146
|
# config.permission_class = "Rbacan::Permission"
|
|
@@ -107,7 +169,7 @@ end
|
|
|
107
169
|
|
|
108
170
|
## Defining Roles and Permissions
|
|
109
171
|
|
|
110
|
-
|
|
172
|
+
In legacy mode, use the `Rbacan::RolesAndPermissions` module in your seeds. All methods are idempotent — safe to run multiple times. Hardened mode rejects these unscoped helpers; host-owned applications must seed their own scoped authorization records.
|
|
111
173
|
|
|
112
174
|
```ruby
|
|
113
175
|
# db/seeds.rb
|
|
@@ -201,9 +263,20 @@ Include `Rbacan::Authorization` in your `ApplicationController` (or any specific
|
|
|
201
263
|
```ruby
|
|
202
264
|
class ApplicationController < ActionController::Base
|
|
203
265
|
include Rbacan::Authorization
|
|
266
|
+
helper_method :rbacan_authorization_context
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def rbacan_authorization_context
|
|
271
|
+
AuthorizationContextResolver.call(request: request, user: current_user, subject: @post)
|
|
272
|
+
end
|
|
204
273
|
end
|
|
205
274
|
```
|
|
206
275
|
|
|
276
|
+
The host resolver must build the immutable identifier context only after authenticating the user and
|
|
277
|
+
loading tenant, property, and optional subject records through the authorized scope.
|
|
278
|
+
`AuthorizationContextResolver` is application-defined; RBACan does not infer these values.
|
|
279
|
+
|
|
207
280
|
Then use `authorize!` or `authorize_role!` as `before_action` callbacks:
|
|
208
281
|
|
|
209
282
|
```ruby
|
|
@@ -255,7 +328,9 @@ config.unauthorized_handler = ->(controller, permission:, role:) {
|
|
|
255
328
|
|
|
256
329
|
## View Helpers
|
|
257
330
|
|
|
258
|
-
`Rbacan::ViewHelpers` is automatically included in all views.
|
|
331
|
+
`Rbacan::ViewHelpers` is automatically included in all views. In host-owned mode the host must expose
|
|
332
|
+
`current_user` and `rbacan_authorization_context` to the view. Use `authorized?` to conditionally
|
|
333
|
+
render content:
|
|
259
334
|
|
|
260
335
|
```erb
|
|
261
336
|
<% authorized?(:delete_post) do %>
|
|
@@ -267,40 +342,47 @@ config.unauthorized_handler = ->(controller, permission:, role:) {
|
|
|
267
342
|
<% end %>
|
|
268
343
|
```
|
|
269
344
|
|
|
270
|
-
You can also use `has_role?`
|
|
345
|
+
You can also use `has_role?` directly in views through the host-owned concern:
|
|
271
346
|
|
|
272
347
|
```erb
|
|
273
|
-
<% if current_user.has_role?(:admin) %>
|
|
348
|
+
<% if current_user.has_role?(:admin, context: rbacan_authorization_context) %>
|
|
274
349
|
<%= link_to "Admin Panel", admin_root_path %>
|
|
275
350
|
<% end %>
|
|
276
|
-
|
|
277
|
-
<% if current_user.has_any_role?(:admin, :moderator) %>
|
|
278
|
-
<%= link_to "Moderation Queue", moderation_path %>
|
|
279
|
-
<% end %>
|
|
280
351
|
```
|
|
281
352
|
|
|
282
353
|
---
|
|
283
354
|
|
|
284
355
|
## Route Constraints
|
|
285
356
|
|
|
286
|
-
Restrict access to entire route namespaces based on role or permission.
|
|
357
|
+
Restrict access to entire route namespaces based on role or permission. Hardened mode requires explicit
|
|
358
|
+
request resolvers:
|
|
287
359
|
|
|
288
360
|
```ruby
|
|
289
|
-
|
|
290
|
-
|
|
361
|
+
resolve_user = ->(request) { CurrentSessionResolver.call(request) }
|
|
362
|
+
resolve_context = ->(request) { AuthorizationContextResolver.call(request) }
|
|
363
|
+
|
|
364
|
+
constraints Rbacan::RouteConstraint.new(
|
|
365
|
+
role: :admin,
|
|
366
|
+
user_resolver: resolve_user,
|
|
367
|
+
context_resolver: resolve_context
|
|
368
|
+
) do
|
|
291
369
|
namespace :admin do
|
|
292
370
|
resources :users
|
|
293
371
|
resources :roles
|
|
294
372
|
end
|
|
295
373
|
end
|
|
296
374
|
|
|
297
|
-
|
|
298
|
-
|
|
375
|
+
constraints Rbacan::RouteConstraint.new(
|
|
376
|
+
permission: :access_dashboard,
|
|
377
|
+
user_resolver: resolve_user,
|
|
378
|
+
context_resolver: resolve_context
|
|
379
|
+
) do
|
|
299
380
|
get "/dashboard", to: "dashboard#index"
|
|
300
381
|
end
|
|
301
382
|
```
|
|
302
383
|
|
|
303
|
-
|
|
384
|
+
Without both resolvers hardened constraints fail closed. In explicitly enabled legacy mode only, the
|
|
385
|
+
constraint retains the 0.4 behavior of reading Warden or `session[:user_id]`.
|
|
304
386
|
|
|
305
387
|
---
|
|
306
388
|
|
|
@@ -1,31 +1,38 @@
|
|
|
1
1
|
Rbacan.configure do |config|
|
|
2
|
-
#
|
|
2
|
+
# Hardened host-owned mode is the default. The generated 0.4-style schema remains
|
|
3
|
+
# inactive until the host consciously opts into its destructive/unscoped APIs:
|
|
4
|
+
# config.legacy_mode = true
|
|
5
|
+
|
|
6
|
+
# Name of your user model (default: "User")
|
|
3
7
|
# config.permittable_class = "User"
|
|
4
8
|
|
|
5
|
-
# Override
|
|
6
|
-
# config.role_class
|
|
7
|
-
# config.permission_class
|
|
8
|
-
# config.user_role_class
|
|
9
|
-
# config.role_permission_class
|
|
9
|
+
# Override ActiveRecord model class names if you use custom models
|
|
10
|
+
# config.role_class = "Rbacan::Role"
|
|
11
|
+
# config.permission_class = "Rbacan::Permission"
|
|
12
|
+
# config.user_role_class = "Rbacan::UserRole"
|
|
13
|
+
# config.role_permission_class = "Rbacan::RolePermission"
|
|
14
|
+
|
|
15
|
+
# Host role/permission lookup columns (default: :name)
|
|
16
|
+
# config.role_key = :code
|
|
17
|
+
# config.permission_key = :code
|
|
10
18
|
|
|
11
19
|
# Override table names if needed
|
|
12
|
-
# config.role_table
|
|
13
|
-
# config.permission_table
|
|
14
|
-
# config.user_role_table
|
|
15
|
-
# config.role_permission_table
|
|
16
|
-
|
|
17
|
-
# Primary key type for
|
|
18
|
-
# Set
|
|
19
|
-
# When nil, auto-detects
|
|
20
|
+
# config.role_table = "roles"
|
|
21
|
+
# config.permission_table = "permissions"
|
|
22
|
+
# config.user_role_table = "user_roles"
|
|
23
|
+
# config.role_permission_table = "role_permissions"
|
|
24
|
+
|
|
25
|
+
# Primary key type for migrations.
|
|
26
|
+
# Set :uuid if your app uses UUID primary keys.
|
|
27
|
+
# When nil, auto-detects Rails generator config (primary_key_type).
|
|
20
28
|
# config.primary_key_type = :uuid
|
|
21
29
|
|
|
22
30
|
# Enable tenant scoping for roles and user_roles.
|
|
23
|
-
# When true, adds tenant_id to
|
|
24
|
-
#
|
|
25
|
-
# Permissions always remain global regardless of this setting.
|
|
31
|
+
# When true, adds tenant_id to roles and user_roles.
|
|
32
|
+
# Permissions remain global regardless of this setting.
|
|
26
33
|
# config.tenant_scoped = false
|
|
27
34
|
|
|
28
|
-
#
|
|
35
|
+
# Name of your tenant model class (default: "Tenant").
|
|
29
36
|
# Only used when tenant_scoped is true.
|
|
30
37
|
# config.tenant_class = "Tenant"
|
|
31
38
|
|
|
@@ -33,13 +40,6 @@ Rbacan.configure do |config|
|
|
|
33
40
|
# :raise — raises Rbacan::NotAuthorized (default)
|
|
34
41
|
# :redirect — redirects to unauthorized_redirect_path
|
|
35
42
|
# lambda — called with (controller, permission:, role:)
|
|
36
|
-
#
|
|
37
43
|
# config.unauthorized_handler = :raise
|
|
38
|
-
# config.unauthorized_handler = :redirect
|
|
39
|
-
# config.unauthorized_handler = ->(controller, permission:, role:) {
|
|
40
|
-
# controller.render plain: "Forbidden", status: :forbidden
|
|
41
|
-
# }
|
|
42
|
-
|
|
43
|
-
# Path to redirect to when unauthorized_handler is :redirect
|
|
44
44
|
# config.unauthorized_redirect_path = "/"
|
|
45
45
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require 'active_support/concern'
|
|
2
|
+
|
|
3
|
+
module Rbacan
|
|
4
|
+
module Authorizable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
def can?(permission, context:)
|
|
8
|
+
Rbacan.authorized?(user: self, permission: permission, context: context)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def has_role?(role, context:)
|
|
12
|
+
Rbacan.authorized?(user: self, role: role, context: context)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/rbacan/authorization.rb
CHANGED
|
@@ -1,29 +1,51 @@
|
|
|
1
1
|
require 'active_support/concern'
|
|
2
|
+
require 'i18n'
|
|
2
3
|
|
|
3
4
|
module Rbacan
|
|
4
5
|
module Authorization
|
|
5
6
|
extend ActiveSupport::Concern
|
|
6
7
|
|
|
7
|
-
included do
|
|
8
|
-
helper_method :authorized? if respond_to?(:helper_method)
|
|
9
|
-
end
|
|
10
|
-
|
|
11
8
|
# Calls the unauthorized handler if current_user lacks the given permission.
|
|
12
|
-
def authorize!(permission)
|
|
13
|
-
|
|
9
|
+
def authorize!(permission, context: nil)
|
|
10
|
+
allowed = if Rbacan.legacy_mode
|
|
11
|
+
current_user&.can?(permission.to_s)
|
|
12
|
+
else
|
|
13
|
+
Rbacan.authorized?(
|
|
14
|
+
user: current_user,
|
|
15
|
+
permission: permission,
|
|
16
|
+
context: context || rbacan_authorization_context
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
unless allowed
|
|
14
21
|
_handle_unauthorized(permission: permission.to_s)
|
|
15
22
|
end
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
# Calls the unauthorized handler if current_user lacks the given role.
|
|
19
|
-
def authorize_role!(role)
|
|
20
|
-
|
|
26
|
+
def authorize_role!(role, context: nil)
|
|
27
|
+
allowed = if Rbacan.legacy_mode
|
|
28
|
+
current_user&.has_role?(role.to_s)
|
|
29
|
+
else
|
|
30
|
+
Rbacan.authorized?(
|
|
31
|
+
user: current_user,
|
|
32
|
+
role: role,
|
|
33
|
+
context: context || rbacan_authorization_context
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
unless allowed
|
|
21
38
|
_handle_unauthorized(role: role.to_s)
|
|
22
39
|
end
|
|
23
40
|
end
|
|
24
41
|
|
|
25
42
|
private
|
|
26
43
|
|
|
44
|
+
def rbacan_authorization_context
|
|
45
|
+
raise Rbacan::ConfigurationError,
|
|
46
|
+
'authorization context is required; define rbacan_authorization_context in the host controller'
|
|
47
|
+
end
|
|
48
|
+
|
|
27
49
|
def _handle_unauthorized(permission: nil, role: nil)
|
|
28
50
|
handler = Rbacan.unauthorized_handler
|
|
29
51
|
|
|
@@ -31,8 +53,14 @@ module Rbacan
|
|
|
31
53
|
when :raise
|
|
32
54
|
raise Rbacan::NotAuthorized.new(nil, permission: permission, role: role)
|
|
33
55
|
when :redirect
|
|
56
|
+
error = Rbacan::NotAuthorized.new(nil, permission: permission, role: role)
|
|
57
|
+
alert = if Rbacan.legacy_mode
|
|
58
|
+
error.message
|
|
59
|
+
else
|
|
60
|
+
I18n.t(error.code, permission: permission, role: role, raise: true)
|
|
61
|
+
end
|
|
34
62
|
redirect_to Rbacan.unauthorized_redirect_path,
|
|
35
|
-
alert:
|
|
63
|
+
alert: alert
|
|
36
64
|
else
|
|
37
65
|
if handler.respond_to?(:call)
|
|
38
66
|
handler.call(self, permission: permission, role: role)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
module Rbacan
|
|
2
|
+
class ConfigurationError < StandardError; end
|
|
3
|
+
class UnsafeLegacyApi < StandardError; end
|
|
4
|
+
|
|
5
|
+
class AuthorizationContext
|
|
6
|
+
ATTRIBUTES = %i[tenant property vertical subject at metadata].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader(*ATTRIBUTES)
|
|
9
|
+
|
|
10
|
+
def initialize(tenant:, property: nil, vertical: nil, subject: nil, at: Time.now, metadata: {})
|
|
11
|
+
raise ArgumentError, 'authorization tenant must identify a tenant' unless identifying_tenant?(tenant)
|
|
12
|
+
|
|
13
|
+
@tenant = deep_copy_and_freeze(tenant, label: 'authorization tenant')
|
|
14
|
+
@property = deep_copy_and_freeze(property, label: 'authorization property')
|
|
15
|
+
@vertical = deep_copy_and_freeze(vertical, label: 'authorization vertical')
|
|
16
|
+
@subject = deep_copy_and_freeze(subject, label: 'authorization subject')
|
|
17
|
+
@at = copy_and_freeze_time(at)
|
|
18
|
+
@metadata = deep_copy_and_freeze(metadata, label: 'authorization metadata')
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def deep_copy_and_freeze(value, label:)
|
|
25
|
+
copy = case value
|
|
26
|
+
when Hash
|
|
27
|
+
value.to_h do |key, item|
|
|
28
|
+
[ deep_copy_and_freeze(key, label: label), deep_copy_and_freeze(item, label: label) ]
|
|
29
|
+
end
|
|
30
|
+
when Array
|
|
31
|
+
value.map { |item| deep_copy_and_freeze(item, label: label) }
|
|
32
|
+
when String
|
|
33
|
+
value.dup
|
|
34
|
+
when Integer
|
|
35
|
+
value
|
|
36
|
+
when Float
|
|
37
|
+
raise ArgumentError, "#{label} supports only finite JSON numbers" unless value.finite?
|
|
38
|
+
|
|
39
|
+
value
|
|
40
|
+
when NilClass, TrueClass, FalseClass, Symbol
|
|
41
|
+
value
|
|
42
|
+
else
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"#{label} supports only hashes, arrays, strings, numbers, booleans, nil, and symbols"
|
|
45
|
+
end
|
|
46
|
+
copy.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def copy_and_freeze_time(value)
|
|
50
|
+
time = value.to_time if value.respond_to?(:to_time)
|
|
51
|
+
raise ArgumentError, 'authorization timestamp must be time-like' unless time.is_a?(Time)
|
|
52
|
+
|
|
53
|
+
time.dup.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def identifying_tenant?(value)
|
|
57
|
+
case value
|
|
58
|
+
when String, Array, Hash
|
|
59
|
+
!value.empty?
|
|
60
|
+
when Integer
|
|
61
|
+
true
|
|
62
|
+
when Float
|
|
63
|
+
value.finite?
|
|
64
|
+
when Symbol
|
|
65
|
+
!value.to_s.empty?
|
|
66
|
+
else
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class AuthorizationRequest
|
|
73
|
+
attr_reader :user, :permission, :role, :context
|
|
74
|
+
|
|
75
|
+
def initialize(user:, permission:, role:, context:)
|
|
76
|
+
@user = user
|
|
77
|
+
@permission = permission&.to_s&.dup&.freeze
|
|
78
|
+
@role = role&.to_s&.dup&.freeze
|
|
79
|
+
@context = context
|
|
80
|
+
freeze
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class AssignmentRequest
|
|
85
|
+
attr_reader :user, :role, :context
|
|
86
|
+
|
|
87
|
+
def initialize(user:, role:, context:)
|
|
88
|
+
@user = user
|
|
89
|
+
@role = role.to_s.dup.freeze
|
|
90
|
+
@context = context
|
|
91
|
+
freeze
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class << self
|
|
96
|
+
def ensure_legacy_mode!(method_name)
|
|
97
|
+
return if legacy_mode
|
|
98
|
+
|
|
99
|
+
raise UnsafeLegacyApi,
|
|
100
|
+
"#{method_name} is a legacy unscoped API; set Rbacan.legacy_mode = true to opt in"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def authorized?(user:, permission: nil, role: nil, context:)
|
|
104
|
+
validate_authorization_target!(permission, role)
|
|
105
|
+
validate_context!(context)
|
|
106
|
+
return false unless user && authorization_adapter
|
|
107
|
+
|
|
108
|
+
request = AuthorizationRequest.new(
|
|
109
|
+
user: user,
|
|
110
|
+
permission: permission&.to_s,
|
|
111
|
+
role: role&.to_s,
|
|
112
|
+
context: context
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
!!call_authorization_adapter(request)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def assign_role(user:, role:, context:)
|
|
119
|
+
call_assignment_adapter(:assign, user: user, role: role, context: context)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def revoke_role(user:, role:, context:)
|
|
123
|
+
call_assignment_adapter(:revoke, user: user, role: role, context: context)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def validate_authorization_target!(permission, role)
|
|
129
|
+
if permission.nil? ^ role.nil?
|
|
130
|
+
target = permission || role
|
|
131
|
+
raise ArgumentError, 'authorization permission or role cannot be blank' if target.to_s.empty?
|
|
132
|
+
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
raise ArgumentError, 'Provide exactly one permission or role'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def validate_context!(context)
|
|
140
|
+
raise ArgumentError, 'authorization context is required' unless context.is_a?(AuthorizationContext)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def call_authorization_adapter(request)
|
|
144
|
+
if authorization_adapter.respond_to?(:allowed?)
|
|
145
|
+
authorization_adapter.allowed?(request)
|
|
146
|
+
elsif authorization_adapter.respond_to?(:call)
|
|
147
|
+
authorization_adapter.call(request)
|
|
148
|
+
else
|
|
149
|
+
raise ConfigurationError, 'authorization adapter must respond to allowed? or call'
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def call_assignment_adapter(action, user:, role:, context:)
|
|
154
|
+
validate_context!(context)
|
|
155
|
+
raise ArgumentError, 'assignment user is required' unless user
|
|
156
|
+
raise ArgumentError, 'assignment role is required' if role.nil? || role.to_s.empty?
|
|
157
|
+
unless assignment_adapter&.respond_to?(action)
|
|
158
|
+
raise ConfigurationError, "assignment adapter must respond to #{action}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
request = AssignmentRequest.new(user: user, role: role.to_s, context: context)
|
|
162
|
+
assignment_adapter.public_send(action, request)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
module Rbacan
|
|
2
2
|
class NotAuthorized < StandardError
|
|
3
|
-
attr_reader :permission, :role
|
|
3
|
+
attr_reader :permission, :role, :code
|
|
4
4
|
|
|
5
5
|
def initialize(message = nil, permission: nil, role: nil)
|
|
6
6
|
@permission = permission
|
|
7
|
-
@role
|
|
7
|
+
@role = role
|
|
8
|
+
@code = if permission
|
|
9
|
+
'rbacan.not_authorized.permission'
|
|
10
|
+
elsif role
|
|
11
|
+
'rbacan.not_authorized.role'
|
|
12
|
+
else
|
|
13
|
+
'rbacan.not_authorized'
|
|
14
|
+
end
|
|
8
15
|
super(message || default_message)
|
|
9
16
|
end
|
|
10
17
|
|
|
@@ -16,7 +23,7 @@ module Rbacan
|
|
|
16
23
|
elsif role
|
|
17
24
|
"Not authorized: missing role '#{role}'"
|
|
18
25
|
else
|
|
19
|
-
|
|
26
|
+
'Not authorized'
|
|
20
27
|
end
|
|
21
28
|
end
|
|
22
29
|
end
|