fosm-rails 0.2.3 → 0.2.5

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: 50ac8659af44dfad52d33f333001e6b3b846cfbb635e56534366ca371631501a
4
- data.tar.gz: 5d727e837eefe74fa85677199b6cbc78b7e3df28fe33f8275e4c8bd93900383b
3
+ metadata.gz: 6f80bae4ce6fd6dba16419a7a68ac6cec93439bb0296caf5e3c335f039e216ab
4
+ data.tar.gz: 468c55584847418dc48a49466bd1b13fba8268deb40c20086bce383f8b7221ba
5
5
  SHA512:
6
- metadata.gz: e3adb965398f95f843d6d58f39f6b1b54d6c0084c46761b0f30678c7484bf6ca6c4e182e21ce5504b391cf147d9495da4d0d1fcad3ec4992693d7129c40c80c0
7
- data.tar.gz: 04e1e7595f6dc9c211d7eb923ef167e80d612fab71149f1b5624cc5cbffd46ed64f16d909efd8a4c4a418a4895ba02fba39926fd98f1bcb1a09a028b06e7ca3e
6
+ metadata.gz: 988a291c82244fd9249e4698df06f921e0a0eddfb20811238dd42b6d780a90560a3aaded7027260b79e7a4d6e31d5489c877d9b7ea0567ba4bd5e65eea95644c
7
+ data.tar.gz: 2a294a01dd336497e1437a536c8b2e6b1ca69bcffaaea6240e05572e1c9e632ae599c4abd2e099cafb98e8d91c3786fd9a377d9805f43feadef296bbccc1fbc1
data/README.md CHANGED
@@ -337,6 +337,7 @@ The engine mounts an admin interface at `/fosm/admin` (access controlled by `con
337
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
338
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
339
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
340
+ - **Data retention** (`/fosm/admin/data_retention`) — configurable retention policies (default 10 years) with a dashboard showing purge-eligible records per model. Supports single-record and bulk purge with safe-to-purge guards; transition logs are never deleted.
340
341
  - **Transition log** — complete audit trail, filterable by app / event / actor (human vs AI agent)
341
342
  - **Webhooks** — configure HTTP callbacks for any FOSM event (with HMAC-SHA256 signing)
342
343
  - **Settings** — LLM provider key status, engine configuration overview
@@ -0,0 +1,125 @@
1
+ module Fosm
2
+ module Admin
3
+ # Admin dashboard for data retention policy enforcement.
4
+ #
5
+ # Shows all archival-eligible FOSM models (terminal state containing "archiv"
6
+ # + archived_at column) and lets admins review and purge records that have
7
+ # exceeded the configured retention window.
8
+ #
9
+ # All destructive actions run asynchronously via DataRetentionPurgeJob so
10
+ # the request/response cycle is never blocked by bulk deletes.
11
+ class DataRetentionController < BaseController
12
+ PER_PAGE = 50
13
+
14
+ # GET /fosm/admin/data_retention
15
+ # Lists every archival-eligible model with counts.
16
+ def index
17
+ @retention_days = Fosm.config.data_retention_days
18
+ @cutoff_date = Fosm::DataRetention.retention_cutoff_date
19
+
20
+ @eligible_models = Fosm::DataRetention.archival_eligible_models.map do |model_class|
21
+ slug = Fosm::Registry.all.find { |_s, klass| klass.name == model_class.name }&.first
22
+ {
23
+ model_class: model_class,
24
+ slug: slug,
25
+ name: model_class.name.demodulize.titleize,
26
+ archival_states: Fosm::DataRetention.archival_states_for(model_class),
27
+ total_in_archive: Fosm::DataRetention.total_in_archival_state(model_class),
28
+ eligible_for_purge: Fosm::DataRetention.total_eligible_for_purge(model_class)
29
+ }
30
+ end
31
+ end
32
+
33
+ # GET /fosm/admin/data_retention/:id (:id = model slug, e.g. "faas_account")
34
+ # Paginated list of purge-eligible records for one model.
35
+ def show
36
+ @model_class = resolve_eligible_model!(params[:id])
37
+ @slug = params[:id]
38
+ @name = @model_class.name.demodulize.titleize
39
+
40
+ @retention_days = Fosm.config.data_retention_days
41
+ @cutoff_date = Fosm::DataRetention.retention_cutoff_date
42
+ @archival_states = Fosm::DataRetention.archival_states_for(@model_class)
43
+
44
+ @page = [params[:page].to_i, 1].max
45
+ @total = Fosm::DataRetention.total_eligible_for_purge(@model_class)
46
+ @total_pages = [(@total.to_f / PER_PAGE).ceil, 1].max
47
+ @records = Fosm::DataRetention.records_eligible_for_purge(
48
+ @model_class, page: @page, per_page: PER_PAGE
49
+ )
50
+ end
51
+
52
+ # POST /fosm/admin/data_retention/:id/purge_record
53
+ # Enqueues a single-record purge. params[:record_id] identifies the row.
54
+ def purge_record
55
+ @model_class = resolve_eligible_model!(params[:id])
56
+ record = @model_class.find_by(id: params[:record_id])
57
+
58
+ unless record
59
+ redirect_to fosm.admin_data_retention_path(params[:id]),
60
+ alert: "Record not found — it may have already been purged."
61
+ return
62
+ end
63
+
64
+ unless Fosm::DataRetention.safe_to_purge?(record)
65
+ redirect_to fosm.admin_data_retention_path(params[:id]),
66
+ alert: "Record ##{record.id} is not eligible for purge " \
67
+ "(within the #{Fosm.config.data_retention_days}-day retention window)."
68
+ return
69
+ end
70
+
71
+ Fosm::DataRetentionPurgeJob.perform_later(
72
+ model_class_name: @model_class.name,
73
+ record_id: record.id.to_s,
74
+ purged_by_label: current_admin_label
75
+ )
76
+
77
+ redirect_to fosm.admin_data_retention_path(params[:id]),
78
+ notice: "Record ##{record.id} has been queued for purge."
79
+ end
80
+
81
+ # POST /fosm/admin/data_retention/:id/purge_all_expired
82
+ # Enqueues a bulk purge of all retention-expired records for this model.
83
+ def purge_all_expired
84
+ @model_class = resolve_eligible_model!(params[:id])
85
+ total = Fosm::DataRetention.total_eligible_for_purge(@model_class)
86
+
87
+ if total.zero?
88
+ redirect_to fosm.admin_data_retention_path(params[:id]),
89
+ notice: "No records are eligible for purge."
90
+ return
91
+ end
92
+
93
+ Fosm::DataRetentionPurgeJob.perform_later(
94
+ model_class_name: @model_class.name,
95
+ bulk: true,
96
+ purged_by_label: current_admin_label
97
+ )
98
+
99
+ redirect_to fosm.admin_data_retention_path(params[:id]),
100
+ notice: "#{total} record(s) queued for bulk purge. This runs asynchronously."
101
+ end
102
+
103
+ private
104
+
105
+ def resolve_eligible_model!(slug)
106
+ model_class = Fosm::Registry.find(slug)
107
+ raise ActionController::RoutingError, "Unknown FOSM model: #{slug}" unless model_class
108
+ unless Fosm::DataRetention.archival_eligible?(model_class)
109
+ raise ActionController::RoutingError,
110
+ "Model '#{slug}' is not archival-eligible " \
111
+ "(needs a terminal state containing 'archiv' AND an archived_at column)."
112
+ end
113
+ model_class
114
+ end
115
+
116
+ def current_admin_label
117
+ user = instance_exec(&Fosm.config.current_user_method)
118
+ return "admin" unless user
119
+ user.respond_to?(:email) ? user.email : user.to_s
120
+ rescue
121
+ "admin"
122
+ end
123
+ end
124
+ end
125
+ end
@@ -100,7 +100,7 @@ module Fosm
100
100
  private
101
101
 
102
102
  def assignment_params
103
- params.require(:fosm_role_assignment).permit(
103
+ params.require(:role_assignment).permit(
104
104
  :user_type, :user_id, :resource_type, :resource_id, :role_name
105
105
  )
106
106
  end
@@ -0,0 +1,129 @@
1
+ module Fosm
2
+ # Safely purges FOSM records that have exceeded the configured data retention
3
+ # window. Designed to be triggered from the Data Archival admin UI.
4
+ #
5
+ # == Safety guarantees
6
+ #
7
+ # 1. Re-checks archival eligibility of the model class on entry.
8
+ # 2. Re-checks +Fosm::DataRetention.safe_to_purge?+ per record inside the job
9
+ # — the controller's pre-check is advisory only; the retention window or
10
+ # record state could change between the UI request and job execution.
11
+ # 3. Missing records are silently skipped (already purged — idempotent).
12
+ # 4. Records within the retention window are NEVER deleted, even when
13
+ # explicitly enqueued — this is the absolute last line of defence.
14
+ # 5. +fosm_transition_logs+ rows are NOT deleted. The audit trail is
15
+ # preserved forever for compliance purposes.
16
+ # 6. Errors on individual records are logged, not re-raised, so a bulk job
17
+ # continues processing the remaining records.
18
+ # 7. Unknown or non-eligible model class names abort immediately without
19
+ # side-effects.
20
+ #
21
+ # == Usage
22
+ #
23
+ # # Single record
24
+ # Fosm::DataRetentionPurgeJob.perform_later(
25
+ # model_class_name: "Fosm::FaasAccount",
26
+ # record_id: "42",
27
+ # purged_by_label: current_user.email
28
+ # )
29
+ #
30
+ # # Bulk — purges every eligible record for the model
31
+ # Fosm::DataRetentionPurgeJob.perform_later(
32
+ # model_class_name: "Fosm::FaasAccount",
33
+ # bulk: true,
34
+ # purged_by_label: current_user.email
35
+ # )
36
+ class DataRetentionPurgeJob < Fosm::ApplicationJob
37
+ queue_as :default
38
+
39
+ # @param model_class_name [String] e.g. "Fosm::FaasAccount"
40
+ # @param record_id [String, nil] ID of a single record to purge
41
+ # @param bulk [Boolean] true → purge all eligible records
42
+ # @param purged_by_label [String] actor label for audit logging
43
+ def perform(model_class_name:, record_id: nil, bulk: false, purged_by_label: "system")
44
+ model_class = resolve_model_class(model_class_name)
45
+ return unless model_class
46
+
47
+ unless Fosm::DataRetention.archival_eligible?(model_class)
48
+ log_warn "#{model_class_name} is not archival-eligible. Purge aborted."
49
+ return
50
+ end
51
+
52
+ if bulk
53
+ purge_all_expired(model_class, purged_by_label)
54
+ elsif record_id.present?
55
+ purge_single(model_class, record_id.to_s, purged_by_label)
56
+ else
57
+ log_warn "Neither bulk: true nor record_id provided. Nothing to purge."
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def resolve_model_class(name)
64
+ name.constantize
65
+ rescue NameError => e
66
+ log_error "Unknown model class '#{name}': #{e.message}"
67
+ nil
68
+ end
69
+
70
+ def purge_single(model_class, record_id, purged_by_label)
71
+ record = model_class.find_by(id: record_id)
72
+ unless record
73
+ log_warn "#{model_class.name}##{record_id} not found — already purged or never existed."
74
+ return
75
+ end
76
+
77
+ # CRITICAL: re-verify retention window even if the controller already checked.
78
+ # The window or state may have changed between the UI click and job execution.
79
+ unless Fosm::DataRetention.safe_to_purge?(record)
80
+ log_warn(
81
+ "#{model_class.name}##{record_id} is within the " \
82
+ "#{Fosm.config.data_retention_days}-day retention window or not in an " \
83
+ "archival state. Skipping."
84
+ )
85
+ return
86
+ end
87
+
88
+ log_info "Purging #{model_class.name}##{record_id} " \
89
+ "(archived_at: #{record.archived_at}, purged_by: #{purged_by_label})"
90
+ record.destroy!
91
+ log_info "Purged #{model_class.name}##{record_id} successfully."
92
+ rescue => e
93
+ log_error "Failed to purge #{model_class.name}##{record_id}: #{e.message}"
94
+ end
95
+
96
+ def purge_all_expired(model_class, purged_by_label)
97
+ purged = 0
98
+ skipped = 0
99
+
100
+ Fosm::DataRetention.all_eligible_for_purge(model_class).find_each do |record|
101
+ # Per-record safety check — never blindly trust the scope alone.
102
+ unless Fosm::DataRetention.safe_to_purge?(record)
103
+ log_warn "Skipping #{model_class.name}##{record.id} — within retention window."
104
+ skipped += 1
105
+ next
106
+ end
107
+
108
+ begin
109
+ record.destroy!
110
+ purged += 1
111
+ rescue => e
112
+ log_error "Failed to purge #{model_class.name}##{record.id}: #{e.message}"
113
+ skipped += 1
114
+ end
115
+ end
116
+
117
+ log_info "Bulk purge complete for #{model_class.name}: " \
118
+ "#{purged} purged, #{skipped} skipped (purged_by: #{purged_by_label})."
119
+ end
120
+
121
+ def log_info(msg) = logger.info("[Fosm::DataRetentionPurgeJob] #{msg}")
122
+ def log_warn(msg) = logger.warn("[Fosm::DataRetentionPurgeJob] #{msg}")
123
+ def log_error(msg) = logger.error("[Fosm::DataRetentionPurgeJob] #{msg}")
124
+
125
+ def logger
126
+ defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : Logger.new($stdout)
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,68 @@
1
+ <div class="p-6 space-y-6">
2
+
3
+ <!-- Header -->
4
+ <div>
5
+ <h1 class="text-2xl font-bold text-gray-900">Data Archival &amp; Deletion</h1>
6
+ <p class="text-sm text-gray-500 mt-1">
7
+ Retention policy: <strong><%= @retention_days %> days</strong>
8
+ &mdash; records archived before
9
+ <strong><%= @cutoff_date.strftime("%b %d, %Y") %></strong>
10
+ are eligible for purge.
11
+ </p>
12
+ </div>
13
+
14
+ <!-- Compliance notice -->
15
+ <div class="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800 flex gap-3">
16
+ <span class="text-amber-500 text-lg leading-none">&#9888;</span>
17
+ <div>
18
+ <strong>Compliance notice:</strong> Purging permanently deletes business records.
19
+ Transition logs (<code class="bg-amber-100 px-1 rounded">fosm_transition_logs</code>)
20
+ are <em>always</em> preserved for audit compliance — only the source records are removed.
21
+ Configure <code class="bg-amber-100 px-1 rounded">config.data_retention_days</code>
22
+ in <code class="bg-amber-100 px-1 rounded">config/initializers/fosm.rb</code>.
23
+ </div>
24
+ </div>
25
+
26
+ <!-- Model cards -->
27
+ <% if @eligible_models.empty? %>
28
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
29
+ <h2 class="font-semibold text-gray-700">No archival-eligible models found</h2>
30
+ <p class="text-sm text-gray-500 mt-2 max-w-md mx-auto">
31
+ A model is eligible when it has a terminal state containing
32
+ <code class="bg-gray-100 px-1 rounded">archiv</code>
33
+ (e.g. <code class="bg-gray-100 px-1 rounded">:archived</code>)
34
+ <em>and</em> an
35
+ <code class="bg-gray-100 px-1 rounded">archived_at</code>
36
+ datetime column on its table.
37
+ </p>
38
+ </div>
39
+ <% else %>
40
+ <div class="space-y-3">
41
+ <% @eligible_models.each do |model| %>
42
+ <div class="bg-white border border-gray-200 rounded-lg p-5 flex items-center justify-between gap-4">
43
+ <div class="flex-1 min-w-0">
44
+ <h2 class="font-semibold text-gray-900"><%= model[:name] %></h2>
45
+ <p class="text-xs text-gray-400 mt-0.5">
46
+ Archival states:
47
+ <% model[:archival_states].each do |s| %>
48
+ <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 font-mono"><%= s %></span>
49
+ <% end %>
50
+ &middot;
51
+ <%= model[:total_in_archive] %> total in archive
52
+ </p>
53
+ </div>
54
+ <div class="text-right shrink-0">
55
+ <p class="text-2xl font-bold <%= model[:eligible_for_purge] > 0 ? 'text-red-600' : 'text-gray-300' %>">
56
+ <%= model[:eligible_for_purge] %>
57
+ </p>
58
+ <p class="text-xs text-gray-500">eligible for purge</p>
59
+ </div>
60
+ <%= link_to "Manage &rarr;".html_safe,
61
+ fosm.admin_data_retention_path(model[:slug]),
62
+ class: "shrink-0 text-sm text-blue-600 hover:underline font-medium" %>
63
+ </div>
64
+ <% end %>
65
+ </div>
66
+ <% end %>
67
+
68
+ </div>
@@ -0,0 +1,124 @@
1
+ <div class="p-6 space-y-6">
2
+
3
+ <!-- Breadcrumb + header -->
4
+ <div>
5
+ <%= link_to "&larr; Data Archival".html_safe,
6
+ fosm.admin_data_retention_index_path,
7
+ class: "text-sm text-gray-400 hover:text-gray-600 mb-2 block" %>
8
+ <div class="flex items-start justify-between gap-4">
9
+ <div>
10
+ <h1 class="text-2xl font-bold text-gray-900"><%= @name %></h1>
11
+ <p class="text-sm text-gray-500 mt-1">
12
+ Records archived before
13
+ <strong><%= @cutoff_date.strftime("%b %d, %Y") %></strong>
14
+ (<%= @retention_days %>-day policy) &mdash;
15
+ <strong><%= @total %></strong> record(s) eligible for purge.
16
+ </p>
17
+ <p class="text-xs text-gray-400 mt-0.5">
18
+ Archival states:
19
+ <% @archival_states.each do |s| %>
20
+ <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 font-mono"><%= s %></span>
21
+ <% end %>
22
+ </p>
23
+ </div>
24
+
25
+ <% if @total > 0 %>
26
+ <%= button_to fosm.purge_all_expired_admin_data_retention_path(@slug),
27
+ method: :post,
28
+ data: {
29
+ confirm: "This will permanently delete ALL #{@total} eligible #{@name} record(s). " \
30
+ "Transition logs will be preserved. This cannot be undone. Continue?"
31
+ },
32
+ class: "shrink-0 bg-red-600 text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-red-700 transition" do %>
33
+ Purge All <%= @total %> Eligible
34
+ <% end %>
35
+ <% end %>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Records table -->
40
+ <% if @records.empty? %>
41
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
42
+ <p class="text-gray-500 font-medium">No records eligible for purge</p>
43
+ <p class="text-xs text-gray-400 mt-1">
44
+ All archived records are within the <%= @retention_days %>-day retention window.
45
+ </p>
46
+ </div>
47
+ <% else %>
48
+ <div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
49
+ <table class="w-full text-sm">
50
+ <thead class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wide">
51
+ <tr>
52
+ <th class="px-4 py-3 text-left">ID</th>
53
+ <th class="px-4 py-3 text-left">State</th>
54
+ <th class="px-4 py-3 text-left">Archived At</th>
55
+ <th class="px-4 py-3 text-left">Retention Age</th>
56
+ <th class="px-4 py-3 text-right">Action</th>
57
+ </tr>
58
+ </thead>
59
+ <tbody class="divide-y divide-gray-100">
60
+ <% @records.each do |record| %>
61
+ <tr class="hover:bg-gray-50">
62
+ <td class="px-4 py-3 text-gray-600 font-mono text-xs">#<%= record.id %></td>
63
+ <td class="px-4 py-3">
64
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
65
+ <%= record.state %>
66
+ </span>
67
+ </td>
68
+ <td class="px-4 py-3 text-gray-600 text-xs">
69
+ <%= record.archived_at&.strftime("%b %d, %Y %H:%M") %> UTC
70
+ </td>
71
+ <td class="px-4 py-3 text-xs">
72
+ <% days_overdue = ((Time.current - record.archived_at) / 1.day).round %>
73
+ <span class="text-red-600 font-medium"><%= days_overdue %> days</span>
74
+ <span class="text-gray-400">(+<%= days_overdue - @retention_days %> past window)</span>
75
+ </td>
76
+ <td class="px-4 py-3 text-right">
77
+ <%= button_to fosm.purge_record_admin_data_retention_path(@slug),
78
+ params: { record_id: record.id },
79
+ method: :post,
80
+ data: {
81
+ confirm: "Permanently delete #{@name} ##{record.id}? " \
82
+ "Transition logs will be preserved. This cannot be undone."
83
+ },
84
+ class: "text-xs font-medium text-red-600 hover:text-red-800 border border-red-200 hover:border-red-400 px-2 py-1 rounded hover:bg-red-50 transition" do %>
85
+ Purge
86
+ <% end %>
87
+ </td>
88
+ </tr>
89
+ <% end %>
90
+ </tbody>
91
+ </table>
92
+ </div>
93
+
94
+ <!-- Pagination -->
95
+ <% if @total_pages > 1 %>
96
+ <div class="flex items-center justify-between text-sm text-gray-600">
97
+ <span>
98
+ Showing page <%= @page %> of <%= @total_pages %>
99
+ &middot; <%= @total %> total records eligible
100
+ </span>
101
+ <div class="flex gap-2">
102
+ <% if @page > 1 %>
103
+ <%= link_to "&larr; Previous".html_safe,
104
+ fosm.admin_data_retention_path(@slug, page: @page - 1),
105
+ class: "px-3 py-1.5 border border-gray-200 rounded text-sm hover:bg-gray-50 transition" %>
106
+ <% end %>
107
+ <% if @page < @total_pages %>
108
+ <%= link_to "Next &rarr;".html_safe,
109
+ fosm.admin_data_retention_path(@slug, page: @page + 1),
110
+ class: "px-3 py-1.5 border border-gray-200 rounded text-sm hover:bg-gray-50 transition" %>
111
+ <% end %>
112
+ </div>
113
+ </div>
114
+ <% end %>
115
+ <% end %>
116
+
117
+ <!-- Compliance reminder -->
118
+ <div class="text-xs text-gray-400 border-t border-gray-100 pt-4">
119
+ Purge operations run asynchronously via <code>Fosm::DataRetentionPurgeJob</code>.
120
+ Each record is re-verified against the retention window before deletion.
121
+ Transition audit logs are never deleted.
122
+ </div>
123
+
124
+ </div>
@@ -122,12 +122,21 @@
122
122
  })();
123
123
  </script>
124
124
 
125
+ <%# Build a JSON map: { "Fosm::PartnershipAgreement" => ["owner","viewer"], ... }
126
+ Used by the JS below to filter Role Name options when resource type changes. %>
127
+ <% roles_by_type = @apps.each_with_object({}) { |(_, klass), h|
128
+ next unless klass.fosm_lifecycle&.access_defined?
129
+ h[klass.name] = klass.fosm_lifecycle.access_definition.role_names.map(&:to_s).sort
130
+ } %>
131
+
125
132
  <div>
126
133
  <label class="block text-sm font-medium text-gray-700 mb-1">Resource Type</label>
127
134
  <%= f.select :resource_type,
128
135
  @apps.map { |_, klass| [klass.name.demodulize, klass.name] },
129
136
  { include_blank: "— Select a FOSM app —" },
130
- class: "w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500" %>
137
+ id: "role_assignment_resource_type",
138
+ class: "w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500",
139
+ data: { roles: roles_by_type.to_json } %>
131
140
  </div>
132
141
 
133
142
  <div>
@@ -141,29 +150,130 @@
141
150
  </p>
142
151
  </div>
143
152
 
144
- <div>
153
+ <div id="role-name-field">
145
154
  <label class="block text-sm font-medium text-gray-700 mb-1">Role Name</label>
146
- <% role_options = @apps.flat_map { |_, klass|
147
- next [] unless klass.fosm_lifecycle&.access_defined?
148
- klass.fosm_lifecycle.access_definition.role_names.map { |rn|
149
- ["#{klass.name.demodulize} → :#{rn}", rn.to_s]
150
- }
151
- }.uniq(&:last) %>
152
- <% if role_options.any? %>
155
+ <%# Initial render: all known roles sorted. JS narrows this on resource type change. %>
156
+ <% all_roles = roles_by_type.values.flatten.uniq.sort %>
157
+ <% if all_roles.any? %>
153
158
  <%= f.select :role_name,
154
- role_options,
159
+ all_roles.map { |rn| [":#{rn}", rn] },
155
160
  { include_blank: "— Select a role —" },
161
+ id: "role_assignment_role_name",
156
162
  class: "w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500" %>
157
163
  <% else %>
158
- <%= f.text_field :role_name,
159
- placeholder: "owner, approver, viewer…",
164
+ <%= f.select :role_name,
165
+ [],
166
+ { include_blank: "— Select a role —" },
167
+ id: "role_assignment_role_name",
160
168
  class: "w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500" %>
161
169
  <% end %>
162
- <p class="text-xs text-gray-400 mt-1">Must match a role declared in the lifecycle <code>access</code> block.</p>
170
+ <p class="text-xs text-gray-400 mt-1" id="role-name-hint">
171
+ Must match a role declared in the lifecycle <code>access</code> block.
172
+ </p>
173
+ </div>
174
+
175
+ <div id="no-rbac-warning" class="hidden rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
176
+ <strong>No RBAC on this resource type.</strong>
177
+ <span id="no-rbac-warning-name"></span> has no <code>access</code> block in its lifecycle —
178
+ all authenticated users can already fire any transition.
179
+ Role assignments for this type would be stored but never enforced.
163
180
  </div>
164
181
 
182
+ <script>
183
+ (function() {
184
+ var resourceSelect = document.getElementById('role_assignment_resource_type');
185
+ var roleField = document.getElementById('role-name-field');
186
+ var roleHint = document.getElementById('role-name-hint');
187
+ var warning = document.getElementById('no-rbac-warning');
188
+ var warningName = document.getElementById('no-rbac-warning-name');
189
+ var rolesByType = JSON.parse(resourceSelect.dataset.roles || '{}');
190
+
191
+ // Looked up lazily — the submit button appears after this script tag in the DOM.
192
+ function submitBtn() { return document.getElementById('role-grant-submit'); }
193
+
194
+ function currentRoleInput() {
195
+ return document.getElementById('role_assignment_role_name');
196
+ }
197
+
198
+ function allRoles() {
199
+ return Object.values(rolesByType).flat()
200
+ .filter(function(v, i, a) { return a.indexOf(v) === i; })
201
+ .sort();
202
+ }
203
+
204
+ function buildSelect(roles, inputName, inputClass) {
205
+ var sel = document.createElement('select');
206
+ sel.id = 'role_assignment_role_name';
207
+ sel.name = inputName;
208
+ sel.className = inputClass;
209
+ var blank = document.createElement('option');
210
+ blank.value = '';
211
+ blank.textContent = '— Select a role —';
212
+ sel.appendChild(blank);
213
+ roles.forEach(function(rn) {
214
+ var opt = document.createElement('option');
215
+ opt.value = rn;
216
+ opt.textContent = ':' + rn;
217
+ sel.appendChild(opt);
218
+ });
219
+ return sel;
220
+ }
221
+
222
+ function replaceInput(newEl) {
223
+ var old = currentRoleInput();
224
+ old.parentNode.replaceChild(newEl, old);
225
+ }
226
+
227
+ function setNoRbac(typeName) {
228
+ roleField.classList.add('hidden');
229
+ warning.classList.remove('hidden');
230
+ warningName.textContent = ' ' + typeName;
231
+ var btn = submitBtn();
232
+ if (btn) {
233
+ btn.disabled = true;
234
+ btn.classList.add('opacity-50', 'cursor-not-allowed');
235
+ btn.classList.remove('hover:bg-gray-700', 'cursor-pointer');
236
+ }
237
+ }
238
+
239
+ function clearNoRbac() {
240
+ roleField.classList.remove('hidden');
241
+ warning.classList.add('hidden');
242
+ warningName.textContent = '';
243
+ var btn = submitBtn();
244
+ if (btn) {
245
+ btn.disabled = false;
246
+ btn.classList.remove('opacity-50', 'cursor-not-allowed');
247
+ btn.classList.add('hover:bg-gray-700', 'cursor-pointer');
248
+ }
249
+ }
250
+
251
+ resourceSelect.addEventListener('change', function() {
252
+ var selectedType = resourceSelect.value;
253
+ var old = currentRoleInput();
254
+ var inputName = old.name;
255
+ var inputClass = old.className;
256
+ var roles = rolesByType[selectedType];
257
+
258
+ if (!selectedType) {
259
+ clearNoRbac();
260
+ replaceInput(buildSelect(allRoles(), inputName, inputClass));
261
+ roleHint.innerHTML = 'Must match a role declared in the lifecycle <code>access</code> block.';
262
+ } else if (roles && roles.length > 0) {
263
+ clearNoRbac();
264
+ replaceInput(buildSelect(roles, inputName, inputClass));
265
+ roleHint.textContent = roles.length + ' role' + (roles.length !== 1 ? 's' : '') +
266
+ ' available for ' + selectedType.split('::').pop() + '.';
267
+ } else {
268
+ setNoRbac(selectedType.split('::').pop());
269
+ }
270
+ });
271
+ })();
272
+ </script>
273
+
165
274
  <div class="flex items-center gap-3 pt-2">
166
275
  <%= f.submit "Grant Role",
276
+ id: "role-grant-submit",
167
277
  class: "bg-gray-900 text-white text-sm px-4 py-2 rounded hover:bg-gray-700 cursor-pointer" %>
168
278
  <%= link_to "Cancel", fosm.admin_roles_path,
169
279
  class: "text-sm text-gray-500 hover:underline" %>
@@ -19,10 +19,12 @@
19
19
  <nav class="p-3 space-y-1 flex-1">
20
20
  <%= link_to "Dashboard", fosm.admin_root_path, class: "block px-3 py-2 rounded text-sm text-gray-700 hover:bg-gray-100" %>
21
21
  <%= link_to "Transitions", fosm.admin_transitions_path, class: "block px-3 py-2 rounded text-sm text-gray-700 hover:bg-gray-100" %>
22
+ <%= link_to "Roles", fosm.admin_roles_path, class: "block px-3 py-2 rounded text-sm text-gray-700 hover:bg-gray-100" %>
22
23
  <%= link_to "Webhooks", fosm.admin_webhooks_path, class: "block px-3 py-2 rounded text-sm text-gray-700 hover:bg-gray-100" %>
23
24
  </nav>
24
- <div class="p-3 border-t border-gray-100">
25
- <%= link_to "Settings", fosm.admin_settings_path, class: "block px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100" %>
25
+ <div class="p-3 border-t border-gray-100 space-y-1">
26
+ <%= link_to "Settings", fosm.admin_settings_path, class: "block px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100" %>
27
+ <%= link_to "Data Archival", fosm.admin_data_retention_index_path, class: "block px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100" %>
26
28
  </div>
27
29
  </div>
28
30
 
data/config/routes.rb CHANGED
@@ -18,5 +18,11 @@ Fosm::Engine.routes.draw do
18
18
  end
19
19
  end
20
20
  resource :settings, only: [ :show ]
21
+ resources :data_retention, only: [ :index, :show ] do
22
+ member do
23
+ post :purge_record
24
+ post :purge_all_expired
25
+ end
26
+ end
21
27
  end
22
28
  end
@@ -1,4 +1,8 @@
1
1
  module Fosm
2
+ # Default data retention period in days (10 years).
3
+ # Override per-project via: config.data_retention_days = 2555 # 7 years
4
+ DATA_RETENTION_DEFAULT_DAYS = 3650
5
+
2
6
  class Configuration
3
7
  # The base controller class the engine's controllers will inherit from.
4
8
  # Set this to match your app's ApplicationController.
@@ -56,6 +60,15 @@ module Fosm
56
60
  # config.webhooks_enabled = false
57
61
  attr_accessor :webhooks_enabled
58
62
 
63
+ # Data retention policy in days. Records in an archival terminal state
64
+ # (state name contains "archiv") with an `archived_at` timestamp older
65
+ # than this many days are eligible for purge from the Data Archival admin.
66
+ #
67
+ # Gem default: Fosm::DATA_RETENTION_DEFAULT_DAYS (3650 = 10 years).
68
+ # Override per-project in config/initializers/fosm.rb:
69
+ # config.data_retention_days = 2555 # 7 years
70
+ attr_accessor :data_retention_days
71
+
59
72
  def initialize
60
73
  @base_controller = "ApplicationController"
61
74
  @admin_authorize = -> { true } # Override in initializer!
@@ -67,6 +80,7 @@ module Fosm
67
80
  @webhook_job_queue = :default
68
81
  @transition_log_job_queue = :fosm_audit
69
82
  @webhooks_enabled = true
83
+ @data_retention_days = Fosm::DATA_RETENTION_DEFAULT_DAYS
70
84
  end
71
85
  end
72
86
 
@@ -0,0 +1,160 @@
1
+ module Fosm
2
+ # Service for discovering and managing FOSM objects under a data retention policy.
3
+ #
4
+ # == Eligibility criteria
5
+ #
6
+ # A model is "archival-eligible" when it satisfies BOTH of the following:
7
+ # 1. It has at least one *terminal* state whose name contains "archiv"
8
+ # (case-insensitive — covers :archived, :archival, :archiviert, etc.)
9
+ # 2. Its database table has an `archived_at` datetime/timestamp column.
10
+ #
11
+ # == Retention window
12
+ #
13
+ # Driven by +Fosm.config.data_retention_days+ (default 3650 = 10 years).
14
+ # Records with a non-nil +archived_at+ older than the cutoff are
15
+ # "eligible for purge".
16
+ #
17
+ # == Audit-safety guarantee
18
+ #
19
+ # Purging a business record NEVER touches +fosm_transition_logs+. The audit
20
+ # trail is intentionally kept forever for compliance purposes. Only the
21
+ # source record (e.g. the Invoice or FaasAccount row) is deleted.
22
+ module DataRetention
23
+ class << self
24
+ # All registered FOSM model classes that meet both eligibility criteria.
25
+ #
26
+ # @return [Array<Class>]
27
+ def archival_eligible_models
28
+ Fosm::Registry.model_classes.select { |mc| archival_eligible?(mc) }
29
+ end
30
+
31
+ # Returns true when the model has an archival terminal state AND an
32
+ # `archived_at` column.
33
+ #
34
+ # @param model_class [Class]
35
+ # @return [Boolean]
36
+ def archival_eligible?(model_class)
37
+ has_archival_terminal_state?(model_class) && has_archived_at_column?(model_class)
38
+ end
39
+
40
+ # Returns true when at least one terminal state name includes "archiv".
41
+ #
42
+ # @param model_class [Class]
43
+ # @return [Boolean]
44
+ def has_archival_terminal_state?(model_class)
45
+ lifecycle = model_class.try(:fosm_lifecycle)
46
+ return false unless lifecycle
47
+ lifecycle.states.any? { |s| s.terminal? && s.name.to_s.downcase.include?("archiv") }
48
+ end
49
+
50
+ # Returns true when the model's table has an `archived_at` column.
51
+ #
52
+ # Rescues gracefully if the table doesn't exist yet (e.g. during migrations).
53
+ #
54
+ # @param model_class [Class]
55
+ # @return [Boolean]
56
+ def has_archived_at_column?(model_class)
57
+ model_class.column_names.include?("archived_at")
58
+ rescue => _e
59
+ false
60
+ end
61
+
62
+ # All archival terminal state names for the model (as strings).
63
+ #
64
+ # @param model_class [Class]
65
+ # @return [Array<String>]
66
+ def archival_states_for(model_class)
67
+ lifecycle = model_class.try(:fosm_lifecycle)
68
+ return [] unless lifecycle
69
+ lifecycle.states
70
+ .select { |s| s.terminal? && s.name.to_s.downcase.include?("archiv") }
71
+ .map { |s| s.name.to_s }
72
+ end
73
+
74
+ # The cutoff timestamp: records with `archived_at` before this moment are
75
+ # eligible for purge.
76
+ #
77
+ # @return [ActiveSupport::TimeWithZone]
78
+ def retention_cutoff_date
79
+ Fosm.config.data_retention_days.days.ago
80
+ end
81
+
82
+ # Total records currently in any archival terminal state, regardless of
83
+ # whether they have passed the retention window.
84
+ #
85
+ # @param model_class [Class]
86
+ # @return [Integer]
87
+ def total_in_archival_state(model_class)
88
+ states = archival_states_for(model_class)
89
+ return 0 if states.empty?
90
+ model_class.where(state: states).count
91
+ end
92
+
93
+ # Total records eligible for purge: in an archival state, with a non-nil
94
+ # `archived_at` older than the retention cutoff.
95
+ #
96
+ # @param model_class [Class]
97
+ # @return [Integer]
98
+ def total_eligible_for_purge(model_class)
99
+ eligible_scope(model_class).count
100
+ end
101
+
102
+ # Returns a paginated ActiveRecord relation of purge-eligible records,
103
+ # ordered oldest-first (most overdue first).
104
+ #
105
+ # Pagination is offset-based. Pages are 1-indexed.
106
+ #
107
+ # @param model_class [Class]
108
+ # @param page [Integer] 1-based page number (clamped to >= 1)
109
+ # @param per_page [Integer] max records per page
110
+ # @return [ActiveRecord::Relation]
111
+ def records_eligible_for_purge(model_class, page: 1, per_page: 50)
112
+ offset = ([page.to_i, 1].max - 1) * per_page
113
+ eligible_scope(model_class).offset(offset).limit(per_page)
114
+ end
115
+
116
+ # An unbounded scope of all purge-eligible records for use in batch jobs.
117
+ # Callers should use +find_each+ to avoid loading all rows into memory.
118
+ #
119
+ # @param model_class [Class]
120
+ # @return [ActiveRecord::Relation]
121
+ def all_eligible_for_purge(model_class)
122
+ eligible_scope(model_class)
123
+ end
124
+
125
+ # Returns true when a single record is safe to purge:
126
+ #
127
+ # - Its class has an `archived_at` column (defensive belt-and-suspenders).
128
+ # - +archived_at+ is not nil.
129
+ # - +archived_at+ is strictly older than the retention cutoff.
130
+ # - The record's current state is an archival terminal state.
131
+ #
132
+ # This is the *authoritative* pre-purge check. Always call this immediately
133
+ # before destroying, even when the controller already checked — the
134
+ # retention window or record state may have changed since the UI check.
135
+ #
136
+ # @param record [ActiveRecord::Base]
137
+ # @return [Boolean]
138
+ def safe_to_purge?(record)
139
+ return false unless record.class.column_names.include?("archived_at")
140
+ archived_at = record.archived_at
141
+ return false if archived_at.nil?
142
+ return false if archived_at >= retention_cutoff_date
143
+ archival_states_for(record.class).include?(record.state.to_s)
144
+ end
145
+
146
+ private
147
+
148
+ # Shared base scope for eligibility queries.
149
+ def eligible_scope(model_class)
150
+ states = archival_states_for(model_class)
151
+ return model_class.none if states.empty?
152
+ model_class
153
+ .where(state: states)
154
+ .where.not(archived_at: nil)
155
+ .where("archived_at < ?", retention_cutoff_date)
156
+ .order(archived_at: :asc)
157
+ end
158
+ end
159
+ end
160
+ end
data/lib/fosm/engine.rb CHANGED
@@ -114,23 +114,22 @@ module Fosm
114
114
  end
115
115
  end
116
116
 
117
- # Auto-register all Fosm models with the registry after app loads.
117
+ # Re-register all Fosm models after every code reload (development) and
118
+ # once at boot (all environments). to_prepare fires on every reload in
119
+ # development, and once in production/test — making the registry always
120
+ # consistent with the currently-loaded class objects.
118
121
  # Use ::Rails to avoid ambiguity with Fosm::Rails module.
119
- config.after_initialize do
122
+ config.to_prepare do
120
123
  ::Rails.application.eager_load! if ::Rails.env.development? && !::Rails.application.config.eager_load
121
124
 
122
- ObjectSpace.each_object(Class).select { |klass|
123
- klass < ActiveRecord::Base &&
124
- klass.name&.start_with?("Fosm::") &&
125
- klass.respond_to?(:fosm_lifecycle) &&
126
- klass.fosm_lifecycle.present?
127
- }.each do |klass|
128
- slug = klass.name.demodulize.underscore
129
- Fosm::Registry.register(klass, slug: slug)
130
- end
125
+ Fosm::Registry.clear!
126
+ Fosm::Registry.repopulate!
127
+ end
131
128
 
132
- # Start the background flusher thread for the :buffered log strategy.
133
- # Only starts in long-running server processes (not in test/rake/console tasks).
129
+ # Start the background flusher thread for the :buffered log strategy.
130
+ # Kept in after_initialize (runs once) so the thread is not re-spawned on
131
+ # every development reload.
132
+ config.after_initialize do
134
133
  if Fosm.config.transition_log_strategy == :buffered && !::Rails.env.test?
135
134
  Fosm::TransitionBuffer.start_flusher!
136
135
  end
data/lib/fosm/registry.rb CHANGED
@@ -23,7 +23,13 @@ module Fosm
23
23
  end
24
24
 
25
25
  def model_classes
26
- @registered.values
26
+ @registered.values.map { |klass| klass.name.constantize rescue klass }
27
+ end
28
+
29
+ def slug_for(model_class)
30
+ target_name = model_class.name
31
+ @registered.each { |slug, klass| return slug if klass.name == target_name }
32
+ nil
27
33
  end
28
34
 
29
35
  def slugs
@@ -33,6 +39,28 @@ module Fosm
33
39
  def each(&block)
34
40
  @registered.each(&block)
35
41
  end
42
+
43
+ # Remove all registered entries.
44
+ # Called by to_prepare in development so stale class references are
45
+ # replaced after Rails reloads the application code.
46
+ def clear!
47
+ @registered = {}
48
+ end
49
+
50
+ # Scan ObjectSpace for all ActiveRecord subclasses that include
51
+ # Fosm::Lifecycle and register them. Calling this after clear! is
52
+ # equivalent to the boot-time registration that after_initialize performs.
53
+ def repopulate!
54
+ ObjectSpace.each_object(Class).select { |klass|
55
+ klass < ActiveRecord::Base &&
56
+ klass.name&.start_with?("Fosm::") &&
57
+ klass.respond_to?(:fosm_lifecycle) &&
58
+ klass.fosm_lifecycle.present?
59
+ }.each do |klass|
60
+ slug = klass.name.demodulize.underscore
61
+ register(klass, slug: slug)
62
+ end
63
+ end
36
64
  end
37
65
  end
38
66
  end
data/lib/fosm/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Fosm
2
- VERSION = "0.2.3"
2
+ VERSION = "0.2.5"
3
3
  end
data/lib/fosm-rails.rb CHANGED
@@ -6,6 +6,7 @@ require "fosm/registry"
6
6
  require "fosm/current"
7
7
  require "fosm/transition_buffer"
8
8
  require "fosm/lifecycle"
9
+ require "fosm/data_retention"
9
10
  require "fosm/agent"
10
11
  require "fosm/engine"
11
12
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fosm-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abhishek Parolkar
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-18 00:00:00.000000000 Z
11
+ date: 2026-05-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -56,6 +56,7 @@ files:
56
56
  - app/controllers/fosm/admin/apps_controller.rb
57
57
  - app/controllers/fosm/admin/base_controller.rb
58
58
  - app/controllers/fosm/admin/dashboard_controller.rb
59
+ - app/controllers/fosm/admin/data_retention_controller.rb
59
60
  - app/controllers/fosm/admin/roles_controller.rb
60
61
  - app/controllers/fosm/admin/settings_controller.rb
61
62
  - app/controllers/fosm/admin/transitions_controller.rb
@@ -66,6 +67,7 @@ files:
66
67
  - app/helpers/fosm/rails/application_helper.rb
67
68
  - app/jobs/fosm/access_event_job.rb
68
69
  - app/jobs/fosm/application_job.rb
70
+ - app/jobs/fosm/data_retention_purge_job.rb
69
71
  - app/jobs/fosm/rails/application_job.rb
70
72
  - app/jobs/fosm/transition_log_job.rb
71
73
  - app/jobs/fosm/webhook_delivery_job.rb
@@ -79,6 +81,8 @@ files:
79
81
  - app/views/fosm/admin/agents/show.html.erb
80
82
  - app/views/fosm/admin/apps/show.html.erb
81
83
  - app/views/fosm/admin/dashboard/index.html.erb
84
+ - app/views/fosm/admin/data_retention/index.html.erb
85
+ - app/views/fosm/admin/data_retention/show.html.erb
82
86
  - app/views/fosm/admin/roles/index.html.erb
83
87
  - app/views/fosm/admin/roles/new.html.erb
84
88
  - app/views/fosm/admin/settings/show.html.erb
@@ -97,6 +101,7 @@ files:
97
101
  - lib/fosm/agent.rb
98
102
  - lib/fosm/configuration.rb
99
103
  - lib/fosm/current.rb
104
+ - lib/fosm/data_retention.rb
100
105
  - lib/fosm/engine.rb
101
106
  - lib/fosm/errors.rb
102
107
  - lib/fosm/lifecycle.rb