fosm-rails 0.2.3 → 0.2.4
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/README.md +1 -0
- data/app/controllers/fosm/admin/data_retention_controller.rb +125 -0
- data/app/jobs/fosm/data_retention_purge_job.rb +129 -0
- data/app/views/fosm/admin/data_retention/index.html.erb +68 -0
- data/app/views/fosm/admin/data_retention/show.html.erb +124 -0
- data/app/views/layouts/fosm/application.html.erb +3 -2
- data/config/routes.rb +6 -0
- data/lib/fosm/configuration.rb +14 -0
- data/lib/fosm/data_retention.rb +160 -0
- data/lib/fosm/registry.rb +7 -1
- data/lib/fosm/version.rb +1 -1
- data/lib/fosm-rails.rb +1 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41f74b1b8b8a51fde040030b20b00e076a302be054bb6f7a5cd7dff22201c889
|
|
4
|
+
data.tar.gz: 6030364793d6d6cd82ceef77f257c2998771d12108e433011e4c61e61eb4cf13
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 92649aff68edd9a5d932c3e674fe45628a08ab98334faa1a0477c464a0029671322ba643695461a3aef866d8f38fe3cf0304d23901b0c3a4c84bd2318276ae51
|
|
7
|
+
data.tar.gz: a9f6ba8159d2b61e2519c7758127149b5d83288c30f99ad715664ae632cfcde6f778c7944593e2b30d9773af7412fa5e52cafde06bf7b788af0c126b219afade
|
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
|
|
@@ -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 & Deletion</h1>
|
|
6
|
+
<p class="text-sm text-gray-500 mt-1">
|
|
7
|
+
Retention policy: <strong><%= @retention_days %> days</strong>
|
|
8
|
+
— 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">⚠</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
|
+
·
|
|
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 →".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 "← 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) —
|
|
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
|
+
· <%= @total %> total records eligible
|
|
100
|
+
</span>
|
|
101
|
+
<div class="flex gap-2">
|
|
102
|
+
<% if @page > 1 %>
|
|
103
|
+
<%= link_to "← 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 →".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>
|
|
@@ -21,8 +21,9 @@
|
|
|
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
22
|
<%= link_to "Webhooks", fosm.admin_webhooks_path, class: "block px-3 py-2 rounded text-sm text-gray-700 hover:bg-gray-100" %>
|
|
23
23
|
</nav>
|
|
24
|
-
<div class="p-3 border-t border-gray-100">
|
|
25
|
-
<%= link_to "Settings",
|
|
24
|
+
<div class="p-3 border-t border-gray-100 space-y-1">
|
|
25
|
+
<%= link_to "Settings", fosm.admin_settings_path, class: "block px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100" %>
|
|
26
|
+
<%= 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
27
|
</div>
|
|
27
28
|
</div>
|
|
28
29
|
|
data/config/routes.rb
CHANGED
data/lib/fosm/configuration.rb
CHANGED
|
@@ -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/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
|
data/lib/fosm/version.rb
CHANGED
data/lib/fosm-rails.rb
CHANGED
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.
|
|
4
|
+
version: 0.2.4
|
|
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-
|
|
11
|
+
date: 2026-05-03 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
|