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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: caa495883333e121bf051fcc48f83f16a9182bf0b3ab04257f0c6eb4d6aa107b
4
- data.tar.gz: 95d917636381c725c1bc35f105775bf7dd262064143d1779bf2eee24a092fb6a
3
+ metadata.gz: 632476c001f595b1c39b2eb3a98af17ff6aeca2c54505a373c85bc442d3f07fd
4
+ data.tar.gz: 1d1401c44b00161288485afa4bb7228cfd88e216b1254d12233345334d437eeb
5
5
  SHA512:
6
- metadata.gz: b0a8dcc832dc6a0b459023dc630a224d45d321add78a180d96d0e588b7080e1546809001bbd8e48441e67ca39062fca5ad3fb14e2319d2001e3ad20b395217ba
7
- data.tar.gz: 73ae82e375097698267f092dbffea06f2472ef36ec4f158213daf3186890d51ec5a53e2f7fb26fa974827f0180bae3bf6bf2e9c62100e7b74828f2bf1f9dafa9
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 configured base_controller
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. Begin database transaction:
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
- 7. Enqueue WebhookDeliveryJob asynchronously (outside transaction)
198
- 8. Return true
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
- The transaction ensures that if a side effect raises, the state update is rolled back. The webhook job fires outside the transaction so it doesn't delay the response and doesn't roll back if the HTTP call fails.
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