argus-trail 0.1.0 → 0.2.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 +61 -0
- data/README.md +52 -35
- data/app/controllers/argus/trail/application_controller.rb +16 -9
- data/app/controllers/argus/trail/permissions_controller.rb +1 -1
- data/app/controllers/argus/trail/roles_controller.rb +1 -1
- data/app/models/argus/trail/permission.rb +36 -0
- data/app/views/argus/trail/permissions/_form.html.erb +21 -3
- data/app/views/argus/trail/permissions/edit.html.erb +1 -1
- data/app/views/argus/trail/permissions/index.html.erb +4 -2
- data/app/views/argus/trail/permissions/show.html.erb +1 -1
- data/app/views/argus/trail/roles/_form.html.erb +30 -12
- data/app/views/argus/trail/roles/index.html.erb +3 -3
- data/app/views/argus/trail/roles/show.html.erb +2 -2
- data/lib/argus/trail/actor.rb +33 -2
- data/lib/argus/trail/authorizable.rb +38 -0
- data/lib/argus/trail/configuration.rb +23 -10
- data/lib/argus/trail/permission_scanner.rb +59 -0
- data/lib/argus/trail/version.rb +1 -1
- data/lib/argus/trail.rb +2 -0
- data/lib/generators/argus/trail/install/install_generator.rb +49 -0
- data/lib/generators/argus/trail/install/templates/POST_INSTALL +27 -25
- data/lib/generators/argus/trail/install/templates/create_argus_trail_tables.rb.erb +16 -0
- data/lib/generators/argus/trail/install/templates/initializer.rb +16 -3
- data/lib/generators/argus/trail/upgrade_v0_2/templates/POST_INSTALL +24 -0
- data/lib/generators/argus/trail/upgrade_v0_2/templates/add_module_wise_permissions_and_audit_index.rb.erb +21 -0
- data/lib/generators/argus/trail/upgrade_v0_2/upgrade_v0_2_generator.rb +42 -0
- data/lib/tasks/argus_trail.rake +40 -0
- metadata +22 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d86d8207fc46e8cafc37a10432ca8c334a0532fab39d679011a48cb08be88a08
|
|
4
|
+
data.tar.gz: 3a6763f5853cb25fd301a6041276c69762e18bdf0fc7dafe1f5f8aaed01809fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b8f6ca3c73d1851e927ddd84a3e7e9916ec83af41d9a5746c4f21b6ff4dec457e0c1cbe65836ac043f36a03762e6d411550231a06aa07ee567657bf60d88afc
|
|
7
|
+
data.tar.gz: 57e1e8ff8a764adf00073a3e4c4a34769186eb62089904636526640e87461a12741a2257ab9aea3e627ee8323e969a91e333883a74c0fe98dfff0e2af9c264bd
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
**Upgrading from 0.1.x? Run this first:**
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bin/rails generate argus:trail:upgrade_v0_2
|
|
9
|
+
bin/rails db:migrate
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
This adds `module_name`/`action` columns to `argus_trail_permissions`, a
|
|
13
|
+
composite index on them, and an index on `argus_trail_audit_entries.created_at`.
|
|
14
|
+
Safe to run on any install — every step is guarded and a no-op if already
|
|
15
|
+
applied.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Module-wise permissions**: `Permission` now supports `module_name`/`action`
|
|
20
|
+
(e.g. `module_name: "admin/accounts", action: "read"`) alongside the
|
|
21
|
+
existing flat `name`/`description` permissions. Name/description auto-derive
|
|
22
|
+
when both are set. The role/permission admin screens group these by module
|
|
23
|
+
with a "select all" toggle.
|
|
24
|
+
- **`bin/rails argus_trail:fetch_permissions`**: scans your app's routes and
|
|
25
|
+
creates a `Permission` for every controller/action pair found (skipping the
|
|
26
|
+
engine's own routes and framework-internal ones), safe to rerun — only
|
|
27
|
+
ever adds. `argus_trail:fetch_permissions:prune` removes permissions whose
|
|
28
|
+
route is gone and aren't granted to any role.
|
|
29
|
+
- **`Argus::Trail::Authorizable`**: a controller concern — `include
|
|
30
|
+
Argus::Trail::Authorizable` gates every action behind the matching
|
|
31
|
+
module-wise permission automatically, no further code needed.
|
|
32
|
+
- **Plug-and-play install**: the install generator now auto-injects `include
|
|
33
|
+
Argus::Trail::Actor` into your actor model and the `current_actor`
|
|
34
|
+
before_action into `ApplicationController` (idempotent, skips gracefully if
|
|
35
|
+
the files don't exist yet).
|
|
36
|
+
- **No more hard-fail without Pundit**: the admin screens default to "any
|
|
37
|
+
signed-in actor" when neither Pundit policies nor `config.authorize_with`
|
|
38
|
+
are set, instead of raising `MissingAuthorization`. Tighten with a policy
|
|
39
|
+
or `authorize_with` as before.
|
|
40
|
+
- `Actor#has_permission?` now accepts either a plain permission name or a
|
|
41
|
+
`(module_name, action)` pair, and is memoized per actor instance (cleared
|
|
42
|
+
by `sync_roles!`) so repeated checks in one request don't re-query.
|
|
43
|
+
- `config.permission_scan_excludes` and `config.action_name_mapper` to
|
|
44
|
+
customize what `fetch_permissions`/`Authorizable` scan and how actions
|
|
45
|
+
normalize.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- `roles#index`/`permissions#index` called `.count` on an already-preloaded
|
|
50
|
+
association inside the row loop — `.count` always re-queries regardless of
|
|
51
|
+
`includes`, unlike `.size`. Switched to `.size` (no behavior change, fewer
|
|
52
|
+
queries).
|
|
53
|
+
- Added the missing index on `argus_trail_audit_entries.created_at` — used by
|
|
54
|
+
both the default `.recent` ordering and the "Today" stat on every page load
|
|
55
|
+
of what's an append-only, ever-growing table.
|
|
56
|
+
|
|
57
|
+
## 0.1.1 / 0.1.0
|
|
58
|
+
|
|
59
|
+
Initial mountable-engine release: roles, permissions, role assignments, and
|
|
60
|
+
a unified audit log, with paginated HTML admin screens and install/views/config
|
|
61
|
+
generators.
|
data/README.md
CHANGED
|
@@ -38,38 +38,65 @@ the engine's own tables (`argus_trail_roles`, `argus_trail_permissions`,
|
|
|
38
38
|
`argus_trail_role_assignments` is a polymorphic join table the engine owns
|
|
39
39
|
end to end.
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
It also wires up the two integration points that used to be manual steps:
|
|
42
|
+
it adds `include Argus::Trail::Actor` to your actor model (`User` by
|
|
43
|
+
default — pass `--actor=YourModel` if it's called something else) and adds
|
|
44
|
+
`before_action { Argus::Trail.current_actor = current_user }` to your
|
|
45
|
+
`ApplicationController`. Both are idempotent (safe to rerun) and skipped
|
|
46
|
+
with an explanatory message if the corresponding file doesn't exist yet.
|
|
47
|
+
|
|
48
|
+
## Generating module-wise permissions
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bin/rails argus_trail:fetch_permissions
|
|
52
|
+
```
|
|
42
53
|
|
|
43
|
-
|
|
54
|
+
Scans your app's routes and creates a `Permission` for every controller
|
|
55
|
+
action it finds (skipping the engine's own routes and framework-internal
|
|
56
|
+
ones), grouped by `module_name` (the controller, e.g. `"admin/accounts"`)
|
|
57
|
+
and `action` (`read`/`create`/`update`/`destroy`, or a custom action name
|
|
58
|
+
as-is). It only ever adds permissions — safe to rerun after adding
|
|
59
|
+
controllers/actions. `bin/rails argus_trail:fetch_permissions:prune` removes
|
|
60
|
+
the ones whose route is gone and that aren't granted to any role.
|
|
61
|
+
|
|
62
|
+
Visit the mounted engine to create roles and check off which module-wise
|
|
63
|
+
permissions (and any permissions you created by hand) each one grants.
|
|
64
|
+
|
|
65
|
+
## Gating your own controllers
|
|
44
66
|
|
|
45
67
|
```ruby
|
|
46
|
-
class
|
|
47
|
-
include Argus::Trail::
|
|
68
|
+
class AccountsController < ApplicationController
|
|
69
|
+
include Argus::Trail::Authorizable
|
|
48
70
|
end
|
|
49
71
|
```
|
|
50
72
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
73
|
+
No further code needed — the required permission is derived automatically
|
|
74
|
+
from the controller and action (the same `module_name`/`action` pair
|
|
75
|
+
`fetch_permissions` generated), using
|
|
76
|
+
`Argus::Trail.config.action_name_mapper`. A signed-in actor without a role
|
|
77
|
+
granting that permission gets a 403.
|
|
78
|
+
|
|
79
|
+
## Wiring up your app
|
|
80
|
+
|
|
81
|
+
**Recording role changes on an actor.** Use `Actor#sync_roles!` (instead of
|
|
82
|
+
assigning `role_ids=`/`roles=` directly) so assignments and revocations land
|
|
83
|
+
in the audit log, and use `has_permission?` to check a permission by name or
|
|
84
|
+
by module + action:
|
|
56
85
|
|
|
57
86
|
```ruby
|
|
58
87
|
user.sync_roles!([ admin_role.id, support_role.id ], changed_by: current_user)
|
|
88
|
+
|
|
89
|
+
user.has_permission?("manage_billing") # a plain, manually created permission
|
|
90
|
+
user.has_permission?("admin/accounts", :read) # a module-wise permission
|
|
59
91
|
```
|
|
60
92
|
|
|
61
93
|
An actor can hold any number of roles at once; assigning a single role is
|
|
62
94
|
just the `new_role_ids.size == 1` case of the same call.
|
|
63
95
|
|
|
64
|
-
**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
**3. Authorize the admin screens.** If you have Pundit, define policies —
|
|
71
|
-
Argus::Trail uses Pundit's normal lookup, so these are just regular
|
|
72
|
-
policies:
|
|
96
|
+
**Authorizing the admin screens.** With neither Pundit nor
|
|
97
|
+
`config.authorize_with` configured, the admin screens default to "any
|
|
98
|
+
signed-in actor" — they work out of the box with zero policies. Tighten
|
|
99
|
+
this with Pundit:
|
|
73
100
|
|
|
74
101
|
```ruby
|
|
75
102
|
class Argus::Trail::RolePolicy < ApplicationPolicy
|
|
@@ -78,11 +105,10 @@ class Argus::Trail::RolePolicy < ApplicationPolicy
|
|
|
78
105
|
end
|
|
79
106
|
```
|
|
80
107
|
|
|
81
|
-
|
|
82
|
-
`Argus::Trail::AuditEntryPolicy
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
Pundit happens to also be in your Gemfile:
|
|
108
|
+
(define the same for `Argus::Trail::PermissionPolicy` and
|
|
109
|
+
`Argus::Trail::AuditEntryPolicy`), or with a proc — which always takes
|
|
110
|
+
priority over Pundit, so this works even if Pundit happens to also be in
|
|
111
|
+
your Gemfile:
|
|
86
112
|
|
|
87
113
|
```ruby
|
|
88
114
|
# config/initializers/argus_trail.rb
|
|
@@ -93,18 +119,7 @@ end
|
|
|
93
119
|
|
|
94
120
|
See [`docs/INTEGRATION_GUIDE.md`](docs/INTEGRATION_GUIDE.md#8-step-6--authorization)
|
|
95
121
|
for a wiring example per authorization gem (Pundit, CanCanCan, Action
|
|
96
|
-
Policy, plain proc).
|
|
97
|
-
the engine fails closed and raises an actionable error rather than silently
|
|
98
|
-
allowing access.
|
|
99
|
-
|
|
100
|
-
## Recording role changes on an actor
|
|
101
|
-
|
|
102
|
-
Use `Actor#sync_roles!` (instead of assigning `role_ids=`/`roles=` directly)
|
|
103
|
-
so assignments and revocations land in the audit log:
|
|
104
|
-
|
|
105
|
-
```ruby
|
|
106
|
-
user.sync_roles!(params[:role_ids], changed_by: current_user)
|
|
107
|
-
```
|
|
122
|
+
Policy, plain proc).
|
|
108
123
|
|
|
109
124
|
## Recording permission changes on a role
|
|
110
125
|
|
|
@@ -148,6 +163,8 @@ Argus::Trail.configure do |config|
|
|
|
148
163
|
config.current_actor_method = :current_user
|
|
149
164
|
config.per_page = 30
|
|
150
165
|
config.layout = nil # e.g. "application"
|
|
166
|
+
config.permission_scan_excludes = [] # extra controller paths for fetch_permissions to skip
|
|
167
|
+
config.action_name_mapper = ->(action) { ... } # see lib/argus/trail/configuration.rb for the default
|
|
151
168
|
end
|
|
152
169
|
```
|
|
153
170
|
|
|
@@ -18,27 +18,34 @@ module Argus
|
|
|
18
18
|
|
|
19
19
|
# Call with a record (show/edit/update/destroy) or a class (index/new/create).
|
|
20
20
|
# Resolution order: an explicitly configured proc always wins; otherwise, if
|
|
21
|
-
# Pundit is bundled
|
|
22
|
-
# Argus::Trail::RolePolicy
|
|
23
|
-
#
|
|
21
|
+
# Pundit is bundled *and* a policy is actually defined for this record
|
|
22
|
+
# (e.g. Argus::Trail::RolePolicy), delegate to it; otherwise fall back to
|
|
23
|
+
# the plug-and-play default below so the admin screens work out of the
|
|
24
|
+
# box with no policies and no config.
|
|
24
25
|
def authorize_access!(record_or_class)
|
|
25
26
|
handler = Argus::Trail.config.authorize_with
|
|
26
27
|
return handler.call(self, record_or_class) if handler
|
|
27
|
-
return authorize(record_or_class) if defined?(Pundit)
|
|
28
|
+
return authorize(record_or_class) if defined?(Pundit) && pundit_policy_defined?(record_or_class)
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
# No Pundit policy and no authorize_with configured — just require a
|
|
31
|
+
# signed-in actor. Define a policy or set config.authorize_with to
|
|
32
|
+
# tighten this further (e.g. restrict to admins only).
|
|
33
|
+
head :forbidden unless current_actor
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
# Scope a relation down to what the current actor may see. Only Pundit's
|
|
33
37
|
# policy_scope narrows results; a custom authorize_with is a yes/no gate,
|
|
34
|
-
# not a scope, so it leaves the relation as-is
|
|
38
|
+
# not a scope, so it leaves the relation as-is — same for the
|
|
39
|
+
# plug-and-play default.
|
|
35
40
|
def authorized_scope(scope_class)
|
|
36
|
-
return
|
|
41
|
+
return policy_scope(scope_class) if Argus::Trail.config.authorize_with.nil? && defined?(Pundit) && pundit_policy_defined?(scope_class)
|
|
37
42
|
scope_class.all
|
|
38
43
|
end
|
|
39
44
|
|
|
40
|
-
def
|
|
41
|
-
|
|
45
|
+
def pundit_policy_defined?(record_or_class)
|
|
46
|
+
Pundit::PolicyFinder.new(record_or_class).policy.present?
|
|
47
|
+
rescue NameError
|
|
48
|
+
false
|
|
42
49
|
end
|
|
43
50
|
end
|
|
44
51
|
end
|
|
@@ -56,7 +56,7 @@ module Argus
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def permission_params
|
|
59
|
-
params.require(:permission).permit(:name, :description)
|
|
59
|
+
params.require(:permission).permit(:name, :description, :module_name, :action)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def permission_class = Argus::Trail.config.permission_class
|
|
@@ -4,8 +4,44 @@ module Argus
|
|
|
4
4
|
has_many :role_permissions, class_name: Argus::Trail.config.role_permission_class_name, dependent: :destroy
|
|
5
5
|
has_many :roles, through: :role_permissions
|
|
6
6
|
|
|
7
|
+
before_validation :set_derived_attributes
|
|
8
|
+
|
|
7
9
|
validates :name, presence: true, uniqueness: true
|
|
8
10
|
validates :description, presence: true
|
|
11
|
+
validates :action, uniqueness: { scope: :module_name }, if: -> { module_name.present? }
|
|
12
|
+
|
|
13
|
+
scope :module_wise, -> { where.not(module_name: nil) }
|
|
14
|
+
|
|
15
|
+
# Groups permissions by `module_name` (e.g. every permission generated
|
|
16
|
+
# from the "admin/accounts" controller) in the order the admin UI
|
|
17
|
+
# renders its checkbox matrix. Manually-created, module-less
|
|
18
|
+
# permissions land together under a `nil` key.
|
|
19
|
+
def self.grouped_by_module
|
|
20
|
+
order(:module_name, :action).group_by(&:module_name)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def module_permission?
|
|
24
|
+
module_name.present? && action.present?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def display_name
|
|
28
|
+
return name.humanize unless module_permission?
|
|
29
|
+
|
|
30
|
+
"#{module_name.humanize} — #{action.humanize}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Module-wise permissions (created by `rails argus_trail:fetch_permissions`,
|
|
36
|
+
# or by hand) derive their name/description from module_name + action so
|
|
37
|
+
# callers never have to invent one; a manually created, module-less
|
|
38
|
+
# permission just keeps whatever name/description it was given.
|
|
39
|
+
def set_derived_attributes
|
|
40
|
+
return unless module_permission?
|
|
41
|
+
|
|
42
|
+
self.name = name.presence || "#{module_name}##{action}"
|
|
43
|
+
self.description = description.presence || "#{action.to_s.humanize} access to #{module_name.to_s.humanize}"
|
|
44
|
+
end
|
|
9
45
|
end
|
|
10
46
|
end
|
|
11
47
|
end
|
|
@@ -10,14 +10,32 @@
|
|
|
10
10
|
</div>
|
|
11
11
|
<% end %>
|
|
12
12
|
|
|
13
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
14
|
+
<div>
|
|
15
|
+
<label class="block text-sm font-semibold text-gray-700 mb-1">Module</label>
|
|
16
|
+
<%= f.text_field :module_name, placeholder: "e.g. admin/accounts",
|
|
17
|
+
class: "block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" %>
|
|
18
|
+
<p class="text-xs text-gray-400 mt-1">Leave blank for a one-off, module-less permission.</p>
|
|
19
|
+
</div>
|
|
20
|
+
<div>
|
|
21
|
+
<label class="block text-sm font-semibold text-gray-700 mb-1">Action</label>
|
|
22
|
+
<%= f.text_field :action, placeholder: "e.g. read",
|
|
23
|
+
class: "block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" %>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
13
27
|
<div>
|
|
14
|
-
<label class="block text-sm font-semibold text-gray-700 mb-1">
|
|
15
|
-
|
|
28
|
+
<label class="block text-sm font-semibold text-gray-700 mb-1">
|
|
29
|
+
Permission Name <% unless permission.module_name.present? %><span class="text-red-500">*</span><% end %>
|
|
30
|
+
</label>
|
|
31
|
+
<%= f.text_field :name, placeholder: "e.g. manage_users — auto-filled from Module/Action above when both are set",
|
|
16
32
|
class: "block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" %>
|
|
17
33
|
</div>
|
|
18
34
|
|
|
19
35
|
<div>
|
|
20
|
-
<label class="block text-sm font-semibold text-gray-700 mb-1">
|
|
36
|
+
<label class="block text-sm font-semibold text-gray-700 mb-1">
|
|
37
|
+
Description <% unless permission.module_name.present? %><span class="text-red-500">*</span><% end %>
|
|
38
|
+
</label>
|
|
21
39
|
<%= f.text_area :description, rows: 2,
|
|
22
40
|
class: "block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none" %>
|
|
23
41
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
2
2
|
<div class="flex items-center gap-4 mb-8">
|
|
3
3
|
<%= link_to "← Back", permission_path(@permission), class: "text-gray-400 hover:text-gray-600" %>
|
|
4
|
-
<h1 class="text-2xl font-bold text-gray-900">Edit Permission: <%= @permission.
|
|
4
|
+
<h1 class="text-2xl font-bold text-gray-900">Edit Permission: <%= @permission.display_name %></h1>
|
|
5
5
|
</div>
|
|
6
6
|
|
|
7
7
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
<thead class="bg-gray-50">
|
|
14
14
|
<tr>
|
|
15
15
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Permission</th>
|
|
16
|
+
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Module</th>
|
|
16
17
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Description</th>
|
|
17
18
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Roles</th>
|
|
18
19
|
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
|
|
@@ -21,9 +22,10 @@
|
|
|
21
22
|
<tbody class="bg-white divide-y divide-gray-200">
|
|
22
23
|
<% @permissions.each do |permission| %>
|
|
23
24
|
<tr class="hover:bg-gray-50 transition-colors">
|
|
24
|
-
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= permission.
|
|
25
|
+
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= permission.display_name %></td>
|
|
26
|
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= permission.module_name&.humanize %></td>
|
|
25
27
|
<td class="px-6 py-4 text-sm text-gray-600"><%= permission.description %></td>
|
|
26
|
-
<td class="px-6 py-4 text-sm text-gray-600"><%= permission.roles.
|
|
28
|
+
<td class="px-6 py-4 text-sm text-gray-600"><%= permission.roles.size %></td>
|
|
27
29
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
28
30
|
<div class="flex items-center justify-end gap-3">
|
|
29
31
|
<%= link_to "View", permission_path(permission), class: "text-blue-600 hover:text-blue-800" %>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<div class="flex items-center gap-4">
|
|
4
4
|
<%= link_to "← Back", permissions_path, class: "text-gray-400 hover:text-gray-600" %>
|
|
5
5
|
<div>
|
|
6
|
-
<h1 class="text-2xl font-bold text-gray-900"><%= @permission.
|
|
6
|
+
<h1 class="text-2xl font-bold text-gray-900"><%= @permission.display_name %></h1>
|
|
7
7
|
<p class="text-sm text-gray-500 mt-0.5"><%= @permission.description %></p>
|
|
8
8
|
</div>
|
|
9
9
|
</div>
|
|
@@ -23,22 +23,40 @@
|
|
|
23
23
|
|
|
24
24
|
<div>
|
|
25
25
|
<label class="block text-sm font-semibold text-gray-700 mb-3">Permissions</label>
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<
|
|
26
|
+
|
|
27
|
+
<div class="space-y-4">
|
|
28
|
+
<% @grouped_permissions.sort_by { |module_name, _| module_name.to_s }.each do |module_name, permissions| %>
|
|
29
|
+
<div class="argus-trail-permission-group border border-gray-200 rounded-lg overflow-hidden">
|
|
30
|
+
<div class="flex items-center gap-2 bg-gray-50 px-4 py-2 border-b border-gray-200">
|
|
31
|
+
<input type="checkbox" class="argus-trail-module-toggle h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
32
|
+
<%= "checked" if permissions.all? { |p| role.permission_ids.include?(p.id) } %>
|
|
33
|
+
onclick="const group = this.closest('.argus-trail-permission-group'); group.querySelectorAll('.argus-trail-permission-checkbox').forEach(cb => cb.checked = this.checked)">
|
|
34
|
+
<span class="text-sm font-semibold text-gray-700">
|
|
35
|
+
<%= module_name.present? ? module_name.humanize : "General" %>
|
|
36
|
+
</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-4">
|
|
39
|
+
<% permissions.each do |permission| %>
|
|
40
|
+
<label class="flex items-start gap-3 p-3 border border-gray-200 rounded-lg hover:border-indigo-300 hover:bg-indigo-50/50 cursor-pointer">
|
|
41
|
+
<input type="checkbox" name="permission_ids[]" value="<%= permission.id %>"
|
|
42
|
+
<%= "checked" if role.permission_ids.include?(permission.id) %>
|
|
43
|
+
class="argus-trail-permission-checkbox mt-0.5 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
44
|
+
onchange="const group = this.closest('.argus-trail-permission-group'); group.querySelector('.argus-trail-module-toggle').checked = [...group.querySelectorAll('.argus-trail-permission-checkbox')].every(cb => cb.checked)">
|
|
45
|
+
<div>
|
|
46
|
+
<p class="text-sm font-medium text-gray-800"><%= permission.module_permission? ? permission.action.humanize : permission.name.humanize %></p>
|
|
47
|
+
<p class="text-xs text-gray-500 mt-0.5"><%= permission.description %></p>
|
|
48
|
+
</div>
|
|
49
|
+
</label>
|
|
50
|
+
<% end %>
|
|
35
51
|
</div>
|
|
36
|
-
</
|
|
52
|
+
</div>
|
|
37
53
|
<% end %>
|
|
38
54
|
</div>
|
|
39
|
-
|
|
55
|
+
|
|
56
|
+
<% if @grouped_permissions.empty? %>
|
|
40
57
|
<p class="text-sm text-gray-400 italic mt-2">
|
|
41
|
-
No permissions exist yet. <%= link_to "Create one first", new_permission_path
|
|
58
|
+
No permissions exist yet. <%= link_to "Create one first", new_permission_path %>, or run
|
|
59
|
+
<code class="bg-gray-100 px-1 rounded">bin/rails argus_trail:fetch_permissions</code> to generate them from your routes.
|
|
42
60
|
</p>
|
|
43
61
|
<% end %>
|
|
44
62
|
</div>
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
<% if role.permissions.any? %>
|
|
33
33
|
<% role.permissions.first(3).each do |perm| %>
|
|
34
34
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-50 text-indigo-700 border border-indigo-100">
|
|
35
|
-
<%= perm.
|
|
35
|
+
<%= perm.display_name %>
|
|
36
36
|
</span>
|
|
37
37
|
<% end %>
|
|
38
|
-
<% if role.permissions.
|
|
38
|
+
<% if role.permissions.size > 3 %>
|
|
39
39
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
|
|
40
|
-
+<%= role.permissions.
|
|
40
|
+
+<%= role.permissions.size - 3 %> more
|
|
41
41
|
</span>
|
|
42
42
|
<% end %>
|
|
43
43
|
<% else %>
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
<h2 class="text-base font-semibold text-gray-900 mb-4">Permissions (<%= @role.permissions.count %>)</h2>
|
|
16
16
|
<% if @role.permissions.any? %>
|
|
17
17
|
<div class="space-y-2">
|
|
18
|
-
<% @role.permissions.order(:name).each do |perm| %>
|
|
18
|
+
<% @role.permissions.order(:module_name, :action, :name).each do |perm| %>
|
|
19
19
|
<div class="p-3 bg-indigo-50 rounded-lg">
|
|
20
|
-
<p class="text-sm font-medium text-indigo-900"><%= perm.
|
|
20
|
+
<p class="text-sm font-medium text-indigo-900"><%= perm.display_name %></p>
|
|
21
21
|
<p class="text-xs text-indigo-600 mt-0.5"><%= perm.description %></p>
|
|
22
22
|
</div>
|
|
23
23
|
<% end %>
|
data/lib/argus/trail/actor.rb
CHANGED
|
@@ -24,8 +24,37 @@ module Argus
|
|
|
24
24
|
as: :changed_by, class_name: "Argus::Trail::AuditEntry", dependent: :nullify
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
# Two calling conventions:
|
|
28
|
+
# has_permission?("manage_billing") — match a plain permission name
|
|
29
|
+
# has_permission?("admin/accounts", :read) — match a module-wise permission
|
|
30
|
+
# (module_name + action), e.g. the
|
|
31
|
+
# ones `argus_trail:fetch_permissions`
|
|
32
|
+
# generates from your routes.
|
|
33
|
+
#
|
|
34
|
+
# Memoized per actor instance/per (name|module+action) pair — a single
|
|
35
|
+
# request typically loads `current_user` once and then asks it several
|
|
36
|
+
# has_permission? questions across controllers/views (e.g. one per
|
|
37
|
+
# Argus::Trail::Authorizable-gated controller, plus any UI that checks
|
|
38
|
+
# a few more to decide what to show); without this, each of those re-ran
|
|
39
|
+
# the same roles->permissions join query. Cleared by sync_roles! below,
|
|
40
|
+
# since that's the only thing in this concern that can change the
|
|
41
|
+
# answer for an already-loaded actor.
|
|
42
|
+
def has_permission?(module_name_or_permission_name, action = nil)
|
|
43
|
+
cache_key = [ module_name_or_permission_name.to_s, action&.to_s ]
|
|
44
|
+
@argus_trail_permission_cache ||= {}
|
|
45
|
+
return @argus_trail_permission_cache[cache_key] if @argus_trail_permission_cache.key?(cache_key)
|
|
46
|
+
|
|
47
|
+
table = Argus::Trail.config.permission_class.table_name
|
|
48
|
+
scope = roles.joins(:permissions)
|
|
49
|
+
|
|
50
|
+
result =
|
|
51
|
+
if action.nil?
|
|
52
|
+
scope.exists?(table => { name: cache_key[0] })
|
|
53
|
+
else
|
|
54
|
+
scope.exists?(table => { module_name: cache_key[0], action: cache_key[1] })
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@argus_trail_permission_cache[cache_key] = result
|
|
29
58
|
end
|
|
30
59
|
|
|
31
60
|
# Diffs the requested role ids against the actor's current ones and
|
|
@@ -49,6 +78,8 @@ module Argus
|
|
|
49
78
|
argus_trail_record_role_change("role_revoked", role, changed_by)
|
|
50
79
|
end
|
|
51
80
|
end
|
|
81
|
+
|
|
82
|
+
@argus_trail_permission_cache = nil
|
|
52
83
|
end
|
|
53
84
|
|
|
54
85
|
private
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Argus
|
|
2
|
+
module Trail
|
|
3
|
+
# Include in any of *your* controllers to gate every action behind a
|
|
4
|
+
# module-wise permission — zero further code required:
|
|
5
|
+
#
|
|
6
|
+
# class AccountsController < ApplicationController
|
|
7
|
+
# include Argus::Trail::Authorizable
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# The required permission is derived automatically from the controller
|
|
11
|
+
# and action, using the exact same convention
|
|
12
|
+
# `bin/rails argus_trail:fetch_permissions` used to create the Permission
|
|
13
|
+
# rows in the first place: module_name is `controller_path` (e.g.
|
|
14
|
+
# "admin/accounts"), and the action is normalized through
|
|
15
|
+
# `Argus::Trail.config.action_name_mapper` (index/show -> read, new/create
|
|
16
|
+
# -> create, edit/update -> update, destroy -> destroy, anything else kept
|
|
17
|
+
# as-is). A signed-in actor without a role granting that permission gets a
|
|
18
|
+
# 403; a signed-out request also gets a 403 (authenticate first in your
|
|
19
|
+
# own `before_action` if you need a redirect to a login page instead).
|
|
20
|
+
module Authorizable
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
included do
|
|
24
|
+
before_action :argus_trail_authorize_permission!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def argus_trail_authorize_permission!
|
|
30
|
+
actor = send(Argus::Trail.config.current_actor_method)
|
|
31
|
+
return head :forbidden unless actor
|
|
32
|
+
|
|
33
|
+
action = Argus::Trail.config.action_name_mapper.call(action_name)
|
|
34
|
+
head :forbidden unless actor.has_permission?(controller_path, action)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
module Argus
|
|
2
2
|
module Trail
|
|
3
3
|
class Configuration
|
|
4
|
-
class MissingAuthorization < StandardError
|
|
5
|
-
def message
|
|
6
|
-
"Argus::Trail could not determine how to authorize access to its admin screens. " \
|
|
7
|
-
"Either add Pundit to your Gemfile with Argus::Trail::Role/Permission policies, " \
|
|
8
|
-
"or set `config.authorize_with` in config/initializers/argus_trail.rb " \
|
|
9
|
-
"(e.g. `config.authorize_with = ->(controller, record) { controller.current_user&.admin? }`)."
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
4
|
attr_accessor :actor_class_name, :role_class_name, :permission_class_name,
|
|
14
5
|
:role_permission_class_name, :changed_by_resolver, :authorize_with,
|
|
15
|
-
:current_actor_method, :per_page, :layout
|
|
6
|
+
:current_actor_method, :per_page, :layout,
|
|
7
|
+
:permission_scan_excludes, :action_name_mapper
|
|
16
8
|
|
|
17
9
|
def initialize
|
|
18
10
|
@actor_class_name = "User"
|
|
@@ -24,6 +16,27 @@ module Argus
|
|
|
24
16
|
@current_actor_method = :current_user
|
|
25
17
|
@per_page = 30
|
|
26
18
|
@layout = nil
|
|
19
|
+
|
|
20
|
+
# Used by `bin/rails argus_trail:fetch_permissions` when scanning your
|
|
21
|
+
# app's routes to build module-wise (controller x action) permissions.
|
|
22
|
+
# Extra controller paths (strings or Regexps) to skip, on top of the
|
|
23
|
+
# engine's own routes and framework-internal ones (Rails health check,
|
|
24
|
+
# Active Storage, Action Mailbox/Text), which are always excluded.
|
|
25
|
+
@permission_scan_excludes = []
|
|
26
|
+
|
|
27
|
+
# How a route's Rails action name maps to the permission's `action`
|
|
28
|
+
# column. Default collapses the standard 7 REST actions down to the
|
|
29
|
+
# 4 CRUD-ish verbs the admin UI groups checkboxes by; anything else
|
|
30
|
+
# (a custom member/collection action) is kept as-is.
|
|
31
|
+
@action_name_mapper = lambda do |action|
|
|
32
|
+
case action.to_s
|
|
33
|
+
when "index", "show" then "read"
|
|
34
|
+
when "new", "create" then "create"
|
|
35
|
+
when "edit", "update" then "update"
|
|
36
|
+
when "destroy" then "destroy"
|
|
37
|
+
else action.to_s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
27
40
|
end
|
|
28
41
|
|
|
29
42
|
def actor_class
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Argus
|
|
2
|
+
module Trail
|
|
3
|
+
# Scans the host app's routes to build the (module_name, action) pairs
|
|
4
|
+
# that back module-wise permissions — the same data `bin/rails
|
|
5
|
+
# argus_trail:fetch_permissions` syncs into Permission rows, and that
|
|
6
|
+
# Argus::Trail::Authorizable checks a signed-in actor against.
|
|
7
|
+
#
|
|
8
|
+
# `module_name` is a route's controller path exactly as Rails reports it
|
|
9
|
+
# (e.g. "admin/accounts", "sessions") — the same string
|
|
10
|
+
# `ActionController::Metal#controller_path` returns at runtime, so a
|
|
11
|
+
# scanned permission always lines up with the controller it came from.
|
|
12
|
+
class PermissionScanner
|
|
13
|
+
DEFAULT_EXCLUDES = [
|
|
14
|
+
%r{\Aargus/trail/},
|
|
15
|
+
%r{\Arails/},
|
|
16
|
+
%r{\Aactive_storage/},
|
|
17
|
+
%r{\Aaction_mailbox/},
|
|
18
|
+
%r{\Aaction_text/},
|
|
19
|
+
"rails/health_check"
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def self.scan
|
|
23
|
+
new.scan
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Array of unique [module_name, action] pairs across every routed
|
|
27
|
+
# controller action, actions already normalized via
|
|
28
|
+
# `Argus::Trail.config.action_name_mapper`.
|
|
29
|
+
def scan
|
|
30
|
+
Rails.application.eager_load!
|
|
31
|
+
|
|
32
|
+
routes.filter_map { |route| build_pair(route) }.uniq
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def routes
|
|
38
|
+
Rails.application.routes.routes
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_pair(route)
|
|
42
|
+
controller = route.defaults[:controller]
|
|
43
|
+
action = route.defaults[:action]
|
|
44
|
+
return if controller.blank? || action.blank?
|
|
45
|
+
return if excluded?(controller)
|
|
46
|
+
|
|
47
|
+
[ controller, Argus::Trail.config.action_name_mapper.call(action) ]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def excluded?(controller)
|
|
51
|
+
excludes.any? { |pattern| pattern.is_a?(Regexp) ? pattern.match?(controller) : pattern.to_s == controller }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def excludes
|
|
55
|
+
DEFAULT_EXCLUDES + Array(Argus::Trail.config.permission_scan_excludes)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/argus/trail/version.rb
CHANGED
data/lib/argus/trail.rb
CHANGED
|
@@ -2,6 +2,8 @@ require "argus/trail/version"
|
|
|
2
2
|
require "argus/trail/configuration"
|
|
3
3
|
require "argus/trail/current"
|
|
4
4
|
require "argus/trail/actor"
|
|
5
|
+
require "argus/trail/authorizable"
|
|
6
|
+
require "argus/trail/permission_scanner"
|
|
5
7
|
require "argus/trail/pagination"
|
|
6
8
|
require "argus/trail/engine"
|
|
7
9
|
|
|
@@ -10,6 +10,9 @@ module Argus
|
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
+
class_option :actor, type: :string, default: "User",
|
|
14
|
+
desc: "Name of your actor/user model — gets `include Argus::Trail::Actor`"
|
|
15
|
+
|
|
13
16
|
def self.next_migration_number(dirname)
|
|
14
17
|
ActiveRecord::Migration.next_migration_number(current_migration_number(dirname) + 1)
|
|
15
18
|
end
|
|
@@ -31,6 +34,48 @@ module Argus
|
|
|
31
34
|
route 'mount Argus::Trail::Engine => "/admin/access"'
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
# No code needed beyond running this generator: opts your actor model
|
|
38
|
+
# into role assignment/permission checks automatically, instead of
|
|
39
|
+
# asking you to add `include Argus::Trail::Actor` by hand.
|
|
40
|
+
def include_actor_concern
|
|
41
|
+
model_file = File.expand_path("app/models/#{actor_file_name}.rb", destination_root)
|
|
42
|
+
|
|
43
|
+
unless File.exist?(model_file)
|
|
44
|
+
say_status :skip,
|
|
45
|
+
"app/models/#{actor_file_name}.rb not found — add `include Argus::Trail::Actor` " \
|
|
46
|
+
"to your actor model by hand (run with --actor=YourModel if it's not User)", :yellow
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if File.read(model_file).include?("Argus::Trail::Actor")
|
|
51
|
+
say_status :skip, "app/models/#{actor_file_name}.rb already includes Argus::Trail::Actor", :blue
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
inject_into_class model_file, options[:actor], " include Argus::Trail::Actor\n\n"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Likewise, wires up "who's making this change" for the audit log
|
|
59
|
+
# automatically instead of asking for a manual before_action.
|
|
60
|
+
def wire_current_actor
|
|
61
|
+
controller_file = File.expand_path("app/controllers/application_controller.rb", destination_root)
|
|
62
|
+
|
|
63
|
+
unless File.exist?(controller_file)
|
|
64
|
+
say_status :skip,
|
|
65
|
+
"app/controllers/application_controller.rb not found — add " \
|
|
66
|
+
"`before_action { Argus::Trail.current_actor = current_user }` by hand", :yellow
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if File.read(controller_file).include?("Argus::Trail.current_actor")
|
|
71
|
+
say_status :skip, "application_controller.rb already wires Argus::Trail.current_actor", :blue
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
inject_into_class controller_file, "ApplicationController",
|
|
76
|
+
" before_action { Argus::Trail.current_actor = current_user }\n\n"
|
|
77
|
+
end
|
|
78
|
+
|
|
34
79
|
def show_readme
|
|
35
80
|
readme "POST_INSTALL" if behavior == :invoke
|
|
36
81
|
end
|
|
@@ -40,6 +85,10 @@ module Argus
|
|
|
40
85
|
def migration_version
|
|
41
86
|
"[#{ActiveRecord::Migration.current_version}]"
|
|
42
87
|
end
|
|
88
|
+
|
|
89
|
+
def actor_file_name
|
|
90
|
+
options[:actor].underscore
|
|
91
|
+
end
|
|
43
92
|
end
|
|
44
93
|
end
|
|
45
94
|
end
|
|
@@ -2,40 +2,42 @@
|
|
|
2
2
|
Argus::Trail installed
|
|
3
3
|
========================================================================
|
|
4
4
|
|
|
5
|
+
Done automatically by this generator:
|
|
6
|
+
- config/initializers/argus_trail.rb written
|
|
7
|
+
- migration for argus_trail_roles/permissions/role_permissions/
|
|
8
|
+
role_assignments/audit_entries created
|
|
9
|
+
- Argus::Trail::Engine mounted in config/routes.rb (default /admin/access)
|
|
10
|
+
- `include Argus::Trail::Actor` added to your actor model (--actor=User
|
|
11
|
+
by default — rerun with --actor=YourModel if you skipped this)
|
|
12
|
+
- `before_action { Argus::Trail.current_actor = current_user }` added to
|
|
13
|
+
ApplicationController
|
|
14
|
+
|
|
5
15
|
Next steps:
|
|
6
16
|
|
|
7
|
-
1. Run the
|
|
17
|
+
1. Run the migration:
|
|
8
18
|
bin/rails db:migrate
|
|
9
19
|
|
|
10
|
-
2.
|
|
11
|
-
|
|
12
|
-
polymorphic join table, so an actor can hold any number of roles:
|
|
13
|
-
|
|
14
|
-
class User < ApplicationRecord
|
|
15
|
-
include Argus::Trail::Actor
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
3. Tell the engine who's making each change, once, in your ApplicationController:
|
|
20
|
+
2. Generate module-wise permissions from your existing routes:
|
|
21
|
+
bin/rails argus_trail:fetch_permissions
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
Re-run it any time you add controllers/actions — it only ever adds new
|
|
24
|
+
permissions. `bin/rails argus_trail:fetch_permissions:prune` removes ones
|
|
25
|
+
whose route is gone and that aren't granted to any role.
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
3. Gate your own controllers behind a permission with one line — no further
|
|
28
|
+
code needed, the required permission is derived from the controller and
|
|
29
|
+
action automatically:
|
|
26
30
|
|
|
27
|
-
class
|
|
28
|
-
|
|
29
|
-
def show? = user.admin?
|
|
30
|
-
# ...
|
|
31
|
+
class AccountsController < ApplicationController
|
|
32
|
+
include Argus::Trail::Authorizable
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
to
|
|
35
|
+
4. Visit the mounted engine (default /admin/access, see config/routes.rb) to
|
|
36
|
+
create roles, assign permissions, and assign roles to actors. With
|
|
37
|
+
neither Pundit policies nor config.authorize_with set, the admin screens
|
|
38
|
+
are open to any signed-in actor — define
|
|
39
|
+
Argus::Trail::RolePolicy/PermissionPolicy/AuditEntryPolicy (Pundit) or set
|
|
40
|
+
config.authorize_with to restrict further (e.g. to admins only).
|
|
39
41
|
|
|
40
42
|
Want to customize the HTML? Run:
|
|
41
43
|
bin/rails generate argus:trail:views
|
|
@@ -10,9 +10,20 @@ class CreateArgusTrailTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
10
10
|
create_table :argus_trail_permissions do |t|
|
|
11
11
|
t.string :name, null: false
|
|
12
12
|
t.string :description
|
|
13
|
+
# module_name/action back "module-wise" permissions — the rows
|
|
14
|
+
# `bin/rails argus_trail:fetch_permissions` generates from your routes
|
|
15
|
+
# (e.g. module_name: "admin/accounts", action: "read"). Both stay nil
|
|
16
|
+
# for a manually created, one-off permission.
|
|
17
|
+
t.string :module_name
|
|
18
|
+
t.string :action
|
|
13
19
|
t.timestamps
|
|
14
20
|
end
|
|
15
21
|
add_index :argus_trail_permissions, :name, unique: true
|
|
22
|
+
# Composite, not module_name alone — every module-wise permission lookup
|
|
23
|
+
# (Actor#has_permission?, Authorizable) filters on both columns together,
|
|
24
|
+
# and the leftmost-prefix rule means this still serves a module_name-only
|
|
25
|
+
# query (grouped_by_module) just as well as a single-column index would.
|
|
26
|
+
add_index :argus_trail_permissions, [ :module_name, :action ]
|
|
16
27
|
|
|
17
28
|
create_table :argus_trail_role_permissions do |t|
|
|
18
29
|
t.references :role, null: false, foreign_key: { to_table: :argus_trail_roles }
|
|
@@ -43,5 +54,10 @@ class CreateArgusTrailTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
43
54
|
t.datetime :created_at, null: false
|
|
44
55
|
end
|
|
45
56
|
add_index :argus_trail_audit_entries, :event_type
|
|
57
|
+
# This table is append-only and grows forever (see AuditEntry's class
|
|
58
|
+
# comment) — .recent orders by created_at DESC on every paginated page
|
|
59
|
+
# load, and the admin screen's "Today" stat filters on it too, so an
|
|
60
|
+
# unindexed created_at turns into a full-table sort/scan as the log grows.
|
|
61
|
+
add_index :argus_trail_audit_entries, :created_at
|
|
46
62
|
end
|
|
47
63
|
end
|
|
@@ -23,9 +23,10 @@ Argus::Trail.configure do |config|
|
|
|
23
23
|
# config.changed_by_resolver = -> { Argus::Trail.current_actor }
|
|
24
24
|
|
|
25
25
|
# Gate for who may use the roles/permissions/audit-log admin screens.
|
|
26
|
-
# Left unset, Argus::Trail falls back to Pundit if it's in your Gemfile
|
|
27
|
-
# (
|
|
28
|
-
#
|
|
26
|
+
# Left unset, Argus::Trail falls back to Pundit if it's in your Gemfile and
|
|
27
|
+
# a policy is defined (e.g. Argus::Trail::RolePolicy) — otherwise it just
|
|
28
|
+
# requires someone to be signed in, so the admin screens work out of the
|
|
29
|
+
# box with zero policies/config. Tighten either way you like:
|
|
29
30
|
# config.authorize_with = ->(controller, record_or_class) { controller.current_user&.admin? }
|
|
30
31
|
|
|
31
32
|
# The method the engine calls on its controllers to get the logged-in actor.
|
|
@@ -37,4 +38,16 @@ Argus::Trail.configure do |config|
|
|
|
37
38
|
# Render engine screens inside one of your app's own layouts instead of
|
|
38
39
|
# the engine's self-contained Tailwind-CDN layout.
|
|
39
40
|
# config.layout = "application"
|
|
41
|
+
|
|
42
|
+
# Extra controller paths `bin/rails argus_trail:fetch_permissions` should
|
|
43
|
+
# skip when scanning your routes to build module-wise permissions (strings
|
|
44
|
+
# or Regexps). The engine's own routes and Rails-internal ones (health
|
|
45
|
+
# check, Active Storage, Action Mailbox/Text) are always skipped.
|
|
46
|
+
# config.permission_scan_excludes = [ "rails/conductor", %r{\Aadmin/sidekiq} ]
|
|
47
|
+
|
|
48
|
+
# How a scanned route's Rails action name maps to a permission's `action`
|
|
49
|
+
# column — also used by Argus::Trail::Authorizable. Defaults to collapsing
|
|
50
|
+
# index/show -> read, new/create -> create, edit/update -> update,
|
|
51
|
+
# destroy -> destroy, and keeping anything else (a custom action) as-is.
|
|
52
|
+
# config.action_name_mapper = ->(action) { action.to_s }
|
|
40
53
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
========================================================================
|
|
2
|
+
Argus::Trail upgrade migration generated
|
|
3
|
+
========================================================================
|
|
4
|
+
|
|
5
|
+
This adds what's new to your existing install:
|
|
6
|
+
- module_name/action columns on argus_trail_permissions
|
|
7
|
+
- a composite index on (module_name, action)
|
|
8
|
+
- an index on argus_trail_audit_entries.created_at
|
|
9
|
+
|
|
10
|
+
Next steps:
|
|
11
|
+
|
|
12
|
+
1. Run it:
|
|
13
|
+
bin/rails db:migrate
|
|
14
|
+
|
|
15
|
+
2. Generate module-wise permissions from your existing routes:
|
|
16
|
+
bin/rails argus_trail:fetch_permissions
|
|
17
|
+
|
|
18
|
+
3. (Optional) Gate your own controllers behind a permission with one line:
|
|
19
|
+
class AccountsController < ApplicationController
|
|
20
|
+
include Argus::Trail::Authorizable
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
See README.md and docs/INTEGRATION_GUIDE.md for the full picture.
|
|
24
|
+
========================================================================
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class ArgusTrailAddModuleWisePermissionsAndAuditIndex < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
# Every step below is guarded so this is safe to run twice, and safe even
|
|
4
|
+
# on a host where create_argus_trail_tables already created these (every
|
|
5
|
+
# check just becomes a no-op) — this migration only exists for hosts
|
|
6
|
+
# upgrading from a version of argus-trail that predates module-wise
|
|
7
|
+
# permissions.
|
|
8
|
+
unless column_exists?(:argus_trail_permissions, :module_name)
|
|
9
|
+
add_column :argus_trail_permissions, :module_name, :string
|
|
10
|
+
end
|
|
11
|
+
unless column_exists?(:argus_trail_permissions, :action)
|
|
12
|
+
add_column :argus_trail_permissions, :action, :string
|
|
13
|
+
end
|
|
14
|
+
unless index_exists?(:argus_trail_permissions, [ :module_name, :action ])
|
|
15
|
+
add_index :argus_trail_permissions, [ :module_name, :action ]
|
|
16
|
+
end
|
|
17
|
+
unless index_exists?(:argus_trail_audit_entries, :created_at)
|
|
18
|
+
add_index :argus_trail_audit_entries, :created_at
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/migration"
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module Argus
|
|
6
|
+
module Trail
|
|
7
|
+
module Generators
|
|
8
|
+
# Upgrade path for a host that installed argus-trail before module-wise
|
|
9
|
+
# permissions existed (argus_trail_permissions had no module_name/action
|
|
10
|
+
# columns, argus_trail_audit_entries had no index on created_at). A
|
|
11
|
+
# fresh install never needs this — create_argus_trail_tables already
|
|
12
|
+
# includes both — this generator exists purely for pre-existing hosts
|
|
13
|
+
# upgrading the gem in place.
|
|
14
|
+
#
|
|
15
|
+
# Usage: bin/rails generate argus:trail:upgrade_v0_2 && bin/rails db:migrate
|
|
16
|
+
class UpgradeV02Generator < Rails::Generators::Base
|
|
17
|
+
include Rails::Generators::Migration
|
|
18
|
+
|
|
19
|
+
source_root File.expand_path("templates", __dir__)
|
|
20
|
+
|
|
21
|
+
def self.next_migration_number(dirname)
|
|
22
|
+
ActiveRecord::Migration.next_migration_number(current_migration_number(dirname) + 1)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_upgrade_migration
|
|
26
|
+
migration_template "add_module_wise_permissions_and_audit_index.rb.erb",
|
|
27
|
+
"db/migrate/argus_trail_add_module_wise_permissions_and_audit_index.rb"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def show_readme
|
|
31
|
+
readme "POST_INSTALL" if behavior == :invoke
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def migration_version
|
|
37
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
namespace :argus_trail do
|
|
2
|
+
desc "Scan your app's routes and create any missing module-wise Argus::Trail::Permission records"
|
|
3
|
+
task fetch_permissions: :environment do
|
|
4
|
+
permission_class = Argus::Trail.config.permission_class
|
|
5
|
+
pairs = Argus::Trail::PermissionScanner.scan
|
|
6
|
+
existing = permission_class.module_wise.pluck(:module_name, :action).to_set
|
|
7
|
+
|
|
8
|
+
created = pairs.reject { |pair| existing.include?(pair) }.map do |module_name, action|
|
|
9
|
+
permission_class.create!(module_name: module_name, action: action)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
puts "Argus::Trail: scanned #{pairs.size} controller/action pair(s), " \
|
|
13
|
+
"created #{created.size} new permission(s)."
|
|
14
|
+
created.each { |permission| puts " + #{permission.module_name}##{permission.action}" }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
namespace :fetch_permissions do
|
|
18
|
+
desc "Remove module-wise Argus::Trail::Permission records whose route no longer exists " \
|
|
19
|
+
"and that aren't granted to any role"
|
|
20
|
+
task prune: :environment do
|
|
21
|
+
permission_class = Argus::Trail.config.permission_class
|
|
22
|
+
current_pairs = Argus::Trail::PermissionScanner.scan.to_set
|
|
23
|
+
|
|
24
|
+
orphaned = permission_class.module_wise.reject { |permission| current_pairs.include?([ permission.module_name, permission.action ]) }
|
|
25
|
+
removable, blocked = orphaned.partition { |permission| permission.roles.none? }
|
|
26
|
+
|
|
27
|
+
removable.each(&:destroy!)
|
|
28
|
+
|
|
29
|
+
puts "Argus::Trail: removed #{removable.size} orphaned permission(s)."
|
|
30
|
+
removable.each { |permission| puts " - #{permission.module_name}##{permission.action}" }
|
|
31
|
+
|
|
32
|
+
if blocked.any?
|
|
33
|
+
puts "Argus::Trail: left #{blocked.size} orphaned permission(s) in place — still granted to a role:"
|
|
34
|
+
blocked.each do |permission|
|
|
35
|
+
puts " ! #{permission.module_name}##{permission.action} (role(s): #{permission.roles.pluck(:name).join(', ')})"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: argus-trail
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ram Laxman Yadav
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: rails
|
|
@@ -24,17 +23,19 @@ dependencies:
|
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
25
|
version: '6.1'
|
|
27
|
-
description: Plug-and-play role/permission management with
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
with
|
|
26
|
+
description: Plug-and-play role/permission management with module-wise permissions
|
|
27
|
+
generated from your own routes, one-line controller enforcement, and a unified audit
|
|
28
|
+
log of role reassignments and permission grants/revokes — shipped as a mountable
|
|
29
|
+
engine with paginated HTML admin screens and generators for install/views/config.
|
|
30
|
+
Agnostic to auth (works standalone, auto-integrates with Pundit) and pagination
|
|
31
|
+
(auto-integrates with Kaminari).
|
|
32
32
|
email:
|
|
33
33
|
- yadavramlaxman@gmail.com
|
|
34
34
|
executables: []
|
|
35
35
|
extensions: []
|
|
36
36
|
extra_rdoc_files: []
|
|
37
37
|
files:
|
|
38
|
+
- CHANGELOG.md
|
|
38
39
|
- MIT-LICENSE
|
|
39
40
|
- README.md
|
|
40
41
|
- Rakefile
|
|
@@ -65,24 +66,32 @@ files:
|
|
|
65
66
|
- config/routes.rb
|
|
66
67
|
- lib/argus/trail.rb
|
|
67
68
|
- lib/argus/trail/actor.rb
|
|
69
|
+
- lib/argus/trail/authorizable.rb
|
|
68
70
|
- lib/argus/trail/configuration.rb
|
|
69
71
|
- lib/argus/trail/current.rb
|
|
70
72
|
- lib/argus/trail/engine.rb
|
|
71
73
|
- lib/argus/trail/pagination.rb
|
|
74
|
+
- lib/argus/trail/permission_scanner.rb
|
|
72
75
|
- lib/argus/trail/version.rb
|
|
73
76
|
- lib/generators/argus/trail/config/config_generator.rb
|
|
74
77
|
- lib/generators/argus/trail/install/install_generator.rb
|
|
75
78
|
- lib/generators/argus/trail/install/templates/POST_INSTALL
|
|
76
79
|
- lib/generators/argus/trail/install/templates/create_argus_trail_tables.rb.erb
|
|
77
80
|
- lib/generators/argus/trail/install/templates/initializer.rb
|
|
81
|
+
- lib/generators/argus/trail/upgrade_v0_2/templates/POST_INSTALL
|
|
82
|
+
- lib/generators/argus/trail/upgrade_v0_2/templates/add_module_wise_permissions_and_audit_index.rb.erb
|
|
83
|
+
- lib/generators/argus/trail/upgrade_v0_2/upgrade_v0_2_generator.rb
|
|
78
84
|
- lib/generators/argus/trail/views/views_generator.rb
|
|
79
|
-
|
|
85
|
+
- lib/tasks/argus_trail.rake
|
|
86
|
+
homepage: https://ramlaxmanyadav.github.io/argus-trail/
|
|
80
87
|
licenses:
|
|
81
88
|
- MIT
|
|
82
89
|
metadata:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
source_code_uri: https://github.com/ramlaxmanyadav/argus-trail
|
|
91
|
+
documentation_uri: https://ramlaxmanyadav.github.io/argus-trail/
|
|
92
|
+
changelog_uri: https://github.com/ramlaxmanyadav/argus-trail/blob/main/CHANGELOG.md
|
|
93
|
+
bug_tracker_uri: https://github.com/ramlaxmanyadav/argus-trail/issues
|
|
94
|
+
rubygems_mfa_required: 'true'
|
|
86
95
|
rdoc_options: []
|
|
87
96
|
require_paths:
|
|
88
97
|
- lib
|
|
@@ -97,8 +106,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
97
106
|
- !ruby/object:Gem::Version
|
|
98
107
|
version: '0'
|
|
99
108
|
requirements: []
|
|
100
|
-
rubygems_version: 3.
|
|
101
|
-
signing_key:
|
|
109
|
+
rubygems_version: 3.6.9
|
|
102
110
|
specification_version: 4
|
|
103
111
|
summary: Mountable Rails engine for roles, permissions, and an audit trail of who
|
|
104
112
|
changed what.
|