fosm-rails 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/AGENTS.md +58 -10
- data/README.md +123 -6
- data/app/controllers/fosm/admin/roles_controller.rb +117 -0
- data/app/controllers/fosm/application_controller.rb +32 -0
- data/app/jobs/fosm/access_event_job.rb +12 -0
- data/app/jobs/fosm/transition_log_job.rb +16 -0
- data/app/models/fosm/access_event.rb +33 -0
- data/app/models/fosm/role_assignment.rb +61 -0
- data/app/views/fosm/admin/apps/show.html.erb +95 -0
- data/app/views/fosm/admin/dashboard/index.html.erb +2 -1
- data/app/views/fosm/admin/roles/index.html.erb +139 -0
- data/app/views/fosm/admin/roles/new.html.erb +195 -0
- data/config/routes.rb +7 -2
- data/db/migrate/20240101000003_create_fosm_role_assignments.rb +41 -0
- data/db/migrate/20240101000004_create_fosm_access_events.rb +30 -0
- data/lib/fosm/configuration.rb +17 -6
- data/lib/fosm/current.rb +56 -0
- data/lib/fosm/engine.rb +6 -0
- data/lib/fosm/errors.rb +21 -0
- data/lib/fosm/lifecycle/access_definition.rb +62 -0
- data/lib/fosm/lifecycle/definition.rb +42 -4
- data/lib/fosm/lifecycle/role_definition.rb +70 -0
- data/lib/fosm/lifecycle.rb +152 -29
- data/lib/fosm/transition_buffer.rb +53 -0
- data/lib/fosm/version.rb +1 -1
- data/lib/fosm-rails.rb +2 -0
- metadata +16 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 632476c001f595b1c39b2eb3a98af17ff6aeca2c54505a373c85bc442d3f07fd
|
|
4
|
+
data.tar.gz: 1d1401c44b00161288485afa4bb7228cfd88e216b1254d12233345334d437eeb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 343cff86bca38d2098bee6b13a577c4d9b2ec1f14dd186d12f1ea239817768a737e86542cd74eb8f6266bcc4388faf615351ed6d8677420863d7f6ca1aa3560d
|
|
7
|
+
data.tar.gz: 5f811cda70c5a7321af1599ce639da25b746f70a81e7dbbecb108acd4773259cb2b00450979ab6268a4f41436c3b6a4e8c767d393972cc025701d3a00d940e7e
|
data/AGENTS.md
CHANGED
|
@@ -128,8 +128,16 @@ The admin explorer renders the lifecycle definition directly from the Ruby code
|
|
|
128
128
|
|
|
129
129
|
The `Fosm::Agent` base class generates exactly one Gemlings tool per lifecycle event. The tool calls `fire!` which enforces the machine rules. The AI cannot fire an event that doesn't exist. The AI cannot bypass a guard. The AI cannot modify state directly.
|
|
130
130
|
|
|
131
|
+
**RBAC adds a third boundary**: if the model has an `access` block, the agent tool inherits the actor's permissions. An agent running as `actor: current_user` cannot fire events the actor doesn't have a role for. The machine refusal and the permission refusal happen at the same layer — inside `fire!`.
|
|
132
|
+
|
|
131
133
|
When adding new agent capabilities, add new **lifecycle events** (which automatically generate new agent tools), not new raw database tools.
|
|
132
134
|
|
|
135
|
+
### 7. Access control lives in the lifecycle definition
|
|
136
|
+
|
|
137
|
+
Role declarations belong in the same block as states and events — not in a separate policy file, config YAML, or initializer. The lifecycle block IS the specification for what the object is, what it can do, and who can do what to it.
|
|
138
|
+
|
|
139
|
+
**Do not** introduce a separate authorization mechanism (e.g., Pundit policies, CanCanCan abilities) for FOSM-managed events. Use the `access` block. This keeps the specification co-located and removes the possibility of drift between what the machine allows and what the authorization layer allows.
|
|
140
|
+
|
|
133
141
|
---
|
|
134
142
|
|
|
135
143
|
## Engine architecture
|
|
@@ -139,15 +147,19 @@ lib/
|
|
|
139
147
|
fosm/
|
|
140
148
|
lifecycle.rb ← ActiveSupport::Concern — the main DSL mixin
|
|
141
149
|
lifecycle/
|
|
142
|
-
definition.rb ← Holds states/events/guards/side_effects for one model
|
|
150
|
+
definition.rb ← Holds states/events/guards/side_effects/access for one model
|
|
143
151
|
state_definition.rb ← Value object: name, initial?, terminal?
|
|
144
152
|
event_definition.rb ← Value object: name, from_states, to_state, guards, side_effects
|
|
145
153
|
guard_definition.rb ← Named callable: (record) → bool
|
|
146
154
|
side_effect_definition.rb ← Named callable: (record, transition) → void
|
|
155
|
+
access_definition.rb ← access{} block: roles, default_role, permission lookups
|
|
156
|
+
role_definition.rb ← Individual role: CRUD permissions + event permissions
|
|
157
|
+
current.rb ← Per-request RBAC cache (ActiveSupport::CurrentAttributes)
|
|
158
|
+
transition_buffer.rb ← :buffered log strategy — thread-safe queue + bulk INSERT
|
|
147
159
|
agent.rb ← Base class: model_class DSL + Gemlings tool generation
|
|
148
|
-
configuration.rb ← Fosm.configure { } block
|
|
160
|
+
configuration.rb ← Fosm.configure { } block (incl. transition_log_strategy)
|
|
149
161
|
registry.rb ← Global slug → model_class map
|
|
150
|
-
errors.rb ← Fosm::InvalidTransition, GuardFailed, etc.
|
|
162
|
+
errors.rb ← Fosm::InvalidTransition, GuardFailed, AccessDenied, etc.
|
|
151
163
|
engine.rb ← Rails::Engine, migration hooks, auto-registration
|
|
152
164
|
fosm-rails.rb ← Entry point
|
|
153
165
|
|
|
@@ -155,16 +167,21 @@ app/
|
|
|
155
167
|
models/fosm/
|
|
156
168
|
transition_log.rb ← Immutable audit trail (shared across all FOSM apps)
|
|
157
169
|
webhook_subscription.rb ← Admin-configured HTTP callbacks
|
|
170
|
+
role_assignment.rb ← Actor → role → resource (type-level or record-level)
|
|
171
|
+
access_event.rb ← Immutable RBAC audit log (grants/revokes)
|
|
158
172
|
controllers/fosm/
|
|
159
|
-
application_controller.rb ← Inherits from
|
|
173
|
+
application_controller.rb ← Inherits from base_controller; provides fosm_authorize!
|
|
160
174
|
admin/
|
|
161
175
|
base_controller.rb ← Admin auth before_action
|
|
162
176
|
dashboard_controller.rb
|
|
163
|
-
apps_controller.rb
|
|
177
|
+
apps_controller.rb ← Renders access control matrix on app detail page
|
|
178
|
+
roles_controller.rb ← Grant/revoke roles; superadmin only
|
|
164
179
|
transitions_controller.rb
|
|
165
180
|
webhooks_controller.rb
|
|
166
181
|
jobs/fosm/
|
|
167
182
|
webhook_delivery_job.rb ← Async HTTP POST with HMAC signing, retries
|
|
183
|
+
transition_log_job.rb ← Async transition log write (:async strategy)
|
|
184
|
+
access_event_job.rb ← Async RBAC audit log write
|
|
168
185
|
|
|
169
186
|
lib/generators/fosm/app/
|
|
170
187
|
app_generator.rb ← rails generate fosm:app
|
|
@@ -181,6 +198,8 @@ lib/generators/fosm/app/
|
|
|
181
198
|
|
|
182
199
|
## How `fire!` works
|
|
183
200
|
|
|
201
|
+
With the RBAC and async audit trail additions, `fire!` now follows this sequence:
|
|
202
|
+
|
|
184
203
|
```
|
|
185
204
|
record.fire!(:send_invoice, actor: current_user)
|
|
186
205
|
|
|
@@ -189,16 +208,45 @@ record.fire!(:send_invoice, actor: current_user)
|
|
|
189
208
|
3. Check: is current state terminal? → raise TerminalState if yes
|
|
190
209
|
4. Check: is the event valid from current state? → raise InvalidTransition if not
|
|
191
210
|
5. Run guards: each guard.call(record) → raise GuardFailed if any return false
|
|
192
|
-
6.
|
|
211
|
+
6. RBAC check (if access block declared):
|
|
212
|
+
a. Bypass if actor is nil, Symbol, or superadmin
|
|
213
|
+
b. Load actor's roles from per-request cache (one SQL query total, then O(1))
|
|
214
|
+
c. Check if any actor role permits this event → raise AccessDenied if not
|
|
215
|
+
7. Begin database transaction:
|
|
193
216
|
a. UPDATE record SET state = 'sent'
|
|
194
|
-
b. INSERT INTO fosm_transition_logs (...)
|
|
217
|
+
b. [if strategy == :sync] INSERT INTO fosm_transition_logs (...)
|
|
195
218
|
c. Run each side_effect.call(record, transition_data)
|
|
196
219
|
d. COMMIT (or ROLLBACK if any step raises)
|
|
197
|
-
|
|
198
|
-
|
|
220
|
+
8. [if strategy == :async] Enqueue TransitionLogJob (non-blocking)
|
|
221
|
+
[if strategy == :buffered] Push to TransitionBuffer (flushed every ~1s)
|
|
222
|
+
9. Enqueue WebhookDeliveryJob asynchronously (always, outside transaction)
|
|
223
|
+
10. Return true
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Total blocking SQL operations: 1** (the UPDATE). Everything else is either in-memory, cached, or async. The RBAC check at step 6 is O(1) after the first check per actor per request.
|
|
227
|
+
|
|
228
|
+
### Transition log strategies
|
|
229
|
+
|
|
230
|
+
| Strategy | Latency | Consistency | When to use |
|
|
231
|
+
|---|---|---|---|
|
|
232
|
+
| `:sync` | +1ms | Strict — log always matches state | Compliance requirements, testing |
|
|
233
|
+
| `:async` | ~0ms | Near-real-time (ms delay via SolidQueue) | Production default |
|
|
234
|
+
| `:buffered` | ~0ms | Up to 1s delay; data loss on crash | Very high throughput (1000+ fire!/sec) |
|
|
235
|
+
|
|
236
|
+
Configure in `config/initializers/fosm.rb`:
|
|
237
|
+
```ruby
|
|
238
|
+
config.transition_log_strategy = :async # recommended
|
|
199
239
|
```
|
|
200
240
|
|
|
201
|
-
|
|
241
|
+
### RBAC access model
|
|
242
|
+
|
|
243
|
+
The access control design draws from three traditions:
|
|
244
|
+
|
|
245
|
+
- **Linux/POSIX**: permissions live ON the object (in the lifecycle block, not a separate file), deny-by-default once declared, root/superadmin always bypasses
|
|
246
|
+
- **SAP authorization**: separation of duties (the :owner who sends an invoice cannot be the :approver who pays it), activity granularity (CRUD actions + individual events), audit trail for every access change
|
|
247
|
+
- **Rails/DHH**: convention-over-configuration (no `access` block = open by default), rules readable as English in the model file, one query per request via `CurrentAttributes` cache
|
|
248
|
+
|
|
249
|
+
The `Fosm::Current` cache loads ALL role assignments for the current actor in one SQL query on first access, keyed by `"ClassName:id"`. Subsequent RBAC checks in the same request (across multiple records or events) hit the in-memory hash only. The cache resets automatically at the end of each request.
|
|
202
250
|
|
|
203
251
|
---
|
|
204
252
|
|
data/README.md
CHANGED
|
@@ -58,7 +58,7 @@ Rails.application.routes.draw do
|
|
|
58
58
|
end
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
Configure auth in `config/initializers/fosm.rb`:
|
|
61
|
+
Configure auth and performance in `config/initializers/fosm.rb`:
|
|
62
62
|
|
|
63
63
|
```ruby
|
|
64
64
|
Fosm.configure do |config|
|
|
@@ -71,12 +71,18 @@ Fosm.configure do |config|
|
|
|
71
71
|
# How to authorize individual FOSM apps
|
|
72
72
|
config.app_authorize = ->(_level) { authenticate_user! }
|
|
73
73
|
|
|
74
|
-
# How to get the current user (for transition log actor tracking)
|
|
74
|
+
# How to get the current user (for transition log actor tracking and RBAC)
|
|
75
75
|
config.current_user_method = -> { current_user }
|
|
76
76
|
|
|
77
77
|
# Layouts
|
|
78
78
|
config.admin_layout = "admin" # your admin layout
|
|
79
79
|
config.app_layout = "application"
|
|
80
|
+
|
|
81
|
+
# Transition log write strategy:
|
|
82
|
+
# :sync — INSERT inside the fire! transaction (strictest consistency, default)
|
|
83
|
+
# :async — SolidQueue job after commit (non-blocking, recommended for production)
|
|
84
|
+
# :buffered — bulk INSERT every ~1s via background thread (highest throughput)
|
|
85
|
+
config.transition_log_strategy = :async
|
|
80
86
|
end
|
|
81
87
|
```
|
|
82
88
|
|
|
@@ -150,6 +156,107 @@ side_effect :send_notification, on: :activate do |record, transition|
|
|
|
150
156
|
end
|
|
151
157
|
```
|
|
152
158
|
|
|
159
|
+
### Access control (RBAC)
|
|
160
|
+
|
|
161
|
+
Declare role-based access control inside the `lifecycle` block. Without an `access` block the object is **open-by-default** (all authenticated actors can do everything — backwards-compatible). Once you add an `access` block, the object becomes **deny-by-default**: only explicitly granted capabilities work.
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
lifecycle do
|
|
165
|
+
state :draft, initial: true
|
|
166
|
+
state :sent
|
|
167
|
+
state :paid, terminal: true
|
|
168
|
+
state :cancelled, terminal: true
|
|
169
|
+
|
|
170
|
+
event :send_invoice, from: :draft, to: :sent
|
|
171
|
+
event :pay, from: [:sent], to: :paid
|
|
172
|
+
event :cancel, from: [:draft, :sent], to: :cancelled
|
|
173
|
+
|
|
174
|
+
# ── Access control ────────────────────────────────────────────────
|
|
175
|
+
access do
|
|
176
|
+
# default: true → this role is auto-assigned to the record creator on create
|
|
177
|
+
role :owner, default: true do
|
|
178
|
+
can :crud # shorthand: create + read + update + delete
|
|
179
|
+
can :send_invoice, :cancel # lifecycle events this role may fire
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
role :approver do
|
|
183
|
+
can :read # view the record
|
|
184
|
+
can :pay # fire the :pay event (separation of duties)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
role :viewer do
|
|
188
|
+
can :read # read-only, no event access
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**`can` accepts:**
|
|
195
|
+
|
|
196
|
+
| Argument | Meaning |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `:crud` | Shorthand for all four CRUD operations |
|
|
199
|
+
| `:create` / `:read` / `:update` / `:delete` | Individual CRUD permission |
|
|
200
|
+
| `:send_invoice`, `:pay`, etc. | Permission to fire that specific lifecycle event |
|
|
201
|
+
|
|
202
|
+
**Bypass rules (never blocked by RBAC):**
|
|
203
|
+
|
|
204
|
+
| Actor | Reason |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `actor: nil` | No user context (cron jobs, migrations, console) |
|
|
207
|
+
| `actor: :system` or any Symbol | Programmatic / internal invocations |
|
|
208
|
+
| Superadmin (`actor.superadmin? == true`) | Root equivalent — bypasses all checks |
|
|
209
|
+
|
|
210
|
+
#### Role assignment database
|
|
211
|
+
|
|
212
|
+
Roles are stored in `fosm_role_assignments`. Two scopes:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
# Type-level: Alice is an :approver for ALL Fosm::Invoice records
|
|
216
|
+
Fosm::RoleAssignment.create!(
|
|
217
|
+
user_type: "User",
|
|
218
|
+
user_id: alice.id.to_s,
|
|
219
|
+
resource_type: "Fosm::Invoice",
|
|
220
|
+
resource_id: nil, # nil = type-level
|
|
221
|
+
role_name: "approver"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Record-level: Bob is an :owner for Invoice #42 only
|
|
225
|
+
Fosm::RoleAssignment.create!(
|
|
226
|
+
user_type: "User",
|
|
227
|
+
user_id: bob.id.to_s,
|
|
228
|
+
resource_type: "Fosm::Invoice",
|
|
229
|
+
resource_id: "42", # specific record
|
|
230
|
+
role_name: "owner"
|
|
231
|
+
)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**Auto-assignment on create:** if `default: true` is set on a role and the record has a `created_by` association, FOSM automatically assigns that role to the creator when the record is saved.
|
|
235
|
+
|
|
236
|
+
#### Runtime performance
|
|
237
|
+
|
|
238
|
+
The first RBAC check in a request loads ALL role assignments for the current actor in **one SQL query**, then serves all subsequent checks in the same request from an in-memory hash (O(1)). The cache resets automatically at the end of each request via `ActiveSupport::CurrentAttributes`.
|
|
239
|
+
|
|
240
|
+
#### CRUD enforcement in controllers
|
|
241
|
+
|
|
242
|
+
Use `fosm_authorize!` in generated controllers to enforce CRUD permissions:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
class Fosm::InvoiceController < Fosm::ApplicationController
|
|
246
|
+
before_action -> { fosm_authorize!(:read, Fosm::Invoice) }, only: [:index, :show]
|
|
247
|
+
before_action -> { fosm_authorize!(:create, Fosm::Invoice) }, only: [:new, :create]
|
|
248
|
+
before_action -> { fosm_authorize!(:update, @record) }, only: [:edit, :update]
|
|
249
|
+
before_action -> { fosm_authorize!(:delete, @record) }, only: [:destroy]
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Raises `Fosm::AccessDenied` (a subclass of `Fosm::Error`) if the actor lacks the required role. RBAC is only checked if the lifecycle has an `access` block — otherwise `fosm_authorize!` is a no-op.
|
|
254
|
+
|
|
255
|
+
#### Admin UI for roles
|
|
256
|
+
|
|
257
|
+
- **App detail page** (`/fosm/admin/apps/:slug`) — shows a read-only access control matrix below the lifecycle definition table, with one column per CRUD action and one per lifecycle event
|
|
258
|
+
- **Role assignments** (`/fosm/admin/roles`) — manage role assignments, view declared roles per app, and browse the immutable access event audit trail
|
|
259
|
+
|
|
153
260
|
---
|
|
154
261
|
|
|
155
262
|
## Firing events
|
|
@@ -225,8 +332,9 @@ The agent cannot fire an event that doesn't exist in the lifecycle. Invalid tran
|
|
|
225
332
|
|
|
226
333
|
The engine mounts an admin interface at `/fosm/admin` (access controlled by `config.admin_authorize`):
|
|
227
334
|
|
|
228
|
-
- **Dashboard** — all FOSM apps with state distribution
|
|
229
|
-
- **App detail** — lifecycle definition table, state distribution chart, stuck record detection
|
|
335
|
+
- **Dashboard** — all FOSM apps with state distribution; link to role assignments
|
|
336
|
+
- **App detail** — lifecycle definition table, state distribution chart, stuck record detection, and **access control matrix** (read-only view of declared roles and permissions)
|
|
337
|
+
- **Role assignments** (`/fosm/admin/roles`) — grant/revoke roles, view declared roles per app, browse immutable access event audit trail; accessible only to `config.admin_authorize` actors
|
|
230
338
|
- **Agent explorer** (`/fosm/admin/apps/:slug/agent`) — the auto-generated tool catalog for the app's AI agent, a direct tool tester (no LLM required), and the system prompt injected into agents
|
|
231
339
|
- **Agent chat** (`/fosm/admin/apps/:slug/agent/chat`) — live multi-turn chat with the agent; see tool calls, thoughts, and state changes in real time
|
|
232
340
|
- **Transition log** — complete audit trail, filterable by app / event / actor (human vs AI agent)
|
|
@@ -289,13 +397,22 @@ your_rails_app/
|
|
|
289
397
|
|
|
290
398
|
# Engine provides (from gem):
|
|
291
399
|
app/models/fosm/
|
|
292
|
-
transition_log.rb ← Shared audit trail
|
|
400
|
+
transition_log.rb ← Shared immutable transition audit trail
|
|
293
401
|
webhook_subscription.rb
|
|
402
|
+
role_assignment.rb ← RBAC: actor → role → resource
|
|
403
|
+
access_event.rb ← RBAC: immutable audit log of grants/revokes
|
|
294
404
|
app/controllers/fosm/admin/
|
|
295
405
|
dashboard_controller.rb
|
|
406
|
+
roles_controller.rb ← Role assignment management (superadmin only)
|
|
296
407
|
...
|
|
297
408
|
lib/fosm/
|
|
298
|
-
lifecycle.rb ← The DSL concern
|
|
409
|
+
lifecycle.rb ← The DSL concern (states, events, guards, access)
|
|
410
|
+
lifecycle/
|
|
411
|
+
definition.rb ← Holds all lifecycle + access metadata
|
|
412
|
+
access_definition.rb ← access{} block: role declarations
|
|
413
|
+
role_definition.rb ← Individual role with CRUD + event permissions
|
|
414
|
+
current.rb ← Per-request RBAC cache (one SQL query per actor)
|
|
415
|
+
transition_buffer.rb ← :buffered log strategy (bulk INSERT thread)
|
|
299
416
|
agent.rb ← Gemlings base agent
|
|
300
417
|
engine.rb
|
|
301
418
|
```
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
module Admin
|
|
3
|
+
class RolesController < Fosm::Admin::BaseController
|
|
4
|
+
def index
|
|
5
|
+
@assignments = Fosm::RoleAssignment.order(created_at: :desc)
|
|
6
|
+
@access_events = Fosm::AccessEvent.recent.limit(20)
|
|
7
|
+
@apps = Fosm::Registry.all
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def new
|
|
11
|
+
@assignment = Fosm::RoleAssignment.new
|
|
12
|
+
@apps = Fosm::Registry.all
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
@assignment = Fosm::RoleAssignment.new(assignment_params)
|
|
17
|
+
@assignment.granted_by_type = fosm_current_user&.class&.name
|
|
18
|
+
@assignment.granted_by_id = fosm_current_user&.id&.to_s
|
|
19
|
+
|
|
20
|
+
if @assignment.save
|
|
21
|
+
Fosm::AccessEventJob.perform_later({
|
|
22
|
+
"action" => "grant",
|
|
23
|
+
"user_type" => @assignment.user_type,
|
|
24
|
+
"user_id" => @assignment.user_id,
|
|
25
|
+
"user_label" => resolve_user_label(@assignment.user_type, @assignment.user_id),
|
|
26
|
+
"resource_type" => @assignment.resource_type,
|
|
27
|
+
"resource_id" => @assignment.resource_id,
|
|
28
|
+
"role_name" => @assignment.role_name,
|
|
29
|
+
"performed_by_type" => fosm_current_user&.class&.name,
|
|
30
|
+
"performed_by_id" => fosm_current_user&.id&.to_s,
|
|
31
|
+
"performed_by_label" => (fosm_current_user.respond_to?(:email) ? fosm_current_user.email : fosm_current_user.to_s)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
# Invalidate the per-request RBAC cache for the affected user so their
|
|
35
|
+
# new role is visible immediately in the same request (unlikely but correct)
|
|
36
|
+
user = @assignment.actor
|
|
37
|
+
Fosm::Current.invalidate_for(user) if user
|
|
38
|
+
|
|
39
|
+
redirect_to fosm.admin_roles_path, notice: "Role :#{@assignment.role_name} granted to #{@assignment.actor_label}."
|
|
40
|
+
else
|
|
41
|
+
@apps = Fosm::Registry.all
|
|
42
|
+
render :new, status: :unprocessable_entity
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def users_search
|
|
47
|
+
q = params[:q].to_s.strip
|
|
48
|
+
user_type = params[:user_type].presence || "User"
|
|
49
|
+
results = []
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
klass = user_type.constantize
|
|
53
|
+
scope = klass.all
|
|
54
|
+
|
|
55
|
+
if q.present?
|
|
56
|
+
searchable = klass.column_names & %w[email name]
|
|
57
|
+
if searchable.any?
|
|
58
|
+
conditions = searchable.map { |col| "lower(#{col}) LIKE :q" }.join(" OR ")
|
|
59
|
+
scope = scope.where(conditions, q: "%#{q.downcase}%")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
results = scope.limit(10).map do |user|
|
|
64
|
+
label = [
|
|
65
|
+
(user.name if user.respond_to?(:name) && user.name.present?),
|
|
66
|
+
(user.email if user.respond_to?(:email))
|
|
67
|
+
].compact.join(" — ")
|
|
68
|
+
{ id: user.id.to_s, label: label }
|
|
69
|
+
end
|
|
70
|
+
rescue NameError
|
|
71
|
+
# unknown user_type — return empty
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
render json: results
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def destroy
|
|
78
|
+
@assignment = Fosm::RoleAssignment.find(params[:id])
|
|
79
|
+
|
|
80
|
+
Fosm::AccessEventJob.perform_later({
|
|
81
|
+
"action" => "revoke",
|
|
82
|
+
"user_type" => @assignment.user_type,
|
|
83
|
+
"user_id" => @assignment.user_id,
|
|
84
|
+
"user_label" => @assignment.actor_label,
|
|
85
|
+
"resource_type" => @assignment.resource_type,
|
|
86
|
+
"resource_id" => @assignment.resource_id,
|
|
87
|
+
"role_name" => @assignment.role_name,
|
|
88
|
+
"performed_by_type" => fosm_current_user&.class&.name,
|
|
89
|
+
"performed_by_id" => fosm_current_user&.id&.to_s,
|
|
90
|
+
"performed_by_label" => (fosm_current_user.respond_to?(:email) ? fosm_current_user.email : fosm_current_user.to_s)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
user = @assignment.actor
|
|
94
|
+
@assignment.destroy!
|
|
95
|
+
Fosm::Current.invalidate_for(user) if user
|
|
96
|
+
|
|
97
|
+
redirect_to fosm.admin_roles_path, notice: "Role revoked."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def assignment_params
|
|
103
|
+
params.require(:fosm_role_assignment).permit(
|
|
104
|
+
:user_type, :user_id, :resource_type, :resource_id, :role_name
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_user_label(user_type, user_id)
|
|
109
|
+
user = user_type.constantize.find_by(id: user_id)
|
|
110
|
+
return "#{user_type}##{user_id}" unless user
|
|
111
|
+
user.respond_to?(:email) ? user.email : user.to_s
|
|
112
|
+
rescue NameError
|
|
113
|
+
"#{user_type}##{user_id}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -19,5 +19,37 @@ module Fosm
|
|
|
19
19
|
def fosm_current_user
|
|
20
20
|
instance_exec(&Fosm.config.current_user_method)
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
# Check CRUD permissions for the current actor.
|
|
24
|
+
# Raises Fosm::AccessDenied if the actor lacks the required role.
|
|
25
|
+
# No-ops if the lifecycle has no access block declared (open-by-default).
|
|
26
|
+
#
|
|
27
|
+
# @param action [Symbol] :create, :read, :update, or :delete
|
|
28
|
+
# @param subject [ActiveRecord::Base, Class] a record or model class
|
|
29
|
+
#
|
|
30
|
+
# Example usage in generated controllers:
|
|
31
|
+
# before_action -> { fosm_authorize!(:read, Fosm::Invoice) }, only: [:index, :show]
|
|
32
|
+
# before_action -> { fosm_authorize!(:create, Fosm::Invoice) }, only: [:new, :create]
|
|
33
|
+
# before_action -> { fosm_authorize!(:update, @record) }, only: [:edit, :update]
|
|
34
|
+
# before_action -> { fosm_authorize!(:delete, @record) }, only: [:destroy]
|
|
35
|
+
def fosm_authorize!(action, subject)
|
|
36
|
+
model_class = subject.is_a?(Class) ? subject : subject.class
|
|
37
|
+
lifecycle = model_class.try(:fosm_lifecycle)
|
|
38
|
+
return unless lifecycle&.access_defined?
|
|
39
|
+
|
|
40
|
+
actor = fosm_current_user
|
|
41
|
+
# Bypass for superadmin and nil/symbol actors (mirrors fire! logic)
|
|
42
|
+
return if actor.nil?
|
|
43
|
+
return if actor.is_a?(Symbol)
|
|
44
|
+
return if actor.respond_to?(:superadmin?) && actor.superadmin?
|
|
45
|
+
|
|
46
|
+
record_id = subject.is_a?(ActiveRecord::Base) ? subject.id : nil
|
|
47
|
+
actor_roles = Fosm::Current.roles_for(actor, model_class, record_id)
|
|
48
|
+
permitted_roles = lifecycle.access_definition.roles_for_crud(action)
|
|
49
|
+
|
|
50
|
+
unless (actor_roles & permitted_roles).any?
|
|
51
|
+
raise Fosm::AccessDenied.new(action, actor)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
22
54
|
end
|
|
23
55
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
# Writes an access event audit record asynchronously.
|
|
3
|
+
# Called whenever a role is granted or revoked.
|
|
4
|
+
class AccessEventJob < Fosm::ApplicationJob
|
|
5
|
+
queue_as :fosm_audit
|
|
6
|
+
|
|
7
|
+
# @param event_data [Hash] all columns for the access event row (string keys)
|
|
8
|
+
def perform(event_data)
|
|
9
|
+
Fosm::AccessEvent.create!(event_data)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
# Writes a single transition log entry asynchronously.
|
|
3
|
+
# Used when config.transition_log_strategy = :async (SolidQueue default).
|
|
4
|
+
#
|
|
5
|
+
# The state UPDATE has already committed before this job runs, so there is
|
|
6
|
+
# at most a brief delay between the transition completing and the log entry
|
|
7
|
+
# appearing. For strict consistency, use config.transition_log_strategy = :sync.
|
|
8
|
+
class TransitionLogJob < Fosm::ApplicationJob
|
|
9
|
+
queue_as :fosm_audit
|
|
10
|
+
|
|
11
|
+
# @param log_data [Hash] all columns for the transition log row (string keys)
|
|
12
|
+
def perform(log_data)
|
|
13
|
+
Fosm::TransitionLog.create!(log_data)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
# Immutable append-only audit log for RBAC operations.
|
|
3
|
+
#
|
|
4
|
+
# Records every grant and revoke action so there is a complete audit trail
|
|
5
|
+
# of who had what access, when, and who authorized it.
|
|
6
|
+
#
|
|
7
|
+
# Written asynchronously via Fosm::AccessEventJob (non-blocking).
|
|
8
|
+
class AccessEvent < Fosm::ApplicationRecord
|
|
9
|
+
self.table_name = "fosm_access_events"
|
|
10
|
+
|
|
11
|
+
ACTIONS = %w[grant revoke auto_grant].freeze
|
|
12
|
+
|
|
13
|
+
validates :action, presence: true, inclusion: { in: ACTIONS }
|
|
14
|
+
validates :user_type, presence: true
|
|
15
|
+
validates :user_id, presence: true
|
|
16
|
+
validates :resource_type, presence: true
|
|
17
|
+
validates :role_name, presence: true
|
|
18
|
+
|
|
19
|
+
# Immutability: access events are append-only, never modified or deleted
|
|
20
|
+
before_update { raise ActiveRecord::ReadOnlyRecord, "Fosm::AccessEvent records are immutable" }
|
|
21
|
+
before_destroy { raise ActiveRecord::ReadOnlyRecord, "Fosm::AccessEvent records are immutable" }
|
|
22
|
+
|
|
23
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
24
|
+
scope :grants, -> { where(action: "grant") }
|
|
25
|
+
scope :revokes, -> { where(action: "revoke") }
|
|
26
|
+
scope :for_user, ->(user) { where(user_type: user.class.name, user_id: user.id.to_s) }
|
|
27
|
+
scope :for_resource_type, ->(model_class) { where(resource_type: model_class.to_s) }
|
|
28
|
+
|
|
29
|
+
def grant? = action == "grant"
|
|
30
|
+
def revoke? = action == "revoke"
|
|
31
|
+
def auto_grant? = action == "auto_grant"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
# Persists an actor's role on a FOSM resource.
|
|
3
|
+
#
|
|
4
|
+
# Scopes:
|
|
5
|
+
# resource_id: nil → type-level ("Alice is an :approver for ALL Fosm::Invoice records")
|
|
6
|
+
# resource_id: "42" → record-level ("Alice is an :approver for Fosm::Invoice #42 only")
|
|
7
|
+
#
|
|
8
|
+
# Role names must match those declared in the model's lifecycle access block.
|
|
9
|
+
# This model does NOT validate role names against the lifecycle (it would create a
|
|
10
|
+
# hard coupling between DB state and code). Invalid role names simply have no effect
|
|
11
|
+
# at runtime because Fosm::Current.roles_for returns them but no permission grants
|
|
12
|
+
# will ever include them.
|
|
13
|
+
class RoleAssignment < Fosm::ApplicationRecord
|
|
14
|
+
self.table_name = "fosm_role_assignments"
|
|
15
|
+
|
|
16
|
+
validates :user_type, presence: true
|
|
17
|
+
validates :user_id, presence: true
|
|
18
|
+
validates :resource_type, presence: true
|
|
19
|
+
validates :role_name, presence: true
|
|
20
|
+
|
|
21
|
+
validates :user_id, uniqueness: {
|
|
22
|
+
scope: %i[user_type resource_type resource_id role_name],
|
|
23
|
+
message: "already has this role on this resource"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Scope: all type-level assignments (applies to every record of resource_type)
|
|
27
|
+
scope :type_level, -> { where(resource_id: nil) }
|
|
28
|
+
# Scope: all record-level assignments (pinned to a specific record)
|
|
29
|
+
scope :record_level, -> { where.not(resource_id: nil) }
|
|
30
|
+
# Scope: for a specific FOSM model class
|
|
31
|
+
scope :for_resource_type, ->(model_class) { where(resource_type: model_class.to_s) }
|
|
32
|
+
# Scope: for a specific actor
|
|
33
|
+
scope :for_user, ->(user) { where(user_type: user.class.name, user_id: user.id.to_s) }
|
|
34
|
+
|
|
35
|
+
# Try to resolve the actor object (may return nil if class no longer exists)
|
|
36
|
+
def actor
|
|
37
|
+
user_type.constantize.find_by(id: user_id)
|
|
38
|
+
rescue NameError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Human-readable display label for the actor
|
|
43
|
+
def actor_label
|
|
44
|
+
a = actor
|
|
45
|
+
return "#{user_type}##{user_id}" unless a
|
|
46
|
+
a.respond_to?(:email) ? a.email : a.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record_level?
|
|
50
|
+
resource_id.present?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def type_level?
|
|
54
|
+
resource_id.nil?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def scope_label
|
|
58
|
+
record_level? ? "#{resource_type}##{resource_id}" : "all #{resource_type.demodulize.pluralize}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|