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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/README.md +52 -35
  4. data/app/controllers/argus/trail/application_controller.rb +16 -9
  5. data/app/controllers/argus/trail/permissions_controller.rb +1 -1
  6. data/app/controllers/argus/trail/roles_controller.rb +1 -1
  7. data/app/models/argus/trail/permission.rb +36 -0
  8. data/app/views/argus/trail/permissions/_form.html.erb +21 -3
  9. data/app/views/argus/trail/permissions/edit.html.erb +1 -1
  10. data/app/views/argus/trail/permissions/index.html.erb +4 -2
  11. data/app/views/argus/trail/permissions/show.html.erb +1 -1
  12. data/app/views/argus/trail/roles/_form.html.erb +30 -12
  13. data/app/views/argus/trail/roles/index.html.erb +3 -3
  14. data/app/views/argus/trail/roles/show.html.erb +2 -2
  15. data/lib/argus/trail/actor.rb +33 -2
  16. data/lib/argus/trail/authorizable.rb +38 -0
  17. data/lib/argus/trail/configuration.rb +23 -10
  18. data/lib/argus/trail/permission_scanner.rb +59 -0
  19. data/lib/argus/trail/version.rb +1 -1
  20. data/lib/argus/trail.rb +2 -0
  21. data/lib/generators/argus/trail/install/install_generator.rb +49 -0
  22. data/lib/generators/argus/trail/install/templates/POST_INSTALL +27 -25
  23. data/lib/generators/argus/trail/install/templates/create_argus_trail_tables.rb.erb +16 -0
  24. data/lib/generators/argus/trail/install/templates/initializer.rb +16 -3
  25. data/lib/generators/argus/trail/upgrade_v0_2/templates/POST_INSTALL +24 -0
  26. data/lib/generators/argus/trail/upgrade_v0_2/templates/add_module_wise_permissions_and_audit_index.rb.erb +21 -0
  27. data/lib/generators/argus/trail/upgrade_v0_2/upgrade_v0_2_generator.rb +42 -0
  28. data/lib/tasks/argus_trail.rake +40 -0
  29. metadata +22 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8012d44e5a16eb856aec00b306bc64e780d96c13f34e729e25b0ed0087aca325
4
- data.tar.gz: 2990ef7c7ce767ce3f3a61874b4ddb185c44c7e50cce740e7628dfd0edf531ae
3
+ metadata.gz: d86d8207fc46e8cafc37a10432ca8c334a0532fab39d679011a48cb08be88a08
4
+ data.tar.gz: 3a6763f5853cb25fd301a6041276c69762e18bdf0fc7dafe1f5f8aaed01809fe
5
5
  SHA512:
6
- metadata.gz: 0ac4bd48b2f65ac648d8ba978a5dc8e119f9e189403ae2ef0a97b350f400ff7ae35c4ee14b3e2271446b7083a6876daaaf6a9d2fd02a4ec50225a09e49da6a90
7
- data.tar.gz: 711cee8042f3d5200792b1297f770cd0a016b67e017fe65f19f8d85c0b1f10688263300fdfe0298837c34dc170c476b39a73728b51ff50420c70de3d75725840
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
- ## Wiring up your app
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
- **1. Opt your user model in:**
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 User < ApplicationRecord
47
- include Argus::Trail::Actor
68
+ class AccountsController < ApplicationController
69
+ include Argus::Trail::Authorizable
48
70
  end
49
71
  ```
50
72
 
51
- This adds `has_many :roles` (through the engine's join table),
52
- `has_permission?(name)` (true if *any* assigned role has that permission),
53
- and `sync_roles!` — diffs the requested role ids against the ones the actor
54
- currently holds and writes one `AuditEntry` per assignment
55
- (`role_assigned`) or revocation (`role_revoked`):
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
- **2. Tell the engine who's making changes**, once, in your `ApplicationController`:
65
-
66
- ```ruby
67
- before_action { Argus::Trail.current_actor = current_user }
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
- Do the same for `Argus::Trail::PermissionPolicy` and
82
- `Argus::Trail::AuditEntryPolicy`. Without Pundit — e.g. if your app uses
83
- CanCanCan, Action Policy, or nothing at all — set `config.authorize_with` to
84
- a proc instead; it always takes priority over Pundit, so this works even if
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). With neither Pundit nor `authorize_with` configured,
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, delegate to it (host must define policies, e.g.
22
- # Argus::Trail::RolePolicy — same lookup Pundit already does for any
23
- # namespaced record); otherwise fail closed with an actionable error.
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
- raise Argus::Trail::Configuration::MissingAuthorization
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 authorized_scope_via_pundit(scope_class) if Argus::Trail.config.authorize_with.nil? && defined?(Pundit)
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 authorized_scope_via_pundit(scope_class)
41
- policy_scope(scope_class)
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
@@ -59,7 +59,7 @@ module Argus
59
59
  end
60
60
 
61
61
  def set_permissions
62
- @permissions = permission_class.order(:name)
62
+ @grouped_permissions = permission_class.grouped_by_module
63
63
  end
64
64
 
65
65
  def role_params
@@ -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">Permission Name <span class="text-red-500">*</span></label>
15
- <%= f.text_field :name, placeholder: "e.g. manage_users",
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">Description <span class="text-red-500">*</span></label>
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.name.humanize %></h1>
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.name.humanize %></td>
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.count %></td>
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.name.humanize %></h1>
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
- <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
27
- <% @permissions.each do |permission| %>
28
- <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">
29
- <input type="checkbox" name="permission_ids[]" value="<%= permission.id %>"
30
- <%= "checked" if role.permission_ids.include?(permission.id) %>
31
- class="mt-0.5 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
32
- <div>
33
- <p class="text-sm font-medium text-gray-800"><%= permission.name.humanize %></p>
34
- <p class="text-xs text-gray-500 mt-0.5"><%= permission.description %></p>
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
- </label>
52
+ </div>
37
53
  <% end %>
38
54
  </div>
39
- <% if @permissions.empty? %>
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.name.humanize %>
35
+ <%= perm.display_name %>
36
36
  </span>
37
37
  <% end %>
38
- <% if role.permissions.count > 3 %>
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.count - 3 %> more
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.name.humanize %></p>
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 %>
@@ -24,8 +24,37 @@ module Argus
24
24
  as: :changed_by, class_name: "Argus::Trail::AuditEntry", dependent: :nullify
25
25
  end
26
26
 
27
- def has_permission?(permission_name)
28
- roles.joins(:permissions).exists?(Argus::Trail.config.permission_class.table_name => { name: permission_name })
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
@@ -1,5 +1,5 @@
1
1
  module Argus
2
2
  module Trail
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
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 migrations:
17
+ 1. Run the migration:
8
18
  bin/rails db:migrate
9
19
 
10
- 2. Add one line to the user/account model you configured as actor_class_name.
11
- No migration needed on that model — roles live in the engine's own
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
- class ApplicationController < ActionController::Base
21
- before_action { Argus::Trail.current_actor = current_user }
22
- end
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
- 4. If you have Pundit in your Gemfile, define policies for the engine's
25
- records so its admin screens know who's allowed in, e.g.:
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 Argus::Trail::RolePolicy < ApplicationPolicy
28
- def index? = user.admin?
29
- def show? = user.admin?
30
- # ...
31
+ class AccountsController < ApplicationController
32
+ include Argus::Trail::Authorizable
31
33
  end
32
34
 
33
- Do the same for Argus::Trail::PermissionPolicy and
34
- Argus::Trail::AuditEntryPolicy. No Pundit? Set config.authorize_with
35
- in config/initializers/argus_trail.rb instead.
36
-
37
- 5. Visit the mounted engine (default /admin/access, see config/routes.rb)
38
- to manage roles, permissions, and view the audit log.
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
- # (define Argus::Trail::RolePolicy / PermissionPolicy / AuditEntryPolicy)
28
- # — otherwise it raises until you set one of the two.
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.1.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: 2026-07-11 00:00:00.000000000 Z
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 a unified audit log of
28
- role reassignments and permission grants/revokes, shipped as a mountable engine
29
- with paginated HTML admin screens and generators for install/views/config. Agnostic
30
- to auth (works standalone, auto-integrates with Pundit) and pagination (auto-integrates
31
- with Kaminari).
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
- homepage: https://github.com/reddoorz/argus-trail
85
+ - lib/tasks/argus_trail.rake
86
+ homepage: https://ramlaxmanyadav.github.io/argus-trail/
80
87
  licenses:
81
88
  - MIT
82
89
  metadata:
83
- homepage_uri: https://github.com/reddoorz/argus-trail
84
- source_code_uri: https://github.com/reddoorz/argus-trail
85
- post_install_message:
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.4.10
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.